<script setup lang="ts">
|
import { computed, ref, onMounted, watch } from 'vue'
|
import { useRouter } from 'vue-router'
|
import { useExceptions } from '@/stores/exceptions'
|
import { getUserInfo } from '@/api/auth'
|
import { useTodos, clearTodos } from '@/stores/todos'
|
import { useTests } from '@/stores/tests'
|
|
interface NotificationItem {
|
id: string
|
title: string
|
detail: string
|
time: string
|
path: string
|
type: 'exception' | 'todo' | 'test'
|
}
|
|
const READ_KEY = 'battery_detection_read_notifications_v1'
|
const router = useRouter()
|
const visible = ref(false)
|
const { records: exceptions } = useExceptions()
|
const { records: todos, load: loadTodos } = useTodos()
|
const todoError = ref('')
|
async function refreshTodos() {
|
todoError.value = ''
|
try {
|
const permissions = (await getUserInfo()).permissions || []
|
if (permissions.some(value => ['*:*:*', 'todo:list:list', 'todo:list:query'].includes(value))) await loadTodos()
|
else clearTodos()
|
} catch (error) { clearTodos(); todoError.value = error instanceof Error ? error.message : '待办通知加载失败' }
|
}
|
onMounted(refreshTodos)
|
watch(visible, value => { if (value) void refreshTodos() })
|
const { records: tests } = useTests()
|
|
function loadReadIds() {
|
try {
|
return new Set<string>(JSON.parse(localStorage.getItem(READ_KEY) || '[]') as string[])
|
} catch {
|
localStorage.removeItem(READ_KEY)
|
return new Set<string>()
|
}
|
}
|
|
const readIds = ref(loadReadIds())
|
const notifications = computed<NotificationItem[]>(() => [
|
...exceptions.value.filter(item => item.status === '待处理').map(item => ({
|
id: `exception-${item.id}`, title: `${item.testName}发生异常`, detail: `${item.exceptionItem}:${item.abnormalValue},正常范围 ${item.normalValue}`,
|
time: item.generatedAt, path: '/exception', type: 'exception' as const,
|
})),
|
...todos.value.filter(item => item.status === '待处理').map(item => ({
|
id: `todo-${item.id}`, title: '待办任务提醒', detail: `${item.content} · 负责人 ${item.owner}`,
|
time: item.createdAt, path: '/todo-list', type: 'todo' as const,
|
})),
|
...tests.value.filter(item => item.status === '待测试').map(item => ({
|
id: `test-${item.id}`, title: '测试等待执行', detail: `${item.name} · ${item.batteryGroup} · ${item.batteryCount} 个电池`,
|
time: item.createdAt, path: `/test-list/${item.id}/console`, type: 'test' as const,
|
})),
|
].sort((a, b) => b.time.localeCompare(a.time)).slice(0, 20))
|
|
const unreadCount = computed(() => notifications.value.filter(item => !readIds.value.has(item.id)).length)
|
|
function persistReadIds(next: Set<string>) {
|
readIds.value = next
|
localStorage.setItem(READ_KEY, JSON.stringify([...next]))
|
}
|
|
function markAllRead() {
|
persistReadIds(new Set([...readIds.value, ...notifications.value.map(item => item.id)]))
|
}
|
|
async function openNotification(item: NotificationItem) {
|
persistReadIds(new Set(readIds.value).add(item.id))
|
visible.value = false
|
await router.push(item.path)
|
}
|
|
function typeLabel(type: NotificationItem['type']) {
|
return type === 'exception' ? '异常' : type === 'todo' ? '待办' : '测试'
|
}
|
</script>
|
|
<template>
|
<button class="header-icon-button notification-trigger" type="button" aria-label="消息通知" title="消息通知" @click="visible = true">
|
<el-icon><Bell /></el-icon><span v-if="unreadCount" class="notification-badge">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
|
</button>
|
|
<el-drawer v-model="visible" class="notification-drawer" size="420px" direction="rtl" destroy-on-close>
|
<template #header>
|
<div class="drawer-heading"><div><strong>消息通知</strong><span>{{ unreadCount }} 条未读</span></div><button v-if="unreadCount" type="button" @click="markAllRead"><el-icon><Check /></el-icon>全部已读</button></div>
|
</template>
|
<el-alert v-if="todoError" :title="todoError" type="error" :closable="false" />
|
<div v-if="notifications.length" class="notification-list">
|
<button v-for="item in notifications" :key="item.id" type="button" class="notification-item" :class="[{ unread: !readIds.has(item.id) }, item.type]" @click="openNotification(item)">
|
<span class="type-icon"><el-icon><Warning v-if="item.type === 'exception'" /><List v-else-if="item.type === 'todo'" /><Monitor v-else /></el-icon></span>
|
<span class="notification-content"><span class="notification-meta"><b>{{ typeLabel(item.type) }}</b><time>{{ item.time }}</time></span><strong>{{ item.title }}</strong><small>{{ item.detail }}</small></span>
|
<i v-if="!readIds.has(item.id)" class="unread-dot" aria-hidden="true"></i>
|
</button>
|
</div>
|
<div v-else class="notification-empty"><el-icon><CircleCheck /></el-icon><strong>暂无消息</strong><span>当前没有待处理通知</span></div>
|
<template #footer><button class="view-all-button" type="button" @click="visible = false; router.push('/todo-list')">查看全部待办<el-icon><ArrowRight /></el-icon></button></template>
|
</el-drawer>
|
</template>
|
|
<style scoped>
|
.header-icon-button{position:relative;width:28px;height:28px;display:inline-grid;place-items:center;padding:0;border:0;color:#52606d;background:transparent;font-size:17px;cursor:pointer}.header-icon-button:hover{color:var(--blue);background:#edf4fa}.notification-badge{position:absolute;top:-3px;right:-6px;min-width:16px;height:16px;display:flex;align-items:center;justify-content:center;padding:0 4px;border:2px solid #fff;border-radius:8px;color:#fff;background:#d15b4a;font-size:9px;font-weight:700;line-height:1}.drawer-heading{width:100%;display:flex;align-items:center;justify-content:space-between}.drawer-heading>div{display:flex;align-items:baseline;gap:9px}.drawer-heading strong{color:#26333f;font-size:16px}.drawer-heading span{color:#8795a3;font-size:11px}.drawer-heading button{height:30px;display:inline-flex;align-items:center;gap:5px;padding:0 9px;border:1px solid #bfd7ed;color:#1677c8;background:#eef6ff;font-size:11px;cursor:pointer}.notification-list{border-top:1px solid #e4ebf1}.notification-item{position:relative;width:100%;min-height:84px;display:grid;grid-template-columns:36px minmax(0,1fr) 7px;align-items:start;gap:11px;padding:14px 16px;border:0;border-bottom:1px solid #e4ebf1;color:#52606d;background:#fff;text-align:left;cursor:pointer}.notification-item:hover{background:#f5f9fc}.notification-item.unread{background:#f2f8ff}.type-icon{width:34px;height:34px;display:grid;place-items:center;color:#2b78a3;background:#e5f2fd;font-size:16px}.notification-item.exception .type-icon{color:#b34f4f;background:#fbeceb}.notification-item.todo .type-icon{color:#b27824;background:#fff4df}.notification-content{min-width:0}.notification-meta{display:flex;align-items:center;justify-content:space-between;gap:10px}.notification-meta b{color:#647380;font-size:10px;font-weight:600}.notification-meta time{color:#97a4af;font-size:10px}.notification-content>strong,.notification-content>small{display:block}.notification-content>strong{margin-top:5px;color:#26333f;font-size:13px;font-weight:600}.notification-content>small{margin-top:5px;color:#71808d;font-size:11px;line-height:1.5}.unread-dot{width:7px;height:7px;margin-top:5px;border-radius:50%;background:#2188f5}.notification-empty{height:320px;display:flex;align-items:center;justify-content:center;flex-direction:column;color:#8795a3}.notification-empty>.el-icon{color:#45a37c;font-size:34px}.notification-empty strong{margin-top:12px;color:#52606d;font-size:13px}.notification-empty span{margin-top:5px;font-size:11px}.view-all-button{width:100%;height:36px;display:flex;align-items:center;justify-content:center;gap:6px;border:1px solid #b8cadb;color:#2b78a3;background:#fff;font-size:12px;cursor:pointer}.view-all-button:hover{background:#eef6ff}:global(.notification-drawer){max-width:100vw}:global(.notification-drawer .el-drawer__header){height:60px;margin:0;padding:0 18px;border-bottom:1px solid #d8e1e9}:global(.notification-drawer .el-drawer__body){padding:0}:global(.notification-drawer .el-drawer__footer){padding:12px 16px;border-top:1px solid #d8e1e9}
|
</style>
|