feat(web): 票据夹资产缓存接入与 AI 工作台附件流程完善
- ReceiptFolderView 删除票据后提示已关联附件副本保留,接入 useToast;fetchReceiptFolderAsset 加 no-store 避免预览缓存 - PersonalWorkbenchAiMode 附件区/对话气泡适配资产缓存,personal-workbench-ai-mode.css 调整布局 - usePersonalWorkbenchAiMode/useWorkbenchAiApplicationPreviewFlow/useWorkbenchAiAttachmentAssociationFlow/useWorkbenchAiStewardFlow 完善附件草稿选择与关联流程 - travelRequestDetailSmartEntryRecognition 智能识别增强,AppShellRouteView/PersonalWorkbenchView/useApplicationPreviewEditor/useTravelReimbursementSubmitComposer 等配套适配 - 新增 expense-attachment-draft-selection、receipt-folder-asset-cache、travel-request-detail-smart-entry-recognition 测试,更新 attachment-association-confirmation、expense-application-fast-preview、workbench-ai-mode-switch 测试
This commit is contained in:
@@ -542,6 +542,48 @@
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.workbench-ai-file-card__ocr {
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #2563eb;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.workbench-ai-file-card__ocr i {
|
||||
flex: 0 0 auto;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.workbench-ai-file-card__ocr span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workbench-ai-file-card__ocr.is-recognizing i {
|
||||
animation: workbenchAiOcrSpin 840ms linear infinite;
|
||||
}
|
||||
|
||||
.workbench-ai-file-card__ocr.is-recognized {
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.workbench-ai-file-card__ocr.is-failed {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
@keyframes workbenchAiOcrSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.workbench-ai-file-card__remove {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
@@ -2035,7 +2077,7 @@
|
||||
}
|
||||
|
||||
.application-preview-input {
|
||||
width: 100%;
|
||||
width: min(100%, 420px);
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
@@ -2049,7 +2091,34 @@
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.11);
|
||||
}
|
||||
|
||||
.application-preview-date-input {
|
||||
width: min(100%, 188px);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.application-preview-input--time,
|
||||
.application-preview-input--time_return {
|
||||
width: min(100%, 188px);
|
||||
}
|
||||
|
||||
.application-preview-input--location {
|
||||
width: min(100%, 220px);
|
||||
}
|
||||
|
||||
.application-preview-input--reason {
|
||||
width: min(100%, 680px);
|
||||
}
|
||||
|
||||
.application-preview-input--days {
|
||||
width: min(100%, 150px);
|
||||
}
|
||||
|
||||
.application-preview-input--transportMode {
|
||||
width: min(100%, 240px);
|
||||
}
|
||||
|
||||
.application-preview-select {
|
||||
width: min(100%, 240px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
@@ -182,6 +182,17 @@
|
||||
<span class="workbench-ai-file-card__body">
|
||||
<strong :title="file.name">{{ file.name }}</strong>
|
||||
<small>{{ file.typeLabel }}</small>
|
||||
<small
|
||||
v-if="file.ocrState?.label"
|
||||
class="workbench-ai-file-card__ocr"
|
||||
:class="`is-${file.ocrState.status || 'idle'}`"
|
||||
:title="file.ocrState.title || file.ocrState.label"
|
||||
>
|
||||
<i v-if="file.ocrState.status === 'recognizing'" class="mdi mdi-loading"></i>
|
||||
<i v-else-if="file.ocrState.status === 'recognized'" class="mdi mdi-text-recognition"></i>
|
||||
<i v-else-if="file.ocrState.status === 'failed'" class="mdi mdi-alert-circle-outline"></i>
|
||||
<span>{{ file.ocrState.label }}</span>
|
||||
</small>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -424,9 +435,23 @@
|
||||
<span class="application-preview-label" role="cell">{{ row.label }}</span>
|
||||
<span class="application-preview-value" role="cell">
|
||||
<input
|
||||
v-if="isApplicationPreviewEditing(message, row.key) && resolveInlineApplicationPreviewEditorControl(row.key) === 'text'"
|
||||
v-if="isApplicationPreviewEditing(message, row.key) && resolveInlineApplicationPreviewEditorControl(row.key) === 'date'"
|
||||
v-model="applicationPreviewEditor.draftValue"
|
||||
class="application-preview-input"
|
||||
:class="['application-preview-input', 'application-preview-date-input', `application-preview-input--${row.key}`]"
|
||||
type="date"
|
||||
:min="resolveInlineApplicationPreviewEditorDateMin(message, row.key)"
|
||||
:max="resolveInlineApplicationPreviewEditorDateMax(message, row.key)"
|
||||
autofocus
|
||||
:disabled="isApplicationPreviewEstimatePending(message)"
|
||||
@click.stop
|
||||
@change="commitInlineApplicationPreviewEditor(message)"
|
||||
@keydown.stop="handleInlineApplicationPreviewEditorKeydown($event, message)"
|
||||
@blur="commitInlineApplicationPreviewEditor(message)"
|
||||
/>
|
||||
<input
|
||||
v-else-if="isApplicationPreviewEditing(message, row.key) && resolveInlineApplicationPreviewEditorControl(row.key) === 'text'"
|
||||
v-model="applicationPreviewEditor.draftValue"
|
||||
:class="['application-preview-input', `application-preview-input--${row.key}`]"
|
||||
type="text"
|
||||
autofocus
|
||||
:disabled="isApplicationPreviewEstimatePending(message)"
|
||||
@@ -437,7 +462,7 @@
|
||||
<select
|
||||
v-else-if="isApplicationPreviewEditing(message, row.key) && resolveInlineApplicationPreviewEditorControl(row.key) === 'select'"
|
||||
v-model="applicationPreviewEditor.draftValue"
|
||||
class="application-preview-input application-preview-select"
|
||||
:class="['application-preview-input', 'application-preview-select', `application-preview-input--${row.key}`]"
|
||||
autofocus
|
||||
:disabled="isApplicationPreviewEstimatePending(message)"
|
||||
@click.stop
|
||||
@@ -548,6 +573,17 @@
|
||||
<span class="workbench-ai-file-card__body">
|
||||
<strong :title="file.name">{{ file.name }}</strong>
|
||||
<small>{{ file.typeLabel }}</small>
|
||||
<small
|
||||
v-if="file.ocrState?.label"
|
||||
class="workbench-ai-file-card__ocr"
|
||||
:class="`is-${file.ocrState.status || 'idle'}`"
|
||||
:title="file.ocrState.title || file.ocrState.label"
|
||||
>
|
||||
<i v-if="file.ocrState.status === 'recognizing'" class="mdi mdi-loading"></i>
|
||||
<i v-else-if="file.ocrState.status === 'recognized'" class="mdi mdi-text-recognition"></i>
|
||||
<i v-else-if="file.ocrState.status === 'failed'" class="mdi mdi-alert-circle-outline"></i>
|
||||
<span>{{ file.ocrState.label }}</span>
|
||||
</small>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -7,10 +7,10 @@ import { usePersonalWorkbenchAiMode } from '../../composables/workbenchAiMode/us
|
||||
const props = defineProps({
|
||||
sidebarCommand: { type: Object, default: () => ({ seq: 0, type: '', payload: null }) }
|
||||
})
|
||||
const emit = defineEmits(['conversation-change', 'conversation-history-change', 'open-document'])
|
||||
const emit = defineEmits(['conversation-change', 'conversation-history-change', 'open-document', 'request-updated'])
|
||||
|
||||
const {
|
||||
activeConversationTitle, aiModeActionItems, applicationPreviewEditor, applicationSubmitConfirmOpen, assistantDraft, assistantInputRef, canShowInlineSuggestedActions, canSubmitAiModePrompt, cancelDeleteConversation, cancelInlineApplicationSubmitConfirm, clearWorkbenchDateSelection, commitInlineApplicationPreviewEditor, confirmDeleteConversation, confirmInlineApplicationSubmit, conversationMessages, conversationScrollRef, conversationStarted, deleteDialogOpen, displayModelName, displayUserName, fileInputRef, handleAiAnswerMarkdownClick, handleAiModeFilesChange, handleInlineApplicationPreviewEditorKeydown, handleInlineConversationScroll, handleInlineSuggestedAction, handleVoiceInput, hasInlineAttachmentOcrDetails, hasInlineThinking, isAiModeInputLocked, isApplicationPreviewEditing, isApplicationPreviewEstimatePending, isInlineAttachmentOcrExpanded, isInlineSuggestedActionDisabled, isInlineThinkingExpanded, markInlineMessageFeedback, modelSelectorTitle, openApplicationPreviewEditor, quoteInlineMessage, regenerateLastReply, removeAiModeFile, removeWorkbenchDateTag, renderInlineConversationHtml, requestDeleteCurrentConversation, resolveApplicationPreviewEditorOptions, resolveInlineApplicationPreviewEditorControl, resolveInlineApplicationPreviewMissingFields, resolveInlineApplicationPreviewRows, resolveInlineAttachmentOcrDocuments, resolveInlineAttachmentOcrFileCount, resolveInlineThinkingEvents, runAiModeAction, scrollInlineConversationToTop, selectedFileCards, sending, setWorkbenchDateMode, submitAiModePrompt, toggleInlineAttachmentOcrDetails, toggleInlineThinking, toggleWorkbenchDatePicker, triggerAiModeFileUpload, workbenchCanApplyDateSelection, workbenchDateMode, workbenchDatePickerOpen, workbenchDateTagLabel, workbenchRangeEndDate, workbenchRangeStartDate, workbenchSingleDate, applyWorkbenchDateSelection, buildInlineApplicationPreviewFooterText, copyInlineMessage, formatMessageTime, handleWorkbenchDateInputChange
|
||||
activeConversationTitle, aiModeActionItems, applicationPreviewEditor, applicationSubmitConfirmOpen, assistantDraft, assistantInputRef, canShowInlineSuggestedActions, canSubmitAiModePrompt, cancelDeleteConversation, cancelInlineApplicationSubmitConfirm, clearWorkbenchDateSelection, commitInlineApplicationPreviewEditor, confirmDeleteConversation, confirmInlineApplicationSubmit, conversationMessages, conversationScrollRef, conversationStarted, deleteDialogOpen, displayModelName, displayUserName, fileInputRef, handleAiAnswerMarkdownClick, handleAiModeFilesChange, handleInlineApplicationPreviewEditorKeydown, handleInlineConversationScroll, handleInlineSuggestedAction, handleVoiceInput, hasInlineAttachmentOcrDetails, hasInlineThinking, isAiModeInputLocked, isApplicationPreviewEditing, isApplicationPreviewEstimatePending, isInlineAttachmentOcrExpanded, isInlineSuggestedActionDisabled, isInlineThinkingExpanded, markInlineMessageFeedback, modelSelectorTitle, openApplicationPreviewEditor, quoteInlineMessage, regenerateLastReply, removeAiModeFile, removeWorkbenchDateTag, renderInlineConversationHtml, requestDeleteCurrentConversation, resolveApplicationPreviewEditorOptions, resolveInlineApplicationPreviewEditorControl, resolveInlineApplicationPreviewEditorDateMax, resolveInlineApplicationPreviewEditorDateMin, resolveInlineApplicationPreviewMissingFields, resolveInlineApplicationPreviewRows, resolveInlineAttachmentOcrDocuments, resolveInlineAttachmentOcrFileCount, resolveInlineThinkingEvents, runAiModeAction, scrollInlineConversationToTop, selectedFileCards, sending, setWorkbenchDateMode, submitAiModePrompt, toggleInlineAttachmentOcrDetails, toggleInlineThinking, toggleWorkbenchDatePicker, triggerAiModeFileUpload, workbenchCanApplyDateSelection, workbenchDateMode, workbenchDatePickerOpen, workbenchDateTagLabel, workbenchRangeEndDate, workbenchRangeStartDate, workbenchSingleDate, applyWorkbenchDateSelection, buildInlineApplicationPreviewFooterText, copyInlineMessage, formatMessageTime, handleWorkbenchDateInputChange
|
||||
} = usePersonalWorkbenchAiMode(props, emit)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -78,6 +78,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
const {
|
||||
applicationPreviewEditor,
|
||||
resolveApplicationPreviewEditorControl,
|
||||
resolveApplicationPreviewEditorDateMax,
|
||||
resolveApplicationPreviewEditorDateMin,
|
||||
resolveApplicationPreviewEditorOptions,
|
||||
refreshApplicationPreviewEstimate,
|
||||
isApplicationPreviewEditing,
|
||||
@@ -112,7 +114,6 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
} = useWorkbenchComposerDate({ draft: assistantDraft, focusInput: focusAiModeInput })
|
||||
|
||||
const aiModeActionItems = AI_MODE_ACTION_ITEMS
|
||||
const selectedFileCards = computed(() => buildSelectedFileCards(selectedFiles.value))
|
||||
const displayUserName = computed(() => {
|
||||
const user = currentUser.value || {}
|
||||
return String(user.name || user.username || '同事').trim() || '同事'
|
||||
@@ -161,9 +162,19 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
scrollInlineConversationToBottom,
|
||||
sending,
|
||||
streamOrSetInlineAssistantContent,
|
||||
notifyRequestUpdated: (payload) => emit('request-updated', payload),
|
||||
toast
|
||||
})
|
||||
|
||||
watch(selectedFiles, (files) => {
|
||||
attachmentFlow.primeAiModeReceiptContext(files)
|
||||
})
|
||||
|
||||
const selectedFileCards = computed(() => buildSelectedFileCards(selectedFiles.value).map((card, index) => ({
|
||||
...card,
|
||||
ocrState: attachmentFlow.resolveAiModeReceiptRecognitionState(selectedFiles.value[index])
|
||||
})))
|
||||
|
||||
const applicationFlow = useWorkbenchAiApplicationPreviewFlow({
|
||||
activateInlineConversation,
|
||||
applicationPreviewEditor,
|
||||
@@ -189,6 +200,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
refreshApplicationPreviewEstimate,
|
||||
removeWorkbenchDateTag,
|
||||
replaceInlineMessage,
|
||||
resolveApplicationPreviewEditorDateMax,
|
||||
resolveApplicationPreviewEditorDateMin,
|
||||
resolveApplicationPreviewEditorControl,
|
||||
resolveApplicationPreviewEditorOptions,
|
||||
resolveInlineThinkingEvents,
|
||||
@@ -776,6 +789,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
||||
requestDeleteCurrentConversation: () => sessionCommands.requestDeleteCurrentConversation(deleteDialogOpen),
|
||||
resolveApplicationPreviewEditorOptions: applicationFlow.resolveApplicationPreviewEditorOptions,
|
||||
resolveInlineApplicationPreviewEditorControl: applicationFlow.resolveInlineApplicationPreviewEditorControl,
|
||||
resolveInlineApplicationPreviewEditorDateMax: applicationFlow.resolveInlineApplicationPreviewEditorDateMax,
|
||||
resolveInlineApplicationPreviewEditorDateMin: applicationFlow.resolveInlineApplicationPreviewEditorDateMin,
|
||||
resolveInlineApplicationPreviewMissingFields: applicationFlow.resolveInlineApplicationPreviewMissingFields,
|
||||
resolveInlineApplicationPreviewRows: applicationFlow.resolveInlineApplicationPreviewRows,
|
||||
resolveInlineAttachmentOcrDocuments,
|
||||
|
||||
@@ -68,6 +68,8 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
||||
refreshApplicationPreviewEstimate,
|
||||
removeWorkbenchDateTag,
|
||||
replaceInlineMessage,
|
||||
resolveApplicationPreviewEditorDateMax,
|
||||
resolveApplicationPreviewEditorDateMin,
|
||||
resolveApplicationPreviewEditorControl,
|
||||
resolveApplicationPreviewEditorOptions,
|
||||
resolveInlineThinkingEvents,
|
||||
@@ -105,8 +107,15 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
||||
}
|
||||
|
||||
function resolveInlineApplicationPreviewEditorControl(fieldKey) {
|
||||
const control = resolveApplicationPreviewEditorControl(fieldKey)
|
||||
return control === 'date' ? 'text' : control
|
||||
return resolveApplicationPreviewEditorControl(fieldKey)
|
||||
}
|
||||
|
||||
function resolveInlineApplicationPreviewEditorDateMin(message, fieldKey) {
|
||||
return resolveApplicationPreviewEditorDateMin?.(message, fieldKey) || ''
|
||||
}
|
||||
|
||||
function resolveInlineApplicationPreviewEditorDateMax(message, fieldKey) {
|
||||
return resolveApplicationPreviewEditorDateMax?.(message, fieldKey) || ''
|
||||
}
|
||||
|
||||
function buildInlineApplicationPreviewSuggestedActions(applicationPreview = {}, draftPayload = null) {
|
||||
@@ -180,6 +189,14 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
||||
return '申请核对表已补齐,费用测算已同步。你仍可点击表格继续修改;确认无误后,可以点击下方按钮保存草稿或直接提交,也可以直接回复“保存草稿”或“提交”。'
|
||||
}
|
||||
|
||||
function buildInlineApplicationActionFailureText(error, isSubmit) {
|
||||
return [
|
||||
isSubmit ? '### 申请提交失败' : '### 申请草稿保存失败',
|
||||
error?.message || (isSubmit ? '申请提交失败,请稍后重试。' : '申请草稿保存失败,请稍后重试。'),
|
||||
'我已保留当前申请核对表,您可以修改后重试,也可以稍后再次保存。'
|
||||
].join('\n\n')
|
||||
}
|
||||
|
||||
function resolveLatestApplicationPreviewMessage() {
|
||||
return [...conversationMessages.value]
|
||||
.reverse()
|
||||
@@ -385,8 +402,14 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
||||
} catch (error) {
|
||||
replaceInlineMessage(
|
||||
pendingMessage.id,
|
||||
createInlineMessage('assistant', error?.message || (isSubmit ? '申请提交失败,请稍后重试。' : '申请草稿保存失败,请稍后重试。'), {
|
||||
createInlineMessage('assistant', buildInlineApplicationActionFailureText(error, isSubmit), {
|
||||
id: pendingMessage.id,
|
||||
applicationPreview: targetMessage.applicationPreview,
|
||||
draftPayload: targetMessage.draftPayload || options.draftPayload || null,
|
||||
suggestedActions: buildInlineApplicationPreviewSuggestedActions(
|
||||
targetMessage.applicationPreview,
|
||||
targetMessage.draftPayload || options.draftPayload || null
|
||||
),
|
||||
stewardPlan: {
|
||||
streamStatus: 'failed',
|
||||
thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({
|
||||
@@ -504,6 +527,8 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
||||
openApplicationPreviewEditor,
|
||||
resolveApplicationPreviewEditorOptions,
|
||||
resolveInlineApplicationPreviewEditorControl,
|
||||
resolveInlineApplicationPreviewEditorDateMax,
|
||||
resolveInlineApplicationPreviewEditorDateMin,
|
||||
resolveInlineApplicationPreviewMissingFields,
|
||||
resolveInlineApplicationPreviewRows,
|
||||
startAiApplicationPreview
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import * as aiAttachmentAssociationModel from '../../utils/aiAttachmentAssociationModel.js'
|
||||
import { syncExpenseClaimFilesToDraft } from '../../utils/expenseClaimAttachmentSync.js'
|
||||
import { collectReceiptFiles } from '../../views/scripts/travelReimbursementAttachmentModel.js'
|
||||
import {
|
||||
buildFileIdentity,
|
||||
collectReceiptFiles
|
||||
} from '../../views/scripts/travelReimbursementAttachmentModel.js'
|
||||
import {
|
||||
createExpenseClaimItem,
|
||||
extractExpenseClaimItems,
|
||||
@@ -76,42 +81,256 @@ export function useWorkbenchAiAttachmentAssociationFlow({
|
||||
scrollInlineConversationToBottom,
|
||||
sending,
|
||||
streamOrSetInlineAssistantContent,
|
||||
notifyRequestUpdated,
|
||||
toast
|
||||
}) {
|
||||
async function collectAiModeReceiptContext(files = []) {
|
||||
const safeFiles = Array.isArray(files) ? files : []
|
||||
const aiModeReceiptContextCache = new Map()
|
||||
const aiModeReceiptRecognitionState = reactive({})
|
||||
|
||||
function resolveAiModeReceiptRecognitionStateKey(file) {
|
||||
return buildFileIdentity(file)
|
||||
}
|
||||
|
||||
function pruneAiModeReceiptRecognitionState(files = []) {
|
||||
const activeKeys = new Set(
|
||||
(Array.isArray(files) ? files : [])
|
||||
.filter((file) => isLikelyAiModeOcrFile(file))
|
||||
.map((file) => resolveAiModeReceiptRecognitionStateKey(file))
|
||||
.filter(Boolean)
|
||||
)
|
||||
Object.keys(aiModeReceiptRecognitionState).forEach((key) => {
|
||||
if (!activeKeys.has(key)) {
|
||||
delete aiModeReceiptRecognitionState[key]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setAiModeReceiptRecognitionState(files = [], patch = {}) {
|
||||
const recognitionFiles = (Array.isArray(files) ? files : [])
|
||||
.filter((file) => isLikelyAiModeOcrFile(file))
|
||||
recognitionFiles.forEach((file) => {
|
||||
const key = resolveAiModeReceiptRecognitionStateKey(file)
|
||||
if (!key) {
|
||||
return
|
||||
}
|
||||
aiModeReceiptRecognitionState[key] = {
|
||||
...(aiModeReceiptRecognitionState[key] || {}),
|
||||
fileName: String(file?.name || '').trim(),
|
||||
...patch
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function findAiModeReceiptDocumentForFile(file = {}, documents = [], index = 0) {
|
||||
const fileName = String(file?.name || '').trim()
|
||||
if (fileName) {
|
||||
const exactDocument = documents.find((document) => (
|
||||
String(document?.filename || document?.name || '').trim() === fileName
|
||||
))
|
||||
if (exactDocument) {
|
||||
return exactDocument
|
||||
}
|
||||
}
|
||||
return documents[index] || null
|
||||
}
|
||||
|
||||
function buildAiModeReceiptRecognitionPendingState() {
|
||||
return {
|
||||
status: 'recognizing',
|
||||
label: '智能录入识别中',
|
||||
title: '正在调用智能录入 OCR 识别票据内容'
|
||||
}
|
||||
}
|
||||
|
||||
function buildAiModeReceiptRecognitionDoneState(document = null) {
|
||||
const detail = String(
|
||||
document?.document_type_label ||
|
||||
document?.scene_label ||
|
||||
document?.document_type ||
|
||||
''
|
||||
).trim()
|
||||
return {
|
||||
status: 'recognized',
|
||||
label: detail ? `已识别票据 · ${detail}` : '已识别票据',
|
||||
title: detail ? `智能录入已完成,识别为${detail}` : '智能录入已完成'
|
||||
}
|
||||
}
|
||||
|
||||
function applyAiModeReceiptRecognitionResult(files = [], context = {}) {
|
||||
const documents = Array.isArray(context?.ocrDocuments) ? context.ocrDocuments : []
|
||||
const recognitionFiles = (Array.isArray(files) ? files : [])
|
||||
.filter((file) => isLikelyAiModeOcrFile(file))
|
||||
recognitionFiles.forEach((file, index) => {
|
||||
const document = findAiModeReceiptDocumentForFile(file, documents, index)
|
||||
setAiModeReceiptRecognitionState([file], buildAiModeReceiptRecognitionDoneState(document))
|
||||
})
|
||||
}
|
||||
|
||||
function resolveAiModeReceiptRecognitionState(file) {
|
||||
const key = resolveAiModeReceiptRecognitionStateKey(file)
|
||||
return key ? aiModeReceiptRecognitionState[key] || null : null
|
||||
}
|
||||
|
||||
function buildAiModeReceiptBaseContext(safeFiles = [], ocrFiles = []) {
|
||||
const attachmentNames = safeFiles
|
||||
.map((file) => String(file?.name || '').trim())
|
||||
.filter(Boolean)
|
||||
const ocrFiles = safeFiles.filter((file) => isLikelyAiModeOcrFile(file))
|
||||
const ocrSourceFileNames = ocrFiles
|
||||
.map((file) => String(file?.name || '').trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const baseContext = {
|
||||
return {
|
||||
attachmentNames,
|
||||
attachmentCount: attachmentNames.length,
|
||||
ocrSourceFileNames,
|
||||
ocrSummary: '',
|
||||
ocrDocuments: []
|
||||
}
|
||||
}
|
||||
|
||||
function buildAiModeReceiptContextCacheKey(ocrFiles = []) {
|
||||
return (Array.isArray(ocrFiles) ? ocrFiles : [])
|
||||
.map((file) => buildFileIdentity(file))
|
||||
.filter(Boolean)
|
||||
.join('|')
|
||||
}
|
||||
|
||||
function buildAiModeReceiptContextFromCollected(baseContext = {}, collected = {}) {
|
||||
return {
|
||||
...baseContext,
|
||||
ocrPayload: collected.ocrPayload || { documents: collected.ocrDocuments || [] },
|
||||
ocrSummary: String(collected.ocrSummary || '').trim(),
|
||||
ocrDocuments: Array.isArray(collected.ocrDocuments) ? collected.ocrDocuments : [],
|
||||
ocrFilePreviews: Array.isArray(collected.ocrFilePreviews) ? collected.ocrFilePreviews : []
|
||||
}
|
||||
}
|
||||
|
||||
function rememberAiModeReceiptContext(cacheKey, context) {
|
||||
if (!cacheKey) {
|
||||
return
|
||||
}
|
||||
aiModeReceiptContextCache.set(cacheKey, {
|
||||
status: 'resolved',
|
||||
context: {
|
||||
ocrPayload: context.ocrPayload,
|
||||
ocrSummary: context.ocrSummary,
|
||||
ocrDocuments: context.ocrDocuments,
|
||||
ocrFilePreviews: context.ocrFilePreviews
|
||||
}
|
||||
})
|
||||
if (aiModeReceiptContextCache.size > 20) {
|
||||
aiModeReceiptContextCache.delete(aiModeReceiptContextCache.keys().next().value)
|
||||
}
|
||||
}
|
||||
|
||||
function startAiModeReceiptRecognition(files = []) {
|
||||
const safeFiles = Array.isArray(files) ? files : []
|
||||
const ocrFiles = safeFiles.filter((file) => isLikelyAiModeOcrFile(file))
|
||||
const cacheKey = buildAiModeReceiptContextCacheKey(ocrFiles)
|
||||
if (!ocrFiles.length || !cacheKey) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cached = aiModeReceiptContextCache.get(cacheKey)
|
||||
if (cached?.status === 'resolved') {
|
||||
applyAiModeReceiptRecognitionResult(ocrFiles, cached.context)
|
||||
return null
|
||||
}
|
||||
if (cached?.status === 'pending' && cached.promise) {
|
||||
setAiModeReceiptRecognitionState(ocrFiles, buildAiModeReceiptRecognitionPendingState())
|
||||
return cached.promise
|
||||
}
|
||||
|
||||
setAiModeReceiptRecognitionState(ocrFiles, buildAiModeReceiptRecognitionPendingState())
|
||||
|
||||
const promise = collectReceiptFiles({
|
||||
files: ocrFiles,
|
||||
recognizeOcrFiles
|
||||
}).then((collected) => {
|
||||
const context = buildAiModeReceiptContextFromCollected(
|
||||
buildAiModeReceiptBaseContext(safeFiles, ocrFiles),
|
||||
collected
|
||||
)
|
||||
rememberAiModeReceiptContext(cacheKey, context)
|
||||
applyAiModeReceiptRecognitionResult(ocrFiles, context)
|
||||
return context
|
||||
}).catch((error) => {
|
||||
aiModeReceiptContextCache.delete(cacheKey)
|
||||
setAiModeReceiptRecognitionState(ocrFiles, {
|
||||
status: 'failed',
|
||||
label: '识别失败',
|
||||
title: error?.message || '智能录入 OCR 识别失败'
|
||||
})
|
||||
throw error
|
||||
})
|
||||
|
||||
aiModeReceiptContextCache.set(cacheKey, {
|
||||
status: 'pending',
|
||||
promise
|
||||
})
|
||||
return promise
|
||||
}
|
||||
|
||||
function primeAiModeReceiptContext(files = []) {
|
||||
pruneAiModeReceiptRecognitionState(files)
|
||||
const promise = startAiModeReceiptRecognition(files)
|
||||
if (promise && typeof promise.catch === 'function') {
|
||||
promise.catch((error) => {
|
||||
console.warn('AI mode OCR preload failed:', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function collectAiModeReceiptContext(files = []) {
|
||||
const safeFiles = Array.isArray(files) ? files : []
|
||||
const ocrFiles = safeFiles.filter((file) => isLikelyAiModeOcrFile(file))
|
||||
const baseContext = buildAiModeReceiptBaseContext(safeFiles, ocrFiles)
|
||||
|
||||
if (!ocrFiles.length) {
|
||||
return baseContext
|
||||
}
|
||||
|
||||
const cacheKey = buildAiModeReceiptContextCacheKey(ocrFiles)
|
||||
const cached = cacheKey ? aiModeReceiptContextCache.get(cacheKey) : null
|
||||
if (cached?.status === 'resolved') {
|
||||
applyAiModeReceiptRecognitionResult(ocrFiles, cached.context)
|
||||
return {
|
||||
...baseContext,
|
||||
...cached.context
|
||||
}
|
||||
}
|
||||
if (cached?.status === 'pending' && cached.promise) {
|
||||
try {
|
||||
const cachedContext = await cached.promise
|
||||
applyAiModeReceiptRecognitionResult(ocrFiles, cachedContext)
|
||||
return {
|
||||
...baseContext,
|
||||
ocrPayload: cachedContext.ocrPayload,
|
||||
ocrSummary: cachedContext.ocrSummary,
|
||||
ocrDocuments: cachedContext.ocrDocuments,
|
||||
ocrFilePreviews: cachedContext.ocrFilePreviews
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('AI mode OCR preload result unavailable:', error)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setAiModeReceiptRecognitionState(ocrFiles, buildAiModeReceiptRecognitionPendingState())
|
||||
const collected = await collectReceiptFiles({
|
||||
files: ocrFiles,
|
||||
recognizeOcrFiles
|
||||
})
|
||||
return {
|
||||
...baseContext,
|
||||
ocrSummary: String(collected.ocrSummary || '').trim(),
|
||||
ocrDocuments: Array.isArray(collected.ocrDocuments) ? collected.ocrDocuments : []
|
||||
}
|
||||
const context = buildAiModeReceiptContextFromCollected(baseContext, collected)
|
||||
rememberAiModeReceiptContext(cacheKey, context)
|
||||
applyAiModeReceiptRecognitionResult(ocrFiles, context)
|
||||
return context
|
||||
} catch (error) {
|
||||
console.warn('AI mode OCR request failed:', error)
|
||||
setAiModeReceiptRecognitionState(ocrFiles, {
|
||||
status: 'failed',
|
||||
label: '识别失败',
|
||||
title: error?.message || '智能录入 OCR 识别失败'
|
||||
})
|
||||
return {
|
||||
...baseContext,
|
||||
ocrError: error?.message || 'OCR识别失败,已继续使用附件名称。'
|
||||
@@ -220,6 +439,13 @@ export function useWorkbenchAiAttachmentAssociationFlow({
|
||||
createExpenseClaimItem,
|
||||
uploadExpenseClaimItemAttachment
|
||||
})
|
||||
notifyRequestUpdated?.({
|
||||
claimId: runtime.claimId,
|
||||
claimNo: runtime.claimNo,
|
||||
source: 'ai-workbench-attachment-association-sync',
|
||||
uploadedCount: Number(syncResult?.uploadedCount || 0),
|
||||
skippedCount: Number(syncResult?.skippedCount || 0)
|
||||
})
|
||||
const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationResultMessage({
|
||||
claimNo: runtime.claimNo,
|
||||
fileNames: runtime.fileNames,
|
||||
@@ -281,10 +507,7 @@ export function useWorkbenchAiAttachmentAssociationFlow({
|
||||
scrollInlineConversationToBottom()
|
||||
|
||||
try {
|
||||
const collected = await collectReceiptFiles({
|
||||
files,
|
||||
recognizeOcrFiles
|
||||
})
|
||||
const collected = await collectAiModeReceiptContext(files)
|
||||
const attachmentOcrDetails = buildInlineAttachmentOcrDetails(collected, files)
|
||||
const claimsPayload = await fetchExpenseClaims({ page: 1, pageSize: 100 })
|
||||
const claims = extractExpenseClaimItems(claimsPayload)
|
||||
@@ -351,7 +574,9 @@ export function useWorkbenchAiAttachmentAssociationFlow({
|
||||
return {
|
||||
collectAiModeReceiptContext,
|
||||
confirmAiAttachmentAssociation,
|
||||
primeAiModeReceiptContext,
|
||||
requestAiAttachmentAssociationReply,
|
||||
resolveAiModeReceiptRecognitionState,
|
||||
resolveAiAttachmentAssociationClaimNo
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
buildRequiredApplicationSelectionText,
|
||||
filterRequiredApplicationCandidates
|
||||
} from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
|
||||
import {
|
||||
buildInlineAttachmentOcrDetails
|
||||
} from './workbenchAiMessageModel.js'
|
||||
|
||||
function shouldCheckAiRequiredApplicationGate(prompt) {
|
||||
const compact = String(prompt || '').replace(/\s+/g, '')
|
||||
@@ -269,6 +272,7 @@ export function useWorkbenchAiStewardFlow({
|
||||
}
|
||||
|
||||
const receiptContext = await collectAiModeReceiptContext(files)
|
||||
const attachmentOcrDetails = buildInlineAttachmentOcrDetails(receiptContext, files)
|
||||
const planRequest = buildStewardPlanRequest({
|
||||
rawText: prompt,
|
||||
files,
|
||||
@@ -330,7 +334,8 @@ export function useWorkbenchAiStewardFlow({
|
||||
},
|
||||
suggestedActions: requiredApplicationContinuationFlow
|
||||
? buildAiRequiredApplicationGateSuggestedActions(requiredApplicationContinuationFlow, prompt)
|
||||
: buildStewardSuggestedActions(plan)
|
||||
: buildStewardSuggestedActions(plan),
|
||||
attachmentOcrDetails
|
||||
})
|
||||
)
|
||||
persistCurrentConversation()
|
||||
|
||||
@@ -32,6 +32,7 @@ export function fetchReceiptFolderAsset(pathOrUrl) {
|
||||
throw new Error('票据文件地址为空。')
|
||||
}
|
||||
return apiRequest(target, {
|
||||
cache: 'no-store',
|
||||
responseType: 'blob'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -145,6 +145,7 @@
|
||||
@ai-conversation-history-change="handleAiConversationHistoryChange"
|
||||
@open-assistant="openSmartEntry"
|
||||
@open-document="openWorkbenchDocument"
|
||||
@request-updated="handleRequestUpdated"
|
||||
/>
|
||||
|
||||
<TravelRequestDetailView
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
@conversation-change="emit('ai-conversation-change', $event)"
|
||||
@conversation-history-change="emit('ai-conversation-history-change', $event)"
|
||||
@open-document="emit('open-document', $event)"
|
||||
@request-updated="emit('request-updated', $event)"
|
||||
/>
|
||||
<PersonalWorkbench
|
||||
v-else
|
||||
@@ -31,7 +32,7 @@ defineProps({
|
||||
aiSidebarCommand: { type: Object, default: () => ({ seq: 0, type: '', payload: null }) }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['open-assistant', 'open-document', 'ai-conversation-change', 'ai-conversation-history-change'])
|
||||
const emit = defineEmits(['open-assistant', 'open-document', 'ai-conversation-change', 'ai-conversation-history-change', 'request-updated'])
|
||||
</script>
|
||||
|
||||
<style scoped src="../assets/styles/views/personal-workbench-view.css"></style>
|
||||
|
||||
@@ -367,6 +367,7 @@ import EnterpriseDetailCard from '../components/shared/EnterpriseDetailCard.vue'
|
||||
import EnterpriseDetailPage from '../components/shared/EnterpriseDetailPage.vue'
|
||||
import TableEmptyState from '../components/shared/TableEmptyState.vue'
|
||||
import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
||||
import { useToast } from '../composables/useToast.js'
|
||||
import { fetchExpenseClaims } from '../services/reimbursements.js'
|
||||
import {
|
||||
buildReceiptFile,
|
||||
@@ -383,6 +384,7 @@ import { createReceiptFolderListFilterModel } from './scripts/receiptFolderListF
|
||||
const NEW_CLAIM_VALUE = '__new_claim__'
|
||||
const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size }))
|
||||
const emit = defineEmits(['open-assistant', 'detail-open-change', 'detail-topbar-change'])
|
||||
const { toast } = useToast()
|
||||
|
||||
const activeStatus = ref('all')
|
||||
const keyword = ref('')
|
||||
@@ -687,6 +689,7 @@ async function deleteCurrentReceipt() {
|
||||
await deleteReceiptFolderItem(selectedReceipt.value.id)
|
||||
backToList()
|
||||
await reloadReceipts()
|
||||
toast('已从票据夹删除;已关联到报销单的附件副本会保留。')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
|
||||
@@ -59,6 +59,24 @@ export function buildUnsavedDraftAttachmentConfirmationMessage({ fileNames = []
|
||||
].join('\n').trim()
|
||||
}
|
||||
|
||||
function normalizeOcrDocumentFields(item = {}) {
|
||||
const sources = [
|
||||
item?.document_fields,
|
||||
item?.fields,
|
||||
item?.document_info?.fields,
|
||||
item?.metadata?.fields
|
||||
]
|
||||
const fields = sources.find((source) => Array.isArray(source)) || []
|
||||
return fields
|
||||
.map((field) => {
|
||||
const label = String(field?.label || field?.name || field?.key || '').trim()
|
||||
const key = String(field?.key || field?.name || label || '').trim()
|
||||
const value = String(field?.value || field?.text || '').trim()
|
||||
return { key, label, value }
|
||||
})
|
||||
.filter((field) => field.key && field.label && field.value)
|
||||
}
|
||||
|
||||
export function normalizeOcrDocuments(payload) {
|
||||
const documents = Array.isArray(payload?.documents) ? payload.documents : []
|
||||
return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => ({
|
||||
@@ -78,15 +96,7 @@ export function normalizeOcrDocuments(payload) {
|
||||
receipt_status: String(item.receipt_status || item.receiptStatus || '').trim(),
|
||||
receipt_preview_url: String(item.receipt_preview_url || item.receiptPreviewUrl || '').trim(),
|
||||
receipt_source_url: String(item.receipt_source_url || item.receiptSourceUrl || '').trim(),
|
||||
document_fields: Array.isArray(item.document_fields)
|
||||
? item.document_fields
|
||||
.map((field) => ({
|
||||
key: String(field?.key || '').trim(),
|
||||
label: String(field?.label || '').trim(),
|
||||
value: String(field?.value || '').trim()
|
||||
}))
|
||||
.filter((field) => field.key && field.label && field.value)
|
||||
: [],
|
||||
document_fields: normalizeOcrDocumentFields(item),
|
||||
warnings: Array.isArray(item.warnings) ? item.warnings : []
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from '../../services/reimbursements.js'
|
||||
import {
|
||||
formatCurrency,
|
||||
isSystemGeneratedExpenseItemSource,
|
||||
normalizeIsoDateValue
|
||||
} from './travelRequestDetailExpenseModel.js'
|
||||
|
||||
@@ -109,11 +110,24 @@ export function subscribeSmartEntryRecognitionTask(claimId, listener) {
|
||||
|
||||
function resolveSmartEntryTaskAvailableItems(itemSnapshots) {
|
||||
return (Array.isArray(itemSnapshots) ? itemSnapshots : [])
|
||||
.filter((item) => item && !item.isSystemGenerated && !item.invoiceId)
|
||||
.filter((item) => item && !isSystemGeneratedExpenseItemSource(item) && !item.invoiceId)
|
||||
.map((item) => ({ id: String(item.id || '').trim() }))
|
||||
.filter((item) => item.id)
|
||||
}
|
||||
|
||||
export function resolveCreatedSmartEntryRecognitionItem(items = [], knownItemIds = new Set()) {
|
||||
return (Array.isArray(items) ? items : []).find((entry) => {
|
||||
const itemId = String(entry?.id || '').trim()
|
||||
const invoiceId = String(entry?.invoiceId || entry?.invoice_id || '').trim()
|
||||
return (
|
||||
itemId
|
||||
&& !knownItemIds.has(itemId)
|
||||
&& !invoiceId
|
||||
&& !isSystemGeneratedExpenseItemSource(entry)
|
||||
)
|
||||
}) || null
|
||||
}
|
||||
|
||||
async function resolveSmartEntryRecognitionTaskItem(task) {
|
||||
const availableItem = task.availableItems.shift()
|
||||
if (availableItem?.id) {
|
||||
@@ -122,10 +136,7 @@ async function resolveSmartEntryRecognitionTaskItem(task) {
|
||||
|
||||
const claim = await createExpenseClaimItem(task.claimId, {})
|
||||
const items = Array.isArray(claim?.items) ? claim.items : []
|
||||
const createdItem = items.find((entry) => {
|
||||
const itemId = String(entry?.id || '').trim()
|
||||
return itemId && !task.knownItemIds.has(itemId)
|
||||
})
|
||||
const createdItem = resolveCreatedSmartEntryRecognitionItem(items, task.knownItemIds)
|
||||
|
||||
if (!createdItem) {
|
||||
throw new Error('新增费用明细失败,请稍后重试。')
|
||||
|
||||
@@ -156,6 +156,35 @@ function buildEmptyEditor() {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveApplicationPreviewDateDraftValue(fieldKey = '', dateState = {}) {
|
||||
if (fieldKey === 'time_return') {
|
||||
return dateState.rangeEndDate || dateState.singleDate || getTodayDateValue()
|
||||
}
|
||||
return dateState.rangeStartDate || dateState.singleDate || getTodayDateValue()
|
||||
}
|
||||
|
||||
function validateApplicationPreviewDateRange(value = '') {
|
||||
const dates = parseEditorDateMatches(value)
|
||||
if (dates.length < 2) {
|
||||
return {
|
||||
valid: true,
|
||||
message: ''
|
||||
}
|
||||
}
|
||||
const startDate = dates[0]
|
||||
const endDate = dates[dates.length - 1]
|
||||
if (startDate > endDate) {
|
||||
return {
|
||||
valid: false,
|
||||
message: '出发时间不能晚于返回时间,请重新选择。'
|
||||
}
|
||||
}
|
||||
return {
|
||||
valid: true,
|
||||
message: ''
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRefreshTransportEstimate(fieldKey) {
|
||||
return ['transportMode', 'time', 'time_return', 'location', 'days'].includes(fieldKey)
|
||||
}
|
||||
@@ -271,6 +300,18 @@ export function useApplicationPreviewEditor({
|
||||
return fieldKey === 'transportMode' ? APPLICATION_TRANSPORT_MODE_OPTIONS : []
|
||||
}
|
||||
|
||||
function resolveApplicationPreviewEditorDateMin(message, fieldKey) {
|
||||
if (fieldKey !== 'time_return') return ''
|
||||
const dateState = parseEditorDateValue(message?.applicationPreview?.fields?.time)
|
||||
return dateState.rangeStartDate || dateState.singleDate || ''
|
||||
}
|
||||
|
||||
function resolveApplicationPreviewEditorDateMax(message, fieldKey) {
|
||||
if (fieldKey !== 'time') return ''
|
||||
const dateState = parseEditorDateValue(message?.applicationPreview?.fields?.time)
|
||||
return dateState.dateMode === 'range' ? dateState.rangeEndDate : ''
|
||||
}
|
||||
|
||||
function isApplicationPreviewEditing(message, fieldKey) {
|
||||
return (
|
||||
String(applicationPreviewEditor.value.messageId || '') === String(message?.id || '') &&
|
||||
@@ -288,12 +329,15 @@ export function useApplicationPreviewEditor({
|
||||
const dateState = isApplicationPreviewDateField(fieldKey)
|
||||
? parseEditorDateValue(fields.time || normalizedValue)
|
||||
: {}
|
||||
const draftValue = isApplicationPreviewDateField(fieldKey)
|
||||
? resolveApplicationPreviewDateDraftValue(fieldKey, dateState)
|
||||
: fieldKey === 'transportMode' && !APPLICATION_TRANSPORT_MODE_OPTIONS.includes(normalizedValue)
|
||||
? ''
|
||||
: normalizedValue
|
||||
applicationPreviewEditor.value = {
|
||||
messageId: String(message.id || ''),
|
||||
fieldKey,
|
||||
draftValue: fieldKey === 'transportMode' && !APPLICATION_TRANSPORT_MODE_OPTIONS.includes(normalizedValue)
|
||||
? ''
|
||||
: normalizedValue,
|
||||
draftValue,
|
||||
committing: false,
|
||||
...dateState
|
||||
}
|
||||
@@ -351,6 +395,17 @@ export function useApplicationPreviewEditor({
|
||||
}
|
||||
return false
|
||||
}
|
||||
const dateValidation = isApplicationPreviewDateField(editor.fieldKey)
|
||||
? validateApplicationPreviewDateRange(nextValue)
|
||||
: { valid: true, message: '' }
|
||||
if (!dateValidation.valid) {
|
||||
toast?.(dateValidation.message || '请确认返回时间不早于出发时间。')
|
||||
applicationPreviewEditor.value = {
|
||||
...applicationPreviewEditor.value,
|
||||
committing: false
|
||||
}
|
||||
return false
|
||||
}
|
||||
const nextPreview = normalizeApplicationPreview({
|
||||
...message.applicationPreview,
|
||||
fields: buildEditedApplicationPreviewFields(
|
||||
@@ -403,6 +458,8 @@ export function useApplicationPreviewEditor({
|
||||
resolveApplicationPreviewRows,
|
||||
resolveApplicationPreviewEditorControl,
|
||||
resolveApplicationPreviewEditorOptions,
|
||||
resolveApplicationPreviewEditorDateMin,
|
||||
resolveApplicationPreviewEditorDateMax,
|
||||
refreshApplicationPreviewEstimate,
|
||||
isApplicationPreviewEditing,
|
||||
isApplicationPreviewDateEditorOpen,
|
||||
|
||||
@@ -80,10 +80,14 @@ export function useTravelReimbursementCreateViewControls({
|
||||
pendingText: `已选择草稿 ${record.claimNo},正在识别并归集附件...`,
|
||||
files,
|
||||
uploadDisposition: 'continue_existing',
|
||||
skipDraftAssociationPrompt: true,
|
||||
associationConfirmed: true,
|
||||
extraContext: {
|
||||
review_action: 'link_to_existing_draft',
|
||||
draft_claim_id: claimId,
|
||||
selected_claim_id: claimId,
|
||||
selected_claim_no: String(record?.claimNo || '').trim()
|
||||
selected_claim_no: String(record?.claimNo || '').trim(),
|
||||
attachment_association_confirmed: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -640,6 +640,36 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
const fallbackAnswer = reviewActionResult === 'link_to_existing_draft'
|
||||
? (resultClaimNo ? `已将本次上传的票据关联到草稿 ${resultClaimNo}。` : '已将本次上传的票据关联到现有草稿。')
|
||||
: '智能体已完成处理。'
|
||||
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
|
||||
let attachmentSyncCompleted = false
|
||||
const persistComposerFilesToDraft = async () => {
|
||||
try {
|
||||
const syncResult = await syncComposerFilesToDraft(resolvedDraftClaimId, files)
|
||||
persistSessionState()
|
||||
if (detailScopedUpload) {
|
||||
emitRequestUpdated?.({
|
||||
claimId: resolvedDraftClaimId,
|
||||
source: 'detail-smart-entry-attachment-sync',
|
||||
uploadedCount: Number(syncResult?.uploadedCount || 0),
|
||||
skippedCount: Number(syncResult?.skippedCount || 0)
|
||||
})
|
||||
}
|
||||
return syncResult
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist composer attachments to draft claim:', error)
|
||||
toast(error?.message || '票据已归集到草稿,但附件原件保存失败,请在单据详情中重新上传。')
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (
|
||||
reviewActionResult === 'link_to_existing_draft' &&
|
||||
!effectiveIsKnowledgeSession &&
|
||||
resolvedDraftClaimId &&
|
||||
files.length
|
||||
) {
|
||||
await persistComposerFilesToDraft()
|
||||
attachmentSyncCompleted = true
|
||||
}
|
||||
const operationFeedbackContext = String(payload?.status || '').trim() === 'succeeded'
|
||||
? emitOperationCompleted?.(payload, {
|
||||
operationType: feedbackOperationType || reviewActionResult || (files.length ? 'attachment_review' : 'assistant_round')
|
||||
@@ -702,31 +732,15 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
}
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
|
||||
if (!effectiveIsKnowledgeSession && resolvedDraftClaimId && files.length) {
|
||||
const persistComposerFilesToDraft = async () => {
|
||||
try {
|
||||
const syncResult = await syncComposerFilesToDraft(resolvedDraftClaimId, files)
|
||||
persistSessionState()
|
||||
if (detailScopedUpload) {
|
||||
emitRequestUpdated?.({
|
||||
claimId: resolvedDraftClaimId,
|
||||
source: 'detail-smart-entry-attachment-sync',
|
||||
uploadedCount: Number(syncResult?.uploadedCount || 0),
|
||||
skippedCount: Number(syncResult?.skippedCount || 0)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist composer attachments to draft claim:', error)
|
||||
toast(error?.message || '票据已归集到草稿,但附件原件保存失败,请在单据详情中重新上传。')
|
||||
if (!attachmentSyncCompleted) {
|
||||
const persistTask = persistComposerFilesToDraft()
|
||||
if (detailScopedUpload) {
|
||||
await persistTask
|
||||
} else {
|
||||
void persistTask
|
||||
}
|
||||
}
|
||||
const persistTask = persistComposerFilesToDraft()
|
||||
if (detailScopedUpload) {
|
||||
await persistTask
|
||||
} else {
|
||||
void persistTask
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
clearFlowSimulationTimers()
|
||||
|
||||
Reference in New Issue
Block a user