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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user