diff --git a/web/src/views/scripts/TravelReimbursementCreateView.js b/web/src/views/scripts/TravelReimbursementCreateView.js index 9de7353..b84df8f 100644 --- a/web/src/views/scripts/TravelReimbursementCreateView.js +++ b/web/src/views/scripts/TravelReimbursementCreateView.js @@ -2,8 +2,9 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import ConfirmDialog from '../../components/shared/ConfirmDialog.vue' import { useSystemState } from '../../composables/useSystemState.js' +import { useToast } from '../../composables/useToast.js' import { recognizeOcrFiles } from '../../services/ocr.js' -import { runOrchestrator } from '../../services/orchestrator.js' +import { clearUserConversations, deleteConversation, fetchLatestConversation, runOrchestrator } from '../../services/orchestrator.js' const aiAvatar = '/assets/header.png' const userAvatar = '/assets/person.png' @@ -146,6 +147,24 @@ const REVIEW_OTHER_CATEGORY_OPTIONS = [ const REVIEW_SCENE_OPTIONS = ['请客户吃饭', '出差行程', '住宿报销', '交通出行', '会务活动', '其他场景'] const DATE_INPUT_FORMAT = 'YYYY-MM-DD' +const MAX_ATTACHMENTS = 10 +const MAX_OCR_DOCUMENTS = 10 +const VISIBLE_ATTACHMENT_CHIPS = 2 +const COMPOSER_MAX_ROWS = 5 +const SESSION_TYPE_EXPENSE = 'expense' +const SESSION_TYPE_KNOWLEDGE = 'knowledge' +const HOT_KNOWLEDGE_QUESTIONS = [ + '差旅住宿标准按什么规则执行?', + '酒店超标后如何申请例外报销?', + '招待费报销需要哪些凭证?', + '发票抬头不一致还能报销吗?', + '电子发票验真失败怎么处理?', + '借款多久内需要冲销?', + '预算不足还能先提交报销吗?', + '会议费和招待费如何区分?', + '跨部门项目费用应该怎么归集?', + '员工退票手续费是否可以报销?' +] const CATEGORY_CONFIDENCE_KEYWORDS = { travel: [/出差|差旅|行程|机票|火车|高铁|航班/], hotel: [/住宿|酒店|宾馆|民宿/], @@ -288,7 +307,7 @@ function buildStoredMessageMeta(messageJson, attachmentNames = []) { function normalizeOcrDocuments(payload) { const documents = Array.isArray(payload?.documents) ? payload.documents : [] - return documents.slice(0, 5).map((item) => ({ + return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => ({ filename: item.filename, summary: item.summary, text: String(item.text || '').slice(0, 240), @@ -321,7 +340,7 @@ function inferPreviewKind(file) { function buildFilePreviews(files, previewRegistry) { return files.map((file) => { const kind = inferPreviewKind(file) - if (kind !== 'image') { + if (!['image', 'pdf'].includes(kind)) { return { filename: file.name, kind @@ -343,7 +362,206 @@ function resolveDocumentPreview(filePreviews, filename) { return filePreviews.find((item) => item.filename === filename) ?? null } -function buildWelcomeInsight(entrySource, linkedRequest) { +function buildFileIdentity(file) { + return [file?.name, file?.size, file?.lastModified, file?.type].join('__') +} + +function mergeFilesWithLimit(existingFiles, incomingFiles, limit = MAX_ATTACHMENTS) { + const nextFiles = [] + const seen = new Set() + + for (const file of Array.isArray(existingFiles) ? existingFiles : []) { + const key = buildFileIdentity(file) + if (seen.has(key)) continue + seen.add(key) + nextFiles.push(file) + } + + let duplicateCount = 0 + let overflowCount = 0 + + for (const file of Array.isArray(incomingFiles) ? incomingFiles : []) { + const key = buildFileIdentity(file) + if (seen.has(key)) { + duplicateCount += 1 + continue + } + if (nextFiles.length >= limit) { + overflowCount += 1 + continue + } + seen.add(key) + nextFiles.push(file) + } + + return { + files: nextFiles, + duplicateCount, + overflowCount + } +} + +function mergeFilePreviews(existingPreviews, incomingPreviews) { + const result = [] + const seen = new Set() + + for (const preview of [...(existingPreviews || []), ...(incomingPreviews || [])]) { + const key = [preview?.filename, preview?.kind].join('__') + if (!preview?.filename || seen.has(key)) continue + seen.add(key) + result.push(preview) + } + + return result +} + +function extractReviewAttachmentNames(reviewPayload) { + const documentNames = Array.isArray(reviewPayload?.document_cards) + ? reviewPayload.document_cards.map((item) => String(item?.filename || '').trim()).filter(Boolean) + : [] + if (documentNames.length) { + return documentNames + } + + const slotMap = buildReviewSlotMap(reviewPayload) + const attachmentValue = String(slotMap.attachments?.value || '').trim() + if (!attachmentValue) { + return [] + } + + return attachmentValue.split(/[、,,]/).map((item) => item.trim()).filter(Boolean) +} + +function cloneReviewDocumentDrafts(items) { + return (Array.isArray(items) ? items : []).map((item) => ({ + ...item, + warnings: Array.isArray(item?.warnings) ? [...item.warnings] : [], + fields: Array.isArray(item?.fields) + ? item.fields.map((field) => ({ + label: String(field?.label || '').trim(), + value: String(field?.value || ''), + source: String(field?.source || 'ocr').trim() || 'ocr' + })) + : [] + })) +} + +function buildReviewDocumentDrafts(reviewPayload) { + return buildReviewDocumentSummaries(reviewPayload).map((item) => ({ + index: Number(item.index || 0), + filename: String(item.filename || '').trim(), + document_type: String(item.document_type || 'other').trim() || 'other', + suggested_expense_type: String(item.suggested_expense_type || 'other').trim() || 'other', + scene_label: String(item.scene_label || '').trim(), + summary: String(item.summary || '').trim(), + confidenceLabel: String(item.confidenceLabel || '').trim(), + documentTypeLabel: String(item.documentTypeLabel || '').trim(), + expenseTypeLabel: String(item.expenseTypeLabel || '').trim(), + warnings: Array.isArray(item.warnings) ? [...item.warnings] : [], + fields: Array.isArray(item.fields) + ? item.fields.map((field) => ({ + label: String(field?.label || '').trim(), + value: String(field?.value || ''), + source: String(field?.source || 'ocr').trim() || 'ocr' + })) + : [] + })) +} + +function normalizeReviewDocumentComparableValue(item) { + return { + index: Number(item?.index || 0), + filename: String(item?.filename || '').trim(), + scene_label: String(item?.scene_label || '').trim(), + summary: String(item?.summary || '').trim(), + fields: (Array.isArray(item?.fields) ? item.fields : []).map((field) => ({ + label: String(field?.label || '').trim(), + value: String(field?.value || '').trim() + })) + } +} + +function buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts) { + const baseMap = new Map( + cloneReviewDocumentDrafts(baseDrafts).map((item) => [`${item.index}:${item.filename}`, item]) + ) + + return cloneReviewDocumentDrafts(nextDrafts).reduce((lines, item) => { + const key = `${item.index}:${item.filename}` + const base = baseMap.get(key) + const changes = [] + const nextSceneLabel = String(item.scene_label || '').trim() + const baseSceneLabel = String(base?.scene_label || '').trim() + const nextSummary = String(item.summary || '').trim() + const baseSummary = String(base?.summary || '').trim() + + if (nextSceneLabel !== baseSceneLabel) { + changes.push(`票据场景:${nextSceneLabel || '待补充'}`) + } + + if (nextSummary !== baseSummary) { + changes.push(`识别摘要:${nextSummary || '待补充'}`) + } + + const baseFieldMap = new Map( + (Array.isArray(base?.fields) ? base.fields : []).map((field) => [ + String(field?.label || '').trim(), + String(field?.value || '').trim() + ]) + ) + + for (const field of Array.isArray(item.fields) ? item.fields : []) { + const label = String(field?.label || '').trim() + if (!label) continue + const nextValue = String(field?.value || '').trim() + const baseValue = baseFieldMap.get(label) || '' + if (nextValue !== baseValue) { + changes.push(`${label}:${nextValue || '待补充'}`) + } + } + + if (changes.length) { + lines.push(`第${item.index}张票据(${item.filename}):${changes.join(';')}`) + } + + return lines + }, []) +} + +function buildReviewDocumentCorrectionMessage(baseDrafts, nextDrafts) { + const lines = buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts) + if (!lines.length) { + return '' + } + + return `请同步修正逐票据识别结果:\n${lines.join('\n')}` +} + +function buildReviewDocumentCorrectionContext(drafts) { + return cloneReviewDocumentDrafts(drafts).map((item) => ({ + index: item.index, + filename: item.filename, + scene_label: String(item.scene_label || '').trim(), + summary: String(item.summary || '').trim(), + fields: item.fields.map((field) => ({ + label: String(field.label || '').trim(), + value: String(field.value || '').trim() + })) + })) +} + +function buildWelcomeInsight(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE) { + if (sessionType === SESSION_TYPE_KNOWLEDGE) { + return { + intent: 'welcome', + metricLabel: '当前模式', + metricValue: '知识问答', + title: '财务知识问答', + summary: '这里适合处理制度解释、报销规则、票据规范和常见财务问题,右侧提供 Top 10 热门问题可直接追问。', + agent: null + } + } + return { intent: 'welcome', metricLabel: '当前状态', @@ -357,6 +575,22 @@ function buildWelcomeInsight(entrySource, linkedRequest) { } } +function buildWelcomeMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE) { + if (sessionType === SESSION_TYPE_KNOWLEDGE) { + return '已切换到财务知识问答会话。你可以直接提问制度、报销规则、票据要求或常见财务问题。' + } + + return entrySource === 'detail' && linkedRequest?.id + ? `已进入财务AI工作台,当前关联单据 ${linkedRequest.id}。请描述费用场景或补充票据。` + : '这里是财务AI工作台。你发送的内容会直接进入真实 Orchestrator 和 User Agent。' +} + +function resolveInitialSessionType(conversation) { + const stateJson = conversation?.state_json || conversation?.stateJson || {} + const sessionType = String(stateJson?.session_type || '').trim() + return sessionType || SESSION_TYPE_EXPENSE +} + function buildInitialInsightFromConversation(conversation) { const rawMessages = Array.isArray(conversation?.messages) ? conversation.messages : [] for (let index = rawMessages.length - 1; index >= 0; index -= 1) { @@ -380,6 +614,17 @@ function resolveInitialDraftClaimId(conversation) { return String(conversation?.draft_claim_id || conversation?.draftClaimId || '').trim() } +function resolveKnowledgeRankLabel(index) { + return String(index + 1) +} + +function resolveKnowledgeRankTone(index) { + if (index === 0) return 'gold' + if (index === 1) return 'silver' + if (index === 2) return 'bronze' + return 'default' +} + function normalizeInitialConversationMessages(conversation) { const rawMessages = Array.isArray(conversation?.messages) ? conversation.messages : [] @@ -461,6 +706,7 @@ function createEmptyInlineReviewState() { participants: '', attachment_names: '', attachment_count: 0, + pending_attachment_count: 0, expense_type: '' } } @@ -663,36 +909,90 @@ function formatAmountDisplay(value) { }).format(amount) } -function buildReviewHeadline(reviewPayload, draftPayload) { - const claimNo = String(draftPayload?.claim_no || '').trim() - if (claimNo) { - return `已为你创建报销草稿 ${claimNo}` - } - if (reviewPayload?.can_proceed) { - return '已为你整理好本次报销信息' - } - return '已为你整理报销草稿信息' +function countReviewPendingItems(reviewPayload) { + return resolveReviewMissingSlotCards(reviewPayload).length } -function buildReviewSubline(reviewPayload, draftPayload) { - const claimNo = String(draftPayload?.claim_no || '').trim() - if (claimNo) { - return `草稿已保存为 draft,你可以继续补充费用明细、客户单位和票据附件。` - } - if (reviewPayload?.can_proceed) { - return '当前关键信息基本齐全,核对无误后可以继续下一步处理。' - } - return '当前已识别的信息已经整理完成,你可以继续补充缺失项,或者先保存草稿。' +function countReviewRiskItems(reviewPayload) { + return resolveReviewRiskBriefs(reviewPayload).length } -function buildReviewStateLabel(reviewPayload, draftPayload) { - if (draftPayload?.claim_no) return '草稿已创建' +function buildReviewHeadline(reviewPayload) { + if (countReviewPendingItems(reviewPayload) || countReviewRiskItems(reviewPayload)) { + return '风险提示与待补充信息' + } + if (reviewPayload?.can_proceed) { + return '识别结果已整理完成' + } + return '识别结果摘要' +} + +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 (reviewPayload?.can_proceed) { + return '当前关键信息已基本齐全,展开确认无误后可以继续下一步。' + } + return '已为您整理本轮识别结果,展开后可查看当前识别摘要。' +} + +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 '待补充' + return '已识别' } -function buildReviewStateTone(reviewPayload, draftPayload) { - return reviewPayload?.can_proceed || draftPayload?.claim_no ? 'ready' : 'pending' +function buildReviewStateTone(reviewPayload) { + return reviewPayload?.can_proceed && !countReviewPendingItems(reviewPayload) && !countReviewRiskItems(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(',')},点击展开查看` + } + return '当前无明显风险或缺失项,可展开查看识别摘要' +} + +function buildReviewDisclosureHint(reviewPayload) { + const pendingCount = countReviewPendingItems(reviewPayload) + const riskCount = countReviewRiskItems(reviewPayload) + if (pendingCount || riskCount) { + return '展开后可查看风险说明、待补充字段和处理建议' + } + return '展开后可查看本轮已识别的关键信息' +} + +function shouldOpenReviewDisclosure(reviewPayload) { + return !countReviewPendingItems(reviewPayload) && !countReviewRiskItems(reviewPayload) +} + +function buildReviewTodoSectionTitle(reviewPayload) { + return resolveReviewMissingSlotCards(reviewPayload).length ? '待补充内容' : '已识别信息' +} + +function buildReviewTodoSectionMeta(reviewPayload) { + const count = buildReviewTodoItems(reviewPayload).length + if (resolveReviewMissingSlotCards(reviewPayload).length) { + return count ? `${count} 项` : '待确认' + } + return count ? `${count} 项` : '已齐全' } function buildReviewAlertLabel(slotKey, expenseTypeLabel = '') { @@ -798,10 +1098,10 @@ function buildReviewPrimaryButtonLabel(reviewPayload, draftPayload) { const action = resolveReviewPrimaryAction(reviewPayload) if (!action) return '确认' if (action.action_type === 'save_draft') { - return draftPayload?.claim_no ? '确认并继续完善草稿' : '确认并继续生成草稿' + return draftPayload?.claim_no ? '保存为草稿' : '保存为草稿' } if (action.action_type === 'next_step') { - return '确认并进入下一步' + return '继续下一步' } return action.label || '确认' } @@ -884,6 +1184,7 @@ function buildInlineReviewState(reviewPayload) { participants: String(editFieldMap.participants?.value || slotMap.participants?.value || '').trim(), attachment_names: attachmentNames, attachment_count: attachmentCount, + pending_attachment_count: 0, expense_type: expenseType } } @@ -901,10 +1202,17 @@ function shouldShowReviewFactCard(reviewPayload, slotKey, value = '') { } function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineReviewState()) { + const pendingAttachmentCount = Math.max(0, Number(inlineState.pending_attachment_count || 0)) + const totalAttachmentCount = Math.max(0, Number(inlineState.attachment_count || 0)) + const existingAttachmentCount = Math.max(0, totalAttachmentCount - pendingAttachmentCount) const attachmentStatus = - inlineState.attachment_count > 0 - ? `待保存 ${inlineState.attachment_count} 份` - : buildReviewAttachmentStatus(reviewPayload) + pendingAttachmentCount > 0 + ? existingAttachmentCount > 0 + ? `已上传 ${existingAttachmentCount} 份,待新增 ${pendingAttachmentCount} 份` + : `待保存 ${pendingAttachmentCount} 份` + : totalAttachmentCount > 0 + ? `已上传 ${totalAttachmentCount} 份` + : buildReviewAttachmentStatus(reviewPayload) const cards = [ { key: 'occurred_date', @@ -1153,6 +1461,7 @@ function normalizeInlineReviewComparableState(state) { merchant_name: String(source.merchant_name || '').trim(), participants: String(source.participants || '').trim(), attachment_names: String(source.attachment_names || '').trim(), + pending_attachment_count: Math.max(0, Number(source.pending_attachment_count || 0)), expense_type: String(source.expense_type || '').trim() } } @@ -1201,6 +1510,25 @@ function buildInlineReviewUserText(baseState, nextState, pendingFiles = []) { return `我已修改识别信息:${lines.join(',')}。请按最新内容更新。` } +function buildReviewSubmitUserText(baseState, nextState, pendingFiles = [], baseDrafts = [], nextDrafts = []) { + const inlineLines = buildInlineReviewChangedLines(baseState, nextState, pendingFiles) + const documentLines = buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts) + + if (!inlineLines.length && !documentLines.length) { + return '我已修改识别信息,请按最新内容更新。' + } + + const parts = [] + if (inlineLines.length) { + parts.push(inlineLines.join(',')) + } + if (documentLines.length) { + parts.push(`修正了 ${documentLines.length} 张票据识别信息`) + } + + return `我已修改识别信息:${parts.join(';')}。请按最新内容更新。` +} + function mergeInlineReviewFields(baseFields, inlineState) { const merged = cloneReviewEditFields(baseFields) const updateMap = { @@ -1416,36 +1744,40 @@ export default { emits: ['close', 'draft-saved'], setup(props, { emit }) { const { currentUser } = useSystemState() + const { toast } = useToast() const fileInputRef = ref(null) + const composerTextareaRef = ref(null) const fileInputMode = ref('composer') const messageListRef = ref(null) const composerDraft = ref('') const attachedFiles = ref([]) + const composerFilesExpanded = ref(false) const submitting = ref(false) const linkedRequest = computed(() => sanitizeRequest(props.requestContext)) - const restoredMessages = normalizeInitialConversationMessages(props.initialConversation) - const initialInsight = buildInitialInsightFromConversation(props.initialConversation) - const messages = ref( - restoredMessages.length - ? restoredMessages - : [ - createMessage( - 'assistant', - props.entrySource === 'detail' && linkedRequest.value?.id - ? `已进入统一对话工作台,当前关联单据 ${linkedRequest.value.id}。请描述费用场景或补充票据。` - : '这里是统一对话入口。你发送的内容会直接进入真实 Orchestrator 和 User Agent。' - ) - ] - ) - const conversationId = ref(resolveInitialConversationId(props.initialConversation)) - const draftClaimId = ref(resolveInitialDraftClaimId(props.initialConversation)) + const initialSessionType = resolveInitialSessionType(props.initialConversation) + const initialSessionState = props.initialConversation + ? buildConversationSessionState(props.initialConversation, initialSessionType) + : buildEmptySessionState(initialSessionType) + const activeSessionType = ref(initialSessionState.sessionType) + const messages = ref(initialSessionState.messages) + const conversationId = ref(initialSessionState.conversationId) + const draftClaimId = ref(initialSessionState.draftClaimId) const previewRegistry = [] + const reviewFilePreviews = ref(initialSessionState.reviewFilePreviews) + const sessionSnapshots = ref({ + [SESSION_TYPE_EXPENSE]: null, + [SESSION_TYPE_KNOWLEDGE]: null + }) - const currentInsight = ref(initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value)) + const currentInsight = ref(initialSessionState.currentInsight) const reviewCancelDialogOpen = ref(false) const reviewEditDialogOpen = ref(false) + const deleteSessionDialogOpen = ref(false) + const leaveKnowledgeDialogOpen = ref(false) const reviewActionBusy = ref(false) + const deleteSessionBusy = ref(false) + const leaveKnowledgeBusy = ref(false) const reviewEditFields = ref([]) const reviewActionMessageId = ref('') const reviewInlineForm = ref(createEmptyInlineReviewState()) @@ -1455,33 +1787,64 @@ export default { const reviewInlineEditorKey = ref('') const reviewInlineErrors = ref({}) const reviewOtherCategoryOpen = ref(false) - const sourceLabel = computed(() => SOURCE_LABELS[props.entrySource] ?? '来自 AI 工作台') + const reviewDocumentDrafts = ref([]) + const reviewDocumentBaseDrafts = ref([]) + const activeReviewDocumentIndex = ref(0) + const insightPanelCollapsed = ref(false) + const documentPreviewDialog = ref({ + open: false, + filename: '', + kind: 'file', + url: '' + }) + const sessionSwitchBusy = ref(false) const canSubmit = computed( - () => !submitting.value && Boolean(composerDraft.value.trim() || attachedFiles.value.length) + () => !submitting.value && !sessionSwitchBusy.value && Boolean(composerDraft.value.trim() || attachedFiles.value.length) + ) + const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE) + const hasInsightPanelContent = computed( + () => isKnowledgeSession.value || currentInsight.value.intent !== 'welcome' + ) + const showInsightPanel = computed(() => hasInsightPanelContent.value && !insightPanelCollapsed.value) + const insightPanelToggleLabel = computed(() => + showInsightPanel.value ? '隐藏详细信息' : '展开详细信息' ) - const showInsightPanel = computed(() => currentInsight.value.intent !== 'welcome') const composerPlaceholder = computed(() => { + if (isKnowledgeSession.value) { + return '例如:差旅住宿标准是什么?发票抬头不一致还能报销吗?' + } if (props.entrySource === 'detail' && linkedRequest.value?.id) { return `例如:解释一下 ${linkedRequest.value.id} 的报销风险,或帮我生成处理意见草稿。` } return '例如:查一下本周报销金额、解释酒店超标风险,或根据附件生成报销草稿。' }) const currentIntentLabel = computed(() => { - const labels = { - welcome: '等待输入', - agent: '真实智能体' + if (isKnowledgeSession.value && currentInsight.value.intent === 'welcome') { + return '热门问题' } + const labels = isKnowledgeSession.value + ? { + welcome: '热门问题', + agent: '知识回答' + } + : { + welcome: '等待输入', + agent: '真实智能体' + } return labels[currentInsight.value.intent] ?? 'AI 处理中' }) + const canDeleteCurrentSession = computed( + () => Boolean(conversationId.value) || messages.value.some((item) => item.role === 'user') + ) const latestReviewMessage = computed(() => [...messages.value].reverse().find((item) => item.role === 'assistant' && item.reviewPayload) ?? null ) const activeReviewPayload = computed( () => currentInsight.value.agent?.reviewPayload || latestReviewMessage.value?.reviewPayload || null ) - const activeReviewFilePreviews = computed( - () => currentInsight.value.agent?.filePreviews || [] - ) + const activeReviewFilePreviews = computed(() => reviewFilePreviews.value) + const visibleAttachedFiles = computed(() => attachedFiles.value.slice(0, VISIBLE_ATTACHMENT_CHIPS)) + const hiddenAttachedFileCount = computed(() => Math.max(0, attachedFiles.value.length - VISIBLE_ATTACHMENT_CHIPS)) const reviewIntentText = computed(() => buildReviewIntentText(activeReviewPayload.value)) const reviewFactCards = computed(() => buildReviewFactCards(activeReviewPayload.value, reviewInlineForm.value)) const reviewCategoryOptions = computed(() => @@ -1516,18 +1879,144 @@ export default { 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 activeReviewDocument = computed(() => reviewDocumentDrafts.value[activeReviewDocumentIndex.value] ?? null) + const activeReviewDocumentPreview = computed(() => + activeReviewDocument.value + ? resolveDocumentPreview(activeReviewFilePreviews.value, activeReviewDocument.value.filename) + : null + ) + const canPreviewActiveReviewDocument = computed(() => Boolean(activeReviewDocumentPreview.value?.url)) + const reviewDocumentDirty = computed(() => { + const baseValue = JSON.stringify(reviewDocumentBaseDrafts.value.map(normalizeReviewDocumentComparableValue)) + const nextValue = JSON.stringify(reviewDocumentDrafts.value.map(normalizeReviewDocumentComparableValue)) + return baseValue !== nextValue + }) + const reviewHasUnsavedChanges = computed(() => reviewInlineDirty.value || reviewDocumentDirty.value) + const hotKnowledgeQuestions = computed(() => HOT_KNOWLEDGE_QUESTIONS) const shortcuts = computed(() => [ { - label: '快速生成报销草稿', - icon: 'mdi mdi-file-document-edit-outline', - prompt: - props.entrySource === 'detail' && linkedRequest.value?.id - ? `请基于当前关联单据 ${linkedRequest.value.id} 快速生成报销草稿` - : '帮我快速生成一份报销草稿' + label: isKnowledgeSession.value ? '切换为个人工作台' : '切换为财务知识问答', + icon: isKnowledgeSession.value ? 'mdi mdi-briefcase-outline' : 'mdi mdi-book-open-page-variant-outline', + action: 'switch_view', + targetSessionType: isKnowledgeSession.value ? SESSION_TYPE_EXPENSE : SESSION_TYPE_KNOWLEDGE } ]) + function buildConversationSessionState(conversation, fallbackSessionType = SESSION_TYPE_EXPENSE) { + const sessionType = resolveInitialSessionType(conversation) || fallbackSessionType + const restoredMessages = normalizeInitialConversationMessages(conversation) + const initialInsight = buildInitialInsightFromConversation(conversation) + + return { + sessionType, + messages: restoredMessages.length + ? restoredMessages + : [createMessage('assistant', buildWelcomeMessage(props.entrySource, linkedRequest.value, sessionType))], + conversationId: resolveInitialConversationId(conversation), + draftClaimId: resolveInitialDraftClaimId(conversation), + currentInsight: initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType), + reviewFilePreviews: [], + composerDraft: '', + attachedFiles: [], + composerFilesExpanded: false, + insightPanelCollapsed: false + } + } + + function buildEmptySessionState(sessionType) { + return { + sessionType, + messages: [createMessage('assistant', buildWelcomeMessage(props.entrySource, linkedRequest.value, sessionType))], + conversationId: '', + draftClaimId: '', + currentInsight: buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType), + reviewFilePreviews: [], + composerDraft: '', + attachedFiles: [], + composerFilesExpanded: false, + insightPanelCollapsed: false + } + } + + function resolveCurrentUserId() { + const user = currentUser.value || {} + return String(user.username || user.name || 'anonymous').trim() || 'anonymous' + } + + function captureCurrentSessionState() { + return { + sessionType: activeSessionType.value, + messages: messages.value, + conversationId: conversationId.value, + draftClaimId: draftClaimId.value, + currentInsight: currentInsight.value, + reviewFilePreviews: reviewFilePreviews.value, + composerDraft: composerDraft.value, + attachedFiles: attachedFiles.value, + composerFilesExpanded: composerFilesExpanded.value, + insightPanelCollapsed: insightPanelCollapsed.value + } + } + + function applySessionState(sessionState) { + const nextState = sessionState || buildEmptySessionState(activeSessionType.value) + activeSessionType.value = nextState.sessionType || SESSION_TYPE_EXPENSE + messages.value = Array.isArray(nextState.messages) && nextState.messages.length + ? nextState.messages + : [createMessage('assistant', buildWelcomeMessage(props.entrySource, linkedRequest.value, activeSessionType.value))] + conversationId.value = String(nextState.conversationId || '').trim() + draftClaimId.value = String(nextState.draftClaimId || '').trim() + currentInsight.value = nextState.currentInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value) + reviewFilePreviews.value = Array.isArray(nextState.reviewFilePreviews) ? nextState.reviewFilePreviews : [] + composerDraft.value = String(nextState.composerDraft || '') + attachedFiles.value = Array.isArray(nextState.attachedFiles) ? nextState.attachedFiles : [] + composerFilesExpanded.value = Boolean(nextState.composerFilesExpanded) + insightPanelCollapsed.value = Boolean(nextState.insightPanelCollapsed) + nextTick(() => { + adjustComposerTextareaHeight() + scrollToBottom() + }) + } + + async function loadLatestSessionState(targetSessionType) { + const payload = await fetchLatestConversation(resolveCurrentUserId(), targetSessionType) + if (payload?.found && payload.conversation) { + return buildConversationSessionState(payload.conversation, targetSessionType) + } + return buildEmptySessionState(targetSessionType) + } + + async function switchSessionType(targetSessionType) { + const normalizedTarget = String(targetSessionType || '').trim() || SESSION_TYPE_EXPENSE + if (normalizedTarget === activeSessionType.value || sessionSwitchBusy.value) { + return + } + + sessionSnapshots.value[activeSessionType.value] = captureCurrentSessionState() + if (sessionSnapshots.value[normalizedTarget]) { + applySessionState(sessionSnapshots.value[normalizedTarget]) + return + } + + sessionSwitchBusy.value = true + try { + const nextState = await loadLatestSessionState(normalizedTarget) + sessionSnapshots.value[normalizedTarget] = nextState + applySessionState(nextState) + } catch (error) { + const emptyState = buildEmptySessionState(normalizedTarget) + sessionSnapshots.value[normalizedTarget] = emptyState + applySessionState(emptyState) + toast(error?.message || '加载会话失败,已为你打开新的会话。') + } finally { + sessionSwitchBusy.value = false + } + } + + sessionSnapshots.value[initialSessionState.sessionType] = captureCurrentSessionState() + watch( () => activeReviewPayload.value, (payload) => { @@ -1535,6 +2024,12 @@ export default { reviewInlineForm.value = { ...nextInlineState } reviewInlineBaseForm.value = { ...nextInlineState } reviewInlineBaseFields.value = cloneReviewEditFields(payload?.edit_fields) + const nextDocumentDrafts = buildReviewDocumentDrafts(payload) + reviewDocumentDrafts.value = cloneReviewDocumentDrafts(nextDocumentDrafts) + reviewDocumentBaseDrafts.value = cloneReviewDocumentDrafts(nextDocumentDrafts) + activeReviewDocumentIndex.value = nextDocumentDrafts.length + ? Math.min(activeReviewDocumentIndex.value, nextDocumentDrafts.length - 1) + : 0 reviewInlinePendingFiles.value = [] reviewInlineEditorKey.value = '' reviewInlineErrors.value = {} @@ -1543,14 +2038,38 @@ export default { { immediate: true } ) + watch( + () => hasInsightPanelContent.value, + (available) => { + if (!available) { + insightPanelCollapsed.value = false + } + } + ) + + watch( + () => composerDraft.value, + () => { + nextTick(adjustComposerTextareaHeight) + } + ) + onMounted(() => { - currentInsight.value = initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value) + currentInsight.value = currentInsight.value || buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value) if (props.initialPrompt?.trim() || props.initialFiles.length) { + const initialMerge = mergeFilesWithLimit([], Array.from(props.initialFiles), MAX_ATTACHMENTS) composerDraft.value = props.initialPrompt.trim() - attachedFiles.value = Array.from(props.initialFiles) + attachedFiles.value = initialMerge.files + composerFilesExpanded.value = initialMerge.files.length > VISIBLE_ATTACHMENT_CHIPS + if (initialMerge.overflowCount > 0) { + toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`) + } submitComposer() } else { - nextTick(scrollToBottom) + nextTick(() => { + adjustComposerTextareaHeight() + scrollToBottom() + }) } }) @@ -1565,6 +2084,35 @@ export default { messageListRef.value.scrollTop = messageListRef.value.scrollHeight } + function resetCurrentSessionState() { + const emptyState = buildEmptySessionState(activeSessionType.value) + sessionSnapshots.value[activeSessionType.value] = emptyState + applySessionState(emptyState) + } + + function adjustComposerTextareaHeight() { + if (!composerTextareaRef.value) return + + composerTextareaRef.value.style.height = 'auto' + const styles = window.getComputedStyle(composerTextareaRef.value) + const lineHeight = Number.parseFloat(styles.lineHeight) || 24 + const verticalPadding = + Number.parseFloat(styles.paddingTop || '0') + Number.parseFloat(styles.paddingBottom || '0') + const maxHeight = lineHeight * COMPOSER_MAX_ROWS + verticalPadding + + composerTextareaRef.value.style.height = `${Math.min(composerTextareaRef.value.scrollHeight, maxHeight)}px` + composerTextareaRef.value.style.overflowY = + composerTextareaRef.value.scrollHeight > maxHeight ? 'auto' : 'hidden' + } + + function handleComposerInput() { + adjustComposerTextareaHeight() + } + + function rememberFilePreviews(filePreviews) { + reviewFilePreviews.value = mergeFilePreviews(reviewFilePreviews.value, filePreviews) + } + function replaceMessage(messageId, nextMessage) { const index = messages.value.findIndex((item) => item.id === messageId) if (index === -1) { @@ -1584,16 +2132,44 @@ export default { const files = Array.from(event.target.files ?? []) if (fileInputMode.value === 'inline-review' && activeReviewPayload.value) { - reviewInlinePendingFiles.value = files + const existingNames = extractReviewAttachmentNames(activeReviewPayload.value) + const remainingSlots = Math.max(MAX_ATTACHMENTS - existingNames.length, 0) + const mergeResult = mergeFilesWithLimit(reviewInlinePendingFiles.value, files, remainingSlots) + + if (!remainingSlots && files.length) { + toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,当前票据数量已到上限。`) + } else if (mergeResult.overflowCount > 0) { + toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,新增票据已按上限截断。`) + } + + reviewInlinePendingFiles.value = mergeResult.files + const allAttachmentNames = [...existingNames, ...mergeResult.files.map((file) => file.name)] reviewInlineForm.value = { ...reviewInlineForm.value, - attachment_names: files.map((file) => file.name).join('、'), - attachment_count: files.length + attachment_names: allAttachmentNames.join('、'), + attachment_count: allAttachmentNames.length, + pending_attachment_count: mergeResult.files.length } clearInlineReviewFieldError('attachments') reviewInlineEditorKey.value = '' } else { - attachedFiles.value = files + if (isKnowledgeSession.value) { + toast('财务知识问答暂不支持上传附件。') + fileInputMode.value = 'composer' + if (fileInputRef.value) { + fileInputRef.value.value = '' + } + return + } + + const mergeResult = mergeFilesWithLimit(attachedFiles.value, files, MAX_ATTACHMENTS) + attachedFiles.value = mergeResult.files + if (mergeResult.overflowCount > 0) { + toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`) + } + if (attachedFiles.value.length <= VISIBLE_ATTACHMENT_CHIPS) { + composerFilesExpanded.value = false + } } fileInputMode.value = 'composer' @@ -1602,11 +2178,45 @@ export default { } } - function runShortcut(prompt) { + function toggleAttachedFilesExpanded() { + composerFilesExpanded.value = !composerFilesExpanded.value + } + + function removeAttachedFile(targetFile) { + const fileKey = buildFileIdentity(targetFile) + attachedFiles.value = attachedFiles.value.filter((file) => buildFileIdentity(file) !== fileKey) + if (attachedFiles.value.length <= VISIBLE_ATTACHMENT_CHIPS) { + composerFilesExpanded.value = false + } + } + + function clearAttachedFiles() { + attachedFiles.value = [] + composerFilesExpanded.value = false + if (fileInputRef.value) { + fileInputRef.value.value = '' + } + } + + async function runShortcut(shortcut) { + if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) { + await switchSessionType(shortcut.targetSessionType) + return + } + + const prompt = String(shortcut?.prompt || '').trim() + if (!prompt) return composerDraft.value = prompt submitComposer() } + function toggleInsightPanel() { + if (!hasInsightPanelContent.value) { + return + } + insightPanelCollapsed.value = !insightPanelCollapsed.value + } + function setInlineReviewFieldError(key, message) { reviewInlineErrors.value = { ...reviewInlineErrors.value, @@ -1746,8 +2356,107 @@ export default { }) } + function goReviewDocument(direction) { + const total = reviewDocumentCount.value + if (!total) return + const nextIndex = activeReviewDocumentIndex.value + Number(direction || 0) + activeReviewDocumentIndex.value = Math.max(0, Math.min(total - 1, nextIndex)) + } + + function openActiveReviewDocumentPreview() { + if (!activeReviewDocument.value || !activeReviewDocumentPreview.value?.url) return + documentPreviewDialog.value = { + open: true, + filename: activeReviewDocument.value.filename, + kind: activeReviewDocumentPreview.value.kind, + url: activeReviewDocumentPreview.value.url + } + } + + function closeDocumentPreview() { + documentPreviewDialog.value = { + open: false, + filename: '', + kind: 'file', + url: '' + } + } + + function requestCloseWorkbench() { + if (submitting.value || reviewActionBusy.value || deleteSessionBusy.value || leaveKnowledgeBusy.value || sessionSwitchBusy.value) { + return + } + + if (isKnowledgeSession.value) { + leaveKnowledgeDialogOpen.value = true + return + } + + emit('close') + } + + function openDeleteSessionDialog() { + if (submitting.value || reviewActionBusy.value || deleteSessionBusy.value || sessionSwitchBusy.value) { + return + } + deleteSessionDialogOpen.value = true + } + + function closeDeleteSessionDialog() { + if (deleteSessionBusy.value) { + return + } + deleteSessionDialogOpen.value = false + } + + async function confirmDeleteCurrentSession() { + if (deleteSessionBusy.value || sessionSwitchBusy.value) { + return + } + + deleteSessionBusy.value = true + try { + if (conversationId.value) { + await deleteConversation(conversationId.value, resolveCurrentUserId()) + } + + resetCurrentSessionState() + deleteSessionDialogOpen.value = false + toast('当前会话已删除。') + } catch (error) { + toast(error?.message || '删除当前会话失败,请稍后重试。') + } finally { + deleteSessionBusy.value = false + } + } + + function closeLeaveKnowledgeDialog() { + if (leaveKnowledgeBusy.value) { + return + } + leaveKnowledgeDialogOpen.value = false + } + + async function confirmLeaveKnowledgeSession() { + if (leaveKnowledgeBusy.value || sessionSwitchBusy.value) { + return + } + + leaveKnowledgeBusy.value = true + try { + await clearUserConversations(resolveCurrentUserId(), SESSION_TYPE_KNOWLEDGE) + sessionSnapshots.value[SESSION_TYPE_KNOWLEDGE] = buildEmptySessionState(SESSION_TYPE_KNOWLEDGE) + leaveKnowledgeDialogOpen.value = false + emit('close') + } catch (error) { + toast(error?.message || '清理知识问答会话失败,请稍后重试。') + } finally { + leaveKnowledgeBusy.value = false + } + } + async function saveInlineReviewChanges() { - if (!activeReviewPayload.value || !reviewInlineDirty.value || reviewActionBusy.value) return + if (!activeReviewPayload.value || !reviewHasUnsavedChanges.value || reviewActionBusy.value) return if (reviewInlineEditorKey.value && !commitInlineReviewEditor()) { return @@ -1756,18 +2465,25 @@ export default { reviewActionBusy.value = true try { const fields = mergeInlineReviewFields(reviewInlineBaseFields.value, reviewInlineForm.value) + const documentCorrectionMessage = buildReviewDocumentCorrectionMessage( + reviewDocumentBaseDrafts.value, + reviewDocumentDrafts.value + ) await submitComposer({ - rawText: buildReviewCorrectionMessage(fields), - userText: buildInlineReviewUserText( + rawText: [buildReviewCorrectionMessage(fields), documentCorrectionMessage].filter(Boolean).join('\n'), + userText: buildReviewSubmitUserText( reviewInlineBaseForm.value, reviewInlineForm.value, - reviewInlinePendingFiles.value + reviewInlinePendingFiles.value, + reviewDocumentBaseDrafts.value, + reviewDocumentDrafts.value ), pendingText: '正在保存修改并刷新右侧核对信息...', files: reviewInlinePendingFiles.value, extraContext: { review_action: 'edit_review', - review_form_values: buildReviewFormValues(fields) + review_form_values: buildReviewFormValues(fields), + review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value) } }) } finally { @@ -1775,6 +2491,19 @@ export default { } } + function askHotKnowledgeQuestion(question) { + const normalizedQuestion = String(question || '').trim() + if (!normalizedQuestion || !isKnowledgeSession.value || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) { + return + } + + submitComposer({ + rawText: normalizedQuestion, + userText: normalizedQuestion, + pendingText: '正在整理财务知识答案...' + }) + } + function buildBackendMessage(rawText, fileNames, ocrSummary = '') { const parts = [] const normalizedText = String(rawText || '').trim() @@ -1782,7 +2511,11 @@ export default { if (normalizedText) { parts.push(normalizedText) } else if (fileNames.length) { - parts.push(`我上传了 ${fileNames.length} 份票据,请结合附件名称给出报销建议并尽量生成草稿。`) + parts.push( + isKnowledgeSession.value + ? `我上传了 ${fileNames.length} 份附件,请结合附件名称回答财务相关问题。` + : `我上传了 ${fileNames.length} 份票据,请结合附件名称给出报销建议并尽量生成草稿。` + ) } if (fileNames.length) { @@ -1801,32 +2534,48 @@ export default { } async function submitComposer(options = {}) { + if (sessionSwitchBusy.value) return null + const rawText = String(options.rawText ?? composerDraft.value).trim() - const files = Array.from(options.files ?? attachedFiles.value) + const normalizedFiles = isKnowledgeSession.value ? [] : Array.from(options.files ?? attachedFiles.value) + const fileMergeResult = mergeFilesWithLimit([], normalizedFiles, MAX_ATTACHMENTS) + const files = fileMergeResult.files + if (fileMergeResult.overflowCount > 0) { + toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`) + } if (!rawText && !files.length) return const fileNames = files.map((file) => file.name) const filePreviews = buildFilePreviews(files, previewRegistry) + rememberFilePreviews(filePreviews) const userText = String(options.userText || '').trim() || rawText || - `我上传了 ${fileNames.length} 份票据,请帮我识别并给出报销建议。` + (isKnowledgeSession.value + ? `我上传了 ${fileNames.length} 份附件,请帮我回答相关财务问题。` + : `我上传了 ${fileNames.length} 份票据,请帮我识别并给出报销建议。`) const extraContext = options.extraContext && typeof options.extraContext === 'object' ? options.extraContext : {} messages.value.push(createMessage('user', userText, fileNames)) - const pendingMessage = createMessage('assistant', options.pendingText || '正在识别并更新右侧核对信息...', [], { - meta: ['处理中'] - }) + const pendingMessage = createMessage( + 'assistant', + options.pendingText || (isKnowledgeSession.value ? '正在整理财务知识答案...' : '正在识别并更新右侧核对信息...'), + [], + { + meta: ['处理中'] + } + ) messages.value.push(pendingMessage) composerDraft.value = '' - attachedFiles.value = [] + clearAttachedFiles() if (fileInputRef.value) { fileInputRef.value.value = '' } + nextTick(adjustComposerTextareaHeight) submitting.value = true nextTick(scrollToBottom) @@ -1861,13 +2610,14 @@ export default { name: user.name || '', role: user.role || '', ...buildClientTimeContext(), + session_type: activeSessionType.value, entry_source: props.entrySource, attachment_names: fileNames, attachment_count: fileNames.length, - draft_claim_id: draftClaimId.value || undefined, + draft_claim_id: isKnowledgeSession.value ? undefined : draftClaimId.value || undefined, ocr_summary: ocrSummary, ocr_documents: ocrDocuments, - ...(linkedRequest.value ? { request_context: linkedRequest.value } : {}), + ...(linkedRequest.value && !isKnowledgeSession.value ? { request_context: linkedRequest.value } : {}), ...extraContext } }) @@ -1875,7 +2625,9 @@ export default { conversationId.value = String(payload?.conversation_id || '').trim() || conversationId.value draftClaimId.value = - String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value + isKnowledgeSession.value + ? '' + : String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value replaceMessage( pendingMessage.id, @@ -1991,25 +2743,38 @@ export default { ? reviewInlineBaseFields.value : cloneReviewEditFields(message?.reviewPayload?.edit_fields) const fields = mergeInlineReviewFields(baseFields, reviewInlineForm.value) - const reviewChangedUserText = reviewInlineDirty.value - ? buildInlineReviewUserText( + const reviewChangedUserText = reviewHasUnsavedChanges.value + ? buildReviewSubmitUserText( reviewInlineBaseForm.value, reviewInlineForm.value, - reviewInlinePendingFiles.value + reviewInlinePendingFiles.value, + reviewDocumentBaseDrafts.value, + reviewDocumentDrafts.value ) : '' + const documentCorrectionMessage = buildReviewDocumentCorrectionMessage( + reviewDocumentBaseDrafts.value, + reviewDocumentDrafts.value + ) const payload = await submitComposer({ - rawText: + rawText: [ + reviewHasUnsavedChanges.value ? buildReviewCorrectionMessage(fields) : '', + reviewHasUnsavedChanges.value ? documentCorrectionMessage : '', actionType === 'save_draft' ? '请按当前已识别信息先保存草稿,缺失字段后续再补。' - : '我已核对右侧识别结果,请进入下一步。', + : '我已核对右侧识别结果,请进入下一步。' + ] + .filter(Boolean) + .join('\n'), userText: reviewChangedUserText || (actionType === 'save_draft' ? '我先按当前信息保存草稿。' : '我确认当前识别结果,继续下一步。'), + files: reviewInlinePendingFiles.value, pendingText: actionType === 'save_draft' ? '正在保存当前草稿...' : '正在进入下一步...', extraContext: { review_action: actionType, - review_form_values: buildReviewFormValues(fields) + review_form_values: buildReviewFormValues(fields), + review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value) } }) @@ -2035,21 +2800,35 @@ export default { aiAvatar, userAvatar, fileInputRef, + composerTextareaRef, messageListRef, composerDraft, attachedFiles, + composerFilesExpanded, + visibleAttachedFiles, + hiddenAttachedFileCount, submitting, + sessionSwitchBusy, messages, currentInsight, linkedRequest, - sourceLabel, canSubmit, + activeSessionType, + isKnowledgeSession, + hotKnowledgeQuestions, + hasInsightPanelContent, showInsightPanel, + insightPanelToggleLabel, composerPlaceholder, currentIntentLabel, + canDeleteCurrentSession, latestReviewMessage, activeReviewPayload, activeReviewFilePreviews, + activeReviewDocument, + activeReviewDocumentIndex, + activeReviewDocumentPreview, + canPreviewActiveReviewDocument, reviewIntentText, reviewFactCards, reviewCategoryOptions, @@ -2073,10 +2852,18 @@ export default { recognizedNarratives, reviewRecognitionNotes, reviewDocumentSummaries, + reviewDocumentCount, + reviewDocumentDirty, + reviewHasUnsavedChanges, reviewCancelDialogOpen, reviewEditDialogOpen, + deleteSessionDialogOpen, + leaveKnowledgeDialogOpen, reviewActionBusy, + deleteSessionBusy, + leaveKnowledgeBusy, reviewEditFields, + documentPreviewDialog, shortcuts, resolveReviewMissingSlotCards, resolveReviewRiskBriefs, @@ -2084,6 +2871,11 @@ export default { buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, + buildReviewDisclosureTitle, + buildReviewDisclosureHint, + shouldOpenReviewDisclosure, + buildReviewTodoSectionTitle, + buildReviewTodoSectionMeta, buildReviewAlertChips, buildReviewTodoItems, resolveReviewPrimaryAction, @@ -2097,7 +2889,21 @@ export default { resolveDocumentPreview, triggerFileUpload, handleFilesChange, + handleComposerInput, runShortcut, + askHotKnowledgeQuestion, + resolveKnowledgeRankLabel, + resolveKnowledgeRankTone, + toggleInsightPanel, + toggleAttachedFilesExpanded, + removeAttachedFile, + clearAttachedFiles, + requestCloseWorkbench, + openDeleteSessionDialog, + closeDeleteSessionDialog, + confirmDeleteCurrentSession, + closeLeaveKnowledgeDialog, + confirmLeaveKnowledgeSession, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, @@ -2107,6 +2913,9 @@ export default { selectReviewOtherCategory, queryDraftByClaimNo, explainCurrentReviewRisk, + goReviewDocument, + openActiveReviewDocumentPreview, + closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleReviewAction, diff --git a/web/src/views/scripts/TravelRequestDetailView.js b/web/src/views/scripts/TravelRequestDetailView.js index c730205..d8c5233 100644 --- a/web/src/views/scripts/TravelRequestDetailView.js +++ b/web/src/views/scripts/TravelRequestDetailView.js @@ -453,7 +453,6 @@ export default { grade: request.value.profileGrade, manager: request.value.profileManager, facts: [ - { label: '身份', value: request.value.profileIdentity }, { label: '部门', value: request.value.profileDepartment }, { label: '职级', value: request.value.profileGrade }, { label: '直属上司', value: request.value.profileManager } @@ -492,6 +491,11 @@ export default { kind: 'text', emphasis: true }, + { + label: '报销类型', + value: request.value.typeLabel, + kind: 'text' + }, { label: '当前节点', value: request.value.node,