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/)