feat(expenses): show unified expense event timeline
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
104
web/src/components/travel/TravelRequestExpenseCaseTimeline.vue
Normal file
104
web/src/components/travel/TravelRequestExpenseCaseTimeline.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
91
web/src/composables/requests/useExpenseCaseTimeline.js
Normal file
91
web/src/composables/requests/useExpenseCaseTimeline.js
Normal 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
|
||||
}
|
||||
}
|
||||
10
web/src/services/expenseCases.js
Normal file
10
web/src/services/expenseCases.js
Normal 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)
|
||||
}
|
||||
194
web/src/utils/expenseCaseTimeline.js
Normal file
194
web/src/utils/expenseCaseTimeline.js
Normal 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
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
257
web/tests/expense-case-timeline.test.mjs
Normal file
257
web/tests/expense-case-timeline.test.mjs
Normal file
@@ -0,0 +1,257 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
|
||||
import {
|
||||
buildExpenseCaseTimelineViewModel,
|
||||
isExpenseCaseNotAvailableError
|
||||
} from '../src/utils/expenseCaseTimeline.js'
|
||||
import { fetchExpenseCaseTimeline } from '../src/services/expenseCases.js'
|
||||
import { useExpenseCaseTimeline } from '../src/composables/requests/useExpenseCaseTimeline.js'
|
||||
|
||||
function createDeferred() {
|
||||
let resolve
|
||||
let reject
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
async function flushTimelineUpdates() {
|
||||
await Promise.resolve()
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
test('expense case timeline maps and sorts the real business event chain', () => {
|
||||
const timeline = buildExpenseCaseTimelineViewModel({
|
||||
id: 'case-1',
|
||||
case_no: 'CASE-20260713-001',
|
||||
current_stage: 'reviewing',
|
||||
status: 'active',
|
||||
links: [
|
||||
{ id: 'link-1', relation_type: 'application', resource_id: 'application-1' },
|
||||
{ id: 'link-2', relation_type: 'generated_reimbursement', resource_id: 'claim-1' }
|
||||
],
|
||||
events: [
|
||||
{
|
||||
id: 'event-2',
|
||||
event_type: 'reimbursement_draft_generated',
|
||||
actor_id: 'system',
|
||||
occurred_at: '2026-07-13T02:20:00Z',
|
||||
payload_json: {
|
||||
application_claim_no: 'AP-20260713-001',
|
||||
next_status: 'draft',
|
||||
next_approval_stage: '待提交'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'event-1',
|
||||
event_type: 'application_submitted',
|
||||
actor_id: 'employee@example.com',
|
||||
occurred_at: '2026-07-13T01:10:00Z',
|
||||
payload_json: {
|
||||
claim_no: 'AP-20260713-001',
|
||||
previous_status: 'draft',
|
||||
next_status: 'submitted',
|
||||
next_approval_stage: '直属领导审批'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.equal(timeline.caseNo, 'CASE-20260713-001')
|
||||
assert.equal(timeline.stageLabel, '审批中')
|
||||
assert.equal(timeline.linkCount, 2)
|
||||
assert.deepEqual(timeline.events.map((event) => event.id), ['event-1', 'event-2'])
|
||||
assert.equal(timeline.events[0].label, '费用申请已提交')
|
||||
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[1].label, '报销草稿已自动生成')
|
||||
assert.match(timeline.events[1].summary, /AP-20260713-001/)
|
||||
assert.equal(timeline.events[1].actorLabel, '系统')
|
||||
})
|
||||
|
||||
test('expense case timeline gives returned events an actionable reason', () => {
|
||||
const timeline = buildExpenseCaseTimelineViewModel({
|
||||
current_stage: 'claiming',
|
||||
events: [
|
||||
{
|
||||
id: 'returned-1',
|
||||
event_type: 'claim_returned',
|
||||
actor_id: 'finance@example.com',
|
||||
occurred_at: '2026-07-13T03:00:00Z',
|
||||
payload_json: {
|
||||
reason: '住宿票据缺少入住人信息',
|
||||
previous_status: 'submitted',
|
||||
next_status: 'returned'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.equal(timeline.events[0].label, '报销单被退回')
|
||||
assert.equal(timeline.events[0].tone, 'warning')
|
||||
assert.equal(timeline.events[0].summary, '住宿票据缺少入住人信息')
|
||||
})
|
||||
|
||||
test('expense case timeline describes application archive against the paid reimbursement', () => {
|
||||
const timeline = buildExpenseCaseTimelineViewModel({
|
||||
events: [
|
||||
{
|
||||
id: 'archived-1',
|
||||
event_type: 'application_archived',
|
||||
occurred_at: '2026-07-13T03:00:00Z',
|
||||
payload_json: { reimbursement_claim_no: 'BX-20260713-001' }
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.equal(
|
||||
timeline.events[0].summary,
|
||||
'关联报销单 BX-20260713-001 已付款,本费用申请已归档。'
|
||||
)
|
||||
})
|
||||
|
||||
test('expense case timeline treats 404 as a non-blocking not-yet-covered state', () => {
|
||||
assert.equal(isExpenseCaseNotAvailableError({ status: 404 }), true)
|
||||
assert.equal(isExpenseCaseNotAvailableError({ status: 500 }), false)
|
||||
})
|
||||
|
||||
test('expense case timeline keeps equal-time server order and hides unknown event internals', () => {
|
||||
const timeline = buildExpenseCaseTimelineViewModel({
|
||||
events: [
|
||||
{
|
||||
id: 'unknown-1',
|
||||
event_type: 'connector_internal_delivery_changed',
|
||||
occurred_at: '2026-07-13T03:00:00Z',
|
||||
correlation_id: 'private-correlation',
|
||||
delivery_status: 'pending'
|
||||
},
|
||||
{
|
||||
id: 'known-2',
|
||||
event_type: 'payment_completed',
|
||||
occurred_at: '2026-07-13T03:00:00Z'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.deepEqual(timeline.events.map((event) => event.id), ['unknown-1', 'known-2'])
|
||||
assert.equal(timeline.events[0].label, '流程状态已更新')
|
||||
assert.doesNotMatch(JSON.stringify(timeline.events[0]), /private-correlation|pending/)
|
||||
})
|
||||
|
||||
test('expense case service safely encodes claim identifiers', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
let requestedUrl = ''
|
||||
globalThis.fetch = async (url) => {
|
||||
requestedUrl = String(url)
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ events: [] })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchExpenseCaseTimeline(' claim/with space ')
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
|
||||
assert.equal(requestedUrl, '/api/v1/expense-cases/by-claim/claim%2Fwith%20space')
|
||||
})
|
||||
|
||||
test('expense case composable ignores a stale response after switching claims', async () => {
|
||||
const claimId = ref('claim-a')
|
||||
const refreshKey = ref('claim-a|submitted')
|
||||
const requests = new Map([
|
||||
['claim-a', createDeferred()],
|
||||
['claim-b', createDeferred()]
|
||||
])
|
||||
const scope = effectScope()
|
||||
let state
|
||||
|
||||
scope.run(() => {
|
||||
state = useExpenseCaseTimeline({
|
||||
claimId,
|
||||
refreshKey,
|
||||
fetchTimeline: (nextClaimId) => requests.get(nextClaimId).promise
|
||||
})
|
||||
})
|
||||
|
||||
claimId.value = 'claim-b'
|
||||
refreshKey.value = 'claim-b|approved'
|
||||
await nextTick()
|
||||
requests.get('claim-b').resolve({ case_no: 'CASE-B', events: [] })
|
||||
await flushTimelineUpdates()
|
||||
assert.equal(state.timeline.value.caseNo, 'CASE-B')
|
||||
|
||||
requests.get('claim-a').resolve({ case_no: 'CASE-A', events: [] })
|
||||
await flushTimelineUpdates()
|
||||
assert.equal(state.timeline.value.caseNo, 'CASE-B')
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
test('expense case composable separates 404 compatibility state from retryable errors', async () => {
|
||||
const claimId = ref('claim-a')
|
||||
const refreshKey = ref('first')
|
||||
const scope = effectScope()
|
||||
let attempt = 0
|
||||
let state
|
||||
|
||||
scope.run(() => {
|
||||
state = useExpenseCaseTimeline({
|
||||
claimId,
|
||||
refreshKey,
|
||||
fetchTimeline: async () => {
|
||||
attempt += 1
|
||||
const error = new Error('request failed')
|
||||
error.status = attempt === 1 ? 404 : 500
|
||||
throw error
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await flushTimelineUpdates()
|
||||
assert.equal(state.notAvailable.value, true)
|
||||
assert.equal(state.errorMessage.value, '')
|
||||
|
||||
refreshKey.value = 'retry'
|
||||
await flushTimelineUpdates()
|
||||
assert.equal(state.notAvailable.value, false)
|
||||
assert.match(state.errorMessage.value, /不受影响/)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
test('travel request detail consumes the real expense case endpoint', () => {
|
||||
const detailView = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const progressCard = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/travel/TravelRequestProgressCard.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const timelineComponent = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/travel/TravelRequestExpenseCaseTimeline.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const expenseCaseService = readFileSync(
|
||||
fileURLToPath(new URL('../src/services/expenseCases.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
assert.match(detailView, /:claim-id="request\.claimId"/)
|
||||
assert.match(detailView, /:refresh-key="expenseCaseTimelineRefreshKey"/)
|
||||
assert.match(progressCard, /<TravelRequestExpenseCaseTimeline/)
|
||||
assert.match(progressCard, /TravelRequestExpenseCaseTimeline/)
|
||||
assert.match(timelineComponent, /费用事件时间线/)
|
||||
assert.match(timelineComponent, /当前单据尚未纳入统一费用事件/)
|
||||
assert.match(expenseCaseService, /\/expense-cases\/by-claim\//)
|
||||
})
|
||||
@@ -7,8 +7,8 @@ const detailStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const detailTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)),
|
||||
const progressTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/travel/TravelRequestProgressCard.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const responsiveStyles = readFileSync(
|
||||
@@ -31,8 +31,8 @@ test('detail hero facts keep document number and date on one row on laptop scree
|
||||
})
|
||||
|
||||
test('reimbursement progress title and long linked application status are compact', () => {
|
||||
assert.match(detailTemplate, /isApplicationDocument \? '申请进度' : '报销进度'/)
|
||||
assert.doesNotMatch(detailTemplate, /差旅进度/)
|
||||
assert.match(progressTemplate, /isApplicationDocument \? '申请进度' : '报销进度'/)
|
||||
assert.doesNotMatch(progressTemplate, /差旅进度/)
|
||||
assert.match(detailStyles, /\.progress-step-status \{[\s\S]*width:\s*100%;[\s\S]*max-width:\s*136px;[\s\S]*min-width:\s*0;/)
|
||||
assert.match(detailStyles, /\.progress-step-status \{[\s\S]*white-space:\s*nowrap;[\s\S]*overflow:\s*hidden;[\s\S]*text-overflow:\s*ellipsis;/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user