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

@@ -17,9 +17,12 @@ import {
import {
buildExpenseItemViewModel,
buildDraftBlockingIssues,
buildOptionalTravelReceiptRiskCards,
isApplicationDocumentRequest
} from '../src/views/scripts/travelRequestDetailExpenseModel.js'
import {
buildEmployeeProfileAdviceItems,
buildTravelReceiptMaterialPrompts
} from '../src/views/scripts/travelRequestDetailAdviceModel.js'
const detailViewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)),
@@ -33,6 +36,10 @@ const detailViewInsights = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailInsights.js', import.meta.url)),
'utf8'
)
const detailExpenseModelScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailExpenseModel.js', import.meta.url)),
'utf8'
)
const detailViewStyle = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view.css', import.meta.url)),
'utf8'
@@ -201,7 +208,7 @@ test('risk card badge only shows severity while title keeps business risk name',
test('AI advice falls back to claim risk summary instead of showing an empty risk area', () => {
const riskCards = buildClaimSummaryRiskCards({
riskSummary: 'AI预审发现 1 条中风险附件,已随单流转给审批人复核。'
riskSummary: '自动检测发现 1 条中风险附件,已随单流转给审批人复核。'
})
assert.equal(riskCards.length, 1)
@@ -283,7 +290,13 @@ test('stage risk advice card exposes direct reviewer action suggestion', () => {
assert.match(stageRiskAdviceCard, /\{\{ decisionAction \}\}/)
assert.match(stageRiskAdviceCard, /compactEvidenceItems/)
assert.match(stageRiskAdviceCard, /compactAdviceItems/)
assert.ok(
stageRiskAdviceCard.indexOf('class="employee-risk-advice-list"')
< stageRiskAdviceCard.indexOf('class="employee-risk-action"')
)
assert.match(stageRiskAdviceCard, /employee-risk-tone-pill/)
assert.match(stageRiskAdviceCard, /\.employee-risk-ai-note \{[\s\S]*grid-template-columns: minmax\(0, 1fr\);/)
assert.match(stageRiskAdviceCard, /\.employee-risk-action \{[\s\S]*align-items: center;[\s\S]*justify-content: center;[\s\S]*text-align: center;/)
assert.match(stageRiskAdviceCard, /建议退回补充票据、行程说明或超标原因/)
assert.match(stageRiskAdviceCard, /可按权限继续审批/)
})
@@ -446,15 +459,35 @@ test('AI advice view model omits empty sections', () => {
})
assert.deepEqual(readyAdvice.sections, [])
assert.equal(readyAdvice.badge, '可直接提交')
assert.equal(readyAdvice.badge, '可提交')
assert.deepEqual(completionOnlyAdvice.sections.map((section) => section.title), ['建议补充字段'])
assert.deepEqual(riskOnlyAdvice.sections.map((section) => section.title), ['已知存在风险1项'])
})
test('AI advice separates material prompts and profile advice from risk cards', () => {
const advice = buildAiAdviceViewModel({
completionItems: [],
materialPrompts: ['当前包含 1 条住宿费用明细,但暂未关联住宿发票或酒店水单。'],
profileAdviceItems: ['历史退单建议:近 90 天存在 1 次退单或退回记录。'],
riskCards: []
})
assert.equal(advice.riskCards.length, 0)
assert.equal(advice.badge, '建议关注')
assert.deepEqual(
advice.sections.map((section) => ({ kind: section.kind, title: section.title })),
[
{ kind: 'material', title: '材料补充提示' },
{ kind: 'profile', title: '历史操作建议' }
]
)
})
test('AI advice template renders grouped section titles with completion before risk', () => {
assert.match(detailViewTemplate, /v-if="showAiAdvicePanel" class="detail-card panel validation-card"/)
assert.match(detailViewTemplate, /<h3>\{\{ aiAdviceTitle \}\}<\/h3>/)
assert.match(detailViewTemplate, /<p>\{\{ aiAdviceHint \}\}<\/p>/)
assert.match(detailViewTemplate, /<p v-if="aiAdviceHint">\{\{ aiAdviceHint \}\}<\/p>/)
assert.doesNotMatch(detailViewScript, /AI预审已完成请按风险提示补充原因或进入下一步。/)
assert.match(detailViewScript, /businessStage: currentBusinessStage/)
assert.match(detailViewScript, /filterRiskCardsByBusinessStage/)
assert.match(detailViewScript, /const summaryRiskCards = filterRiskCardsByBusinessStage/)
@@ -462,19 +495,23 @@ test('AI advice template renders grouped section titles with completion before r
assert.match(detailViewScript, /const canViewApprovalRiskAdvice = computed/)
assert.match(detailViewScript, /!isCurrentApplicant\.value/)
assert.match(detailViewScript, /const hasVisibleRiskCards = computed/)
assert.match(detailViewScript, /const showCompactSafeAdvice = computed/)
assert.match(detailViewScript, /const showAiAdvicePanel = computed\(\(\) => \(/)
assert.match(detailViewScript, /isCurrentApplicant\.value && !isApplicationDocument\.value && hasVisibleRiskCards\.value/)
assert.match(detailViewScript, /return '报销风险提示'/)
assert.match(detailViewScript, /canViewApprovalRiskAdvice\.value && aiAdvice\.value\.riskCards\.length > 0/)
assert.match(detailViewScript, /hasAiPreReviewResult\.value/)
assert.match(detailViewScript, /buildTravelReceiptMaterialPrompts\(request\.value, expenseItems\.value\)/)
assert.match(detailViewScript, /buildEmployeeProfileAdviceItems\(employeeRiskProfile\.value\)/)
assert.match(detailViewScript, /fetchEmployeeLatestProfile\(employeeId/)
assert.doesNotMatch(detailViewScript, /hasAiPreReviewResult\.value/)
assert.match(detailViewTemplate, /v-if="aiAdvice\.sections\.length" class="validation-sections"/)
assert.match(detailViewTemplate, /v-for="section in aiAdvice\.sections"/)
assert.match(detailViewTemplate, /validation-section--\$\{section\.kind\}/)
assert.match(detailViewTemplate, /<h4 class="validation-section-title">\{\{ section\.title \}\}<\/h4>/)
assert.match(detailViewTemplate, /v-if="section\.kind === 'completion'" class="validation-list"/)
assert.match(detailViewTemplate, /v-if="section\.kind !== 'risk'" class="validation-list"/)
assert.match(detailViewTemplate, /v-else class="risk-advice-list"/)
assert.ok(
detailViewTemplate.indexOf("section.kind === 'completion'") < detailViewTemplate.indexOf('risk-advice-card')
detailViewTemplate.indexOf("section.kind !== 'risk'") < detailViewTemplate.indexOf('risk-advice-card')
)
})
@@ -600,6 +637,7 @@ test('ticket item types and system allowance row are visible but read only', ()
assert.match(detailViewScript, /value: 'ride_ticket', label: '乘车'/)
assert.match(detailViewScript, /value: 'travel_allowance', label: '出差补贴'/)
assert.match(detailViewScript, /const SYSTEM_GENERATED_EXPENSE_TYPES = new Set\(\['travel_allowance'\]\)/)
assert.match(detailExpenseModelScript, /const OPTIONAL_ATTACHMENT_EXPENSE_TYPES = new Set\(\['ride_ticket', 'travel_allowance'\]\)/)
assert.match(detailViewTemplate, /'system-generated-row': item\.isSystemGenerated/)
assert.match(detailViewTemplate, /v-if="item\.isSystemGenerated" class="system-row-lock"/)
assert.match(detailViewTemplate, /v-if="item\.isSystemGenerated" class="system-attachment-note"/)
@@ -665,16 +703,76 @@ test('expense detail edit keeps delete but removes cancel and allows draft place
assert.match(detailViewScript, /if \(expenseEditor\.itemDate\) \{[\s\S]*itemPayload\.item_date = expenseEditor\.itemDate/)
})
test('travel detail AI advice adds low risk reminders for optional receipts', () => {
assert.match(detailViewScript, /function buildOptionalTravelReceiptRiskCards\(requestModel, items\)/)
assert.match(detailViewScript, /isApplicationDocumentRequest\(requestModel\)[\s\S]*return \[\]/)
assert.match(detailViewScript, /id: 'travel-optional-hotel-ticket'[\s\S]*tone: 'low'[\s\S]*住宿票据提醒/)
assert.match(detailViewScript, /不要忘记补充酒店住宿票据/)
assert.match(detailViewScript, /id: 'travel-optional-ride-ticket'[\s\S]*tone: 'low'[\s\S]*乘车票据提醒/)
assert.match(detailViewScript, /可以继续补充票据报销/)
assert.match(
detailViewScript,
/const optionalRiskCards = filterRiskCardsByBusinessStage\([\s\S]*buildOptionalTravelReceiptRiskCards\(request\.value, expenseItems\.value\)[\s\S]*currentBusinessStage/
test('travel detail AI advice uses material prompts only for required hotel receipts', () => {
assert.match(detailViewScript, /buildTravelReceiptMaterialPrompts\(request\.value, expenseItems\.value\)/)
assert.doesNotMatch(detailViewScript, /buildOptionalTravelReceiptRiskCards/)
assert.doesNotMatch(detailViewScript, /travel-optional-ride-ticket/)
assert.deepEqual(
buildTravelReceiptMaterialPrompts(
{ typeCode: 'travel', detailVariant: 'travel' },
[{ id: 'ride', itemType: 'ride_ticket', itemReason: '打车', invoiceId: '' }]
),
[]
)
assert.deepEqual(
buildTravelReceiptMaterialPrompts(
{ typeCode: 'travel', detailVariant: 'travel' },
[{ id: 'hotel', itemType: 'hotel_ticket', itemReason: '住宿', invoiceId: '' }]
),
['当前包含 1 条住宿费用明细,但暂未关联住宿发票或酒店水单。请补充住宿材料,避免后续被退回补件。']
)
assert.deepEqual(
buildDraftBlockingIssues(
{
profileName: '张三',
typeCode: 'transport',
typeLabel: '交通费',
reason: '客户现场打车',
occurredDisplay: '2026-06-01',
amountValue: 42
},
[
buildExpenseItemViewModel(
{
id: 'ride',
itemType: 'ride_ticket',
itemReason: '园区-客户现场',
itemDate: '2026-06-01',
itemAmount: 42,
invoiceId: ''
},
0,
{ typeCode: 'transport' }
)
]
),
[]
)
assert.ok(
buildDraftBlockingIssues(
{
profileName: '张三',
typeCode: 'hotel',
typeLabel: '住宿费',
reason: '住宿报销',
occurredDisplay: '2026-06-01',
amountValue: 450
},
[
buildExpenseItemViewModel(
{
id: 'hotel',
itemType: 'hotel_ticket',
itemReason: '北京中心酒店',
itemDate: '2026-06-01',
itemAmount: 450,
invoiceId: ''
},
0,
{ typeCode: 'hotel' }
)
]
).some((item) => item.includes('缺少票据标识'))
)
})
@@ -717,13 +815,36 @@ test('application detail does not show optional travel receipt reminders', () =>
assert.equal(isApplicationDocumentRequest(request), true)
assert.deepEqual(
buildOptionalTravelReceiptRiskCards(request, [
buildTravelReceiptMaterialPrompts(request, [
{ id: 'allowance', itemType: 'travel_allowance', isSystemGenerated: true, invoiceId: '' }
]),
[]
)
})
test('employee profile advice highlights prior return and material quality issues', () => {
const items = buildEmployeeProfileAdviceItems({
profiles: [
{
profile_type: 'process_quality',
metrics: {
return_count: 2,
missing_attachment_count: 1,
missing_business_context_count: 1,
invoice_mismatch_count: 1
}
}
],
review_suggestions: [
{ message: '申请人近期材料质量波动较高,建议重点核对附件、事由和票据一致性。' }
]
})
assert.ok(items.some((item) => item.includes('历史退单建议')))
assert.ok(items.some((item) => item.includes('材料完整性建议')))
assert.ok(items.some((item) => item.includes('票据一致性建议')))
})
test('draft submit validation uses expense detail date and amount when claim summary is stale', () => {
const issues = buildDraftBlockingIssues(
{