diff --git a/web/src/assets/styles/components/document-list-shared.css b/web/src/assets/styles/components/document-list-shared.css index b8187c2..fa63b44 100644 --- a/web/src/assets/styles/components/document-list-shared.css +++ b/web/src/assets/styles/components/document-list-shared.css @@ -16,6 +16,7 @@ color: #64748b; font-size: 14px; font-weight: 750; + overflow: visible; } .status-tabs button.active { @@ -33,20 +34,33 @@ background: var(--theme-primary); } +.scope-tab-label { + position: relative; + display: inline-flex; + align-items: center; + line-height: 1.2; +} + .scope-tab-badge { - min-width: 18px; - height: 18px; + position: absolute; + top: -8px; + right: -15px; + min-width: 15px; + height: 15px; display: inline-flex; align-items: center; justify-content: center; - padding: 0 5px; + padding: 0 4px; + border: 1px solid #fff; border-radius: 999px; background: #ef4444; color: #fff; - font-size: 11px; + font-size: 10px; font-weight: 850; line-height: 1; - box-shadow: 0 6px 14px rgba(239, 68, 68, 0.22); + box-shadow: + 0 0 0 2px rgba(239, 68, 68, 0.1), + 0 6px 14px rgba(239, 68, 68, 0.22); } .document-toolbar { @@ -167,6 +181,38 @@ filter: none; } +.mark-read-btn { + min-height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 12px; + border: 1px solid #fecaca; + border-radius: 4px; + background: #fff; + color: #dc2626; + font-size: 13px; + font-weight: 800; + white-space: nowrap; + transition: + border-color 160ms ease, + background 160ms ease, + color 160ms ease, + box-shadow 160ms ease; +} + +.mark-read-btn .mdi { + font-size: 16px; +} + +.mark-read-btn:hover { + border-color: #fca5a5; + background: #fff5f5; + color: #b91c1c; + box-shadow: 0 8px 18px rgba(239, 68, 68, 0.1); +} + .table-wrap { min-height: 400px; margin-top: 10px; @@ -495,6 +541,7 @@ td small { .document-actions, .list-search, .filter-btn, + .mark-read-btn, .page-size-select { width: 100%; } diff --git a/web/src/assets/styles/components/sidebar-rail.css b/web/src/assets/styles/components/sidebar-rail.css index 5004b53..45654ea 100644 --- a/web/src/assets/styles/components/sidebar-rail.css +++ b/web/src/assets/styles/components/sidebar-rail.css @@ -216,11 +216,14 @@ flex: 1; min-width: 0; max-width: 128px; + position: relative; + display: inline-flex; + align-items: center; color: currentColor; font-size: 14px; font-weight: 700; white-space: nowrap; - overflow: hidden; + overflow: visible; opacity: 1; transition: max-width var(--rail-motion-duration) var(--rail-motion-ease), @@ -229,6 +232,16 @@ will-change: max-width, opacity, transform; } +.nav-label-text { + position: relative; + min-width: 0; + max-width: 100%; + display: inline-flex; + align-items: center; + overflow: visible; + line-height: 1.2; +} + .nav-badge { flex: 0 0 auto; min-width: 34px; @@ -251,13 +264,24 @@ } .nav-unread-dot { - flex: 0 0 auto; - width: 8px; - height: 8px; + width: 9px; + height: 9px; border: 2px solid #fff; border-radius: 999px; background: #ef4444; - box-shadow: 0 6px 14px rgba(239, 68, 68, 0.26); + box-shadow: + 0 0 0 3px rgba(239, 68, 68, 0.12), + 0 6px 14px rgba(239, 68, 68, 0.32); +} + +.nav-unread-dot-label { + position: absolute; + top: -8px; + right: -10px; +} + +.nav-unread-dot-collapsed { + display: none; } .rail-user { @@ -469,8 +493,13 @@ transition-delay: 0ms; } -.rail-collapsed .nav-unread-dot { +.rail-collapsed .nav-unread-dot-label { + display: none; +} + +.rail-collapsed .nav-unread-dot-collapsed { position: absolute; + display: block; top: 10px; right: 11px; width: 9px; diff --git a/web/src/components/layout/SidebarRail.vue b/web/src/components/layout/SidebarRail.vue index cbf3982..d37755c 100644 --- a/web/src/components/layout/SidebarRail.vue +++ b/web/src/components/layout/SidebarRail.vue @@ -50,8 +50,13 @@ @click="emit('navigate', item.id)" > - {{ item.displayLabel }} - + + + {{ item.displayLabel }} + + + + {{ item.badge }} diff --git a/web/src/utils/documentCenterNewState.js b/web/src/utils/documentCenterNewState.js index 2423174..87a9864 100644 --- a/web/src/utils/documentCenterNewState.js +++ b/web/src/utils/documentCenterNewState.js @@ -77,3 +77,26 @@ export function markDocumentViewed(row, viewedKeys, storage = getStorage()) { writeViewedDocumentKeys(nextKeys, storage) return nextKeys } + +export function markDocumentsViewed(rows, viewedKeys, storage = getStorage()) { + const nextKeys = new Set(viewedKeys) + let changed = false + + ;(Array.isArray(rows) ? rows : []).forEach((row) => { + if (!isNewDocument(row, nextKeys)) { + return + } + + const key = resolveDocumentNewKey(row) + if (key) { + nextKeys.add(key) + changed = true + } + }) + + if (changed) { + writeViewedDocumentKeys(nextKeys, storage) + } + + return nextKeys +} diff --git a/web/src/views/DocumentsCenterView.vue b/web/src/views/DocumentsCenterView.vue index 68a20af..cbd0107 100644 --- a/web/src/views/DocumentsCenterView.vue +++ b/web/src/views/DocumentsCenterView.vue @@ -9,9 +9,11 @@ :class="{ active: activeScopeTab === tab.value }" @click="activeScopeTab = tab.value" > - {{ tab.label }} - - {{ tab.badgeCount > 99 ? '99+' : tab.badgeCount }} + + {{ tab.label }} + + {{ tab.badgeCount > 99 ? '99+' : tab.badgeCount }} + @@ -122,7 +124,16 @@ - + + + + 一键已读 + 发起申请 @@ -238,7 +249,7 @@ import TableLoadingState from '../components/shared/TableLoadingState.vue' import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js' import { mapExpenseClaimToRequest } from '../composables/useRequests.js' import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims } from '../services/reimbursements.js' -import { countNewDocuments, isNewDocument, markDocumentViewed, readDocumentScope, readViewedDocumentKeys, writeDocumentScope } from '../utils/documentCenterNewState.js' +import { countNewDocuments, isNewDocument, markDocumentViewed, markDocumentsViewed, readDocumentScope, readViewedDocumentKeys, writeDocumentScope } from '../utils/documentCenterNewState.js' import { extractDateText, formatDocumentListTime, resolveDocumentSortTime, resolveDocumentStayTimeDisplay } from '../utils/documentCenterTime.js' import { excludeArchivedDocumentRows, filterApplicationScopeNewRows, isArchivedDocumentRow, prepareApplicationScopeRows } from '../utils/documentCenterRows.js' import { normalizeRequestForUi } from '../utils/requestViewModel.js' @@ -468,6 +479,17 @@ const scopeTabItems = computed(() => badgeCount: scopeNewCountMap.value[tab] || 0 })) ) +const allReadableDocumentRows = computed(() => [ + ...nonArchivedRows.value, + ...filterApplicationScopeNewRows(applicationScopeRows.value), + ...ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT), + ...approvalRows.value +]) +const totalNewDocumentCount = computed(() => countNewDocuments(allReadableDocumentRows.value, viewedDocumentKeys.value)) +const showCreateDocumentActions = computed(() => + [DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT].includes(activeScopeTab.value) +) +const showToolbarActions = computed(() => showCreateDocumentActions.value || totalNewDocumentCount.value > 0) const activeScopeRows = computed(() => { if (activeScopeTab.value === DOCUMENT_SCOPE_ALL) return nonArchivedRows.value @@ -831,6 +853,14 @@ function openDocument(row) { emit('open-document', row.rawRequest || row) } +function markAllDocumentsRead() { + if (!totalNewDocumentCount.value) { + return + } + + viewedDocumentKeys.value = markDocumentsViewed(allReadableDocumentRows.value, viewedDocumentKeys.value) +} + async function loadSupportingRows() { supportingLoading.value = true supportingError.value = '' diff --git a/web/tests/document-center-new-state.test.mjs b/web/tests/document-center-new-state.test.mjs index 0d201ba..8f6570c 100644 --- a/web/tests/document-center-new-state.test.mjs +++ b/web/tests/document-center-new-state.test.mjs @@ -5,6 +5,7 @@ import { countNewDocuments, isNewDocument, markDocumentViewed, + markDocumentsViewed, readDocumentScope, readViewedDocumentKeys, resolveDocumentNewKey, @@ -47,6 +48,19 @@ test('document center new state counts unseen documents and persists viewed rows assert.deepEqual([...readViewedDocumentKeys(storage)], ['owned:claim-1']) }) +test('document center new state can mark all unread rows as viewed at once', () => { + const storage = createMemoryStorage() + const rows = [ + { source: 'owned', claimId: 'claim-1' }, + { source: 'approval', claimId: 'claim-2' }, + { source: 'archive', claimId: 'claim-3' } + ] + const viewedKeys = markDocumentsViewed(rows, readViewedDocumentKeys(storage), storage) + + assert.equal(countNewDocuments(rows, viewedKeys), 0) + assert.deepEqual([...readViewedDocumentKeys(storage)], ['owned:claim-1', 'approval:claim-2']) +}) + test('document center archive rows are never marked as new', () => { const viewedKeys = readViewedDocumentKeys(createMemoryStorage()) const rows = [ diff --git a/web/tests/documents-center-status-filter.test.mjs b/web/tests/documents-center-status-filter.test.mjs index 7bd6784..6095e21 100644 --- a/web/tests/documents-center-status-filter.test.mjs +++ b/web/tests/documents-center-status-filter.test.mjs @@ -137,9 +137,10 @@ test('documents center list shows created time and conditional stay time columns }) test('documents center action buttons are scoped to application and reimbursement tabs', () => { + assert.match(documentsCenterView, /v-if="showToolbarActions" class="document-actions"/) assert.match( documentsCenterView, - /v-if="\[DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT\]\.includes\(activeScopeTab\)"[\s\S]*class="document-actions"/ + /const showCreateDocumentActions = computed\(\(\) =>[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REIMBURSEMENT/ ) assert.match( documentsCenterView, @@ -156,6 +157,7 @@ test('documents center category tabs render bubble counts for new documents', () assert.match(documentsCenterView, /readViewedDocumentKeys/) assert.match(documentsCenterView, /const viewedDocumentKeys = ref\(readViewedDocumentKeys\(\)\)/) assert.match(documentsCenterView, /v-for="tab in scopeTabItems"/) + assert.match(documentsCenterView, //) assert.match(documentsCenterView, / 99 \? '99\+' : tab\.badgeCount/) assert.match(documentsCenterView, /const scopeNewCountMap = computed\(\(\) => \(\{/) @@ -178,6 +180,27 @@ test('documents center category tabs render bubble counts for new documents', () documentsCenterView, /const scopeTabItems = computed\(\(\) =>[\s\S]*badgeCount: scopeNewCountMap\.value\[tab\] \|\| 0/ ) + assert.match(documentListSharedStyles, /\.scope-tab-label\s*\{[\s\S]*position:\s*relative;/) + assert.match(documentListSharedStyles, /\.scope-tab-badge\s*\{[\s\S]*position:\s*absolute;[\s\S]*top:\s*-8px;[\s\S]*height:\s*15px;/) +}) + +test('documents center can mark all unread documents as read from toolbar', () => { + assert.match(documentsCenterView, /markDocumentsViewed/) + assert.match( + documentsCenterView, + /const allReadableDocumentRows = computed\(\(\) => \[[\s\S]*nonArchivedRows\.value[\s\S]*filterApplicationScopeNewRows\(applicationScopeRows\.value\)[\s\S]*approvalRows\.value/ + ) + assert.match(documentsCenterView, /const totalNewDocumentCount = computed\(\(\) => countNewDocuments\(allReadableDocumentRows\.value, viewedDocumentKeys\.value\)\)/) + assert.match(documentsCenterView, /const showToolbarActions = computed\(\(\) => showCreateDocumentActions\.value \|\| totalNewDocumentCount\.value > 0\)/) + assert.match( + documentsCenterView, + / 0"[\s\S]*class="mark-read-btn"[\s\S]*@click="markAllDocumentsRead"[\s\S]*一键已读/ + ) + assert.match( + documentsCenterView, + /function markAllDocumentsRead\(\) \{[\s\S]*viewedDocumentKeys\.value = markDocumentsViewed\(allReadableDocumentRows\.value, viewedDocumentKeys\.value\)/ + ) + assert.match(documentListSharedStyles, /\.mark-read-btn\s*\{[\s\S]*border:\s*1px solid #fecaca;/) }) test('documents center rows show NEW marker until the row is opened', () => { diff --git a/web/tests/sidebar-document-unread-dot.test.mjs b/web/tests/sidebar-document-unread-dot.test.mjs index 52afa5f..824be1e 100644 --- a/web/tests/sidebar-document-unread-dot.test.mjs +++ b/web/tests/sidebar-document-unread-dot.test.mjs @@ -8,6 +8,11 @@ const sidebar = readFileSync( 'utf8' ) +const sidebarStyles = readFileSync( + fileURLToPath(new URL('../src/assets/styles/components/sidebar-rail.css', import.meta.url)), + 'utf8' +) + const documentInbox = readFileSync( fileURLToPath(new URL('../src/composables/useDocumentCenterInbox.js', import.meta.url)), 'utf8' @@ -21,13 +26,16 @@ const documentNewState = readFileSync( test('sidebar renders a red dot for unread document center rows', () => { assert.match(sidebar, /useDocumentCenterInbox/) assert.match(sidebar, /hasUnread: documentInboxHasUnread/) - assert.match(sidebar, /<\/span>/) + assert.match(sidebar, /class="nav-label-text"[\s\S]*class="nav-unread-dot nav-unread-dot-label"/) + assert.match(sidebar, /class="nav-unread-dot nav-unread-dot-collapsed"/) assert.match(sidebar, /hasNewMessage: item\.id === 'documents' \? documentInboxHasUnread\.value : false/) assert.match(sidebar, /void refreshDocumentInbox\(\)/) assert.match(sidebar, /startDocumentInboxPolling\(\)/) assert.match(sidebar, /stopDocumentInboxPolling\(\)/) - assert.match(sidebar, /\.nav-unread-dot\s*\{[\s\S]*background:\s*#ef4444;/) - assert.match(sidebar, /\.rail-collapsed \.nav-unread-dot\s*\{[\s\S]*position:\s*absolute;/) + assert.match(sidebarStyles, /\.nav-label-text\s*\{[\s\S]*position:\s*relative;/) + assert.match(sidebarStyles, /\.nav-unread-dot\s*\{[\s\S]*background:\s*#ef4444;/) + assert.match(sidebarStyles, /\.nav-unread-dot-label\s*\{[\s\S]*position:\s*absolute;[\s\S]*top:\s*-8px;/) + assert.match(sidebarStyles, /\.rail-collapsed \.nav-unread-dot-collapsed\s*\{[\s\S]*position:\s*absolute;/) }) test('document inbox reuses document center viewed-key state', () => {