-
-
- 票据 {{ activeReviewDocument.index }}
- {{ activeReviewDocument.filename }}
-
-
-
-
-
- {{ activeReviewDocument.documentTypeLabel }}
- {{ activeReviewDocument.expenseTypeLabel }}
- {{ activeReviewDocument.confidenceLabel }}
-
-
-
@@ -244,6 +262,24 @@
{{ resolveAttachmentDisplayName(item) || '未上传附件' }}
+
+
+
+ {{ resolveAttachmentRecognition(item).documentTypeLabel }}
+
+
+ {{ resolveAttachmentRecognition(item).requirementLabel }}
+
+
+
+ {{ resolveAttachmentRecognition(item).message }}
+
+
+
diff --git a/web/src/views/scripts/TravelReimbursementCreateView.js b/web/src/views/scripts/TravelReimbursementCreateView.js
index f6251cc..fb794b6 100644
--- a/web/src/views/scripts/TravelReimbursementCreateView.js
+++ b/web/src/views/scripts/TravelReimbursementCreateView.js
@@ -37,9 +37,18 @@ const INTENT_LABELS = {
const DOCUMENT_TYPE_LABELS = {
travel_ticket: '行程单/机票/车票',
+ flight_itinerary: '机票/航班行程单',
+ train_ticket: '火车/高铁票',
hotel_invoice: '酒店住宿票据',
+ taxi_receipt: '出租车/网约车票据',
+ parking_toll_receipt: '停车/通行费票据',
transport_receipt: '交通出行票据',
meal_receipt: '餐饮票据',
+ office_invoice: '办公用品票据',
+ meeting_invoice: '会议/会务票据',
+ training_invoice: '培训票据',
+ vat_invoice: '增值税发票',
+ receipt: '一般收据/凭证',
other: '其他单据'
}
@@ -155,6 +164,9 @@ const COMPOSER_MAX_ROWS = 5
const EXPENSE_QUERY_PAGE_SIZE = 5
const SESSION_TYPE_EXPENSE = 'expense'
const SESSION_TYPE_KNOWLEDGE = 'knowledge'
+const REVIEW_DRAWER_MODE_REVIEW = 'review'
+const REVIEW_DRAWER_MODE_DOCUMENTS = 'documents'
+const REVIEW_DRAWER_MODE_RISK = 'risk'
const HOT_KNOWLEDGE_QUESTIONS = [
'差旅住宿标准按什么规则执行?',
'酒店超标后如何申请例外报销?',
@@ -316,6 +328,19 @@ function normalizeOcrDocuments(payload) {
text: String(item.text || '').slice(0, 240),
avg_score: Number(item.avg_score || 0),
line_count: Number(item.line_count || 0),
+ document_type: String(item.document_type || 'other').trim() || 'other',
+ document_type_label: String(item.document_type_label || '').trim(),
+ scene_code: String(item.scene_code || 'other').trim() || 'other',
+ scene_label: String(item.scene_label || '').trim(),
+ document_fields: Array.isArray(item.document_fields)
+ ? item.document_fields
+ .map((field) => ({
+ key: String(field?.key || '').trim(),
+ label: String(field?.label || '').trim(),
+ value: String(field?.value || '').trim()
+ }))
+ .filter((field) => field.key && field.label && field.value)
+ : [],
warnings: Array.isArray(item.warnings) ? item.warnings : []
}))
}
@@ -362,7 +387,15 @@ function buildFilePreviews(files, previewRegistry) {
function resolveDocumentPreview(filePreviews, filename) {
if (!Array.isArray(filePreviews)) return null
- return filePreviews.find((item) => item.filename === filename) ?? null
+ const matches = filePreviews.filter((item) => item.filename === filename)
+ if (!matches.length) {
+ return null
+ }
+ return (
+ matches.find((item) => item.kind === 'image' && item.url) ||
+ matches.find((item) => item.url) ||
+ matches[0]
+ )
}
function buildFileIdentity(file) {
@@ -418,6 +451,17 @@ function mergeFilePreviews(existingPreviews, incomingPreviews) {
return result
}
+function buildOcrFilePreviews(payload) {
+ const documents = Array.isArray(payload?.documents) ? payload.documents : []
+ return documents
+ .map((item) => ({
+ filename: String(item?.filename || '').trim(),
+ kind: String(item?.preview_kind || '').trim(),
+ url: String(item?.preview_data_url || '').trim()
+ }))
+ .filter((item) => item.filename && item.kind === 'image' && item.url)
+}
+
function extractReviewAttachmentNames(reviewPayload) {
const documentNames = Array.isArray(reviewPayload?.document_cards)
? reviewPayload.document_cards.map((item) => String(item?.filename || '').trim()).filter(Boolean)
@@ -1117,8 +1161,8 @@ function countReviewRiskItems(reviewPayload) {
}
function buildReviewHeadline(reviewPayload) {
- if (countReviewPendingItems(reviewPayload) || countReviewRiskItems(reviewPayload)) {
- return '风险提示与待补充信息'
+ if (countReviewPendingItems(reviewPayload)) {
+ return '待补充信息'
}
if (reviewPayload?.can_proceed) {
return '识别结果已整理完成'
@@ -1128,13 +1172,9 @@ function buildReviewHeadline(reviewPayload) {
function buildReviewSubline(reviewPayload) {
const pendingCount = countReviewPendingItems(reviewPayload)
- const riskCount = countReviewRiskItems(reviewPayload)
- if (pendingCount || riskCount) {
- const parts = []
- if (pendingCount) parts.push(`${pendingCount} 项待补充`)
- if (riskCount) parts.push(`${riskCount} 条提醒`)
- return `请先展开查看${parts.join('、')},再决定继续处理、修改信息或保存草稿。`
+ if (pendingCount) {
+ return `请先展开查看 ${pendingCount} 项待补充内容,再决定继续处理、修改信息或保存草稿。`
}
if (reviewPayload?.can_proceed) {
return '当前关键信息已基本齐全,展开确认无误后可以继续下一步。'
@@ -1144,42 +1184,35 @@ function buildReviewSubline(reviewPayload) {
function buildReviewStateLabel(reviewPayload) {
const pendingCount = countReviewPendingItems(reviewPayload)
- const riskCount = countReviewRiskItems(reviewPayload)
if (pendingCount) return `待补充 ${pendingCount} 项`
- if (riskCount) return `提醒 ${riskCount} 条`
if (reviewPayload?.can_proceed) return '可继续处理'
return '已识别'
}
function buildReviewStateTone(reviewPayload) {
- return reviewPayload?.can_proceed && !countReviewPendingItems(reviewPayload) && !countReviewRiskItems(reviewPayload)
+ return reviewPayload?.can_proceed && !countReviewPendingItems(reviewPayload)
? 'ready'
: 'pending'
}
function buildReviewDisclosureTitle(reviewPayload) {
const pendingCount = countReviewPendingItems(reviewPayload)
- const riskCount = countReviewRiskItems(reviewPayload)
- if (pendingCount || riskCount) {
- const parts = []
- if (riskCount) parts.push(`${riskCount} 条提醒`)
- if (pendingCount) parts.push(`${pendingCount} 项待补充`)
- return `当前有 ${parts.join(',')},点击展开查看`
+ if (pendingCount) {
+ return `当前有 ${pendingCount} 项待补充,点击展开查看`
}
- return '当前无明显风险或缺失项,可展开查看识别摘要'
+ return '当前信息已齐全,可展开查看识别摘要'
}
function buildReviewDisclosureHint(reviewPayload) {
const pendingCount = countReviewPendingItems(reviewPayload)
- const riskCount = countReviewRiskItems(reviewPayload)
- if (pendingCount || riskCount) {
- return '展开后可查看风险说明、待补充字段和处理建议'
+ if (pendingCount) {
+ return '展开后可查看待补充字段和处理建议'
}
return '展开后可查看本轮已识别的关键信息'
}
function shouldOpenReviewDisclosure(reviewPayload) {
- return !countReviewPendingItems(reviewPayload) && !countReviewRiskItems(reviewPayload)
+ return !countReviewPendingItems(reviewPayload)
}
function buildReviewTodoSectionTitle(reviewPayload) {
@@ -1990,6 +2023,7 @@ export default {
const reviewDocumentDrafts = ref([])
const reviewDocumentBaseDrafts = ref([])
const activeReviewDocumentIndex = ref(0)
+ const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW)
const insightPanelCollapsed = ref(false)
const documentPreviewDialog = ref({
open: false,
@@ -2076,11 +2110,38 @@ export default {
const reviewRiskSummary = computed(() => buildReviewRiskSummary(activeReviewPayload.value))
const reviewRiskItems = computed(() => buildReviewRiskItems(activeReviewPayload.value))
const reviewRiskEmpty = computed(() => reviewRiskScore.value === null && !reviewRiskItems.value.length)
+ const reviewDocumentDrawerAvailable = computed(() => reviewDocumentCount.value > 0)
+ const reviewRiskDrawerAvailable = computed(() => !reviewRiskEmpty.value)
const reviewRiskActionAvailable = computed(() => reviewRiskItems.value.length > 0)
const recognizedNarratives = computed(() => buildReviewRecognizedLines(activeReviewPayload.value))
const reviewRecognitionNotes = computed(() => buildReviewRecognitionNotes(activeReviewPayload.value))
const reviewDocumentSummaries = computed(() => buildReviewDocumentSummaries(activeReviewPayload.value))
const reviewDocumentCount = computed(() => reviewDocumentDrafts.value.length)
+ const isReviewDocumentDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS)
+ const isReviewRiskDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK)
+ const reviewDrawerTitle = computed(() => (
+ isReviewDocumentDrawer.value
+ ? '票据识别结果'
+ : isReviewRiskDrawer.value
+ ? '风险提示'
+ : '报销识别核对'
+ ))
+ const reviewDocumentDrawerLabel = computed(() => (
+ isReviewDocumentDrawer.value ? '显示核对' : '显示票据'
+ ))
+ const reviewDocumentDrawerIcon = computed(() => (
+ isReviewDocumentDrawer.value
+ ? 'mdi mdi-file-document-multiple'
+ : 'mdi mdi-file-document-multiple-outline'
+ ))
+ const reviewRiskDrawerLabel = computed(() => (
+ isReviewRiskDrawer.value ? '显示核对' : '显示风险'
+ ))
+ const reviewRiskDrawerIcon = computed(() => (
+ isReviewRiskDrawer.value
+ ? 'mdi mdi-shield-alert'
+ : 'mdi mdi-shield-alert-outline'
+ ))
const activeReviewDocument = computed(() => reviewDocumentDrafts.value[activeReviewDocumentIndex.value] ?? null)
const activeReviewDocumentPreview = computed(() =>
activeReviewDocument.value
@@ -2252,6 +2313,7 @@ export default {
activeReviewDocumentIndex.value = nextDocumentDrafts.length
? Math.min(activeReviewDocumentIndex.value, nextDocumentDrafts.length - 1)
: 0
+ reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
reviewInlinePendingFiles.value = []
reviewInlineEditorKey.value = ''
reviewInlineErrors.value = {}
@@ -2269,6 +2331,24 @@ export default {
}
)
+ watch(
+ () => reviewDocumentDrawerAvailable.value,
+ (available) => {
+ if (!available && reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS) {
+ reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
+ }
+ }
+ )
+
+ watch(
+ () => reviewRiskDrawerAvailable.value,
+ (available) => {
+ if (!available && reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK) {
+ reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
+ }
+ }
+ )
+
watch(
() => composerDraft.value,
() => {
@@ -2440,6 +2520,26 @@ export default {
insightPanelCollapsed.value = !insightPanelCollapsed.value
}
+ function toggleReviewDocumentDrawer() {
+ if (!reviewDocumentDrawerAvailable.value) {
+ return
+ }
+ reviewDrawerMode.value =
+ reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS
+ ? REVIEW_DRAWER_MODE_REVIEW
+ : REVIEW_DRAWER_MODE_DOCUMENTS
+ }
+
+ function toggleReviewRiskDrawer() {
+ if (!reviewRiskDrawerAvailable.value) {
+ return
+ }
+ reviewDrawerMode.value =
+ reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK
+ ? REVIEW_DRAWER_MODE_REVIEW
+ : REVIEW_DRAWER_MODE_RISK
+ }
+
function setInlineReviewFieldError(key, message) {
reviewInlineErrors.value = {
...reviewInlineErrors.value,
@@ -2778,7 +2878,10 @@ export default {
? options.extraContext
: {}
- messages.value.push(createMessage('user', userText, fileNames))
+ // 只有在非静默模式下才添加用户消息
+ if (!options.skipUserMessage) {
+ messages.value.push(createMessage('user', userText, fileNames))
+ }
const pendingMessage = createMessage(
'assistant',
@@ -2807,12 +2910,15 @@ export default {
let ocrPayload = null
let ocrSummary = ''
let ocrDocuments = []
+ let ocrFilePreviews = []
if (files.length) {
try {
ocrPayload = await recognizeOcrFiles(files)
ocrSummary = buildOcrSummary(ocrPayload)
ocrDocuments = normalizeOcrDocuments(ocrPayload)
+ ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
+ rememberFilePreviews(ocrFilePreviews)
} catch (error) {
console.warn('OCR request failed:', error)
}
@@ -2863,7 +2969,11 @@ export default {
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : []
})
)
- currentInsight.value = buildAgentInsight(payload, fileNames, filePreviews)
+ currentInsight.value = buildAgentInsight(
+ payload,
+ fileNames,
+ mergeFilePreviews(filePreviews, ocrFilePreviews)
+ )
} catch (error) {
replaceMessage(
pendingMessage.id,
@@ -2958,6 +3068,13 @@ export default {
return
}
+ // 保存草稿直接处理,不显示对话
+ if (actionType === 'save_draft') {
+ await handleSaveDraftDirectly(message)
+ return
+ }
+
+ // 下一步继续使用对话流程
reviewActionBusy.value = true
try {
const baseFields = reviewInlineBaseFields.value.length
@@ -2981,25 +3098,70 @@ export default {
rawText: [
reviewHasUnsavedChanges.value ? buildReviewCorrectionMessage(fields) : '',
reviewHasUnsavedChanges.value ? documentCorrectionMessage : '',
- actionType === 'save_draft'
- ? '请按当前已识别信息先保存草稿,缺失字段后续再补。'
- : '我已核对右侧识别结果,请进入下一步。'
+ '我已核对右侧识别结果,请进入下一步。'
]
.filter(Boolean)
.join('\n'),
- userText:
- reviewChangedUserText
- || (actionType === 'save_draft' ? '我先按当前信息保存草稿。' : '我确认当前识别结果,继续下一步。'),
+ userText: reviewChangedUserText || '我确认当前识别结果,继续下一步。',
files: reviewInlinePendingFiles.value,
- pendingText: actionType === 'save_draft' ? '正在保存当前草稿...' : '正在进入下一步...',
+ pendingText: '正在进入下一步...',
extraContext: {
review_action: actionType,
review_form_values: buildReviewFormValues(fields),
review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value)
}
})
+ } finally {
+ reviewActionBusy.value = false
+ }
+ }
+
+ // 新增:直接保存草稿的函数,不显示对话
+ async function handleSaveDraftDirectly(message) {
+ reviewActionBusy.value = true
+
+ // 记录当前消息数量,用于后续移除 submitComposer 添加的消息
+ const messageCountBefore = messages.value.length
+
+ try {
+ const baseFields = reviewInlineBaseFields.value.length
+ ? reviewInlineBaseFields.value
+ : cloneReviewEditFields(message?.reviewPayload?.edit_fields)
+ const fields = mergeInlineReviewFields(baseFields, reviewInlineForm.value)
+
+ // 先显示一个临时的"正在保存"消息
+ const savingMessage = createMessage('assistant', '正在保存草稿...', [], { meta: ['处理中'] })
+ messages.value.push(savingMessage)
+ nextTick(scrollToBottom)
+
+ // 调用保存逻辑,不通过对话
+ const payload = await submitComposer({
+ rawText: '请按当前已识别信息先保存草稿,缺失字段后续再补。',
+ userText: '', // 不显示用户消息
+ skipUserMessage: true, // 跳过添加用户消息
+ files: reviewInlinePendingFiles.value,
+ pendingText: '正在保存当前草稿...',
+ extraContext: {
+ review_action: 'save_draft',
+ review_form_values: buildReviewFormValues(fields),
+ review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value)
+ }
+ })
+
+ // 移除临时消息
+ const tempIndex = messages.value.findIndex((msg) => msg === savingMessage)
+ if (tempIndex !== -1) {
+ messages.value.splice(tempIndex, 1)
+ }
+
+ if (payload?.result?.draft_payload?.claim_no) {
+ // 显示保存成功的消息
+ messages.value.push(
+ createMessage('assistant', `✅ 草稿已保存,单号:${payload.result.draft_payload.claim_no}`, [], {
+ meta: ['草稿已保存']
+ })
+ )
- if (actionType === 'save_draft' && payload?.result?.draft_payload?.claim_no) {
emit(
'draft-saved',
buildDraftSavedPayload({
@@ -3010,7 +3172,21 @@ export default {
currentUser: currentUser.value
})
)
+ } else {
+ // 没有返回草稿信息,可能保存失败
+ messages.value.push(createMessage('assistant', '草稿保存完成', [], { meta: ['草稿已保存'] }))
}
+
+ nextTick(scrollToBottom)
+ } catch (error) {
+ // 移除临时消息
+ const tempIndex = messages.value.findIndex((msg) => msg === savingMessage)
+ if (tempIndex !== -1) {
+ messages.value.splice(tempIndex, 1)
+ }
+ // 显示错误消息
+ messages.value.push(createMessage('assistant', '❌ 保存草稿失败,请稍后重试。', [], { meta: ['错误'] }))
+ nextTick(scrollToBottom)
} finally {
reviewActionBusy.value = false
}
@@ -3046,6 +3222,16 @@ export default {
latestReviewMessage,
activeReviewPayload,
activeReviewFilePreviews,
+ reviewDrawerMode,
+ isReviewDocumentDrawer,
+ isReviewRiskDrawer,
+ reviewDrawerTitle,
+ reviewDocumentDrawerAvailable,
+ reviewRiskDrawerAvailable,
+ reviewDocumentDrawerLabel,
+ reviewDocumentDrawerIcon,
+ reviewRiskDrawerLabel,
+ reviewRiskDrawerIcon,
activeReviewDocument,
activeReviewDocumentIndex,
activeReviewDocumentPreview,
@@ -3119,6 +3305,8 @@ export default {
resolveKnowledgeRankLabel,
resolveKnowledgeRankTone,
toggleInsightPanel,
+ toggleReviewDocumentDrawer,
+ toggleReviewRiskDrawer,
toggleAttachedFilesExpanded,
removeAttachedFile,
clearAttachedFiles,
@@ -3144,6 +3332,7 @@ export default {
saveInlineReviewChanges,
submitComposer,
handleReviewAction,
+ handleSaveDraftDirectly,
closeCancelReviewDialog,
confirmCancelReview,
closeEditReviewDialog,
diff --git a/web/src/views/scripts/TravelRequestDetailView.js b/web/src/views/scripts/TravelRequestDetailView.js
index e246adb..f611bd2 100644
--- a/web/src/views/scripts/TravelRequestDetailView.js
+++ b/web/src/views/scripts/TravelRequestDetailView.js
@@ -27,6 +27,21 @@ const EXPENSE_TYPE_OPTIONS = [
{ value: 'other', label: '其他费用' }
]
+const DOCUMENT_TYPE_LABELS = {
+ flight_itinerary: '机票/航班行程单',
+ train_ticket: '火车/高铁票',
+ hotel_invoice: '酒店住宿票据',
+ taxi_receipt: '出租车/网约车票据',
+ parking_toll_receipt: '停车/通行费票据',
+ meal_receipt: '餐饮票据',
+ office_invoice: '办公用品票据',
+ meeting_invoice: '会议/会务票据',
+ training_invoice: '培训票据',
+ vat_invoice: '增值税发票',
+ receipt: '一般收据/凭证',
+ other: '其他单据'
+}
+
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
'travel',
'hotel',
@@ -57,6 +72,10 @@ function resolveExpenseTypeLabel(value) {
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalizeExpenseType(value))?.label || '其他费用'
}
+function resolveDocumentTypeLabel(value) {
+ return DOCUMENT_TYPE_LABELS[String(value || '').trim()] || DOCUMENT_TYPE_LABELS.other
+}
+
function isLocationRequiredExpenseType(value) {
return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value))
}
@@ -582,6 +601,38 @@ export default {
return String(metadata?.file_name || item.attachmentHint || '').trim()
}
+ function resolveAttachmentRecognition(item) {
+ const metadata = resolveAttachmentMeta(item)
+ const documentInfo = metadata?.document_info
+ const requirementCheck = metadata?.requirement_check
+ if (!documentInfo && !requirementCheck) {
+ return null
+ }
+
+ const fields = Array.isArray(documentInfo?.fields)
+ ? documentInfo.fields
+ .map((field) => ({
+ label: String(field?.label || '').trim(),
+ value: String(field?.value || '').trim()
+ }))
+ .filter((field) => field.label && field.value)
+ : []
+
+ return {
+ documentTypeLabel:
+ String(documentInfo?.document_type_label || '').trim()
+ || resolveDocumentTypeLabel(documentInfo?.document_type),
+ requirementLabel: requirementCheck
+ ? (requirementCheck.matches ? '符合当前费用类型' : '不符合当前费用类型')
+ : '待校验附件类型',
+ requirementTone: requirementCheck
+ ? (requirementCheck.matches ? 'pass' : 'high')
+ : 'medium',
+ message: String(requirementCheck?.message || '').trim(),
+ fields: fields.slice(0, 4).map((field) => `${field.label}:${field.value}`)
+ }
+ }
+
function buildAttachmentRiskNotice(attachment) {
const analysis = attachment?.analysis
const severity = String(analysis?.severity || '').trim()
@@ -1144,6 +1195,7 @@ export default {
request,
removeExpenseAttachment,
resolveAttachmentDisplayName,
+ resolveAttachmentRecognition,
resolveExpenseRiskState,
resolveExpenseIssues,
savingExpenseId,
|