fix(expenses): refine operation record audit UI

This commit is contained in:
caoxiaozhu
2026-07-20 10:31:27 +08:00
parent 2a9b3e3273
commit 2a1a5991cb
5 changed files with 278 additions and 96 deletions

View File

@@ -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 预审事件的空白图标;统一费用操作记录的信息密度、状态语义、可访问性和企业后台视觉风格,不改变后端事件协议或审批业务顺序。

View File

@@ -2,13 +2,12 @@
min-width: 0; min-width: 0;
margin-top: 18px; margin-top: 18px;
padding-top: 18px; padding-top: 18px;
border-top: 1px solid #e2e8f0; border-top: 1px solid var(--line);
} }
.expense-case-timeline-head, .expense-case-timeline-head,
.expense-case-timeline-title-row, .expense-case-timeline-title-row,
.expense-case-meta, .expense-case-meta {
.expense-case-event-meta {
display: flex; display: flex;
align-items: center; align-items: center;
} }
@@ -25,26 +24,26 @@
.expense-case-timeline h4 { .expense-case-timeline h4 {
margin: 0; margin: 0;
color: #0f172a; color: var(--ink);
font-size: 14px; font-size: 14px;
font-weight: 850; font-weight: 700;
} }
.expense-case-timeline-head p { .expense-case-timeline-head p {
margin: 6px 0 0; margin: 6px 0 0;
color: #64748b; color: var(--muted);
font-size: 12px; font-size: 12px;
line-height: 1.55; line-height: 1.55;
} }
.expense-case-stage { .expense-case-stage {
padding: 3px 8px; padding: 2px 7px;
border: 1px solid rgba(var(--theme-primary-rgb), .2); border: 1px solid rgba(var(--theme-primary-rgb), .2);
border-radius: 999px; border-radius: 4px;
background: var(--theme-primary-soft); background: var(--theme-primary-soft);
color: var(--theme-primary-active); color: var(--theme-primary-active);
font-size: 11px; font-size: 11px;
font-weight: 800; font-weight: 700;
} }
.expense-case-meta { .expense-case-meta {
@@ -55,22 +54,24 @@
.expense-case-meta span + span::before { .expense-case-meta span + span::before {
content: "·"; content: "·";
margin: 0 7px; margin: 0 7px;
color: #cbd5e1; color: var(--line);
} }
.expense-case-refresh { .expense-case-refresh {
flex: 0 0 auto; flex: 0 0 auto;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center;
gap: 6px; gap: 6px;
padding: 7px 11px; min-height: 36px;
border: 1px solid #dbe4ee; padding: 0 12px;
border-radius: 8px; border: 1px solid var(--line);
background: #fff; border-radius: 4px;
color: #475569; background: var(--surface);
color: var(--text);
font: inherit; font: inherit;
font-size: 12px; font-size: 12px;
font-weight: 750; font-weight: 600;
cursor: pointer; cursor: pointer;
} }
@@ -79,6 +80,11 @@
color: var(--theme-primary-active); color: var(--theme-primary-active);
} }
.expense-case-refresh:focus-visible {
outline: 2px solid var(--theme-primary);
outline-offset: 2px;
}
.expense-case-refresh:disabled { .expense-case-refresh:disabled {
cursor: wait; cursor: wait;
opacity: .62; opacity: .62;
@@ -95,10 +101,10 @@
gap: 12px; gap: 12px;
min-height: 58px; min-height: 58px;
padding: 12px 14px; padding: 12px 14px;
border: 1px dashed #cbd5e1; border: 1px dashed var(--line);
border-radius: 10px; border-radius: 6px;
background: #f8fafc; background: var(--surface-soft);
color: #64748b; color: var(--muted);
font-size: 12px; font-size: 12px;
} }
@@ -116,32 +122,57 @@
} }
.expense-case-state strong { .expense-case-state strong {
color: #334155; color: var(--text);
font-size: 12px; font-size: 12px;
} }
.expense-case-state.error { .expense-case-state.error {
border-color: #fecaca; border-color: var(--danger-line);
background: #fff7f7; background: var(--danger-soft);
} }
.expense-case-state.error > i { .expense-case-state.error > i {
color: #dc2626; color: var(--danger);
} }
.expense-case-state button { .expense-case-state button {
flex: 0 0 auto; flex: 0 0 auto;
padding: 6px 9px; padding: 6px 9px;
border: 0; border: 0;
border-radius: 7px; border-radius: 4px;
background: var(--theme-primary); background: var(--theme-primary);
color: #fff; color: #fff;
font: inherit; font: inherit;
font-size: 11px; font-size: 11px;
font-weight: 800; font-weight: 700;
cursor: pointer; 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 { .expense-case-event-list {
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -149,84 +180,110 @@
} }
.expense-case-event { .expense-case-event {
position: relative; min-height: 70px;
display: grid; padding: 12px 16px;
grid-template-columns: 34px minmax(0, 1fr); border-bottom: 1px solid var(--line);
gap: 12px;
padding: 0 0 18px;
} }
.expense-case-event:last-child { .expense-case-event:last-child {
padding-bottom: 0; border-bottom: 0;
} }
.expense-case-event:not(:last-child)::before { .expense-case-event-main {
content: ""; min-width: 0;
position: absolute; display: flex;
top: 31px; align-items: center;
bottom: 2px; gap: 12px;
left: 16px;
width: 2px;
background: #e2e8f0;
} }
.expense-case-event-icon { .expense-case-event-icon {
position: relative; flex: 0 0 28px;
z-index: 1; width: 28px;
width: 34px; height: 28px;
height: 34px;
display: grid; display: grid;
place-items: center; place-items: center;
border: 1px solid rgba(var(--theme-primary-rgb), .2); border: 1px solid rgba(var(--theme-primary-rgb), .2);
border-radius: 999px; border-radius: 5px;
background: var(--theme-primary-soft); background: var(--theme-primary-soft);
color: var(--theme-primary-active); 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 { .expense-case-event.success .expense-case-event-icon {
border-color: #bbf7d0; border-color: var(--success-line);
background: #f0fdf4; background: var(--success-soft);
color: #15803d; color: var(--success);
} }
.expense-case-event.warning .expense-case-event-icon { .expense-case-event.warning .expense-case-event-icon {
border-color: #fed7aa; border-color: var(--warning-line);
background: #fff7ed; background: var(--warning-soft);
color: #c2410c; 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 { .expense-case-event-copy {
min-width: 0; min-width: 0;
padding-top: 1px;
} }
.expense-case-event-copy > strong { .expense-case-event-copy > strong {
display: block; display: block;
color: #1e293b; color: var(--ink);
font-size: 13px; font-size: 13px;
font-weight: 600;
line-height: 1.45; line-height: 1.45;
} }
.expense-case-event-copy > p { .expense-case-event-copy > p {
margin: 4px 0 7px; margin: 4px 0 0;
color: #475569; color: var(--text);
font-size: 12px; font-size: 12px;
line-height: 1.55; line-height: 1.55;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.expense-case-event-meta { .expense-case-event-actor {
flex-wrap: wrap; min-width: 0;
gap: 6px 14px; display: flex;
color: #94a3b8; align-items: center;
justify-content: flex-start;
gap: 5px;
color: var(--muted);
font-size: 11px; font-size: 11px;
} }
.expense-case-event-meta span { .expense-case-event-actor span {
display: inline-flex; min-width: 0;
align-items: center; overflow: hidden;
gap: 4px; 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) { @media (max-width: 720px) {
@@ -239,7 +296,8 @@
} }
.expense-case-refresh { .expense-case-refresh {
padding: 7px 9px; width: 36px;
padding: 0;
} }
.expense-case-state { .expense-case-state {
@@ -250,4 +308,44 @@
.expense-case-state button { .expense-case-state button {
margin-left: 33px; 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;
}
} }

View File

@@ -1,14 +1,16 @@
<template> <template>
<section class="expense-case-timeline" aria-label="费用事件时间线"> <section class="expense-case-timeline" aria-labelledby="expense-case-timeline-title">
<div class="expense-case-timeline-head"> <div class="expense-case-timeline-head">
<div> <div>
<div class="expense-case-timeline-title-row"> <div class="expense-case-timeline-title-row">
<h4>费用事件时间线</h4> <h4 id="expense-case-timeline-title">费用操作记录</h4>
<span v-if="timeline" class="expense-case-stage">{{ timeline.stageLabel }}</span> <span v-if="timeline" class="expense-case-stage">{{ timeline.stageLabel }}</span>
</div> </div>
<p v-if="timeline" class="expense-case-meta"> <p v-if="timeline" class="expense-case-meta">
<span>{{ timeline.caseNo || '统一费用事件' }}</span> <span>{{ timeline.caseNo || '统一费用事件' }}</span>
<span>已串联 {{ timeline.linkCount }} 张业务单据</span> <span>已串联 {{ timeline.linkCount }} 张业务单据</span>
<span v-if="displayedEvents.length"> {{ displayedEvents.length }} 条记录</span>
<span v-if="displayedEvents.length">最新优先</span>
</p> </p>
<p v-else>记录费用从申请审批报销到付款的真实办理轨迹</p> <p v-else>记录费用从申请审批报销到付款的真实办理轨迹</p>
</div> </div>
@@ -19,12 +21,12 @@
aria-label="刷新费用事件时间线" aria-label="刷新费用事件时间线"
@click="refreshExpenseCaseTimeline" @click="refreshExpenseCaseTimeline"
> >
<i :class="loading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'"></i> <i :class="loading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'" aria-hidden="true"></i>
<span>刷新</span> <span>刷新</span>
</button> </button>
</div> </div>
<div class="expense-case-timeline-body" aria-live="polite"> <div class="expense-case-timeline-body" :aria-busy="loading">
<div v-if="loading && !timeline" class="expense-case-state loading"> <div v-if="loading && !timeline" class="expense-case-state loading">
<i class="mdi mdi-loading mdi-spin"></i> <i class="mdi mdi-loading mdi-spin"></i>
<span>正在同步真实费用事件</span> <span>正在同步真实费用事件</span>
@@ -55,34 +57,50 @@
</div> </div>
</div> </div>
<ol v-else-if="timeline" class="expense-case-event-list"> <div v-else-if="timeline" class="expense-case-event-table">
<div class="expense-case-event-columns" aria-hidden="true">
<span>发生时间</span>
<span>事件</span>
<span>操作人</span>
</div>
<ol class="expense-case-event-list">
<li <li
v-for="event in timeline.events" v-for="event in displayedEvents"
:key="event.id" :key="event.id"
class="expense-case-event" class="expense-case-event"
:class="event.tone" :class="event.tone"
> >
<time
class="expense-case-event-time"
:datetime="event.occurredAt || undefined"
>
{{ event.occurredAtLabel }}
</time>
<div class="expense-case-event-main">
<span class="expense-case-event-icon" aria-hidden="true"> <span class="expense-case-event-icon" aria-hidden="true">
<i :class="event.icon"></i> <i :class="event.icon"></i>
</span> </span>
<div class="expense-case-event-copy"> <div class="expense-case-event-copy">
<strong>{{ event.label }}</strong> <strong>{{ event.label }}</strong>
<p>{{ event.summary }}</p> <p>{{ event.summary }}</p>
<div class="expense-case-event-meta">
<span><i class="mdi mdi-account-outline"></i>{{ event.actorLabel }}</span>
<time :datetime="event.occurredAt || undefined">{{ event.occurredAtLabel }}</time>
</div> </div>
</div> </div>
<div class="expense-case-event-actor" :title="event.actorLabel">
<i class="mdi mdi-account-outline" aria-hidden="true"></i>
<span>{{ event.actorLabel }}</span>
</div>
</li> </li>
</ol> </ol>
</div> </div>
</div>
</section> </section>
</template> </template>
<script setup> <script setup>
import { toRef } from 'vue' import { computed, toRef } from 'vue'
import { useExpenseCaseTimeline } from '../../composables/requests/useExpenseCaseTimeline.js' import { useExpenseCaseTimeline } from '../../composables/requests/useExpenseCaseTimeline.js'
import { orderExpenseCaseEventsForDisplay } from '../../utils/expenseCaseTimeline.js'
const props = defineProps({ const props = defineProps({
claimId: { type: String, default: '' }, claimId: { type: String, default: '' },
@@ -99,6 +117,8 @@ const {
claimId: toRef(props, 'claimId'), claimId: toRef(props, 'claimId'),
refreshKey: toRef(props, 'refreshKey') refreshKey: toRef(props, 'refreshKey')
}) })
const displayedEvents = computed(() => orderExpenseCaseEventsForDisplay(timeline.value?.events))
</script> </script>
<style scoped src="../../assets/styles/components/travel-request-expense-case-timeline.css"></style> <style scoped src="../../assets/styles/components/travel-request-expense-case-timeline.css"></style>

View File

@@ -27,8 +27,8 @@ const EVENT_PRESENTATION = {
claim_draft_created: { label: '报销草稿已创建', icon: 'mdi mdi-file-plus-outline', tone: 'info' }, claim_draft_created: { label: '报销草稿已创建', icon: 'mdi mdi-file-plus-outline', tone: 'info' },
claim_draft_updated: { label: '报销草稿已更新', icon: 'mdi mdi-file-edit-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' }, historical_claim_imported: { label: '历史单据已纳入统一费用事件', icon: 'mdi mdi-history', tone: 'info' },
application_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-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' }, approval_stage_completed: { label: '审批节点已完成', icon: 'mdi mdi-account-check-outline', tone: 'success' },
application_approved: { label: '费用申请已审批', icon: 'mdi mdi-check-decagram-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' }, 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' }, reimbursement_draft_generated: { label: '报销草稿已自动生成', icon: 'mdi mdi-robot-outline', tone: 'success' },
receipt_received: { label: '票据已进入费用事件', icon: 'mdi mdi-receipt-text-plus-outline', tone: 'info' }, receipt_received: { label: '票据已进入费用事件', icon: 'mdi mdi-receipt-text-plus-outline', tone: 'info' },
attachment_associated: { label: '票据已归集到报销草稿', icon: 'mdi mdi-file-link-outline', tone: 'success' }, 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' }, application_pre_review_completed: { label: '费用申请 AI 预审已完成', icon: 'mdi mdi-shield-check-outline', tone: 'success' },
claim_pre_review_completed: { label: '报销单 AI 预审已完成', icon: 'mdi mdi-shield-search-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' }, payment_completed: { label: '付款已完成', icon: 'mdi mdi-cash-check', tone: 'success' },
application_archived: { label: '费用申请已归档', icon: 'mdi mdi-archive-check-outline', 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 = {}) { export function buildExpenseCaseTimelineViewModel(payload = {}) {
const events = Array.isArray(payload?.events) const events = Array.isArray(payload?.events)
? payload.events.map(normalizeEvent).sort((left, right) => { ? 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.sortTimestamp - right.sortTimestamp || left.sourceIndex - right.sourceIndex
} }
return 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) { export function isExpenseCaseNotAvailableError(error) {
return Number(error?.status) === 404 return Number(error?.status) === 404
} }

View File

@@ -6,7 +6,8 @@ import { effectScope, nextTick, ref } from 'vue'
import { import {
buildExpenseCaseTimelineViewModel, buildExpenseCaseTimelineViewModel,
isExpenseCaseNotAvailableError isExpenseCaseNotAvailableError,
orderExpenseCaseEventsForDisplay
} from '../src/utils/expenseCaseTimeline.js' } from '../src/utils/expenseCaseTimeline.js'
import { fetchExpenseCaseTimeline } from '../src/services/expenseCases.js' import { fetchExpenseCaseTimeline } from '../src/services/expenseCases.js'
import { useExpenseCaseTimeline } from '../src/composables/requests/useExpenseCaseTimeline.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.equal(timeline.linkCount, 2)
assert.deepEqual(timeline.events.map((event) => event.id), ['event-1', 'event-2']) assert.deepEqual(timeline.events.map((event) => event.id), ['event-1', 'event-2'])
assert.equal(timeline.events[0].label, '费用申请已提交') 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.match(timeline.events[0].summary, /直属领导审批/) assert.match(timeline.events[0].summary, /直属领导审批/)
assert.equal(timeline.events[0].actorLabel, 'employee@example.com') 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[0].summary, /上海出差高铁票\.pdf/)
assert.match(timeline.events[1].summary, /BX-20260713-001/) assert.match(timeline.events[1].summary, /BX-20260713-001/)
assert.equal(timeline.events[2].tone, 'warning') 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 条重大风险/) 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/) 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 () => { test('expense case service safely encodes claim identifiers', async () => {
const originalFetch = globalThis.fetch const originalFetch = globalThis.fetch
let requestedUrl = '' let requestedUrl = ''
@@ -351,7 +385,11 @@ test('travel request detail consumes the real expense case endpoint', () => {
assert.match(detailView, /:refresh-key="expenseCaseTimelineRefreshKey"/) assert.match(detailView, /:refresh-key="expenseCaseTimelineRefreshKey"/)
assert.match(progressCard, /<TravelRequestExpenseCaseTimeline/) assert.match(progressCard, /<TravelRequestExpenseCaseTimeline/)
assert.match(progressCard, /TravelRequestExpenseCaseTimeline/) assert.match(progressCard, /TravelRequestExpenseCaseTimeline/)
assert.match(timelineComponent, /费用事件时间线/) assert.match(timelineComponent, /费用操作记录/)
assert.match(timelineComponent, /发生时间/)
assert.match(timelineComponent, /操作人/)
assert.doesNotMatch(timelineComponent, /expense-case-latest/)
assert.match(timelineComponent, /orderExpenseCaseEventsForDisplay/)
assert.match(timelineComponent, /当前单据尚未纳入统一费用事件/) assert.match(timelineComponent, /当前单据尚未纳入统一费用事件/)
assert.match(expenseCaseService, /\/expense-cases\/by-claim\//) assert.match(expenseCaseService, /\/expense-cases\/by-claim\//)
}) })