feat: 风险可见性控制与差旅详情页交互优化

- 新增风险可见性工具函数与风险日趋势图表组件
- 优化差旅请求详情页费用模型与视图交互
- 完善顶部导航栏样式与应用壳路由逻辑
- 补充风险可见性、风险看板与差旅详情测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-03 22:15:45 +08:00
parent 75d5c178e1
commit 87da5df91b
17 changed files with 809 additions and 168 deletions

View File

@@ -78,18 +78,28 @@ test('documents center reloads immediately when entered or clicked again', () =>
test('document detail navigation preserves document center list query', () => {
assert.match(
appShellComposable,
/function openRequestDetail\(request\) \{[\s\S]*name: 'app-document-detail'[\s\S]*params: \{ requestId: request\.claimId \|\| request\.id \},[\s\S]*query: \{ \.\.\.route\.query \}/
/function openRequestDetail\(request, options = \{\}\) \{[\s\S]*name: 'app-document-detail'[\s\S]*params: \{ requestId: request\.claimId \|\| request\.id \},[\s\S]*query: buildDocumentDetailQuery\(options\)/
)
assert.match(
appShellComposable,
/function closeRequestDetail\(\) \{[\s\S]*router\.push\(\{ name: 'app-documents', query: \{ \.\.\.route\.query \} \}\)/
/function closeRequestDetail\(\) \{[\s\S]*router\.push\(\{ name: 'app-documents', query: buildDocumentReturnQuery\(\) \}\)/
)
assert.match(
appShellComposable,
/async function handleRequestDeleted\(payload = \{\}\) \{[\s\S]*router\.push\(\{ name: 'app-documents', query: \{ \.\.\.route\.query \} \}\)/
/async function handleRequestDeleted\(payload = \{\}\) \{[\s\S]*router\.push\(\{ name: 'app-documents', query: buildDocumentReturnQuery\(\) \}\)/
)
})
test('document detail refreshes claim detail instead of relying on stale list cache', () => {
assert.match(appShellComposable, /import \{ fetchExpenseClaimDetail \} from '\.\.\/services\/reimbursements\.js'/)
assert.match(appShellComposable, /import \{ mapExpenseClaimToRequest, useRequests \} from '\.\/useRequests\.js'/)
assert.match(appShellComposable, /const snapshot = normalizeRequestForUi\(selectedRequestSnapshot\.value\)[\s\S]*if \(isSameRequestIdentity\(snapshot, requestId\)\) \{[\s\S]*return snapshot/)
assert.match(appShellComposable, /async function refreshSelectedRequestDetail\(requestOrId = selectedRequestSnapshot\.value\) \{[\s\S]*fetchExpenseClaimDetail\(lookupId\)[\s\S]*mapExpenseClaimToRequest\(payload\)[\s\S]*upsertRequestSnapshot\(mappedRequest\)/)
assert.match(appShellComposable, /function openRequestDetail\(request, options = \{\}\) \{[\s\S]*void refreshSelectedRequestDetail\(request\)/)
assert.match(appShellComposable, /async function handleRequestUpdated\(\) \{[\s\S]*await reloadRequests\(\)[\s\S]*await refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
assert.match(appShellComposable, /route\.name === 'app-document-detail'[\s\S]*void refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
})
test('application entry keeps its own assistant source without creating a separate dialog', () => {
assert.match(appShellComposable, /const SMART_ENTRY_SOURCE_APPLICATION = 'application'/)
assert.match(appShellComposable, /function openExpenseApplicationCreate\(\) \{[\s\S]*openFinancialAssistantCreate\(SMART_ENTRY_SOURCE_APPLICATION\)/)

View File

@@ -16,6 +16,10 @@ const dashboardComponent = readFileSync(
fileURLToPath(new URL('../src/components/dashboard/RiskObservationDashboard.vue', import.meta.url)),
'utf8'
)
const riskDailyTrendChart = readFileSync(
fileURLToPath(new URL('../src/components/charts/RiskDailyTrendChart.vue', import.meta.url)),
'utf8'
)
const overviewViewModel = readFileSync(
fileURLToPath(new URL('../src/composables/useOverviewView.js', import.meta.url)),
'utf8'
@@ -24,6 +28,18 @@ const overviewTemplate = readFileSync(
fileURLToPath(new URL('../src/views/OverviewView.vue', import.meta.url)),
'utf8'
)
const topBarComponent = readFileSync(
fileURLToPath(new URL('../src/components/layout/TopBar.vue', import.meta.url)),
'utf8'
)
const appShellComposable = readFileSync(
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
'utf8'
)
const legacyAppScript = readFileSync(
fileURLToPath(new URL('../src/scripts/App.js', import.meta.url)),
'utf8'
)
const riskLabels = readFileSync(
fileURLToPath(new URL('../src/utils/riskLabels.js', import.meta.url)),
'utf8'
@@ -118,6 +134,28 @@ test('risk dashboard follows the top overview range without card-level selectors
assert.match(dashboardComponent, /RiskDailyTrendChart/)
})
test('overview custom date defaults use current year instead of hard-coded legacy dates', () => {
assert.match(topBarComponent, /createCurrentYearDateRange/)
assert.match(topBarComponent, /formatDateValue/)
assert.match(appShellComposable, /createCurrentYearDateRange\(\)/)
assert.match(legacyAppScript, /createCurrentYearDateRange\(\)/)
assert.doesNotMatch(topBarComponent, /2024-07-06|2024-07-12/)
assert.doesNotMatch(appShellComposable, /2024-07-06|2024-07-12/)
assert.doesNotMatch(legacyAppScript, /2024-07-06|2024-07-12/)
})
test('risk daily trend is bucketed for long ranges and keeps chart labels readable', () => {
assert.match(overviewViewModel, /RISK_DAILY_TREND_MAX_BUCKETS = 14/)
assert.match(overviewViewModel, /aggregateRiskDailyTrendRows/)
assert.match(overviewViewModel, /Math\.ceil\(normalizedRows\.length \/ maxBuckets\)/)
assert.match(overviewViewModel, /buildRiskTrendBucketLabel/)
assert.doesNotMatch(overviewViewModel, /rows\.slice\(-7\)/)
assert.match(riskDailyTrendChart, /const barWidth = computed/)
assert.match(riskDailyTrendChart, /barMaxWidth: 14/)
assert.match(riskDailyTrendChart, /bottomGridSize/)
assert.match(riskDailyTrendChart, /replace\('~', '\\n~'\)/)
})
test('risk dashboard shows loading overlay and realtime refresh status', () => {
assert.match(overviewTemplate, /dashboard-loading-state/)
assert.match(overviewTemplate, /floating/)

View File

@@ -105,6 +105,43 @@ test('reimbursement submitter sees only fixable claim risks', () => {
assert.deepEqual(visibleCards.map((card) => card.id), ['ticket-date'])
})
test('reimbursement detail still shows submitter-fixable attachment risks when viewer identity is incomplete', () => {
const request = {
id: 'RE-20260603083825-876B85XW',
claimId: '2ad80b25-b153-407e-91be-ed2651045fb1',
documentTypeCode: 'claim',
approvalKey: 'draft',
node: 'pending-submit',
employeeId: 'EMP-CLAIM-OWNER',
typeCode: 'travel',
expenseItems: []
}
const currentUserWithoutEmployeeMatch = {
id: 'FRONTEND-AUTH-SNAPSHOT',
employeeId: '',
name: '',
roleCodes: []
}
const cards = [
{
id: 'hotel-limit-risk',
source: 'attachment_analysis',
businessStage: 'reimbursement',
tone: 'high',
risk: 'hotel limit exceeded',
risk_domain: 'invoice',
visibility_scope: 'submitter',
actionability: 'fixable_by_submitter'
}
]
const visibleCards = filterRiskCardsForVisibility(cards, {
request,
currentUser: currentUserWithoutEmployeeMatch
})
assert.deepEqual(visibleCards.map((card) => card.id), ['hotel-limit-risk'])
})
test('finance can see reimbursement compliance risks but not budget governance detail', () => {
const request = {
id: 'RE-202606010002',

View File

@@ -17,6 +17,7 @@ import {
import {
buildExpenseItemViewModel,
buildDraftBlockingIssues,
rebuildExpenseItems,
buildStandardAdjustmentMap,
isApplicationDocumentRequest
} from '../src/views/scripts/travelRequestDetailExpenseModel.js'
@@ -543,9 +544,13 @@ test('AI advice risk section uses compact card styling hooks', () => {
test('expense rows show a major-risk warning icon before time', () => {
assert.match(detailViewTemplate, /'has-major-risk': hasExpenseRiskIndicator\(item\)/)
assert.match(detailViewTemplate, /class="expense-time-content"/)
assert.match(detailViewTemplate, /class="expense-risk-indicator"/)
assert.match(detailViewTemplate, /class="expense-risk-indicator-placeholder"/)
assert.match(detailViewTemplate, /@click="focusExpenseRisk\(item\)"/)
assert.match(detailViewStyle, /\.expense-time-content \{/)
assert.match(detailViewStyle, /\.expense-risk-indicator \{/)
assert.match(detailViewStyle, /\.expense-risk-indicator,\s*\.expense-risk-indicator-placeholder \{/)
assert.match(detailViewScript, /function hasExpenseRiskIndicator\(item\)/)
assert.match(detailViewScript, /buildItemClaimRiskState\(item, resolveClaimRiskFlags\(\)\)/)
})
@@ -556,7 +561,7 @@ test('expense risk indicator can focus and flash related risk card', () => {
assert.match(detailViewTemplate, /'is-highlighted': isHighlightedRiskCard\(card\)/)
assert.match(detailViewScript, /async function focusExpenseRisk\(item\)/)
assert.match(detailViewScript, /document\.getElementById\(resolveRiskCardDomId\(card\)\)/)
assert.match(detailViewScript, /scrollIntoView\(\{ behavior: 'smooth', block: 'center' \}\)/)
assert.match(detailViewScript, /scrollIntoView\(\{ behavior: 'smooth', block: 'nearest', inline: 'nearest' \}\)/)
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card\.is-highlighted/)
assert.match(detailViewStyle, /@keyframes risk-card-flash/)
})
@@ -748,6 +753,50 @@ test('expense detail shows standard-adjusted reimbursable amount separately from
assert.equal(item.hasStandardAdjustment, true)
})
test('plain reimbursable amount does not mark an item as standard-adjusted during detail rebuild', () => {
const item = buildExpenseItemViewModel(
{
id: 'hotel-risk-item',
itemType: 'hotel_ticket',
itemReason: '上海住宿',
itemAmount: 1086,
reimbursableAmount: 1086,
originalItemAmount: 1086,
invoiceId: 'hotel-risk.jpg',
standardAdjustmentAccepted: false,
hasStandardAdjustment: false
},
0,
{ riskFlags: [] }
)
assert.equal(item.standardAdjustmentAccepted, false)
assert.equal(item.hasStandardAdjustment, false)
assert.equal(item.reimbursableAmount, 1086)
const rebuiltItems = rebuildExpenseItems([item], { riskFlags: [] })
assert.equal(rebuiltItems[0].standardAdjustmentAccepted, false)
assert.equal(rebuiltItems[0].hasStandardAdjustment, false)
const riskCards = [
{
id: 'hotel-risk',
source: 'attachment_analysis',
itemId: 'hotel-risk-item',
tone: 'high',
risk: '住宿标准超标。'
}
]
const visibleCards = filterSubmitterResolvedRiskCards({
cards: riskCards,
businessStage: 'reimbursement',
isCurrentApplicant: true,
expenseItems: rebuiltItems,
standardAdjustmentMap: new Map()
})
assert.deepEqual(visibleCards.map((card) => card.id), ['hotel-risk'])
})
test('standard adjustment resolves submitter risk prompt only after accepted while reviewer still sees notice', () => {
const originalRiskCard = {
id: 'risk-hotel-1',