diff --git a/document/development/2026-07-20/dev-logs/bugs/expense-operation-record-icon-enterprise-ui.md b/document/development/2026-07-20/dev-logs/bugs/expense-operation-record-icon-enterprise-ui.md new file mode 100644 index 0000000..7757207 --- /dev/null +++ b/document/development/2026-07-20/dev-logs/bugs/expense-operation-record-icon-enterprise-ui.md @@ -0,0 +1,15 @@ +# 费用操作记录图标缺失与企业级样式收敛 + +日期:2026-07-20 +文档路径:document/development/2026-07-20/dev-logs/bugs/expense-operation-record-icon-enterprise-ui.md + +## 修复记录 +- 09:58:修复费用操作记录 AI 预审图标缺失,并将记录区收敛为企业级审计日志样式。 + - Git 提交检查:`git fetch --all --prune` 成功;upstream 为 `origin/main`;`HEAD..origin/main` 无新提交;本地相对 upstream ahead 19 个提交。 + - 本地 ahead 摘要:`07241b46 fix(docker): manage local postgres in default compose`;`787bc3a4 feat(platform): close AI expense value loop`;`242d68c3 feat(approval): add task workflow and waiver decisions`;`28b834ed fix(approval): replay immutable action responses`;`4940ebc4 feat(approval): add safe risk disposition workflow`;`ee88a36b feat(ai): add tenant-safe hierarchical expense learning`;`6bdf65bc feat(expenses): add authoritative pre-review workflow`;`ae3f02c3 feat(expense): add persistent zero-entry receipt association`;`54754b55 feat(ai): add personal expense application memory`;`211f85d9 feat(ai): unify verified expense application workflow`;`5b246307 feat(ai): issue verified application preview decisions`;`a662cfe6 feat(ai): add expense application feedback ledger`;`5ed34c2b feat(expenses): backfill historical claims into expense cases`;`11275e4b fix(migrations): enforce schema ownership safety`;`1347366b feat(expenses): secure timeline and draft events`;`22669a90 feat(expenses): show unified expense event timeline`;`a616b30c fix(expenses): unify AI application submission transaction`;`653eda05 feat(auth): add opaque bearer sessions`;`661990b2 feat(expenses): add transactional expense case events`。 + - 修改:将 MDI 7.4.47 不存在的 `mdi-shield-search-outline` 替换为 `mdi-shield-check-outline`,并把申请/报销提交图标改为语义更准确的 `mdi-file-send-outline`;补充图标映射回归断言。 + - 修改:将费用操作记录改为“发生时间 / 事件 / 操作人”三列审计日志,图标并入事件列;移除绿色“最新”成功态误用,改为中性记录数和“最新优先”元数据;统一紧凑行高、方形状态图标、标准字重、语义色与显式响应式网格。 + - 修改:补充 `aria-labelledby`、`aria-busy`、图标读屏隐藏、操作人完整值提示和刷新按钮键盘焦点样式;事件倒序排序仅对有限时间戳执行数值比较。 + - 操作:通过项目脚本写入本 bug 日志;生成桌面最终截图、聚焦截图和设计对照证据,并更新根目录 `design-qa.md`。 + - 验证:`node --test --test-timeout=60000 tests/expense-case-timeline.test.mjs` 通过,13/13;`npm run build` 通过;`git diff --check` 通过;真实申请 `AT4UHTKWU` 浏览器刷新后仍展示两条记录、三列表头与两个有效图标,未进入加载失败状态。 + - 影响:修复费用申请与报销单 AI 预审事件的空白图标;统一费用操作记录的信息密度、状态语义、可访问性和企业后台视觉风格,不改变后端事件协议或审批业务顺序。 diff --git a/web/src/assets/styles/components/travel-request-expense-case-timeline.css b/web/src/assets/styles/components/travel-request-expense-case-timeline.css index 49834e6..447bc22 100644 --- a/web/src/assets/styles/components/travel-request-expense-case-timeline.css +++ b/web/src/assets/styles/components/travel-request-expense-case-timeline.css @@ -2,13 +2,12 @@ min-width: 0; margin-top: 18px; padding-top: 18px; - border-top: 1px solid #e2e8f0; + border-top: 1px solid var(--line); } .expense-case-timeline-head, .expense-case-timeline-title-row, -.expense-case-meta, -.expense-case-event-meta { +.expense-case-meta { display: flex; align-items: center; } @@ -25,26 +24,26 @@ .expense-case-timeline h4 { margin: 0; - color: #0f172a; + color: var(--ink); font-size: 14px; - font-weight: 850; + font-weight: 700; } .expense-case-timeline-head p { margin: 6px 0 0; - color: #64748b; + color: var(--muted); font-size: 12px; line-height: 1.55; } .expense-case-stage { - padding: 3px 8px; + padding: 2px 7px; border: 1px solid rgba(var(--theme-primary-rgb), .2); - border-radius: 999px; + border-radius: 4px; background: var(--theme-primary-soft); color: var(--theme-primary-active); font-size: 11px; - font-weight: 800; + font-weight: 700; } .expense-case-meta { @@ -55,22 +54,24 @@ .expense-case-meta span + span::before { content: "·"; margin: 0 7px; - color: #cbd5e1; + color: var(--line); } .expense-case-refresh { flex: 0 0 auto; display: inline-flex; align-items: center; + justify-content: center; gap: 6px; - padding: 7px 11px; - border: 1px solid #dbe4ee; - border-radius: 8px; - background: #fff; - color: #475569; + min-height: 36px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 4px; + background: var(--surface); + color: var(--text); font: inherit; font-size: 12px; - font-weight: 750; + font-weight: 600; cursor: pointer; } @@ -79,6 +80,11 @@ color: var(--theme-primary-active); } +.expense-case-refresh:focus-visible { + outline: 2px solid var(--theme-primary); + outline-offset: 2px; +} + .expense-case-refresh:disabled { cursor: wait; opacity: .62; @@ -95,10 +101,10 @@ gap: 12px; min-height: 58px; padding: 12px 14px; - border: 1px dashed #cbd5e1; - border-radius: 10px; - background: #f8fafc; - color: #64748b; + border: 1px dashed var(--line); + border-radius: 6px; + background: var(--surface-soft); + color: var(--muted); font-size: 12px; } @@ -116,32 +122,57 @@ } .expense-case-state strong { - color: #334155; + color: var(--text); font-size: 12px; } .expense-case-state.error { - border-color: #fecaca; - background: #fff7f7; + border-color: var(--danger-line); + background: var(--danger-soft); } .expense-case-state.error > i { - color: #dc2626; + color: var(--danger); } .expense-case-state button { flex: 0 0 auto; padding: 6px 9px; border: 0; - border-radius: 7px; + border-radius: 4px; background: var(--theme-primary); color: #fff; font: inherit; font-size: 11px; - font-weight: 800; + font-weight: 700; cursor: pointer; } +.expense-case-event-table { + overflow: hidden; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface); +} + +.expense-case-event-columns, +.expense-case-event { + display: grid; + grid-template-columns: 148px minmax(280px, 1fr) minmax(160px, 200px); + align-items: center; + column-gap: 20px; +} + +.expense-case-event-columns { + min-height: 34px; + padding: 0 16px; + border-bottom: 1px solid var(--line); + background: var(--surface-soft); + color: var(--muted); + font-size: 11px; + font-weight: 600; +} + .expense-case-event-list { margin: 0; padding: 0; @@ -149,84 +180,110 @@ } .expense-case-event { - position: relative; - display: grid; - grid-template-columns: 34px minmax(0, 1fr); - gap: 12px; - padding: 0 0 18px; + min-height: 70px; + padding: 12px 16px; + border-bottom: 1px solid var(--line); } .expense-case-event:last-child { - padding-bottom: 0; + border-bottom: 0; } -.expense-case-event:not(:last-child)::before { - content: ""; - position: absolute; - top: 31px; - bottom: 2px; - left: 16px; - width: 2px; - background: #e2e8f0; +.expense-case-event-main { + min-width: 0; + display: flex; + align-items: center; + gap: 12px; } .expense-case-event-icon { - position: relative; - z-index: 1; - width: 34px; - height: 34px; + flex: 0 0 28px; + width: 28px; + height: 28px; display: grid; place-items: center; border: 1px solid rgba(var(--theme-primary-rgb), .2); - border-radius: 999px; + border-radius: 5px; background: var(--theme-primary-soft); color: var(--theme-primary-active); - font-size: 17px; + font-size: 16px; +} + +.expense-case-event-icon > i { + display: block; + line-height: 1; } .expense-case-event.success .expense-case-event-icon { - border-color: #bbf7d0; - background: #f0fdf4; - color: #15803d; + border-color: var(--success-line); + background: var(--success-soft); + color: var(--success); } .expense-case-event.warning .expense-case-event-icon { - border-color: #fed7aa; - background: #fff7ed; - color: #c2410c; + border-color: var(--warning-line); + background: var(--warning-soft); + color: var(--warning); +} + +.expense-case-event.danger .expense-case-event-icon { + border-color: var(--danger-line); + background: var(--danger-soft); + color: var(--danger); +} + +.expense-case-event-time { + color: var(--muted); + font-size: 12px; + font-variant-numeric: tabular-nums; + font-weight: 500; + line-height: 1.4; + white-space: nowrap; } .expense-case-event-copy { min-width: 0; - padding-top: 1px; } .expense-case-event-copy > strong { display: block; - color: #1e293b; + color: var(--ink); font-size: 13px; + font-weight: 600; line-height: 1.45; } .expense-case-event-copy > p { - margin: 4px 0 7px; - color: #475569; + margin: 4px 0 0; + color: var(--text); font-size: 12px; line-height: 1.55; overflow-wrap: anywhere; } -.expense-case-event-meta { - flex-wrap: wrap; - gap: 6px 14px; - color: #94a3b8; +.expense-case-event-actor { + min-width: 0; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 5px; + color: var(--muted); font-size: 11px; } -.expense-case-event-meta span { - display: inline-flex; - align-items: center; - gap: 4px; +.expense-case-event-actor span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 960px) { + .expense-case-event-columns, + .expense-case-event { + grid-template-columns: 132px minmax(220px, 1fr) minmax(140px, 168px); + column-gap: 14px; + } } @media (max-width: 720px) { @@ -239,7 +296,8 @@ } .expense-case-refresh { - padding: 7px 9px; + width: 36px; + padding: 0; } .expense-case-state { @@ -250,4 +308,44 @@ .expense-case-state button { margin-left: 33px; } + + .expense-case-timeline-body { + margin-top: 12px; + } + + .expense-case-event-columns { + display: none; + } + + .expense-case-event-table { + border-radius: 5px; + } + + .expense-case-event { + grid-template-columns: minmax(0, 1fr); + align-items: start; + gap: 7px; + min-height: 0; + padding: 13px 14px; + } + + .expense-case-event-time { + grid-column: 1; + grid-row: 2; + padding-left: 40px; + font-size: 11px; + } + + .expense-case-event-main { + grid-column: 1; + grid-row: 1; + align-items: flex-start; + } + + .expense-case-event-actor { + grid-column: 1; + grid-row: 3; + justify-content: flex-start; + padding-left: 40px; + } } diff --git a/web/src/components/travel/TravelRequestExpenseCaseTimeline.vue b/web/src/components/travel/TravelRequestExpenseCaseTimeline.vue index 9bd0409..4133ef9 100644 --- a/web/src/components/travel/TravelRequestExpenseCaseTimeline.vue +++ b/web/src/components/travel/TravelRequestExpenseCaseTimeline.vue @@ -1,14 +1,16 @@ diff --git a/web/src/utils/expenseCaseTimeline.js b/web/src/utils/expenseCaseTimeline.js index 0cae21e..6df5cb5 100644 --- a/web/src/utils/expenseCaseTimeline.js +++ b/web/src/utils/expenseCaseTimeline.js @@ -27,8 +27,8 @@ const EVENT_PRESENTATION = { claim_draft_created: { label: '报销草稿已创建', icon: 'mdi mdi-file-plus-outline', tone: 'info' }, claim_draft_updated: { label: '报销草稿已更新', icon: 'mdi mdi-file-edit-outline', tone: 'info' }, historical_claim_imported: { label: '历史单据已纳入统一费用事件', icon: 'mdi mdi-history', tone: 'info' }, - application_submitted: { label: '费用申请已提交', icon: 'mdi mdi-send-outline', tone: 'info' }, - claim_submitted: { label: '报销单已提交', icon: 'mdi mdi-send-outline', tone: 'info' }, + application_submitted: { label: '费用申请已提交', icon: 'mdi mdi-file-send-outline', tone: 'info' }, + claim_submitted: { label: '报销单已提交', icon: 'mdi mdi-file-send-outline', tone: 'info' }, approval_stage_completed: { label: '审批节点已完成', icon: 'mdi mdi-account-check-outline', tone: 'success' }, application_approved: { label: '费用申请已审批', icon: 'mdi mdi-check-decagram-outline', tone: 'success' }, claim_approved: { label: '报销单已审批', icon: 'mdi mdi-check-decagram-outline', tone: 'success' }, @@ -37,8 +37,8 @@ const EVENT_PRESENTATION = { reimbursement_draft_generated: { label: '报销草稿已自动生成', icon: 'mdi mdi-robot-outline', tone: 'success' }, receipt_received: { label: '票据已进入费用事件', icon: 'mdi mdi-receipt-text-plus-outline', tone: 'info' }, attachment_associated: { label: '票据已归集到报销草稿', icon: 'mdi mdi-file-link-outline', tone: 'success' }, - application_pre_review_completed: { label: '费用申请 AI 预审已完成', icon: 'mdi mdi-shield-search-outline', tone: 'success' }, - claim_pre_review_completed: { label: '报销单 AI 预审已完成', icon: 'mdi mdi-shield-search-outline', tone: 'success' }, + application_pre_review_completed: { label: '费用申请 AI 预审已完成', icon: 'mdi mdi-shield-check-outline', tone: 'success' }, + claim_pre_review_completed: { label: '报销单 AI 预审已完成', icon: 'mdi mdi-shield-check-outline', tone: 'success' }, payment_completed: { label: '付款已完成', icon: 'mdi mdi-cash-check', tone: 'success' }, application_archived: { label: '费用申请已归档', icon: 'mdi mdi-archive-check-outline', tone: 'success' } } @@ -228,7 +228,7 @@ function normalizeEvent(event, index) { export function buildExpenseCaseTimelineViewModel(payload = {}) { const events = Array.isArray(payload?.events) ? payload.events.map(normalizeEvent).sort((left, right) => { - if (left.sortTimestamp !== null && right.sortTimestamp !== null) { + if (Number.isFinite(left.sortTimestamp) && Number.isFinite(right.sortTimestamp)) { return left.sortTimestamp - right.sortTimestamp || left.sourceIndex - right.sourceIndex } return left.sourceIndex - right.sourceIndex @@ -247,6 +247,17 @@ export function buildExpenseCaseTimelineViewModel(payload = {}) { } } +export function orderExpenseCaseEventsForDisplay(events = []) { + return Array.isArray(events) + ? [...events].sort((left, right) => { + if (Number.isFinite(left.sortTimestamp) && Number.isFinite(right.sortTimestamp)) { + return right.sortTimestamp - left.sortTimestamp || left.sourceIndex - right.sourceIndex + } + return left.sourceIndex - right.sourceIndex + }) + : [] +} + export function isExpenseCaseNotAvailableError(error) { return Number(error?.status) === 404 } diff --git a/web/tests/expense-case-timeline.test.mjs b/web/tests/expense-case-timeline.test.mjs index 7d64b86..0a6515e 100644 --- a/web/tests/expense-case-timeline.test.mjs +++ b/web/tests/expense-case-timeline.test.mjs @@ -6,7 +6,8 @@ import { effectScope, nextTick, ref } from 'vue' import { buildExpenseCaseTimelineViewModel, - isExpenseCaseNotAvailableError + isExpenseCaseNotAvailableError, + orderExpenseCaseEventsForDisplay } from '../src/utils/expenseCaseTimeline.js' import { fetchExpenseCaseTimeline } from '../src/services/expenseCases.js' import { useExpenseCaseTimeline } from '../src/composables/requests/useExpenseCaseTimeline.js' @@ -69,6 +70,7 @@ test('expense case timeline maps and sorts the real business event chain', () => assert.equal(timeline.linkCount, 2) assert.deepEqual(timeline.events.map((event) => event.id), ['event-1', 'event-2']) assert.equal(timeline.events[0].label, '费用申请已提交') + assert.equal(timeline.events[0].icon, 'mdi mdi-file-send-outline') assert.match(timeline.events[0].summary, /草稿 → 已提交/) assert.match(timeline.events[0].summary, /直属领导审批/) assert.equal(timeline.events[0].actorLabel, 'employee@example.com') @@ -139,6 +141,7 @@ test('expense case timeline explains receipt collection, association and AI pre- assert.match(timeline.events[0].summary, /上海出差高铁票\.pdf/) assert.match(timeline.events[1].summary, /BX-20260713-001/) assert.equal(timeline.events[2].tone, 'warning') + assert.equal(timeline.events[2].icon, 'mdi mdi-shield-check-outline') assert.match(timeline.events[2].summary, /1 条重大风险/) }) @@ -246,6 +249,37 @@ test('expense case timeline keeps equal-time server order and hides unknown even assert.doesNotMatch(JSON.stringify(timeline.events[0]), /private-correlation|pending/) }) +test('expense case records show newer events first and preserve equal-time server order', () => { + const timeline = buildExpenseCaseTimelineViewModel({ + events: [ + { + id: 'older', + event_type: 'application_submitted', + occurred_at: '2026-07-17T14:54:00Z' + }, + { + id: 'newer-first', + event_type: 'application_pre_review_completed', + occurred_at: '2026-07-18T02:00:00Z' + }, + { + id: 'newer-second', + event_type: 'claim_submitted', + occurred_at: '2026-07-18T02:00:00Z' + } + ] + }) + + const displayedEvents = orderExpenseCaseEventsForDisplay(timeline.events) + + assert.deepEqual( + displayedEvents.map((event) => event.id), + ['newer-first', 'newer-second', 'older'] + ) + assert.equal(displayedEvents[0].icon, 'mdi mdi-shield-check-outline') + assert.deepEqual(timeline.events.map((event) => event.id), ['older', 'newer-first', 'newer-second']) +}) + test('expense case service safely encodes claim identifiers', async () => { const originalFetch = globalThis.fetch let requestedUrl = '' @@ -351,7 +385,11 @@ test('travel request detail consumes the real expense case endpoint', () => { assert.match(detailView, /:refresh-key="expenseCaseTimelineRefreshKey"/) assert.match(progressCard, /