style(web): 更新差旅报销创建页面样式和业务脚本,增强前端交互和状态管理
This commit is contained in:
@@ -6,6 +6,12 @@ import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import { recognizeOcrFiles } from '../../services/ocr.js'
|
||||
import { clearUserConversations, deleteConversation, fetchLatestConversation, runOrchestrator } from '../../services/orchestrator.js'
|
||||
import {
|
||||
fetchExpenseClaimAttachmentAsset,
|
||||
fetchExpenseClaimDetail,
|
||||
fetchExpenseClaimItemAttachmentMeta,
|
||||
uploadExpenseClaimItemAttachment
|
||||
} from '../../services/reimbursements.js'
|
||||
|
||||
const aiAvatar = '/assets/header.png'
|
||||
const userAvatar = '/assets/person.png'
|
||||
@@ -155,7 +161,8 @@ const REVIEW_OTHER_CATEGORY_OPTIONS = [
|
||||
{ key: 'other', label: '其他费用' }
|
||||
]
|
||||
|
||||
const REVIEW_SCENE_OPTIONS = ['请客户吃饭', '出差行程', '住宿报销', '交通出行', '会务活动', '其他场景']
|
||||
const REVIEW_SCENE_OTHER_OPTION = '其他场景'
|
||||
const REVIEW_SCENE_OPTIONS = ['请客户吃饭', '出差行程', '住宿报销', '交通出行', '会务活动', REVIEW_SCENE_OTHER_OPTION]
|
||||
const DATE_INPUT_FORMAT = 'YYYY-MM-DD'
|
||||
const MAX_ATTACHMENTS = 10
|
||||
const MAX_OCR_DOCUMENTS = 10
|
||||
@@ -332,6 +339,9 @@ function normalizeOcrDocuments(payload) {
|
||||
document_type_label: String(item.document_type_label || '').trim(),
|
||||
scene_code: String(item.scene_code || 'other').trim() || 'other',
|
||||
scene_label: String(item.scene_label || '').trim(),
|
||||
preview_kind: String(item.preview_kind || '').trim(),
|
||||
preview_data_url: String(item.preview_data_url || '').trim(),
|
||||
preview_url: String(item.preview_url || '').trim(),
|
||||
document_fields: Array.isArray(item.document_fields)
|
||||
? item.document_fields
|
||||
.map((field) => ({
|
||||
@@ -346,11 +356,129 @@ function normalizeOcrDocuments(payload) {
|
||||
}
|
||||
|
||||
function buildOcrSummary(payload) {
|
||||
const parts = normalizeOcrDocuments(payload)
|
||||
.map((item) => `${item.filename}:${item.summary || item.text}`)
|
||||
.filter(Boolean)
|
||||
return buildOcrSummaryFromDocuments(normalizeOcrDocuments(payload))
|
||||
}
|
||||
|
||||
return parts.join(';')
|
||||
function buildOcrSummaryFromDocuments(documents) {
|
||||
return (Array.isArray(documents) ? documents : [])
|
||||
.slice(0, MAX_OCR_DOCUMENTS)
|
||||
.map((item) => {
|
||||
const filename = String(item?.filename || '').trim()
|
||||
const summary = String(item?.summary || item?.text || '').trim()
|
||||
if (filename && summary) {
|
||||
return `${filename}:${summary}`
|
||||
}
|
||||
return filename || summary
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(';')
|
||||
}
|
||||
|
||||
function normalizeReviewDocumentFieldKey(label) {
|
||||
const compact = String(label || '').replace(/\s+/g, '').toLowerCase()
|
||||
if (!compact) return ''
|
||||
if (
|
||||
['金额', '价税合计', '合计', '总额', '总计', '票价', '支付金额', '实付金额', '实收金额'].some((token) =>
|
||||
compact.includes(token.toLowerCase())
|
||||
)
|
||||
) {
|
||||
return 'amount'
|
||||
}
|
||||
if (['日期', '时间', '开票日期', '发生时间'].some((token) => compact.includes(token.toLowerCase()))) {
|
||||
return 'date'
|
||||
}
|
||||
if (['商户', '酒店', '销售方', '开票方', '收款方'].some((token) => compact.includes(token.toLowerCase()))) {
|
||||
return 'merchant_name'
|
||||
}
|
||||
if (['票据号码', '发票号码', '票号', '单号', '订单号'].some((token) => compact.includes(token.toLowerCase()))) {
|
||||
return 'invoice_number'
|
||||
}
|
||||
if (compact.includes('发票代码')) {
|
||||
return 'invoice_code'
|
||||
}
|
||||
if (compact.includes('车次') || compact.includes('航班')) {
|
||||
return 'trip_no'
|
||||
}
|
||||
if (compact.includes('行程') || compact.includes('路线')) {
|
||||
return 'route'
|
||||
}
|
||||
return compact
|
||||
}
|
||||
|
||||
function buildOcrDocumentsFromReviewPayload(reviewPayload) {
|
||||
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
|
||||
return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => {
|
||||
const fields = Array.isArray(item?.fields)
|
||||
? item.fields
|
||||
.map((field) => {
|
||||
const label = String(field?.label || '').trim()
|
||||
const value = String(field?.value || '').trim()
|
||||
if (!label || !value) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
key: normalizeReviewDocumentFieldKey(label),
|
||||
label,
|
||||
value
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
: []
|
||||
|
||||
return {
|
||||
filename: String(item?.filename || '').trim(),
|
||||
summary: String(item?.summary || '').trim(),
|
||||
text: [
|
||||
String(item?.scene_label || '').trim(),
|
||||
String(item?.summary || '').trim(),
|
||||
...fields.map((field) => `${field.label}:${field.value}`)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.slice(0, 240),
|
||||
avg_score: Number(item?.avg_score || 0),
|
||||
document_type: String(item?.document_type || 'other').trim() || 'other',
|
||||
document_type_label: resolveDocumentTypeLabel(item?.document_type),
|
||||
scene_code: resolveExpenseTypeCode(item?.suggested_expense_type),
|
||||
scene_label: String(item?.scene_label || '').trim(),
|
||||
document_fields: fields,
|
||||
warnings: Array.isArray(item?.warnings) ? item.warnings : []
|
||||
}
|
||||
}).filter((item) => item.filename)
|
||||
}
|
||||
|
||||
function mergeUploadAttachmentNames(existingNames, incomingNames) {
|
||||
const merged = []
|
||||
const seen = new Set()
|
||||
|
||||
for (const value of [...(existingNames || []), ...(incomingNames || [])]) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized || seen.has(normalized)) continue
|
||||
seen.add(normalized)
|
||||
merged.push(normalized)
|
||||
if (merged.length >= MAX_ATTACHMENTS) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
function mergeUploadOcrDocuments(existingDocuments, incomingDocuments) {
|
||||
const merged = []
|
||||
const seen = new Set()
|
||||
|
||||
for (const item of [...(existingDocuments || []), ...(incomingDocuments || [])]) {
|
||||
const filename = String(item?.filename || '').trim()
|
||||
if (!filename || seen.has(filename)) continue
|
||||
seen.add(filename)
|
||||
merged.push(item)
|
||||
if (merged.length >= MAX_OCR_DOCUMENTS) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
function inferPreviewKind(file) {
|
||||
@@ -457,11 +585,46 @@ function buildOcrFilePreviews(payload) {
|
||||
.map((item) => ({
|
||||
filename: String(item?.filename || '').trim(),
|
||||
kind: String(item?.preview_kind || '').trim(),
|
||||
url: String(item?.preview_data_url || '').trim()
|
||||
url: String(item?.preview_url || item?.preview_data_url || '').trim()
|
||||
}))
|
||||
.filter((item) => item.filename && item.kind === 'image' && item.url)
|
||||
}
|
||||
|
||||
function buildReviewFilePreviewsFromReviewPayload(reviewPayload) {
|
||||
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
|
||||
return documents
|
||||
.map((item) => ({
|
||||
filename: String(item?.filename || '').trim(),
|
||||
kind: String(item?.preview_kind || '').trim(),
|
||||
url: String(item?.preview_url || item?.preview_data_url || '').trim()
|
||||
}))
|
||||
.filter((item) => item.filename && item.kind === 'image' && item.url)
|
||||
}
|
||||
|
||||
function buildReviewFilePreviewsFromMessages(messages) {
|
||||
const previews = []
|
||||
for (const message of Array.isArray(messages) ? messages : []) {
|
||||
previews.push(...buildReviewFilePreviewsFromReviewPayload(message?.reviewPayload))
|
||||
}
|
||||
return mergeFilePreviews([], previews)
|
||||
}
|
||||
|
||||
function resolveAttachmentPreviewKind(metadata) {
|
||||
const explicitKind = String(metadata?.preview_kind || '').trim()
|
||||
if (explicitKind) {
|
||||
return explicitKind
|
||||
}
|
||||
|
||||
const mediaType = String(metadata?.media_type || '').trim().toLowerCase()
|
||||
if (mediaType.startsWith('image/')) {
|
||||
return 'image'
|
||||
}
|
||||
if (mediaType === 'application/pdf') {
|
||||
return 'pdf'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function extractReviewAttachmentNames(reviewPayload) {
|
||||
const documentNames = Array.isArray(reviewPayload?.document_cards)
|
||||
? reviewPayload.document_cards.map((item) => String(item?.filename || '').trim()).filter(Boolean)
|
||||
@@ -504,6 +667,8 @@ function buildReviewDocumentDrafts(reviewPayload) {
|
||||
confidenceLabel: String(item.confidenceLabel || '').trim(),
|
||||
documentTypeLabel: String(item.documentTypeLabel || '').trim(),
|
||||
expenseTypeLabel: String(item.expenseTypeLabel || '').trim(),
|
||||
preview_kind: String(item.preview_kind || '').trim(),
|
||||
preview_data_url: String(item.preview_data_url || '').trim(),
|
||||
warnings: Array.isArray(item.warnings) ? [...item.warnings] : [],
|
||||
fields: Array.isArray(item.fields)
|
||||
? item.fields.map((field) => ({
|
||||
@@ -648,7 +813,11 @@ function buildInitialInsightFromConversation(conversation) {
|
||||
const attachmentNames = Array.isArray(messageJson?.attachment_names)
|
||||
? messageJson.attachment_names.filter(Boolean)
|
||||
: []
|
||||
return buildAgentInsight(orchestratorPayload, attachmentNames, [])
|
||||
return buildAgentInsight(
|
||||
orchestratorPayload,
|
||||
attachmentNames,
|
||||
buildReviewFilePreviewsFromReviewPayload(orchestratorPayload?.result?.review_payload)
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1322,6 +1491,13 @@ function resolveReviewPrimaryAction(reviewPayload) {
|
||||
)
|
||||
}
|
||||
|
||||
function resolveReviewSubmitActions(reviewPayload) {
|
||||
return (Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).filter((item) => {
|
||||
const actionType = String(item?.action_type || '').trim()
|
||||
return actionType && !['cancel_review', 'edit_review'].includes(actionType)
|
||||
})
|
||||
}
|
||||
|
||||
function resolveReviewEditAction(reviewPayload) {
|
||||
return (
|
||||
(Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).find(
|
||||
@@ -1339,6 +1515,12 @@ function buildReviewPrimaryButtonLabel(reviewPayload, draftPayload) {
|
||||
if (action.action_type === 'next_step') {
|
||||
return '继续下一步'
|
||||
}
|
||||
if (action.action_type === 'link_to_existing_draft') {
|
||||
return action.label || '关联到现有草稿'
|
||||
}
|
||||
if (action.action_type === 'create_new_claim_from_documents') {
|
||||
return action.label || '单独建立报销单'
|
||||
}
|
||||
return action.label || '确认'
|
||||
}
|
||||
|
||||
@@ -1471,7 +1653,7 @@ function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineRevi
|
||||
{
|
||||
key: 'scene',
|
||||
label: '场景 / 事由',
|
||||
value: String(inlineState.scene_label || '').trim() || '待补充',
|
||||
value: String(inlineState.reason_value || inlineState.scene_label || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-silverware-fork-knife',
|
||||
editor: 'select',
|
||||
modelKey: 'scene_label',
|
||||
@@ -2003,6 +2185,7 @@ export default {
|
||||
const conversationId = ref(initialSessionState.conversationId)
|
||||
const draftClaimId = ref(initialSessionState.draftClaimId)
|
||||
const previewRegistry = []
|
||||
const restoredDraftPreviewClaims = new Set()
|
||||
const reviewFilePreviews = ref(initialSessionState.reviewFilePreviews)
|
||||
const sessionSnapshots = ref({
|
||||
[SESSION_TYPE_EXPENSE]: null,
|
||||
@@ -2012,6 +2195,7 @@ export default {
|
||||
const currentInsight = ref(initialSessionState.currentInsight)
|
||||
const reviewCancelDialogOpen = ref(false)
|
||||
const reviewEditDialogOpen = ref(false)
|
||||
const uploadDecisionDialogOpen = ref(false)
|
||||
const deleteSessionDialogOpen = ref(false)
|
||||
const reviewActionBusy = ref(false)
|
||||
const deleteSessionBusy = ref(false)
|
||||
@@ -2024,6 +2208,7 @@ export default {
|
||||
const reviewInlineEditorKey = ref('')
|
||||
const reviewInlineErrors = ref({})
|
||||
const reviewOtherCategoryOpen = ref(false)
|
||||
const composerUploadIntent = ref(String(initialSessionState.composerUploadIntent || '').trim())
|
||||
const reviewDocumentDrafts = ref([])
|
||||
const reviewDocumentBaseDrafts = ref([])
|
||||
const activeReviewDocumentIndex = ref(0)
|
||||
@@ -2149,7 +2334,18 @@ export default {
|
||||
const activeReviewDocument = computed(() => reviewDocumentDrafts.value[activeReviewDocumentIndex.value] ?? null)
|
||||
const activeReviewDocumentPreview = computed(() =>
|
||||
activeReviewDocument.value
|
||||
? resolveDocumentPreview(activeReviewFilePreviews.value, activeReviewDocument.value.filename)
|
||||
? (
|
||||
resolveDocumentPreview(activeReviewFilePreviews.value, activeReviewDocument.value.filename)
|
||||
|| (
|
||||
activeReviewDocument.value.preview_kind === 'image' && activeReviewDocument.value.preview_data_url
|
||||
? {
|
||||
filename: activeReviewDocument.value.filename,
|
||||
kind: activeReviewDocument.value.preview_kind,
|
||||
url: activeReviewDocument.value.preview_data_url
|
||||
}
|
||||
: null
|
||||
)
|
||||
)
|
||||
: null
|
||||
)
|
||||
const canPreviewActiveReviewDocument = computed(() => Boolean(activeReviewDocumentPreview.value?.url))
|
||||
@@ -2174,6 +2370,7 @@ export default {
|
||||
const sessionType = resolveInitialSessionType(conversation) || fallbackSessionType
|
||||
const restoredMessages = normalizeInitialConversationMessages(conversation)
|
||||
const initialInsight = buildInitialInsightFromConversation(conversation)
|
||||
const restoredReviewFilePreviews = buildReviewFilePreviewsFromMessages(restoredMessages)
|
||||
|
||||
return {
|
||||
sessionType,
|
||||
@@ -2183,10 +2380,11 @@ export default {
|
||||
conversationId: resolveInitialConversationId(conversation),
|
||||
draftClaimId: resolveInitialDraftClaimId(conversation),
|
||||
currentInsight: initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType),
|
||||
reviewFilePreviews: [],
|
||||
reviewFilePreviews: restoredReviewFilePreviews,
|
||||
composerDraft: '',
|
||||
attachedFiles: [],
|
||||
composerFilesExpanded: false,
|
||||
composerUploadIntent: '',
|
||||
insightPanelCollapsed: false
|
||||
}
|
||||
}
|
||||
@@ -2202,6 +2400,7 @@ export default {
|
||||
composerDraft: '',
|
||||
attachedFiles: [],
|
||||
composerFilesExpanded: false,
|
||||
composerUploadIntent: '',
|
||||
insightPanelCollapsed: false
|
||||
}
|
||||
}
|
||||
@@ -2222,6 +2421,7 @@ export default {
|
||||
composerDraft: composerDraft.value,
|
||||
attachedFiles: attachedFiles.value,
|
||||
composerFilesExpanded: composerFilesExpanded.value,
|
||||
composerUploadIntent: composerUploadIntent.value,
|
||||
insightPanelCollapsed: insightPanelCollapsed.value
|
||||
}
|
||||
}
|
||||
@@ -2239,7 +2439,9 @@ export default {
|
||||
composerDraft.value = String(nextState.composerDraft || '')
|
||||
attachedFiles.value = Array.isArray(nextState.attachedFiles) ? nextState.attachedFiles : []
|
||||
composerFilesExpanded.value = Boolean(nextState.composerFilesExpanded)
|
||||
composerUploadIntent.value = String(nextState.composerUploadIntent || '').trim()
|
||||
insightPanelCollapsed.value = Boolean(nextState.insightPanelCollapsed)
|
||||
uploadDecisionDialogOpen.value = false
|
||||
nextTick(() => {
|
||||
adjustComposerTextareaHeight()
|
||||
scrollToBottom()
|
||||
@@ -2247,7 +2449,9 @@ export default {
|
||||
}
|
||||
|
||||
async function loadLatestSessionState(targetSessionType) {
|
||||
const payload = await fetchLatestConversation(resolveCurrentUserId(), targetSessionType)
|
||||
const payload = await fetchLatestConversation(resolveCurrentUserId(), targetSessionType, {
|
||||
preferRecoverable: targetSessionType === SESSION_TYPE_EXPENSE
|
||||
})
|
||||
if (payload?.found && payload.conversation) {
|
||||
return buildConversationSessionState(payload.conversation, targetSessionType)
|
||||
}
|
||||
@@ -2307,6 +2511,7 @@ export default {
|
||||
watch(
|
||||
() => activeReviewPayload.value,
|
||||
(payload) => {
|
||||
rememberFilePreviews(buildReviewFilePreviewsFromReviewPayload(payload))
|
||||
const nextInlineState = buildInlineReviewState(payload)
|
||||
reviewInlineForm.value = { ...nextInlineState }
|
||||
reviewInlineBaseForm.value = { ...nextInlineState }
|
||||
@@ -2360,6 +2565,17 @@ export default {
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [activeSessionType.value, resolveActiveClaimId()],
|
||||
([sessionType, claimId]) => {
|
||||
if (sessionType !== SESSION_TYPE_EXPENSE || !claimId) {
|
||||
return
|
||||
}
|
||||
void restorePersistedDraftAttachmentPreviews(claimId)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
void clearKnowledgeSessionOnEntry()
|
||||
currentInsight.value = currentInsight.value || buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value)
|
||||
@@ -2420,6 +2636,128 @@ export default {
|
||||
reviewFilePreviews.value = mergeFilePreviews(reviewFilePreviews.value, filePreviews)
|
||||
}
|
||||
|
||||
function trackPreviewObjectUrl(url) {
|
||||
if (!url || !String(url).startsWith('blob:')) {
|
||||
return
|
||||
}
|
||||
previewRegistry.push(url)
|
||||
}
|
||||
|
||||
function resolveActiveClaimId() {
|
||||
return String(draftClaimId.value || linkedRequest.value?.claimId || '').trim()
|
||||
}
|
||||
|
||||
async function buildPersistedAttachmentPreview(metadata) {
|
||||
const filename = String(metadata?.file_name || '').trim()
|
||||
const kind = resolveAttachmentPreviewKind(metadata)
|
||||
const previewPath = String(metadata?.preview_url || '').trim()
|
||||
if (!filename || !kind || !previewPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
const blob = await fetchExpenseClaimAttachmentAsset(previewPath)
|
||||
const url = URL.createObjectURL(blob)
|
||||
trackPreviewObjectUrl(url)
|
||||
return {
|
||||
filename,
|
||||
kind,
|
||||
url
|
||||
}
|
||||
}
|
||||
|
||||
async function restorePersistedDraftAttachmentPreviews(claimId, options = {}) {
|
||||
const normalizedClaimId = String(claimId || '').trim()
|
||||
if (!normalizedClaimId || isKnowledgeSession.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const force = Boolean(options.force)
|
||||
if (!force && restoredDraftPreviewClaims.has(normalizedClaimId)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const claim = await fetchExpenseClaimDetail(normalizedClaimId)
|
||||
const items = Array.isArray(claim?.items) ? claim.items : []
|
||||
const previews = []
|
||||
|
||||
for (const item of items) {
|
||||
const itemId = String(item?.id || '').trim()
|
||||
if (!itemId) continue
|
||||
|
||||
let metadata = null
|
||||
try {
|
||||
metadata = await fetchExpenseClaimItemAttachmentMeta(normalizedClaimId, itemId)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const filename = String(metadata?.file_name || '').trim()
|
||||
if (!metadata?.previewable || !filename || resolveDocumentPreview(reviewFilePreviews.value, filename)) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const preview = await buildPersistedAttachmentPreview(metadata)
|
||||
if (preview) {
|
||||
previews.push(preview)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load persisted attachment preview:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (previews.length) {
|
||||
rememberFilePreviews(previews)
|
||||
}
|
||||
restoredDraftPreviewClaims.add(normalizedClaimId)
|
||||
} catch (error) {
|
||||
console.warn('Failed to restore persisted draft attachment previews:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function syncComposerFilesToDraft(claimId, files) {
|
||||
const normalizedClaimId = String(claimId || '').trim()
|
||||
if (!normalizedClaimId || !Array.isArray(files) || !files.length || isKnowledgeSession.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const claim = await fetchExpenseClaimDetail(normalizedClaimId)
|
||||
const items = Array.isArray(claim?.items) ? claim.items : []
|
||||
const exactMatchBuckets = new Map()
|
||||
const placeholderQueue = []
|
||||
const usedItemIds = new Set()
|
||||
|
||||
for (const item of items) {
|
||||
const itemId = String(item?.id || '').trim()
|
||||
const invoiceId = String(item?.invoiceId || item?.invoice_id || '').trim()
|
||||
if (!itemId) continue
|
||||
if (invoiceId && !invoiceId.includes('/')) {
|
||||
placeholderQueue.push(item)
|
||||
}
|
||||
if (!invoiceId) continue
|
||||
const bucket = exactMatchBuckets.get(invoiceId) || []
|
||||
bucket.push(item)
|
||||
exactMatchBuckets.set(invoiceId, bucket)
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const exactBucket = exactMatchBuckets.get(file.name) || []
|
||||
const nextExactMatch = exactBucket.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
|
||||
const fallbackMatch = placeholderQueue.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
|
||||
const targetItem = nextExactMatch || fallbackMatch
|
||||
const targetItemId = String(targetItem?.id || '').trim()
|
||||
if (!targetItemId) {
|
||||
continue
|
||||
}
|
||||
|
||||
usedItemIds.add(targetItemId)
|
||||
await uploadExpenseClaimItemAttachment(normalizedClaimId, targetItemId, file)
|
||||
}
|
||||
|
||||
await restorePersistedDraftAttachmentPreviews(normalizedClaimId, { force: true })
|
||||
}
|
||||
|
||||
function replaceMessage(messageId, nextMessage) {
|
||||
const index = messages.value.findIndex((item) => item.id === messageId)
|
||||
if (index === -1) {
|
||||
@@ -2471,6 +2809,9 @@ export default {
|
||||
|
||||
const mergeResult = mergeFilesWithLimit(attachedFiles.value, files, MAX_ATTACHMENTS)
|
||||
attachedFiles.value = mergeResult.files
|
||||
if (fileInputMode.value === 'composer-continue' && files.length) {
|
||||
composerUploadIntent.value = 'continue_existing'
|
||||
}
|
||||
if (mergeResult.overflowCount > 0) {
|
||||
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
|
||||
}
|
||||
@@ -2495,16 +2836,45 @@ export default {
|
||||
if (attachedFiles.value.length <= VISIBLE_ATTACHMENT_CHIPS) {
|
||||
composerFilesExpanded.value = false
|
||||
}
|
||||
if (!attachedFiles.value.length) {
|
||||
composerUploadIntent.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function clearAttachedFiles() {
|
||||
attachedFiles.value = []
|
||||
composerFilesExpanded.value = false
|
||||
composerUploadIntent.value = ''
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function closeUploadDecisionDialog() {
|
||||
if (submitting.value || reviewActionBusy.value) return
|
||||
uploadDecisionDialogOpen.value = false
|
||||
}
|
||||
|
||||
async function continueExistingUpload() {
|
||||
if (submitting.value || reviewActionBusy.value) return
|
||||
uploadDecisionDialogOpen.value = false
|
||||
composerUploadIntent.value = 'continue_existing'
|
||||
await submitComposer({
|
||||
uploadDisposition: 'continue_existing',
|
||||
skipUploadDecisionPrompt: true
|
||||
})
|
||||
}
|
||||
|
||||
async function createNewUploadDocument() {
|
||||
if (submitting.value || reviewActionBusy.value) return
|
||||
uploadDecisionDialogOpen.value = false
|
||||
composerUploadIntent.value = ''
|
||||
await submitComposer({
|
||||
uploadDisposition: 'new_document',
|
||||
skipUploadDecisionPrompt: true
|
||||
})
|
||||
}
|
||||
|
||||
async function runShortcut(shortcut) {
|
||||
if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) {
|
||||
await switchSessionType(shortcut.targetSessionType)
|
||||
@@ -2611,6 +2981,20 @@ export default {
|
||||
expense_type: String(reviewInlineForm.value.expense_type || '').trim()
|
||||
}
|
||||
|
||||
if (
|
||||
activeEditorKey === 'scene' &&
|
||||
nextForm.scene_label === REVIEW_SCENE_OTHER_OPTION
|
||||
) {
|
||||
nextForm.reason_value = String(reviewInlineForm.value.reason_value || '').trim()
|
||||
if (!nextForm.reason_value) {
|
||||
setInlineReviewFieldError('scene', '请选择“其他场景”后,请补充具体事由')
|
||||
reviewInlineForm.value = nextForm
|
||||
return false
|
||||
}
|
||||
} else if (activeEditorKey === 'scene') {
|
||||
nextForm.reason_value = nextForm.scene_label
|
||||
}
|
||||
|
||||
if (activeEditorKey === 'occurred_date' && nextForm.occurred_date && !isValidIsoDateString(nextForm.occurred_date)) {
|
||||
setInlineReviewFieldError('occurred_date', `请输入正确的时间格式:${DATE_INPUT_FORMAT}`)
|
||||
return false
|
||||
@@ -2635,12 +3019,19 @@ export default {
|
||||
}
|
||||
|
||||
function selectInlineScene(scene) {
|
||||
const normalizedScene = String(scene || '').trim()
|
||||
reviewInlineForm.value = {
|
||||
...reviewInlineForm.value,
|
||||
scene_label: String(scene || '').trim(),
|
||||
reason_value: String(scene || '').trim()
|
||||
scene_label: normalizedScene,
|
||||
reason_value:
|
||||
normalizedScene === REVIEW_SCENE_OTHER_OPTION
|
||||
? ''
|
||||
: normalizedScene
|
||||
}
|
||||
clearInlineReviewFieldError('scene')
|
||||
if (normalizedScene !== REVIEW_SCENE_OTHER_OPTION) {
|
||||
reviewInlineEditorKey.value = ''
|
||||
}
|
||||
reviewInlineEditorKey.value = ''
|
||||
}
|
||||
|
||||
function selectReviewCategory(option) {
|
||||
@@ -2671,7 +3062,8 @@ export default {
|
||||
if (!normalized || submitting.value || reviewActionBusy.value) return
|
||||
submitComposer({
|
||||
rawText: `查看报销草稿 ${normalized} 的当前信息`,
|
||||
userText: `查看草稿 ${normalized}`
|
||||
userText: `查看草稿 ${normalized}`,
|
||||
systemGenerated: true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2679,7 +3071,8 @@ export default {
|
||||
if (!activeReviewPayload.value || submitting.value || reviewActionBusy.value) return
|
||||
submitComposer({
|
||||
rawText: '请解释一下当前这笔报销的合规风险和待补充项。',
|
||||
userText: '查看全部风险项'
|
||||
userText: '查看全部风险项',
|
||||
systemGenerated: true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2702,10 +3095,8 @@ export default {
|
||||
|
||||
function closeDocumentPreview() {
|
||||
documentPreviewDialog.value = {
|
||||
open: false,
|
||||
filename: '',
|
||||
kind: 'file',
|
||||
url: ''
|
||||
...documentPreviewDialog.value,
|
||||
open: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2804,6 +3195,7 @@ export default {
|
||||
),
|
||||
pendingText: '正在保存修改并刷新右侧核对信息...',
|
||||
files: reviewInlinePendingFiles.value,
|
||||
systemGenerated: true,
|
||||
extraContext: {
|
||||
review_action: 'edit_review',
|
||||
review_form_values: buildReviewFormValues(fields),
|
||||
@@ -2861,6 +3253,10 @@ export default {
|
||||
if (sessionSwitchBusy.value) return null
|
||||
|
||||
const rawText = String(options.rawText ?? composerDraft.value).trim()
|
||||
const systemGenerated = Boolean(options.systemGenerated)
|
||||
const resolvedUploadDisposition =
|
||||
String(options.uploadDisposition || '').trim() ||
|
||||
(composerUploadIntent.value === 'continue_existing' ? 'continue_existing' : '')
|
||||
const normalizedFiles = isKnowledgeSession.value ? [] : Array.from(options.files ?? attachedFiles.value)
|
||||
const fileMergeResult = mergeFilesWithLimit([], normalizedFiles, MAX_ATTACHMENTS)
|
||||
const files = fileMergeResult.files
|
||||
@@ -2869,6 +3265,25 @@ export default {
|
||||
}
|
||||
if (!rawText && !files.length) return
|
||||
|
||||
const extraContext = options.extraContext && typeof options.extraContext === 'object'
|
||||
? { ...options.extraContext }
|
||||
: {}
|
||||
const reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value)
|
||||
const hasExistingDocumentEvent =
|
||||
Boolean(String(draftClaimId.value || '').trim()) || reviewAttachmentNames.length > 0
|
||||
|
||||
if (
|
||||
!isKnowledgeSession.value &&
|
||||
files.length &&
|
||||
hasExistingDocumentEvent &&
|
||||
!resolvedUploadDisposition &&
|
||||
!options.skipUploadDecisionPrompt &&
|
||||
!String(extraContext.review_action || '').trim()
|
||||
) {
|
||||
uploadDecisionDialogOpen.value = true
|
||||
return null
|
||||
}
|
||||
|
||||
const fileNames = files.map((file) => file.name)
|
||||
const filePreviews = buildFilePreviews(files, previewRegistry)
|
||||
rememberFilePreviews(filePreviews)
|
||||
@@ -2877,10 +3292,11 @@ export default {
|
||||
rawText ||
|
||||
(isKnowledgeSession.value
|
||||
? `我上传了 ${fileNames.length} 份附件,请帮我回答相关财务问题。`
|
||||
: `我上传了 ${fileNames.length} 份票据,请帮我识别并给出报销建议。`)
|
||||
const extraContext = options.extraContext && typeof options.extraContext === 'object'
|
||||
? options.extraContext
|
||||
: {}
|
||||
: resolvedUploadDisposition === 'continue_existing'
|
||||
? `继续上传 ${fileNames.length} 份票据,并归集到当前单据。`
|
||||
: resolvedUploadDisposition === 'new_document'
|
||||
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
|
||||
: `我上传了 ${fileNames.length} 份票据,请帮我识别并给出报销建议。`)
|
||||
|
||||
// 只有在非静默模式下才添加用户消息
|
||||
if (!options.skipUserMessage) {
|
||||
@@ -2928,7 +3344,23 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
const backendMessage = buildBackendMessage(rawText, fileNames, ocrSummary)
|
||||
let effectiveFileNames = [...fileNames]
|
||||
let effectiveOcrDocuments = [...ocrDocuments]
|
||||
let effectiveOcrSummary = ocrSummary
|
||||
|
||||
if (resolvedUploadDisposition === 'continue_existing') {
|
||||
extraContext.review_action = 'link_to_existing_draft'
|
||||
effectiveFileNames = mergeUploadAttachmentNames(reviewAttachmentNames, fileNames)
|
||||
effectiveOcrDocuments = mergeUploadOcrDocuments(
|
||||
buildOcrDocumentsFromReviewPayload(activeReviewPayload.value),
|
||||
ocrDocuments
|
||||
)
|
||||
effectiveOcrSummary = buildOcrSummaryFromDocuments(effectiveOcrDocuments)
|
||||
} else if (resolvedUploadDisposition === 'new_document') {
|
||||
extraContext.review_action = 'create_new_claim_from_documents'
|
||||
}
|
||||
|
||||
const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary)
|
||||
const payload = await runOrchestrator({
|
||||
source: 'user_message',
|
||||
user_id: user.username || user.name || 'anonymous',
|
||||
@@ -2942,11 +3374,12 @@ export default {
|
||||
...buildClientTimeContext(),
|
||||
session_type: activeSessionType.value,
|
||||
entry_source: props.entrySource,
|
||||
attachment_names: fileNames,
|
||||
attachment_count: fileNames.length,
|
||||
user_input_text: systemGenerated ? '' : rawText,
|
||||
attachment_names: effectiveFileNames,
|
||||
attachment_count: effectiveFileNames.length,
|
||||
draft_claim_id: isKnowledgeSession.value ? undefined : draftClaimId.value || undefined,
|
||||
ocr_summary: ocrSummary,
|
||||
ocr_documents: ocrDocuments,
|
||||
ocr_summary: effectiveOcrSummary,
|
||||
ocr_documents: effectiveOcrDocuments,
|
||||
...(linkedRequest.value && !isKnowledgeSession.value ? { request_context: linkedRequest.value } : {}),
|
||||
...extraContext
|
||||
}
|
||||
@@ -2959,10 +3392,20 @@ export default {
|
||||
? ''
|
||||
: String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value
|
||||
|
||||
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
|
||||
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
|
||||
try {
|
||||
await syncComposerFilesToDraft(resolvedDraftClaimId, files)
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist composer attachments to draft claim:', error)
|
||||
toast(error?.message || '票据已识别,但附件持久化失败,请重试上传。')
|
||||
}
|
||||
}
|
||||
|
||||
replaceMessage(
|
||||
pendingMessage.id,
|
||||
createMessage('assistant', payload?.result?.answer || payload?.result?.message || '智能体已完成处理。', [], {
|
||||
meta: buildMessageMeta(payload, fileNames),
|
||||
meta: buildMessageMeta(payload, effectiveFileNames),
|
||||
citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [],
|
||||
suggestedActions: Array.isArray(payload?.result?.suggested_actions)
|
||||
? payload.result.suggested_actions
|
||||
@@ -2975,7 +3418,7 @@ export default {
|
||||
)
|
||||
currentInsight.value = buildAgentInsight(
|
||||
payload,
|
||||
fileNames,
|
||||
effectiveFileNames,
|
||||
mergeFilePreviews(filePreviews, ocrFilePreviews)
|
||||
)
|
||||
} catch (error) {
|
||||
@@ -2993,6 +3436,7 @@ export default {
|
||||
currentInsight.value = buildErrorInsight(error, fileNames)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
composerUploadIntent.value = ''
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
@@ -3039,6 +3483,7 @@ export default {
|
||||
rawText: buildReviewCorrectionMessage(fields),
|
||||
userText: '我已修改识别信息,请按最新内容更新。',
|
||||
pendingText: '正在根据修改内容重新识别...',
|
||||
systemGenerated: true,
|
||||
extraContext: {
|
||||
review_action: 'edit_review',
|
||||
review_form_values: buildReviewFormValues(fields)
|
||||
@@ -3064,7 +3509,7 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (!['save_draft', 'next_step'].includes(actionType)) {
|
||||
if (!['save_draft', 'next_step', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(actionType)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3072,13 +3517,11 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
// 保存草稿直接处理,不显示对话
|
||||
if (actionType === 'save_draft') {
|
||||
await handleSaveDraftDirectly(message)
|
||||
if (['save_draft', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(actionType)) {
|
||||
await handleSaveDraftDirectly(message, actionType)
|
||||
return
|
||||
}
|
||||
|
||||
// 下一步继续使用对话流程
|
||||
reviewActionBusy.value = true
|
||||
try {
|
||||
const baseFields = reviewInlineBaseFields.value.length
|
||||
@@ -3109,6 +3552,7 @@ export default {
|
||||
userText: reviewChangedUserText || '我确认当前识别结果,继续下一步。',
|
||||
files: reviewInlinePendingFiles.value,
|
||||
pendingText: '正在进入下一步...',
|
||||
systemGenerated: true,
|
||||
extraContext: {
|
||||
review_action: actionType,
|
||||
review_form_values: buildReviewFormValues(fields),
|
||||
@@ -3133,12 +3577,48 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:直接保存草稿的函数,不显示对话
|
||||
async function handleSaveDraftDirectly(message) {
|
||||
async function handleSaveDraftDirectly(message, actionType = 'save_draft') {
|
||||
reviewActionBusy.value = true
|
||||
let savingMessage = null
|
||||
|
||||
// 记录当前消息数量,用于后续移除 submitComposer 添加的消息
|
||||
const messageCountBefore = messages.value.length
|
||||
const actionConfig = {
|
||||
save_draft: {
|
||||
rawText: '请按当前已识别信息先保存草稿,缺失字段后续再补。',
|
||||
pendingText: '正在保存当前草稿...',
|
||||
helperText: '正在保存草稿...',
|
||||
successMeta: '草稿已保存',
|
||||
successMessage: (payload) => {
|
||||
const claimNo = String(payload?.result?.draft_payload?.claim_no || '').trim()
|
||||
return claimNo ? `草稿已保存,单号:${claimNo}` : '草稿保存完成'
|
||||
}
|
||||
},
|
||||
link_to_existing_draft: {
|
||||
rawText: '请把当前上传的票据合并到现有报销草稿中。',
|
||||
pendingText: '正在关联到现有草稿...',
|
||||
helperText: '正在关联现有草稿...',
|
||||
successMeta: '已关联草稿',
|
||||
successMessage: (payload) => {
|
||||
const claimNo = String(payload?.result?.draft_payload?.claim_no || '').trim()
|
||||
return claimNo ? `已关联到草稿 ${claimNo}` : '已关联到现有草稿'
|
||||
}
|
||||
},
|
||||
create_new_claim_from_documents: {
|
||||
rawText: '请基于当前上传的多张票据,单独建立一张新的报销草稿。',
|
||||
pendingText: '正在建立新的报销草稿...',
|
||||
helperText: '正在建立新报销草稿...',
|
||||
successMeta: '新草稿已建立',
|
||||
successMessage: (payload) => {
|
||||
const claimNo = String(payload?.result?.draft_payload?.claim_no || '').trim()
|
||||
return claimNo ? `已建立新草稿 ${claimNo}` : '已建立新的报销草稿'
|
||||
}
|
||||
}
|
||||
}[actionType] || {
|
||||
rawText: '请按当前已识别信息先保存草稿,缺失字段后续再补。',
|
||||
pendingText: '正在保存当前草稿...',
|
||||
helperText: '正在保存草稿...',
|
||||
successMeta: '草稿已保存',
|
||||
successMessage: () => '草稿保存完成'
|
||||
}
|
||||
|
||||
try {
|
||||
const baseFields = reviewInlineBaseFields.value.length
|
||||
@@ -3146,36 +3626,33 @@ export default {
|
||||
: cloneReviewEditFields(message?.reviewPayload?.edit_fields)
|
||||
const fields = mergeInlineReviewFields(baseFields, reviewInlineForm.value)
|
||||
|
||||
// 先显示一个临时的"正在保存"消息
|
||||
const savingMessage = createMessage('assistant', '正在保存草稿...', [], { meta: ['处理中'] })
|
||||
savingMessage = createMessage('assistant', actionConfig.helperText, [], { meta: ['处理中'] })
|
||||
messages.value.push(savingMessage)
|
||||
nextTick(scrollToBottom)
|
||||
|
||||
// 调用保存逻辑,不通过对话
|
||||
const payload = await submitComposer({
|
||||
rawText: '请按当前已识别信息先保存草稿,缺失字段后续再补。',
|
||||
userText: '', // 不显示用户消息
|
||||
skipUserMessage: true, // 跳过添加用户消息
|
||||
rawText: actionConfig.rawText,
|
||||
userText: '',
|
||||
skipUserMessage: true,
|
||||
files: reviewInlinePendingFiles.value,
|
||||
pendingText: '正在保存当前草稿...',
|
||||
pendingText: actionConfig.pendingText,
|
||||
systemGenerated: true,
|
||||
extraContext: {
|
||||
review_action: 'save_draft',
|
||||
review_action: actionType,
|
||||
review_form_values: buildReviewFormValues(fields),
|
||||
review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value)
|
||||
}
|
||||
})
|
||||
|
||||
// 移除临时消息
|
||||
const tempIndex = messages.value.findIndex((msg) => msg === savingMessage)
|
||||
if (tempIndex !== -1) {
|
||||
messages.value.splice(tempIndex, 1)
|
||||
}
|
||||
|
||||
if (payload?.result?.draft_payload?.claim_no) {
|
||||
// 显示保存成功的消息
|
||||
messages.value.push(
|
||||
createMessage('assistant', `✅ 草稿已保存,单号:${payload.result.draft_payload.claim_no}`, [], {
|
||||
meta: ['草稿已保存']
|
||||
createMessage('assistant', actionConfig.successMessage(payload), [], {
|
||||
meta: [actionConfig.successMeta]
|
||||
})
|
||||
)
|
||||
|
||||
@@ -3190,19 +3667,18 @@ export default {
|
||||
})
|
||||
)
|
||||
} else {
|
||||
// 没有返回草稿信息,可能保存失败
|
||||
messages.value.push(createMessage('assistant', '草稿保存完成', [], { meta: ['草稿已保存'] }))
|
||||
messages.value.push(createMessage('assistant', actionConfig.successMessage(payload), [], { meta: [actionConfig.successMeta] }))
|
||||
}
|
||||
|
||||
nextTick(scrollToBottom)
|
||||
} catch (error) {
|
||||
// 移除临时消息
|
||||
const tempIndex = messages.value.findIndex((msg) => msg === savingMessage)
|
||||
if (tempIndex !== -1) {
|
||||
messages.value.splice(tempIndex, 1)
|
||||
if (savingMessage) {
|
||||
const tempIndex = messages.value.findIndex((msg) => msg === savingMessage)
|
||||
if (tempIndex !== -1) {
|
||||
messages.value.splice(tempIndex, 1)
|
||||
}
|
||||
}
|
||||
// 显示错误消息
|
||||
messages.value.push(createMessage('assistant', '❌ 保存草稿失败,请稍后重试。', [], { meta: ['错误'] }))
|
||||
messages.value.push(createMessage('assistant', '保存失败,请稍后重试。', [], { meta: ['错误'] }))
|
||||
nextTick(scrollToBottom)
|
||||
} finally {
|
||||
reviewActionBusy.value = false
|
||||
@@ -3265,6 +3741,7 @@ export default {
|
||||
reviewOtherCategoryOpen,
|
||||
reviewInlinePendingFiles,
|
||||
DATE_INPUT_FORMAT,
|
||||
REVIEW_SCENE_OTHER_OPTION,
|
||||
REVIEW_SCENE_OPTIONS,
|
||||
REVIEW_OTHER_CATEGORY_OPTIONS,
|
||||
reviewPanelConfidence,
|
||||
@@ -3281,6 +3758,7 @@ export default {
|
||||
reviewHasUnsavedChanges,
|
||||
reviewCancelDialogOpen,
|
||||
reviewEditDialogOpen,
|
||||
uploadDecisionDialogOpen,
|
||||
deleteSessionDialogOpen,
|
||||
reviewActionBusy,
|
||||
deleteSessionBusy,
|
||||
@@ -3300,6 +3778,7 @@ export default {
|
||||
buildReviewTodoSectionMeta,
|
||||
buildReviewAlertChips,
|
||||
buildReviewTodoItems,
|
||||
resolveReviewSubmitActions,
|
||||
resolveReviewPrimaryAction,
|
||||
resolveReviewEditAction,
|
||||
buildReviewPrimaryButtonLabel,
|
||||
@@ -3334,6 +3813,9 @@ export default {
|
||||
openDeleteSessionDialog,
|
||||
closeDeleteSessionDialog,
|
||||
confirmDeleteCurrentSession,
|
||||
closeUploadDecisionDialog,
|
||||
continueExistingUpload,
|
||||
createNewUploadDocument,
|
||||
openInlineReviewEditor,
|
||||
closeInlineReviewEditor,
|
||||
commitInlineReviewEditor,
|
||||
|
||||
Reference in New Issue
Block a user