feat(web): AI 工作台文件预览/附件关联任务与草稿分支

- 新增 WorkbenchAiFilePreviewDialog 附件预览对话框及 useWorkbenchAiFilePreview,附件支持点击预览
- 新增 attachmentAssociationJobs/linkedReimbursementDraftJobs 前端服务与对应 composable,接入后台任务轮询与状态展示
- 新增 travelReimbursementDraftBranchModel 草稿分支模型,报销关联门控支持跳过/选择草稿
- PersonalWorkbenchAiMode 及各 composable(expense/document/steward/application-preview/attachment-association)重构适配,WorkbenchAiComposer/FileStrip 样式与交互完善
- DocumentsCenter/ReceiptFolder/TravelReimbursementCreate 等视图及 scripts 重构,风险/差旅规划/审批等工具适配
- 新增/更新前端测试:application-result-card、reimbursement-list-preview-fetch、guided-flow、composer-components 等
This commit is contained in:
caoxiaozhu
2026-06-24 10:42:50 +08:00
parent 0264a4b5b4
commit ee730aa31c
73 changed files with 2528 additions and 379 deletions

View File

@@ -9,6 +9,10 @@ const routerScript = readFileSync(
fileURLToPath(new URL('../src/router/index.js', import.meta.url)),
'utf8'
)
const backendUnavailableScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/BackendUnavailableRouteView.js', import.meta.url)),
'utf8'
)
test('app route guard allows stale healthy state when health check times out', () => {
assert.match(routerScript, /checkBackendHealth\(\{\s*allowStaleOnTimeout:\s*true\s*\}\)/)
@@ -49,3 +53,11 @@ test('backend health timeout does not block app rendering when stale fallback is
global.fetch = originalFetch
}
})
test('backend unavailable page automatically recovers after service startup race', () => {
assert.match(backendUnavailableScript, /onMounted\(\s*\(\)\s*=>\s*\{/)
assert.match(backendUnavailableScript, /startAutoRecover\(\)/)
assert.match(backendUnavailableScript, /globalThis\.setInterval/)
assert.match(backendUnavailableScript, /router\.replace\(loggedIn\.value \? resolveEntryRoute\(\) : \{ name: 'login' \}\)/)
assert.match(backendUnavailableScript, /onBeforeUnmount\(\s*\(\)\s*=>\s*\{/)
})

View File

@@ -268,7 +268,7 @@ test('assistant scope guard blocks unsupported non-financial intent', () => {
Array.from({ length: 4 }, () => ASSISTANT_SCOPE_ACTION_SWITCH)
)
assert.match(greetingGuard.text, /小财管家暂时不处理「你好」/)
assert.match(greetingGuard.text, /可以直接点下面的场景继续/)
assert.match(greetingGuard.text, /可以直接点下面的场景继续/)
assert.equal(guard.suggestedActions.length, 4)
assert.equal(guard.blocked, true)
assert.equal(guard.targetSessionType, '')
@@ -466,6 +466,28 @@ test('application preview parses same-month shorthand date range', () => {
assert.doesNotMatch(preview.fields.reason, /小财管家继续执行/)
})
test('application preview splits compact destination and business purpose', () => {
const preview = buildLocalApplicationPreview(
'2026-02-20 至 2026-02-23去上海辅助国网仿生产服务器部署火车',
{
name: '曹笑竹',
departmentName: '技术部',
position: '财务智能化产品经理',
managerName: '向万红',
grade: 'P5'
},
{ today: '2026-06-09' }
)
assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23')
assert.equal(preview.fields.days, '4天')
assert.equal(preview.fields.location, '上海')
assert.equal(preview.fields.reason, '辅助国网仿生产服务器部署')
assert.equal(preview.fields.transportMode, '火车')
assert.equal(preview.readyToSubmit, true)
assert.deepEqual(preview.validationIssues, [])
})
test('application preview blocks submit when date range conflicts with explicit days', () => {
const preview = buildLocalApplicationPreview(
'申请2月20-23日去上海出差3天辅助国网仿生产服务器部署火车',
@@ -569,6 +591,40 @@ test('application preview trusts model-refined fields over noisy source candidat
assert.deepEqual(preview.validationIssues, [])
})
test('application preview normalizes model-refined location mixed with business content', () => {
const rawText = '申请2月20日-23日火车出差事由辅助国网仿生产服务器部署'
const preview = buildModelRefinedApplicationPreview(
buildLocalApplicationPreview(rawText, { name: '曹笑竹', grade: 'P5' }, { today: '2026-06-09' }),
{
parse_strategy: 'llm_primary',
entities: [
{ type: 'expense_type', value: '差旅费', normalized_value: 'travel' },
{ type: 'location', value: '上海辅助国网仿生产服务器', normalized_value: '上海辅助国网仿生产服务器' },
{ type: 'reason', value: '辅助国网仿生产服务器部署', normalized_value: '辅助国网仿生产服务器部署' },
{ type: 'transport_mode', value: '火车', normalized_value: '火车' },
{ type: 'policy_total_amount', value: '2120元', normalized_value: '2120' }
],
time_range: {
start_date: '2026-02-20',
end_date: '2026-02-23'
},
missing_slots: []
},
rawText,
{ name: '曹笑竹', grade: 'P5' }
)
const estimateRequest = buildApplicationPolicyEstimateRequest(preview, { grade: 'P5', location: '武汉' })
const footer = buildApplicationPreviewFooterMessage(preview)
assert.equal(preview.fields.location, '上海')
assert.equal(preview.fields.reason, '辅助国网仿生产服务器部署')
assert.equal(preview.readyToSubmit, true)
assert.deepEqual(preview.validationIssues, [])
assert.match(footer, /#application-submit/)
assert.equal(estimateRequest.canCalculate, true)
assert.equal(estimateRequest.payload.location, '上海')
})
test('application preview blocks submit when transport candidates conflict', () => {
const preview = buildLocalApplicationPreview(
'申请2月20-23日去上海出差4天辅助国网仿生产服务器部署出行方式飞机坐火车',
@@ -1054,7 +1110,7 @@ test('steward application missing transport blocks preview table', () => {
assert.match(submitComposerScript, /applicationPreview:\s*pauseForMissingFields \? null : applicationPreview/)
assert.match(submitComposerScript, /我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表/)
assert.match(submitComposerScript, /applicationPreview:\s*normalized/)
assert.doesNotMatch(submitComposerScript, /请先告诉我打算怎么出行:\*\*火车、飞机或轮船\*\*/)
assert.doesNotMatch(submitComposerScript, /请先告诉我打算怎么出行:\*\*火车、飞机或轮船\*\*/)
assert.match(suggestedActionsScript, /payload\.applicationPreview/)
assert.match(suggestedActionsScript, /function continueStewardApplicationFieldCompletion/)

View File

@@ -57,6 +57,9 @@ function testReceiptFolderViewSurface() {
assert.match(view, /buildReceiptFile\(item\)/)
assert.match(view, /source: selectedDraft \? 'detail' : 'receipt-folder'/)
assert.match(view, /emit\('open-assistant'/)
assert.match(view, /REIMBURSEMENT_LIST_PREVIEW_PARAMS/)
assert.match(view, /fetchExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.doesNotMatch(view, /const claims = await fetchExpenseClaims\(\)/)
}
function testReceiptFolderServiceContract() {
@@ -160,18 +163,19 @@ function testReceiptFolderDetailLayoutAdjustments() {
function testAssistantUnlinkedReceiptPrompt() {
const submitComposer = readProjectFile('web/src/views/scripts/useTravelReimbursementSubmitComposer.js')
const assistantView = readProjectFile('web/src/views/scripts/TravelReimbursementCreateView.js')
const attachmentFlow = readProjectFile('web/src/views/scripts/travelReimbursementSubmitAttachmentFlow.js')
const suggestedActions = readProjectFile('web/src/views/scripts/useTravelReimbursementSuggestedActions.js')
assert.match(submitComposer, /fetchReceiptFolderItems/)
assert.match(submitComposer, /promptUnlinkedReceiptFolderIfNeeded/)
assert.match(submitComposer, /fetchReceiptFolderItems\('unlinked'\)/)
assert.match(submitComposer, /skipReceiptFolderUnlinkedPrompt/)
assert.match(submitComposer, /open_receipt_folder/)
assert.match(submitComposer, /continue_upload_with_unlinked_receipts/)
assert.match(assistantView, /actionType === 'open_receipt_folder'/)
assert.match(assistantView, /router\.push\(\{ name: 'app-receiptFolder' \}\)/)
assert.match(assistantView, /actionType === 'continue_upload_with_unlinked_receipts'/)
assert.match(assistantView, /skipReceiptFolderUnlinkedPrompt: true/)
assert.match(attachmentFlow, /fetchReceiptFolderItems\('unlinked'\)/)
assert.match(attachmentFlow, /skipReceiptFolderUnlinkedPrompt/)
assert.match(attachmentFlow, /open_receipt_folder/)
assert.match(attachmentFlow, /continue_upload_with_unlinked_receipts/)
assert.match(suggestedActions, /actionType === 'open_receipt_folder'/)
assert.match(suggestedActions, /router\.push\(\{ name: 'app-receiptFolder' \}\)/)
assert.match(suggestedActions, /actionType === 'continue_upload_with_unlinked_receipts'/)
assert.match(suggestedActions, /skipReceiptFolderUnlinkedPrompt: true/)
}
function run() {

View File

@@ -0,0 +1,37 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import test from 'node:test'
const root = process.cwd()
function readProjectFile(path) {
return readFileSync(join(root, path), 'utf8')
}
test('workbench and document list refreshes use preview pagination', () => {
const useRequests = readProjectFile('web/src/composables/useRequests.js')
const useAppShell = readProjectFile('web/src/composables/useAppShell.js')
const documentsCenter = readProjectFile('web/src/views/DocumentsCenterView.vue')
const approvalCenter = readProjectFile('web/src/views/scripts/ApprovalCenterView.js')
const archiveCenter = readProjectFile('web/src/views/scripts/ArchiveCenterView.js')
assert.match(useRequests, /REIMBURSEMENT_LIST_PREVIEW_PARAMS/)
assert.match(useRequests, /fetchExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.doesNotMatch(useRequests, /fetchAllExpenseClaims\(\)/)
assert.match(useAppShell, /REIMBURSEMENT_LIST_PREVIEW_PARAMS/)
assert.match(useAppShell, /fetchApprovalExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.doesNotMatch(useAppShell, /fetchAllApprovalExpenseClaims\(\)/)
assert.match(documentsCenter, /fetchApprovalExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.match(documentsCenter, /fetchArchivedExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.doesNotMatch(documentsCenter, /fetchAllApprovalExpenseClaims\(\)/)
assert.doesNotMatch(documentsCenter, /fetchAllArchivedExpenseClaims\(\)/)
assert.match(approvalCenter, /fetchApprovalExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.doesNotMatch(approvalCenter, /fetchApprovalExpenseClaims\(\)/)
assert.match(archiveCenter, /fetchArchivedExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.doesNotMatch(archiveCenter, /fetchArchivedExpenseClaims\(\)/)
})

View File

@@ -20,12 +20,12 @@ test('steward plan summary uses warm guidance copy for application flow', () =>
next_action: 'confirm_create_application'
})
assert.match(message, /我先帮把步骤理清楚/)
assert.match(message, /我先看了一下,这次主要是 \*\*1 个事项\*\*/)
assert.match(message, /我先帮把步骤理清楚/)
assert.match(message, /我先看了一下,这次主要是 \*\*1 个事项\*\*/)
assert.match(message, /为了不让步骤混在一起/)
assert.match(message, /我会请申请助手先把申请单草稿整理出来/)
assert.match(message, /看这个顺序是否合适/)
assert.match(message, /需要补充的信息会在具体步骤里再温和提醒/)
assert.match(message, /这步交给申请助手——先把申请单草稿拉出来给您过目/)
assert.match(message, /看这个顺序是否合适/)
assert.match(message, /需要补充的信息会在具体步骤里再温和提醒/)
assert.doesNotMatch(message, /我会这样推进/)
assert.doesNotMatch(message, /不会一次性把所有动作都执行掉/)
assert.doesNotMatch(message, /交给申请助手生成申请单核对结果/)
@@ -59,8 +59,8 @@ test('steward plan summary guides bare reimbursement intent into scene selection
const message = buildStewardPlanMessageText(plan)
assert.match(message, /我来带发起报销/)
assert.match(message, /现在只说了要报销/)
assert.match(message, /我来带发起报销/)
assert.match(message, /现在只说了要报销/)
assert.match(message, /先选报销场景/)
assert.match(message, /差旅费、交通费、住宿费/)
assert.doesNotMatch(message, /步骤混在一起/)

View File

@@ -94,6 +94,10 @@ const submitComposerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
'utf8'
)
const submitDraftPreflightScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementSubmitDraftPreflight.js', import.meta.url)),
'utf8'
)
const messageHandlersScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementCreateViewMessageHandlers.js', import.meta.url)),
'utf8'
@@ -477,6 +481,12 @@ test('guided flow is local until final confirmation or collected query handoff',
assert.doesNotMatch(guidedFlowScript, /startExpenseClaimDraftFlowStep/)
assert.match(guidedModelScript, /review_action:\s*['"]save_draft['"]/)
assert.match(guidedFlowScript, /fetchExpenseClaims/)
assert.match(guidedFlowScript, /REIMBURSEMENT_LIST_PREVIEW_PARAMS/)
assert.match(guidedFlowScript, /fetchExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.match(submitDraftPreflightScript, /REIMBURSEMENT_LIST_PREVIEW_PARAMS/)
assert.match(submitDraftPreflightScript, /fetchExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.doesNotMatch(guidedFlowScript, /fetchExpenseClaims\(\)/)
assert.doesNotMatch(submitDraftPreflightScript, /fetchExpenseClaims\(\)/)
assert.match(guidedFlowScript, /GUIDED_ACTION_SELECT_REQUIRED_APPLICATION/)
assert.match(guidedFlowScript, /isGuidedReimbursementReadyForReview\(guidedFlowState\.value\)[\s\S]*pushReimbursementSummary\(\)/)
assert.match(guidedFlowScript, /isGuidedReimbursementReadyForReview\(currentState\) && fileNames\.length[\s\S]*buildGuidedReviewSubmitOptions\(currentState, mergedFiles\)[\s\S]*skipDraftAssociationPrompt:\s*true[\s\S]*skipUserMessage:\s*true[\s\S]*submitExistingComposer\(submitOptions\)/)

View File

@@ -434,6 +434,13 @@ test('AI advice ignores approval opinions and flow logs as risks', () => {
severity: 'info',
label: '财务审核通过',
message: '周晓彤 已完成财务审核,进入归档入账。'
},
{
source: 'application_link_sync',
event_type: 'expense_application_reimbursement_deleted',
severity: 'warning',
label: '关联报销单已删除',
message: '关联报销单 RDELETE01 已删除,申请单已回到待关联状态。'
}
]
})

View File

@@ -4,6 +4,10 @@ import test from 'node:test'
import { buildInlineApplicationPreview } from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
import { buildStewardSuggestedActions } from '../src/views/scripts/stewardPlanModel.js'
import { useWorkbenchAiActionRouter } from '../src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js'
import {
CONTINUE_REIMBURSEMENT_DRAFT_ACTION,
CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION
} from '../src/views/scripts/travelReimbursementAssociationGateModel.js'
test('workbench steward application confirmation opens inline application preview directly', () => {
const [action] = buildStewardSuggestedActions({
@@ -136,3 +140,108 @@ test('workbench reimbursement skip link action opens new reimbursement flow', ()
label: '不关联,单独新建报销单'
})
})
test('workbench draft continuation action asks for attachments or description', () => {
let continuationPayload = null
let fallbackConversationStarted = false
const router = useWorkbenchAiActionRouter({
aiExpenseDraft: { value: null },
applicationFlow: {
isInlineSuggestedActionDisabled: () => false,
executeInlineApplicationPreviewAction: () => {}
},
assistantDraft: { value: '' },
attachmentFlow: {
confirmAiAttachmentAssociation: () => {}
},
emit: () => {},
expenseFlow: {
linkAiExpenseApplication: () => {},
promptAiReimbursementDraftContinuation: (payload) => {
continuationPayload = payload
},
promptStandaloneReimbursementDraftCreation: () => {},
pushInlineExpenseSceneSelectionPrompt: () => {},
startAiApplicationPreviewFromAction: () => {},
startAiExpenseDraft: () => {},
startAiReimbursementAssociationGate: () => {}
},
focusAiModeInput: () => {},
hasInlineAttachmentOcrDetails: () => false,
resolveLatestInlineUserPrompt: () => '',
selectedFiles: { value: [] },
startInlineConversation: () => {
fallbackConversationStarted = true
},
toast: () => {},
toggleInlineAttachmentOcrDetails: () => {}
})
router.handleInlineSuggestedAction({
label: '继续关联草稿 RE-202606-010',
action_type: CONTINUE_REIMBURSEMENT_DRAFT_ACTION,
payload: {
claim_id: 'draft-travel-1',
claim_no: 'RE-202606-010',
original_message: '我要报销'
}
})
assert.equal(fallbackConversationStarted, false)
assert.deepEqual(continuationPayload, {
claim_id: 'draft-travel-1',
claim_no: 'RE-202606-010',
original_message: '我要报销'
})
})
test('workbench standalone draft action asks before creating a new reimbursement draft', () => {
let standalonePrompt = null
let fallbackConversationStarted = false
const router = useWorkbenchAiActionRouter({
aiExpenseDraft: { value: null },
applicationFlow: {
isInlineSuggestedActionDisabled: () => false,
executeInlineApplicationPreviewAction: () => {}
},
assistantDraft: { value: '' },
attachmentFlow: {
confirmAiAttachmentAssociation: () => {}
},
emit: () => {},
expenseFlow: {
linkAiExpenseApplication: () => {},
promptAiReimbursementDraftContinuation: () => {},
promptStandaloneReimbursementDraftCreation: (sourceText, label) => {
standalonePrompt = { sourceText, label }
},
pushInlineExpenseSceneSelectionPrompt: () => {},
startAiApplicationPreviewFromAction: () => {},
startAiExpenseDraft: () => {},
startAiReimbursementAssociationGate: () => {}
},
focusAiModeInput: () => {},
hasInlineAttachmentOcrDetails: () => false,
resolveLatestInlineUserPrompt: () => '',
selectedFiles: { value: [] },
startInlineConversation: () => {
fallbackConversationStarted = true
},
toast: () => {},
toggleInlineAttachmentOcrDetails: () => {}
})
router.handleInlineSuggestedAction({
label: '独立新建报销单',
action_type: CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION,
payload: {
original_message: '我要报销'
}
})
assert.equal(fallbackConversationStarted, false)
assert.deepEqual(standalonePrompt, {
sourceText: '我要报销',
label: '独立新建报销单'
})
})

View File

@@ -0,0 +1,46 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildInlineApplicationDetailAction,
buildInlineApplicationPreviewActionResultText
} from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
import {
AI_APPLICATION_ACTION_SAVE_DRAFT,
AI_APPLICATION_ACTION_SUBMIT
} from '../src/services/aiApplicationPreviewActions.js'
const draftPayload = {
claim_no: 'AUKSNUCFD',
claim_id: 'claim-1001',
approval_stage: '直属领导审批',
start_date: '2026-02-20',
location: '上海辅助国网仿生产服务器',
reason: '差旅费用申请'
}
test('application result card stays display-only while the detail shortcut keeps navigation', () => {
const resultText = buildInlineApplicationPreviewActionResultText(AI_APPLICATION_ACTION_SUBMIT, {
result: { draft_payload: draftPayload }
})
const [detailAction] = buildInlineApplicationDetailAction(draftPayload)
assert.match(resultText, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \|/)
assert.doesNotMatch(resultText, /\| 操作 \|/)
assert.doesNotMatch(resultText, /\[查看\]/)
assert.doesNotMatch(resultText, /点击卡片.*操作.*查看/)
assert.equal(detailAction?.label, '查看单据详情')
assert.equal(detailAction?.action_type, 'open_application_detail')
assert.equal(detailAction?.payload?.claim_no, 'AUKSNUCFD')
})
test('saved draft result also avoids the duplicate in-card view guidance', () => {
const resultText = buildInlineApplicationPreviewActionResultText(AI_APPLICATION_ACTION_SAVE_DRAFT, {
result: { draft_payload: draftPayload }
})
assert.match(resultText, /申请草稿已保存/)
assert.doesNotMatch(resultText, /\| 操作 \|/)
assert.doesNotMatch(resultText, /\[查看\]/)
assert.doesNotMatch(resultText, /点击卡片.*操作.*查看/)
})

View File

@@ -9,9 +9,13 @@ function readSource(path) {
const aiModeComponent = readSource('../src/components/business/PersonalWorkbenchAiMode.vue')
const aiModeTemplate = readSource('../src/components/business/PersonalWorkbenchAiMode.template.html')
const aiModeStyles = readSource('../src/assets/styles/components/personal-workbench-ai-mode.css')
const composerComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiComposer.vue')
const fileStripComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiFileStrip.vue')
const filePreviewComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiFilePreviewDialog.vue')
const filePreviewStyles = readSource('../src/assets/styles/components/workbench-ai-file-preview-dialog.css')
const aiModeRuntime = readSource('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js')
const filePreviewRuntime = readSource('../src/composables/workbenchAiMode/useWorkbenchAiFilePreview.js')
function countOccurrences(source, pattern) {
return source.match(pattern)?.length || 0
@@ -44,3 +48,57 @@ test('shared workbench file strip preserves OCR status badges', () => {
assert.match(fileStripComponent, /mdi mdi-text-recognition/)
assert.match(fileStripComponent, /:title="file\.ocrState\.title \|\| file\.ocrState\.label"/)
})
test('AI mode primes attachment OCR synchronously after file selection', () => {
assert.match(
filePreviewRuntime,
/watch\(\s*selectedFiles,\s*\(files(?:,\s*previousFiles\s*=\s*\[\])?\)\s*=>\s*\{[\s\S]*attachmentFlow\.primeAiModeReceiptContext\(files\)[\s\S]*\},\s*\{\s*flush:\s*'sync'\s*\}\s*\)/
)
})
test('AI mode keeps conversation anchored above selected attachments', () => {
assert.match(
filePreviewRuntime,
/watch\(\s*selectedFiles,\s*\(files,\s*previousFiles\s*=\s*\[\]\)\s*=>\s*\{[\s\S]*const fileCountChanged = files\.length !== previousFiles\.length[\s\S]*scrollInlineConversationToBottom\(\{\s*force:\s*true\s*\}\)[\s\S]*\},\s*\{\s*flush:\s*'sync'\s*\}\s*\)/
)
assert.match(aiModeStyles, /\.workbench-ai-conversation-bottom\s*\{[\s\S]*gap:\s*14px;/)
assert.match(aiModeStyles, /\.workbench-ai-thread\s*\{[\s\S]*scroll-padding-bottom:\s*42px;/)
})
test('AI mode lays selected attachments in a horizontal scroll strip', () => {
assert.match(aiModeStyles, /\.workbench-ai-file-strip\s*\{[\s\S]*flex-wrap:\s*nowrap;[\s\S]*overflow-x:\s*auto;[\s\S]*overflow-y:\s*hidden;/)
assert.match(aiModeStyles, /\.workbench-ai-file-card\s*\{[\s\S]*flex:\s*0 0 312px;/)
assert.match(fileStripComponent, /role="button"/)
assert.match(fileStripComponent, /@click="runtime\.openAiModeFilePreview\(file\.key\)"/)
assert.match(fileStripComponent, /@click\.stop="runtime\.removeAiModeFile\(file\.key\)"/)
})
test('AI mode attachment preview opens a split source and recognition dialog', () => {
assert.match(aiModeComponent, /import WorkbenchAiFilePreviewDialog from '\.\/workbench-ai\/WorkbenchAiFilePreviewDialog\.vue'/)
assert.match(aiModeTemplate, /<WorkbenchAiFilePreviewDialog :runtime="workbenchAiRuntime" \/>/)
assert.match(aiModeRuntime, /useWorkbenchAiFilePreview\(/)
assert.match(aiModeRuntime, /\.\.\.filePreview,/)
assert.match(filePreviewRuntime, /URL\.createObjectURL\(rawFile\)/)
assert.match(filePreviewRuntime, /attachmentFlow\.resolveAiModeReceiptRecognitionState\(rawFile\)/)
assert.match(filePreviewComponent, /class="workbench-ai-file-preview-source"/)
assert.match(filePreviewComponent, /class="workbench-ai-file-preview-insight"/)
assert.match(filePreviewComponent, /<img[\s\S]*v-if="preview\.sourceKind === 'image'"/)
assert.match(filePreviewComponent, /<iframe[\s\S]*v-else-if="preview\.sourceKind === 'pdf'"/)
assert.match(filePreviewComponent, /v-for="field in preview\.ocrFields"/)
})
test('AI mode attachment preview centers inside the main content area beside the sidebar', () => {
assert.match(filePreviewStyles, /--workbench-ai-preview-sidebar-offset:\s*var\(--sidebar-expanded-width,\s*304px\);/)
assert.match(
filePreviewStyles,
/\.workbench-ai-file-preview-mask\s*\{[\s\S]*grid-template-columns:\s*var\(--workbench-ai-preview-sidebar-offset\) minmax\(0,\s*1fr\);/
)
assert.match(
filePreviewStyles,
/\.workbench-ai-file-preview-dialog\s*\{[\s\S]*grid-column:\s*2;[\s\S]*justify-self:\s*center;/
)
assert.match(
filePreviewStyles,
/@media \(max-width:\s*900px\)\s*\{[\s\S]*--workbench-ai-preview-sidebar-offset:\s*0px;[\s\S]*grid-template-columns:\s*minmax\(0,\s*1fr\);/
)
})

View File

@@ -195,14 +195,14 @@ test('AI mode formats saved application draft as a detail table without continui
assert.match(aiMode, /function normalizeInlineApplicationStatusLabel\(value, fallback = ''\)/)
assert.match(aiMode, /submitted:\s*'审批中'/)
assert.match(aiMode, /const statusLabel = normalizeInlineApplicationStatusLabel\(info\.statusLabel, options\.statusLabel\)/)
assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \| 操作 \|/)
assert.match(aiMode, /\[查看\]\(\$\{href\}\)/)
assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \|/)
assert.doesNotMatch(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \| 操作 \|/)
assert.doesNotMatch(aiMode, /\[查看\]\(\$\{href\}\)/)
assert.match(aiMode, /dateLabel:\s*rangeText \|\| dateText \|\| resolveBodyField\(\['时间', '日期', '申请时间'\]\) \|\| '待补充'/)
assert.match(aiMode, /locationLabel:[\s\S]*resolveBodyField\(\['地点', '目的地'\]\) \|\| '待补充'/)
assert.match(aiMode, /reasonLabel:[\s\S]*resolveBodyField\(\['事由', '事件', '申请事由'\]\) \|\| '待补充'/)
assert.match(aiMode, /buildInlineApplicationActionDetailHref\(info\)/)
assert.match(aiMode, /params\.set\('claim_id', claimId\)/)
assert.match(aiMode, /params\.set\('claim_no', claimNo\)/)
assert.match(aiMode, /function buildInlineApplicationDetailAction\(draftPayload = \{\}\)/)
assert.match(aiMode, /action_type:\s*'open_application_detail'/)
const resultStart = aiMode.indexOf('function buildInlineApplicationPreviewActionResultText')
const resultEnd = aiMode.indexOf('\nfunction buildInlineApplicationDetailAction', resultStart)
@@ -227,7 +227,7 @@ test('AI mode formats saved application draft as a detail table without continui
executeBlock,
/targetMessage\.suggestedActions = isSubmit[\s\S]*buildInlineApplicationPreviewSuggestedActions\(targetMessage\.applicationPreview, draftPayload\)/
)
assert.match(executeBlock, /suggestedActions:\s*isSubmit\s*\?\s*buildInlineApplicationDetailAction\(draftPayload\)\s*:\s*\[\]/)
assert.match(executeBlock, /suggestedActions:\s*buildInlineApplicationDetailAction\(draftPayload\)/)
})
test('AI mode locks application preview actions while estimate refresh is pending', () => {

View File

@@ -244,9 +244,10 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /buildFileIdentity,[\s\S]*collectReceiptFiles[\s\S]*travelReimbursementAttachmentModel\.js/)
assert.match(aiModeSurface, /MAX_ATTACHMENTS,[\s\S]*mergeFilesWithLimit[\s\S]*travelReimbursementAttachmentModel\.js/)
assert.match(aiModeSurface, /import \* as aiAttachmentAssociationModel from '\.\.\/\.\.\/utils\/aiAttachmentAssociationModel\.js'/)
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.resolveAiAttachmentAssociationMatch/)
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.buildAiAttachmentAssociationResultMessage/)
assert.match(aiModeSurface, /syncExpenseClaimFilesToDraft/)
assert.match(aiModeSurface, /createAttachmentAssociationJob[\s\S]*services\/attachmentAssociationJobs\.js/)
assert.match(aiModeSurface, /fetchAttachmentAssociationJob[\s\S]*services\/attachmentAssociationJobs\.js/)
assert.match(aiModeSurface, /import \{ recognizeOcrFiles \} from '\.\.\/\.\.\/services\/ocr\.js'/)
assert.match(aiModeSurface, /const AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION = 'confirm_ai_attachment_association'/)
assert.match(aiModeSurface, /const AI_ATTACHMENT_OCR_DETAIL_ACTION = 'show_ai_attachment_ocr_details'/)
@@ -269,10 +270,16 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /resolveAiAttachmentAssociationClaimNo\(actionPayload\)/)
assert.match(aiModeSurface, /if \(actionType === AI_ATTACHMENT_OCR_DETAIL_ACTION\)/)
assert.match(aiModeSurface, /const collected = await collectAiModeReceiptContext\(files\)/)
assert.match(aiModeSurface, /const claims = extractExpenseClaimItems\(claimsPayload\)/)
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.resolveAiAttachmentAssociationMatch\(claims, collected\.ocrDocuments\)/)
assert.match(aiModeSurface, /aiAttachmentAssociationRuntime\.set\(associationId/)
assert.match(aiModeSurface, /attachmentOcrDetails,\s*[\s\S]*includeOcrDetails: Boolean\(attachmentOcrDetails\)/)
assert.match(aiModeSurface, /function extractReceiptIdsFromOcrDocuments\(documents = \[\]\)/)
assert.match(aiModeSurface, /const receiptIds = attachmentJobFlow\.extractReceiptIdsFromOcrDocuments\(collected\.ocrDocuments\)/)
assert.match(aiModeSurface, /await createAttachmentAssociationJob\(\{[\s\S]*receipt_ids: receiptIds,[\s\S]*conversation_id: conversationId\?\.value/)
assert.match(aiModeSurface, /attachmentAssociationJob: job/)
assert.match(aiModeSurface, /async function pollJob\(/)
assert.match(aiModeSurface, /fetchAttachmentAssociationJob\(normalizedJobId\)/)
assert.match(aiModeSurface, /function resumePendingJobs\(\)/)
assert.match(aiModeSurface, /resumePendingAiAttachmentAssociationJobs: attachmentJobFlow\.resumePendingJobs/)
assert.match(aiModeSurface, /attachmentFlow\.resumePendingAiAttachmentAssociationJobs\(\)/)
assert.match(aiModeSurface, /attachmentAssociationJob: normalizeInlineAttachmentAssociationJob/)
assert.match(aiModeSurface, /async function confirmAiAttachmentAssociation\(actionPayload = \{\}, sourceMessage = null\)/)
assert.match(aiModeSurface, /syncExpenseClaimFilesToDraft\(\{[\s\S]*fetchExpenseClaimDetail,[\s\S]*createExpenseClaimItem,[\s\S]*uploadExpenseClaimItemAttachment/)
assert.match(aiModeSurface, /if \(actionType === AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION\)/)
@@ -388,7 +395,8 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /const hasServerStreamedContent = Boolean\(String\(pendingMessage\.content \|\| ''\)\.trim\(\)\)/)
assert.match(aiModeSurface, /if \(!hasServerStreamedContent\) \{[\s\S]*await streamInlineAssistantContent\(pendingMessage\.id, finalMessageText\)[\s\S]*\}/)
assert.match(aiModeSurface, /if \(actionType === AI_APPLICATION_ACTION_SUBMIT\) \{[\s\S]*buildInlineApplicationResultTable\(draftPayload/)
assert.match(aiModeSurface, /需要查看完整详情时,请点击卡片“操作”行的“查看”进入单据详情。/)
assert.match(aiModeSurface, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \|/)
assert.doesNotMatch(aiModeSurface, /需要查看完整详情时,请点击卡片“操作”行的“查看”进入单据详情。/)
assert.match(aiModeSurface, /function buildInlineApplicationActionFailureText\(error, isSubmit\)/)
assert.match(aiModeSurface, /我已保留当前申请核对表/)
assert.match(aiModeSurface, /applicationPreview:\s*targetMessage\.applicationPreview/)
@@ -549,12 +557,25 @@ test('AI mode normal assistant requests include OCR context for uploaded receipt
assert.match(aiModeSurface, /const aiModeReceiptContextCache = new Map\(\)/)
assert.match(aiModeSurface, /const aiModeReceiptRecognitionState = reactive\(\{\}\)/)
assert.match(aiModeSurface, /function resolveAiModeReceiptRecognitionState\(file\)/)
assert.match(aiModeSurface, /function hasPendingAiModeReceiptRecognition\(files = \[\]\)/)
assert.match(aiModeSurface, /function hasFailedAiModeReceiptRecognition\(files = \[\]\)/)
assert.match(aiModeSurface, /resolveAiModeReceiptRecognitionState\(selectedFiles\.value\[index\]\)/)
assert.match(aiModeSurface, /status:\s*'recognizing'[\s\S]*label:\s*'智能录入识别中'/)
assert.match(aiModeSurface, /status:\s*'recognized'[\s\S]*label:\s*detail \? `已识别票据/)
assert.match(aiModeSurface, /status:\s*'recognized'[\s\S]*label:\s*detail \? `当前会话已识别/)
assert.match(aiModeSurface, /本状态不代表票据夹已有记录/)
assert.match(aiModeSurface, /status:\s*'failed'[\s\S]*label:\s*'识别失败'/)
assert.match(aiModeSurface, /const isAiModeReceiptRecognitionPending = computed\(\(\) => attachmentFlow\.hasPendingAiModeReceiptRecognition\(selectedFiles\.value\)\)/)
assert.match(aiModeSurface, /const hasAiModeReceiptRecognitionFailure = computed\(\(\) => attachmentFlow\.hasFailedAiModeReceiptRecognition\(selectedFiles\.value\)\)/)
assert.match(aiModeSurface, /const isAiModeInputLocked = computed\(\(\) => applicationPreviewEstimatePending\.value \|\| isAiModeReceiptRecognitionPending\.value\)/)
assert.match(aiModeSurface, /!hasAiModeReceiptRecognitionFailure\.value[\s\S]*Boolean\(assistantDraft\.value\.trim\(\)\)/)
assert.match(aiModeSurface, /function resolveAiModeInputLockMessage\(\) \{[\s\S]*附件识别中,请稍等/)
assert.match(aiModeSurface, /hasAiModeReceiptRecognitionFailure\.value[\s\S]*请先移除识别失败的附件或重新上传/)
assert.match(aiModeSurface, /:placeholder="runtime\.isAiModeInputLocked \? runtime\.aiModeInputLockMessage : placeholder"/)
assert.match(aiModeSurface, /function primeAiModeReceiptContext\(files = \[\]\)/)
assert.match(aiModeSurface, /function startAiModeReceiptRecognition\(files = \[\]\)/)
assert.match(aiModeSurface, /function startAiModeReceiptRecognition\(files = \[\], options = \{\}\)/)
assert.match(aiModeSurface, /const forceRefresh = Boolean\(options\.forceRefresh\)/)
assert.match(aiModeSurface, /if \(!forceRefresh && cached\?\.status === 'resolved'\) \{/)
assert.match(aiModeSurface, /startAiModeReceiptRecognition\(files, \{ forceRefresh: true \}\)/)
assert.match(aiModeSurface, /function buildAiModeReceiptContextCacheKey\(ocrFiles = \[\]\)/)
assert.match(aiModeSurface, /applyAiModeReceiptRecognitionResult\(ocrFiles, context\)/)
assert.match(aiModeSurface, /buildFileIdentity\(file\)/)

View File

@@ -3,7 +3,14 @@ import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import test from 'node:test'
import { useWorkbenchAiExpenseFlow } from '../src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js'
import {
buildLinkedDraftRunningText,
useWorkbenchAiExpenseFlow
} from '../src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js'
import {
CONTINUE_REIMBURSEMENT_DRAFT_ACTION,
CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION
} from '../src/views/scripts/travelReimbursementAssociationGateModel.js'
const personalWorkbenchAiMode = readFileSync(
join(process.cwd(), 'web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js'),
@@ -40,7 +47,11 @@ function buildFlow(options = {}) {
conversationStarted,
createInlineMessage,
currentUser: { value: options.currentUser || { name: '张小青', username: 'xiaoqing.zhang' } },
createLinkedReimbursementDraftJobForAi: options.createLinkedReimbursementDraftJobForAi,
fetchExpenseClaimsForAi: options.fetchExpenseClaimsForAi,
fetchLinkedReimbursementDraftJobForAi: options.fetchLinkedReimbursementDraftJobForAi,
linkedDraftJobPollIntervalMs: options.linkedDraftJobPollIntervalMs ?? 0,
linkedDraftJobMaxPolls: options.linkedDraftJobMaxPolls ?? 2,
runOrchestratorForAi: options.runOrchestratorForAi,
associationQueryTimeoutMs: options.associationQueryTimeoutMs,
persistCurrentConversation: () => {
@@ -61,6 +72,18 @@ function buildFlow(options = {}) {
const conversationStarted = { value: false }
test('linked reimbursement draft running text avoids duplicate status wording', () => {
const content = buildLinkedDraftRunningText(
{ message: '正在后台生成报销草稿...' },
'AVF9ST8TT'
)
const repeated = content.match(/正在后台生成报销草稿/g) || []
assert.equal(repeated.length, 1)
assert.doesNotMatch(content, /处理状态:\s*正在后台生成报销草稿/)
assert.match(content, /回来后我会继续查询任务结果/)
})
test('reimbursement intent checks drafts before recommending approved application documents', async () => {
conversationStarted.value = false
let queried = 0
@@ -135,7 +158,7 @@ test('reimbursement intent stops at existing reimbursement drafts before applica
reason: '北京客户现场实施报销',
location: '北京',
status: 'draft',
amount: 650,
amount: '0.00',
created_at: '2026-06-23T10:00:00Z'
},
{
@@ -160,10 +183,20 @@ test('reimbursement intent stops at existing reimbursement drafts before applica
assert.match(assistantMessage.content, /先检查.*报销草稿/)
assert.match(assistantMessage.content, /查到 1 个可继续的报销草稿/)
assert.match(assistantMessage.content, /RE-202606-010/)
assert.match(assistantMessage.content, /待确认/)
assert.doesNotMatch(assistantMessage.content, />0\.00</)
assert.match(assistantMessage.content, /2026-06-23 10:00/)
assert.doesNotMatch(assistantMessage.content, /T10:00:00Z/)
assert.doesNotMatch(assistantMessage.content, /AP-202606-001/)
assert.match(assistantMessage.content, /下方三个按钮/)
assert.doesNotMatch(assistantMessage.content, /跳过草稿后再关联申请单/)
assert.equal(assistantMessage.suggestedActions.length, 3)
assert.equal(assistantMessage.suggestedActions[0].action_type, 'open_application_detail')
assert.match(assistantMessage.suggestedActions[0].label, /继续草稿/)
assert.equal(assistantMessage.suggestedActions.at(-1).action_type, 'skip_reimbursement_draft_check')
assert.equal(assistantMessage.suggestedActions[0].label, '查看草稿 RE-202606-010')
assert.equal(assistantMessage.suggestedActions[1].action_type, CONTINUE_REIMBURSEMENT_DRAFT_ACTION)
assert.equal(assistantMessage.suggestedActions[1].label, '继续关联草稿 RE-202606-010')
assert.equal(assistantMessage.suggestedActions[2].action_type, CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION)
assert.equal(assistantMessage.suggestedActions[2].label, '独立新建报销单')
})
test('reimbursement association gate shows thinking before querying and renders application cards', async () => {
@@ -274,8 +307,33 @@ test('reimbursement association gate matches short username with returned employ
test('linked application selection can create reimbursement draft from association gate', async () => {
conversationStarted.value = false
const orchestratorCalls = []
const createJobCalls = []
const fetchJobCalls = []
const { aiExpenseDraft, conversationMessages, flow } = buildFlow({
fetchExpenseClaimsForAi: async () => ({ items: [] }),
createLinkedReimbursementDraftJobForAi: async (payload) => {
createJobCalls.push(payload)
return {
job_id: 'linked-draft-job-1',
status: 'queued',
message: '已创建后台生成任务。'
}
},
fetchLinkedReimbursementDraftJobForAi: async (jobId) => {
fetchJobCalls.push(jobId)
return {
job_id: jobId,
status: 'succeeded',
message: '报销草稿已生成。',
draft_payload: {
claim_id: 'draft-linked-1',
claim_no: 'RE-202606-009',
status: 'draft',
expense_type: 'travel',
reason: '北京客户现场实施'
}
}
},
runOrchestratorForAi: async (payload, options) => {
orchestratorCalls.push({ payload, options })
return {
@@ -303,16 +361,93 @@ test('linked application selection can create reimbursement draft from associati
application_amount_label: '1,650元'
})
assert.equal(orchestratorCalls.length, 1)
assert.equal(orchestratorCalls[0].payload.context_json.review_action, 'save_draft')
assert.equal(orchestratorCalls[0].payload.context_json.expense_scene_selection.application_claim_no, 'AP-202606-001')
assert.equal(orchestratorCalls[0].payload.context_json.review_form_values.application_claim_no, 'AP-202606-001')
assert.equal(orchestratorCalls.length, 0)
assert.equal(createJobCalls.length, 1)
assert.equal(createJobCalls[0].context_json.review_action, 'save_draft')
assert.equal(createJobCalls[0].context_json.expense_scene_selection.application_claim_no, 'AP-202606-001')
assert.equal(createJobCalls[0].context_json.review_form_values.application_claim_no, 'AP-202606-001')
assert.deepEqual(fetchJobCalls, ['linked-draft-job-1'])
assert.equal(aiExpenseDraft.value, null)
assert.match(conversationMessages.value.at(-1).content, /报销草稿 RE-202606-009 已生成/)
assert.equal(conversationMessages.value.at(-1).draftPayload.claim_no, 'RE-202606-009')
assert.equal(conversationMessages.value.at(-1).suggestedActions[0].action_type, 'open_application_detail')
})
test('linked reimbursement draft job resumes from pending history message', async () => {
conversationStarted.value = true
const fetchJobCalls = []
const { conversationMessages, flow } = buildFlow({
fetchLinkedReimbursementDraftJobForAi: async (jobId) => {
fetchJobCalls.push(jobId)
return {
job_id: jobId,
status: 'succeeded',
message: '报销草稿已生成。',
draft_payload: {
claim_id: 'draft-resumed-1',
claim_no: 'RE-202606-011',
status: 'draft',
expense_type: 'travel'
}
}
}
})
conversationMessages.value.push(createInlineMessage('assistant', '已关联申请单 AP-202606-001正在后台生成报销草稿...', {
id: 'pending-linked-draft-message',
pending: true,
linkedReimbursementDraftJob: {
jobId: 'linked-draft-job-resume',
status: 'queued',
applicationClaimNo: 'AP-202606-001'
}
}))
flow.resumePendingLinkedReimbursementDraftJobs()
await new Promise((resolve) => setTimeout(resolve, 5))
assert.deepEqual(fetchJobCalls, ['linked-draft-job-resume'])
assert.match(conversationMessages.value.at(-1).content, /报销草稿 RE-202606-011 已生成/)
assert.match(conversationMessages.value.at(-1).content, /AP-202606-001/)
assert.equal(conversationMessages.value.at(-1).pending, false)
assert.equal(conversationMessages.value.at(-1).draftPayload.claim_no, 'RE-202606-011')
})
test('continuing an existing reimbursement draft prompts for attachments or description', () => {
conversationStarted.value = false
const { conversationMessages, flow } = buildFlow()
flow.promptAiReimbursementDraftContinuation({
claim_id: 'draft-travel-1',
claim_no: 'RE-202606-010',
original_message: '我要报销'
})
assert.equal(conversationMessages.value.at(-2).role, 'user')
assert.equal(conversationMessages.value.at(-2).content, '继续关联草稿 RE-202606-010')
const assistantMessage = conversationMessages.value.at(-1)
assert.equal(assistantMessage.role, 'assistant')
assert.match(assistantMessage.content, /请上传相关的附件/)
assert.match(assistantMessage.content, /补充说明/)
assert.equal(assistantMessage.suggestedActions[0].action_type, 'open_application_detail')
assert.equal(assistantMessage.suggestedActions[0].label, '查看草稿 RE-202606-010')
})
test('standalone reimbursement draft branch asks before creating a new draft', () => {
conversationStarted.value = false
const { conversationMessages, flow } = buildFlow()
flow.promptStandaloneReimbursementDraftCreation('我要报销', '独立新建报销单')
assert.equal(conversationMessages.value.at(-2).role, 'user')
assert.equal(conversationMessages.value.at(-2).content, '独立新建报销单')
const assistantMessage = conversationMessages.value.at(-1)
assert.equal(assistantMessage.role, 'assistant')
assert.match(assistantMessage.content, /是否新建草稿单据/)
assert.equal(assistantMessage.suggestedActions[0].label, '新建草稿单据')
assert.equal(assistantMessage.suggestedActions[0].action_type, 'skip_required_application_link')
assert.equal(assistantMessage.suggestedActions[1].label, '暂不新建')
})
test('personal workbench routes reimbursement creation intent to association gate before steward', () => {
assert.match(
personalWorkbenchAiMode,

View File

@@ -2,7 +2,9 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { ref } from 'vue'
import { useWorkbenchComposerDate } from '../src/composables/useWorkbenchComposerDate.js'
import {
buildWorkbenchDateLabel,
canApplyWorkbenchDateSelection,
@@ -76,3 +78,34 @@ test('workbench date helper builds labels and inserts them into draft text', ()
)
assert.equal(canApplyWorkbenchDateSelection({ mode: 'range', rangeStartDate: '2026-06-01', rangeEndDate: '2026-05-31' }), false)
})
test('workbench range end date changes keep the picker open until the user confirms', () => {
const draft = ref('')
let focusCount = 0
const dateRuntime = useWorkbenchComposerDate({
draft,
focusInput: () => {
focusCount += 1
}
})
dateRuntime.workbenchDatePickerOpen.value = true
dateRuntime.workbenchDateMode.value = 'range'
dateRuntime.workbenchRangeStartDate.value = '2026-02-20'
dateRuntime.workbenchRangeEndDate.value = '2026-03-23'
dateRuntime.handleWorkbenchDateInputChange('range-end')
assert.equal(dateRuntime.workbenchDatePickerOpen.value, true)
assert.equal(dateRuntime.workbenchDateTagLabel.value, '')
assert.equal(draft.value, '')
assert.equal(focusCount, 0)
dateRuntime.applyWorkbenchDateSelection()
assert.equal(dateRuntime.workbenchDatePickerOpen.value, false)
assert.equal(dateRuntime.workbenchDateTagLabel.value, '2026-02-20 至 2026-03-23')
assert.equal(draft.value, '')
assert.equal(dateRuntime.buildWorkbenchPromptText(), '2026-02-20 至 2026-03-23')
assert.equal(focusCount, 1)
})