puzhibing
2026-08-17 237f2a7744bd594f1f41a818ccb52907e4f24336
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useExceptions } from '@/stores/exceptions'
import { useTodos } 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 } = useTodos()
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>
    <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>