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:
2026-09-13 23:08:22 +08:00
parent a0f6582914
commit b70557a077
53 changed files with 3982 additions and 101 deletions
@@ -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>