fix(notifications): refine bell notification center
This commit is contained in:
@@ -495,17 +495,80 @@
|
|||||||
font-weight: 650;
|
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;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
display: grid;
|
display: inline-grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-head button:hover {
|
.notification-close-btn span,
|
||||||
background: #f1f5f9;
|
.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;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,8 +624,25 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
max-height: 320px;
|
max-height: 244px;
|
||||||
overflow: auto;
|
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 {
|
.notification-row {
|
||||||
|
|||||||
@@ -142,9 +142,24 @@
|
|||||||
<strong>通知中心</strong>
|
<strong>通知中心</strong>
|
||||||
<small>{{ unreadNotifications.length ? `${unreadNotifications.length} 条待处理` : '暂无待处理通知' }}</small>
|
<small>{{ unreadNotifications.length ? `${unreadNotifications.length} 条待处理` : '暂无待处理通知' }}</small>
|
||||||
</span>
|
</span>
|
||||||
<button type="button" aria-label="关闭通知" @click="notificationOpen = false">
|
<span class="notification-head-actions">
|
||||||
<i class="mdi mdi-close"></i>
|
<button
|
||||||
</button>
|
class="notification-clear-btn"
|
||||||
|
type="button"
|
||||||
|
:disabled="notificationItems.length === 0"
|
||||||
|
@click="clearAllNotifications"
|
||||||
|
>
|
||||||
|
清空通知
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="notification-close-btn"
|
||||||
|
type="button"
|
||||||
|
aria-label="关闭通知"
|
||||||
|
@click="notificationOpen = false"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="notification-tabs" role="tablist" aria-label="通知状态">
|
<div class="notification-tabs" role="tablist" aria-label="通知状态">
|
||||||
@@ -374,63 +389,152 @@ const eyebrowLabel = computed(() => (
|
|||||||
|| (isChat.value ? 'Smart Finance Q&A' : 'Smart Expense Operations')
|
|| (isChat.value ? 'Smart Finance Q&A' : 'Smart Expense Operations')
|
||||||
))
|
))
|
||||||
const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司')
|
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 {
|
const {
|
||||||
|
markDocumentInboxRowRead,
|
||||||
|
markDocumentInboxRowsRead,
|
||||||
|
notificationRows: documentInboxNotificationRows,
|
||||||
refreshDocumentInbox,
|
refreshDocumentInbox,
|
||||||
startDocumentInboxPolling,
|
startDocumentInboxPolling,
|
||||||
stopDocumentInboxPolling,
|
stopDocumentInboxPolling
|
||||||
unreadCount: documentInboxUnreadCount
|
|
||||||
} = useDocumentCenterInbox()
|
} = useDocumentCenterInbox()
|
||||||
let documentInboxInitialRefreshTimer = null
|
let documentInboxInitialRefreshTimer = null
|
||||||
const workbenchNotificationCount = computed(() => {
|
const readNotificationIds = ref(readNotificationIdSet(NOTIFICATION_READ_STORAGE_KEY))
|
||||||
const summary = props.workbenchSummary ?? {}
|
const hiddenNotificationIds = ref(readNotificationIdSet(NOTIFICATION_HIDDEN_STORAGE_KEY))
|
||||||
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 notificationOpen = ref(false)
|
const notificationOpen = ref(false)
|
||||||
const notificationTab = ref('unread')
|
const notificationTab = ref('unread')
|
||||||
const documentInboxBadgeText = computed(() => {
|
|
||||||
const count = Number(documentInboxUnreadCount.value || 0)
|
function readNotificationIdSet(storageKey) {
|
||||||
return count > 99 ? '99+' : String(count)
|
if (typeof window === 'undefined') {
|
||||||
})
|
return new Set()
|
||||||
const documentInboxNotification = computed(() => {
|
|
||||||
const count = Number(documentInboxUnreadCount.value || 0)
|
|
||||||
if (!Number.isFinite(count) || count <= 0) {
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
try {
|
||||||
id: 'document-center-unread',
|
const parsed = JSON.parse(window.localStorage.getItem(storageKey) || '[]')
|
||||||
title: '单据中心有新单据',
|
return new Set(Array.isArray(parsed) ? parsed.map((item) => String(item || '').trim()).filter(Boolean) : [])
|
||||||
description: `当前有 ${count} 条新单据待查看`,
|
} catch {
|
||||||
time: '刚刚更新',
|
return new Set()
|
||||||
category: '单据中心',
|
|
||||||
tone: 'danger',
|
|
||||||
unread: true,
|
|
||||||
icon: 'mdi mdi-file-document-alert-outline',
|
|
||||||
badge: documentInboxBadgeText.value,
|
|
||||||
target: { type: 'documents-center' }
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
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(() => (
|
const workbenchNotificationItems = computed(() => (
|
||||||
Array.isArray(props.workbenchSummary?.notifications)
|
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 notificationItems = computed(() =>
|
||||||
const inboxNotification = documentInboxNotification.value
|
[...documentNotificationItems.value, ...workbenchNotificationItems.value].slice(0, MAX_NOTIFICATION_ITEMS)
|
||||||
return inboxNotification
|
)
|
||||||
? [inboxNotification, ...workbenchNotificationItems.value]
|
|
||||||
: workbenchNotificationItems.value
|
|
||||||
})
|
|
||||||
const unreadNotifications = computed(() => notificationItems.value.filter((item) => item.unread))
|
const unreadNotifications = computed(() => notificationItems.value.filter((item) => item.unread))
|
||||||
const readNotifications = computed(() => notificationItems.value.filter((item) => !item.unread))
|
const readNotifications = computed(() => notificationItems.value.filter((item) => !item.unread))
|
||||||
const activeNotifications = computed(() => (
|
const activeNotifications = computed(() => (
|
||||||
notificationTab.value === 'unread' ? unreadNotifications.value : readNotifications.value
|
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() {
|
function clearDocumentInboxInitialRefreshTimer() {
|
||||||
if (documentInboxInitialRefreshTimer && typeof window !== 'undefined') {
|
if (documentInboxInitialRefreshTimer && typeof window !== 'undefined') {
|
||||||
@@ -471,14 +575,47 @@ function resolveNotificationIcon(item) {
|
|||||||
return 'mdi mdi-bell-outline'
|
return 'mdi mdi-bell-outline'
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNotification(item) {
|
function markNotificationRead(item) {
|
||||||
notificationOpen.value = false
|
if (!item?.id || !item.unread) {
|
||||||
const target = item?.target || {}
|
|
||||||
if (target.type === 'documents-center') {
|
|
||||||
emit('navigate', 'documents')
|
|
||||||
return
|
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)) {
|
if (target.type === 'document' && (target.id || target.claimNo)) {
|
||||||
emit('openDocument', {
|
emit('openDocument', {
|
||||||
claimId: target.id,
|
claimId: target.id,
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims, fetchExpenseCla
|
|||||||
import {
|
import {
|
||||||
DOCUMENT_VIEWED_KEYS_CHANGE_EVENT,
|
DOCUMENT_VIEWED_KEYS_CHANGE_EVENT,
|
||||||
countNewDocuments,
|
countNewDocuments,
|
||||||
|
isNewDocument,
|
||||||
|
markDocumentViewed,
|
||||||
|
markDocumentsViewed,
|
||||||
readViewedDocumentKeys,
|
readViewedDocumentKeys,
|
||||||
resolveDocumentNewKey
|
resolveDocumentNewKey
|
||||||
} from '../utils/documentCenterNewState.js'
|
} from '../utils/documentCenterNewState.js'
|
||||||
@@ -24,6 +27,12 @@ let refreshPromise = null
|
|||||||
let lastRefreshAt = 0
|
let lastRefreshAt = 0
|
||||||
let viewedKeysListenerAttached = false
|
let viewedKeysListenerAttached = false
|
||||||
|
|
||||||
|
const SOURCE_LABELS = {
|
||||||
|
owned: '我的单据',
|
||||||
|
approval: '待我处理',
|
||||||
|
archive: '归档单据'
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeClaimText(...values) {
|
function normalizeClaimText(...values) {
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
const normalized = String(value || '').trim()
|
const normalized = String(value || '').trim()
|
||||||
@@ -35,18 +44,41 @@ function normalizeClaimText(...values) {
|
|||||||
return ''
|
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) {
|
function buildDocumentInboxRow(claim, source) {
|
||||||
const request = mapExpenseClaimToRequest(claim)
|
const request = mapExpenseClaimToRequest(claim)
|
||||||
const claimId = normalizeClaimText(request.claimId, request.id, claim?.id, claim?.claim_id)
|
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 documentNo = normalizeClaimText(request.documentNo, request.claimNo, request.id, claim?.claim_no)
|
||||||
const documentKey = normalizeClaimText(claimId, documentNo)
|
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
|
return documentKey
|
||||||
? {
|
? {
|
||||||
source,
|
source,
|
||||||
claimId: claimId || documentKey,
|
claimId: claimId || documentKey,
|
||||||
documentNo,
|
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
|
: null
|
||||||
}
|
}
|
||||||
@@ -127,6 +159,25 @@ export function useDocumentCenterInbox() {
|
|||||||
|
|
||||||
const unreadCount = computed(() => countNewDocuments(documentRows.value, viewedDocumentKeys.value))
|
const unreadCount = computed(() => countNewDocuments(documentRows.value, viewedDocumentKeys.value))
|
||||||
const hasUnread = computed(() => unreadCount.value > 0)
|
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 = {}) {
|
async function refreshDocumentInbox(options = {}) {
|
||||||
const force = Boolean(options.force)
|
const force = Boolean(options.force)
|
||||||
@@ -191,6 +242,9 @@ export function useDocumentCenterInbox() {
|
|||||||
return {
|
return {
|
||||||
hasUnread,
|
hasUnread,
|
||||||
loading,
|
loading,
|
||||||
|
markDocumentInboxRowRead,
|
||||||
|
markDocumentInboxRowsRead,
|
||||||
|
notificationRows,
|
||||||
refreshDocumentInbox,
|
refreshDocumentInbox,
|
||||||
startDocumentInboxPolling,
|
startDocumentInboxPolling,
|
||||||
stopDocumentInboxPolling,
|
stopDocumentInboxPolling,
|
||||||
|
|||||||
@@ -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'])
|
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', () => {
|
test('document center scope state restores only allowed tabs', () => {
|
||||||
const storage = createMemoryStorage()
|
const storage = createMemoryStorage()
|
||||||
const scopes = ['全部', '申请单', '报销单', '审核单', '归档']
|
const scopes = ['全部', '申请单', '报销单', '审核单', '归档']
|
||||||
|
|||||||
@@ -52,22 +52,30 @@ test('sidebar no longer renders document center unread indicators', () => {
|
|||||||
|
|
||||||
test('topbar bell owns document center unread notifications', () => {
|
test('topbar bell owns document center unread notifications', () => {
|
||||||
assert.match(topbar, /useDocumentCenterInbox/)
|
assert.match(topbar, /useDocumentCenterInbox/)
|
||||||
assert.match(topbar, /unreadCount: documentInboxUnreadCount/)
|
assert.match(topbar, /notificationRows: documentInboxNotificationRows/)
|
||||||
assert.match(topbar, /const workbenchNotificationCount = computed/)
|
assert.match(topbar, /const documentNotificationItems = computed/)
|
||||||
assert.match(topbar, /const count = workbenchNotificationCount\.value \+ Number\(documentInboxUnreadCount\.value \|\| 0\)/)
|
assert.match(topbar, /title: `\$\{row\.documentTypeLabel \|\| '单据'\} \$\{row\.documentNo \|\| row\.claimId \|\| '待生成'\}`/)
|
||||||
assert.match(topbar, /const documentInboxNotification = computed/)
|
assert.match(topbar, /description: resolveDocumentNotificationDescription\(row\)/)
|
||||||
assert.match(topbar, /id: 'document-center-unread'/)
|
assert.match(topbar, /markDocumentInboxRowRead\(item\.documentRow\)/)
|
||||||
assert.match(topbar, /title: '单据中心有新单据'/)
|
assert.match(topbar, /markDocumentInboxRowsRead\(documentRows\)/)
|
||||||
assert.match(topbar, /target: \{ type: 'documents-center' \}/)
|
assert.match(topbar, /const topbarNotificationCount = computed\(\(\) => \{[\s\S]*const count = unreadNotifications\.value\.length/)
|
||||||
assert.match(topbar, /emit\('navigate', 'documents'\)/)
|
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(appShellRouteView, /@navigate="handleNavigate"/)
|
||||||
assert.match(topbar, /startDocumentInboxPolling\(\)/)
|
assert.match(topbar, /startDocumentInboxPolling\(\)/)
|
||||||
assert.match(topbar, /stopDocumentInboxPolling\(\)/)
|
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(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-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-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-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/)
|
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, /DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
|
||||||
assert.match(documentInbox, /readViewedDocumentKeys/)
|
assert.match(documentInbox, /readViewedDocumentKeys/)
|
||||||
assert.match(documentInbox, /countNewDocuments\(documentRows\.value, viewedDocumentKeys\.value\)/)
|
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, /fetchExpenseClaims/)
|
||||||
assert.match(documentInbox, /fetchApprovalExpenseClaims/)
|
assert.match(documentInbox, /fetchApprovalExpenseClaims/)
|
||||||
assert.match(documentInbox, /fetchArchivedExpenseClaims/)
|
assert.match(documentInbox, /fetchArchivedExpenseClaims/)
|
||||||
|
|||||||
Reference in New Issue
Block a user