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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user