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:
@@ -53,6 +53,13 @@ export function isForbidden(payload: unknown): boolean {
|
||||
return [record.status, record.statusCode, record.code].some((v) => v === 403)
|
||||
}
|
||||
|
||||
/** 单设备登录:账号已在其他设备登录(4011,与后端 DeviceSessionPolicy.CODE_KICKED 对齐)。 */
|
||||
export function isKicked(payload: unknown): boolean {
|
||||
const record = payload as { code?: unknown } | null
|
||||
if (!record || typeof record !== 'object') return false
|
||||
return record.code === 4011
|
||||
}
|
||||
|
||||
/** Axios 错误(带 response)或普通 Error 统一取可展示文案。 */
|
||||
export function requestErrorMessage(error: unknown): string {
|
||||
const response = (error as { response?: { data?: unknown; status?: number } })?.response
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import axios from 'axios'
|
||||
import { isForbidden, isUnauthorized, loginRedirectTarget, shouldRedirectUnauthorized } from './envelope'
|
||||
import {
|
||||
isForbidden,
|
||||
isKicked,
|
||||
isLoginLocation,
|
||||
isUnauthorized,
|
||||
loginRedirectTarget,
|
||||
shouldRedirectUnauthorized,
|
||||
} from './envelope'
|
||||
|
||||
export { unwrap } from './envelope'
|
||||
|
||||
@@ -17,15 +24,29 @@ function redirectToLogin(requestUrl?: string): void {
|
||||
window.location.assign('/admin-vue/login?redirect=' + target)
|
||||
}
|
||||
|
||||
/** 单设备登录:被新设备顶下线,整页回登录页(整页 reload 顺带重置会话 store,避免守卫弹回)。 */
|
||||
function redirectToLoginKicked(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
if (isLoginLocation(window.location.pathname)) return
|
||||
window.location.assign('/admin-vue/login?kicked=1')
|
||||
}
|
||||
|
||||
http.interceptors.response.use(
|
||||
(response) => {
|
||||
// Java 未登录以 HTTP 200 + body.code=401 返回,需统一按未登录处理;
|
||||
// 403(已登录但无后台权限,如用工具前端账号 token 访问后台)与 401 同样跳登录页。
|
||||
if (isUnauthorized(response.data) || isForbidden(response.data)) redirectToLogin(response.config?.url)
|
||||
// 4011(账号已在其他设备登录)单独提示,不按普通 401 处理。
|
||||
if (isKicked(response.data)) {
|
||||
redirectToLoginKicked()
|
||||
} else if (isUnauthorized(response.data) || isForbidden(response.data)) {
|
||||
redirectToLogin(response.config?.url)
|
||||
}
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
if (
|
||||
if (isKicked(error?.response?.data)) {
|
||||
redirectToLoginKicked()
|
||||
} else if (
|
||||
error?.response?.status === 401 ||
|
||||
error?.response?.status === 403 ||
|
||||
isUnauthorized(error?.response?.data) ||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { http } from './http'
|
||||
import { unwrap } from './envelope'
|
||||
|
||||
/** 单条通知(后台铃铛)。 */
|
||||
export interface AdminNotificationItem {
|
||||
id: number
|
||||
scene: string
|
||||
level: string
|
||||
title: string
|
||||
content: string
|
||||
read: boolean
|
||||
readAt: string | null
|
||||
createdAt: string | null
|
||||
}
|
||||
|
||||
export interface AdminNotificationPage {
|
||||
items: AdminNotificationItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
unreadCount: number
|
||||
}
|
||||
|
||||
export interface AdminNotificationSummary {
|
||||
unreadCount: number
|
||||
latestId: number
|
||||
}
|
||||
|
||||
/** 铃铛轮询:未读数 + 最新通知 id。 */
|
||||
export async function fetchNotificationSummary(): Promise<AdminNotificationSummary> {
|
||||
const { data } = await http.get('/api/admin/notifications/summary')
|
||||
return unwrap<AdminNotificationSummary>(data)
|
||||
}
|
||||
|
||||
export async function fetchNotificationList(
|
||||
params: { page?: number; pageSize?: number; onlyUnread?: boolean } = {},
|
||||
): Promise<AdminNotificationPage> {
|
||||
const { data } = await http.get('/api/admin/notifications', {
|
||||
params: {
|
||||
page: params.page ?? 1,
|
||||
pageSize: params.pageSize ?? 20,
|
||||
onlyUnread: params.onlyUnread ? 'true' : 'false',
|
||||
},
|
||||
})
|
||||
return unwrap<AdminNotificationPage>(data)
|
||||
}
|
||||
|
||||
export async function markNotificationRead(id: number): Promise<boolean> {
|
||||
const { data } = await http.post(`/api/admin/notifications/${id}/read`)
|
||||
return unwrap<boolean>(data)
|
||||
}
|
||||
|
||||
export async function markAllNotificationsRead(): Promise<number> {
|
||||
const { data } = await http.post('/api/admin/notifications/read-all')
|
||||
return unwrap<number>(data)
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
<template>
|
||||
<div ref="rootRef" class="admin-notification-bell">
|
||||
<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 AdminNotificationItem,
|
||||
} from '@/api/notifications'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
import {
|
||||
NOTIFICATION_POLL_INTERVAL_MS,
|
||||
formatNotificationTime,
|
||||
formatUnreadBadge,
|
||||
hasNewNotification,
|
||||
readLastNotifiedId,
|
||||
writeLastNotifiedId,
|
||||
} from '@/layout/notification-bell-model'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const unreadCount = ref(0)
|
||||
const items = ref<AdminNotificationItem[]>([])
|
||||
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
|
||||
|
||||
function currentUid(): number | string {
|
||||
return session.user?.id ?? 0
|
||||
}
|
||||
|
||||
/** 拉未读数:发现新通知提醒一次(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(currentUid()))) {
|
||||
ElMessage.warning('收到新的告警通知,请点击右上角铃铛查看')
|
||||
writeLastNotifiedId(currentUid(), latestId)
|
||||
console.log('[admin-notification] 检测到新通知 latestId=', latestId)
|
||||
}
|
||||
} catch (error) {
|
||||
// 通知接口失败静默降级:不显示红点、不打扰使用者
|
||||
console.warn('[admin-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(currentUid(), Math.max(readLastNotifiedId(currentUid()), latest))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 列表加载失败在面板内提示,不弹全局消息打扰用户
|
||||
console.warn('[admin-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: AdminNotificationItem) {
|
||||
if (item.read) return
|
||||
try {
|
||||
await markNotificationRead(item.id)
|
||||
item.read = true
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||
} catch (error) {
|
||||
console.warn('[admin-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('[admin-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>
|
||||
.admin-notification-bell {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bell-trigger {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: var(--admin-muted, #5b6f83);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.bell-trigger:hover,
|
||||
.bell-trigger--active {
|
||||
background: #edf5fb;
|
||||
border-color: #cbd9e6;
|
||||
color: var(--admin-primary-strong, #2f5d8b);
|
||||
}
|
||||
|
||||
.bell-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.bell-badge {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: 0;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 999px;
|
||||
background: #d64545;
|
||||
color: #ffffff;
|
||||
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: 380px;
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: 460px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--admin-border, #d8e3ee);
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 18px 48px rgba(39, 67, 94, 0.22);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bell-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #edf1f5;
|
||||
}
|
||||
|
||||
.bell-panel-title {
|
||||
color: var(--admin-text, #24384d);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bell-read-all {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--admin-primary-strong, #2f5d8b);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bell-read-all:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bell-empty {
|
||||
padding: 36px 0;
|
||||
color: #8a99a8;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bell-empty--error {
|
||||
color: #b23c3c;
|
||||
}
|
||||
|
||||
.bell-list {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.bell-item {
|
||||
padding: 11px 14px;
|
||||
border-bottom: 1px solid #f0f3f7;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.bell-item:hover {
|
||||
background: #f7f9fc;
|
||||
}
|
||||
|
||||
.bell-item--unread {
|
||||
background: #f2f6ff;
|
||||
}
|
||||
|
||||
.bell-item--unread .bell-item-title::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-right: 7px;
|
||||
border-radius: 50%;
|
||||
background: #d64545;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
|
||||
.bell-item-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bell-item-title {
|
||||
color: var(--admin-text, #24384d);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bell-item--error .bell-item-title {
|
||||
color: #b23c3c;
|
||||
}
|
||||
|
||||
.bell-item--warning .bell-item-title {
|
||||
color: #a8793e;
|
||||
}
|
||||
|
||||
.bell-item--info .bell-item-title {
|
||||
color: var(--admin-primary-strong, #2f5d8b);
|
||||
}
|
||||
|
||||
.bell-item-time {
|
||||
flex-shrink: 0;
|
||||
color: #9aa6b3;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.bell-item-content {
|
||||
margin-top: 4px;
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.bell-panel-foot {
|
||||
border-top: 1px solid #edf1f5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bell-more {
|
||||
width: 100%;
|
||||
padding: 9px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--admin-primary-strong, #2f5d8b);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bell-more:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bell-foot-note {
|
||||
display: block;
|
||||
padding: 8px 0;
|
||||
color: #9aa6b3;
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
shouldShowEmptyMenu,
|
||||
} from '@/layout/empty-state'
|
||||
import GlobalErrorContainer from '@/layout/GlobalErrorContainer.vue'
|
||||
import NotificationBell from '@/components/NotificationBell.vue'
|
||||
import OperationGuide from '@/layout/OperationGuide.vue'
|
||||
import { crumbsForActiveRoute, resolveDocumentTitle, updateDocumentTitle } from '@/layout/title-breadcrumb'
|
||||
import {
|
||||
@@ -100,6 +101,7 @@ async function signOut() {
|
||||
<h1>{{ pageTitle }}</h1>
|
||||
</div>
|
||||
<div class="admin-user">
|
||||
<NotificationBell />
|
||||
<div class="admin-user-meta">
|
||||
<strong>{{ userVm.username }}</strong>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 后台铃铛通知纯逻辑:未读徽标文案、新通知判定、「已提醒过」去重记录、时间展示。
|
||||
* 轮询调度与渲染在 NotificationBell.vue;这里只放可单测的纯函数与存储读写。
|
||||
*/
|
||||
|
||||
/** 未读轮询间隔(60 秒)。 */
|
||||
export const NOTIFICATION_POLL_INTERVAL_MS = 60_000
|
||||
|
||||
const NOTIFIED_KEY_PREFIX = 'admin-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 | number | null | undefined): string {
|
||||
const normalized = uid === null || uid === undefined || String(uid).trim() === '' ? '0' : String(uid)
|
||||
return `${NOTIFIED_KEY_PREFIX}:${normalized}`
|
||||
}
|
||||
|
||||
/** 读取当前管理员「已提醒过的最大通知 id」。 */
|
||||
export function readLastNotifiedId(uid: string | number | null | undefined): 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 | number | null | undefined, id: number): void {
|
||||
try {
|
||||
if (Number.isFinite(id) && id > 0) {
|
||||
window.localStorage.setItem(storageKey(uid), String(Math.floor(id)))
|
||||
}
|
||||
} catch {
|
||||
/* 存储不可用时静默忽略(退化为每次都提醒,不影响功能) */
|
||||
}
|
||||
}
|
||||
|
||||
/** 通知时间展示:今天只显示 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}`
|
||||
}
|
||||
@@ -18,6 +18,8 @@ const errorMessage = ref('')
|
||||
const logoUrl = joinAdminPath('assets', 'logo.jpg')
|
||||
|
||||
const inputType = computed(() => (showPassword.value ? 'text' : 'password'))
|
||||
/** 单设备登录:被新设备顶下线后跳回登录页(整页跳转携带 ?kicked=1) */
|
||||
const kickedNotice = computed(() => route.query.kicked === '1')
|
||||
|
||||
function deviceId(): string {
|
||||
try {
|
||||
@@ -120,6 +122,7 @@ onMounted(() => {
|
||||
<p class="login-subtitle">使用管理员账号进入数富AI运营后台。</p>
|
||||
</header>
|
||||
<form novalidate @submit.prevent="submit">
|
||||
<p v-if="kickedNotice" class="kicked-msg" role="alert">该账号已在其他设备登录,本设备已下线。如非本人操作,请及时修改密码。</p>
|
||||
<p v-if="errorMessage" class="error-msg" role="alert">{{ errorMessage }}</p>
|
||||
<div class="form-group">
|
||||
<label for="loginUsername">用户名</label>
|
||||
@@ -296,6 +299,20 @@ button, input { font: inherit; }
|
||||
border: 1px solid currentColor; border-radius: 50%; font-size: 11px; font-weight: 800;
|
||||
}
|
||||
|
||||
/* 单设备登录:被新设备顶下线的提示 */
|
||||
.kicked-msg {
|
||||
display: flex; align-items: flex-start; gap: 9px;
|
||||
margin: -4px 0 18px; padding: 11px 12px;
|
||||
border: 1px solid #e0cf9f; border-radius: 11px; background: #fbf4e2; color: #8a6a1f;
|
||||
font-size: 13px; line-height: 1.55;
|
||||
}
|
||||
.kicked-msg::before {
|
||||
content: "!";
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
flex: 0 0 18px; width: 18px; height: 18px;
|
||||
border: 1px solid currentColor; border-radius: 50%; font-size: 11px; font-weight: 800;
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 9px;
|
||||
width: 100%; min-height: 48px; padding: 12px 18px;
|
||||
|
||||
@@ -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