From 4d748bcdebf4491c1d6eee3243fbeaab87590bca Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Tue, 12 May 2026 07:22:11 +0000 Subject: [PATCH] feat(web): update travel reimbursement view - travel-reimbursement-create-view.css: update form styles - TravelReimbursementCreateView.vue: update view component - scripts/TravelReimbursementCreateView.js: update view logic --- .../travel-reimbursement-create-view.css | 270 ++++++++++++- .../views/TravelReimbursementCreateView.vue | 378 +++++++++--------- .../scripts/TravelReimbursementCreateView.js | 270 ++++++++++--- 3 files changed, 678 insertions(+), 240 deletions(-) diff --git a/web/src/assets/styles/views/travel-reimbursement-create-view.css b/web/src/assets/styles/views/travel-reimbursement-create-view.css index a09754b..e3eea31 100644 --- a/web/src/assets/styles/views/travel-reimbursement-create-view.css +++ b/web/src/assets/styles/views/travel-reimbursement-create-view.css @@ -55,6 +55,11 @@ white-space: nowrap; } +.assistant-badge.warning { + background: rgba(249, 115, 22, 0.12); + color: #c2410c; +} + .assistant-header h2 { color: #0f172a; font-size: 24px; @@ -176,8 +181,7 @@ .message-row.user .message-avatar { order: 2; - background: #dbeafe; - color: #2563eb; + background: transparent; } .message-row.user .message-bubble { @@ -193,9 +197,16 @@ display: grid; place-items: center; border-radius: 999px; - background: #dff7ee; - color: #059669; - font-size: 20px; + overflow: hidden; + background: transparent; + box-shadow: 0 6px 16px rgba(15, 23, 42, 0.14); +} + +.message-avatar img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; } .message-bubble { @@ -766,6 +777,69 @@ line-height: 1.7; } +.review-inline-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.review-inline-btn, +.primary-dialog-btn, +.secondary-dialog-btn, +.danger-dialog-btn { + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 16px; + border-radius: 999px; + font-size: 13px; + font-weight: 800; +} + +.review-inline-btn { + border: 1px solid #dbe6f0; + background: #fff; + color: #334155; +} + +.review-inline-btn.primary, +.primary-dialog-btn { + border: 1px solid rgba(16, 185, 129, 0.22); + background: linear-gradient(135deg, #10b981, #059669); + color: #fff; + box-shadow: 0 10px 22px rgba(16, 185, 129, 0.18); +} + +.review-inline-btn.secondary, +.secondary-dialog-btn { + border: 1px solid #dbe6f0; + background: #fff; + color: #334155; +} + +.danger-dialog-btn { + border: 1px solid rgba(239, 68, 68, 0.22); + background: linear-gradient(135deg, #ef4444, #dc2626); + color: #fff; + box-shadow: 0 10px 22px rgba(239, 68, 68, 0.18); +} + +.review-inline-btn:disabled, +.primary-dialog-btn:disabled, +.secondary-dialog-btn:disabled, +.danger-dialog-btn:disabled { + cursor: not-allowed; + opacity: 0.62; + box-shadow: none; +} + +.review-inline-note { + color: #64748b; + font-size: 12px; + line-height: 1.6; +} + .review-mini-grid, .review-slot-grid, .review-doc-field-grid { @@ -847,6 +921,30 @@ background: #f8fbff; } +.review-slot-meta-list { + display: grid; + gap: 8px; +} + +.review-slot-meta-item { + padding: 9px 10px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.82); + border: 1px solid rgba(226, 232, 240, 0.9); +} + +.review-slot-meta-item span { + color: #94a3b8; + font-size: 11px; + font-weight: 800; +} + +.review-slot-meta-item strong { + display: block; + margin-top: 4px; + font-size: 12px; +} + .review-brief-list, .review-claim-list, .review-document-list { @@ -952,6 +1050,141 @@ font-weight: 800; } +.review-conclusion strong { + font-size: 15px; + line-height: 1.6; +} + +.review-overlay { + z-index: 10001; +} + +.review-confirm-modal, +.review-edit-modal { + width: min(720px, calc(100vw - 40px)); + border-radius: 24px; + background: + radial-gradient(circle at top right, rgba(16, 185, 129, 0.08), transparent 28%), + linear-gradient(180deg, #fbfdff 0%, #f6f9fc 100%); + box-shadow: + 0 24px 80px rgba(15, 23, 42, 0.22), + 0 2px 12px rgba(15, 23, 42, 0.08); + border: 1px solid #e7eef6; +} + +.review-confirm-modal { + padding: 24px; + display: grid; + gap: 18px; +} + +.review-confirm-modal h3, +.review-edit-head h3 { + margin-top: 12px; + color: #0f172a; + font-size: 22px; + font-weight: 900; + line-height: 1.35; +} + +.review-confirm-modal p, +.review-edit-head p { + margin-top: 8px; + color: #64748b; + font-size: 14px; + line-height: 1.7; +} + +.review-confirm-actions, +.review-edit-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + flex-wrap: wrap; +} + +.review-edit-modal { + max-height: min(860px, calc(100vh - 48px)); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; +} + +.review-edit-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 22px 24px 18px; + border-bottom: 1px solid #eef2f7; +} + +.review-edit-form { + min-height: 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + padding: 20px 24px; + overflow-y: auto; +} + +.review-edit-field { + display: grid; + gap: 8px; +} + +.review-edit-field.attachments, +.review-edit-field.business, +.review-edit-field.basic { + min-width: 0; +} + +.review-edit-field span { + color: #334155; + font-size: 13px; + font-weight: 800; +} + +.review-edit-field span em { + margin-left: 4px; + color: #dc2626; + font-style: normal; +} + +.review-edit-field input, +.review-edit-field textarea { + width: 100%; + border: 1px solid #dbe6f0; + border-radius: 16px; + background: #fff; + color: #0f172a; + font-size: 14px; + line-height: 1.6; + padding: 12px 14px; + resize: vertical; +} + +.review-edit-field input:focus, +.review-edit-field textarea:focus { + outline: none; + border-color: #60a5fa; + box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.14); +} + +.review-edit-field textarea { + min-height: 96px; +} + +.review-edit-field.attachments, +.review-edit-field textarea, +.review-edit-field .textarea { + grid-column: span 2; +} + +.review-edit-actions { + padding: 0 24px 24px; +} + .welcome-grid { display: grid; gap: 12px; @@ -1092,4 +1325,31 @@ .review-mini-grid { grid-template-columns: 1fr; } + + .review-edit-modal { + width: calc(100vw - 24px); + } + + .review-edit-form { + grid-template-columns: 1fr; + padding: 18px; + } + + .review-edit-field.attachments, + .review-edit-field textarea, + .review-edit-field .textarea { + grid-column: auto; + } + + .review-edit-actions, + .review-confirm-actions { + padding: 0 18px 18px; + justify-content: stretch; + } + + .primary-dialog-btn, + .secondary-dialog-btn, + .danger-dialog-btn { + width: 100%; + } } diff --git a/web/src/views/TravelReimbursementCreateView.vue b/web/src/views/TravelReimbursementCreateView.vue index 39cd759..a32b1d7 100644 --- a/web/src/views/TravelReimbursementCreateView.vue +++ b/web/src/views/TravelReimbursementCreateView.vue @@ -1,7 +1,7 @@ diff --git a/web/src/views/scripts/TravelReimbursementCreateView.js b/web/src/views/scripts/TravelReimbursementCreateView.js index b864433..95be99a 100644 --- a/web/src/views/scripts/TravelReimbursementCreateView.js +++ b/web/src/views/scripts/TravelReimbursementCreateView.js @@ -1,21 +1,11 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue' +import aiAvatar from '../../assets/header.svg' +import userAvatar from '../../assets/person.svg' import { useSystemState } from '../../composables/useSystemState.js' import { recognizeOcrFiles } from '../../services/ocr.js' import { runOrchestrator } from '../../services/orchestrator.js' -const DEFAULT_REQUEST = { - id: 'BR240712001', - reason: '客户方案汇报', - city: '上海', - period: '07-08 ~ 07-11', - applyTime: '2024-07-07', - amount: '¥3,680.00', - node: '财务复核', - approval: '主管审批中', - travel: '已订酒店 / 机票' -} - const SOURCE_LABELS = { workbench: '来自个人工作台', topbar: '来自发起报销', @@ -87,18 +77,21 @@ function formatMessageTime(value) { } function sanitizeRequest(request) { - if (!request) return { ...DEFAULT_REQUEST } - return { - id: request.id ?? DEFAULT_REQUEST.id, - reason: request.reason ?? DEFAULT_REQUEST.reason, - city: request.city ?? DEFAULT_REQUEST.city, - period: request.period ?? DEFAULT_REQUEST.period, - applyTime: request.applyTime ?? DEFAULT_REQUEST.applyTime, - amount: request.amount ?? DEFAULT_REQUEST.amount, - node: request.node ?? DEFAULT_REQUEST.node, - approval: request.approval ?? DEFAULT_REQUEST.approval, - travel: request.travel ?? DEFAULT_REQUEST.travel + if (!request || typeof request !== 'object') return null + + const normalized = { + id: String(request.id || '').trim(), + reason: String(request.reason || request.title || '').trim(), + city: String(request.city || request.location || '').trim(), + period: String(request.period || '').trim(), + applyTime: String(request.applyTime || request.occurredAt || '').trim(), + amount: String(request.amount || '').trim(), + node: String(request.node || '').trim(), + approval: String(request.approval || '').trim(), + travel: String(request.travel || '').trim() } + + return Object.values(normalized).some(Boolean) ? normalized : null } function resolveStatusLabel(status) { @@ -223,17 +216,32 @@ function resolveDocumentPreview(filePreviews, filename) { function buildWelcomeInsight(entrySource, linkedRequest) { return { intent: 'welcome', - metricLabel: '运行模式', - metricValue: 'Ready', - title: entrySource === 'detail' ? `已关联 ${linkedRequest.id}` : '已接入真实智能体对话', + metricLabel: '当前状态', + metricValue: '待识别', + title: entrySource === 'detail' && linkedRequest?.id ? `已关联 ${linkedRequest.id}` : '等待识别内容', summary: - entrySource === 'detail' - ? '发送消息后会直接调用 Orchestrator,并返回真实的规则引用、建议动作和草稿结果。' - : '这里不再使用前端本地意图模拟,所有发送内容都会进入真实 Orchestrator 调度链路。', + entrySource === 'detail' && linkedRequest?.id + ? '发送消息后会直接结合当前单据上下文识别报销语义,并在右侧展示可核对字段。' + : '请输入费用场景或上传票据,右侧会展示识别出的报销类型、时间、金额和待补字段。', agent: null } } +function buildInitialInsightFromConversation(conversation) { + const rawMessages = Array.isArray(conversation?.messages) ? conversation.messages : [] + for (let index = rawMessages.length - 1; index >= 0; index -= 1) { + const item = rawMessages[index] + const messageJson = item?.message_json || item?.messageJson || {} + const orchestratorPayload = messageJson?.orchestrator_payload || null + if (!orchestratorPayload) continue + const attachmentNames = Array.isArray(messageJson?.attachment_names) + ? messageJson.attachment_names.filter(Boolean) + : [] + return buildAgentInsight(orchestratorPayload, attachmentNames, []) + } + return null +} + function resolveInitialConversationId(conversation) { return String(conversation?.conversation_id || conversation?.conversationId || '').trim() } @@ -269,6 +277,40 @@ function normalizeInitialConversationMessages(conversation) { }) } +function cloneReviewEditFields(fields) { + const items = Array.isArray(fields) ? fields : [] + return items.map((item) => ({ + key: String(item?.key || '').trim(), + label: String(item?.label || '').trim(), + value: String(item?.value || ''), + placeholder: String(item?.placeholder || ''), + required: Boolean(item?.required), + field_type: String(item?.field_type || item?.fieldType || 'text').trim() || 'text', + group: String(item?.group || 'basic').trim() || 'basic' + })) +} + +function buildReviewFormValues(fields) { + return cloneReviewEditFields(fields).reduce((result, item) => { + if (!item.key) { + return result + } + result[item.key] = String(item.value || '').trim() + return result + }, {}) +} + +function buildReviewCorrectionMessage(fields) { + const lines = ['请按以下核对后的报销信息更新当前识别结果:'] + for (const item of cloneReviewEditFields(fields)) { + if (!item.label || (!item.value && !item.required)) { + continue + } + lines.push(`${item.label}:${String(item.value || '').trim() || '待补充'}`) + } + return lines.join('\n') +} + function buildErrorInsight(error, fileNames = []) { return { intent: 'agent', @@ -376,14 +418,15 @@ export default { 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}。你的提问会直接进入真实智能体链路。` + props.entrySource === 'detail' && linkedRequest.value?.id + ? `已进入统一对话工作台,当前关联单据 ${linkedRequest.value.id}。请描述费用场景或补充票据。` : '这里是统一对话入口。你发送的内容会直接进入真实 Orchestrator 和 User Agent。' ) ] @@ -392,14 +435,19 @@ export default { const draftClaimId = ref(resolveInitialDraftClaimId(props.initialConversation)) const previewRegistry = [] - const currentInsight = ref(buildWelcomeInsight(props.entrySource, linkedRequest.value)) + const currentInsight = ref(initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value)) + const reviewCancelDialogOpen = ref(false) + const reviewEditDialogOpen = ref(false) + const reviewActionBusy = ref(false) + const reviewEditFields = ref([]) + const reviewActionMessageId = ref('') const sourceLabel = computed(() => SOURCE_LABELS[props.entrySource] ?? '来自 AI 工作台') const canSubmit = computed( () => !submitting.value && Boolean(composerDraft.value.trim() || attachedFiles.value.length) ) const showInsightPanel = computed(() => currentInsight.value.intent !== 'welcome') const composerPlaceholder = computed(() => { - if (props.entrySource === 'detail') { + if (props.entrySource === 'detail' && linkedRequest.value?.id) { return `例如:解释一下 ${linkedRequest.value.id} 的报销风险,或帮我生成处理意见草稿。` } return '例如:查一下本周报销金额、解释酒店超标风险,或根据附件生成报销草稿。' @@ -411,9 +459,28 @@ export default { } return labels[currentInsight.value.intent] ?? 'AI 处理中' }) + 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 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') { + if (props.entrySource === 'detail' && linkedRequest.value?.id) { return [ { label: '解释风险原因', @@ -463,7 +530,7 @@ export default { }) onMounted(() => { - currentInsight.value = buildWelcomeInsight(props.entrySource, linkedRequest.value) + currentInsight.value = initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value) if (props.initialPrompt?.trim() || props.initialFiles.length) { composerDraft.value = props.initialPrompt.trim() attachedFiles.value = Array.from(props.initialFiles) @@ -525,27 +592,32 @@ export default { parts.push(`OCR摘要:${ocrSummary}`) } - if (props.entrySource === 'detail') { + if (props.entrySource === 'detail' && linkedRequest.value?.id) { parts.push(`关联单号:${linkedRequest.value.id}`) } return parts.join('\n') } - async function submitComposer() { - if (!canSubmit.value) return + async function submitComposer(options = {}) { + const rawText = String(options.rawText ?? composerDraft.value).trim() + const files = Array.from(options.files ?? attachedFiles.value) + if (!rawText && !files.length) return - const rawText = composerDraft.value.trim() - const files = Array.from(attachedFiles.value) const fileNames = files.map((file) => file.name) const filePreviews = buildFilePreviews(files, previewRegistry) const userText = - rawText || `我上传了 ${fileNames.length} 份票据,请帮我识别并给出报销建议。` + String(options.userText || '').trim() || + rawText || + `我上传了 ${fileNames.length} 份票据,请帮我识别并给出报销建议。` + const extraContext = options.extraContext && typeof options.extraContext === 'object' + ? options.extraContext + : {} messages.value.push(createMessage('user', userText, fileNames)) - const pendingMessage = createMessage('assistant', 'Orchestrator 正在处理中...', [], { - meta: ['运行中'] + const pendingMessage = createMessage('assistant', options.pendingText || '正在识别并更新右侧核对信息...', [], { + meta: ['处理中'] }) messages.value.push(pendingMessage) @@ -586,12 +658,13 @@ export default { name: user.name || '', role: user.role || '', entry_source: props.entrySource, - request_context: linkedRequest.value, attachment_names: fileNames, attachment_count: fileNames.length, draft_claim_id: draftClaimId.value || undefined, ocr_summary: ocrSummary, - ocr_documents: ocrDocuments + ocr_documents: ocrDocuments, + ...(linkedRequest.value ? { request_context: linkedRequest.value } : {}), + ...extraContext } }) @@ -632,8 +705,99 @@ export default { } } + function openCancelReviewDialog(message) { + reviewActionMessageId.value = String(message?.id || '') + reviewCancelDialogOpen.value = true + } + + function closeCancelReviewDialog() { + if (reviewActionBusy.value) return + reviewCancelDialogOpen.value = false + reviewActionMessageId.value = '' + } + + function confirmCancelReview() { + if (reviewActionBusy.value) return + reviewCancelDialogOpen.value = false + emit('close') + } + + function openEditReviewDialog(message) { + reviewEditFields.value = cloneReviewEditFields(message?.reviewPayload?.edit_fields) + reviewActionMessageId.value = String(message?.id || '') + reviewEditDialogOpen.value = true + } + + function closeEditReviewDialog() { + if (reviewActionBusy.value) return + reviewEditDialogOpen.value = false + reviewEditFields.value = [] + reviewActionMessageId.value = '' + } + + async function applyEditedReview() { + if (reviewActionBusy.value) return + + reviewActionBusy.value = true + try { + const fields = cloneReviewEditFields(reviewEditFields.value) + await submitComposer({ + rawText: buildReviewCorrectionMessage(fields), + userText: '我已修改识别信息,请按最新内容更新。', + pendingText: '正在根据修改内容重新识别...', + extraContext: { + review_action: 'edit_review', + review_form_values: buildReviewFormValues(fields) + } + }) + } finally { + reviewActionBusy.value = false + } + closeEditReviewDialog() + } + + async function handleReviewAction(message, action) { + const actionType = String(action?.action_type || '').trim() + if (!actionType || reviewActionBusy.value) return + + if (actionType === 'cancel_review') { + openCancelReviewDialog(message) + return + } + + if (actionType === 'edit_review') { + openEditReviewDialog(message) + return + } + + if (!['save_draft', 'next_step'].includes(actionType)) { + return + } + + reviewActionBusy.value = true + try { + const fields = cloneReviewEditFields(message?.reviewPayload?.edit_fields) + await submitComposer({ + rawText: + actionType === 'save_draft' + ? '请按当前已识别信息先保存草稿,缺失字段后续再补。' + : '我已核对右侧识别结果,请进入下一步。', + userText: actionType === 'save_draft' ? '我先按当前信息保存草稿。' : '我确认当前识别结果,继续下一步。', + pendingText: actionType === 'save_draft' ? '正在保存当前草稿...' : '正在进入下一步...', + extraContext: { + review_action: actionType, + review_form_values: buildReviewFormValues(fields) + } + }) + } finally { + reviewActionBusy.value = false + } + } + return { emit, + aiAvatar, + userAvatar, fileInputRef, messageListRef, composerDraft, @@ -647,12 +811,26 @@ export default { showInsightPanel, composerPlaceholder, currentIntentLabel, + latestReviewMessage, + activeReviewPayload, + activeReviewFilePreviews, + recognizedSlotCards, + missingSlotCards, + reviewCancelDialogOpen, + reviewEditDialogOpen, + reviewActionBusy, + reviewEditFields, shortcuts, resolveDocumentPreview, triggerFileUpload, handleFilesChange, runShortcut, - submitComposer + submitComposer, + handleReviewAction, + closeCancelReviewDialog, + confirmCancelReview, + closeEditReviewDialog, + applyEditedReview } } }