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:
@@ -29,6 +29,7 @@
|
||||
</nav>
|
||||
|
||||
<div class="top-right">
|
||||
<NotificationBell />
|
||||
<BrandApiSecretSettingsButton variant="topbar" />
|
||||
<span class="admin-name">{{ username }}</span>
|
||||
</div>
|
||||
@@ -39,6 +40,7 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import BrandApiSecretSettingsButton from '@/pages/brand/components/BrandApiSecretSettingsButton.vue'
|
||||
import NotificationBell from '@/shared/components/NotificationBell.vue'
|
||||
import { filterGroupsByPermission, TOOL_GROUPS } from '@/pages/amazon/tool-catalog'
|
||||
import type { VisibleGroup } from '@/pages/amazon/tool-catalog'
|
||||
import { resolvePageHref } from '@/shared/page-prefix'
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<NotificationBell theme="light" />
|
||||
<span class="username-text">{{ username || '未登录' }}</span>
|
||||
<router-link :to="logoutHref" class="logout-link">退出</router-link>
|
||||
</div>
|
||||
@@ -63,6 +64,7 @@ import { restoreLoginUser } from '@/shared/auth/ensure-auth'
|
||||
import { getCurrentUserAppColumnRaw, readCachedAppColumnPermissions, type PermissionMenuItem } from '@/shared/api/permission'
|
||||
import { useVersionUpdate } from '@/shared/composables/useVersionUpdate'
|
||||
import UpdateProgressBar from '@/shared/components/UpdateProgressBar.vue'
|
||||
import NotificationBell from '@/shared/components/NotificationBell.vue'
|
||||
import { resolvePageHref } from '@/shared/page-prefix'
|
||||
|
||||
const username = ref('')
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
<div class="login-box">
|
||||
<h1 class="login-title">登录</h1>
|
||||
<p v-if="kickedNotice" class="kicked-msg">该账号已在其他设备登录,本设备已下线。如非本人操作,请及时修改密码。</p>
|
||||
<p v-if="errorMessage" class="error-msg">{{ errorMessage }}</p>
|
||||
<form id="loginForm" @submit.prevent="submitLogin">
|
||||
<div class="form-group">
|
||||
@@ -98,6 +99,7 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { loginWithDevice } from '@/shared/api/user'
|
||||
import { KICK_NOTICE_KEY } from '@/shared/auth/kick-handler'
|
||||
import { useVersionUpdate } from '@/shared/composables/useVersionUpdate'
|
||||
import { clearApiSecretCache } from '@/shared/utils/api-secret-store'
|
||||
import UpdateProgressBar from '@/shared/components/UpdateProgressBar.vue'
|
||||
@@ -116,6 +118,7 @@ const password = ref('')
|
||||
const passwordVisible = ref(false)
|
||||
const loggingIn = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const kickedNotice = ref(false)
|
||||
const rememberPassword = ref(false)
|
||||
const autoLogin = ref(false)
|
||||
const updateOpen = ref(false)
|
||||
@@ -244,6 +247,18 @@ function hasLogoutParam(): boolean {
|
||||
return params.get('logout') === '1' || params.get('switch') === '1'
|
||||
}
|
||||
|
||||
/** 被新设备顶下线后跳回登录页(query 会被路由守卫吞掉,故另看 localStorage 标记) */
|
||||
function isKickedNav(): boolean {
|
||||
try {
|
||||
if (new URLSearchParams(window.location.search || '').get('kicked') === '1') {
|
||||
return true
|
||||
}
|
||||
return lsGet(KICK_NOTICE_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function togglePassword() {
|
||||
passwordVisible.value = !passwordVisible.value
|
||||
}
|
||||
@@ -431,10 +446,24 @@ onMounted(() => {
|
||||
lsSet(AUTO_LOGIN_KEY, '0')
|
||||
}
|
||||
|
||||
if (isKickedNav()) {
|
||||
// 被新设备顶下线:提示原因并关闭自动登录——否则本机每次启动都会静默重登,
|
||||
// 把对方又顶下线,两台机器来回互踢。标记展示后即清(避免登出时误显示)。
|
||||
kickedNotice.value = true
|
||||
autoLogin.value = false
|
||||
lsSet(AUTO_LOGIN_KEY, '0')
|
||||
lsRemove(KICK_NOTICE_KEY)
|
||||
}
|
||||
|
||||
// 恢复记住的凭据并尝试自动登录(桌面与网页形态一致;登出/切号导航由 tryAutoLogin 内部豁免)
|
||||
// loadCredentials 现为异步(AES 解密),须先恢复密码再触发自动登录
|
||||
void (async () => {
|
||||
await loadCredentials()
|
||||
if (kickedNotice.value) {
|
||||
// 刚被顶下线:不自动重登,等用户手动点登录(此时顶掉对方,最后登录者胜)
|
||||
autoLogin.value = false
|
||||
return
|
||||
}
|
||||
tryAutoLogin()
|
||||
})()
|
||||
})
|
||||
@@ -592,6 +621,19 @@ body {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 单设备登录:被新设备顶下线的提示 */
|
||||
.kicked-msg {
|
||||
color: #b8860b;
|
||||
background: #fdf6e3;
|
||||
border: 1px solid #f0d9a0;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 记住密码 / 自动登录 */
|
||||
.login-options {
|
||||
display: flex;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<header class="setup-header">
|
||||
<span class="setup-title">数富AI</span>
|
||||
<div class="setup-header-right">
|
||||
<NotificationBell />
|
||||
<span class="username-text">{{ username || '未登录' }}</span>
|
||||
<router-link to="/login?logout=1" class="logout-link">退出</router-link>
|
||||
</div>
|
||||
@@ -34,8 +35,8 @@ import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import ApiSecretSettingsPanel from '@/shared/components/ApiSecretSettingsPanel.vue'
|
||||
import NotificationBell from '@/shared/components/NotificationBell.vue'
|
||||
import {
|
||||
checkApiSecret,
|
||||
getStoredApiSecretSnapshot,
|
||||
listApiSecretModules,
|
||||
loadApiSecrets,
|
||||
@@ -47,7 +48,7 @@ const route = useRoute()
|
||||
const panelRef = ref<InstanceType<typeof ApiSecretSettingsPanel> | null>(null)
|
||||
const submitting = ref(false)
|
||||
const username = ref('')
|
||||
const hint = ref('保存后系统会自动检测密钥连通性;检测未通过的密钥需要修正后重试。')
|
||||
const hint = ref('填写密钥并保存后,请点击对应模块的「检测」按钮确认可用,全部检测通过后即可进入工具台。')
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
@@ -57,21 +58,7 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
/** 对已保存但尚未检测出结果的必填模块补一次检测,避免"已配置却因未检测被拦"。 */
|
||||
async function ensureAllChecked() {
|
||||
for (const module of listApiSecretModules()) {
|
||||
const moduleKey = module.moduleKey as ApiSecretModuleKey
|
||||
const snapshot = getStoredApiSecretSnapshot(moduleKey)
|
||||
if (!snapshot.exists) continue
|
||||
if (snapshot.checkStatus === 'passed' || snapshot.checkStatus === 'error') continue
|
||||
try {
|
||||
await checkApiSecret(moduleKey)
|
||||
} catch (error) {
|
||||
console.warn('[api-secret] 补齐检测失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交:保存后只校验检测状态;检测一律由用户手动点击「检测」触发(2026-09-13 起不再自动补检)。 */
|
||||
async function submit() {
|
||||
if (submitting.value) return
|
||||
const panel = panelRef.value
|
||||
@@ -81,14 +68,13 @@ async function submit() {
|
||||
const saved = await panel.saveAll({ requireAll: true })
|
||||
if (!saved) return
|
||||
|
||||
await ensureAllChecked()
|
||||
const state = await loadApiSecrets({ force: true })
|
||||
if (state === 'incomplete') {
|
||||
const failed = listApiSecretModules()
|
||||
.map((module) => getStoredApiSecretSnapshot(module.moduleKey as ApiSecretModuleKey))
|
||||
.filter((snapshot) => !snapshot.exists || (snapshot.checkStatus !== 'passed' && snapshot.checkStatus !== 'error'))
|
||||
hint.value = `以下密钥尚未通过检测:${failed.map((item) => item.masked || '未配置').join('、')},请点击「检测」确认。`
|
||||
ElMessage.warning('密钥尚未全部检测通过,请逐项检测确认')
|
||||
hint.value = `以下密钥尚未通过检测:${failed.map((item) => item.masked || '未配置').join('、')},请点击上方的「检测」按钮逐项确认后重试。`
|
||||
ElMessage.warning('密钥尚未全部检测通过,请点击「检测」按钮逐项确认')
|
||||
return
|
||||
}
|
||||
const redirect = typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/')
|
||||
|
||||
@@ -209,6 +209,12 @@ export const API_ENDPOINTS = {
|
||||
migrate: '/api/user-secrets/migrate',
|
||||
proxyBalance: '/api/user-secrets/proxy-balance',
|
||||
},
|
||||
notification: {
|
||||
summary: '/api/notifications/summary',
|
||||
list: '/api/notifications',
|
||||
read: '/api/notifications/{id}/read',
|
||||
readAll: '/api/notifications/read-all',
|
||||
},
|
||||
collectData: {
|
||||
parse: '/api/collect-data/parse',
|
||||
countryPreference: '/api/collect-data/country-preference',
|
||||
|
||||
@@ -5,6 +5,7 @@ import axios, {
|
||||
type AxiosResponse,
|
||||
type InternalAxiosRequestConfig,
|
||||
} from 'axios'
|
||||
import { handleKicked, isKickedPayload } from '../auth/kick-handler.ts'
|
||||
|
||||
export interface LegacyApiSuccess<T> {
|
||||
success: true
|
||||
@@ -50,6 +51,27 @@ export function extractErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败'
|
||||
}
|
||||
|
||||
/** 从 axios 错误对象里取响应体(兼容真实 AxiosError 与测试桩)。 */
|
||||
function responsePayloadOf(error: unknown): unknown {
|
||||
return (error as { response?: { data?: unknown } } | null | undefined)?.response?.data
|
||||
}
|
||||
|
||||
/** 响应成功分支:Java 业务错误是 HTTP 200 + body.code,互踢下线必须在这里也判。 */
|
||||
export function onResponseFulfilled(response: AxiosResponse): AxiosResponse {
|
||||
if (isKickedPayload(response.data)) {
|
||||
handleKicked()
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
/** 响应失败分支:折叠为 Error 之前先判互踢下线(HTTP 401 等状态码路径)。 */
|
||||
export function onResponseRejected(error: unknown): Promise<never> {
|
||||
if (isKickedPayload(responsePayloadOf(error))) {
|
||||
handleKicked()
|
||||
}
|
||||
return Promise.reject(new Error(extractErrorMessage(error)))
|
||||
}
|
||||
|
||||
function createHttpClient(): AxiosInstance {
|
||||
const instance = axios.create({
|
||||
withCredentials: true,
|
||||
@@ -74,10 +96,7 @@ function createHttpClient(): AxiosInstance {
|
||||
return config
|
||||
})
|
||||
|
||||
instance.interceptors.response.use(
|
||||
(response: AxiosResponse) => response,
|
||||
(error: unknown) => Promise.reject(new Error(extractErrorMessage(error))),
|
||||
)
|
||||
instance.interceptors.response.use(onResponseFulfilled, onResponseRejected)
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { get, post, type JavaApiResponse, unwrapJavaResponse } from '../../http.ts'
|
||||
import { buildJavaUrl } from '../../url.ts'
|
||||
import { API_ENDPOINTS } from '../../endpoints.ts'
|
||||
|
||||
/** 通知场景:密钥欠费 / 密钥失效 / 任务失败 / 服务异常 / 系统。 */
|
||||
export type NotificationScene = 'secret_balance' | 'secret_invalid' | 'task_failed' | 'service_down' | 'system'
|
||||
|
||||
export type NotificationLevel = 'info' | 'warning' | 'error'
|
||||
|
||||
export interface NotificationItem {
|
||||
id: number
|
||||
scene: string
|
||||
level: string
|
||||
title: string
|
||||
content: string
|
||||
read: boolean
|
||||
readAt: string | null
|
||||
createdAt: string | null
|
||||
}
|
||||
|
||||
export interface NotificationPage {
|
||||
items: NotificationItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
unreadCount: number
|
||||
}
|
||||
|
||||
export interface NotificationSummary {
|
||||
unreadCount: number
|
||||
latestId: number
|
||||
}
|
||||
|
||||
/** 铃铛轮询:未读数 + 最新通知 id(latestId 增大表示有新通知)。 */
|
||||
export function fetchNotificationSummary() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<NotificationSummary>>(buildJavaUrl(API_ENDPOINTS.notification.summary)),
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchNotificationList(params: { page?: number; pageSize?: number; onlyUnread?: boolean } = {}) {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<NotificationPage>>(
|
||||
buildJavaUrl(API_ENDPOINTS.notification.list, {
|
||||
page: params.page ?? 1,
|
||||
pageSize: params.pageSize ?? 20,
|
||||
onlyUnread: params.onlyUnread ? 'true' : 'false',
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function markNotificationRead(id: number) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<boolean>>(
|
||||
buildJavaUrl(API_ENDPOINTS.notification.read.replace('{id}', encodeURIComponent(String(id)))),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function markAllNotificationsRead() {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<number>>(buildJavaUrl(API_ENDPOINTS.notification.readAll)),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 单设备登录(互踢)下线处理。
|
||||
*
|
||||
* 账号在新设备登录后,旧设备的下一次受保护请求会拿到 code=4011;
|
||||
* 这里统一做四件事:清本地登录态 → 关闭自动登录 → 通知桌面端清 current_uid → 跳登录页。
|
||||
*
|
||||
* 关闭自动登录是关键:否则本机每次启动都会静默重登,把对方又顶下线(两台机器来回互踢)。
|
||||
* 记住的账号密码保留,手动点一次即可重新登录(此时顶掉对方,最后登录者胜)。
|
||||
*/
|
||||
|
||||
const AUTH_TOKEN_KEY = 'aiimage_auth_token'
|
||||
const UID_KEY = 'uid'
|
||||
const USERNAME_KEY = 'username'
|
||||
const AUTO_LOGIN_KEY = 'aiimage_auto_login'
|
||||
|
||||
/** 登录页据此标记显示"已被顶下线"警示(路由守卫会吞掉 query,故用 localStorage 兜底)。 */
|
||||
export const KICK_NOTICE_KEY = 'aiimage_kick_notice'
|
||||
|
||||
/** 服务端「已在其他设备登录」业务码,与后端 DeviceSessionPolicy.CODE_KICKED 对齐。 */
|
||||
export const KICKED_CODE = 4011
|
||||
|
||||
/** 本次页面生命周期内是否已发起跳转(并发 4011 只跳一次;整页跳转后 window 重建自然重置)。 */
|
||||
type KickWindow = Window & { __aiimageKickRedirected?: boolean }
|
||||
|
||||
/** 响应体(Java 错误信封 HTTP 200 + body.code)是否为互踢下线。 */
|
||||
export function isKickedPayload(payload: unknown): boolean {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return false
|
||||
}
|
||||
return (payload as { code?: unknown }).code === KICKED_CODE
|
||||
}
|
||||
|
||||
/** 清桌面端 Python 侧的登录用户标记(未登录/换号后任务回退全局代理池)。 */
|
||||
function clearDesktopCurrentUid(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
const apply = () => {
|
||||
try {
|
||||
const bridge = (
|
||||
window as unknown as {
|
||||
pywebview?: { api?: { save_config?: (data: Record<string, unknown>) => Promise<unknown> } }
|
||||
}
|
||||
).pywebview
|
||||
if (bridge?.api && typeof bridge.api.save_config === 'function') {
|
||||
void bridge.api.save_config({ current_uid: '' })
|
||||
}
|
||||
} catch {
|
||||
/* 网页形态无 pywebview 桥,忽略 */
|
||||
}
|
||||
}
|
||||
apply()
|
||||
// 被顶下线常发生在页面刚加载时,此刻 pywebview 桥可能尚未就绪(调用会被丢弃):
|
||||
// 等桥就绪事件后再补一次。重复写同值幂等;网页形态该事件不会触发。
|
||||
try {
|
||||
window.addEventListener('pywebviewready', apply, { once: true })
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 被顶下线:清登录态并回登录页;并发/重复触发安全(清理可重复,跳转只发一次)。 */
|
||||
export function handleKicked(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const storage = window.localStorage
|
||||
storage.removeItem(AUTH_TOKEN_KEY)
|
||||
storage.removeItem(UID_KEY)
|
||||
storage.removeItem(USERNAME_KEY)
|
||||
storage.setItem(AUTO_LOGIN_KEY, '0')
|
||||
storage.setItem(KICK_NOTICE_KEY, '1')
|
||||
} catch {
|
||||
/* 忽略存储异常 */
|
||||
}
|
||||
clearDesktopCurrentUid()
|
||||
// 先清 uid 再整页跳转:否则路由守卫会把"已登录"的用户带回首页
|
||||
try {
|
||||
const kickWindow = window as KickWindow
|
||||
if (!kickWindow.__aiimageKickRedirected && !window.location.pathname.startsWith('/login')) {
|
||||
kickWindow.__aiimageKickRedirected = true
|
||||
console.log('[auth] 账号已在其他设备登录,本设备下线,跳转登录页')
|
||||
window.location.assign('/login?kicked=1')
|
||||
}
|
||||
} catch {
|
||||
/* 忽略跳转异常 */
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@
|
||||
<div class="secret-card-head">
|
||||
<div>
|
||||
<div class="secret-card-title">代理设置</div>
|
||||
<div class="secret-card-desc">供客户端任务连接代理服务。</div>
|
||||
<div class="secret-card-desc">供客户端任务连接代理服务,支持静态代理地址或代理服务商的提取链接。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
v-model="proxyUrl"
|
||||
class="secret-input"
|
||||
type="text"
|
||||
placeholder="请输入代理地址"
|
||||
placeholder="如 http://user:pass@host:port 或代理提取链接"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
:disabled="!proxyReady || busy"
|
||||
@@ -454,8 +454,9 @@ async function loadBalance() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存:逐模块保存非空输入(空输入保留服务端原值),代理仅在改动时保存;
|
||||
* 保存后对有输入值的模块自动检测一次,让用户立即知道密钥是否可用。
|
||||
* 保存:逐模块保存非空输入(空输入保留服务端原值),代理仅在改动时保存。
|
||||
* 保存后不再自动检测(2026-09-13 起检测一律由用户手动点击触发),
|
||||
* 保存会把检测状态重置为未检测,需用户点「检测」确认可用性。
|
||||
*/
|
||||
async function saveAll(options: { requireAll?: boolean } = {}): Promise<boolean> {
|
||||
if (busy.value || proxyLoading.value) return false
|
||||
@@ -491,15 +492,7 @@ async function saveAll(options: { requireAll?: boolean } = {}): Promise<boolean>
|
||||
await syncProxyToServer(nextProxyUrl)
|
||||
}
|
||||
|
||||
// 保存成功的模块立即自动检测,结果直接反映在卡片状态行
|
||||
for (const module of pendingModules) {
|
||||
const moduleKey = module.moduleKey as ApiSecretModuleKey
|
||||
try {
|
||||
ensureModuleState(module.moduleKey).result = await checkApiSecret(moduleKey)
|
||||
} catch (error) {
|
||||
console.warn('[api-secret] 保存后自动检测失败:', error)
|
||||
}
|
||||
}
|
||||
// 保存后不再自动检测:检测一律由用户手动点击「检测」触发
|
||||
refreshSnapshots()
|
||||
return true
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
<template>
|
||||
<div ref="rootRef" class="notification-bell" :class="`notification-bell--${theme}`">
|
||||
<button
|
||||
type="button"
|
||||
class="bell-trigger"
|
||||
:class="{ 'bell-trigger--active': panelOpen }"
|
||||
:title="unreadCount > 0 ? `通知(${unreadCount} 条未读)` : '通知'"
|
||||
aria-label="通知"
|
||||
@click="togglePanel"
|
||||
>
|
||||
<svg
|
||||
class="bell-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||
</svg>
|
||||
<span v-if="badgeText" class="bell-badge">{{ badgeText }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="panelOpen" class="bell-panel" role="dialog" aria-label="通知列表">
|
||||
<div class="bell-panel-head">
|
||||
<span class="bell-panel-title">通知</span>
|
||||
<button
|
||||
v-if="unreadCount > 0"
|
||||
type="button"
|
||||
class="bell-read-all"
|
||||
:disabled="markingAll"
|
||||
@click="readAll"
|
||||
>
|
||||
{{ markingAll ? '处理中...' : '全部已读' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !items.length" class="bell-empty">正在加载...</div>
|
||||
<div v-else-if="loadError && !items.length" class="bell-empty bell-empty--error">{{ loadError }}</div>
|
||||
<div v-else-if="!items.length" class="bell-empty">暂无通知</div>
|
||||
<ul v-else class="bell-list">
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="bell-item"
|
||||
:class="[`bell-item--${item.level || 'warning'}`, { 'bell-item--unread': !item.read }]"
|
||||
@click="onItemClick(item)"
|
||||
>
|
||||
<div class="bell-item-head">
|
||||
<span class="bell-item-title">{{ item.title }}</span>
|
||||
<span class="bell-item-time">{{ formatTime(item.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="bell-item-content">{{ item.content }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="bell-panel-foot">
|
||||
<button v-if="hasMore" type="button" class="bell-more" :disabled="loading" @click="loadMore">
|
||||
{{ loading ? '加载中...' : '加载更多' }}
|
||||
</button>
|
||||
<span v-else-if="items.length" class="bell-foot-note">已显示全部</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import {
|
||||
fetchNotificationList,
|
||||
fetchNotificationSummary,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
type NotificationItem,
|
||||
} from '@/shared/api/types/modules/notification.ts'
|
||||
import {
|
||||
NOTIFICATION_POLL_INTERVAL_MS,
|
||||
currentNotificationUid,
|
||||
formatNotificationTime,
|
||||
formatUnreadBadge,
|
||||
hasNewNotification,
|
||||
readLastNotifiedId,
|
||||
writeLastNotifiedId,
|
||||
} from '@/shared/utils/notification-bell.ts'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
/** 顶栏主题:工具页/配置页深色(dark),桌面入口首页浅色(light)。 */
|
||||
const props = withDefaults(defineProps<{ theme?: 'dark' | 'light' }>(), {
|
||||
theme: 'dark',
|
||||
})
|
||||
const theme = computed(() => props.theme)
|
||||
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const uid = currentNotificationUid()
|
||||
|
||||
const unreadCount = ref(0)
|
||||
const items = ref<NotificationItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const panelOpen = ref(false)
|
||||
const loading = ref(false)
|
||||
const markingAll = ref(false)
|
||||
const loadError = ref('')
|
||||
|
||||
const badgeText = computed(() => formatUnreadBadge(unreadCount.value))
|
||||
const hasMore = computed(() => items.value.length < total.value)
|
||||
|
||||
let pollTimer: number | null = null
|
||||
|
||||
/** 拉未读数:发现新通知提醒一次(localStorage 记录已提醒过的最大 id,避免重复弹)。 */
|
||||
async function refreshSummary() {
|
||||
try {
|
||||
const summary = await fetchNotificationSummary()
|
||||
unreadCount.value = Number(summary?.unreadCount ?? 0)
|
||||
const latestId = Number(summary?.latestId ?? 0)
|
||||
if (hasNewNotification(latestId, readLastNotifiedId(uid))) {
|
||||
ElMessage.warning('收到新的告警通知,请点击右上角铃铛查看')
|
||||
writeLastNotifiedId(uid, latestId)
|
||||
console.log('[notification] 检测到新通知 latestId=', latestId)
|
||||
}
|
||||
} catch (error) {
|
||||
// 通知接口失败静默降级:不显示红点、不打扰用户
|
||||
console.warn('[notification] 未读数刷新失败(静默降级):', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPage(targetPage: number) {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fetchNotificationList({ page: targetPage, pageSize: PAGE_SIZE })
|
||||
const list = Array.isArray(result?.items) ? result.items : []
|
||||
items.value = targetPage <= 1 ? list : [...items.value, ...list]
|
||||
total.value = Number(result?.total ?? items.value.length)
|
||||
unreadCount.value = Number(result?.unreadCount ?? unreadCount.value)
|
||||
page.value = targetPage
|
||||
loadError.value = ''
|
||||
if (targetPage <= 1) {
|
||||
const latest = items.value.length ? Number(items.value[0].id) : 0
|
||||
if (latest > 0) {
|
||||
writeLastNotifiedId(uid, Math.max(readLastNotifiedId(uid), latest))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 列表加载失败在面板内提示,不弹全局消息打扰用户
|
||||
console.warn('[notification] 通知列表加载失败:', error)
|
||||
loadError.value = error instanceof Error ? error.message : '通知加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (!hasMore.value) return
|
||||
void loadPage(page.value + 1)
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
panelOpen.value = !panelOpen.value
|
||||
if (panelOpen.value) {
|
||||
void loadPage(1)
|
||||
}
|
||||
}
|
||||
|
||||
async function onItemClick(item: NotificationItem) {
|
||||
if (item.read) return
|
||||
try {
|
||||
await markNotificationRead(item.id)
|
||||
item.read = true
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||
} catch (error) {
|
||||
console.warn('[notification] 标记已读失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function readAll() {
|
||||
if (markingAll.value || unreadCount.value === 0) return
|
||||
markingAll.value = true
|
||||
try {
|
||||
await markAllNotificationsRead()
|
||||
for (const item of items.value) {
|
||||
item.read = true
|
||||
}
|
||||
unreadCount.value = 0
|
||||
} catch (error) {
|
||||
console.warn('[notification] 全部已读失败:', error)
|
||||
ElMessage.error(error instanceof Error ? error.message : '操作失败')
|
||||
} finally {
|
||||
markingAll.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(value: string | null) {
|
||||
return formatNotificationTime(value)
|
||||
}
|
||||
|
||||
/** 点击组件外部关闭面板。 */
|
||||
function onDocumentMouseDown(event: MouseEvent) {
|
||||
if (!panelOpen.value) return
|
||||
const root = rootRef.value
|
||||
if (root && event.target instanceof Node && !root.contains(event.target)) {
|
||||
panelOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 页面回到前台时立即刷新一次未读数。 */
|
||||
function onVisibilityChange() {
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
|
||||
void refreshSummary()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousedown', onDocumentMouseDown)
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
void refreshSummary()
|
||||
pollTimer = window.setInterval(() => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
|
||||
return
|
||||
}
|
||||
void refreshSummary()
|
||||
}, NOTIFICATION_POLL_INTERVAL_MS)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousedown', onDocumentMouseDown)
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
if (pollTimer != null) {
|
||||
window.clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification-bell {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bell-trigger {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #c8d2e2;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.bell-trigger:hover,
|
||||
.bell-trigger--active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #f5f8fc;
|
||||
}
|
||||
|
||||
.bell-icon {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
|
||||
.bell-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 1px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 999px;
|
||||
background: #e5484d;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bell-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
z-index: 3200;
|
||||
width: 360px;
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: 440px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #2c3540;
|
||||
border-radius: 12px;
|
||||
background: #171b20;
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bell-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #262d35;
|
||||
}
|
||||
|
||||
.bell-panel-title {
|
||||
color: #eef4fb;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bell-read-all {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #8dc4ff;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bell-read-all:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bell-empty {
|
||||
padding: 32px 0;
|
||||
color: #7f8a96;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bell-empty--error {
|
||||
color: #ff9b9b;
|
||||
}
|
||||
|
||||
.bell-list {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.bell-item {
|
||||
padding: 11px 14px;
|
||||
border-bottom: 1px solid #222830;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.bell-item:hover {
|
||||
background: #1d242c;
|
||||
}
|
||||
|
||||
.bell-item--unread {
|
||||
background: #1b232e;
|
||||
}
|
||||
|
||||
.bell-item--unread .bell-item-title::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-right: 7px;
|
||||
border-radius: 50%;
|
||||
background: #e5484d;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
|
||||
.bell-item--error .bell-item-title {
|
||||
color: #ffb4b4;
|
||||
}
|
||||
|
||||
.bell-item--warning .bell-item-title {
|
||||
color: #f0c674;
|
||||
}
|
||||
|
||||
.bell-item--info .bell-item-title {
|
||||
color: #8dc4ff;
|
||||
}
|
||||
|
||||
.bell-item-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bell-item-title {
|
||||
color: #dce6f0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bell-item-time {
|
||||
flex-shrink: 0;
|
||||
color: #6f7a86;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.bell-item-content {
|
||||
margin-top: 4px;
|
||||
color: #9aa6b3;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.bell-panel-foot {
|
||||
border-top: 1px solid #262d35;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bell-more {
|
||||
width: 100%;
|
||||
padding: 9px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #8dc4ff;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bell-more:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bell-foot-note {
|
||||
display: block;
|
||||
padding: 8px 0;
|
||||
color: #6f7a86;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ---- 浅色主题(桌面入口首页顶栏) ---- */
|
||||
.notification-bell--light .bell-trigger {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-trigger:hover,
|
||||
.notification-bell--light .bell-trigger--active {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-panel {
|
||||
border-color: #e2e8f0;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 18px 48px rgba(31, 45, 61, 0.18);
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-panel-head {
|
||||
border-bottom-color: #edf1f5;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-panel-title {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-read-all {
|
||||
color: #4c5bd4;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-empty {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-item {
|
||||
border-bottom-color: #f0f3f7;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-item:hover {
|
||||
background: #f7f9fc;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-item--unread {
|
||||
background: #f2f6ff;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-item-title {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-item--error .bell-item-title {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-item--warning .bell-item-title {
|
||||
color: #a8793e;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-item--info .bell-item-title {
|
||||
color: #4c5bd4;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-item-time {
|
||||
color: #9aa6b3;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-item-content {
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-panel-foot {
|
||||
border-top-color: #edf1f5;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-more {
|
||||
color: #4c5bd4;
|
||||
}
|
||||
|
||||
.notification-bell--light .bell-foot-note {
|
||||
color: #9aa6b3;
|
||||
}
|
||||
</style>
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const MODULE_KEYS = [
|
||||
'similarAsin',
|
||||
'appearancePatent',
|
||||
'userSecret',
|
||||
'notification',
|
||||
'collectData',
|
||||
'imageVideo',
|
||||
'brand',
|
||||
@@ -316,6 +317,12 @@ test('test_endpoints_frozen_snapshot', () => {
|
||||
"migrate": "/api/user-secrets/migrate",
|
||||
"proxyBalance": "/api/user-secrets/proxy-balance"
|
||||
},
|
||||
"notification": {
|
||||
"summary": "/api/notifications/summary",
|
||||
"list": "/api/notifications",
|
||||
"read": "/api/notifications/{id}/read",
|
||||
"readAll": "/api/notifications/read-all"
|
||||
},
|
||||
"collectData": {
|
||||
"parse": "/api/collect-data/parse",
|
||||
"countryPreference": "/api/collect-data/country-preference",
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
formatNotificationTime,
|
||||
formatUnreadBadge,
|
||||
hasNewNotification,
|
||||
readLastNotifiedId,
|
||||
writeLastNotifiedId,
|
||||
} from '../src/shared/utils/notification-bell.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()
|
||||
;(globalThis as Record<string, unknown>).window = { localStorage }
|
||||
return { localStorage }
|
||||
}
|
||||
|
||||
test('formatUnreadBadge 徽标文案:0 与非法值为空、超过 99 显示 99+', () => {
|
||||
assert.equal(formatUnreadBadge(0), '')
|
||||
assert.equal(formatUnreadBadge(-1), '')
|
||||
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+')
|
||||
assert.equal(formatUnreadBadge(9999), '99+')
|
||||
})
|
||||
|
||||
test('hasNewNotification 新通知判定:latestId 需大于已提醒 id', () => {
|
||||
assert.equal(hasNewNotification(0, 0), false)
|
||||
assert.equal(hasNewNotification(null, 0), false)
|
||||
assert.equal(hasNewNotification(5, 5), false)
|
||||
assert.equal(hasNewNotification(5, 7), false)
|
||||
assert.equal(hasNewNotification(5, 3), true)
|
||||
assert.equal(hasNewNotification(5, 0), true)
|
||||
assert.equal(hasNewNotification(5, null), true)
|
||||
})
|
||||
|
||||
test('已提醒 id 按用户读写并容错', () => {
|
||||
setupWindow()
|
||||
assert.equal(readLastNotifiedId('42'), 0, '未写入时按 0 处理')
|
||||
|
||||
writeLastNotifiedId('42', 123)
|
||||
assert.equal(readLastNotifiedId('42'), 123)
|
||||
assert.equal(readLastNotifiedId('43'), 0, '不同用户互不影响')
|
||||
|
||||
writeLastNotifiedId('42', 0)
|
||||
assert.equal(readLastNotifiedId('42'), 123, '非法 id 不覆盖已有值')
|
||||
|
||||
window.localStorage.setItem('notification:last-notified-id:42', 'broken')
|
||||
assert.equal(readLastNotifiedId('42'), 0, '损坏值按 0 处理')
|
||||
})
|
||||
|
||||
test('formatNotificationTime 今天/昨天/更早展示', () => {
|
||||
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,172 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { onResponseFulfilled, onResponseRejected } from '../src/shared/api/http.ts'
|
||||
import {
|
||||
KICK_NOTICE_KEY,
|
||||
KICKED_CODE,
|
||||
handleKicked,
|
||||
isKickedPayload,
|
||||
} from '../src/shared/auth/kick-handler.ts'
|
||||
|
||||
function createStorage(initial: Record<string, string> = {}) {
|
||||
const store = new Map(Object.entries(initial))
|
||||
return {
|
||||
getItem: (key: string) => (store.has(key) ? (store.get(key) as string) : null),
|
||||
setItem: (key: string, value: string) => void store.set(key, String(value)),
|
||||
removeItem: (key: string) => void store.delete(key),
|
||||
has: (key: string) => store.has(key),
|
||||
}
|
||||
}
|
||||
|
||||
function setupWindow(initial: Record<string, string> = {}, pathname = '/home') {
|
||||
const localStorage = createStorage(initial)
|
||||
const assigned: string[] = []
|
||||
const listeners = new Map<string, Array<() => void>>()
|
||||
const fakeWindow: Record<string, unknown> = {
|
||||
localStorage,
|
||||
location: {
|
||||
pathname,
|
||||
search: '',
|
||||
assign: (url: string) => {
|
||||
assigned.push(url)
|
||||
},
|
||||
},
|
||||
addEventListener: (type: string, handler: () => void) => {
|
||||
const list = listeners.get(type) || []
|
||||
list.push(handler)
|
||||
listeners.set(type, list)
|
||||
},
|
||||
}
|
||||
const dispatch = (type: string) => {
|
||||
for (const handler of listeners.get(type) || []) handler()
|
||||
}
|
||||
;(globalThis as Record<string, unknown>).window = fakeWindow
|
||||
return { localStorage, assigned, dispatch, fakeWindow }
|
||||
}
|
||||
|
||||
/** 登录态齐全的初始存储(含桌面端设备号,互踢下线不应清掉它) */
|
||||
function loggedInStorage() {
|
||||
return {
|
||||
aiimage_auth_token: 'jwt-token',
|
||||
uid: '1095',
|
||||
username: 'ceshi001',
|
||||
aiimage_auto_login: '1',
|
||||
aiimage_device_id: 'dev-fingerprint',
|
||||
aiimage_remember_user: 'ceshi001',
|
||||
}
|
||||
}
|
||||
|
||||
test('isKickedPayload 只认 code=4011', () => {
|
||||
assert.equal(isKickedPayload({ success: false, code: KICKED_CODE, message: '该账号已在其他设备登录' }), true)
|
||||
assert.equal(isKickedPayload({ success: false, code: 401, message: '未登录' }), false)
|
||||
assert.equal(isKickedPayload({ success: true, data: {} }), false)
|
||||
assert.equal(isKickedPayload(null), false)
|
||||
assert.equal(isKickedPayload('4011'), false)
|
||||
})
|
||||
|
||||
test('handleKicked 清登录态并关自动登录,跳登录页带 kicked 标记', () => {
|
||||
const { localStorage, assigned } = setupWindow(loggedInStorage())
|
||||
|
||||
handleKicked()
|
||||
|
||||
assert.equal(localStorage.getItem('aiimage_auth_token'), null)
|
||||
assert.equal(localStorage.getItem('uid'), null)
|
||||
assert.equal(localStorage.getItem('username'), null)
|
||||
// 被踢后不再自动重登,否则本机每次启动都会静默重登把对方又顶下线
|
||||
assert.equal(localStorage.getItem('aiimage_auto_login'), '0')
|
||||
assert.equal(localStorage.getItem(KICK_NOTICE_KEY), '1')
|
||||
// 设备号与记住的账号保留:同设备重登不算新设备,账号回填方便手动重登
|
||||
assert.equal(localStorage.getItem('aiimage_device_id'), 'dev-fingerprint')
|
||||
assert.equal(localStorage.getItem('aiimage_remember_user'), 'ceshi001')
|
||||
assert.deepEqual(assigned, ['/login?kicked=1'])
|
||||
})
|
||||
|
||||
test('handleKicked 已在登录页时不跳转(仍清理登录态)', () => {
|
||||
const { localStorage, assigned } = setupWindow(loggedInStorage(), '/login')
|
||||
|
||||
handleKicked()
|
||||
|
||||
assert.equal(localStorage.getItem('uid'), null)
|
||||
assert.equal(localStorage.getItem(KICK_NOTICE_KEY), '1')
|
||||
assert.deepEqual(assigned, [])
|
||||
})
|
||||
|
||||
test('handleKicked 并发重复触发只跳转一次且状态一致', () => {
|
||||
const { localStorage, assigned } = setupWindow(loggedInStorage())
|
||||
|
||||
handleKicked()
|
||||
handleKicked()
|
||||
|
||||
assert.equal(localStorage.getItem('uid'), null)
|
||||
assert.equal(localStorage.getItem('aiimage_auto_login'), '0')
|
||||
assert.deepEqual(assigned, ['/login?kicked=1'])
|
||||
})
|
||||
|
||||
test('handleKicked 桌面端同步清 Python 侧 current_uid', () => {
|
||||
const { localStorage, fakeWindow } = setupWindow(loggedInStorage())
|
||||
const saved: Array<Record<string, unknown>> = []
|
||||
fakeWindow.pywebview = { api: { save_config: (data: Record<string, unknown>) => void saved.push(data) } }
|
||||
|
||||
handleKicked()
|
||||
|
||||
assert.deepEqual(saved, [{ current_uid: '' }])
|
||||
})
|
||||
|
||||
test('handleKicked 桥未就绪时等 pywebviewready 补清 current_uid', () => {
|
||||
// 真机复现:被顶下线发生在页面刚加载时,pywebview 桥尚未就绪,立即调用会被丢弃
|
||||
const { dispatch, fakeWindow } = setupWindow(loggedInStorage())
|
||||
const saved: Array<Record<string, unknown>> = []
|
||||
|
||||
handleKicked()
|
||||
assert.deepEqual(saved, [], '桥未就绪时不应有调用结果')
|
||||
|
||||
// 桥就绪:注入 api 并触发就绪事件,应补一次清理
|
||||
fakeWindow.pywebview = { api: { save_config: (data: Record<string, unknown>) => void saved.push(data) } }
|
||||
dispatch('pywebviewready')
|
||||
|
||||
assert.deepEqual(saved, [{ current_uid: '' }])
|
||||
})
|
||||
|
||||
test('handleKicked 网页形态(无 pywebview)不受影响', () => {
|
||||
const { localStorage, assigned } = setupWindow(loggedInStorage())
|
||||
|
||||
handleKicked()
|
||||
|
||||
assert.equal(localStorage.getItem('uid'), null)
|
||||
assert.deepEqual(assigned, ['/login?kicked=1'])
|
||||
})
|
||||
|
||||
// ---------- 拦截器接线:Java 错误信封走 HTTP 200,成功分支也必须判 ----------
|
||||
|
||||
test('响应成功分支:body.code=4011 触发下线', () => {
|
||||
const { localStorage, assigned } = setupWindow(loggedInStorage())
|
||||
const response = { data: { success: false, code: KICKED_CODE, message: '该账号已在其他设备登录,本设备已下线' } }
|
||||
|
||||
const returned = onResponseFulfilled(response as never)
|
||||
|
||||
assert.equal(returned, response)
|
||||
assert.equal(localStorage.getItem('uid'), null)
|
||||
assert.equal(localStorage.getItem('aiimage_auto_login'), '0')
|
||||
assert.deepEqual(assigned, ['/login?kicked=1'])
|
||||
})
|
||||
|
||||
test('响应失败分支:错误响应体 code=4011 触发下线', async () => {
|
||||
const { localStorage, assigned } = setupWindow(loggedInStorage())
|
||||
|
||||
await assert.rejects(
|
||||
() => onResponseRejected({ response: { data: { code: KICKED_CODE } }, message: '请求失败' }),
|
||||
/请求失败/,
|
||||
)
|
||||
|
||||
assert.equal(localStorage.getItem('uid'), null)
|
||||
assert.deepEqual(assigned, ['/login?kicked=1'])
|
||||
})
|
||||
|
||||
test('普通业务错误(403 等)不触发下线', () => {
|
||||
const { localStorage, assigned } = setupWindow(loggedInStorage())
|
||||
|
||||
onResponseFulfilled({ data: { success: false, code: 403, message: '需要管理员权限' } } as never)
|
||||
|
||||
assert.equal(localStorage.getItem('uid'), '1095')
|
||||
assert.deepEqual(assigned, [])
|
||||
})
|
||||
Reference in New Issue
Block a user