feat: 重构报销单服务并完善前端提交与审核交互
重构 expense_claims 服务模块结构并优化差旅票据审核逻辑, 增强用户代理服务的票据类型识别,前端报销创建页面拆分为 附件模型和会话模型模块,重构提交编排器和草稿关联确认流 程,更新知识库索引,补充单元测试。
This commit is contained in:
@@ -200,7 +200,7 @@
|
||||
}
|
||||
|
||||
.review-message-block {
|
||||
margin-top: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.review-summary {
|
||||
@@ -208,12 +208,12 @@
|
||||
color: #1f2937;
|
||||
font-size: var(--wb-fs-bubble);
|
||||
line-height: 1.58;
|
||||
white-space: pre-line;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.review-plain-followup {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
color: #334155;
|
||||
font-size: var(--wb-fs-bubble);
|
||||
@@ -225,13 +225,31 @@
|
||||
}
|
||||
|
||||
.review-plain-lead {
|
||||
color: #334155;
|
||||
margin: 0 0 2px;
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid #2563eb;
|
||||
color: #0f172a;
|
||||
font-size: max(13px, calc(var(--wb-fs-bubble) + 1px));
|
||||
font-weight: 820;
|
||||
line-height: 1.42;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.review-plain-lead.danger {
|
||||
border-left-color: #dc2626;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.review-plain-summary {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
line-height: 1.62;
|
||||
}
|
||||
|
||||
.review-plain-list {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
gap: 7px;
|
||||
margin: 2px 0 0;
|
||||
padding: 0 0 0 18px;
|
||||
}
|
||||
|
||||
@@ -247,11 +265,14 @@
|
||||
}
|
||||
|
||||
.review-plain-note {
|
||||
margin-top: 2px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.review-inline-save-copy {
|
||||
margin-top: 46px !important;
|
||||
color: #475569;
|
||||
line-height: 1.62;
|
||||
}
|
||||
|
||||
.review-inline-draft-link {
|
||||
|
||||
@@ -185,7 +185,7 @@
|
||||
--wb-fs-welcome: 16px;
|
||||
}
|
||||
|
||||
.assistant-modal-stage .message-answer-markdown table {
|
||||
.assistant-modal-stage .message-answer-markdown :deep(table) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
@@ -661,7 +661,7 @@
|
||||
|
||||
.message-answer-content {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.message-answer-content p,
|
||||
@@ -672,15 +672,33 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.message-answer-markdown h1,
|
||||
.message-answer-markdown h2,
|
||||
.message-answer-markdown h3,
|
||||
.message-answer-markdown h4 {
|
||||
margin: 0;
|
||||
.message-answer-markdown :deep(h1),
|
||||
.message-answer-markdown :deep(h2),
|
||||
.message-answer-markdown :deep(h3),
|
||||
.message-answer-markdown :deep(h4) {
|
||||
margin: 12px 0 4px;
|
||||
color: #0f172a;
|
||||
font-size: var(--wb-fs-md-h3);
|
||||
font-weight: 750;
|
||||
line-height: 1.46;
|
||||
font-size: max(13px, calc(var(--wb-fs-bubble) + 1px));
|
||||
font-weight: 820;
|
||||
line-height: 1.42;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.message-answer-markdown :deep(h1:first-child),
|
||||
.message-answer-markdown :deep(h2:first-child),
|
||||
.message-answer-markdown :deep(h3:first-child),
|
||||
.message-answer-markdown :deep(h4:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.message-answer-markdown :deep(h3) {
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid #2563eb;
|
||||
}
|
||||
|
||||
.message-answer-markdown :deep(h3 + p),
|
||||
.message-answer-markdown :deep(h3 + .markdown-table-wrap) {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.message-answer-markdown {
|
||||
@@ -690,26 +708,31 @@
|
||||
line-height: 1.58;
|
||||
}
|
||||
|
||||
.message-answer-markdown p,
|
||||
.message-answer-markdown li,
|
||||
.message-answer-markdown td,
|
||||
.message-answer-markdown th,
|
||||
.message-answer-markdown blockquote {
|
||||
.message-answer-markdown :deep(p),
|
||||
.message-answer-markdown :deep(li),
|
||||
.message-answer-markdown :deep(td),
|
||||
.message-answer-markdown :deep(th),
|
||||
.message-answer-markdown :deep(blockquote) {
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
line-height: 1.58;
|
||||
}
|
||||
|
||||
.message-answer-markdown ul,
|
||||
.message-answer-markdown ol {
|
||||
.message-answer-markdown :deep(p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.message-answer-markdown :deep(ul),
|
||||
.message-answer-markdown :deep(ol) {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.message-answer-markdown strong {
|
||||
.message-answer-markdown :deep(strong) {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.message-answer-markdown blockquote {
|
||||
.message-answer-markdown :deep(blockquote) {
|
||||
padding: 8px 10px;
|
||||
border-left: 3px solid #cbd5e1;
|
||||
border-radius: 0 10px 10px 0;
|
||||
@@ -717,14 +740,14 @@
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.message-answer-markdown code {
|
||||
.message-answer-markdown :deep(code) {
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
background: #e2e8f0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.message-answer-markdown pre {
|
||||
.message-answer-markdown :deep(pre) {
|
||||
overflow-x: auto;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
@@ -732,47 +755,64 @@
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.message-answer-markdown pre code {
|
||||
.message-answer-markdown :deep(pre code) {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.message-answer-markdown a {
|
||||
.message-answer-markdown :deep(a) {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.message-answer-markdown table {
|
||||
width: auto;
|
||||
.message-answer-markdown :deep(.markdown-table-wrap) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 8px 0 10px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe4ee;
|
||||
border-radius: 16px;
|
||||
border-collapse: collapse;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.message-answer-markdown :deep(table) {
|
||||
width: 100%;
|
||||
min-width: 460px;
|
||||
border: 0;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
background: #fff;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.message-answer-markdown th,
|
||||
.message-answer-markdown td {
|
||||
padding: 10px 12px;
|
||||
.message-answer-markdown :deep(th),
|
||||
.message-answer-markdown :deep(td) {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
vertical-align: top;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.message-answer-markdown th {
|
||||
background: #eff6ff;
|
||||
.message-answer-markdown :deep(th) {
|
||||
background: #f8fafc;
|
||||
color: #0f172a;
|
||||
font-weight: 850;
|
||||
font-weight: 760;
|
||||
border-bottom-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.message-answer-markdown td {
|
||||
.message-answer-markdown :deep(td) {
|
||||
color: #334155;
|
||||
font-weight: 650;
|
||||
font-weight: 520;
|
||||
}
|
||||
|
||||
.message-answer-markdown tbody tr:last-child td {
|
||||
.message-answer-markdown :deep(tbody tr:nth-child(even) td) {
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.message-answer-markdown :deep(tbody tr:last-child td) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { apiRequest } from './api.js'
|
||||
|
||||
export function recognizeOcrFiles(files) {
|
||||
export function recognizeOcrFiles(files, options = {}) {
|
||||
const formData = new FormData()
|
||||
for (const file of files) {
|
||||
formData.append('files', file)
|
||||
@@ -9,6 +9,7 @@ export function recognizeOcrFiles(files) {
|
||||
return apiRequest('/ocr/recognize', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
contentType: null
|
||||
contentType: null,
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,7 +6,78 @@ const markdown = new MarkdownIt({
|
||||
breaks: true
|
||||
})
|
||||
|
||||
const defaultTableOpen = markdown.renderer.rules.table_open
|
||||
const defaultTableClose = markdown.renderer.rules.table_close
|
||||
|
||||
markdown.renderer.rules.table_open = (tokens, idx, options, env, self) => (
|
||||
`<div class="markdown-table-wrap">${defaultTableOpen ? defaultTableOpen(tokens, idx, options, env, self) : '<table>'}`
|
||||
)
|
||||
|
||||
markdown.renderer.rules.table_close = (tokens, idx, options, env, self) => (
|
||||
`${defaultTableClose ? defaultTableClose(tokens, idx, options, env, self) : '</table>'}</div>`
|
||||
)
|
||||
|
||||
const ALLOWED_COLON_HEADING_TITLES = new Set([
|
||||
'基础信息识别结果',
|
||||
'报销测算参考',
|
||||
'补充信息'
|
||||
])
|
||||
|
||||
function splitColonHeadingLine(line) {
|
||||
const rawLine = String(line || '')
|
||||
const trimmed = rawLine.trim()
|
||||
if (!trimmed || trimmed.startsWith('|') || /^#{1,6}\s/.test(trimmed)) {
|
||||
return [rawLine]
|
||||
}
|
||||
|
||||
const chineseColonIndex = trimmed.indexOf(':')
|
||||
const asciiColonIndex = trimmed.indexOf(':')
|
||||
const colonIndexes = [chineseColonIndex, asciiColonIndex].filter((index) => index > 0)
|
||||
if (!colonIndexes.length) {
|
||||
return [rawLine]
|
||||
}
|
||||
|
||||
const colonIndex = Math.min(...colonIndexes)
|
||||
const title = trimmed.slice(0, colonIndex + 1)
|
||||
const titleText = title.slice(0, -1)
|
||||
const body = trimmed.slice(colonIndex + 1).trim()
|
||||
if (!ALLOWED_COLON_HEADING_TITLES.has(titleText)) {
|
||||
return [rawLine]
|
||||
}
|
||||
|
||||
return body ? [`### ${title}`, '', body] : [`### ${title}`]
|
||||
}
|
||||
|
||||
function normalizeColonHeadings(text) {
|
||||
const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n')
|
||||
const normalizedLines = []
|
||||
let inFence = false
|
||||
|
||||
lines.forEach((line) => {
|
||||
if (/^\s*(```|~~~)/.test(line)) {
|
||||
inFence = !inFence
|
||||
normalizedLines.push(line)
|
||||
return
|
||||
}
|
||||
if (inFence) {
|
||||
normalizedLines.push(line)
|
||||
return
|
||||
}
|
||||
|
||||
const nextLines = splitColonHeadingLine(line)
|
||||
if (nextLines[0]?.startsWith('### ') && normalizedLines.length) {
|
||||
const previousLine = normalizedLines[normalizedLines.length - 1]
|
||||
if (String(previousLine || '').trim()) {
|
||||
normalizedLines.push('')
|
||||
}
|
||||
}
|
||||
normalizedLines.push(...nextLines)
|
||||
})
|
||||
|
||||
return normalizedLines.join('\n').replace(/\n{3,}/g, '\n\n')
|
||||
}
|
||||
|
||||
export function renderMarkdown(text = '') {
|
||||
const normalized = String(text || '').trim()
|
||||
const normalized = normalizeColonHeadings(text).trim()
|
||||
return normalized ? markdown.render(normalized) : ''
|
||||
}
|
||||
|
||||
@@ -88,9 +88,10 @@
|
||||
<time>{{ message.time }}</time>
|
||||
</header>
|
||||
<div
|
||||
v-if="message.text && message.role === 'assistant' && message.reviewPayload"
|
||||
v-if="message.text && message.role === 'assistant' && message.reviewPayload && buildReviewMainMessageText(message)"
|
||||
class="review-summary message-answer-content message-answer-markdown"
|
||||
v-html="renderMarkdown(message.text)"
|
||||
v-html="renderMarkdown(buildReviewMainMessageText(message))"
|
||||
@click="handleAssistantMarkdownClick($event, message)"
|
||||
></div>
|
||||
|
||||
<div
|
||||
@@ -103,6 +104,7 @@
|
||||
v-else-if="message.text && message.role === 'assistant'"
|
||||
class="message-answer-content message-answer-markdown"
|
||||
v-html="renderMarkdown(message.text)"
|
||||
@click="handleAssistantMarkdownClick($event, message)"
|
||||
></div>
|
||||
|
||||
<div
|
||||
@@ -298,7 +300,15 @@
|
||||
v-for="followup in [buildReviewPlainFollowupCopy(message.reviewPayload)]"
|
||||
:key="`${message.id}-review-followup`"
|
||||
>
|
||||
<p class="review-plain-lead">{{ followup.lead }}</p>
|
||||
<h3
|
||||
class="review-plain-lead"
|
||||
:class="{ danger: followup.tone === 'danger' }"
|
||||
>
|
||||
{{ followup.lead }}
|
||||
</h3>
|
||||
<p v-if="followup.summary" class="review-plain-summary">
|
||||
{{ followup.summary }}
|
||||
</p>
|
||||
<ul v-if="followup.items.length" class="review-plain-list">
|
||||
<li
|
||||
v-for="item in followup.items"
|
||||
|
||||
@@ -120,6 +120,7 @@ import {
|
||||
VISIBLE_ATTACHMENT_CHIPS,
|
||||
buildAgentInsight,
|
||||
buildErrorInsight,
|
||||
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
|
||||
buildFileIdentity,
|
||||
buildFilePreviews,
|
||||
buildOcrDocumentsFromReviewPayload,
|
||||
@@ -431,6 +432,19 @@ function buildReviewRiskConversationText(item) {
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const REVIEW_PENDING_SUMMARY_PATTERN = /(^|\n)\s*(?:当前还有|我这边看到还有|下方还有|这笔报销还有|目前还有|还有|这次识别结果里还有|我还需要你确认|当前信息还差|本次报销还有)\s+[^\n]*(?:信息待补充|风险提醒|细节还需要进一步确认)[^\n]*(?:草稿)[^\n]*。\s*/g
|
||||
|
||||
function buildReviewMainMessageText(message) {
|
||||
const text = String(message?.text || '')
|
||||
if (!message?.reviewPayload) {
|
||||
return text
|
||||
}
|
||||
return text
|
||||
.replace(REVIEW_PENDING_SUMMARY_PATTERN, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'TravelReimbursementCreateView',
|
||||
components: {
|
||||
@@ -779,7 +793,10 @@ export default {
|
||||
attachedFiles,
|
||||
composerFilesExpanded
|
||||
}
|
||||
const { submitComposerInternal } = useTravelReimbursementSubmitComposer({
|
||||
const {
|
||||
confirmPendingAttachmentAssociationInternal,
|
||||
submitComposerInternal
|
||||
} = useTravelReimbursementSubmitComposer({
|
||||
MAX_ATTACHMENTS,
|
||||
activeReviewPayload,
|
||||
activeSessionType,
|
||||
@@ -1303,7 +1320,8 @@ export default {
|
||||
skipUploadDecisionPrompt: true,
|
||||
extraContext: {
|
||||
draft_claim_id: claimId,
|
||||
selected_claim_id: claimId
|
||||
selected_claim_id: claimId,
|
||||
selected_claim_no: String(record?.claimNo || '').trim()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1443,6 +1461,27 @@ export default {
|
||||
// submitting.value = false
|
||||
return submitComposerInternal(options)
|
||||
}
|
||||
|
||||
async function handleAssistantMarkdownClick(event, message) {
|
||||
const anchor = event?.target?.closest?.('a')
|
||||
if (!anchor || !message || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const href = String(anchor.getAttribute('href') || '').trim()
|
||||
if (href !== ATTACHMENT_ASSOCIATION_CONFIRM_HREF) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
reviewActionBusy.value = true
|
||||
try {
|
||||
await confirmPendingAttachmentAssociationInternal(message)
|
||||
} finally {
|
||||
reviewActionBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReviewAction(message, action) {
|
||||
const actionType = String(action?.action_type || '').trim()
|
||||
if (!actionType || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return
|
||||
@@ -1481,11 +1520,11 @@ export default {
|
||||
reviewIntentText, reviewFactCards, reviewCategoryOptions, reviewOtherCategoryOptions, reviewSelectedOtherCategory, reviewInlineDirty, reviewInlineForm, reviewInlineEditorKey, reviewInlineErrors, reviewOtherCategoryOpen, reviewInlinePendingFiles, DATE_INPUT_FORMAT, REVIEW_SCENE_OTHER_OPTION, REVIEW_SCENE_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS,
|
||||
workbenchVisible, reviewPanelConfidence, reviewRiskSummary, reviewRiskItems, reviewRiskEmpty, recognizedNarratives, reviewRecognitionNotes, reviewDocumentSummaries, reviewDocumentCount, reviewDocumentDirty, reviewHasUnsavedChanges, uploadDecisionDialogOpen,
|
||||
travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, deleteSessionDialogOpen, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts,
|
||||
resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, resolveReviewFooterActions, resolveReviewSaveDraftAction, buildReviewPrimaryButtonLabel,
|
||||
resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, resolveReviewFooterActions, resolveReviewSaveDraftAction, buildReviewPrimaryButtonLabel, buildReviewMainMessageText,
|
||||
renderMarkdown, buildExpenseQueryWindowLabel, buildExpenseQueryHint, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, resolveDocumentPreview, triggerFileUpload, applyComposerDateSelection, handleFilesChange, handleComposerInput, handleComposerEnter, runShortcut, runWelcomeQuickAction: runShortcut, handleSuggestedAction, isSuggestedActionSelected, askHotKnowledgeQuestion, resolveKnowledgeRankLabel, resolveKnowledgeRankTone,
|
||||
refreshFlowRunDetail, formatFlowStepDuration, resolveFlowStepStatusLabel, resolveFlowStepDetail, toggleInsightPanel, openTravelCalculator, toggleTravelCalculator, closeTravelCalculator, submitTravelCalculator, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewRiskDrawer, toggleReviewFlowDrawer, toggleAttachedFilesExpanded, removeAttachedFile, clearAttachedFiles,
|
||||
requestCloseWorkbench, emitCloseAfterLeave, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, closeUploadDecisionDialog, continueExistingUpload, createNewUploadDocument, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory,
|
||||
queryDraftByClaimNo, appendReviewRiskBriefToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleReviewAction, handleSaveDraftDirectly, canUseInlineSaveDraft, handleInlineSaveDraft
|
||||
queryDraftByClaimNo, appendReviewRiskBriefToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, canUseInlineSaveDraft, handleInlineSaveDraft
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ function resolveStatusTone(status) {
|
||||
export const MAX_ATTACHMENTS = 10
|
||||
export const MAX_OCR_DOCUMENTS = 10
|
||||
export const VISIBLE_ATTACHMENT_CHIPS = 2
|
||||
export const ATTACHMENT_ASSOCIATION_CONFIRM_HREF = '#confirm-attachment-association'
|
||||
|
||||
export function normalizeOcrDocuments(payload) {
|
||||
const documents = Array.isArray(payload?.documents) ? payload.documents : []
|
||||
@@ -85,6 +86,88 @@ export function buildOcrSummaryFromDocuments(documents) {
|
||||
.join(';')
|
||||
}
|
||||
|
||||
function resolveAssociationDocumentTypeLabel(document) {
|
||||
const explicitLabel = String(document?.document_type_label || '').trim()
|
||||
if (explicitLabel) {
|
||||
return explicitLabel
|
||||
}
|
||||
|
||||
const sceneLabel = String(document?.scene_label || '').trim()
|
||||
if (sceneLabel) {
|
||||
return sceneLabel
|
||||
}
|
||||
|
||||
const typeLabel = resolveDocumentTypeLabel(document?.document_type)
|
||||
return String(typeLabel || '').trim() || '其他票据'
|
||||
}
|
||||
|
||||
function buildAssociationDocumentContentLines(document) {
|
||||
const fields = Array.isArray(document?.document_fields) ? document.document_fields : []
|
||||
const fieldLines = fields
|
||||
.map((field) => {
|
||||
const label = String(field?.label || '').trim()
|
||||
const value = String(field?.value || '').trim()
|
||||
return label && value ? `- ${label}:${value}` : ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
if (fieldLines.length) {
|
||||
return fieldLines.slice(0, 8)
|
||||
}
|
||||
|
||||
const summary = String(document?.summary || document?.text || '').trim()
|
||||
if (summary) {
|
||||
return [`- 识别内容:${summary}`]
|
||||
}
|
||||
|
||||
return ['- 识别内容:暂未提取到结构化字段,请以票据原件为准。']
|
||||
}
|
||||
|
||||
export function buildAttachmentAssociationConfirmationMessage({
|
||||
claimNo = '',
|
||||
claimTitle = '',
|
||||
fileNames = [],
|
||||
ocrDocuments = []
|
||||
} = {}) {
|
||||
const documents = Array.isArray(ocrDocuments) && ocrDocuments.length
|
||||
? ocrDocuments
|
||||
: (Array.isArray(fileNames) ? fileNames : [])
|
||||
.map((filename) => ({ filename }))
|
||||
.filter((item) => String(item.filename || '').trim())
|
||||
const targetLines = [
|
||||
claimNo ? `- 草稿单号:${claimNo}` : '',
|
||||
claimTitle ? `- 单据说明:${claimTitle}` : '',
|
||||
`- 本次待归集附件:${documents.length || fileNames.length || 0} 份`
|
||||
].filter(Boolean)
|
||||
|
||||
const documentBlocks = documents.map((document, index) => {
|
||||
const filename = String(document?.filename || '').trim() || `附件 ${index + 1}`
|
||||
const typeLabel = resolveAssociationDocumentTypeLabel(document)
|
||||
const contentLines = buildAssociationDocumentContentLines(document)
|
||||
return [
|
||||
`附件 ${index + 1}:${filename}`,
|
||||
'',
|
||||
`附件类型:${typeLabel}`,
|
||||
'',
|
||||
...contentLines
|
||||
].join('\n')
|
||||
})
|
||||
|
||||
return [
|
||||
'已识别附件信息:',
|
||||
'',
|
||||
documentBlocks.join('\n\n'),
|
||||
'',
|
||||
'请问是否确定将票据信息归集到单据:',
|
||||
'',
|
||||
targetLines.join('\n'),
|
||||
'',
|
||||
`如果 [确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF}) 该信息,我将直接将票据进行归集。`
|
||||
]
|
||||
.filter((part) => String(part || '').trim())
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export function normalizeReviewDocumentFieldKey(label) {
|
||||
const compact = String(label || '').replace(/\s+/g, '').toLowerCase()
|
||||
if (!compact) return ''
|
||||
|
||||
@@ -167,6 +167,7 @@ export function createMessage(role, text, attachments = [], extras = {}) {
|
||||
draftPayload: null,
|
||||
reviewPayload: null,
|
||||
riskFlags: [],
|
||||
pendingAttachmentAssociation: null,
|
||||
...extras
|
||||
}
|
||||
}
|
||||
@@ -666,6 +667,7 @@ export function serializeSessionMessages(messages) {
|
||||
draftPayload: message.draftPayload || null,
|
||||
reviewPayload: message.reviewPayload || null,
|
||||
riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [],
|
||||
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
|
||||
assistantName: message.assistantName || '',
|
||||
isWelcome: Boolean(message.isWelcome),
|
||||
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []
|
||||
|
||||
@@ -224,14 +224,22 @@ export function resolveReviewMissingSlotCards(reviewPayload) {
|
||||
|
||||
export function resolveReviewExtraMissingLabels(reviewPayload) {
|
||||
const labels = Array.isArray(reviewPayload?.missing_slots)
|
||||
? reviewPayload.missing_slots.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
? reviewPayload.missing_slots
|
||||
.map((item) => {
|
||||
if (item && typeof item === 'object') {
|
||||
return String(item.label || item.title || item.key || '').trim()
|
||||
}
|
||||
return String(item || '').trim()
|
||||
})
|
||||
.filter(Boolean)
|
||||
: []
|
||||
if (!labels.length) return []
|
||||
|
||||
const slotLabels = new Set(
|
||||
(Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards : [])
|
||||
.map((item) => String(item?.label || item?.key || '').trim())
|
||||
.filter(Boolean)
|
||||
(Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards : []).flatMap((item) => [
|
||||
String(item?.label || '').trim(),
|
||||
String(item?.key || '').trim()
|
||||
]).filter(Boolean)
|
||||
)
|
||||
return labels.filter((label) => !slotLabels.has(label))
|
||||
}
|
||||
@@ -1239,23 +1247,66 @@ function buildReviewPlainFollowupItem(item, pendingMode) {
|
||||
}
|
||||
}
|
||||
|
||||
const REVIEW_PENDING_SUMMARY_TEMPLATES = [
|
||||
({ issueSummary }) => `当前还有 ${issueSummary}。请核查对话中的文字说明;如果想先暂存,也可以点击对话文字中的“草稿”。`,
|
||||
({ issueSummary }) => `我这边看到还有 ${issueSummary},建议先把下方内容核对一下;暂时不处理也没关系,可以点击“草稿”先保存。`,
|
||||
({ issueSummary }) => `下方还有 ${issueSummary},需要你确认。信息没补齐前可以先核查说明,后续需要暂存时点“草稿”。`,
|
||||
({ issueSummary }) => `这笔报销还有 ${issueSummary},尚未完全确认。请先看一下下面的补充项;需要中途保存时,可以点“草稿”。`,
|
||||
({ issueSummary }) => `目前还有 ${issueSummary}。你可以先按下面的提示补充,也可以稍后再处理,点击“草稿”即可暂存当前信息。`,
|
||||
({ issueSummary }) => `还有 ${issueSummary},建议先核对下面说明;如果票据或金额暂时不全,可以通过“草稿”保留当前进度。`,
|
||||
({ issueSummary }) => `这次识别结果里还有 ${issueSummary}。请重点看下面几项,暂不提交时可以点“草稿”保存。`,
|
||||
({ issueSummary }) => `我还需要你确认 ${issueSummary}。下面列出了具体内容;如果现在不方便补齐,可以先点“草稿”。`,
|
||||
({ issueSummary }) => `当前还有 ${issueSummary},需要进一步处理。请根据下面提示核查,待补充完再继续;临时保存可点击“草稿”。`,
|
||||
({ issueSummary }) => `本次报销还有 ${issueSummary},请先检查下面的补充项;想先留存当前识别结果时可以点“草稿”。`
|
||||
]
|
||||
|
||||
function buildStableTemplateIndex(signature, total) {
|
||||
const source = String(signature || '')
|
||||
let hash = 0
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
hash = ((hash << 5) - hash + source.charCodeAt(index)) >>> 0
|
||||
}
|
||||
return total ? hash % total : 0
|
||||
}
|
||||
|
||||
function buildReviewPendingSummary(pendingCount, riskCount, signature = '') {
|
||||
const issueParts = []
|
||||
if (pendingCount) {
|
||||
issueParts.push(`${pendingCount} 项信息待补充`)
|
||||
}
|
||||
if (riskCount) {
|
||||
issueParts.push(`${riskCount} 条风险提醒`)
|
||||
}
|
||||
const issueSummary = issueParts.length ? issueParts.join('、') : '一些细节还需要进一步确认'
|
||||
const templateIndex = buildStableTemplateIndex(signature || issueSummary, REVIEW_PENDING_SUMMARY_TEMPLATES.length)
|
||||
return REVIEW_PENDING_SUMMARY_TEMPLATES[templateIndex]({ issueSummary })
|
||||
}
|
||||
|
||||
export function buildReviewPlainFollowupCopy(reviewPayload) {
|
||||
const todoItems = buildReviewTodoItems(reviewPayload)
|
||||
const pendingCount = countReviewPendingItems(reviewPayload)
|
||||
const riskBriefs = resolvePresentationRiskBriefs(reviewPayload)
|
||||
const extraMissingCount = resolveReviewExtraMissingLabels(reviewPayload).length
|
||||
|
||||
if (pendingCount || resolveReviewExtraMissingLabels(reviewPayload).length) {
|
||||
if (pendingCount || extraMissingCount) {
|
||||
const summarySignature = [
|
||||
pendingCount || extraMissingCount,
|
||||
riskBriefs.length,
|
||||
...todoItems.map((item) => `${item.key}:${item.title}:${item.status}`)
|
||||
].join('|')
|
||||
return {
|
||||
lead: '我还需要你核查或补充下面这些信息:',
|
||||
lead: '补充信息:',
|
||||
tone: 'danger',
|
||||
summary: buildReviewPendingSummary(pendingCount || extraMissingCount, riskBriefs.length, summarySignature),
|
||||
items: todoItems.map((item) => buildReviewPlainFollowupItem(item, true)),
|
||||
notes: riskBriefs.length
|
||||
? [`另外还有 ${riskBriefs.length} 条风险提醒,提交前建议一起确认。`]
|
||||
: []
|
||||
notes: []
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
lead: todoItems.length ? '我已整理出当前识别到的关键信息:' : '当前关键信息已基本整理完成。',
|
||||
tone: 'neutral',
|
||||
summary: '',
|
||||
items: todoItems.map((item) => buildReviewPlainFollowupItem(item, false)),
|
||||
notes: [
|
||||
reviewPayload?.can_proceed ? '确认无误后,可以继续下一步。' : '',
|
||||
|
||||
@@ -103,15 +103,15 @@ export function useTravelReimbursementReviewDrawer({
|
||||
const isReviewFlowDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW)
|
||||
const reviewDrawerTitle = computed(() => (
|
||||
isReviewDocumentDrawer.value
|
||||
? '绁ㄦ嵁璇嗗埆缁撴灉'
|
||||
? '票据识别结果'
|
||||
: isReviewRiskDrawer.value
|
||||
? '椋庨櫓鎻愮ず'
|
||||
? '风险提示'
|
||||
: isReviewFlowDrawer.value
|
||||
? '璋冪敤娴佺▼'
|
||||
: '鎶ラ攢璇嗗埆鏍稿'
|
||||
? '执行流程'
|
||||
: '报销识别核对'
|
||||
))
|
||||
const reviewDocumentDrawerLabel = computed(() => (
|
||||
'鍗曟嵁璇嗗埆'
|
||||
'单据识别'
|
||||
))
|
||||
const reviewDocumentDrawerIcon = computed(() => (
|
||||
isReviewDocumentDrawer.value
|
||||
@@ -119,7 +119,7 @@ export function useTravelReimbursementReviewDrawer({
|
||||
: 'mdi mdi-file-document-multiple-outline'
|
||||
))
|
||||
const reviewRiskDrawerLabel = computed(() => (
|
||||
'鏄剧ず椋庨櫓'
|
||||
'显示风险'
|
||||
))
|
||||
const reviewRiskDrawerIcon = computed(() => (
|
||||
isReviewRiskDrawer.value
|
||||
@@ -127,7 +127,7 @@ export function useTravelReimbursementReviewDrawer({
|
||||
: 'mdi mdi-shield-alert-outline'
|
||||
))
|
||||
const reviewFlowDrawerLabel = computed(() => (
|
||||
'璋冪敤娴佺▼'
|
||||
'执行流程'
|
||||
))
|
||||
const reviewFlowDrawerIcon = computed(() => (
|
||||
isReviewFlowDrawer.value
|
||||
@@ -253,7 +253,7 @@ export function useTravelReimbursementReviewDrawer({
|
||||
) {
|
||||
nextForm.reason_value = String(reviewInlineForm.value.reason_value || '').trim()
|
||||
if (!nextForm.reason_value) {
|
||||
setInlineReviewFieldError('scene', '璇烽€夋嫨鈥滃叾浠栧満鏅€濆悗锛岃琛ュ厖鍏蜂綋浜嬬敱')
|
||||
setInlineReviewFieldError('scene', '请选择“其他场景”后,请补充具体事由')
|
||||
reviewInlineForm.value = nextForm
|
||||
return false
|
||||
}
|
||||
@@ -262,14 +262,14 @@ export function useTravelReimbursementReviewDrawer({
|
||||
}
|
||||
|
||||
if (activeEditorKey === 'occurred_date' && nextForm.occurred_date && !isValidIsoDateString(nextForm.occurred_date)) {
|
||||
setInlineReviewFieldError('occurred_date', `璇疯緭鍏ユ纭殑鏃堕棿鏍煎紡锛?{DATE_INPUT_FORMAT}`)
|
||||
setInlineReviewFieldError('occurred_date', `请输入正确的时间格式:${DATE_INPUT_FORMAT}`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (activeEditorKey === 'amount' && nextForm.amount) {
|
||||
const normalizedAmount = normalizeAmountValue(nextForm.amount)
|
||||
if (!normalizedAmount) {
|
||||
setInlineReviewFieldError('amount', '璇疯緭鍏ユ纭殑鏁板瓧閲戦锛屼緥濡?200 鎴?200.50')
|
||||
setInlineReviewFieldError('amount', '请输入正确的数字金额,例如 200 或 200.50')
|
||||
return false
|
||||
}
|
||||
nextForm.amount = normalizedAmount
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
|
||||
buildAttachmentAssociationConfirmationMessage
|
||||
} from './travelReimbursementAttachmentModel.js'
|
||||
|
||||
export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
const {
|
||||
MAX_ATTACHMENTS,
|
||||
@@ -74,6 +79,87 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
uploadDecisionDialogOpen,
|
||||
toast
|
||||
} = ctx
|
||||
|
||||
const pendingAttachmentAssociations = new Map()
|
||||
|
||||
function createPendingAttachmentAssociationId() {
|
||||
return `attachment-association-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
|
||||
function normalizeRecognizedAttachmentData(data) {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return null
|
||||
}
|
||||
const documents = Array.isArray(data.ocrDocuments) ? data.ocrDocuments : []
|
||||
if (!documents.length) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
ocrPayload: data.ocrPayload || null,
|
||||
ocrSummary: String(data.ocrSummary || '').trim(),
|
||||
ocrDocuments: documents,
|
||||
ocrFilePreviews: Array.isArray(data.ocrFilePreviews) ? data.ocrFilePreviews : []
|
||||
}
|
||||
}
|
||||
|
||||
function buildConfirmedAssociationText(message) {
|
||||
return String(message?.text || '').replace(
|
||||
`[确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})`,
|
||||
'已确认'
|
||||
)
|
||||
}
|
||||
|
||||
async function confirmPendingAttachmentAssociation(message) {
|
||||
if (submitting.value || sessionSwitchBusy.value) return null
|
||||
|
||||
const pending = message?.pendingAttachmentAssociation && typeof message.pendingAttachmentAssociation === 'object'
|
||||
? message.pendingAttachmentAssociation
|
||||
: null
|
||||
const associationId = String(pending?.id || '').trim()
|
||||
if (!associationId || pending?.status === 'confirmed') {
|
||||
return null
|
||||
}
|
||||
|
||||
const runtime = pendingAttachmentAssociations.get(associationId)
|
||||
if (!runtime || !Array.isArray(runtime.files) || !runtime.files.length) {
|
||||
toast('当前会话里没有可归集的附件原件,请重新上传票据后再确认。')
|
||||
return null
|
||||
}
|
||||
|
||||
pending.status = 'confirmed'
|
||||
message.pendingAttachmentAssociation = pending
|
||||
message.text = buildConfirmedAssociationText(message)
|
||||
message.meta = ['已确认归集']
|
||||
persistSessionState()
|
||||
|
||||
return submitComposer({
|
||||
rawText: `确认将本次上传的 ${runtime.fileNames.length} 份票据归集到草稿 ${runtime.claimNo || '当前草稿'}`,
|
||||
userText: `确认归集到草稿 ${runtime.claimNo || '当前草稿'}`,
|
||||
files: runtime.files,
|
||||
uploadDisposition: 'continue_existing',
|
||||
skipUploadDecisionPrompt: true,
|
||||
skipDraftAssociationPrompt: true,
|
||||
pendingText: runtime.claimNo
|
||||
? `正在将票据归集到草稿 ${runtime.claimNo}...`
|
||||
: '正在将票据归集到当前草稿...',
|
||||
associationConfirmed: true,
|
||||
recognizedAttachmentData: {
|
||||
ocrPayload: runtime.ocrPayload,
|
||||
ocrSummary: runtime.ocrSummary,
|
||||
ocrDocuments: runtime.ocrDocuments,
|
||||
ocrFilePreviews: runtime.ocrFilePreviews
|
||||
},
|
||||
extraContext: {
|
||||
...runtime.extraContext,
|
||||
review_action: 'link_to_existing_draft',
|
||||
draft_claim_id: runtime.claimId,
|
||||
selected_claim_id: runtime.claimId,
|
||||
selected_claim_no: runtime.claimNo,
|
||||
attachment_association_confirmed: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildBackendMessage(rawText, fileNames, ocrSummary = '') {
|
||||
const parts = []
|
||||
const normalizedText = String(rawText || '').trim()
|
||||
@@ -128,6 +214,11 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
? initialExtraContext
|
||||
: mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext)
|
||||
const reviewAction = String(extraContext.review_action || '').trim()
|
||||
const attachmentAssociationConfirmed = Boolean(
|
||||
options.associationConfirmed ||
|
||||
extraContext.attachment_association_confirmed ||
|
||||
reviewAction === 'link_to_existing_draft'
|
||||
)
|
||||
const hasSelectedExpenseType = Boolean(
|
||||
extraContext.expense_scene_selection ||
|
||||
String(extraContext.review_form_values?.expense_type || extraContext.review_form_values?.reimbursement_type || '').trim()
|
||||
@@ -305,21 +396,100 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
let ocrSummary = ''
|
||||
let ocrDocuments = []
|
||||
let ocrFilePreviews = []
|
||||
const recognizedAttachmentData = normalizeRecognizedAttachmentData(options.recognizedAttachmentData)
|
||||
|
||||
if (files.length) {
|
||||
const ocrStartedAt = Date.now()
|
||||
startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt })
|
||||
try {
|
||||
ocrPayload = await recognizeOcrFiles(files)
|
||||
ocrSummary = buildOcrSummary(ocrPayload)
|
||||
ocrDocuments = normalizeOcrDocuments(ocrPayload)
|
||||
ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
|
||||
if (recognizedAttachmentData) {
|
||||
ocrPayload = recognizedAttachmentData.ocrPayload
|
||||
ocrSummary = recognizedAttachmentData.ocrSummary || buildOcrSummaryFromDocuments(recognizedAttachmentData.ocrDocuments)
|
||||
ocrDocuments = [...recognizedAttachmentData.ocrDocuments]
|
||||
ocrFilePreviews = [...recognizedAttachmentData.ocrFilePreviews]
|
||||
rememberFilePreviews(ocrFilePreviews)
|
||||
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)
|
||||
} catch (error) {
|
||||
console.warn('OCR request failed:', error)
|
||||
completeFlowStep('ocr', 'OCR识别失败,已继续使用附件名称', Date.now() - ocrStartedAt)
|
||||
completeFlowStep('ocr', `复用已确认的 ${ocrDocuments.length || files.length} 张票据识别结果`, Date.now() - ocrStartedAt)
|
||||
} else {
|
||||
try {
|
||||
ocrPayload = await recognizeOcrFiles(files, {
|
||||
timeoutMs: 90000,
|
||||
timeoutMessage: '票据 OCR 识别超时,已继续使用附件名称处理。'
|
||||
})
|
||||
ocrSummary = buildOcrSummary(ocrPayload)
|
||||
ocrDocuments = normalizeOcrDocuments(ocrPayload)
|
||||
ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
|
||||
rememberFilePreviews(ocrFilePreviews)
|
||||
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)
|
||||
} catch (error) {
|
||||
console.warn('OCR request failed:', error)
|
||||
completeFlowStep('ocr', 'OCR识别失败,已继续使用附件名称', Date.now() - ocrStartedAt)
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedUploadDisposition === 'continue_existing') {
|
||||
replaceMessage(pendingMessage.id, {
|
||||
...pendingMessage,
|
||||
text: attachmentAssociationConfirmed
|
||||
? '票据识别已完成,正在把本次附件归集到已选择的草稿...'
|
||||
: '票据识别已完成,正在整理归集前确认信息...',
|
||||
meta: attachmentAssociationConfirmed ? ['正在归集'] : ['等待确认归集']
|
||||
})
|
||||
persistSessionState()
|
||||
}
|
||||
}
|
||||
|
||||
const associationTargetClaimId = String(extraContext.draft_claim_id || draftClaimId.value || '').trim()
|
||||
const associationTargetClaimNo = String(
|
||||
extraContext.selected_claim_no ||
|
||||
extraContext.draft_claim_no ||
|
||||
''
|
||||
).trim()
|
||||
if (
|
||||
files.length &&
|
||||
resolvedUploadDisposition === 'continue_existing' &&
|
||||
associationTargetClaimId &&
|
||||
!attachmentAssociationConfirmed
|
||||
) {
|
||||
const associationId = createPendingAttachmentAssociationId()
|
||||
const pendingAssociation = {
|
||||
id: associationId,
|
||||
status: 'pending',
|
||||
claimId: associationTargetClaimId,
|
||||
claimNo: associationTargetClaimNo,
|
||||
fileNames
|
||||
}
|
||||
pendingAttachmentAssociations.set(associationId, {
|
||||
files,
|
||||
fileNames,
|
||||
ocrPayload,
|
||||
ocrSummary,
|
||||
ocrDocuments,
|
||||
ocrFilePreviews,
|
||||
filePreviews,
|
||||
claimId: associationTargetClaimId,
|
||||
claimNo: associationTargetClaimNo,
|
||||
extraContext: {
|
||||
...extraContext,
|
||||
draft_claim_id: associationTargetClaimId,
|
||||
selected_claim_id: associationTargetClaimId,
|
||||
selected_claim_no: associationTargetClaimNo
|
||||
}
|
||||
})
|
||||
replaceMessage(pendingMessage.id, createMessage(
|
||||
'assistant',
|
||||
buildAttachmentAssociationConfirmationMessage({
|
||||
claimNo: associationTargetClaimNo,
|
||||
fileNames,
|
||||
ocrDocuments
|
||||
}),
|
||||
[],
|
||||
{
|
||||
meta: ['等待确认归集'],
|
||||
pendingAttachmentAssociation: pendingAssociation
|
||||
}
|
||||
))
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
return null
|
||||
}
|
||||
|
||||
let effectiveFileNames = [...fileNames]
|
||||
@@ -359,6 +529,16 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
})
|
||||
|
||||
const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary)
|
||||
const orchestratorOptions = isKnowledgeSession.value
|
||||
? {
|
||||
timeoutMs: 18000,
|
||||
timeoutMessage: '知识问答整理超时,已停止等待。建议缩小问题范围或稍后重试。'
|
||||
}
|
||||
: {
|
||||
timeoutMs: 120000,
|
||||
timeoutMessage: '票据归集处理超时,当前仍停留在原草稿,请稍后重试或重新选择附件。'
|
||||
}
|
||||
|
||||
const payload = await runOrchestrator(
|
||||
{
|
||||
source: 'user_message',
|
||||
@@ -393,12 +573,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
...extraContext
|
||||
}
|
||||
},
|
||||
isKnowledgeSession.value
|
||||
? {
|
||||
timeoutMs: 18000,
|
||||
timeoutMessage: '知识问答整理超时,已停止等待。建议缩小问题范围或稍后重试。'
|
||||
}
|
||||
: {}
|
||||
orchestratorOptions
|
||||
)
|
||||
responsePayload = payload
|
||||
flowRunId.value = String(payload?.run_id || '').trim()
|
||||
@@ -413,35 +588,42 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
? ''
|
||||
: String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value
|
||||
|
||||
replaceMessage(
|
||||
pendingMessage.id,
|
||||
createMessage('assistant', payload?.result?.answer || payload?.result?.message || '智能体已完成处理。', [], {
|
||||
meta: buildMessageMeta(payload, effectiveFileNames),
|
||||
citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [],
|
||||
suggestedActions: Array.isArray(payload?.result?.suggested_actions)
|
||||
? payload.result.suggested_actions
|
||||
: [],
|
||||
queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload),
|
||||
draftPayload: payload?.result?.draft_payload || null,
|
||||
reviewPayload: payload?.result?.review_payload || null,
|
||||
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : []
|
||||
})
|
||||
)
|
||||
const reviewActionResult = String(extraContext.review_action || '').trim()
|
||||
const resultClaimNo = String(payload?.result?.draft_payload?.claim_no || '').trim()
|
||||
const fallbackAnswer = reviewActionResult === 'link_to_existing_draft'
|
||||
? (resultClaimNo ? `已将本次上传的票据关联到草稿 ${resultClaimNo}。` : '已将本次上传的票据关联到现有草稿。')
|
||||
: '智能体已完成处理。'
|
||||
const assistantMessage = createMessage('assistant', payload?.result?.answer || payload?.result?.message || fallbackAnswer, [], {
|
||||
meta: buildMessageMeta(payload, effectiveFileNames),
|
||||
citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [],
|
||||
suggestedActions: Array.isArray(payload?.result?.suggested_actions)
|
||||
? payload.result.suggested_actions
|
||||
: [],
|
||||
queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload),
|
||||
draftPayload: payload?.result?.draft_payload || null,
|
||||
reviewPayload: payload?.result?.review_payload || null,
|
||||
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : []
|
||||
})
|
||||
replaceMessage(pendingMessage.id, assistantMessage)
|
||||
currentInsight.value = buildAgentInsight(
|
||||
payload,
|
||||
effectiveFileNames,
|
||||
mergeFilePreviews(filePreviews, ocrFilePreviews)
|
||||
)
|
||||
completeFlowResult(payload, flowRunDetail)
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
|
||||
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
|
||||
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
|
||||
try {
|
||||
await syncComposerFilesToDraft(resolvedDraftClaimId, files)
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist composer attachments to draft claim:', error)
|
||||
toast(error?.message || '票据已识别,但附件原件保存失败,请重试上传。')
|
||||
}
|
||||
void syncComposerFilesToDraft(resolvedDraftClaimId, files)
|
||||
.then(() => {
|
||||
persistSessionState()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('Failed to persist composer attachments to draft claim:', error)
|
||||
toast(error?.message || '票据已归集到草稿,但附件原件保存失败,请在单据详情中重新上传。')
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
clearFlowSimulationTimers()
|
||||
@@ -458,6 +640,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
)
|
||||
)
|
||||
currentInsight.value = buildErrorInsight(error, fileNames)
|
||||
persistSessionState()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
composerUploadIntent.value = ''
|
||||
@@ -469,6 +652,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
|
||||
|
||||
return {
|
||||
confirmPendingAttachmentAssociationInternal: confirmPendingAttachmentAssociation,
|
||||
submitComposerInternal: submitComposer
|
||||
}
|
||||
}
|
||||
|
||||
34
web/tests/attachment-association-confirmation.test.mjs
Normal file
34
web/tests/attachment-association-confirmation.test.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
|
||||
buildAttachmentAssociationConfirmationMessage
|
||||
} from '../src/views/scripts/travelReimbursementAttachmentModel.js'
|
||||
|
||||
test('attachment association prompt prints recognized receipt details before confirmation link', () => {
|
||||
const message = buildAttachmentAssociationConfirmationMessage({
|
||||
claimNo: 'EXP-202605-001',
|
||||
fileNames: ['train-ticket.pdf'],
|
||||
ocrDocuments: [
|
||||
{
|
||||
filename: 'train-ticket.pdf',
|
||||
document_type: 'train_ticket',
|
||||
scene_label: '差旅票据',
|
||||
summary: '铁路电子客票 武汉-上海 票价 354 元',
|
||||
document_fields: [
|
||||
{ key: 'route', label: '行程', value: '武汉-上海' },
|
||||
{ key: 'amount', label: '票价', value: '354.00' },
|
||||
{ key: 'date', label: '乘车日期', value: '2026-02-20' }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.match(message, /已识别附件信息:/)
|
||||
assert.match(message, /附件类型:差旅票据/)
|
||||
assert.match(message, /行程:武汉-上海/)
|
||||
assert.match(message, /票价:354.00/)
|
||||
assert.match(message, /草稿单号:EXP-202605-001/)
|
||||
assert.match(message, new RegExp(`\\[确认\\]\\(${ATTACHMENT_ASSOCIATION_CONFIRM_HREF}\\)`))
|
||||
})
|
||||
@@ -156,19 +156,19 @@ test('review drawer save action is disabled while receipt recognition is submitt
|
||||
)
|
||||
})
|
||||
|
||||
test('draft creation waits for composer attachments to be persisted before leaving submit state', () => {
|
||||
test('draft creation starts composer attachment persistence after response rendering', () => {
|
||||
assert.match(
|
||||
submitComposerScript,
|
||||
/try \{\s*await syncComposerFilesToDraft\(resolvedDraftClaimId, files\)\s*\} catch \(error\) \{/s
|
||||
/void syncComposerFilesToDraft\(resolvedDraftClaimId, files\)\s*\.then\(\(\) => \{\s*persistSessionState\(\)\s*\}\)\s*\.catch\(\(error\) => \{/s
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
submitComposerScript,
|
||||
/syncComposerFilesToDraft\(resolvedDraftClaimId, files\)\.catch/
|
||||
/await syncComposerFilesToDraft\(resolvedDraftClaimId, files\)/
|
||||
)
|
||||
assert.ok(
|
||||
submitComposerScript.indexOf('await syncComposerFilesToDraft(resolvedDraftClaimId, files)') <
|
||||
submitComposerScript.indexOf('submitting.value = false'),
|
||||
'attachment persistence should finish before submit state is cleared'
|
||||
submitComposerScript.indexOf('replaceMessage(pendingMessage.id, assistantMessage)') <
|
||||
submitComposerScript.indexOf('void syncComposerFilesToDraft(resolvedDraftClaimId, files)'),
|
||||
'assistant response should render before background attachment persistence starts'
|
||||
)
|
||||
assert.match(attachmentsScript, /function normalizeAttachmentMatchName\(value\)/)
|
||||
assert.match(attachmentsScript, /const normalizedMatchBuckets = new Map\(\)/)
|
||||
|
||||
Reference in New Issue
Block a user