@@ -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 par ts . join ( '; ' )
function buildOcrSummaryFromDocumen ts( 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 : f als e,
filename : '' ,
kind : 'file' ,
url : ''
... documentPreviewDialog . v alu e,
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 : o crSummary,
ocr _documents : o crDocuments,
ocr _summary : effectiveO crSummary,
ocr _documents : effectiveO crDocuments,
... ( 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 , f ileNames) ,
meta : buildMessageMeta ( payload , effectiveF ileNames) ,
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 ,
f ileNames,
effectiveF ileNames,
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 ,