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:
caoxiaozhu
2026-06-23 09:42:13 +08:00
parent 84a8998e59
commit e725b7f19c
22 changed files with 850 additions and 70 deletions

View File

@@ -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;
}

View File

@@ -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"

View File

@@ -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>

View File

@@ -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,

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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()

View File

@@ -32,6 +32,7 @@ export function fetchReceiptFolderAsset(pathOrUrl) {
throw new Error('票据文件地址为空。')
}
return apiRequest(target, {
cache: 'no-store',
responseType: 'blob'
})
}

View File

@@ -145,6 +145,7 @@
@ai-conversation-history-change="handleAiConversationHistoryChange"
@open-assistant="openSmartEntry"
@open-document="openWorkbenchDocument"
@request-updated="handleRequestUpdated"
/>
<TravelRequestDetailView

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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 : []
}))
}

View File

@@ -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('新增费用明细失败,请稍后重试。')

View File

@@ -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,

View File

@@ -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
}
})
}

View File

@@ -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()

View File

@@ -263,6 +263,36 @@ test('OCR documents keep full recognized text for backend context', () => {
assert.match(documents[0].text, /电子客票号E1234567890/)
})
test('OCR documents normalize receipt-folder field shapes for AI cards', () => {
const documents = normalizeOcrDocuments({
documents: [
{
filename: 'train-ticket.png',
document_info: {
fields: [
{ label: '身份证号', value: '4201061987****1615' },
{ key: 'seat_no', label: '座位号', value: '01B' }
]
}
},
{
filename: 'hotel.png',
fields: [
{ name: 'amount', label: '金额', value: '450元' }
]
}
]
})
assert.deepEqual(documents[0].document_fields, [
{ key: '身份证号', label: '身份证号', value: '4201061987****1615' },
{ key: 'seat_no', label: '座位号', value: '01B' }
])
assert.deepEqual(documents[1].document_fields, [
{ key: 'amount', label: '金额', value: '450元' }
])
})
test('receipt files are collected through a single OCR persistence entry before draft association', async () => {
const files = [
{ name: 'invoice.png' }

View File

@@ -1902,6 +1902,80 @@ test('application preview editor can edit return date from inline table input',
assert.equal(message.applicationPreview.fields.days, '5\u5929')
})
test('application preview editor opens date fields with native date input values', () => {
const preview = normalizeApplicationPreview({
fields: {
applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7',
time: '2026-02-20 \u81f3 2026-02-23',
location: '\u4e0a\u6d77',
reason: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72',
days: '4\u5929'
}
})
const message = {
id: 'application-preview-editor-native-date-message',
applicationPreview: preview,
text: ''
}
const editor = useApplicationPreviewEditor({
persistSessionState: () => {},
toast: () => {}
})
editor.openApplicationPreviewEditor(message, 'time', message.applicationPreview.fields.time)
assert.equal(editor.resolveApplicationPreviewEditorControl('time'), 'date')
assert.equal(editor.applicationPreviewEditor.value.draftValue, '2026-02-20')
assert.equal(editor.resolveApplicationPreviewEditorDateMax(message, 'time'), '2026-02-23')
editor.openApplicationPreviewEditor(message, 'time_return', '2026-02-23')
assert.equal(editor.resolveApplicationPreviewEditorControl('time_return'), 'date')
assert.equal(editor.applicationPreviewEditor.value.draftValue, '2026-02-23')
assert.equal(editor.resolveApplicationPreviewEditorDateMin(message, 'time_return'), '2026-02-20')
})
test('application preview editor blocks invalid date ranges', async () => {
const preview = normalizeApplicationPreview({
fields: {
applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7',
time: '2026-02-20 \u81f3 2026-02-23',
location: '\u4e0a\u6d77',
reason: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72',
days: '4\u5929',
transportMode: '\u706b\u8f66',
amount: ''
}
})
const message = {
id: 'application-preview-editor-invalid-date-message',
applicationPreview: preview,
text: ''
}
const toastMessages = []
const editor = useApplicationPreviewEditor({
persistSessionState: () => {},
toast: (messageText) => {
toastMessages.push(messageText)
}
})
editor.openApplicationPreviewEditor(message, 'time_return', '2026-02-23')
editor.applicationPreviewEditor.value.draftValue = '2026-02-19'
const returnCommitted = await editor.commitApplicationPreviewEditor(message)
assert.equal(returnCommitted, false)
assert.equal(message.applicationPreview.fields.time, '2026-02-20 \u81f3 2026-02-23')
assert.equal(message.applicationPreview.fields.days, '4\u5929')
assert.equal(toastMessages.at(-1), '\u51fa\u53d1\u65f6\u95f4\u4e0d\u80fd\u665a\u4e8e\u8fd4\u56de\u65f6\u95f4\uff0c\u8bf7\u91cd\u65b0\u9009\u62e9\u3002')
editor.openApplicationPreviewEditor(message, 'time', message.applicationPreview.fields.time)
editor.applicationPreviewEditor.value.draftValue = '2026-02-24'
const startCommitted = await editor.commitApplicationPreviewEditor(message)
assert.equal(startCommitted, false)
assert.equal(message.applicationPreview.fields.time, '2026-02-20 \u81f3 2026-02-23')
assert.equal(toastMessages.at(-1), '\u51fa\u53d1\u65f6\u95f4\u4e0d\u80fd\u665a\u4e8e\u8fd4\u56de\u65f6\u95f4\uff0c\u8bf7\u91cd\u65b0\u9009\u62e9\u3002')
})
test('application preview editor estimates after shorthand return date input', async () => {
const preview = normalizeApplicationPreview({
fields: {

View File

@@ -0,0 +1,90 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { useTravelReimbursementCreateViewControls } from '../src/views/scripts/useTravelReimbursementCreateViewControls.js'
function ref(value) {
return { value }
}
const submitComposerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
'utf8'
)
test('选择候选草稿时直接确认归集并带入附件原件', async () => {
const submitCalls = []
const attachedFiles = [{ name: '2月20 武汉-上海.pdf' }]
const message = {
queryPayload: {
selectionMode: 'draft_association'
}
}
const controls = useTravelReimbursementCreateViewControls({
activeSessionType: ref('expense'),
attachedFiles: ref(attachedFiles),
clearAssistantSessionSnapshot: () => {},
closeAfterBusy: ref(false),
conversationId: ref('conversation-1'),
deleteConversation: async () => {},
deleteSessionBusy: ref(false),
deleteSessionDialogOpen: ref(false),
draftClaimId: ref(''),
emitClose: () => {},
getExpenseQueryActivePage: () => 1,
getExpenseQueryTotalPages: () => 1,
persistSessionState: () => {},
resetCurrentSessionState: () => {},
reviewActionBusy: ref(false),
router: { push: () => {} },
resolveCurrentUserId: () => 'user-1',
sessionSwitchBusy: ref(false),
submitComposer: async (options) => {
submitCalls.push(options)
return { ok: true }
},
submitting: ref(false),
toast: () => {},
workbenchVisible: ref(true)
})
await controls.handleExpenseQueryRecordClick(message, {
claimId: 'claim-1',
claimNo: 'R74CB7C2R'
})
assert.equal(submitCalls.length, 1)
assert.equal(submitCalls[0].associationConfirmed, true)
assert.equal(submitCalls[0].skipDraftAssociationPrompt, true)
assert.equal(submitCalls[0].uploadDisposition, 'continue_existing')
assert.deepEqual(submitCalls[0].files, attachedFiles)
assert.equal(submitCalls[0].files[0], attachedFiles[0])
assert.equal(submitCalls[0].extraContext.review_action, 'link_to_existing_draft')
assert.equal(submitCalls[0].extraContext.attachment_association_confirmed, true)
assert.equal(submitCalls[0].extraContext.draft_claim_id, 'claim-1')
assert.equal(message.queryPayload.selectionLocked, true)
assert.equal(message.queryPayload.selectedClaimId, 'claim-1')
})
test('确认归集到现有草稿时先同步附件再渲染最终结果', () => {
assert.match(
submitComposerScript,
/let attachmentSyncCompleted = false/
)
assert.match(
submitComposerScript,
/if \(\s*reviewActionResult === 'link_to_existing_draft'[\s\S]*await persistComposerFilesToDraft\(\)[\s\S]*attachmentSyncCompleted = true[\s\S]*\}/
)
assert.ok(
submitComposerScript.indexOf('await persistComposerFilesToDraft()') <
submitComposerScript.indexOf('const assistantMessage = createMessage('),
'附件同步应先于最终助手消息,避免详情页先展示空明细和旧风险'
)
assert.match(
submitComposerScript,
/if \(!attachmentSyncCompleted\) \{\s*const persistTask = persistComposerFilesToDraft\(\)/
)
})

View File

@@ -0,0 +1,14 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import test from 'node:test'
const root = process.cwd()
test('receipt folder asset fetch bypasses stale preview cache', () => {
const service = readFileSync(join(root, 'web/src/services/receiptFolder.js'), 'utf8')
assert.match(service, /export function fetchReceiptFolderAsset/)
assert.match(service, /cache: 'no-store'/)
assert.match(service, /responseType: 'blob'/)
})

View File

@@ -0,0 +1,35 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
resolveCreatedSmartEntryRecognitionItem
} from '../src/views/scripts/travelRequestDetailSmartEntryRecognition.js'
test('智能录入创建明细后跳过系统补贴行', () => {
const createdItem = resolveCreatedSmartEntryRecognitionItem([
{
id: 'allowance-item',
item_type: 'travel_allowance',
invoice_id: ''
},
{
id: 'business-item',
item_type: 'travel',
invoice_id: ''
}
], new Set())
assert.equal(createdItem?.id, 'business-item')
})
test('智能录入创建明细后没有可上传业务行时返回空', () => {
const createdItem = resolveCreatedSmartEntryRecognitionItem([
{
id: 'allowance-item',
item_type: 'travel_allowance',
invoice_id: ''
}
], new Set())
assert.equal(createdItem, null)
})

View File

@@ -229,12 +229,17 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /rows="3"/)
assert.match(aiModeSurface, /workbench-ai-composer-toolbar/)
assert.match(aiModeSurface, /<article v-for="file in selectedFileCards"[\s\S]*class="workbench-ai-file-card"/)
assert.match(aiModeSurface, /class="workbench-ai-file-card__ocr"/)
assert.match(aiModeSurface, /file\.ocrState\?\.label/)
assert.match(aiModeSurface, /mdi mdi-text-recognition/)
assert.match(aiModeStyles, /\.workbench-ai-file-card__ocr/)
assert.match(aiModeStyles, /workbenchAiOcrSpin/)
assert.match(aiModeSurface, /:aria-label="`移除附件 \$\{file\.name\}`"/)
assert.match(aiModeSurface, /function removeAiModeFile\(fileKey\)/)
assert.match(aiModeSurface, /const selectedFileCards = computed/)
assert.match(aiModeSurface, /resolveAiComposerFileType\(file\)/)
assert.match(aiModeSurface, /AI_COMPOSER_FILE_TYPE_META = \{[\s\S]*pdf:\s*\{ label:\s*'PDF'/)
assert.match(aiModeSurface, /import \{ collectReceiptFiles \} from '\.\.\/\.\.\/views\/scripts\/travelReimbursementAttachmentModel\.js'/)
assert.match(aiModeSurface, /buildFileIdentity,[\s\S]*collectReceiptFiles[\s\S]*travelReimbursementAttachmentModel\.js/)
assert.match(aiModeSurface, /MAX_ATTACHMENTS,[\s\S]*mergeFilesWithLimit[\s\S]*travelReimbursementAttachmentModel\.js/)
assert.match(aiModeSurface, /import \* as aiAttachmentAssociationModel from '\.\.\/\.\.\/utils\/aiAttachmentAssociationModel\.js'/)
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.resolveAiAttachmentAssociationMatch/)
@@ -261,7 +266,7 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /function findAiAttachmentAssociationRuntime\(options = \{\}\)/)
assert.match(aiModeSurface, /resolveAiAttachmentAssociationClaimNo\(actionPayload\)/)
assert.match(aiModeSurface, /if \(actionType === AI_ATTACHMENT_OCR_DETAIL_ACTION\)/)
assert.match(aiModeSurface, /collectReceiptFiles\(\{[\s\S]*files,[\s\S]*recognizeOcrFiles[\s\S]*\}\)/)
assert.match(aiModeSurface, /const collected = await collectAiModeReceiptContext\(files\)/)
assert.match(aiModeSurface, /const claims = extractExpenseClaimItems\(claimsPayload\)/)
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.resolveAiAttachmentAssociationMatch\(claims, collected\.ocrDocuments\)/)
assert.match(aiModeSurface, /aiAttachmentAssociationRuntime\.set\(associationId/)
@@ -278,6 +283,14 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /mdi mdi-calendar-range/)
assert.match(aiModeSurface, /workbench-ai-date-popover/)
assert.match(aiModeSurface, /type="date"/)
assert.match(aiModeSurface, /:min="resolveInlineApplicationPreviewEditorDateMin\(message, row\.key\)"/)
assert.match(aiModeSurface, /:max="resolveInlineApplicationPreviewEditorDateMax\(message, row\.key\)"/)
assert.match(aiModeSurface, /resolveInlineApplicationPreviewEditorControl\(row\.key\) === 'date'/)
assert.match(aiModeSurface, /class="\['application-preview-input', 'application-preview-date-input', `application-preview-input--\$\{row\.key\}`\]"/)
assert.match(aiModeSurface, /function resolveInlineApplicationPreviewEditorControl\(fieldKey\) \{[\s\S]*return resolveApplicationPreviewEditorControl\(fieldKey\)/)
assert.match(aiModeSurface, /function resolveInlineApplicationPreviewEditorDateMin\(message, fieldKey\) \{[\s\S]*return resolveApplicationPreviewEditorDateMin\?\.\(message, fieldKey\) \|\| ''/)
assert.match(aiModeSurface, /function resolveInlineApplicationPreviewEditorDateMax\(message, fieldKey\) \{[\s\S]*return resolveApplicationPreviewEditorDateMax\?\.\(message, fieldKey\) \|\| ''/)
assert.doesNotMatch(aiModeSurface, /return control === 'date' \? 'text' : control/)
assert.doesNotMatch(aiModeSurface, /mdi mdi-web/)
assert.match(aiModeSurface, /mdi mdi-microphone-outline/)
assert.match(aiModeSurface, /mdi mdi-arrow-up/)
@@ -342,6 +355,9 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-action-link\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-table-wrap\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-image-frame\)/)
assert.match(aiModeStyles, /\.application-preview-date-input\s*\{[\s\S]*width:\s*min\(100%,\s*188px\);/)
assert.match(aiModeStyles, /\.application-preview-input--location\s*\{[\s\S]*width:\s*min\(100%,\s*220px\);/)
assert.match(aiModeStyles, /\.application-preview-input--reason\s*\{[\s\S]*width:\s*min\(100%,\s*680px\);/)
assert.match(aiModeSurface, /import \{ fetchSettings \} from '\.\.\/\.\.\/services\/settings\.js'/)
assert.match(aiModeSurface, /fetchStewardPlan,[\s\S]*fetchStewardPlanStream[\s\S]*services\/steward\.js'/)
assert.match(aiModeSurface, /import \{ useWorkbenchComposerDate \} from '\.\.\/useWorkbenchComposerDate\.js'/)
@@ -354,7 +370,7 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /buildStewardPlanRequest/)
assert.match(aiModeSurface, /buildStewardPlanMessageText/)
assert.match(aiModeSurface, /buildStewardSuggestedActions/)
assert.match(aiModeSurface, /const emit = defineEmits\(\['conversation-change', 'conversation-history-change', 'open-document'\]\)/)
assert.match(aiModeSurface, /const emit = defineEmits\(\['conversation-change', 'conversation-history-change', 'open-document', 'request-updated'\]\)/)
assert.match(aiModeSurface, /function startInlineConversation\(prompt, entry = \{\}, files = \[\]\)/)
assert.match(aiModeSurface, /activateInlineConversation\(\{[\s\S]*title:[\s\S]*\}\)[\s\S]*conversationMessages\.value\.push\(createInlineMessage\('user'/)
assert.match(aiModeSurface, /persistCurrentConversation\(\)/)
@@ -370,6 +386,13 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeSurface, /if \(!hasServerStreamedContent\) \{[\s\S]*await streamInlineAssistantContent\(pendingMessage\.id, finalMessageText\)[\s\S]*\}/)
assert.match(aiModeSurface, /if \(actionType === AI_APPLICATION_ACTION_SUBMIT\) \{[\s\S]*buildInlineApplicationResultTable\(draftPayload/)
assert.match(aiModeSurface, /需要查看完整详情时,请点击卡片“操作”行的“查看”进入单据详情。/)
assert.match(aiModeSurface, /function buildInlineApplicationActionFailureText\(error, isSubmit\)/)
assert.match(aiModeSurface, /我已保留当前申请核对表/)
assert.match(aiModeSurface, /applicationPreview:\s*targetMessage\.applicationPreview/)
assert.match(
aiModeSurface,
/suggestedActions:\s*buildInlineApplicationPreviewSuggestedActions\([\s\S]*targetMessage\.applicationPreview/
)
assert.doesNotMatch(aiModeSurface, /\*\*申请单号:\*\*/)
assert.doesNotMatch(aiModeSurface, /createInlineMessage\('assistant', buildStewardPlanMessageText\(plan\)/)
assert.doesNotMatch(aiModeSurface, /runOrchestrator\(/)
@@ -489,14 +512,51 @@ test('AI mode screen follows the approved reference structure', () => {
assert.ok(pngPresentation.minimumForegroundHeightRatio > 0.9)
})
test('AI attachment association notifies shell to refresh the target detail page', () => {
const aiModeComponent = readSource('../src/components/business/PersonalWorkbenchAiMode.vue')
const workbenchView = readSource('../src/views/PersonalWorkbenchView.vue')
const appShellRouteView = readSource('../src/views/AppShellRouteView.vue')
const aiModeComposable = readSource('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js')
const attachmentFlow = readSource('../src/composables/workbenchAiMode/useWorkbenchAiAttachmentAssociationFlow.js')
assert.match(aiModeComponent, /defineEmits\(\[[^\]]*'request-updated'/)
assert.match(workbenchView, /@request-updated="emit\('request-updated', \$event\)"/)
assert.match(workbenchView, /defineEmits\(\[[^\]]*'request-updated'/)
assert.match(appShellRouteView, /<PersonalWorkbenchView[\s\S]*@request-updated="handleRequestUpdated"/)
assert.match(
aiModeComposable,
/notifyRequestUpdated:\s*\(payload\)\s*=>\s*emit\('request-updated', payload\)/
)
assert.match(
attachmentFlow,
/notifyRequestUpdated\?\.\(\{[\s\S]*claimId:[\s\S]*runtime\.claimId[\s\S]*uploadedCount:[\s\S]*syncResult\?\.uploadedCount/
)
})
test('AI mode normal assistant requests include OCR context for uploaded receipts', () => {
assert.match(aiModeSurface, /function isLikelyAiModeOcrFile\(file = \{\}\)/)
assert.match(aiModeSurface, /const aiModeReceiptContextCache = new Map\(\)/)
assert.match(aiModeSurface, /const aiModeReceiptRecognitionState = reactive\(\{\}\)/)
assert.match(aiModeSurface, /function resolveAiModeReceiptRecognitionState\(file\)/)
assert.match(aiModeSurface, /resolveAiModeReceiptRecognitionState\(selectedFiles\.value\[index\]\)/)
assert.match(aiModeSurface, /status:\s*'recognizing'[\s\S]*label:\s*'智能录入识别中'/)
assert.match(aiModeSurface, /status:\s*'recognized'[\s\S]*label:\s*detail \? `已识别票据/)
assert.match(aiModeSurface, /status:\s*'failed'[\s\S]*label:\s*'识别失败'/)
assert.match(aiModeSurface, /function primeAiModeReceiptContext\(files = \[\]\)/)
assert.match(aiModeSurface, /function startAiModeReceiptRecognition\(files = \[\]\)/)
assert.match(aiModeSurface, /function buildAiModeReceiptContextCacheKey\(ocrFiles = \[\]\)/)
assert.match(aiModeSurface, /applyAiModeReceiptRecognitionResult\(ocrFiles, context\)/)
assert.match(aiModeSurface, /buildFileIdentity\(file\)/)
assert.match(aiModeSurface, /watch\(selectedFiles, \(files\) => \{[\s\S]*attachmentFlow\.primeAiModeReceiptContext\(files\)/)
assert.match(aiModeSurface, /async function collectAiModeReceiptContext\(files = \[\]\)/)
assert.match(aiModeSurface, /cached\?\.status === 'pending'[\s\S]*await cached\.promise/)
assert.match(aiModeSurface, /collectReceiptFiles\(\{[\s\S]*files:\s*ocrFiles,[\s\S]*recognizeOcrFiles[\s\S]*\}\)/)
assert.match(aiModeSurface, /const receiptContext = await collectAiModeReceiptContext\(files\)/)
assert.match(aiModeSurface, /const attachmentOcrDetails = buildInlineAttachmentOcrDetails\(receiptContext, files\)/)
assert.match(aiModeSurface, /ocr_summary:\s*receiptContext\.ocrSummary/)
assert.match(aiModeSurface, /ocr_documents:\s*receiptContext\.ocrDocuments/)
assert.match(aiModeSurface, /attachment_names:\s*receiptContext\.attachmentNames/)
assert.match(aiModeSurface, /attachment_count:\s*receiptContext\.attachmentCount/)
assert.match(aiModeSurface, /ocr_source_file_names:\s*receiptContext\.ocrSourceFileNames/)
assert.match(aiModeSurface, /attachmentOcrDetails/)
})