diff --git a/web/src/views/scripts/TravelReimbursementCreateView.js b/web/src/views/scripts/TravelReimbursementCreateView.js index f5d52fb..6c4deaf 100644 --- a/web/src/views/scripts/TravelReimbursementCreateView.js +++ b/web/src/views/scripts/TravelReimbursementCreateView.js @@ -1,4 +1,4 @@ -import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue' +import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useSystemState } from '../../composables/useSystemState.js' import { recognizeOcrFiles } from '../../services/ocr.js' @@ -32,6 +32,103 @@ const INTENT_LABELS = { operate: '动作请求' } +const DOCUMENT_TYPE_LABELS = { + travel_ticket: '行程单/机票/车票', + hotel_invoice: '酒店住宿票据', + transport_receipt: '交通出行票据', + meal_receipt: '餐饮票据', + other: '其他单据' +} + +const EXPENSE_TYPE_LABELS = { + travel: '差旅费', + hotel: '住宿费', + transport: '交通费', + meal: '伙食费', + meeting: '会务费', + entertainment: '业务招待费', + other: '其他费用' +} + +const REVIEW_SLOT_CONFIG = { + expense_type: { + title: '报销类型', + hint: '请选择本次费用类型', + status: '待确认', + icon: 'mdi mdi-shape-outline' + }, + customer_name: { + title: '客户单位名称', + hint: '请补充客户单位全称', + status: '待补充', + icon: 'mdi mdi-domain' + }, + time_range: { + title: '业务发生时间', + hint: '请确认费用发生日期', + status: '待补充', + icon: 'mdi mdi-calendar-month-outline' + }, + location: { + title: '业务地点', + hint: '请补充业务发生地点', + status: '待补充', + icon: 'mdi mdi-map-marker-outline' + }, + merchant_name: { + title: '酒店/商户', + hint: '请补充酒店或商户名称', + status: '待补充', + icon: 'mdi mdi-storefront-outline' + }, + amount: { + title: '报销金额', + hint: '请补充本次费用金额', + status: '待补充', + icon: 'mdi mdi-cash' + }, + reason: { + title: '报销事由', + hint: '请补充本次费用背景或用途', + status: '待补充', + icon: 'mdi mdi-text-box-outline' + }, + participants: { + title: '同行人员信息', + hint: '请至少填写 1 名同行人员', + status: '待补充', + icon: 'mdi mdi-account-group-outline' + }, + attachments: { + title: '票据附件', + hint: '请上传发票/收据等票据附件', + status: '未上传', + icon: 'mdi mdi-paperclip' + } +} + +const REVIEW_FALLBACK_GROUP_CODES = ['other', 'travel', 'transport', 'hotel', 'meal', 'entertainment'] + +const REVIEW_CATEGORY_PRESET_OPTIONS = [ + { key: 'travel', label: '差旅费' }, + { key: 'transport', label: '交通费' }, + { key: 'hotel', label: '住宿费' }, + { key: 'meal', label: '餐费' }, + { key: 'entertainment', label: '业务招待费' }, + { key: 'other_trigger', label: '其他类型', is_other: true } +] + +const REVIEW_OTHER_CATEGORY_OPTIONS = [ + { key: 'meeting', label: '会务费' }, + { key: 'office', label: '办公费' }, + { key: 'training', label: '培训费' }, + { key: 'communication', label: '通讯费' }, + { key: 'welfare', label: '福利费' }, + { key: 'other', label: '其他费用' } +] + +const REVIEW_SCENE_OPTIONS = ['请客户吃饭', '出差行程', '住宿报销', '交通出行', '会务活动', '其他场景'] + let messageSeed = 0 function nowTime() { @@ -222,8 +319,8 @@ function buildWelcomeInsight(entrySource, linkedRequest) { title: entrySource === 'detail' && linkedRequest?.id ? `已关联 ${linkedRequest.id}` : '等待识别内容', summary: entrySource === 'detail' && linkedRequest?.id - ? '发送消息后会直接结合当前单据上下文识别报销语义,并在右侧展示可核对字段。' - : '请输入费用场景或上传票据,右侧会展示识别出的报销类型、时间、金额和待补字段。', + ? '发送消息后会直接结合当前单据上下文识别报销语义,右侧展示已识别内容,主对话区展示待补项和风险提示。' + : '请输入费用场景或上传票据,右侧会展示已识别内容,主对话区会提示待补信息和风险注意事项。', agent: null } } @@ -312,6 +409,607 @@ function buildReviewCorrectionMessage(fields) { return lines.join('\n') } +function buildReviewEditFieldMap(fields) { + return cloneReviewEditFields(fields).reduce((result, item) => { + if (!item.key) return result + result[item.key] = item + return result + }, {}) +} + +function createEmptyInlineReviewState() { + return { + occurred_date: '', + amount: '', + scene_label: '', + reason_value: '', + customer_name: '', + attachment_names: '', + attachment_count: 0, + expense_type: '' + } +} + +function buildClientTimeContext() { + const now = new Date() + const locale = + typeof navigator !== 'undefined' && typeof navigator.language === 'string' + ? navigator.language + : 'zh-CN' + + return { + client_now_iso: now.toISOString(), + client_timezone_offset_minutes: now.getTimezoneOffset(), + client_locale: locale + } +} + +function resolveReviewRecognizedSlotCards(reviewPayload) { + return Array.isArray(reviewPayload?.slot_cards) + ? reviewPayload.slot_cards.filter((item) => item.status !== 'missing') + : [] +} + +function resolveReviewMissingSlotCards(reviewPayload) { + return Array.isArray(reviewPayload?.slot_cards) + ? reviewPayload.slot_cards.filter((item) => item.status === 'missing') + : [] +} + +function resolveReviewRiskBriefs(reviewPayload) { + return Array.isArray(reviewPayload?.risk_briefs) ? reviewPayload.risk_briefs : [] +} + +function formatConfidenceLabel(value) { + const score = Number(value || 0) + if (!score) return '待补充' + return `${Math.round(score * 100)}%` +} + +function resolveDocumentTypeLabel(type) { + return DOCUMENT_TYPE_LABELS[String(type || '').trim()] || DOCUMENT_TYPE_LABELS.other +} + +function resolveExpenseTypeLabel(type, fallbackLabel = '') { + const normalized = String(type || '').trim() + return EXPENSE_TYPE_LABELS[normalized] || String(fallbackLabel || '').trim() || EXPENSE_TYPE_LABELS.other +} + +function buildReviewRecognizedLines(reviewPayload) { + return resolveReviewRecognizedSlotCards(reviewPayload) + .filter((item) => String(item?.value || '').trim()) + .map((item) => `${item.label}:${item.value}`) +} + +function buildReviewSlotMap(reviewPayload) { + return Object.fromEntries( + (Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards : []).map((item) => [item.key, item]) + ) +} + +function resolveExpenseTypeCode(value) { + const normalized = String(value || '').trim() + if (!normalized) return 'other' + if (EXPENSE_TYPE_LABELS[normalized]) return normalized + const matched = Object.entries(EXPENSE_TYPE_LABELS).find(([, label]) => label === normalized) + return matched?.[0] || 'other' +} + +function formatAmountDisplay(value) { + const normalized = String(value || '').trim() + const match = normalized.match(/^(\d+(?:\.\d+)?)元$/) + if (!match) return normalized + + const amount = Number(match[1]) + if (!Number.isFinite(amount)) return normalized + return Number.isInteger(amount) ? `${amount}元` : `${amount.toFixed(2).replace(/\.?0+$/, '')}元` +} + +function buildReviewHeadline(reviewPayload, draftPayload) { + const claimNo = String(draftPayload?.claim_no || '').trim() + if (claimNo) { + return `已为你创建报销草稿 ${claimNo}` + } + if (reviewPayload?.can_proceed) { + return '已为你整理好本次报销信息' + } + return '已为你整理报销草稿信息' +} + +function buildReviewSubline(reviewPayload, draftPayload) { + const claimNo = String(draftPayload?.claim_no || '').trim() + if (claimNo) { + return `草稿已保存为 draft,你可以继续补充费用明细、客户单位和票据附件。` + } + if (reviewPayload?.can_proceed) { + return '当前关键信息基本齐全,核对无误后可以继续下一步处理。' + } + return '当前已识别的信息已经整理完成,你可以继续补充缺失项,或者先保存草稿。' +} + +function buildReviewStateLabel(reviewPayload, draftPayload) { + if (draftPayload?.claim_no) return '草稿已创建' + if (reviewPayload?.can_proceed) return '可继续处理' + return '待补充' +} + +function buildReviewStateTone(reviewPayload, draftPayload) { + return reviewPayload?.can_proceed || draftPayload?.claim_no ? 'ready' : 'pending' +} + +function buildReviewAlertLabel(slotKey, expenseTypeLabel = '') { + if (slotKey === 'customer_name') { + return expenseTypeLabel === '业务招待费' ? '业务招待费需补充客户单位名称' : '缺少客户单位名称' + } + if (slotKey === 'participants') return '缺少同行人员' + if (slotKey === 'attachments') return '票据附件未上传' + if (slotKey === 'amount') return '报销金额待确认' + if (slotKey === 'time_range') return '业务发生时间待确认' + if (slotKey === 'reason') return '事由说明待补充' + if (slotKey === 'expense_type') return '报销类型待确认' + if (slotKey === 'location') return '业务地点待补充' + if (slotKey === 'merchant_name') return '酒店/商户待补充' + return '仍有信息待补充' +} + +function buildReviewAlertChips(reviewPayload) { + const slotMap = buildReviewSlotMap(reviewPayload) + const expenseTypeLabel = String(slotMap.expense_type?.value || '').trim() + const chips = [] + + for (const item of resolveReviewMissingSlotCards(reviewPayload).slice(0, 3)) { + chips.push({ + key: item.key, + label: buildReviewAlertLabel(item.key, expenseTypeLabel), + tone: item.key === 'attachments' ? 'danger' : 'warning' + }) + } + + if (chips.length < 3) { + for (const risk of resolveReviewRiskBriefs(reviewPayload)) { + if (chips.some((item) => item.label === risk.title)) continue + chips.push({ + key: risk.title, + label: risk.title, + tone: risk.level === 'high' ? 'danger' : 'warning' + }) + if (chips.length >= 3) break + } + } + + if (!chips.length && reviewPayload?.can_proceed) { + chips.push({ + key: 'ready', + label: '当前识别信息已可继续处理', + tone: 'success' + }) + } + + return chips +} + +function buildReviewTodoItems(reviewPayload) { + const missingItems = resolveReviewMissingSlotCards(reviewPayload) + if (missingItems.length) { + return missingItems.map((item) => { + const config = REVIEW_SLOT_CONFIG[item.key] || {} + return { + key: item.key, + icon: config.icon || 'mdi mdi-form-select', + title: config.title || item.label, + hint: item.hint || config.hint || `请补充${item.label}`, + status: config.status || '待补充', + tone: item.key === 'attachments' ? 'danger' : 'warning' + } + }) + } + + return resolveReviewRecognizedSlotCards(reviewPayload) + .filter((item) => String(item?.value || '').trim()) + .slice(0, 3) + .map((item) => { + const config = REVIEW_SLOT_CONFIG[item.key] || {} + return { + key: item.key, + icon: config.icon || 'mdi mdi-check-circle-outline', + title: config.title || item.label, + hint: `已识别:${item.value}`, + status: '已识别', + tone: 'ready' + } + }) +} + +function resolveReviewPrimaryAction(reviewPayload) { + return ( + (Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).find( + (item) => item.emphasis === 'primary' || ['save_draft', 'next_step'].includes(String(item?.action_type || '')) + ) || null + ) +} + +function resolveReviewEditAction(reviewPayload) { + return ( + (Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).find( + (item) => String(item?.action_type || '') === 'edit_review' + ) || null + ) +} + +function buildReviewPrimaryButtonLabel(reviewPayload, draftPayload) { + const action = resolveReviewPrimaryAction(reviewPayload) + if (!action) return '确认' + if (action.action_type === 'save_draft') { + return draftPayload?.claim_no ? '确认并继续完善草稿' : '确认并继续生成草稿' + } + if (action.action_type === 'next_step') { + return '确认并进入下一步' + } + return action.label || '确认' +} + +function buildReviewIntentText(reviewPayload) { + const slotMap = buildReviewSlotMap(reviewPayload) + const expenseType = String(slotMap.expense_type?.value || '').trim() + if (expenseType) { + return `报销一笔${expenseType}` + } + return '发起一笔报销' +} + +function buildReviewSceneValue(reviewPayload) { + const slotMap = buildReviewSlotMap(reviewPayload) + const reason = String(slotMap.reason?.value || slotMap.reason?.raw_value || '').trim() + const expenseType = String(slotMap.expense_type?.value || '').trim() + return summarizeReviewScene(reason, expenseType) +} + +function summarizeReviewScene(reason, expenseType = '') { + const normalizedReason = String(reason || '').trim() + const normalizedExpenseType = String(expenseType || '').trim() + const compactReason = normalizedReason.replace(/\s+/g, '') + + if (compactReason) { + if (/请客户.*吃饭|客户.*吃饭|招待|宴请/.test(compactReason)) return '请客户吃饭' + if (/出差|差旅/.test(compactReason)) return '出差行程' + if (/酒店|住宿/.test(compactReason)) return '住宿报销' + if (/交通|打车|车费|停车/.test(compactReason)) return '交通出行' + if (/会务|会议|参会/.test(compactReason)) return '会务活动' + return compactReason.length > 12 ? `${compactReason.slice(0, 12)}...` : compactReason + } + + if (normalizedExpenseType === '业务招待费') return '请客户吃饭' + if (normalizedExpenseType === '差旅费') return '出差行程' + if (normalizedExpenseType === '住宿费') return '住宿报销' + if (normalizedExpenseType === '交通费') return '交通出行' + if (normalizedExpenseType === '会务费') return '会务活动' + if (normalizedExpenseType) return normalizedExpenseType + return '待补充' +} + +function buildInlineReviewState(reviewPayload) { + const slotMap = buildReviewSlotMap(reviewPayload) + const editFieldMap = buildReviewEditFieldMap(reviewPayload?.edit_fields) + const attachmentNames = String( + editFieldMap.attachment_names?.value || + slotMap.attachments?.value || + (Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards.map((item) => item.filename).join('、') : '') + ).trim() + const attachmentCount = Array.isArray(reviewPayload?.document_cards) + ? reviewPayload.document_cards.length + : attachmentNames + ? attachmentNames.split('、').filter(Boolean).length + : 0 + const expenseType = String(editFieldMap.expense_type?.value || slotMap.expense_type?.value || '').trim() + const reasonValue = String( + editFieldMap.reason?.value || slotMap.reason?.value || slotMap.reason?.raw_value || '' + ).trim() + + return { + occurred_date: String( + editFieldMap.occurred_date?.value || slotMap.time_range?.normalized_value || slotMap.time_range?.value || '' + ).trim(), + amount: String( + editFieldMap.amount?.value || slotMap.amount?.normalized_value || slotMap.amount?.value || '' + ).trim(), + scene_label: summarizeReviewScene(reasonValue, expenseType), + reason_value: reasonValue, + customer_name: String(editFieldMap.customer_name?.value || slotMap.customer_name?.value || '').trim(), + attachment_names: attachmentNames, + attachment_count: attachmentCount, + expense_type: expenseType + } +} + +function buildReviewAttachmentStatus(reviewPayload) { + const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] + if (!documents.length) return '未上传' + return documents.length === 1 ? '已上传 1 份' : `已上传 ${documents.length} 份` +} + +function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineReviewState()) { + const attachmentStatus = + inlineState.attachment_count > 0 + ? `待保存 ${inlineState.attachment_count} 份` + : buildReviewAttachmentStatus(reviewPayload) + + return [ + { + key: 'occurred_date', + label: '发生时间', + value: String(inlineState.occurred_date || '').trim() || '待补充', + icon: 'mdi mdi-calendar-month-outline', + editor: 'date' + }, + { + key: 'amount', + label: '金额', + value: formatAmountDisplay(inlineState.amount) || '待补充', + icon: 'mdi mdi-cash', + editor: 'text' + }, + { + key: 'scene', + label: '场景', + value: String(inlineState.scene_label || '').trim() || '待补充', + icon: 'mdi mdi-silverware-fork-knife', + editor: 'select' + }, + { + key: 'customer_name', + label: '关联客户', + value: String(inlineState.customer_name || '').trim() || '待补充', + icon: 'mdi mdi-domain', + editor: 'text' + }, + { + key: 'attachments', + label: '票据状态', + value: attachmentStatus, + icon: 'mdi mdi-file-document-outline', + editor: 'upload' + } + ] +} + +function buildReviewCategoryOptions(selectedLabel = '') { + const presetLabels = REVIEW_CATEGORY_PRESET_OPTIONS.filter((item) => !item.is_other).map((item) => item.label) + return REVIEW_CATEGORY_PRESET_OPTIONS.map((item, index) => ({ + ...item, + active: item.is_other ? Boolean(selectedLabel) && !presetLabels.includes(selectedLabel) : item.label === selectedLabel, + caption: index === 0 ? '常用' : index < 5 ? '常用' : '更多' + })) +} + +function buildReviewPanelConfidence(reviewPayload) { + const recognized = resolveReviewRecognizedSlotCards(reviewPayload).filter((item) => + ['expense_type', 'time_range', 'amount', 'customer_name', 'attachments'].includes(item.key) + ) + if (!recognized.length) return '0%' + const average = recognized.reduce((sum, item) => sum + Number(item.confidence || 0), 0) / recognized.length + return formatConfidenceLabel(average) +} + +function buildReviewRiskScore(reviewPayload) { + const missingCount = resolveReviewMissingSlotCards(reviewPayload).length + const riskPenalty = resolveReviewRiskBriefs(reviewPayload).reduce((sum, item) => { + if (item.level === 'high') return sum + 10 + if (item.level === 'warning') return sum + 6 + return sum + 3 + }, 0) + const score = 92 - missingCount * 9 - riskPenalty + return Math.max(28, Math.min(98, score)) +} + +function buildMissingRiskLine(slotKey, expenseTypeLabel = '') { + if (slotKey === 'customer_name') { + return expenseTypeLabel === '业务招待费' + ? '业务招待费需补充客户单位名称,以便进行合规校验。' + : '当前仍缺少客户单位名称,建议补充后再提交。' + } + if (slotKey === 'participants') { + return '缺少同行人员信息,建议补充至少 1 名。' + } + if (slotKey === 'attachments') { + return '尚未上传票据附件,当前无法完成票据核对。' + } + if (slotKey === 'amount') { + return '报销金额仍待确认,提交前需补齐金额信息。' + } + if (slotKey === 'time_range') { + return '业务发生时间仍待确认,建议补充准确日期。' + } + if (slotKey === 'reason') { + return '报销事由说明仍不完整,建议补充业务背景。' + } + return '当前仍有识别信息待补充,建议先核对后再处理。' +} + +function buildReviewRiskSummary(reviewPayload) { + if (resolveReviewMissingSlotCards(reviewPayload).length) { + return '存在一定合规风险,请尽快补充完整信息以降低风险。' + } + if (resolveReviewRiskBriefs(reviewPayload).length) { + return '当前识别结果可继续处理,但提交前仍建议核对以下提醒。' + } + return '当前未发现明显阻断项,确认无误后可以继续下一步。' +} + +function buildReviewRiskItems(reviewPayload) { + const slotMap = buildReviewSlotMap(reviewPayload) + const expenseTypeLabel = String(slotMap.expense_type?.value || '').trim() + const items = [] + + for (const slot of resolveReviewMissingSlotCards(reviewPayload)) { + items.push(buildMissingRiskLine(slot.key, expenseTypeLabel)) + } + + for (const brief of resolveReviewRiskBriefs(reviewPayload)) { + if (items.includes(brief.content)) continue + items.push(brief.content) + } + + return items.slice(0, 4) +} + +function normalizeInlineReviewComparableState(state) { + const source = state && typeof state === 'object' ? state : {} + return { + occurred_date: String(source.occurred_date || '').trim(), + amount: String(source.amount || '').trim(), + scene_label: String(source.scene_label || '').trim(), + reason_value: String(source.reason_value || '').trim(), + customer_name: String(source.customer_name || '').trim(), + attachment_names: String(source.attachment_names || '').trim(), + expense_type: String(source.expense_type || '').trim() + } +} + +function buildInlineReviewChangedLines(baseState, nextState, pendingFiles = []) { + const base = normalizeInlineReviewComparableState(baseState) + const next = normalizeInlineReviewComparableState(nextState) + const lines = [] + + if (base.occurred_date !== next.occurred_date) { + lines.push(`发生时间 ${next.occurred_date || '待补充'}`) + } + if (base.amount !== next.amount) { + lines.push(`金额 ${formatAmountDisplay(next.amount) || '待补充'}`) + } + if (base.scene_label !== next.scene_label) { + lines.push(`场景 ${next.scene_label || '待补充'}`) + } + if (base.customer_name !== next.customer_name) { + lines.push(`关联客户 ${next.customer_name || '待补充'}`) + } + if (base.expense_type !== next.expense_type) { + lines.push(`报销分类 ${next.expense_type || '待补充'}`) + } + if (base.attachment_names !== next.attachment_names || pendingFiles.length) { + lines.push(`票据 ${next.attachment_names || (pendingFiles.length ? `已选择 ${pendingFiles.length} 份附件` : '待上传')}`) + } + + return lines +} + +function buildInlineReviewUserText(baseState, nextState, pendingFiles = []) { + const lines = buildInlineReviewChangedLines(baseState, nextState, pendingFiles) + if (!lines.length) { + return '我已修改识别信息,请按最新内容更新。' + } + return `我已修改识别信息:${lines.join(',')}。请按最新内容更新。` +} + +function mergeInlineReviewFields(baseFields, inlineState) { + const merged = cloneReviewEditFields(baseFields) + const updateMap = { + expense_type: inlineState.expense_type, + occurred_date: inlineState.occurred_date, + amount: inlineState.amount, + customer_name: inlineState.customer_name, + reason: inlineState.reason_value || inlineState.scene_label, + attachment_names: inlineState.attachment_names + } + + for (const item of merged) { + if (!(item.key in updateMap)) continue + item.value = String(updateMap[item.key] || '').trim() + } + + return merged +} + +function buildReviewRecognitionNotes(reviewPayload) { + const recognized = resolveReviewRecognizedSlotCards(reviewPayload) + const notes = [] + const timeSlot = recognized.find((item) => item.key === 'time_range') + const sourceLabels = [...new Set(recognized.map((item) => String(item?.source_label || '').trim()).filter(Boolean))] + + if (timeSlot?.raw_value && timeSlot.raw_value !== timeSlot.value && timeSlot.value) { + notes.push(`时间已按你的本地日期换算:${timeSlot.raw_value} -> ${timeSlot.value}`) + } + + if (sourceLabels.length) { + notes.push(`本轮主要依据:${sourceLabels.join('、')}`) + } + + const documentCards = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] + if (documentCards.length) { + notes.push(`已关联 ${documentCards.length} 份附件,逐张识别结果已整理在下方`) + } else { + notes.push('当前还没有上传票据,这一轮主要依据你的文字描述完成初步识别') + } + + return notes +} + +function buildReviewDocumentSummaries(reviewPayload) { + const docs = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : [] + return docs.map((item) => { + const fields = Array.isArray(item.fields) ? item.fields : [] + return { + ...item, + documentTypeLabel: resolveDocumentTypeLabel(item.document_type), + expenseTypeLabel: resolveExpenseTypeLabel(item.suggested_expense_type, item.scene_label), + confidenceLabel: formatConfidenceLabel(item.avg_score), + lines: fields + .filter((field) => String(field?.value || '').trim()) + .map((field) => `${field.label}:${field.value}`) + } + }) +} + +function buildReviewDecisionHint(reviewPayload) { + const missingSlots = resolveReviewMissingSlotCards(reviewPayload) + const riskBriefs = resolveReviewRiskBriefs(reviewPayload) + if (reviewPayload?.can_proceed) { + return riskBriefs.length + ? `我已经把信息整理好了。你可以直接进入下一步,提交前再看一下下方 ${riskBriefs.length} 条提醒。` + : '我已经把信息整理好了。你确认无误后,可以直接进入下一步。' + } + if (missingSlots.length) { + return `我先完成了当前这轮识别,还差 ${missingSlots.length} 项关键信息。你可以继续补充;如果暂时拿不全,也可以先保存草稿。` + } + return '如果你觉得识别结果有偏差,点“修改识别信息”直接校正,我会按新内容重新识别。' +} + +function buildReviewMissingHint(reviewPayload) { + const missingSlots = resolveReviewMissingSlotCards(reviewPayload) + if (!missingSlots.length) { + return '' + } + if (reviewPayload?.can_proceed) { + return '当前关键信息已经齐全,这里无需再补充。' + } + return '下面这些字段还需要你再确认或补齐,补完后我就能继续往下处理。' +} + +function buildReviewRiskHint(reviewPayload) { + const riskBriefs = resolveReviewRiskBriefs(reviewPayload) + if (!riskBriefs.length) { + return '' + } + return '这些是我根据当前场景和历史记录给你的提醒,提交前建议顺手核对一下。' +} + +function buildReviewActionHint(reviewPayload) { + if (reviewPayload?.can_proceed) { + return '如果识别无误,直接点“下一步”;如果有偏差,先修改识别信息。' + } + return '如果现在信息还不完整,可以先保存草稿;识别错了就点“修改识别信息”。' +} + +function buildReviewStatusTag(reviewPayload) { + const missingCount = resolveReviewMissingSlotCards(reviewPayload).length + if (reviewPayload?.can_proceed) { + return '可继续处理' + } + if (missingCount > 0) { + return `待补充 ${missingCount} 项` + } + return '待确认' +} + function buildErrorInsight(error, fileNames = []) { return { intent: 'agent', @@ -413,6 +1111,7 @@ export default { const { currentUser } = useSystemState() const fileInputRef = ref(null) + const fileInputMode = ref('composer') const messageListRef = ref(null) const composerDraft = ref('') const attachedFiles = ref([]) @@ -442,6 +1141,12 @@ export default { const reviewActionBusy = ref(false) const reviewEditFields = ref([]) const reviewActionMessageId = ref('') + const reviewInlineForm = ref(createEmptyInlineReviewState()) + const reviewInlineBaseForm = ref(createEmptyInlineReviewState()) + const reviewInlineBaseFields = ref([]) + const reviewInlinePendingFiles = ref([]) + const reviewInlineEditorKey = ref('') + const reviewOtherCategoryOpen = ref(false) const sourceLabel = computed(() => SOURCE_LABELS[props.entrySource] ?? '来自 AI 工作台') const canSubmit = computed( () => !submitting.value && Boolean(composerDraft.value.trim() || attachedFiles.value.length) @@ -469,66 +1174,53 @@ export default { const activeReviewFilePreviews = computed( () => currentInsight.value.agent?.filePreviews || [] ) - const recognizedSlotCards = computed(() => - Array.isArray(activeReviewPayload.value?.slot_cards) - ? activeReviewPayload.value.slot_cards.filter((item) => item.status !== 'missing') - : [] - ) - const missingSlotCards = computed(() => - Array.isArray(activeReviewPayload.value?.slot_cards) - ? activeReviewPayload.value.slot_cards.filter((item) => item.status === 'missing') - : [] - ) - - const shortcuts = computed(() => { - if (props.entrySource === 'detail' && linkedRequest.value?.id) { - return [ - { - label: '解释风险原因', - icon: 'mdi mdi-shield-alert-outline', - prompt: `解释一下 ${linkedRequest.value.id} 为什么会被拦截` - }, - { - label: '生成处理意见', - icon: 'mdi mdi-file-document-edit-outline', - prompt: `帮我给 ${linkedRequest.value.id} 生成处理意见草稿` - }, - { - label: '列出补件清单', - icon: 'mdi mdi-format-list-checks', - prompt: `帮我列出 ${linkedRequest.value.id} 还需要补哪些附件` - }, - { - label: '引用相关制度', - icon: 'mdi mdi-book-open-variant-outline', - prompt: `解释一下 ${linkedRequest.value.id} 相关的报销制度依据` - } - ] - } - - return [ - { - label: '查本周报销金额', - icon: 'mdi mdi-cash-multiple', - prompt: '查一下本周报销金额' - }, - { - label: '解释报销风险', - icon: 'mdi mdi-shield-alert-outline', - prompt: '为什么酒店超标报销不能直接通过' - }, - { - label: '生成报销草稿', - icon: 'mdi mdi-file-document-edit-outline', - prompt: '帮我生成一份差旅报销草稿' - }, - { - label: '查待付款金额', - icon: 'mdi mdi-bank-transfer-out', - prompt: '供应商B待付款多少' - } - ] + const reviewIntentText = computed(() => buildReviewIntentText(activeReviewPayload.value)) + const reviewFactCards = computed(() => buildReviewFactCards(activeReviewPayload.value, reviewInlineForm.value)) + const reviewCategoryOptions = computed(() => buildReviewCategoryOptions(reviewInlineForm.value.expense_type)) + const reviewSelectedOtherCategory = computed(() => { + const presetLabels = REVIEW_CATEGORY_PRESET_OPTIONS.filter((item) => !item.is_other).map((item) => item.label) + return presetLabels.includes(reviewInlineForm.value.expense_type) ? '' : reviewInlineForm.value.expense_type }) + const reviewInlineDirty = computed( + () => + buildInlineReviewChangedLines( + reviewInlineBaseForm.value, + reviewInlineForm.value, + reviewInlinePendingFiles.value + ).length > 0 + ) + const reviewPanelConfidence = computed(() => buildReviewPanelConfidence(activeReviewPayload.value)) + const reviewRiskScore = computed(() => buildReviewRiskScore(activeReviewPayload.value)) + const reviewRiskSummary = computed(() => buildReviewRiskSummary(activeReviewPayload.value)) + const reviewRiskItems = computed(() => buildReviewRiskItems(activeReviewPayload.value)) + const recognizedNarratives = computed(() => buildReviewRecognizedLines(activeReviewPayload.value)) + const reviewRecognitionNotes = computed(() => buildReviewRecognitionNotes(activeReviewPayload.value)) + const reviewDocumentSummaries = computed(() => buildReviewDocumentSummaries(activeReviewPayload.value)) + + const shortcuts = computed(() => [ + { + label: '快速生成报销草稿', + icon: 'mdi mdi-file-document-edit-outline', + prompt: + props.entrySource === 'detail' && linkedRequest.value?.id + ? `请基于当前关联单据 ${linkedRequest.value.id} 快速生成报销草稿` + : '帮我快速生成一份报销草稿' + } + ]) + + watch( + () => activeReviewPayload.value, + (payload) => { + const nextInlineState = buildInlineReviewState(payload) + reviewInlineForm.value = { ...nextInlineState } + reviewInlineBaseForm.value = { ...nextInlineState } + reviewInlineBaseFields.value = cloneReviewEditFields(payload?.edit_fields) + reviewInlinePendingFiles.value = [] + reviewInlineEditorKey.value = '' + reviewOtherCategoryOpen.value = false + }, + { immediate: true } + ) onMounted(() => { currentInsight.value = initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value) @@ -561,13 +1253,31 @@ export default { messages.value.splice(index, 1, nextMessage) } - function triggerFileUpload() { - if (submitting.value) return + function triggerFileUpload(mode = 'composer') { + if (submitting.value || reviewActionBusy.value) return + fileInputMode.value = mode fileInputRef.value?.click() } function handleFilesChange(event) { - attachedFiles.value = Array.from(event.target.files ?? []) + const files = Array.from(event.target.files ?? []) + + if (fileInputMode.value === 'inline-review' && activeReviewPayload.value) { + reviewInlinePendingFiles.value = files + reviewInlineForm.value = { + ...reviewInlineForm.value, + attachment_names: files.map((file) => file.name).join('、'), + attachment_count: files.length + } + reviewInlineEditorKey.value = '' + } else { + attachedFiles.value = files + } + + fileInputMode.value = 'composer' + if (fileInputRef.value) { + fileInputRef.value.value = '' + } } function runShortcut(prompt) { @@ -575,6 +1285,110 @@ export default { submitComposer() } + function openInlineReviewEditor(key) { + if (!activeReviewPayload.value || submitting.value || reviewActionBusy.value) return + if (key === 'attachments') { + triggerFileUpload('inline-review') + return + } + reviewInlineEditorKey.value = reviewInlineEditorKey.value === key ? '' : key + if (key !== 'expense_type') { + reviewOtherCategoryOpen.value = false + } + } + + function closeInlineReviewEditor() { + reviewInlineEditorKey.value = '' + reviewOtherCategoryOpen.value = false + } + + function commitInlineReviewEditor() { + reviewInlineForm.value = { + ...reviewInlineForm.value, + occurred_date: String(reviewInlineForm.value.occurred_date || '').trim(), + amount: String(reviewInlineForm.value.amount || '').trim(), + customer_name: String(reviewInlineForm.value.customer_name || '').trim(), + scene_label: String(reviewInlineForm.value.scene_label || '').trim(), + reason_value: String(reviewInlineForm.value.reason_value || reviewInlineForm.value.scene_label || '').trim(), + expense_type: String(reviewInlineForm.value.expense_type || '').trim() + } + reviewInlineEditorKey.value = '' + } + + function selectInlineScene(scene) { + reviewInlineForm.value = { + ...reviewInlineForm.value, + scene_label: String(scene || '').trim(), + reason_value: String(scene || '').trim() + } + reviewInlineEditorKey.value = '' + } + + function selectReviewCategory(option) { + if (!option) return + if (option.is_other) { + reviewOtherCategoryOpen.value = !reviewOtherCategoryOpen.value + return + } + + reviewInlineForm.value = { + ...reviewInlineForm.value, + expense_type: option.label + } + reviewOtherCategoryOpen.value = false + } + + function selectReviewOtherCategory(option) { + if (!option) return + reviewInlineForm.value = { + ...reviewInlineForm.value, + expense_type: option.label + } + reviewOtherCategoryOpen.value = false + } + + function queryDraftByClaimNo(claimNo) { + const normalized = String(claimNo || '').trim() + if (!normalized || submitting.value || reviewActionBusy.value) return + submitComposer({ + rawText: `查看报销草稿 ${normalized} 的当前信息`, + userText: `查看草稿 ${normalized}` + }) + } + + function explainCurrentReviewRisk() { + if (!activeReviewPayload.value || submitting.value || reviewActionBusy.value) return + submitComposer({ + rawText: '请解释一下当前这笔报销的合规风险和待补充项。', + userText: '查看全部风险项' + }) + } + + async function saveInlineReviewChanges() { + if (!activeReviewPayload.value || !reviewInlineDirty.value || reviewActionBusy.value) return + + reviewActionBusy.value = true + try { + const fields = mergeInlineReviewFields(reviewInlineBaseFields.value, reviewInlineForm.value) + await submitComposer({ + rawText: buildReviewCorrectionMessage(fields), + userText: buildInlineReviewUserText( + reviewInlineBaseForm.value, + reviewInlineForm.value, + reviewInlinePendingFiles.value + ), + pendingText: '正在保存修改并刷新右侧核对信息...', + files: reviewInlinePendingFiles.value, + extraContext: { + review_action: 'edit_review', + review_form_values: buildReviewFormValues(fields) + } + }) + } finally { + reviewActionBusy.value = false + } + } + function buildBackendMessage(rawText, fileNames, ocrSummary = '') { const parts = [] const normalizedText = String(rawText || '').trim() @@ -658,6 +1472,7 @@ export default { is_admin: Boolean(user.isAdmin), name: user.name || '', role: user.role || '', + ...buildClientTimeContext(), entry_source: props.entrySource, attachment_names: fileNames, attachment_count: fileNames.length, @@ -815,17 +1630,58 @@ export default { latestReviewMessage, activeReviewPayload, activeReviewFilePreviews, - recognizedSlotCards, - missingSlotCards, + reviewIntentText, + reviewFactCards, + reviewCategoryOptions, + reviewSelectedOtherCategory, + reviewInlineDirty, + reviewInlineForm, + reviewInlineEditorKey, + reviewOtherCategoryOpen, + reviewInlinePendingFiles, + REVIEW_SCENE_OPTIONS, + REVIEW_OTHER_CATEGORY_OPTIONS, + reviewPanelConfidence, + reviewRiskScore, + reviewRiskSummary, + reviewRiskItems, + recognizedNarratives, + reviewRecognitionNotes, + reviewDocumentSummaries, reviewCancelDialogOpen, reviewEditDialogOpen, reviewActionBusy, reviewEditFields, shortcuts, + resolveReviewMissingSlotCards, + resolveReviewRiskBriefs, + buildReviewHeadline, + buildReviewSubline, + buildReviewStateLabel, + buildReviewStateTone, + buildReviewAlertChips, + buildReviewTodoItems, + resolveReviewPrimaryAction, + resolveReviewEditAction, + buildReviewPrimaryButtonLabel, + buildReviewDecisionHint, + buildReviewMissingHint, + buildReviewRiskHint, + buildReviewActionHint, + buildReviewStatusTag, resolveDocumentPreview, triggerFileUpload, handleFilesChange, runShortcut, + openInlineReviewEditor, + closeInlineReviewEditor, + commitInlineReviewEditor, + selectInlineScene, + selectReviewCategory, + selectReviewOtherCategory, + queryDraftByClaimNo, + explainCurrentReviewRisk, + saveInlineReviewChanges, submitComposer, handleReviewAction, closeCancelReviewDialog,