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
This commit is contained in:
caoxiaozhu
2026-05-12 07:22:11 +00:00
parent bff20d8eb3
commit 4d748bcdeb
3 changed files with 678 additions and 240 deletions

View File

@@ -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
}
}
}