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,97 @@
|
||||
/**
|
||||
* 铃铛通知纯逻辑:未读徽标文案、新通知判定、「已提醒过」去重记录、时间展示。
|
||||
* 轮询调度与渲染在 NotificationBell.vue;这里只放可单测的纯函数与存储读写。
|
||||
*/
|
||||
|
||||
/** 未读轮询间隔(60 秒)。 */
|
||||
export const NOTIFICATION_POLL_INTERVAL_MS = 60_000
|
||||
|
||||
const NOTIFIED_KEY_PREFIX = 'notification:last-notified-id'
|
||||
|
||||
/** 未读徽标文案:0 或非法值显示空串,>99 显示 99+。 */
|
||||
export function formatUnreadBadge(count: number | null | undefined): string {
|
||||
const value = Number(count ?? 0)
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return ''
|
||||
}
|
||||
return value > 99 ? '99+' : String(Math.floor(value))
|
||||
}
|
||||
|
||||
/** 是否出现新通知:latestId 大于上次已提醒过的 id。 */
|
||||
export function hasNewNotification(
|
||||
latestId: number | null | undefined,
|
||||
lastNotifiedId: number | null | undefined,
|
||||
): boolean {
|
||||
const latest = Number(latestId ?? 0)
|
||||
const notified = Number(lastNotifiedId ?? 0)
|
||||
if (!Number.isFinite(latest) || latest <= 0) {
|
||||
return false
|
||||
}
|
||||
return latest > (Number.isFinite(notified) && notified > 0 ? notified : 0)
|
||||
}
|
||||
|
||||
function storageKey(uid: string): string {
|
||||
return `${NOTIFIED_KEY_PREFIX}:${uid || '0'}`
|
||||
}
|
||||
|
||||
/** 读取当前用户「已提醒过的最大通知 id」(多页面/多会话共享)。 */
|
||||
export function readLastNotifiedId(uid: string): number {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey(uid))
|
||||
const value = Number(raw ?? 0)
|
||||
return Number.isFinite(value) && value > 0 ? value : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLastNotifiedId(uid: string, id: number): void {
|
||||
try {
|
||||
if (Number.isFinite(id) && id > 0) {
|
||||
window.localStorage.setItem(storageKey(uid), String(Math.floor(id)))
|
||||
}
|
||||
} catch {
|
||||
/* 存储不可用时静默忽略(退化为每次都提醒,不影响功能) */
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前登录用户 uid(未登录返回 '0')。 */
|
||||
export function currentNotificationUid(): string {
|
||||
try {
|
||||
return window.localStorage.getItem('uid') || '0'
|
||||
} catch {
|
||||
return '0'
|
||||
}
|
||||
}
|
||||
|
||||
/** 通知时间展示:今天只显示 HH:mm;昨天显示「昨天 HH:mm」;更早显示 MM-DD HH:mm。 */
|
||||
export function formatNotificationTime(
|
||||
value: string | null | undefined,
|
||||
now: Date = new Date(),
|
||||
): string {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
const pad = (input: number) => String(input).padStart(2, '0')
|
||||
const hourMinute = `${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
const sameDay =
|
||||
date.getFullYear() === now.getFullYear() &&
|
||||
date.getMonth() === now.getMonth() &&
|
||||
date.getDate() === now.getDate()
|
||||
if (sameDay) {
|
||||
return hourMinute
|
||||
}
|
||||
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
|
||||
const isYesterday =
|
||||
date.getFullYear() === yesterday.getFullYear() &&
|
||||
date.getMonth() === yesterday.getMonth() &&
|
||||
date.getDate() === yesterday.getDate()
|
||||
if (isYesterday) {
|
||||
return `昨天 ${hourMinute}`
|
||||
}
|
||||
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${hourMinute}`
|
||||
}
|
||||
Reference in New Issue
Block a user