diff --git a/web/src/assets/styles/components/top-bar.css b/web/src/assets/styles/components/top-bar.css
index b5d52dd..edc4adf 100644
--- a/web/src/assets/styles/components/top-bar.css
+++ b/web/src/assets/styles/components/top-bar.css
@@ -495,17 +495,80 @@
font-weight: 650;
}
-.notification-head button {
+.notification-head-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ flex: 0 0 auto;
+}
+
+.notification-clear-btn {
+ height: 26px;
+ padding: 0 9px;
+ border: 1px solid #dbe4ee;
+ border-radius: 4px;
+ background: #fff;
+ color: #475569;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.notification-clear-btn:hover:not(:disabled) {
+ border-color: var(--theme-primary-light-5);
+ background: var(--theme-primary-light-9);
+ color: var(--theme-primary-active);
+}
+
+.notification-clear-btn:disabled {
+ color: #cbd5e1;
+ cursor: not-allowed;
+}
+
+.notification-close-btn {
+ position: relative;
width: 26px;
height: 26px;
- display: grid;
+ display: inline-grid;
place-items: center;
+ border: 1px solid transparent;
border-radius: 4px;
color: #64748b;
}
-.notification-head button:hover {
- background: #f1f5f9;
+.notification-close-btn span,
+.notification-close-btn span::before,
+.notification-close-btn span::after {
+ position: absolute;
+ display: block;
+}
+
+.notification-close-btn span {
+ inset: 0;
+}
+
+.notification-close-btn span::before,
+.notification-close-btn span::after {
+ content: "";
+ top: 50%;
+ left: 50%;
+ width: 12px;
+ height: 2px;
+ border-radius: 999px;
+ background: currentColor;
+ transform-origin: center;
+}
+
+.notification-close-btn span::before {
+ transform: translate(-50%, -50%) rotate(45deg);
+}
+
+.notification-close-btn span::after {
+ transform: translate(-50%, -50%) rotate(-45deg);
+}
+
+.notification-close-btn:hover {
+ border-color: #e2e8f0;
+ background: #f8fafc;
color: #0f172a;
}
@@ -561,8 +624,25 @@
z-index: 1;
display: grid;
gap: 8px;
- max-height: 320px;
- overflow: auto;
+ max-height: 244px;
+ overflow-x: hidden;
+ overflow-y: auto;
+ padding-right: 2px;
+ scrollbar-width: thin;
+ scrollbar-color: #cbd5e1 transparent;
+}
+
+.notification-list::-webkit-scrollbar {
+ width: 6px;
+}
+
+.notification-list::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.notification-list::-webkit-scrollbar-thumb {
+ border-radius: 999px;
+ background: #cbd5e1;
}
.notification-row {
diff --git a/web/src/components/layout/TopBar.vue b/web/src/components/layout/TopBar.vue
index 7485b53..acbc692 100644
--- a/web/src/components/layout/TopBar.vue
+++ b/web/src/components/layout/TopBar.vue
@@ -142,9 +142,24 @@
通知中心
{{ unreadNotifications.length ? `${unreadNotifications.length} 条待处理` : '暂无待处理通知' }}
-
+
+
+
+
@@ -374,63 +389,152 @@ const eyebrowLabel = computed(() => (
|| (isChat.value ? 'Smart Finance Q&A' : 'Smart Expense Operations')
))
const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司')
+const NOTIFICATION_READ_STORAGE_KEY = 'x-financial.topbar.notifications.read'
+const NOTIFICATION_HIDDEN_STORAGE_KEY = 'x-financial.topbar.notifications.hidden'
+const MAX_NOTIFICATION_ITEMS = 30
const {
+ markDocumentInboxRowRead,
+ markDocumentInboxRowsRead,
+ notificationRows: documentInboxNotificationRows,
refreshDocumentInbox,
startDocumentInboxPolling,
- stopDocumentInboxPolling,
- unreadCount: documentInboxUnreadCount
+ stopDocumentInboxPolling
} = useDocumentCenterInbox()
let documentInboxInitialRefreshTimer = null
-const workbenchNotificationCount = computed(() => {
- const summary = props.workbenchSummary ?? {}
- const count = Number(summary.unreadNotificationCount ?? 0)
- return Number.isFinite(count) && count > 0 ? count : 0
-})
-const topbarNotificationCount = computed(() => {
- const count = workbenchNotificationCount.value + Number(documentInboxUnreadCount.value || 0)
- return Number.isFinite(count) && count > 0 ? Math.min(count, 99) : 0
-})
+const readNotificationIds = ref(readNotificationIdSet(NOTIFICATION_READ_STORAGE_KEY))
+const hiddenNotificationIds = ref(readNotificationIdSet(NOTIFICATION_HIDDEN_STORAGE_KEY))
const notificationOpen = ref(false)
const notificationTab = ref('unread')
-const documentInboxBadgeText = computed(() => {
- const count = Number(documentInboxUnreadCount.value || 0)
- return count > 99 ? '99+' : String(count)
-})
-const documentInboxNotification = computed(() => {
- const count = Number(documentInboxUnreadCount.value || 0)
- if (!Number.isFinite(count) || count <= 0) {
- return null
+
+function readNotificationIdSet(storageKey) {
+ if (typeof window === 'undefined') {
+ return new Set()
}
- return {
- id: 'document-center-unread',
- title: '单据中心有新单据',
- description: `当前有 ${count} 条新单据待查看`,
- time: '刚刚更新',
- category: '单据中心',
- tone: 'danger',
- unread: true,
- icon: 'mdi mdi-file-document-alert-outline',
- badge: documentInboxBadgeText.value,
- target: { type: 'documents-center' }
+ try {
+ const parsed = JSON.parse(window.localStorage.getItem(storageKey) || '[]')
+ return new Set(Array.isArray(parsed) ? parsed.map((item) => String(item || '').trim()).filter(Boolean) : [])
+ } catch {
+ return new Set()
}
-})
+}
+
+function writeNotificationIdSet(storageKey, values) {
+ if (typeof window === 'undefined') {
+ return
+ }
+
+ window.localStorage.setItem(storageKey, JSON.stringify(Array.from(values).filter(Boolean)))
+}
+
+function updateNotificationIdSet(targetRef, storageKey, updater) {
+ const next = updater(new Set(targetRef.value))
+ targetRef.value = next
+ writeNotificationIdSet(storageKey, next)
+}
+
+function normalizeNotificationId(value) {
+ return String(value || '').trim()
+}
+
+function isNotificationHidden(id) {
+ return hiddenNotificationIds.value.has(normalizeNotificationId(id))
+}
+
+function formatNotificationTime(value) {
+ const date = new Date(value)
+ if (!Number.isFinite(date.getTime())) {
+ return '最近更新'
+ }
+
+ const month = String(date.getMonth() + 1).padStart(2, '0')
+ const day = String(date.getDate()).padStart(2, '0')
+ const hour = String(date.getHours()).padStart(2, '0')
+ const minute = String(date.getMinutes()).padStart(2, '0')
+ return `${month}-${day} ${hour}:${minute}`
+}
+
+function resolveDocumentNotificationTone(row) {
+ if (row?.source === 'approval') {
+ return 'warning'
+ }
+
+ return row?.isUnread ? 'info' : 'success'
+}
+
+function resolveDocumentNotificationDescription(row) {
+ return [
+ row?.title,
+ row?.initiatorName ? `发起人 ${row.initiatorName}` : '',
+ row?.statusLabel ? `状态 ${row.statusLabel}` : ''
+ ].filter(Boolean).join(' · ') || '单据中心有新的单据状态'
+}
+
+function resolveWorkbenchNotificationId(item, index) {
+ return normalizeNotificationId(`workbench:${item?.id || [item?.title, item?.description, item?.time, index].join('|')}`)
+}
+
+const documentNotificationItems = computed(() =>
+ documentInboxNotificationRows.value
+ .map((row) => {
+ const id = normalizeNotificationId(`document:${row.documentKey || row.claimId || row.documentNo}`)
+ if (!id || isNotificationHidden(id)) {
+ return null
+ }
+
+ return {
+ id,
+ kind: 'document',
+ title: `${row.documentTypeLabel || '单据'} ${row.documentNo || row.claimId || '待生成'}`,
+ description: resolveDocumentNotificationDescription(row),
+ time: formatNotificationTime(row.updatedAt || row.createdAt),
+ category: row.sourceLabel || '单据中心',
+ tone: resolveDocumentNotificationTone(row),
+ unread: Boolean(row.isUnread),
+ icon: row.source === 'approval' ? 'mdi mdi-clipboard-text-clock-outline' : 'mdi mdi-file-document-outline',
+ badge: row.isUnread ? '新' : '',
+ target: {
+ type: 'document',
+ id: row.claimId,
+ claimNo: row.documentNo
+ },
+ documentRow: row
+ }
+ })
+ .filter(Boolean)
+)
+
const workbenchNotificationItems = computed(() => (
Array.isArray(props.workbenchSummary?.notifications)
- ? props.workbenchSummary.notifications
+ ? props.workbenchSummary.notifications.map((item, index) => {
+ const id = resolveWorkbenchNotificationId(item, index)
+ if (!id || isNotificationHidden(id)) {
+ return null
+ }
+
+ return {
+ ...item,
+ id,
+ kind: 'workbench',
+ category: item.category || '个人工作台',
+ unread: Boolean(item.unread) && !readNotificationIds.value.has(id),
+ icon: item.icon || resolveNotificationIcon(item)
+ }
+ }).filter(Boolean)
: []
))
-const notificationItems = computed(() => {
- const inboxNotification = documentInboxNotification.value
- return inboxNotification
- ? [inboxNotification, ...workbenchNotificationItems.value]
- : workbenchNotificationItems.value
-})
+const notificationItems = computed(() =>
+ [...documentNotificationItems.value, ...workbenchNotificationItems.value].slice(0, MAX_NOTIFICATION_ITEMS)
+)
const unreadNotifications = computed(() => notificationItems.value.filter((item) => item.unread))
const readNotifications = computed(() => notificationItems.value.filter((item) => !item.unread))
const activeNotifications = computed(() => (
notificationTab.value === 'unread' ? unreadNotifications.value : readNotifications.value
))
+const topbarNotificationCount = computed(() => {
+ const count = unreadNotifications.value.length
+ return count > 0 ? Math.min(count, 99) : 0
+})
function clearDocumentInboxInitialRefreshTimer() {
if (documentInboxInitialRefreshTimer && typeof window !== 'undefined') {
@@ -471,14 +575,47 @@ function resolveNotificationIcon(item) {
return 'mdi mdi-bell-outline'
}
-function openNotification(item) {
- notificationOpen.value = false
- const target = item?.target || {}
- if (target.type === 'documents-center') {
- emit('navigate', 'documents')
+function markNotificationRead(item) {
+ if (!item?.id || !item.unread) {
return
}
+ if (item.kind === 'document' && item.documentRow) {
+ markDocumentInboxRowRead(item.documentRow)
+ return
+ }
+
+ updateNotificationIdSet(readNotificationIds, NOTIFICATION_READ_STORAGE_KEY, (next) => {
+ next.add(item.id)
+ return next
+ })
+}
+
+function clearAllNotifications() {
+ const currentItems = notificationItems.value
+ if (!currentItems.length) {
+ return
+ }
+
+ const documentRows = currentItems
+ .filter((item) => item.kind === 'document' && item.documentRow)
+ .map((item) => item.documentRow)
+
+ if (documentRows.length) {
+ markDocumentInboxRowsRead(documentRows)
+ }
+
+ updateNotificationIdSet(hiddenNotificationIds, NOTIFICATION_HIDDEN_STORAGE_KEY, (next) => {
+ currentItems.forEach((item) => next.add(item.id))
+ return next
+ })
+ notificationTab.value = 'unread'
+}
+
+function openNotification(item) {
+ markNotificationRead(item)
+ notificationOpen.value = false
+ const target = item?.target || {}
if (target.type === 'document' && (target.id || target.claimNo)) {
emit('openDocument', {
claimId: target.id,
diff --git a/web/src/composables/useDocumentCenterInbox.js b/web/src/composables/useDocumentCenterInbox.js
index faeb04f..d3c546b 100644
--- a/web/src/composables/useDocumentCenterInbox.js
+++ b/web/src/composables/useDocumentCenterInbox.js
@@ -4,6 +4,9 @@ import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims, fetchExpenseCla
import {
DOCUMENT_VIEWED_KEYS_CHANGE_EVENT,
countNewDocuments,
+ isNewDocument,
+ markDocumentViewed,
+ markDocumentsViewed,
readViewedDocumentKeys,
resolveDocumentNewKey
} from '../utils/documentCenterNewState.js'
@@ -24,6 +27,12 @@ let refreshPromise = null
let lastRefreshAt = 0
let viewedKeysListenerAttached = false
+const SOURCE_LABELS = {
+ owned: '我的单据',
+ approval: '待我处理',
+ archive: '归档单据'
+}
+
function normalizeClaimText(...values) {
for (const value of values) {
const normalized = String(value || '').trim()
@@ -35,18 +44,41 @@ function normalizeClaimText(...values) {
return ''
}
+function resolveSortTime(...values) {
+ for (const value of values) {
+ const time = Date.parse(String(value || '').trim())
+ if (Number.isFinite(time)) {
+ return time
+ }
+ }
+
+ return 0
+}
+
function buildDocumentInboxRow(claim, source) {
const request = mapExpenseClaimToRequest(claim)
const claimId = normalizeClaimText(request.claimId, request.id, claim?.id, claim?.claim_id)
const documentNo = normalizeClaimText(request.documentNo, request.claimNo, request.id, claim?.claim_no)
const documentKey = normalizeClaimText(claimId, documentNo)
+ const createdAt = normalizeClaimText(request.createdAt, request.submittedAt, claim?.created_at, claim?.submitted_at)
+ const updatedAt = normalizeClaimText(request.updatedAt, request.approvedAt, claim?.updated_at, claim?.approved_at)
+ const documentTypeLabel = normalizeClaimText(request.documentTypeLabel, claim?.document_type_label) || '报销单'
return documentKey
? {
source,
claimId: claimId || documentKey,
documentNo,
- documentKey: `${source}:${documentKey}`
+ documentKey: `${source}:${documentKey}`,
+ documentTypeLabel,
+ sourceLabel: SOURCE_LABELS[source] || '单据中心',
+ title: normalizeClaimText(claim?.title, request.title, request.sceneLabel, request.note) || documentTypeLabel,
+ initiatorName: normalizeClaimText(request.initiatorName, request.person, claim?.employee_name, claim?.applicant_name),
+ statusLabel: normalizeClaimText(request.approvalStatus, request.status, claim?.approval_status, claim?.status),
+ createdAt,
+ updatedAt,
+ sortTime: resolveSortTime(updatedAt, createdAt),
+ rawRequest: request
}
: null
}
@@ -127,6 +159,25 @@ export function useDocumentCenterInbox() {
const unreadCount = computed(() => countNewDocuments(documentRows.value, viewedDocumentKeys.value))
const hasUnread = computed(() => unreadCount.value > 0)
+ const notificationRows = computed(() =>
+ documentRows.value
+ .filter((row) => String(row?.source || '').trim() !== 'archive')
+ .map((row) => ({
+ ...row,
+ isUnread: isNewDocument(row, viewedDocumentKeys.value)
+ }))
+ .sort((left, right) => Number(right.sortTime || 0) - Number(left.sortTime || 0))
+ )
+
+ function markDocumentInboxRowRead(row) {
+ viewedDocumentKeys.value = markDocumentViewed(row, viewedDocumentKeys.value)
+ return viewedDocumentKeys.value
+ }
+
+ function markDocumentInboxRowsRead(rows = documentRows.value) {
+ viewedDocumentKeys.value = markDocumentsViewed(rows, viewedDocumentKeys.value)
+ return viewedDocumentKeys.value
+ }
async function refreshDocumentInbox(options = {}) {
const force = Boolean(options.force)
@@ -191,6 +242,9 @@ export function useDocumentCenterInbox() {
return {
hasUnread,
loading,
+ markDocumentInboxRowRead,
+ markDocumentInboxRowsRead,
+ notificationRows,
refreshDocumentInbox,
startDocumentInboxPolling,
stopDocumentInboxPolling,
diff --git a/web/tests/document-center-new-state.test.mjs b/web/tests/document-center-new-state.test.mjs
index 8f6570c..a62e65a 100644
--- a/web/tests/document-center-new-state.test.mjs
+++ b/web/tests/document-center-new-state.test.mjs
@@ -85,6 +85,24 @@ test('document center sidebar inbox shares source scoped document keys', () => {
assert.deepEqual(rows.map((row) => resolveDocumentNewKey(row)), ['approval:claim-1', 'archive:claim-2'])
})
+test('document center inbox rows expose real notification metadata', () => {
+ const rows = buildDocumentInboxRows({
+ ownedClaims: [{
+ id: 'claim-1',
+ claim_no: 'EXP-1',
+ title: '差旅报销',
+ status: 'draft',
+ created_at: '2026-06-03T09:10:00+08:00'
+ }]
+ })
+
+ assert.equal(rows[0].documentNo, 'EXP-1')
+ assert.equal(rows[0].sourceLabel, '我的单据')
+ assert.equal(rows[0].title, '差旅报销')
+ assert.equal(rows[0].createdAt, '2026-06-03T09:10:00+08:00')
+ assert.equal(Number.isFinite(rows[0].sortTime), true)
+})
+
test('document center scope state restores only allowed tabs', () => {
const storage = createMemoryStorage()
const scopes = ['全部', '申请单', '报销单', '审核单', '归档']
diff --git a/web/tests/sidebar-document-unread-dot.test.mjs b/web/tests/sidebar-document-unread-dot.test.mjs
index 4ceab8e..a9358ee 100644
--- a/web/tests/sidebar-document-unread-dot.test.mjs
+++ b/web/tests/sidebar-document-unread-dot.test.mjs
@@ -52,22 +52,30 @@ test('sidebar no longer renders document center unread indicators', () => {
test('topbar bell owns document center unread notifications', () => {
assert.match(topbar, /useDocumentCenterInbox/)
- assert.match(topbar, /unreadCount: documentInboxUnreadCount/)
- assert.match(topbar, /const workbenchNotificationCount = computed/)
- assert.match(topbar, /const count = workbenchNotificationCount\.value \+ Number\(documentInboxUnreadCount\.value \|\| 0\)/)
- assert.match(topbar, /const documentInboxNotification = computed/)
- assert.match(topbar, /id: 'document-center-unread'/)
- assert.match(topbar, /title: '单据中心有新单据'/)
- assert.match(topbar, /target: \{ type: 'documents-center' \}/)
- assert.match(topbar, /emit\('navigate', 'documents'\)/)
+ assert.match(topbar, /notificationRows: documentInboxNotificationRows/)
+ assert.match(topbar, /const documentNotificationItems = computed/)
+ assert.match(topbar, /title: `\$\{row\.documentTypeLabel \|\| '单据'\} \$\{row\.documentNo \|\| row\.claimId \|\| '待生成'\}`/)
+ assert.match(topbar, /description: resolveDocumentNotificationDescription\(row\)/)
+ assert.match(topbar, /markDocumentInboxRowRead\(item\.documentRow\)/)
+ assert.match(topbar, /markDocumentInboxRowsRead\(documentRows\)/)
+ assert.match(topbar, /const topbarNotificationCount = computed\(\(\) => \{[\s\S]*const count = unreadNotifications\.value\.length/)
+ assert.doesNotMatch(topbar, /document-center-unread/)
+ assert.doesNotMatch(topbar, /target: \{ type: 'documents-center' \}/)
+ assert.doesNotMatch(topbar, /emit\('navigate', 'documents'\)/)
assert.match(appShellRouteView, /@navigate="handleNavigate"/)
assert.match(topbar, /startDocumentInboxPolling\(\)/)
assert.match(topbar, /stopDocumentInboxPolling\(\)/)
+ assert.match(topbar, /class="notification-clear-btn"/)
+ assert.match(topbar, /function clearAllNotifications\(\)/)
+ assert.match(topbar, /function markNotificationRead\(item\)/)
assert.match(topbar, /class="notification-type-icon" :class="item\.tone"/)
assert.match(topbarStyles, /\.notification-head-copy\s*\{[\s\S]*display:\s*grid;/)
+ assert.match(topbarStyles, /\.notification-head-actions\s*\{[\s\S]*display:\s*inline-flex;/)
+ assert.match(topbarStyles, /\.notification-close-btn span::before\s*\{[\s\S]*rotate\(45deg\);/)
+ assert.match(topbarStyles, /\.notification-close-btn span::after\s*\{[\s\S]*rotate\(-45deg\);/)
+ assert.match(topbarStyles, /\.notification-list\s*\{[\s\S]*max-height:\s*244px;[\s\S]*overflow-y:\s*auto;/)
assert.match(topbarStyles, /\.notification-tabs button em\s*\{[\s\S]*border-radius:\s*999px;/)
assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*34px minmax\(0,\s*1fr\) 16px;/)
- assert.match(topbarStyles, /\.notification-type-icon\.danger\s*\{[\s\S]*background:\s*#fff5f5;/)
assert.doesNotMatch(topbarStyles, /\.notification-dot/)
})
@@ -75,6 +83,10 @@ test('document inbox reuses document center viewed-key state', () => {
assert.match(documentInbox, /DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
assert.match(documentInbox, /readViewedDocumentKeys/)
assert.match(documentInbox, /countNewDocuments\(documentRows\.value, viewedDocumentKeys\.value\)/)
+ assert.match(documentInbox, /const notificationRows = computed/)
+ assert.match(documentInbox, /isUnread: isNewDocument\(row, viewedDocumentKeys\.value\)/)
+ assert.match(documentInbox, /function markDocumentInboxRowRead\(row\)/)
+ assert.match(documentInbox, /function markDocumentInboxRowsRead\(rows = documentRows\.value\)/)
assert.match(documentInbox, /fetchExpenseClaims/)
assert.match(documentInbox, /fetchApprovalExpenseClaims/)
assert.match(documentInbox, /fetchArchivedExpenseClaims/)