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:
@@ -1,5 +1,12 @@
|
||||
import { fetchExpenseClaims } from '../../services/reimbursements.js'
|
||||
import {
|
||||
REIMBURSEMENT_LIST_PREVIEW_PARAMS,
|
||||
fetchExpenseClaims
|
||||
} from '../../services/reimbursements.js'
|
||||
import { runOrchestrator } from '../../services/orchestrator.js'
|
||||
import {
|
||||
createLinkedReimbursementDraftJob,
|
||||
fetchLinkedReimbursementDraftJob
|
||||
} from '../../services/linkedReimbursementDraftJobs.js'
|
||||
import {
|
||||
applyAiExpenseAnswer,
|
||||
buildAiExpenseStepPrompt,
|
||||
@@ -27,7 +34,11 @@ import {
|
||||
buildReimbursementAssociationSelectionText,
|
||||
buildReimbursementAssociationQueryFailedText,
|
||||
buildReimbursementDraftActions,
|
||||
buildReimbursementDraftContinuationText,
|
||||
buildReimbursementDraftSelectionText,
|
||||
buildStandaloneReimbursementDraftConfirmationActions,
|
||||
buildStandaloneReimbursementDraftConfirmationText,
|
||||
buildViewReimbursementDraftAction,
|
||||
fetchReimbursementAssociationClaims,
|
||||
filterReimbursementAssociationCandidates,
|
||||
filterReimbursementDraftCandidates,
|
||||
@@ -37,6 +48,10 @@ import {
|
||||
export { SESSION_TYPE_EXPENSE }
|
||||
|
||||
const AI_REIMBURSEMENT_ASSOCIATION_STEP_DELAY_MS = 320
|
||||
const LINKED_DRAFT_JOB_POLL_INTERVAL_MS = 1200
|
||||
const LINKED_DRAFT_JOB_MAX_POLLS = 100
|
||||
const LINKED_DRAFT_JOB_PENDING_STATUSES = new Set(['queued', 'running'])
|
||||
const LINKED_DRAFT_RUNNING_PHRASE = '正在后台生成报销草稿'
|
||||
|
||||
function waitForReimbursementAssociationStep() {
|
||||
return new Promise((resolve) => {
|
||||
@@ -44,6 +59,20 @@ function waitForReimbursementAssociationStep() {
|
||||
})
|
||||
}
|
||||
|
||||
export function buildLinkedDraftRunningText(job = {}, claimNo = '') {
|
||||
const statusText = String(job?.message || '').trim()
|
||||
const shouldShowStatusText = Boolean(
|
||||
statusText && !statusText.includes(LINKED_DRAFT_RUNNING_PHRASE)
|
||||
)
|
||||
return [
|
||||
`已关联申请单${claimNo ? ` ${claimNo}` : ''},正在后台生成报销草稿...`,
|
||||
shouldShowStatusText ? '' : null,
|
||||
shouldShowStatusText ? `处理状态:${statusText}` : null,
|
||||
'',
|
||||
'您可以先离开当前会话,回来后我会继续查询任务结果。'
|
||||
].filter((line) => line !== null).join('\n')
|
||||
}
|
||||
|
||||
export function useWorkbenchAiExpenseFlow({
|
||||
activateInlineConversation,
|
||||
aiExpenseDraft,
|
||||
@@ -70,6 +99,10 @@ export function useWorkbenchAiExpenseFlow({
|
||||
startAiApplicationPreview,
|
||||
fetchExpenseClaimsForAi = fetchExpenseClaims,
|
||||
runOrchestratorForAi = runOrchestrator,
|
||||
createLinkedReimbursementDraftJobForAi = createLinkedReimbursementDraftJob,
|
||||
fetchLinkedReimbursementDraftJobForAi = fetchLinkedReimbursementDraftJob,
|
||||
linkedDraftJobPollIntervalMs = LINKED_DRAFT_JOB_POLL_INTERVAL_MS,
|
||||
linkedDraftJobMaxPolls = LINKED_DRAFT_JOB_MAX_POLLS,
|
||||
associationQueryTimeoutMs = REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MS
|
||||
}) {
|
||||
function replaceInlineAssistantMessage(messageId, content = '', options = {}) {
|
||||
@@ -79,6 +112,7 @@ export function useWorkbenchAiExpenseFlow({
|
||||
stewardPlan: options.stewardPlan || null,
|
||||
suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : [],
|
||||
draftPayload: options.draftPayload || null,
|
||||
linkedReimbursementDraftJob: options.linkedReimbursementDraftJob || null,
|
||||
text: options.text || content
|
||||
})
|
||||
replaceInlineMessage(messageId, nextMessage)
|
||||
@@ -113,6 +147,67 @@ export function useWorkbenchAiExpenseFlow({
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeDraftActionPayload(payload = {}) {
|
||||
return {
|
||||
id: String(payload.id || payload.claim_id || payload.claimId || '').trim(),
|
||||
claim_no: String(payload.claim_no || payload.claimNo || '').trim(),
|
||||
original_message: String(payload.original_message || payload.originalMessage || '我要报销').trim() || '我要报销'
|
||||
}
|
||||
}
|
||||
|
||||
function pushPromptConversationUserMessage(text = '') {
|
||||
const normalizedText = String(text || '').trim()
|
||||
if (normalizedText) {
|
||||
pushInlineUserMessage(normalizedText)
|
||||
}
|
||||
}
|
||||
|
||||
function promptAiReimbursementDraftContinuation(payload = {}) {
|
||||
const draft = normalizeDraftActionPayload(payload)
|
||||
const claimNo = draft.claim_no || '当前草稿'
|
||||
if (!conversationStarted.value) {
|
||||
activateInlineConversation({
|
||||
title: `继续草稿 ${claimNo}`.trim().slice(0, 18) || '继续草稿'
|
||||
})
|
||||
}
|
||||
assistantDraft.value = ''
|
||||
closeWorkbenchDatePicker()
|
||||
pushPromptConversationUserMessage(`继续关联草稿 ${claimNo}`)
|
||||
conversationMessages.value.push(createInlineMessage('assistant', buildReimbursementDraftContinuationText(draft), {
|
||||
meta: ['等待上传附件或说明'],
|
||||
suggestedActions: [buildViewReimbursementDraftAction(draft, draft.original_message)]
|
||||
}))
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
}
|
||||
|
||||
function promptStandaloneReimbursementDraftCreation(originalMessage = '我要报销', selectedLabel = '独立新建报销单') {
|
||||
const sourceText = String(originalMessage || '我要报销').trim() || '我要报销'
|
||||
const userText = String(selectedLabel || '独立新建报销单').trim() || '独立新建报销单'
|
||||
if (!conversationStarted.value) {
|
||||
activateInlineConversation({
|
||||
title: userText.slice(0, 18) || '新建报销'
|
||||
})
|
||||
}
|
||||
assistantDraft.value = ''
|
||||
closeWorkbenchDatePicker()
|
||||
pushPromptConversationUserMessage(userText)
|
||||
conversationMessages.value.push(createInlineMessage('assistant', buildStandaloneReimbursementDraftConfirmationText(), {
|
||||
meta: ['等待确认新建草稿'],
|
||||
suggestedActions: buildStandaloneReimbursementDraftConfirmationActions(sourceText)
|
||||
}))
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
}
|
||||
|
||||
function cancelStandaloneReimbursementDraftCreation() {
|
||||
conversationMessages.value.push(createInlineMessage('assistant', '好的,本次先不新建报销草稿。您可以继续查看已有草稿,或补充新的报销说明。', {
|
||||
meta: ['已取消新建']
|
||||
}))
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
}
|
||||
|
||||
async function startAiReimbursementAssociationGate(originalMessage = '我要报销', selectedLabel = '', options = {}) {
|
||||
const sourceText = String(originalMessage || '我要报销').trim() || '我要报销'
|
||||
if (!conversationStarted.value) {
|
||||
@@ -255,7 +350,7 @@ export function useWorkbenchAiExpenseFlow({
|
||||
aiExpenseDraft.value = next
|
||||
|
||||
if (isAiExpenseDraftComplete(next)) {
|
||||
conversationMessages.value.push(createInlineMessage('assistant', `${buildAiExpenseSummary(next)}\n\n如果哪一项需要修改,直接告诉我;确认无误后我再帮你生成报销草稿。`))
|
||||
conversationMessages.value.push(createInlineMessage('assistant', `${buildAiExpenseSummary(next)}\n\n如果哪一项需要修改,请直接告诉我;确认无误后我再帮您生成报销草稿。`))
|
||||
aiExpenseDraft.value = null
|
||||
} else {
|
||||
conversationMessages.value.push(createInlineMessage('assistant', buildAiExpenseStepPrompt(next)))
|
||||
@@ -267,7 +362,7 @@ export function useWorkbenchAiExpenseFlow({
|
||||
async function resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel) {
|
||||
let claims = null
|
||||
try {
|
||||
claims = await fetchExpenseClaimsForAi()
|
||||
claims = await fetchExpenseClaimsForAi(REIMBURSEMENT_LIST_PREVIEW_PARAMS)
|
||||
} catch {
|
||||
aiExpenseDraft.value = null
|
||||
conversationMessages.value.push(createInlineMessage('assistant', '查询可关联申请单时出现异常,请稍后再试,我先暂停这次报销流程。'))
|
||||
@@ -322,6 +417,122 @@ export function useWorkbenchAiExpenseFlow({
|
||||
}
|
||||
}
|
||||
|
||||
function waitForLinkedDraftJobPoll() {
|
||||
return new Promise((resolve) => {
|
||||
globalThis.setTimeout(resolve, linkedDraftJobPollIntervalMs)
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeLinkedDraftJob(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(),
|
||||
error: String(job?.error || '').trim(),
|
||||
runId: String(job?.run_id || job?.runId || '').trim(),
|
||||
applicationClaimNo: String(job?.application_claim_no || job?.applicationClaimNo || '').trim(),
|
||||
draftPayload: job?.draft_payload && typeof job.draft_payload === 'object'
|
||||
? job.draft_payload
|
||||
: job?.draftPayload && typeof job.draftPayload === 'object'
|
||||
? job.draftPayload
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
function buildLinkedDraftFailedText(job = {}) {
|
||||
return String(job?.message || job?.error || '').trim()
|
||||
|| '生成报销草稿时出现异常。申请单关联信息我先保留在当前会话里,您可以稍后重试,或单独新建报销单。'
|
||||
}
|
||||
|
||||
const activeLinkedDraftJobPolls = new Set()
|
||||
|
||||
async function pollLinkedDraftJob({
|
||||
jobId,
|
||||
pendingMessageId,
|
||||
claimNo = '',
|
||||
initialJob = null
|
||||
}) {
|
||||
const normalizedJobId = String(jobId || '').trim()
|
||||
if (!normalizedJobId || activeLinkedDraftJobPolls.has(normalizedJobId)) {
|
||||
return
|
||||
}
|
||||
activeLinkedDraftJobPolls.add(normalizedJobId)
|
||||
let currentJob = initialJob ? normalizeLinkedDraftJob(initialJob) : null
|
||||
try {
|
||||
for (let index = 0; index <= linkedDraftJobMaxPolls; index += 1) {
|
||||
if (!currentJob && index > 0) {
|
||||
currentJob = normalizeLinkedDraftJob(await fetchLinkedReimbursementDraftJobForAi(normalizedJobId))
|
||||
}
|
||||
if (currentJob && !LINKED_DRAFT_JOB_PENDING_STATUSES.has(currentJob.status)) {
|
||||
if (currentJob.status === 'succeeded') {
|
||||
const draftPayload = currentJob.draftPayload || null
|
||||
const draftClaimNo = String(draftPayload?.claim_no || draftPayload?.claimNo || '').trim()
|
||||
const content = draftClaimNo
|
||||
? `报销草稿 ${draftClaimNo} 已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。`
|
||||
: `报销草稿已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。`
|
||||
replaceInlineAssistantMessage(pendingMessageId, content, {
|
||||
draftPayload,
|
||||
linkedReimbursementDraftJob: {
|
||||
...currentJob,
|
||||
applicationClaimNo: claimNo
|
||||
},
|
||||
suggestedActions: buildLinkedDraftAction(draftPayload)
|
||||
})
|
||||
aiExpenseDraft.value = null
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
return
|
||||
}
|
||||
throw new Error(buildLinkedDraftFailedText(currentJob))
|
||||
}
|
||||
if (currentJob) {
|
||||
replaceInlineAssistantMessage(pendingMessageId, buildLinkedDraftRunningText(currentJob, claimNo), {
|
||||
pending: true,
|
||||
linkedReimbursementDraftJob: {
|
||||
...currentJob,
|
||||
applicationClaimNo: claimNo
|
||||
},
|
||||
suggestedActions: []
|
||||
})
|
||||
persistCurrentConversation()
|
||||
}
|
||||
await waitForLinkedDraftJobPoll()
|
||||
currentJob = normalizeLinkedDraftJob(await fetchLinkedReimbursementDraftJobForAi(normalizedJobId))
|
||||
}
|
||||
throw new Error('报销草稿仍在后台生成中,稍后回到会话会继续刷新结果。')
|
||||
} finally {
|
||||
activeLinkedDraftJobPolls.delete(normalizedJobId)
|
||||
}
|
||||
}
|
||||
|
||||
function resumePendingLinkedReimbursementDraftJobs() {
|
||||
conversationMessages.value.forEach((message) => {
|
||||
const job = normalizeLinkedDraftJob(message.linkedReimbursementDraftJob || null)
|
||||
if (!job || !LINKED_DRAFT_JOB_PENDING_STATUSES.has(job.status)) {
|
||||
return
|
||||
}
|
||||
void pollLinkedDraftJob({
|
||||
jobId: job.jobId,
|
||||
pendingMessageId: message.id,
|
||||
claimNo: job.applicationClaimNo,
|
||||
initialJob: job
|
||||
}).catch((error) => {
|
||||
replaceInlineAssistantMessage(message.id, buildLinkedDraftFailedText(error), {
|
||||
linkedReimbursementDraftJob: {
|
||||
...job,
|
||||
status: 'failed',
|
||||
message: error?.message || '报销草稿生成状态查询失败。'
|
||||
}
|
||||
})
|
||||
persistCurrentConversation()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function buildLinkedDraftAction(draftPayload = {}) {
|
||||
const claimNo = String(draftPayload.claim_no || draftPayload.claimNo || '').trim()
|
||||
const claimId = String(draftPayload.claim_id || draftPayload.claimId || '').trim()
|
||||
@@ -375,39 +586,28 @@ export function useWorkbenchAiExpenseFlow({
|
||||
application,
|
||||
application.original_message || resolveLatestInlineUserPrompt() || '我要报销'
|
||||
)
|
||||
const user = currentUser.value || {}
|
||||
const payload = await runOrchestratorForAi(
|
||||
{
|
||||
source: 'user_message',
|
||||
user_id: user.username || user.name || 'anonymous',
|
||||
conversation_id: null,
|
||||
message: submitOptions.rawText,
|
||||
context_json: {
|
||||
...buildWorkbenchUserContext(),
|
||||
...submitOptions.extraContext
|
||||
}
|
||||
},
|
||||
{
|
||||
timeoutMs: 120000,
|
||||
timeoutMessage: '生成报销草稿超时,请稍后重试。'
|
||||
const job = await createLinkedReimbursementDraftJobForAi({
|
||||
message: submitOptions.rawText,
|
||||
conversation_id: '',
|
||||
context_json: {
|
||||
...buildWorkbenchUserContext(),
|
||||
...submitOptions.extraContext
|
||||
}
|
||||
)
|
||||
const draftPayload = payload?.result?.draft_payload || null
|
||||
const draftClaimNo = String(draftPayload?.claim_no || draftPayload?.claimNo || '').trim()
|
||||
const content = draftClaimNo
|
||||
? `报销草稿 ${draftClaimNo} 已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。`
|
||||
: `报销草稿已生成,并已关联申请单 ${claimNo || '所选申请单'}。后续可以在草稿详情中上传票据或补充信息。`
|
||||
replaceInlineAssistantMessage(pendingMessageId, content, {
|
||||
draftPayload,
|
||||
suggestedActions: buildLinkedDraftAction(draftPayload)
|
||||
})
|
||||
aiExpenseDraft.value = null
|
||||
persistCurrentConversation()
|
||||
scrollInlineConversationToBottom()
|
||||
} catch {
|
||||
const normalizedJob = normalizeLinkedDraftJob(job)
|
||||
if (!normalizedJob) {
|
||||
throw new Error('报销草稿生成任务创建失败,请稍后重试。')
|
||||
}
|
||||
await pollLinkedDraftJob({
|
||||
jobId: normalizedJob.jobId,
|
||||
pendingMessageId,
|
||||
claimNo,
|
||||
initialJob: normalizedJob
|
||||
})
|
||||
} catch (error) {
|
||||
replaceInlineAssistantMessage(
|
||||
pendingMessageId,
|
||||
'生成报销草稿时出现异常。申请单关联信息我先保留在当前会话里,你可以稍后重试或单独新建报销单。',
|
||||
buildLinkedDraftFailedText(error),
|
||||
{
|
||||
suggestedActions: []
|
||||
}
|
||||
@@ -419,8 +619,12 @@ export function useWorkbenchAiExpenseFlow({
|
||||
|
||||
return {
|
||||
advanceAiExpenseDraft,
|
||||
cancelStandaloneReimbursementDraftCreation,
|
||||
linkAiExpenseApplication,
|
||||
promptAiReimbursementDraftContinuation,
|
||||
promptStandaloneReimbursementDraftCreation,
|
||||
pushInlineExpenseSceneSelectionPrompt,
|
||||
resumePendingLinkedReimbursementDraftJobs,
|
||||
startAiApplicationPreviewFromAction,
|
||||
startAiReimbursementAssociationGate,
|
||||
startAiExpenseDraft
|
||||
|
||||
Reference in New Issue
Block a user