feat(web): 统一平台管理员判定与 AI 工作台申请预览动作接入

- authUser 抽出 resolveAuthUserAdminFlag,统一 isAdmin 解析(含 superadmin、role_codes、中英文角色名),accessControl 复用同一逻辑
- 登录态、应用外壳路由、系统状态接入统一管理员判定,LoginView 与相关 composable 配套调整
- AI 工作台申请提交改为调用新的 /application-preview-action 接口,草稿保存仍走 orchestrator;预审模型补充重叠冲突提示与阻断判断
- 同步更新 accessControl/api-request/ai 预览动作等前端测试
This commit is contained in:
caoxiaozhu
2026-06-20 14:42:04 +08:00
parent 729d833edb
commit 96c2e1099a
21 changed files with 1364 additions and 331 deletions

View File

@@ -62,6 +62,7 @@ test('archived claims can only be deleted by admin users', () => {
assert.equal(canDeleteArchivedExpenseClaims({ roleCodes: ['executive'] }), false)
assert.equal(canDeleteArchivedExpenseClaims({ roleCodes: ['finance'] }), false)
assert.equal(canDeleteArchivedExpenseClaims({ isAdmin: true, roleCodes: ['manager'] }), true)
assert.equal(canDeleteArchivedExpenseClaims({ username: 'superadmin', roleCodes: ['manager'] }), true)
})
test('legacy reimbursement approval and archive centers are no longer accessible app views', () => {
@@ -76,6 +77,7 @@ test('legacy reimbursement approval and archive centers are no longer accessible
test('platform admin users do not enter the personal workbench', () => {
const adminUser = { username: 'admin', isAdmin: true, roleCodes: ['manager', 'finance'] }
const legacyAdminUser = { username: 'superadmin', roleCodes: ['manager'] }
const employeeUser = { username: 'employee@example.com', roleCodes: [] }
const navItems = [
{ id: 'workbench', label: '个人工作台' },
@@ -85,8 +87,10 @@ test('platform admin users do not enter the personal workbench', () => {
]
assert.equal(canAccessAppView(adminUser, 'workbench'), false)
assert.equal(canAccessAppView(legacyAdminUser, 'workbench'), false)
assert.equal(canAccessAppView(employeeUser, 'workbench'), true)
assert.equal(getAccessibleViewIds(adminUser).includes('workbench'), false)
assert.deepEqual(resolveDefaultAuthorizedRoute(legacyAdminUser), { name: 'app-documents' })
assert.deepEqual(resolveDefaultAuthorizedRoute(adminUser), { name: 'app-documents' })
assert.deepEqual(
filterNavItemsByAccess(navItems, adminUser).map((item) => item.id),

View File

@@ -4,7 +4,9 @@ import test from 'node:test'
import {
buildAiApplicationPrecheck,
buildAiApplicationPrecheckMessage,
buildAiApplicationPrecheckThinkingEvents
buildAiApplicationPrecheckThinkingEvents,
buildAiApplicationSubmitConflictMessage,
isAiApplicationPrecheckBlocking
} from '../src/utils/aiApplicationPrecheckModel.js'
const preview = {
@@ -71,6 +73,42 @@ test('application precheck blocks application generation when existing applicati
assert.doesNotMatch(message, /出差申请表草稿已生成/)
})
test('application submit precheck blocks submit and keeps application detail action link', () => {
const precheck = buildAiApplicationPrecheck(preview, {
currentUser: { name: '曹笑竹', departmentName: '技术部' },
claimsPayload: {
items: [
{
claim_no: 'AP-OVERLAP',
document_type: 'expense_application',
expense_type: 'travel_application',
employee_name: '曹笑竹',
status: 'submitted',
risk_flags_json: [
{
source: 'application_detail',
application_detail: {
business_time: '2026-02-20 至 2026-02-23',
reason: '辅助国网仿生产服务器部署',
location: '上海'
}
}
]
}
]
}
})
assert.equal(isAiApplicationPrecheckBlocking(precheck), true)
const message = buildAiApplicationSubmitConflictMessage(preview, precheck)
assert.match(message, /### 发现相同日期已有申请单/)
assert.match(message, /当前不能继续提交/)
assert.match(message, /请先核对申请时间是否填写正确/)
assert.match(message, /\[查看\]\(#ai-open-application-detail:AP-OVERLAP\)/)
assert.doesNotMatch(message, /生成新的出差申请表/)
})
test('application precheck emits thinking events for overlap, budget, and form generation', () => {
const precheck = buildAiApplicationPrecheck(preview, {
currentUser: { name: '曹笑竹' },

View File

@@ -1,127 +1,90 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
AI_APPLICATION_ACTION_SAVE_DRAFT,
AI_APPLICATION_ACTION_SUBMIT,
buildAiApplicationPreviewActionPayload
runAiApplicationPreviewAction
} from '../src/services/aiApplicationPreviewActions.js'
import {
applyApplicationPolicyEstimateResult,
buildApplicationPolicyEstimateRequest,
buildLocalApplicationPreview
} from '../src/utils/expenseApplicationPreview.js'
const applicationPreview = {
fields: {
applicationType: '差旅费用申请',
applicant: '曹笑竹',
grade: 'P5',
department: '技术部',
position: '财务智能化产品经理',
managerName: '向万红',
time: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '辅助国网仿生产服务器部署',
days: '4天',
transportMode: '火车',
lodgingDailyCap: '250元/天',
subsidyDailyCap: '100元/天',
transportPolicy: '按交通费用预估表暂估',
policyEstimate: '交通 720元 + 住宿 1,000元 + 补贴 400元 = 2,120元4天',
amount: '2,120元'
}
}
async function testSubmitActionUsesFastPreviewEndpoint() {
let capturedUrl = ''
let capturedOptions = null
const currentUser = {
username: 'caoxiaozhu@xf.com',
name: '曹笑竹',
departmentName: '技术部',
position: '财务智能化产品经理',
grade: 'P5',
managerName: '向万红',
roleCodes: ['employee']
}
test('save application preview payload uses save draft action without submit wording', () => {
const payload = buildAiApplicationPreviewActionPayload({
actionType: AI_APPLICATION_ACTION_SAVE_DRAFT,
applicationPreview,
currentUser,
conversationId: 'inline-1'
})
assert.equal(payload.user_id, 'caoxiaozhu@xf.com')
assert.equal(payload.conversation_id, 'inline-1')
assert.equal(payload.context_json.session_type, 'application')
assert.equal(payload.context_json.review_action, undefined)
assert.equal(payload.context_json.application_action, 'save_draft')
assert.equal(payload.context_json.application_preview.fields.transportMode, '火车')
assert.match(payload.message, /费用申请保存草稿/)
assert.match(payload.message, /保存草稿/)
assert.doesNotMatch(payload.message, /确认提交/)
})
test('submit application preview payload keeps existing draft id for resubmission', () => {
const payload = buildAiApplicationPreviewActionPayload({
actionType: AI_APPLICATION_ACTION_SUBMIT,
applicationPreview,
currentUser,
conversationId: 'inline-1',
draftPayload: {
claim_id: 'draft-001',
claim_no: 'AP-202602200001'
global.fetch = async (url, options) => {
capturedUrl = String(url)
capturedOptions = options
return {
ok: true,
async json() {
return {
status: 'succeeded',
result: {
draft_payload: {
claim_id: 'claim-fast-submit',
claim_no: 'AP-20260620-FAST',
status: 'submitted',
approval_stage: '直属领导审批'
}
}
}
}
}
}
await runAiApplicationPreviewAction({
actionType: AI_APPLICATION_ACTION_SUBMIT,
applicationPreview: {
fields: {
applicationType: '差旅费用申请',
time: '2026-07-01 至 2026-07-03',
location: '北京',
reason: '项目实施',
days: '3天',
transportMode: '火车',
amount: '1000元'
}
},
currentUser: { username: 'zhangsan@example.com', name: '张三' },
conversationId: 'conversation-fast-submit'
})
assert.equal(payload.context_json.review_action, undefined)
assert.equal(payload.context_json.application_edit_claim_id, 'draft-001')
assert.equal(payload.context_json.draft_claim_id, 'draft-001')
assert.match(payload.message, /费用申请确认提交/)
assert.match(payload.message, /确认提交/)
})
assert.equal(capturedUrl, '/api/v1/reimbursements/application-preview-action')
assert.equal(capturedOptions.method, 'POST')
const body = JSON.parse(capturedOptions.body)
assert.equal(body.context_json.session_type, 'application')
assert.equal(body.context_json.application_stage, 'expense_application')
assert.equal(body.context_json.application_preview.fields.transportMode, '火车')
}
test('travel application preview calculates base standards before transport mode is selected', () => {
const preview = buildLocalApplicationPreview(
'2月20-23日去上海出差辅助国网仿生产服务器部署',
{ name: '曹笑竹', grade: 'P5', location: '武汉' },
{ today: '2026-06-20' }
)
const request = buildApplicationPolicyEstimateRequest(preview, { grade: 'P5', location: '武汉' })
async function testSaveDraftActionKeepsOrchestratorPath() {
let capturedUrl = ''
assert.equal(request.canCalculate, true)
assert.deepEqual(request.payload, {
days: 4,
location: '上海',
grade: 'P5',
transport_mode: null,
origin_location: '武汉',
travel_date: '2026-02-20'
global.fetch = async (url) => {
capturedUrl = String(url)
return {
ok: true,
async json() {
return { status: 'succeeded', result: {} }
}
}
}
await runAiApplicationPreviewAction({
actionType: AI_APPLICATION_ACTION_SAVE_DRAFT,
applicationPreview: { fields: { reason: '项目实施' } },
currentUser: { username: 'zhangsan@example.com', name: '张三' }
})
const estimatedPreview = applyApplicationPolicyEstimateResult(preview, {
days: 4,
location: '上海',
matched_city: '上海',
grade: 'P5',
hotel_rate: 450,
hotel_amount: 1800,
total_allowance_rate: 100,
allowance_amount: 400,
transport_mode: '火车',
transport_origin: '武汉',
transport_destination: '上海',
transport_estimated_amount: 720,
total_amount: 2200,
rule_name: '公司差旅费报销规则',
rule_version: 'v1.0.0'
}, { grade: 'P5', location: '武汉' })
assert.equal(capturedUrl, '/api/v1/orchestrator/run')
}
assert.equal(estimatedPreview.fields.transportMode, '')
assert.equal(estimatedPreview.missingFields.includes('出行方式'), true)
assert.equal(estimatedPreview.fields.lodgingDailyCap, '450元/天')
assert.equal(estimatedPreview.fields.subsidyDailyCap, '100元/天')
assert.equal(estimatedPreview.fields.transportPolicy, '选择火车、飞机或轮船后自动预估交通费用')
assert.equal(estimatedPreview.fields.policyEstimate, '交通待补充 + 住宿 1,800元 + 补贴 400元 = 2,200元4天不含交通')
assert.equal(estimatedPreview.fields.amount, '2,200元不含交通')
async function run() {
await testSubmitActionUsesFastPreviewEndpoint()
await testSaveDraftActionKeepsOrchestratorPath()
console.log('ai-application-preview-actions tests passed')
}
run().catch((error) => {
console.error(error)
process.exit(1)
})

View File

@@ -94,6 +94,46 @@ async function testInjectsAuthenticatedUserHeaders() {
assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true')
}
async function testInjectsLegacyAdminHeaderFromSnakeCaseFlag() {
const sessionStorage = new Map([
[
'x-financial-auth-user',
JSON.stringify({
username: 'superadmin',
name: 'superadmin',
roleCodes: ['manager'],
is_admin: true
})
]
])
global.window = {
sessionStorage: {
getItem(key) {
return sessionStorage.get(key) ?? null
}
}
}
let capturedOptions = null
global.fetch = async (_url, options) => {
capturedOptions = options
return {
ok: true,
async json() {
return { ok: true }
}
}
}
await apiRequest('/reimbursements/claims/demo', { method: 'DELETE' })
assert.equal(capturedOptions.headers['x-auth-username'], 'superadmin')
assert.equal(capturedOptions.headers['x-auth-role-codes'], 'manager')
assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true')
}
async function testFormatsValidationErrors() {
global.fetch = async () => ({
ok: false,
@@ -153,6 +193,7 @@ async function run() {
await testUsesCustomContentTypeHeader()
await testSupportsBlobResponses()
await testInjectsAuthenticatedUserHeaders()
await testInjectsLegacyAdminHeaderFromSnakeCaseFlag()
await testFormatsValidationErrors()
await testRejectsWithCustomTimeoutMessage()
console.log('api-request tests passed')

View File

@@ -118,7 +118,7 @@ test('workbench progress refresh is silent to avoid homepage flashing', () => {
test('document detail navigation preserves document center list query', () => {
assert.match(
appShellComposable,
/function openRequestDetail\(request, options = \{\}\) \{[\s\S]*name: 'app-document-detail'[\s\S]*params: \{ requestId: request\.claimId \|\| request\.id \},[\s\S]*query: buildDocumentDetailQuery\(options\)/
/function openRequestDetail\(request, options = \{\}\) \{[\s\S]*const requestId = resolveRequestDetailLookupId\(request\)[\s\S]*name: 'app-document-detail'[\s\S]*params: \{ requestId \},[\s\S]*query: buildDocumentDetailQuery\(options\)/
)
assert.match(
appShellComposable,
@@ -135,7 +135,8 @@ test('document detail refreshes claim detail instead of relying on stale list ca
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, /function isDetailLookupOnlyPayload\(payload = \{\}\) \{[\s\S]*payload\?\.detailLookupOnly/)
assert.match(appShellComposable, /function openRequestDetail\(request, options = \{\}\) \{[\s\S]*selectedRequestSnapshot\.value = isDetailLookupOnlyRequest \? null : request \|\| null[\s\S]*void refreshSelectedRequestDetail\(isDetailLookupOnlyRequest \? requestId : request\)/)
assert.match(appShellComposable, /async function handleRequestUpdated\(payload = \{\}\) \{[\s\S]*await reloadWorkbenchRequests\(\)[\s\S]*await refreshSelectedRequestDetail\(claimId\)/)
assert.match(appShellComposable, /route\.name === 'app-document-detail'[\s\S]*void refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
})

View File

@@ -53,6 +53,7 @@ test('AI mode offers an inline application shortcut when no candidate applicatio
assert.match(aiMode, /buildLocalApplicationPreviewMessage/)
assert.match(aiMode, /refreshApplicationPreviewEstimate/)
assert.match(aiMode, /applicationPreview:\s*preview/)
assert.match(aiMode, /suggestedActions:\s*buildInlineApplicationPreviewSuggestedActions\(preview\)/)
assert.doesNotMatch(aiMode, /function startAiApplicationDraft/)
assert.doesNotMatch(aiMode, /buildAiApplicationStepPrompt/)
})
@@ -94,12 +95,143 @@ test('AI mode handles document query prompts locally before steward planning', (
assert.match(aiMode, /emit\('open-document', buildAiDocumentDetailRequest\(detailReference\)\)/)
})
test('AI mode continues required application gate decisions into table preview from steward plan', () => {
assert.match(aiMode, /function continueAiRequiredApplicationGateFromPlan\(normalizedPlan, prompt = ''\)/)
assert.match(aiMode, /flow\.flowId === 'travel_application'[\s\S]*void startAiApplicationPreview\('travel', '差旅费', prompt/)
assert.match(aiMode, /flow\.flowId === 'travel_reimbursement'[\s\S]*startAiExpenseDraft\('travel', '差旅费', true/)
assert.match(aiMode, /continueAiRequiredApplicationGateFromPlan\(normalizedPlan, prompt\)/)
test('AI mode asks for manual confirmation before generating application preview table', () => {
assert.match(aiMode, /function buildAiRequiredApplicationGateSuggestedActions\(flow, prompt = ''\)/)
assert.match(aiMode, /label:\s*'确认发起出差申请'/)
assert.match(aiMode, /action_type:\s*'ai_application_start_inline'/)
assert.match(aiMode, /carry_text:\s*prompt/)
assert.match(aiMode, /label:\s*'确认关联已有申请单'/)
assert.match(aiMode, /flow_id:\s*'travel_reimbursement'/)
assert.match(aiMode, /suggestedActions:\s*requiredApplicationContinuationFlow[\s\S]*buildAiRequiredApplicationGateSuggestedActions\(requiredApplicationContinuationFlow, prompt\)/)
assert.doesNotMatch(aiMode, /continueAiRequiredApplicationGateFromPlan\(normalizedPlan, prompt\)/)
assert.doesNotMatch(aiMode, /flow\.flowId === 'travel_application'[\s\S]*void startAiApplicationPreview\('travel', '差旅费', prompt\)/)
assert.match(aiMode, /class="workbench-ai-application-preview application-preview-shell"/)
assert.match(aiMode, /resolveInlineApplicationPreviewRows\(message\)/)
assert.match(aiMode, /commitInlineApplicationPreviewEditor\(message\)/)
})
test('AI mode shows pending feedback before async application preview estimate refresh', () => {
const startPreviewFunction = aiMode.match(
/async function startAiApplicationPreview[\s\S]*?\n}\n\nfunction requestDeleteCurrentConversation/
)?.[0] || ''
assert.match(startPreviewFunction, /const pendingMessage = createInlineMessage\(\s*'assistant',\s*'正在生成申请核对表/)
assert.ok(
startPreviewFunction.indexOf('conversationMessages.value.push(pendingMessage)') <
startPreviewFunction.indexOf('await refreshApplicationPreviewEstimate(')
)
assert.match(startPreviewFunction, /pending:\s*true/)
assert.match(startPreviewFunction, /replaceInlineMessage\(\s*pendingMessage\.id/)
})
test('AI mode handles application preview save and submit through buttons or text commands', () => {
assert.match(aiMode, /AI_APPLICATION_ACTION_SAVE_DRAFT/)
assert.match(aiMode, /AI_APPLICATION_ACTION_SUBMIT/)
assert.match(aiMode, /runAiApplicationPreviewAction/)
assert.match(aiMode, /buildAiApplicationPrecheck/)
assert.match(aiMode, /buildAiApplicationSubmitConflictMessage/)
assert.match(aiMode, /isAiApplicationPrecheckBlocking/)
assert.match(aiMode, /applicationSubmitConfirmOpen/)
assert.match(aiMode, /确认直接提交申请/)
assert.match(aiMode, /function buildInlineApplicationPreviewSuggestedActions\(applicationPreview = \{\}, draftPayload = null\)/)
assert.match(aiMode, /label:\s*'直接提交'/)
assert.match(aiMode, /function resolveInlineApplicationPreviewActionFromText\(text = ''\)/)
assert.match(aiMode, /function executeInlineApplicationPreviewAction\(actionType, sourceMessage = null, options = \{\}\)/)
assert.match(aiMode, /function confirmInlineApplicationSubmit\(\)/)
assert.match(aiMode, /function cancelInlineApplicationSubmitConfirm\(\)/)
assert.match(aiMode, /function handleInlineApplicationPreviewTextAction\(prompt\)/)
assert.match(aiMode, /if \(handleInlineApplicationPreviewTextAction\(cleanPrompt\)\) \{[\s\S]*return[\s\S]*\}/)
assert.match(aiMode, /\[AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT\]\.includes\(actionType\)/)
assert.match(aiMode, /normalizedPreview\.readyToSubmit/)
assert.match(aiMode, /fetchExpenseClaims\(\{ page: 1, pageSize: 100 \}\)/)
assert.match(aiMode, /skipUserMessage/)
assert.match(aiMode, /暂不能提交申请/)
assert.match(aiMode, /#ai-open-application-detail:/)
})
test('AI mode waits for submit confirmation before adding submit action to the conversation', () => {
const executeStart = aiMode.indexOf('async function executeInlineApplicationPreviewAction')
const executeEnd = aiMode.indexOf('\nfunction handleInlineApplicationPreviewTextAction', executeStart)
const executeBlock = aiMode.slice(executeStart, executeEnd)
const confirmGateIndex = executeBlock.indexOf('if (isSubmit && !options.confirmed)')
const requestConfirmIndex = executeBlock.indexOf('requestInlineApplicationSubmitConfirmation', confirmGateIndex)
const confirmedActionPushIndex = executeBlock.indexOf('pushInlineApplicationActionUserMessage(userText)', requestConfirmIndex)
assert.ok(confirmGateIndex >= 0, '直接提交应先进入确认分支')
assert.ok(requestConfirmIndex > confirmGateIndex, '直接提交确认分支应先打开确认弹窗')
assert.ok(confirmedActionPushIndex > requestConfirmIndex, '确认弹窗打开前不应追加“直接提交”用户消息')
assert.match(
executeBlock,
/requestInlineApplicationSubmitConfirmation\(targetMessage,\s*\{\s*\.\.\.options,\s*userText\s*\}\)/
)
const confirmStart = aiMode.indexOf('function confirmInlineApplicationSubmit()')
const confirmEnd = aiMode.indexOf('\nasync function runInlineApplicationSubmitPrecheck', confirmStart)
const confirmBlock = aiMode.slice(confirmStart, confirmEnd)
assert.match(confirmBlock, /userText:\s*context\.userText \|\| '直接提交'/)
assert.match(confirmBlock, /skipUserMessage:\s*false/)
const cancelStart = aiMode.indexOf('function cancelInlineApplicationSubmitConfirm()')
const cancelEnd = aiMode.indexOf('\nfunction confirmInlineApplicationSubmit', cancelStart)
const cancelBlock = aiMode.slice(cancelStart, cancelEnd)
assert.doesNotMatch(cancelBlock, /pushInlineUserMessage|pushInlineApplicationActionUserMessage/)
})
test('AI mode formats saved application draft as a detail table without continuing submit flow', () => {
assert.match(aiMode, /function buildInlineApplicationResultTable\(draftPayload = \{\}, options = \{\}\)/)
assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 操作 \|/)
assert.match(aiMode, /\[查看\]\(\$\{href\}\)/)
const resultStart = aiMode.indexOf('function buildInlineApplicationPreviewActionResultText')
const resultEnd = aiMode.indexOf('\nfunction buildInlineApplicationDetailAction', resultStart)
const resultBlock = aiMode.slice(resultStart, resultEnd)
const submitBranchIndex = resultBlock.indexOf('actionType === AI_APPLICATION_ACTION_SUBMIT')
const saveBranchIndex = resultBlock.indexOf("'### 申请草稿已保存'")
const saveBranch = resultBlock.slice(saveBranchIndex)
assert.ok(submitBranchIndex >= 0)
assert.ok(saveBranchIndex > submitBranchIndex, '保存草稿结果应走非提交分支')
assert.match(
saveBranch,
/buildInlineApplicationResultTable\(draftPayload,\s*\{[\s\S]*statusLabel:\s*'草稿'[\s\S]*stageLabel:\s*'待提交'/
)
assert.doesNotMatch(saveBranch, /进入审批流程/)
const executeStart = aiMode.indexOf('async function executeInlineApplicationPreviewAction')
const executeEnd = aiMode.indexOf('\nfunction handleInlineApplicationPreviewTextAction', executeStart)
const executeBlock = aiMode.slice(executeStart, executeEnd)
assert.match(executeBlock, /targetMessage\.suggestedActions = \[\]/)
assert.doesNotMatch(
executeBlock,
/targetMessage\.suggestedActions = isSubmit[\s\S]*buildInlineApplicationPreviewSuggestedActions\(targetMessage\.applicationPreview, draftPayload\)/
)
assert.match(executeBlock, /suggestedActions:\s*isSubmit\s*\?\s*buildInlineApplicationDetailAction\(draftPayload\)\s*:\s*\[\]/)
})
test('AI mode locks application preview actions while estimate refresh is pending', () => {
assert.match(aiMode, /function isApplicationPreviewEstimatePendingPreview\(applicationPreview = \{\}\)/)
assert.match(
aiMode,
/function buildInlineApplicationPreviewSuggestedActions\(applicationPreview = \{\}, draftPayload = null\) \{[\s\S]*if \(isApplicationPreviewEstimatePendingPreview\(applicationPreview\)\) \{[\s\S]*return \[\]/
)
assert.match(aiMode, /const isAiModeInputLocked = computed\(\(\) => applicationPreviewEstimatePending\.value\)/)
assert.match(aiMode, /:disabled="isAiModeInputLocked"/)
assert.match(aiMode, /v-if="canShowInlineSuggestedActions\(message\)"/)
assert.match(aiMode, /:disabled="isInlineSuggestedActionDisabled\(action, message\)"/)
assert.match(
aiMode,
/message\.suggestedActions = \[\][\s\S]*const committed = await commitApplicationPreviewEditor\(message\)/
)
assert.match(
aiMode,
/if \(applicationPreviewEstimatePending\.value\) \{[\s\S]*toast\('请等待费用测算完成后再继续操作。'\)[\s\S]*return true/
)
assert.match(
aiMode,
/row\.editable && !isApplicationPreviewEstimatePending\(message\) \? 0 : -1/
)
assert.match(
aiMode,
/费用测算正在同步,请稍等,完成后才能保存草稿或直接提交。/
)
})

View File

@@ -211,7 +211,8 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiMode, /<img[\s\S]*class="workbench-ai-orb__image"/)
assert.match(aiMode, /小财管家/)
assert.match(aiMode, /我是您的小财管家/)
assert.match(aiMode, /placeholder="今天我能帮您做点什么?"/)
assert.match(aiMode, /今天我能帮您做点什么?/)
assert.match(aiMode, /费用测算中,请稍等/)
assert.match(aiMode, /rows="3"/)
assert.match(aiMode, /workbench-ai-composer-toolbar/)
assert.match(aiMode, /Axiom Ultra 3\.1/)
@@ -257,7 +258,7 @@ test('AI mode screen follows the approved reference structure', () => {
assert.doesNotMatch(aiMode, /小财管家正在思考/)
assert.doesNotMatch(aiMode, /思考过程/)
assert.doesNotMatch(aiMode, /message\.pending \?/)
assert.match(aiMode, /placeholder="继续和小财管家对话\.\.\."/)
assert.match(aiMode, /继续和小财管家对话\.\.\./)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__head\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__body\)/)
@@ -293,6 +294,9 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiMode, /const finalMessageText = requiredApplicationContinuationFlow[\s\S]*buildAiRequiredApplicationGateAutoMessage\(normalizedPlan, requiredApplicationContinuationFlow\)[\s\S]*buildStewardPlanMessageText\(plan\)/)
assert.match(aiMode, /const hasServerStreamedContent = Boolean\(String\(pendingMessage\.content \|\| ''\)\.trim\(\)\)/)
assert.match(aiMode, /if \(!hasServerStreamedContent\) \{[\s\S]*await streamInlineAssistantContent\(pendingMessage\.id, finalMessageText\)[\s\S]*\}/)
assert.match(aiMode, /if \(actionType === AI_APPLICATION_ACTION_SUBMIT\) \{[\s\S]*buildInlineApplicationResultTable\(draftPayload/)
assert.match(aiMode, /需要查看完整详情时,请点击列表最后一列的“查看”进入单据详情。/)
assert.doesNotMatch(aiMode, /\*\*申请单号:\*\*/)
assert.doesNotMatch(aiMode, /createInlineMessage\('assistant', buildStewardPlanMessageText\(plan\)/)
assert.doesNotMatch(aiMode, /runOrchestrator\(/)
assert.doesNotMatch(aiMode, /buildFallbackAnswer/)

View File

@@ -15,6 +15,10 @@ const workbench = readFileSync(
fileURLToPath(new URL('../src/components/business/PersonalWorkbench.vue', import.meta.url)),
'utf8'
)
const aiMode = readFileSync(
fileURLToPath(new URL('../src/components/business/PersonalWorkbenchAiMode.vue', import.meta.url)),
'utf8'
)
test('workbench document detail keeps workbench as the return target', () => {
assert.match(workbench, /source:\s*'workbench'/)
@@ -22,10 +26,31 @@ test('workbench document detail keeps workbench as the return target', () => {
assert.match(appShell, /:back-label="detailBackLabel"/)
assert.match(appShell, /String\(payload\.returnTo \|\| ''\)\.trim\(\) === 'workbench'/)
assert.match(appShell, /String\(payload\.source \|\| ''\)\.trim\(\) === 'workbench'/)
assert.match(appShell, /openRequestDetail\(request \|\| payload,\s*\{ returnTo \}\)/)
assert.match(appShell, /const detailPayload = request \|\| \{[\s\S]*detailLookupOnly:\s*true[\s\S]*\}/)
assert.match(appShell, /openRequestDetail\(detailPayload,\s*\{ returnTo \}\)/)
assert.match(appShellComposable, /const detailReturnTarget = computed/)
assert.match(appShellComposable, /detailReturnTarget\.value === 'workbench' \? '返回首页' : '返回单据中心'/)
assert.match(appShellComposable, /nextQuery\.returnTo = 'workbench'/)
assert.match(appShellComposable, /router\.push\(\{ name: 'app-workbench' \}\)/)
assert.match(appShellComposable, /router\.push\(\{ name: 'app-documents', query: buildDocumentReturnQuery\(\) \}\)/)
})
test('AI detail links wait for full document detail instead of rendering a half snapshot', () => {
assert.match(aiMode, /detailLookupOnly:\s*true/)
assert.match(
appShell,
/v-else-if="activeView === 'documents' && detailMode && !selectedRequest"[\s\S]*正在加载完整单据详情/
)
assert.match(
appShell,
/const detailPayload = request \|\| \{[\s\S]*detailLookupOnly:\s*true[\s\S]*\}/
)
assert.match(
appShellComposable,
/const isDetailLookupOnlyRequest = isDetailLookupOnlyPayload\(request\)[\s\S]*selectedRequestSnapshot\.value = isDetailLookupOnlyRequest \? null : request \|\| null/
)
assert.match(
appShellComposable,
/void refreshSelectedRequestDetail\(isDetailLookupOnlyRequest \? requestId : request\)/
)
})