feat: 优化差旅报销预审流程与个人工作台 UI 体系

- 完善 user_agent_application 申请差旅报销预审槽位与消息组装
- 增强预算助理报告与风险建议卡片交互
- 重构登录页视觉样式与移动端响应式适配
- 优化个人工作台、文档中心、政策中心、员工管理等页面布局
- 拆分 travelRequestDetailPreReviewModel 为 advice/submit 模型
- 补充报销草稿、风险复核、Item Sync 与模板执行器测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-02 14:01:51 +08:00
parent 92444e7eae
commit ca691f3ee0
107 changed files with 5663 additions and 1542 deletions

View File

@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
import { ref } from 'vue'
import {
applyApplicationBusinessTimeContext,
buildApplicationPreviewFooterMessage,
buildApplicationPreviewRows,
buildApplicationPreviewSubmitText,
@@ -15,6 +16,7 @@ import {
buildLocalApplicationPreviewMessage,
buildModelRefinedApplicationPreview,
normalizeApplicationPreview,
resolveApplicationTimeLabel,
shouldUseLocalApplicationPreview
} from '../src/utils/expenseApplicationPreview.js'
import {
@@ -162,8 +164,10 @@ test('application preview renders ordered editable rows and submit text uses edi
const rows = buildApplicationPreviewRows(editedPreview)
assert.deepEqual(
rows.map((row) => row.label),
['申请类型', '姓名', '职级', '部门', '岗位', '直属领导', '发生时间', '地点', '事由', '天数', '出行方式', '住宿上限/天', '补贴标准/天', '交通费用口径', '规则测算参考', '系统预估费用']
['申请类型', '姓名', '职级', '部门', '岗位', '直属领导', '行程时间', '地点', '事由', '天数', '出行方式', '住宿上限/天', '补贴标准/天', '交通费用口径', '规则测算参考', '系统预估费用']
)
assert.match(buildApplicationPreviewSubmitText(editedPreview), /行程时间2026-05-25 至 2026-05-28/)
assert.doesNotMatch(buildApplicationPreviewSubmitText(editedPreview), /发生时间:/)
assert.equal(rows.find((row) => row.key === 'amount')?.value, '1900元')
assert.equal(rows.find((row) => row.key === 'amount')?.highlight, true)
assert.equal(rows.find((row) => row.key === 'amount')?.editable, false)
@@ -220,6 +224,39 @@ test('application estimate builds deterministic mock transport amount and total'
assert.equal(datedTotalEstimate.totalAmountDisplay, '3,260')
})
test('application preview uses selected date range and business-specific time label', () => {
const preview = applyApplicationBusinessTimeContext(
buildLocalApplicationPreview(
'去上海出差4天支撑国网仿生产环境部署飞机',
{
name: '曹笑竹',
departmentName: '技术部',
position: '财务智能化产品经理',
managerName: '向万红',
grade: 'P5'
},
{ today: '2026-06-02' }
),
{
mode: 'range',
start_date: '2026-02-20',
end_date: '2026-02-23',
business_time: '2026-02-20 至 2026-02-23'
}
)
const rows = buildApplicationPreviewRows(preview)
const submitText = buildApplicationPreviewSubmitText(preview)
assert.equal(resolveApplicationTimeLabel(preview.fields.applicationType), '行程时间')
assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23')
assert.equal(preview.fields.days, '4天')
assert.equal(preview.fields.reason, '支撑国网仿生产环境部署')
assert.equal(rows.find((row) => row.key === 'time')?.label, '行程时间')
assert.match(submitText, /行程时间2026-02-20 至 2026-02-23/)
assert.match(submitText, /事由:支撑国网仿生产环境部署/)
assert.doesNotMatch(submitText, /发生时间:/)
})
test('application preview cleans empty time labels and keeps only business reason', () => {
const preview = buildLocalApplicationPreview('发生时间去九江出差3天服务美团业务部署预计费用1800元火车', {
name: '李文静',
@@ -407,8 +444,20 @@ test('application session shows intent flow, persists preview, and supports inli
assert.doesNotMatch(messageItemTemplate, /ui\.commitApplicationPreviewDateEditor\(message\)/)
assert.match(messageItemTemplate, /application-preview-date-chip/)
assert.match(messageItemTemplate, /申请单据已生成/)
assert.match(messageItemTemplate, /ui\.shouldShowDraftSavedCard\(message\)/)
assert.match(messageItemTemplate, /报销草稿已生成/)
assert.match(messageItemTemplate, /ui\.resolveReimbursementDraftClaimNo\(message\.draftPayload\)/)
assert.match(messageItemTemplate, /class="reimbursement-draft-link"/)
assert.match(messageItemTemplate, /查看详情/)
assert.doesNotMatch(messageItemTemplate, /ui\.buildReimbursementDraftSummaryItems\(message\.draftPayload\)/)
assert.doesNotMatch(messageItemTemplate, /可以继续上传票据,我会归集到这张草稿。/)
assert.ok(
messageItemTemplate.indexOf('class="draft-preview application-draft-preview"')
< messageItemTemplate.indexOf('class="message-detail-block review-message-block"')
)
assert.match(messageItemTemplate, /application-draft-head/)
assert.match(messageItemTemplate, /mdi mdi-file-document-check-outline/)
assert.match(messageItemTemplate, /mdi mdi-file-document-edit-outline/)
assert.match(messageItemTemplate, /'is-primary': item\.label === '单号'/)
assert.match(messageItemTemplate, /完整审批链、附件和明细可在单据详情中[\s\S]*application-draft-detail-link[\s\S]*>查看<\/button>/)
assert.doesNotMatch(messageItemTemplate, /application-draft-detail-btn/)
@@ -416,6 +465,8 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(messageItemTemplate, /<OperationFeedbackInlineCard/)
assert.match(messageItemTemplate, /ui\.isOperationFeedbackVisible\(message\)/)
assert.match(messageItemTemplate, /ui\.submitOperationFeedbackForMessage\(message, \$event\)/)
assert.match(submitComposerScript, /employee_grade:\s*user\.grade \|\| user\.employeeGrade \|\| user\.employee_grade/)
assert.match(submitComposerScript, /employeeGrade:\s*user\.grade \|\| user\.employeeGrade \|\| user\.employee_grade/)
assert.match(createViewTemplate, /'has-insight': hasInsightPanelContent && showInsightPanel/)
assert.match(messageItemTemplate, /v-model="ui\.applicationPreviewEditor\.draftValue"/)
assert.doesNotMatch(messageItemTemplate, /v-model="ui\.applicationPreviewEditor\.singleDate"/)
@@ -464,12 +515,19 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(applicationMessageStyles, /\.application-draft-brief-item \{[\s\S]*border: 0;[\s\S]*background: #ffffff;/)
assert.doesNotMatch(applicationMessageStyles, /\.application-draft-brief-item:nth-child\(even\)/)
assert.match(applicationMessageStyles, /\.application-draft-brief-item\.is-primary \{[\s\S]*grid-column: 1 \/ -1;/)
assert.match(applicationMessageStyles, /\.application-draft-preview\.reimbursement-draft-preview \{[\s\S]*max-width: 520px;/)
assert.match(applicationMessageStyles, /\.reimbursement-draft-card \{[\s\S]*grid-template-columns: 30px minmax\(0, 1fr\);/)
assert.match(applicationMessageStyles, /\.reimbursement-draft-link \{[\s\S]*text-decoration: underline;/)
assert.match(flowScript, /application-submit-success/)
assert.match(flowScript, /function shouldHideToolCall/)
assert.match(flowScript, /semantic_ontology/)
assert.match(flowScript, /return null/)
assert.match(flowScript, /申请单提交成功/)
assert.match(submitComposerScript, /const isApplicationSubmitOperation = feedbackOperationType === 'submit_application'/)
assert.match(submitComposerScript, /if \(isApplicationSubmitOperation\) \{[\s\S]*startFlowStep\('application-submit-success'/)
assert.match(submitComposerScript, /else if \(rawText && !reviewAction\) \{[\s\S]*startFlowStep\('intent'/)
assert.match(submitComposerScript, /if \(!isApplicationSubmitOperation\) \{[\s\S]*startExpenseClaimDraftFlowStep/)
assert.match(flowScript, /function resolveDurationFromFields/)
assert.match(flowScript, /function resolveStartedTimestamp/)
assert.match(flowScript, /function resolveFinishedTimestamp/)
@@ -521,6 +579,64 @@ test('flow panel durations use backend timing instead of local preview delay', (
assert.equal(flow.formatFlowStepDuration({ status: 'completed', durationMs: null }), '--')
})
test('application submit confirmation flow only shows submit success step', () => {
const flow = createFlowHarness()
flow.resetFlowRun({ startedAt: Date.parse('2026-05-29T00:00:00.000Z') })
flow.startFlowStep('application-submit-success', {
title: '申请单提交成功',
tool: 'ApplicationSubmit',
detail: '正在提交费用申请...'
})
flow.completeFlowResult({
status: 'succeeded',
result: {
answer: '申请单据已生成,并已进入审批流程。',
draft_payload: {
draft_type: 'expense_application',
status: 'submitted',
claim_no: 'AP-20260602010101-ABCDEFGH',
approval_stage: '直属领导审批'
}
}
})
assert.deepEqual(flow.flowSteps.value.map((step) => step.key), ['application-submit-success'])
assert.deepEqual(flow.visibleFlowSteps.value.map((step) => step.key), ['application-submit-success'])
const submitStep = flow.flowSteps.value[0]
assert.equal(submitStep.status, 'completed')
assert.match(submitStep.detail, /AP-20260602010101-ABCDEFGH/)
assert.doesNotMatch(flow.flowSteps.value.map((step) => step.key).join(','), /intent|extraction/)
})
test('application duplicate confirmation flow marks submit step as blocked duplicate', () => {
const flow = createFlowHarness()
flow.resetFlowRun({ startedAt: Date.parse('2026-05-29T00:00:00.000Z') })
flow.startFlowStep('application-submit-success', {
title: '申请单提交成功',
tool: 'ApplicationSubmit',
detail: '正在提交费用申请...'
})
flow.completeFlowResult({
status: 'succeeded',
result: {
answer: [
'检测到同一申请人、同一申请类型、同一行程时间已存在申请单,系统没有重复创建。',
'已有申请单号AP-20260602010101-ABCDEFGH',
'当前节点:直属领导审批'
].join('\n')
}
})
assert.deepEqual(flow.flowSteps.value.map((step) => step.key), ['application-submit-success'])
const submitStep = flow.flowSteps.value[0]
assert.equal(submitStep.status, 'completed')
assert.equal(submitStep.title, '重复申请已拦截')
assert.match(submitStep.detail, /AP-20260602010101-ABCDEFGH/)
assert.doesNotMatch(submitStep.detail, /提交成功/)
})
test('assistant markdown tables render with component-scoped table styling', () => {
const rendered = renderMarkdown([
'| 项目 | 标准口径 | 天数 | 小计 |',