feat(认证/通知): 单设备登录互踢 + 站内通知铃铛系统
- 单设备登录:登录成功即 last-login-wins 绑定 users.machine;非超管旧 token 在下一次受保护请求抛 4011 下线,超管豁免;仅认 token 内签名 deviceId,不用请求头。 前端两端接入踢下线跳转(?kicked=1 提示),V118 清空历史 machine - 站内通知:新增 notification 模块(任务失败扫描 / 密钥欠费 / 下游服务探测三类来源), 前后台铃铛组件 + 轮询;后台列表按主管数据范围(UserDataScopeSupport)过滤;V116 建表 均已于 2026-09-13 部署上线,此次补提交源码(此前仅存在于已部署 JAR/构建产物中)
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
formatNotificationTime,
|
||||
formatUnreadBadge,
|
||||
hasNewNotification,
|
||||
readLastNotifiedId,
|
||||
writeLastNotifiedId,
|
||||
} from '../src/layout/notification-bell-model.ts'
|
||||
|
||||
function createStorage() {
|
||||
const store = new Map<string, string>()
|
||||
return {
|
||||
getItem: (key: string) => (store.has(key) ? (store.get(key) as string) : null),
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, String(value))
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function setupWindow() {
|
||||
const localStorage = createStorage()
|
||||
const globalScope = globalThis as Record<string, unknown>
|
||||
const previous = globalScope.window
|
||||
globalScope.window = { localStorage }
|
||||
return {
|
||||
localStorage,
|
||||
restore: () => {
|
||||
globalScope.window = previous
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('test_未读徽标:0 与非法值不显示、超过 99 显示 99+', () => {
|
||||
assert.equal(formatUnreadBadge(0), '')
|
||||
assert.equal(formatUnreadBadge(-3), '')
|
||||
assert.equal(formatUnreadBadge(null), '')
|
||||
assert.equal(formatUnreadBadge(undefined), '')
|
||||
assert.equal(formatUnreadBadge(Number.NaN), '')
|
||||
assert.equal(formatUnreadBadge(1), '1')
|
||||
assert.equal(formatUnreadBadge(99), '99')
|
||||
assert.equal(formatUnreadBadge(100), '99+')
|
||||
})
|
||||
|
||||
test('test_新通知判定:latestId 需大于已提醒 id', () => {
|
||||
assert.equal(hasNewNotification(0, 0), false)
|
||||
assert.equal(hasNewNotification(null, 5), false)
|
||||
assert.equal(hasNewNotification(5, 5), false)
|
||||
assert.equal(hasNewNotification(4, 5), false)
|
||||
assert.equal(hasNewNotification(6, 5), true)
|
||||
assert.equal(hasNewNotification(6, 0), true)
|
||||
assert.equal(hasNewNotification(6, null), true)
|
||||
})
|
||||
|
||||
test('test_已提醒 id 按管理员读写并容错', () => {
|
||||
setupWindow()
|
||||
assert.equal(readLastNotifiedId(7), 0, '未写入时按 0 处理')
|
||||
|
||||
writeLastNotifiedId(7, 88)
|
||||
assert.equal(readLastNotifiedId(7), 88)
|
||||
assert.equal(readLastNotifiedId(8), 0, '不同管理员互不影响')
|
||||
|
||||
writeLastNotifiedId(7, 0)
|
||||
assert.equal(readLastNotifiedId(7), 88, '非法 id 不覆盖已有值')
|
||||
|
||||
window.localStorage.setItem('admin-notification:last-notified-id:7', 'broken')
|
||||
assert.equal(readLastNotifiedId(7), 0, '损坏值按 0 处理')
|
||||
|
||||
assert.equal(readLastNotifiedId(null), readLastNotifiedId('0'), '空 uid 归一为 0 号键')
|
||||
})
|
||||
|
||||
test('test_时间展示:今天/昨天/更早', () => {
|
||||
const now = new Date(2026, 8, 13, 15, 30)
|
||||
assert.equal(formatNotificationTime(new Date(2026, 8, 13, 9, 5).toISOString(), now), '09:05')
|
||||
assert.equal(formatNotificationTime(new Date(2026, 8, 12, 23, 59).toISOString(), now), '昨天 23:59')
|
||||
assert.equal(formatNotificationTime(new Date(2026, 8, 1, 8, 0).toISOString(), now), '09-01 08:00')
|
||||
assert.equal(formatNotificationTime(null, now), '')
|
||||
assert.equal(formatNotificationTime('not-a-date', now), '')
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readSource } from './helpers.ts'
|
||||
import { isForbidden, isKicked, isUnauthorized } from '../src/api/envelope.ts'
|
||||
|
||||
test('单设备登录:4011 只认业务码,不被 401/403 判定吞掉', () => {
|
||||
assert.equal(isKicked({ code: 4011 }), true)
|
||||
assert.equal(isKicked({ code: 401 }), false)
|
||||
assert.equal(isKicked({ status: 4011 }), false)
|
||||
assert.equal(isKicked(null), false)
|
||||
assert.equal(isKicked('4011'), false)
|
||||
// 4011 与普通 401/403 是两种处理(提示"已在其他设备登录" vs 跳登录页),不能误判
|
||||
assert.equal(isUnauthorized({ code: 4011 }), false)
|
||||
assert.equal(isForbidden({ code: 4011 }), false)
|
||||
})
|
||||
|
||||
test('单设备登录:http 拦截器接线(两分支先判 4011,跳带 kicked 标记的登录页)', () => {
|
||||
const http = readSource('src/api/http.ts')
|
||||
assert.match(http, /isKicked/)
|
||||
assert.match(http, /\/admin-vue\/login\?kicked=1/)
|
||||
})
|
||||
|
||||
test('单设备登录:登录页显示被顶下线提示', () => {
|
||||
const page = readSource('src/pages/login/LoginPage.vue')
|
||||
assert.match(page, /kickedNotice/)
|
||||
assert.match(page, /该账号已在其他设备登录/)
|
||||
assert.match(page, /kicked-msg/)
|
||||
})
|
||||
Reference in New Issue
Block a user