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

@@ -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;
}
}

View File

@@ -1,14 +1,16 @@
<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>
<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>
</div>
<p v-if="timeline" class="expense-case-meta">
<span>{{ timeline.caseNo || '统一费用事件' }}</span>
<span>已串联 {{ timeline.linkCount }} 张业务单据</span>
<span v-if="displayedEvents.length"> {{ displayedEvents.length }} 条记录</span>
<span v-if="displayedEvents.length">最新优先</span>
</p>
<p v-else>记录费用从申请审批报销到付款的真实办理轨迹</p>
</div>
@@ -19,12 +21,12 @@
aria-label="刷新费用事件时间线"
@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>
</button>
</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">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在同步真实费用事件</span>
@@ -55,34 +57,50 @@
</div>
</div>
<ol v-else-if="timeline" class="expense-case-event-list">
<li
v-for="event in timeline.events"
:key="event.id"
class="expense-case-event"
:class="event.tone"
>
<span class="expense-case-event-icon" aria-hidden="true">
<i :class="event.icon"></i>
</span>
<div class="expense-case-event-copy">
<strong>{{ event.label }}</strong>
<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 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
v-for="event in displayedEvents"
:key="event.id"
class="expense-case-event"
: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">
<i :class="event.icon"></i>
</span>
<div class="expense-case-event-copy">
<strong>{{ event.label }}</strong>
<p>{{ event.summary }}</p>
</div>
</div>
</div>
</li>
</ol>
<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>
</ol>
</div>
</div>
</section>
</template>
<script setup>
import { toRef } from 'vue'
import { computed, toRef } from 'vue'
import { useExpenseCaseTimeline } from '../../composables/requests/useExpenseCaseTimeline.js'
import { orderExpenseCaseEventsForDisplay } from '../../utils/expenseCaseTimeline.js'
const props = defineProps({
claimId: { type: String, default: '' },
@@ -99,6 +117,8 @@ const {
claimId: toRef(props, 'claimId'),
refreshKey: toRef(props, 'refreshKey')
})
const displayedEvents = computed(() => orderExpenseCaseEventsForDisplay(timeline.value?.events))
</script>
<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_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
}

View File

@@ -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, /<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(expenseCaseService, /\/expense-cases\/by-claim\//)
})