feat(expenses): show unified expense event timeline

This commit is contained in:
caoxiaozhu
2026-07-13 16:58:51 +08:00
parent a616b30cb2
commit 22669a9071
12 changed files with 944 additions and 7 deletions

View File

@@ -0,0 +1,253 @@
.expense-case-timeline {
min-width: 0;
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid #e2e8f0;
}
.expense-case-timeline-head,
.expense-case-timeline-title-row,
.expense-case-meta,
.expense-case-event-meta {
display: flex;
align-items: center;
}
.expense-case-timeline-head {
justify-content: space-between;
gap: 18px;
}
.expense-case-timeline-title-row {
flex-wrap: wrap;
gap: 9px;
}
.expense-case-timeline h4 {
margin: 0;
color: #0f172a;
font-size: 14px;
font-weight: 850;
}
.expense-case-timeline-head p {
margin: 6px 0 0;
color: #64748b;
font-size: 12px;
line-height: 1.55;
}
.expense-case-stage {
padding: 3px 8px;
border: 1px solid rgba(var(--theme-primary-rgb), .2);
border-radius: 999px;
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
font-size: 11px;
font-weight: 800;
}
.expense-case-meta {
flex-wrap: wrap;
gap: 0;
}
.expense-case-meta span + span::before {
content: "·";
margin: 0 7px;
color: #cbd5e1;
}
.expense-case-refresh {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 11px;
border: 1px solid #dbe4ee;
border-radius: 8px;
background: #fff;
color: #475569;
font: inherit;
font-size: 12px;
font-weight: 750;
cursor: pointer;
}
.expense-case-refresh:hover:not(:disabled) {
border-color: rgba(var(--theme-primary-rgb), .34);
color: var(--theme-primary-active);
}
.expense-case-refresh:disabled {
cursor: wait;
opacity: .62;
}
.expense-case-timeline-body {
min-height: 58px;
margin-top: 15px;
}
.expense-case-state {
display: flex;
align-items: center;
gap: 12px;
min-height: 58px;
padding: 12px 14px;
border: 1px dashed #cbd5e1;
border-radius: 10px;
background: #f8fafc;
color: #64748b;
font-size: 12px;
}
.expense-case-state > i {
flex: 0 0 auto;
color: var(--theme-primary);
font-size: 21px;
}
.expense-case-state div {
flex: 1 1 auto;
min-width: 0;
display: grid;
gap: 3px;
}
.expense-case-state strong {
color: #334155;
font-size: 12px;
}
.expense-case-state.error {
border-color: #fecaca;
background: #fff7f7;
}
.expense-case-state.error > i {
color: #dc2626;
}
.expense-case-state button {
flex: 0 0 auto;
padding: 6px 9px;
border: 0;
border-radius: 7px;
background: var(--theme-primary);
color: #fff;
font: inherit;
font-size: 11px;
font-weight: 800;
cursor: pointer;
}
.expense-case-event-list {
margin: 0;
padding: 0;
list-style: none;
}
.expense-case-event {
position: relative;
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
gap: 12px;
padding: 0 0 18px;
}
.expense-case-event:last-child {
padding-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-icon {
position: relative;
z-index: 1;
width: 34px;
height: 34px;
display: grid;
place-items: center;
border: 1px solid rgba(var(--theme-primary-rgb), .2);
border-radius: 999px;
background: var(--theme-primary-soft);
color: var(--theme-primary-active);
font-size: 17px;
}
.expense-case-event.success .expense-case-event-icon {
border-color: #bbf7d0;
background: #f0fdf4;
color: #15803d;
}
.expense-case-event.warning .expense-case-event-icon {
border-color: #fed7aa;
background: #fff7ed;
color: #c2410c;
}
.expense-case-event-copy {
min-width: 0;
padding-top: 1px;
}
.expense-case-event-copy > strong {
display: block;
color: #1e293b;
font-size: 13px;
line-height: 1.45;
}
.expense-case-event-copy > p {
margin: 4px 0 7px;
color: #475569;
font-size: 12px;
line-height: 1.55;
overflow-wrap: anywhere;
}
.expense-case-event-meta {
flex-wrap: wrap;
gap: 6px 14px;
color: #94a3b8;
font-size: 11px;
}
.expense-case-event-meta span {
display: inline-flex;
align-items: center;
gap: 4px;
}
@media (max-width: 720px) {
.expense-case-timeline-head {
align-items: flex-start;
}
.expense-case-refresh span {
display: none;
}
.expense-case-refresh {
padding: 7px 9px;
}
.expense-case-state {
align-items: flex-start;
flex-wrap: wrap;
}
.expense-case-state button {
margin-left: 33px;
}
}

View File

@@ -0,0 +1,104 @@
<template>
<section class="expense-case-timeline" aria-label="费用事件时间线">
<div class="expense-case-timeline-head">
<div>
<div class="expense-case-timeline-title-row">
<h4>费用事件时间线</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>
</p>
<p v-else>记录费用从申请审批报销到付款的真实办理轨迹</p>
</div>
<button
class="expense-case-refresh"
type="button"
:disabled="loading"
aria-label="刷新费用事件时间线"
@click="refreshExpenseCaseTimeline"
>
<i :class="loading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'"></i>
<span>刷新</span>
</button>
</div>
<div class="expense-case-timeline-body" aria-live="polite">
<div v-if="loading && !timeline" class="expense-case-state loading">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在同步真实费用事件</span>
</div>
<div v-else-if="errorMessage" class="expense-case-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<div>
<strong>费用事件加载失败</strong>
<span>{{ errorMessage }}</span>
</div>
<button type="button" @click="refreshExpenseCaseTimeline">重新加载</button>
</div>
<div v-else-if="notAvailable" class="expense-case-state empty">
<i class="mdi mdi-timeline-alert-outline"></i>
<div>
<strong>当前单据尚未纳入统一费用事件</strong>
<span>上方进度仍可正常使用后续业务动作写入后将自动展示办理记录</span>
</div>
</div>
<div v-else-if="timeline && !timeline.events.length" class="expense-case-state empty">
<i class="mdi mdi-timeline-clock-outline"></i>
<div>
<strong>统一费用事件已建立</strong>
<span>暂无可展示的办理记录</span>
</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>
</div>
</li>
</ol>
</div>
</section>
</template>
<script setup>
import { toRef } from 'vue'
import { useExpenseCaseTimeline } from '../../composables/requests/useExpenseCaseTimeline.js'
const props = defineProps({
claimId: { type: String, default: '' },
refreshKey: { type: String, default: '' }
})
const {
timeline,
loading,
errorMessage,
notAvailable,
refreshExpenseCaseTimeline
} = useExpenseCaseTimeline({
claimId: toRef(props, 'claimId'),
refreshKey: toRef(props, 'refreshKey')
})
</script>
<style scoped src="../../assets/styles/components/travel-request-expense-case-timeline.css"></style>

View File

@@ -31,14 +31,23 @@
</div>
</div>
</div>
<TravelRequestExpenseCaseTimeline
v-if="claimId"
:claim-id="claimId"
:refresh-key="refreshKey"
/>
</article>
</template>
<script setup>
import TravelRequestExpenseCaseTimeline from './TravelRequestExpenseCaseTimeline.vue'
defineProps({
isApplicationDocument: { type: Boolean, default: false },
progressSteps: { type: Array, default: () => [] },
currentProgressRingMotion: { type: Object, required: true }
currentProgressRingMotion: { type: Object, required: true },
claimId: { type: String, default: '' },
refreshKey: { type: String, default: '' }
})
</script>

View File

@@ -0,0 +1,91 @@
import { onScopeDispose, ref, unref, watch } from 'vue'
import { fetchExpenseCaseTimeline } from '../../services/expenseCases.js'
import {
buildExpenseCaseTimelineViewModel,
isExpenseCaseNotAvailableError
} from '../../utils/expenseCaseTimeline.js'
export function useExpenseCaseTimeline({
claimId,
refreshKey,
fetchTimeline = fetchExpenseCaseTimeline
}) {
const timeline = ref(null)
const loading = ref(false)
const errorMessage = ref('')
const notAvailable = ref(false)
let loadSequence = 0
let disposed = false
function resetTimelineState() {
timeline.value = null
loading.value = false
errorMessage.value = ''
notAvailable.value = false
}
async function loadTimeline() {
const normalizedClaimId = String(unref(claimId) || '').trim()
const sequence = ++loadSequence
if (!normalizedClaimId) {
resetTimelineState()
return
}
timeline.value = null
loading.value = true
errorMessage.value = ''
notAvailable.value = false
try {
const payload = await fetchTimeline(normalizedClaimId, {
timeoutMs: 10000,
timeoutMessage: '费用事件加载超时,请稍后重试。'
})
if (disposed || sequence !== loadSequence) {
return
}
timeline.value = buildExpenseCaseTimelineViewModel(payload || {})
} catch (error) {
if (disposed || sequence !== loadSequence) {
return
}
timeline.value = null
if (isExpenseCaseNotAvailableError(error)) {
notAvailable.value = true
return
}
errorMessage.value = '费用事件暂时加载失败,上方进度与单据操作不受影响。'
} finally {
if (!disposed && sequence === loadSequence) {
loading.value = false
}
}
}
watch(
[
() => String(unref(claimId) || '').trim(),
() => String(unref(refreshKey) || '')
],
loadTimeline,
{ immediate: true }
)
onScopeDispose(() => {
disposed = true
loadSequence += 1
})
return {
timeline,
loading,
errorMessage,
notAvailable,
refreshExpenseCaseTimeline: loadTimeline
}
}

View File

@@ -0,0 +1,10 @@
import { apiRequest } from './api.js'
export function fetchExpenseCaseTimeline(claimId, options = {}) {
const normalizedClaimId = String(claimId || '').trim()
if (!normalizedClaimId) {
return Promise.resolve(null)
}
return apiRequest(`/expense-cases/by-claim/${encodeURIComponent(normalizedClaimId)}`, options)
}

View File

@@ -0,0 +1,194 @@
const CASE_STAGE_LABELS = {
planning: '申请规划',
approved_to_spend: '已批准支出',
spending: '消费中',
claiming: '填报中',
reviewing: '审批中',
paying: '付款中',
accounting: '已付款 / 待入账',
closed: '已结束',
cancelled: '已取消'
}
const BUSINESS_STATUS_LABELS = {
draft: '草稿',
submitted: '已提交',
returned: '已退回',
approved: '已审批',
pending_payment: '待付款',
paid: '已付款',
archived: '已归档',
active: '进行中',
closed: '已关闭',
cancelled: '已取消'
}
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' },
application_submitted: { label: '费用申请已提交', icon: 'mdi mdi-send-outline', tone: 'info' },
claim_submitted: { label: '报销单已提交', icon: 'mdi mdi-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' },
application_returned: { label: '费用申请被退回', icon: 'mdi mdi-arrow-u-left-top', tone: 'warning' },
claim_returned: { label: '报销单被退回', icon: 'mdi mdi-arrow-u-left-top', tone: 'warning' },
reimbursement_draft_generated: { label: '报销草稿已自动生成', icon: 'mdi mdi-robot-outline', tone: 'success' },
payment_completed: { label: '付款已完成', icon: 'mdi mdi-cash-check', tone: 'success' },
application_archived: { label: '费用申请已归档', icon: 'mdi mdi-archive-check-outline', tone: 'success' }
}
const FALLBACK_EVENT_PRESENTATION = {
label: '流程状态已更新',
icon: 'mdi mdi-timeline-clock-outline',
tone: 'info'
}
const EVENT_DATE_TIME_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
function compactText(value) {
return String(value ?? '').trim()
}
function readField(source, snakeKey, camelKey) {
return source?.[snakeKey] ?? source?.[camelKey]
}
function resolveStatusLabel(value) {
const normalized = compactText(value)
return BUSINESS_STATUS_LABELS[normalized] || normalized
}
function buildStateTransitionSummary(payload) {
const previousStatus = resolveStatusLabel(readField(payload, 'previous_status', 'previousStatus'))
const nextStatus = resolveStatusLabel(readField(payload, 'next_status', 'nextStatus'))
const nextStage = compactText(readField(payload, 'next_approval_stage', 'nextApprovalStage'))
const transition = previousStatus && nextStatus
? `${previousStatus}${nextStatus}`
: nextStatus
? `状态更新为${nextStatus}`
: ''
if (transition && nextStage) {
return `${transition} · 当前节点:${nextStage}`
}
return transition || (nextStage ? `当前节点:${nextStage}` : '')
}
function buildEventSummary(eventType, payload) {
const reason = compactText(payload?.reason)
if (reason) {
return reason
}
const opinion = compactText(payload?.opinion)
if (opinion) {
return opinion
}
if (eventType === 'reimbursement_draft_generated') {
const applicationClaimNo = compactText(readField(payload, 'application_claim_no', 'applicationClaimNo'))
if (applicationClaimNo) {
return `已由费用申请 ${applicationClaimNo} 自动生成报销草稿。`
}
}
if (eventType === 'application_archived') {
const reimbursementClaimNo = compactText(readField(payload, 'reimbursement_claim_no', 'reimbursementClaimNo'))
if (reimbursementClaimNo) {
return `关联报销单 ${reimbursementClaimNo} 已付款,本费用申请已归档。`
}
}
if (eventType === 'payment_completed') {
const archivedApplications = readField(payload, 'archived_applications', 'archivedApplications')
const archivedCount = Array.isArray(archivedApplications)
? archivedApplications.length
: 0
if (archivedCount > 0) {
return `付款完成,并同步归档 ${archivedCount} 张关联费用申请。`
}
}
return buildStateTransitionSummary(payload) || '业务状态已更新。'
}
function formatOccurredAt(value) {
const normalized = compactText(value)
if (!normalized) {
return '时间待同步'
}
const date = new Date(normalized)
if (Number.isNaN(date.getTime())) {
return normalized
}
return EVENT_DATE_TIME_FORMATTER.format(date).replaceAll('/', '-')
}
function resolveActorLabel(event) {
const actorId = compactText(readField(event, 'actor_id', 'actorId'))
const actorType = compactText(readField(event, 'actor_type', 'actorType')).toLowerCase()
if (actorType === 'system' || actorId.toLowerCase() === 'system') {
return '系统'
}
return actorId || '操作人待同步'
}
function normalizeEvent(event, index) {
const eventType = compactText(readField(event, 'event_type', 'eventType'))
const presentation = EVENT_PRESENTATION[eventType] || FALLBACK_EVENT_PRESENTATION
const occurredAt = readField(event, 'occurred_at', 'occurredAt')
const timestamp = new Date(occurredAt || '').getTime()
const payload = readField(event, 'payload_json', 'payloadJson') || {}
return {
id: compactText(event?.id) || `${eventType || 'event'}-${index}`,
eventType,
label: presentation.label,
icon: presentation.icon,
tone: presentation.tone,
summary: buildEventSummary(eventType, payload),
actorLabel: resolveActorLabel(event),
occurredAt: compactText(occurredAt),
occurredAtLabel: formatOccurredAt(occurredAt),
sortTimestamp: Number.isNaN(timestamp) ? null : timestamp,
sourceIndex: 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) {
return left.sortTimestamp - right.sortTimestamp || left.sourceIndex - right.sourceIndex
}
return left.sourceIndex - right.sourceIndex
})
: []
return {
caseId: compactText(payload?.id),
caseNo: compactText(readField(payload, 'case_no', 'caseNo')),
stage: compactText(readField(payload, 'current_stage', 'currentStage')),
stageLabel: CASE_STAGE_LABELS[compactText(readField(payload, 'current_stage', 'currentStage'))] || '流程跟进中',
status: compactText(payload?.status),
statusLabel: resolveStatusLabel(payload?.status) || '状态待同步',
linkCount: Array.isArray(payload?.links) ? payload.links.length : 0,
events
}
}
export function isExpenseCaseNotAvailableError(error) {
return Number(error?.status) === 404
}

View File

@@ -7,6 +7,8 @@
:is-application-document="isApplicationDocument"
:progress-steps="progressSteps"
:current-progress-ring-motion="currentProgressRingMotion"
:claim-id="request.claimId"
:refresh-key="expenseCaseTimelineRefreshKey"
/>
<div class="detail-grid">
<section class="detail-left">

View File

@@ -347,6 +347,12 @@ export function useTravelRequestDetailSetup(props, { emit }) {
isCurrentDirectManagerApprover: isCurrentDirectManagerApprover.value
})
})
const expenseCaseTimelineRefreshKey = computed(() => [
request.value.claimId,
request.value.status,
request.value.node,
request.value.updatedAt || request.value.updated_at
].map((value) => String(value || '').trim()).join('|'))
const currentProgressRingMotion = {
initial: { scale: 1, opacity: 0.34 },
enter: {
@@ -638,6 +644,7 @@ export function useTravelRequestDetailSetup(props, { emit }) {
canApproveRequest,
canReturnRequest,
currentProgressRingMotion,
expenseCaseTimelineRefreshKey,
expenseItems,
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
hasLeaderApprovalEvents,