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

@@ -0,0 +1,250 @@
import * as aiAttachmentAssociationModel from '../../utils/aiAttachmentAssociationModel.js'
import { fetchAttachmentAssociationJob } from '../../services/attachmentAssociationJobs.js'
const ATTACHMENT_ASSOCIATION_JOB_POLL_INTERVAL_MS = 1200
const ATTACHMENT_ASSOCIATION_JOB_MAX_POLLS = 90
const ATTACHMENT_ASSOCIATION_JOB_PENDING_STATUSES = new Set(['queued', 'running'])
export function useWorkbenchAiAttachmentAssociationJobs({
conversationMessages,
createInlineMessage,
persistCurrentConversation,
replaceInlineMessage,
streamOrSetInlineAssistantContent,
notifyRequestUpdated,
toast,
buildDetailActions,
buildThinkingEvents
}) {
const activeJobPolls = new Set()
function delay(milliseconds) {
return new Promise((resolve) => {
globalThis.setTimeout(resolve, milliseconds)
})
}
function normalizeJob(job = {}) {
const jobId = String(job?.job_id || job?.jobId || '').trim()
if (!jobId) {
return null
}
return {
jobId,
status: String(job?.status || 'queued').trim() || 'queued',
message: String(job?.message || '').trim(),
receiptIds: (Array.isArray(job?.receipt_ids) ? job.receipt_ids : job?.receiptIds || [])
.map((item) => String(item || '').trim())
.filter(Boolean),
claimId: String(job?.claim_id || job?.claimId || '').trim(),
claimNo: String(job?.claim_no || job?.claimNo || '').trim(),
uploadedCount: Number(job?.uploaded_count ?? job?.uploadedCount ?? 0) || 0,
skippedCount: Number(job?.skipped_count ?? job?.skippedCount ?? 0) || 0,
error: String(job?.error || '').trim()
}
}
function isPending(job = {}) {
return ATTACHMENT_ASSOCIATION_JOB_PENDING_STATUSES.has(String(job?.status || '').trim())
}
function extractReceiptIdsFromOcrDocuments(documents = []) {
return Array.from(new Set(
(Array.isArray(documents) ? documents : [])
.map((document) => String(document?.receipt_id || document?.receiptId || '').trim())
.filter(Boolean)
))
}
function buildRunningMessage(job = {}, fileNames = []) {
const names = fileNames.filter(Boolean)
const attachmentLabel = names.length
? `${names.length} 份:${names.slice(0, 2).join('、')}${names.length > 2 ? ' 等' : ''}`
: '已识别票据附件'
const statusText = String(job?.message || '').trim() || '正在后台匹配并归集票据附件。'
return [
'我已收到附件关联请求,正在后台继续处理。',
'',
`本次附件:${attachmentLabel}`,
`处理状态:${statusText}`,
'',
'您可以先离开当前会话,回来后我会继续查询任务结果。'
].join('\n')
}
function buildFailedMessage(job = {}) {
return String(job?.message || job?.error || '').trim() || '自动归集失败,请补充说明或重新上传附件后再试。'
}
function findMessage(messageId) {
return conversationMessages.value.find((message) => message.id === messageId) || null
}
function replaceJobMessage(messageId, content, options = {}) {
const sourceMessage = findMessage(messageId)
if (!sourceMessage) {
return false
}
replaceInlineMessage(
messageId,
createInlineMessage('assistant', content, {
id: messageId,
attachmentAssociationJob: options.attachmentAssociationJob || sourceMessage?.attachmentAssociationJob || null,
attachmentOcrDetails: options.attachmentOcrDetails || sourceMessage?.attachmentOcrDetails || null,
pending: Boolean(options.pending),
stewardPlan: options.stewardPlan || null,
suggestedActions: options.suggestedActions || []
})
)
return true
}
async function updateJobMessage({
job,
messageId,
fileNames = [],
attachmentOcrDetails = null
}) {
const normalizedJob = normalizeJob(job)
if (!normalizedJob) {
return false
}
if (!findMessage(messageId)) {
return true
}
if (normalizedJob.status === 'succeeded') {
const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationResultMessage({
claimNo: normalizedJob.claimNo,
fileNames,
uploadedCount: normalizedJob.uploadedCount,
skippedCount: normalizedJob.skippedCount
})
await streamOrSetInlineAssistantContent(messageId, finalMessageText)
replaceJobMessage(messageId, finalMessageText, {
attachmentAssociationJob: normalizedJob,
attachmentOcrDetails,
stewardPlan: {
streamStatus: 'completed',
thinkingEvents: buildThinkingEvents('completed')
},
suggestedActions: buildDetailActions({
claimId: normalizedJob.claimId,
claimNo: normalizedJob.claimNo
})
})
notifyRequestUpdated?.({
claimId: normalizedJob.claimId,
claimNo: normalizedJob.claimNo,
source: 'ai-workbench-attachment-association-job',
uploadedCount: normalizedJob.uploadedCount,
skippedCount: normalizedJob.skippedCount
})
persistCurrentConversation()
return true
}
if (normalizedJob.status === 'failed') {
replaceJobMessage(messageId, buildFailedMessage(normalizedJob), {
attachmentAssociationJob: normalizedJob,
attachmentOcrDetails,
stewardPlan: {
streamStatus: 'failed',
thinkingEvents: buildThinkingEvents('failed')
}
})
persistCurrentConversation()
return true
}
replaceJobMessage(messageId, buildRunningMessage(normalizedJob, fileNames), {
attachmentAssociationJob: normalizedJob,
attachmentOcrDetails,
pending: true,
stewardPlan: {
streamStatus: 'streaming',
thinkingEvents: buildThinkingEvents('running')
}
})
persistCurrentConversation()
return false
}
async function pollJob({
jobId,
messageId,
fileNames = [],
attachmentOcrDetails = null,
initialJob = null
} = {}) {
const normalizedJobId = String(jobId || '').trim()
if (!normalizedJobId || activeJobPolls.has(normalizedJobId)) {
return
}
activeJobPolls.add(normalizedJobId)
try {
let currentJob = initialJob ? normalizeJob(initialJob) : null
if (currentJob) {
const done = await updateJobMessage({ job: currentJob, messageId, fileNames, attachmentOcrDetails })
if (done) {
return
}
}
for (let index = 0; index < ATTACHMENT_ASSOCIATION_JOB_MAX_POLLS; index += 1) {
await delay(ATTACHMENT_ASSOCIATION_JOB_POLL_INTERVAL_MS)
currentJob = normalizeJob(await fetchAttachmentAssociationJob(normalizedJobId))
if (!currentJob) {
throw new Error('附件关联任务不存在或已失效。')
}
const done = await updateJobMessage({ job: currentJob, messageId, fileNames, attachmentOcrDetails })
if (done) {
return
}
}
throw new Error('附件关联任务仍在后台处理中,稍后回到会话会继续刷新结果。')
} catch (error) {
const message = error?.message || '自动归集状态查询失败,请稍后回到会话查看。'
replaceJobMessage(messageId, message, {
attachmentAssociationJob: {
jobId: normalizedJobId,
status: 'failed',
message,
error: message
},
attachmentOcrDetails,
stewardPlan: {
streamStatus: 'failed',
thinkingEvents: buildThinkingEvents('failed')
}
})
toast(message)
persistCurrentConversation()
} finally {
activeJobPolls.delete(normalizedJobId)
}
}
function resumePendingJobs() {
conversationMessages.value.forEach((message) => {
const job = normalizeJob(message.attachmentAssociationJob || null)
if (!job || !isPending(job)) {
return
}
void pollJob({
jobId: job.jobId,
messageId: message.id,
fileNames: message.attachmentOcrDetails?.fileNames || [],
attachmentOcrDetails: message.attachmentOcrDetails || null,
initialJob: job
})
})
}
return {
buildRunningMessage,
extractReceiptIdsFromOcrDocuments,
normalizeJob,
pollJob,
resumePendingJobs
}
}