style(web): 更新差旅报销创建页面样式和业务脚本,增强前端交互和状态管理

This commit is contained in:
caoxiaozhu
2026-05-14 15:43:10 +00:00
parent 98f68c47b0
commit 7209c75ad8
5 changed files with 651 additions and 72 deletions

View File

@@ -3102,6 +3102,25 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.review-upload-decision-modal {
display: grid;
gap: 18px;
}
.review-upload-decision-copy {
display: grid;
gap: 10px;
}
.review-upload-decision-actions {
justify-content: stretch;
}
.review-upload-decision-actions .primary-dialog-btn,
.review-upload-decision-actions .secondary-dialog-btn {
flex: 1 1 168px;
}
.review-edit-modal { .review-edit-modal {
max-height: min(860px, calc(100vh - 48px)); max-height: min(860px, calc(100vh - 48px));
display: grid; display: grid;
@@ -3503,6 +3522,10 @@
justify-content: stretch; justify-content: stretch;
} }
.review-upload-decision-actions {
width: 100%;
}
.primary-dialog-btn, .primary-dialog-btn,
.secondary-dialog-btn, .secondary-dialog-btn,
.danger-dialog-btn { .danger-dialog-btn {

View File

@@ -3,9 +3,13 @@ import { useRoute, useRouter } from 'vue-router'
import { useNavigation, navItems } from './useNavigation.js' import { useNavigation, navItems } from './useNavigation.js'
import { useRequests } from './useRequests.js' import { useRequests } from './useRequests.js'
import { useSystemState } from './useSystemState.js'
import { useToast } from './useToast.js' import { useToast } from './useToast.js'
import { fetchLatestConversation } from '../services/orchestrator.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js' import { normalizeRequestForUi } from '../utils/requestViewModel.js'
const SESSION_TYPE_EXPENSE = 'expense'
function isPlaceholderValue(value) { function isPlaceholderValue(value) {
const text = String(value || '').trim() const text = String(value || '').trim()
if (!text) { if (!text) {
@@ -101,6 +105,7 @@ export function useAppShell() {
rejectRequest, rejectRequest,
reload: reloadRequests reload: reloadRequests
} = useRequests() } = useRequests()
const { currentUser } = useSystemState()
const { toast } = useToast() const { toast } = useToast()
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' }) const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
@@ -179,7 +184,34 @@ export function useAppShell() {
smartEntrySessionId.value += 1 smartEntrySessionId.value += 1
} }
function openSmartEntry(payload = {}) { function resolveCurrentUserId() {
const user = currentUser.value || {}
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
}
async function resolveSmartEntryConversation(payload = {}) {
if (payload.conversation) {
return payload.conversation
}
if (!payload.restoreLatestConversation) {
return null
}
try {
const latestPayload = await fetchLatestConversation(resolveCurrentUserId(), SESSION_TYPE_EXPENSE, {
preferRecoverable: true
})
return latestPayload?.found ? latestPayload.conversation || null : null
} catch (error) {
console.warn('Failed to restore latest expense conversation for smart entry:', error)
toast(error?.message || '恢复最近报销会话失败,请稍后重试。')
return null
}
}
async function openSmartEntry(payload = {}) {
const conversation = await resolveSmartEntryConversation(payload)
smartEntryOpen.value = true smartEntryOpen.value = true
smartEntryContext.value = { smartEntryContext.value = {
@@ -187,7 +219,7 @@ export function useAppShell() {
source: payload.source ?? 'workbench', source: payload.source ?? 'workbench',
request: payload.request ?? selectedRequest.value, request: payload.request ?? selectedRequest.value,
files: Array.isArray(payload.files) ? payload.files : [], files: Array.isArray(payload.files) ? payload.files : [],
conversation: payload.conversation ?? null conversation
} }
smartEntrySessionId.value += 1 smartEntrySessionId.value += 1
} }

View File

@@ -264,16 +264,17 @@
</div> </div>
</details> </details>
<div v-if="resolveReviewPrimaryAction(message.reviewPayload) || message.draftPayload?.claim_no" class="review-footer-actions"> <div v-if="resolveReviewSubmitActions(message.reviewPayload).length || message.draftPayload?.claim_no" class="review-footer-actions">
<div class="review-footer-btn-row"> <div class="review-footer-btn-row">
<button <button
v-if="resolveReviewPrimaryAction(message.reviewPayload)" v-for="action in resolveReviewSubmitActions(message.reviewPayload)"
:key="`${message.id}-${action.action_type}`"
type="button" type="button"
class="review-footer-btn primary" :class="['review-footer-btn', action.emphasis === 'primary' ? 'primary' : '']"
:disabled="reviewActionBusy" :disabled="reviewActionBusy"
@click="handleReviewAction(message, resolveReviewPrimaryAction(message.reviewPayload))" @click="handleReviewAction(message, action)"
> >
{{ buildReviewPrimaryButtonLabel(message.reviewPayload, message.draftPayload) }} {{ action.label || buildReviewPrimaryButtonLabel(message.reviewPayload, message.draftPayload) }}
</button> </button>
<button <button
@@ -290,7 +291,7 @@
type="button" type="button"
class="review-footer-btn" class="review-footer-btn"
:disabled="submitting || reviewActionBusy" :disabled="submitting || reviewActionBusy"
@click="triggerFileUpload" @click="triggerFileUpload(message.reviewPayload.document_cards?.length ? 'composer-continue' : 'composer')"
> >
{{ message.reviewPayload.document_cards?.length ? '继续上传票据' : '上传票据' }} {{ message.reviewPayload.document_cards?.length ? '继续上传票据' : '上传票据' }}
</button> </button>
@@ -599,6 +600,18 @@
> >
{{ scene }} {{ scene }}
</button> </button>
<input
v-if="reviewInlineForm.scene_label === REVIEW_SCENE_OTHER_OPTION"
v-model="reviewInlineForm.reason_value"
class="review-inline-input review-inline-select-custom"
:class="{ invalid: Boolean(reviewInlineErrors[item.key]) }"
type="text"
placeholder="请输入具体事由"
@click.stop
@input="clearInlineReviewFieldError(item.key)"
@blur="commitInlineReviewEditor"
@keydown.enter.prevent="commitInlineReviewEditor"
/>
</div> </div>
</template> </template>
<strong v-else :title="item.value">{{ item.value }}</strong> <strong v-else :title="item.value">{{ item.value }}</strong>
@@ -905,6 +918,30 @@
@confirm="confirmCancelReview" @confirm="confirmCancelReview"
/> />
<Transition name="assistant-modal">
<div v-if="uploadDecisionDialogOpen" class="assistant-overlay review-overlay">
<section class="review-confirm-modal review-upload-decision-modal">
<div class="review-upload-decision-copy">
<span class="assistant-badge">上传票据</span>
<h3>检测到你已有单据事件</h3>
<p>这次新上传的附件需要先确认处理方式你可以继续归集到上一笔单据也可以重新开启一张新单据</p>
</div>
<div class="review-confirm-actions review-upload-decision-actions">
<button type="button" class="primary-dialog-btn" :disabled="submitting || reviewActionBusy" @click="continueExistingUpload">
继续
</button>
<button type="button" class="secondary-dialog-btn" :disabled="submitting || reviewActionBusy" @click="createNewUploadDocument">
新单据
</button>
<button type="button" class="secondary-dialog-btn" :disabled="submitting || reviewActionBusy" @click="closeUploadDecisionDialog">
取消
</button>
</div>
</section>
</div>
</Transition>
<Transition name="assistant-modal"> <Transition name="assistant-modal">
<div v-if="documentPreviewDialog.open" class="assistant-overlay review-overlay"> <div v-if="documentPreviewDialog.open" class="assistant-overlay review-overlay">
<section class="review-preview-modal"> <section class="review-preview-modal">

View File

@@ -6,6 +6,12 @@ import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js' import { useToast } from '../../composables/useToast.js'
import { recognizeOcrFiles } from '../../services/ocr.js' import { recognizeOcrFiles } from '../../services/ocr.js'
import { clearUserConversations, deleteConversation, fetchLatestConversation, runOrchestrator } from '../../services/orchestrator.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 aiAvatar = '/assets/header.png'
const userAvatar = '/assets/person.png' const userAvatar = '/assets/person.png'
@@ -155,7 +161,8 @@ const REVIEW_OTHER_CATEGORY_OPTIONS = [
{ key: 'other', label: '其他费用' } { 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 DATE_INPUT_FORMAT = 'YYYY-MM-DD'
const MAX_ATTACHMENTS = 10 const MAX_ATTACHMENTS = 10
const MAX_OCR_DOCUMENTS = 10 const MAX_OCR_DOCUMENTS = 10
@@ -332,6 +339,9 @@ function normalizeOcrDocuments(payload) {
document_type_label: String(item.document_type_label || '').trim(), document_type_label: String(item.document_type_label || '').trim(),
scene_code: String(item.scene_code || 'other').trim() || 'other', scene_code: String(item.scene_code || 'other').trim() || 'other',
scene_label: String(item.scene_label || '').trim(), 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) document_fields: Array.isArray(item.document_fields)
? item.document_fields ? item.document_fields
.map((field) => ({ .map((field) => ({
@@ -346,11 +356,129 @@ function normalizeOcrDocuments(payload) {
} }
function buildOcrSummary(payload) { function buildOcrSummary(payload) {
const parts = normalizeOcrDocuments(payload) return buildOcrSummaryFromDocuments(normalizeOcrDocuments(payload))
.map((item) => `${item.filename}${item.summary || item.text}`) }
.filter(Boolean)
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) { function inferPreviewKind(file) {
@@ -457,11 +585,46 @@ function buildOcrFilePreviews(payload) {
.map((item) => ({ .map((item) => ({
filename: String(item?.filename || '').trim(), filename: String(item?.filename || '').trim(),
kind: String(item?.preview_kind || '').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) .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) { function extractReviewAttachmentNames(reviewPayload) {
const documentNames = Array.isArray(reviewPayload?.document_cards) const documentNames = Array.isArray(reviewPayload?.document_cards)
? reviewPayload.document_cards.map((item) => String(item?.filename || '').trim()).filter(Boolean) ? reviewPayload.document_cards.map((item) => String(item?.filename || '').trim()).filter(Boolean)
@@ -504,6 +667,8 @@ function buildReviewDocumentDrafts(reviewPayload) {
confidenceLabel: String(item.confidenceLabel || '').trim(), confidenceLabel: String(item.confidenceLabel || '').trim(),
documentTypeLabel: String(item.documentTypeLabel || '').trim(), documentTypeLabel: String(item.documentTypeLabel || '').trim(),
expenseTypeLabel: String(item.expenseTypeLabel || '').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] : [], warnings: Array.isArray(item.warnings) ? [...item.warnings] : [],
fields: Array.isArray(item.fields) fields: Array.isArray(item.fields)
? item.fields.map((field) => ({ ? item.fields.map((field) => ({
@@ -648,7 +813,11 @@ function buildInitialInsightFromConversation(conversation) {
const attachmentNames = Array.isArray(messageJson?.attachment_names) const attachmentNames = Array.isArray(messageJson?.attachment_names)
? messageJson.attachment_names.filter(Boolean) ? messageJson.attachment_names.filter(Boolean)
: [] : []
return buildAgentInsight(orchestratorPayload, attachmentNames, []) return buildAgentInsight(
orchestratorPayload,
attachmentNames,
buildReviewFilePreviewsFromReviewPayload(orchestratorPayload?.result?.review_payload)
)
} }
return null 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) { function resolveReviewEditAction(reviewPayload) {
return ( return (
(Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).find( (Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).find(
@@ -1339,6 +1515,12 @@ function buildReviewPrimaryButtonLabel(reviewPayload, draftPayload) {
if (action.action_type === 'next_step') { if (action.action_type === 'next_step') {
return '继续下一步' 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 || '确认' return action.label || '确认'
} }
@@ -1471,7 +1653,7 @@ function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineRevi
{ {
key: 'scene', key: 'scene',
label: '场景 / 事由', label: '场景 / 事由',
value: String(inlineState.scene_label || '').trim() || '待补充', value: String(inlineState.reason_value || inlineState.scene_label || '').trim() || '待补充',
icon: 'mdi mdi-silverware-fork-knife', icon: 'mdi mdi-silverware-fork-knife',
editor: 'select', editor: 'select',
modelKey: 'scene_label', modelKey: 'scene_label',
@@ -2003,6 +2185,7 @@ export default {
const conversationId = ref(initialSessionState.conversationId) const conversationId = ref(initialSessionState.conversationId)
const draftClaimId = ref(initialSessionState.draftClaimId) const draftClaimId = ref(initialSessionState.draftClaimId)
const previewRegistry = [] const previewRegistry = []
const restoredDraftPreviewClaims = new Set()
const reviewFilePreviews = ref(initialSessionState.reviewFilePreviews) const reviewFilePreviews = ref(initialSessionState.reviewFilePreviews)
const sessionSnapshots = ref({ const sessionSnapshots = ref({
[SESSION_TYPE_EXPENSE]: null, [SESSION_TYPE_EXPENSE]: null,
@@ -2012,6 +2195,7 @@ export default {
const currentInsight = ref(initialSessionState.currentInsight) const currentInsight = ref(initialSessionState.currentInsight)
const reviewCancelDialogOpen = ref(false) const reviewCancelDialogOpen = ref(false)
const reviewEditDialogOpen = ref(false) const reviewEditDialogOpen = ref(false)
const uploadDecisionDialogOpen = ref(false)
const deleteSessionDialogOpen = ref(false) const deleteSessionDialogOpen = ref(false)
const reviewActionBusy = ref(false) const reviewActionBusy = ref(false)
const deleteSessionBusy = ref(false) const deleteSessionBusy = ref(false)
@@ -2024,6 +2208,7 @@ export default {
const reviewInlineEditorKey = ref('') const reviewInlineEditorKey = ref('')
const reviewInlineErrors = ref({}) const reviewInlineErrors = ref({})
const reviewOtherCategoryOpen = ref(false) const reviewOtherCategoryOpen = ref(false)
const composerUploadIntent = ref(String(initialSessionState.composerUploadIntent || '').trim())
const reviewDocumentDrafts = ref([]) const reviewDocumentDrafts = ref([])
const reviewDocumentBaseDrafts = ref([]) const reviewDocumentBaseDrafts = ref([])
const activeReviewDocumentIndex = ref(0) const activeReviewDocumentIndex = ref(0)
@@ -2149,7 +2334,18 @@ export default {
const activeReviewDocument = computed(() => reviewDocumentDrafts.value[activeReviewDocumentIndex.value] ?? null) const activeReviewDocument = computed(() => reviewDocumentDrafts.value[activeReviewDocumentIndex.value] ?? null)
const activeReviewDocumentPreview = computed(() => const activeReviewDocumentPreview = computed(() =>
activeReviewDocument.value 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 : null
) )
const canPreviewActiveReviewDocument = computed(() => Boolean(activeReviewDocumentPreview.value?.url)) const canPreviewActiveReviewDocument = computed(() => Boolean(activeReviewDocumentPreview.value?.url))
@@ -2174,6 +2370,7 @@ export default {
const sessionType = resolveInitialSessionType(conversation) || fallbackSessionType const sessionType = resolveInitialSessionType(conversation) || fallbackSessionType
const restoredMessages = normalizeInitialConversationMessages(conversation) const restoredMessages = normalizeInitialConversationMessages(conversation)
const initialInsight = buildInitialInsightFromConversation(conversation) const initialInsight = buildInitialInsightFromConversation(conversation)
const restoredReviewFilePreviews = buildReviewFilePreviewsFromMessages(restoredMessages)
return { return {
sessionType, sessionType,
@@ -2183,10 +2380,11 @@ export default {
conversationId: resolveInitialConversationId(conversation), conversationId: resolveInitialConversationId(conversation),
draftClaimId: resolveInitialDraftClaimId(conversation), draftClaimId: resolveInitialDraftClaimId(conversation),
currentInsight: initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType), currentInsight: initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType),
reviewFilePreviews: [], reviewFilePreviews: restoredReviewFilePreviews,
composerDraft: '', composerDraft: '',
attachedFiles: [], attachedFiles: [],
composerFilesExpanded: false, composerFilesExpanded: false,
composerUploadIntent: '',
insightPanelCollapsed: false insightPanelCollapsed: false
} }
} }
@@ -2202,6 +2400,7 @@ export default {
composerDraft: '', composerDraft: '',
attachedFiles: [], attachedFiles: [],
composerFilesExpanded: false, composerFilesExpanded: false,
composerUploadIntent: '',
insightPanelCollapsed: false insightPanelCollapsed: false
} }
} }
@@ -2222,6 +2421,7 @@ export default {
composerDraft: composerDraft.value, composerDraft: composerDraft.value,
attachedFiles: attachedFiles.value, attachedFiles: attachedFiles.value,
composerFilesExpanded: composerFilesExpanded.value, composerFilesExpanded: composerFilesExpanded.value,
composerUploadIntent: composerUploadIntent.value,
insightPanelCollapsed: insightPanelCollapsed.value insightPanelCollapsed: insightPanelCollapsed.value
} }
} }
@@ -2239,7 +2439,9 @@ export default {
composerDraft.value = String(nextState.composerDraft || '') composerDraft.value = String(nextState.composerDraft || '')
attachedFiles.value = Array.isArray(nextState.attachedFiles) ? nextState.attachedFiles : [] attachedFiles.value = Array.isArray(nextState.attachedFiles) ? nextState.attachedFiles : []
composerFilesExpanded.value = Boolean(nextState.composerFilesExpanded) composerFilesExpanded.value = Boolean(nextState.composerFilesExpanded)
composerUploadIntent.value = String(nextState.composerUploadIntent || '').trim()
insightPanelCollapsed.value = Boolean(nextState.insightPanelCollapsed) insightPanelCollapsed.value = Boolean(nextState.insightPanelCollapsed)
uploadDecisionDialogOpen.value = false
nextTick(() => { nextTick(() => {
adjustComposerTextareaHeight() adjustComposerTextareaHeight()
scrollToBottom() scrollToBottom()
@@ -2247,7 +2449,9 @@ export default {
} }
async function loadLatestSessionState(targetSessionType) { 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) { if (payload?.found && payload.conversation) {
return buildConversationSessionState(payload.conversation, targetSessionType) return buildConversationSessionState(payload.conversation, targetSessionType)
} }
@@ -2307,6 +2511,7 @@ export default {
watch( watch(
() => activeReviewPayload.value, () => activeReviewPayload.value,
(payload) => { (payload) => {
rememberFilePreviews(buildReviewFilePreviewsFromReviewPayload(payload))
const nextInlineState = buildInlineReviewState(payload) const nextInlineState = buildInlineReviewState(payload)
reviewInlineForm.value = { ...nextInlineState } reviewInlineForm.value = { ...nextInlineState }
reviewInlineBaseForm.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(() => { onMounted(() => {
void clearKnowledgeSessionOnEntry() void clearKnowledgeSessionOnEntry()
currentInsight.value = currentInsight.value || buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value) currentInsight.value = currentInsight.value || buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value)
@@ -2420,6 +2636,128 @@ export default {
reviewFilePreviews.value = mergeFilePreviews(reviewFilePreviews.value, filePreviews) 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) { function replaceMessage(messageId, nextMessage) {
const index = messages.value.findIndex((item) => item.id === messageId) const index = messages.value.findIndex((item) => item.id === messageId)
if (index === -1) { if (index === -1) {
@@ -2471,6 +2809,9 @@ export default {
const mergeResult = mergeFilesWithLimit(attachedFiles.value, files, MAX_ATTACHMENTS) const mergeResult = mergeFilesWithLimit(attachedFiles.value, files, MAX_ATTACHMENTS)
attachedFiles.value = mergeResult.files attachedFiles.value = mergeResult.files
if (fileInputMode.value === 'composer-continue' && files.length) {
composerUploadIntent.value = 'continue_existing'
}
if (mergeResult.overflowCount > 0) { if (mergeResult.overflowCount > 0) {
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`) toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
} }
@@ -2495,16 +2836,45 @@ export default {
if (attachedFiles.value.length <= VISIBLE_ATTACHMENT_CHIPS) { if (attachedFiles.value.length <= VISIBLE_ATTACHMENT_CHIPS) {
composerFilesExpanded.value = false composerFilesExpanded.value = false
} }
if (!attachedFiles.value.length) {
composerUploadIntent.value = ''
}
} }
function clearAttachedFiles() { function clearAttachedFiles() {
attachedFiles.value = [] attachedFiles.value = []
composerFilesExpanded.value = false composerFilesExpanded.value = false
composerUploadIntent.value = ''
if (fileInputRef.value) { if (fileInputRef.value) {
fileInputRef.value.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) { async function runShortcut(shortcut) {
if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) { if (shortcut?.action === 'switch_view' && shortcut?.targetSessionType) {
await switchSessionType(shortcut.targetSessionType) await switchSessionType(shortcut.targetSessionType)
@@ -2611,6 +2981,20 @@ export default {
expense_type: String(reviewInlineForm.value.expense_type || '').trim() 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)) { if (activeEditorKey === 'occurred_date' && nextForm.occurred_date && !isValidIsoDateString(nextForm.occurred_date)) {
setInlineReviewFieldError('occurred_date', `请输入正确的时间格式:${DATE_INPUT_FORMAT}`) setInlineReviewFieldError('occurred_date', `请输入正确的时间格式:${DATE_INPUT_FORMAT}`)
return false return false
@@ -2635,13 +3019,20 @@ export default {
} }
function selectInlineScene(scene) { function selectInlineScene(scene) {
const normalizedScene = String(scene || '').trim()
reviewInlineForm.value = { reviewInlineForm.value = {
...reviewInlineForm.value, ...reviewInlineForm.value,
scene_label: String(scene || '').trim(), scene_label: normalizedScene,
reason_value: String(scene || '').trim() reason_value:
normalizedScene === REVIEW_SCENE_OTHER_OPTION
? ''
: normalizedScene
} }
clearInlineReviewFieldError('scene')
if (normalizedScene !== REVIEW_SCENE_OTHER_OPTION) {
reviewInlineEditorKey.value = '' reviewInlineEditorKey.value = ''
} }
}
function selectReviewCategory(option) { function selectReviewCategory(option) {
if (!option) return if (!option) return
@@ -2671,7 +3062,8 @@ export default {
if (!normalized || submitting.value || reviewActionBusy.value) return if (!normalized || submitting.value || reviewActionBusy.value) return
submitComposer({ submitComposer({
rawText: `查看报销草稿 ${normalized} 的当前信息`, rawText: `查看报销草稿 ${normalized} 的当前信息`,
userText: `查看草稿 ${normalized}` userText: `查看草稿 ${normalized}`,
systemGenerated: true
}) })
} }
@@ -2679,7 +3071,8 @@ export default {
if (!activeReviewPayload.value || submitting.value || reviewActionBusy.value) return if (!activeReviewPayload.value || submitting.value || reviewActionBusy.value) return
submitComposer({ submitComposer({
rawText: '请解释一下当前这笔报销的合规风险和待补充项。', rawText: '请解释一下当前这笔报销的合规风险和待补充项。',
userText: '查看全部风险项' userText: '查看全部风险项',
systemGenerated: true
}) })
} }
@@ -2702,10 +3095,8 @@ export default {
function closeDocumentPreview() { function closeDocumentPreview() {
documentPreviewDialog.value = { documentPreviewDialog.value = {
open: false, ...documentPreviewDialog.value,
filename: '', open: false
kind: 'file',
url: ''
} }
} }
@@ -2804,6 +3195,7 @@ export default {
), ),
pendingText: '正在保存修改并刷新右侧核对信息...', pendingText: '正在保存修改并刷新右侧核对信息...',
files: reviewInlinePendingFiles.value, files: reviewInlinePendingFiles.value,
systemGenerated: true,
extraContext: { extraContext: {
review_action: 'edit_review', review_action: 'edit_review',
review_form_values: buildReviewFormValues(fields), review_form_values: buildReviewFormValues(fields),
@@ -2861,6 +3253,10 @@ export default {
if (sessionSwitchBusy.value) return null if (sessionSwitchBusy.value) return null
const rawText = String(options.rawText ?? composerDraft.value).trim() 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 normalizedFiles = isKnowledgeSession.value ? [] : Array.from(options.files ?? attachedFiles.value)
const fileMergeResult = mergeFilesWithLimit([], normalizedFiles, MAX_ATTACHMENTS) const fileMergeResult = mergeFilesWithLimit([], normalizedFiles, MAX_ATTACHMENTS)
const files = fileMergeResult.files const files = fileMergeResult.files
@@ -2869,6 +3265,25 @@ export default {
} }
if (!rawText && !files.length) return 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 fileNames = files.map((file) => file.name)
const filePreviews = buildFilePreviews(files, previewRegistry) const filePreviews = buildFilePreviews(files, previewRegistry)
rememberFilePreviews(filePreviews) rememberFilePreviews(filePreviews)
@@ -2877,10 +3292,11 @@ export default {
rawText || rawText ||
(isKnowledgeSession.value (isKnowledgeSession.value
? `我上传了 ${fileNames.length} 份附件,请帮我回答相关财务问题。` ? `我上传了 ${fileNames.length} 份附件,请帮我回答相关财务问题。`
: resolvedUploadDisposition === 'continue_existing'
? `继续上传 ${fileNames.length} 份票据,并归集到当前单据。`
: resolvedUploadDisposition === 'new_document'
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
: `我上传了 ${fileNames.length} 份票据,请帮我识别并给出报销建议。`) : `我上传了 ${fileNames.length} 份票据,请帮我识别并给出报销建议。`)
const extraContext = options.extraContext && typeof options.extraContext === 'object'
? options.extraContext
: {}
// 只有在非静默模式下才添加用户消息 // 只有在非静默模式下才添加用户消息
if (!options.skipUserMessage) { 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({ const payload = await runOrchestrator({
source: 'user_message', source: 'user_message',
user_id: user.username || user.name || 'anonymous', user_id: user.username || user.name || 'anonymous',
@@ -2942,11 +3374,12 @@ export default {
...buildClientTimeContext(), ...buildClientTimeContext(),
session_type: activeSessionType.value, session_type: activeSessionType.value,
entry_source: props.entrySource, entry_source: props.entrySource,
attachment_names: fileNames, user_input_text: systemGenerated ? '' : rawText,
attachment_count: fileNames.length, attachment_names: effectiveFileNames,
attachment_count: effectiveFileNames.length,
draft_claim_id: isKnowledgeSession.value ? undefined : draftClaimId.value || undefined, draft_claim_id: isKnowledgeSession.value ? undefined : draftClaimId.value || undefined,
ocr_summary: ocrSummary, ocr_summary: effectiveOcrSummary,
ocr_documents: ocrDocuments, ocr_documents: effectiveOcrDocuments,
...(linkedRequest.value && !isKnowledgeSession.value ? { request_context: linkedRequest.value } : {}), ...(linkedRequest.value && !isKnowledgeSession.value ? { request_context: linkedRequest.value } : {}),
...extraContext ...extraContext
} }
@@ -2959,10 +3392,20 @@ export default {
? '' ? ''
: String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value : 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( replaceMessage(
pendingMessage.id, pendingMessage.id,
createMessage('assistant', payload?.result?.answer || payload?.result?.message || '智能体已完成处理。', [], { 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 : [], citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [],
suggestedActions: Array.isArray(payload?.result?.suggested_actions) suggestedActions: Array.isArray(payload?.result?.suggested_actions)
? payload.result.suggested_actions ? payload.result.suggested_actions
@@ -2975,7 +3418,7 @@ export default {
) )
currentInsight.value = buildAgentInsight( currentInsight.value = buildAgentInsight(
payload, payload,
fileNames, effectiveFileNames,
mergeFilePreviews(filePreviews, ocrFilePreviews) mergeFilePreviews(filePreviews, ocrFilePreviews)
) )
} catch (error) { } catch (error) {
@@ -2993,6 +3436,7 @@ export default {
currentInsight.value = buildErrorInsight(error, fileNames) currentInsight.value = buildErrorInsight(error, fileNames)
} finally { } finally {
submitting.value = false submitting.value = false
composerUploadIntent.value = ''
nextTick(scrollToBottom) nextTick(scrollToBottom)
} }
@@ -3039,6 +3483,7 @@ export default {
rawText: buildReviewCorrectionMessage(fields), rawText: buildReviewCorrectionMessage(fields),
userText: '我已修改识别信息,请按最新内容更新。', userText: '我已修改识别信息,请按最新内容更新。',
pendingText: '正在根据修改内容重新识别...', pendingText: '正在根据修改内容重新识别...',
systemGenerated: true,
extraContext: { extraContext: {
review_action: 'edit_review', review_action: 'edit_review',
review_form_values: buildReviewFormValues(fields) review_form_values: buildReviewFormValues(fields)
@@ -3064,7 +3509,7 @@ export default {
return 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 return
} }
@@ -3072,13 +3517,11 @@ export default {
return return
} }
// 保存草稿直接处理,不显示对话 if (['save_draft', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(actionType)) {
if (actionType === 'save_draft') { await handleSaveDraftDirectly(message, actionType)
await handleSaveDraftDirectly(message)
return return
} }
// 下一步继续使用对话流程
reviewActionBusy.value = true reviewActionBusy.value = true
try { try {
const baseFields = reviewInlineBaseFields.value.length const baseFields = reviewInlineBaseFields.value.length
@@ -3109,6 +3552,7 @@ export default {
userText: reviewChangedUserText || '我确认当前识别结果,继续下一步。', userText: reviewChangedUserText || '我确认当前识别结果,继续下一步。',
files: reviewInlinePendingFiles.value, files: reviewInlinePendingFiles.value,
pendingText: '正在进入下一步...', pendingText: '正在进入下一步...',
systemGenerated: true,
extraContext: { extraContext: {
review_action: actionType, review_action: actionType,
review_form_values: buildReviewFormValues(fields), review_form_values: buildReviewFormValues(fields),
@@ -3133,12 +3577,48 @@ export default {
} }
} }
// 新增:直接保存草稿的函数,不显示对话 async function handleSaveDraftDirectly(message, actionType = 'save_draft') {
async function handleSaveDraftDirectly(message) {
reviewActionBusy.value = true reviewActionBusy.value = true
let savingMessage = null
// 记录当前消息数量,用于后续移除 submitComposer 添加的消息 const actionConfig = {
const messageCountBefore = messages.value.length 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 { try {
const baseFields = reviewInlineBaseFields.value.length const baseFields = reviewInlineBaseFields.value.length
@@ -3146,36 +3626,33 @@ export default {
: cloneReviewEditFields(message?.reviewPayload?.edit_fields) : cloneReviewEditFields(message?.reviewPayload?.edit_fields)
const fields = mergeInlineReviewFields(baseFields, reviewInlineForm.value) const fields = mergeInlineReviewFields(baseFields, reviewInlineForm.value)
// 先显示一个临时的"正在保存"消息 savingMessage = createMessage('assistant', actionConfig.helperText, [], { meta: ['处理中'] })
const savingMessage = createMessage('assistant', '正在保存草稿...', [], { meta: ['处理中'] })
messages.value.push(savingMessage) messages.value.push(savingMessage)
nextTick(scrollToBottom) nextTick(scrollToBottom)
// 调用保存逻辑,不通过对话
const payload = await submitComposer({ const payload = await submitComposer({
rawText: '请按当前已识别信息先保存草稿,缺失字段后续再补。', rawText: actionConfig.rawText,
userText: '', // 不显示用户消息 userText: '',
skipUserMessage: true, // 跳过添加用户消息 skipUserMessage: true,
files: reviewInlinePendingFiles.value, files: reviewInlinePendingFiles.value,
pendingText: '正在保存当前草稿...', pendingText: actionConfig.pendingText,
systemGenerated: true,
extraContext: { extraContext: {
review_action: 'save_draft', review_action: actionType,
review_form_values: buildReviewFormValues(fields), review_form_values: buildReviewFormValues(fields),
review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value) review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value)
} }
}) })
// 移除临时消息
const tempIndex = messages.value.findIndex((msg) => msg === savingMessage) const tempIndex = messages.value.findIndex((msg) => msg === savingMessage)
if (tempIndex !== -1) { if (tempIndex !== -1) {
messages.value.splice(tempIndex, 1) messages.value.splice(tempIndex, 1)
} }
if (payload?.result?.draft_payload?.claim_no) { if (payload?.result?.draft_payload?.claim_no) {
// 显示保存成功的消息
messages.value.push( messages.value.push(
createMessage('assistant', `✅ 草稿已保存,单号:${payload.result.draft_payload.claim_no}`, [], { createMessage('assistant', actionConfig.successMessage(payload), [], {
meta: ['草稿已保存'] meta: [actionConfig.successMeta]
}) })
) )
@@ -3190,19 +3667,18 @@ export default {
}) })
) )
} else { } else {
// 没有返回草稿信息,可能保存失败 messages.value.push(createMessage('assistant', actionConfig.successMessage(payload), [], { meta: [actionConfig.successMeta] }))
messages.value.push(createMessage('assistant', '草稿保存完成', [], { meta: ['草稿已保存'] }))
} }
nextTick(scrollToBottom) nextTick(scrollToBottom)
} catch (error) { } catch (error) {
// 移除临时消息 if (savingMessage) {
const tempIndex = messages.value.findIndex((msg) => msg === savingMessage) const tempIndex = messages.value.findIndex((msg) => msg === savingMessage)
if (tempIndex !== -1) { if (tempIndex !== -1) {
messages.value.splice(tempIndex, 1) messages.value.splice(tempIndex, 1)
} }
// 显示错误消息 }
messages.value.push(createMessage('assistant', '❌ 保存草稿失败,请稍后重试。', [], { meta: ['错误'] })) messages.value.push(createMessage('assistant', '保存失败,请稍后重试。', [], { meta: ['错误'] }))
nextTick(scrollToBottom) nextTick(scrollToBottom)
} finally { } finally {
reviewActionBusy.value = false reviewActionBusy.value = false
@@ -3265,6 +3741,7 @@ export default {
reviewOtherCategoryOpen, reviewOtherCategoryOpen,
reviewInlinePendingFiles, reviewInlinePendingFiles,
DATE_INPUT_FORMAT, DATE_INPUT_FORMAT,
REVIEW_SCENE_OTHER_OPTION,
REVIEW_SCENE_OPTIONS, REVIEW_SCENE_OPTIONS,
REVIEW_OTHER_CATEGORY_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS,
reviewPanelConfidence, reviewPanelConfidence,
@@ -3281,6 +3758,7 @@ export default {
reviewHasUnsavedChanges, reviewHasUnsavedChanges,
reviewCancelDialogOpen, reviewCancelDialogOpen,
reviewEditDialogOpen, reviewEditDialogOpen,
uploadDecisionDialogOpen,
deleteSessionDialogOpen, deleteSessionDialogOpen,
reviewActionBusy, reviewActionBusy,
deleteSessionBusy, deleteSessionBusy,
@@ -3300,6 +3778,7 @@ export default {
buildReviewTodoSectionMeta, buildReviewTodoSectionMeta,
buildReviewAlertChips, buildReviewAlertChips,
buildReviewTodoItems, buildReviewTodoItems,
resolveReviewSubmitActions,
resolveReviewPrimaryAction, resolveReviewPrimaryAction,
resolveReviewEditAction, resolveReviewEditAction,
buildReviewPrimaryButtonLabel, buildReviewPrimaryButtonLabel,
@@ -3334,6 +3813,9 @@ export default {
openDeleteSessionDialog, openDeleteSessionDialog,
closeDeleteSessionDialog, closeDeleteSessionDialog,
confirmDeleteCurrentSession, confirmDeleteCurrentSession,
closeUploadDecisionDialog,
continueExistingUpload,
createNewUploadDocument,
openInlineReviewEditor, openInlineReviewEditor,
closeInlineReviewEditor, closeInlineReviewEditor,
commitInlineReviewEditor, commitInlineReviewEditor,

View File

@@ -7,8 +7,8 @@ import {
deleteExpenseClaimItem, deleteExpenseClaimItem,
deleteExpenseClaimItemAttachment, deleteExpenseClaimItemAttachment,
deleteExpenseClaim, deleteExpenseClaim,
fetchExpenseClaimItemAttachment,
fetchExpenseClaimItemAttachmentMeta, fetchExpenseClaimItemAttachmentMeta,
fetchExpenseClaimItemAttachmentPreview,
submitExpenseClaim, submitExpenseClaim,
uploadExpenseClaimItemAttachment, uploadExpenseClaimItemAttachment,
updateExpenseClaimItem updateExpenseClaimItem
@@ -894,10 +894,14 @@ export default {
attachmentPreviewOpen.value = true attachmentPreviewOpen.value = true
attachmentPreviewLoading.value = true attachmentPreviewLoading.value = true
attachmentPreviewName.value = resolveAttachmentDisplayName(item) attachmentPreviewName.value = resolveAttachmentDisplayName(item)
attachmentPreviewMediaType.value = String(resolveAttachmentMeta(item)?.media_type || '').trim() const metadata = resolveAttachmentMeta(item)
attachmentPreviewMediaType.value =
String(metadata?.preview_kind || '').trim() === 'image'
? 'image/png'
: String(metadata?.media_type || '').trim()
try { try {
const blob = await fetchExpenseClaimItemAttachment(request.value.claimId, item.id) const blob = await fetchExpenseClaimItemAttachmentPreview(request.value.claimId, item.id)
revokeAttachmentPreviewUrl() revokeAttachmentPreviewUrl()
attachmentPreviewUrl.value = URL.createObjectURL(blob) attachmentPreviewUrl.value = URL.createObjectURL(blob)
attachmentPreviewMediaType.value = blob.type || attachmentPreviewMediaType.value attachmentPreviewMediaType.value = blob.type || attachmentPreviewMediaType.value
@@ -1139,7 +1143,8 @@ export default {
emit('openAssistant', { emit('openAssistant', {
source: 'detail', source: 'detail',
prompt: '', prompt: '',
request: request.value request: request.value,
restoreLatestConversation: true
}) })
} }