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;
|
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 {
|
.workbench-ai-file-card__remove {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
@@ -2035,7 +2077,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.application-preview-input {
|
.application-preview-input {
|
||||||
width: 100%;
|
width: min(100%, 420px);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
@@ -2049,7 +2091,34 @@
|
|||||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.11);
|
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 {
|
.application-preview-select {
|
||||||
|
width: min(100%, 240px);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,17 @@
|
|||||||
<span class="workbench-ai-file-card__body">
|
<span class="workbench-ai-file-card__body">
|
||||||
<strong :title="file.name">{{ file.name }}</strong>
|
<strong :title="file.name">{{ file.name }}</strong>
|
||||||
<small>{{ file.typeLabel }}</small>
|
<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>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -424,9 +435,23 @@
|
|||||||
<span class="application-preview-label" role="cell">{{ row.label }}</span>
|
<span class="application-preview-label" role="cell">{{ row.label }}</span>
|
||||||
<span class="application-preview-value" role="cell">
|
<span class="application-preview-value" role="cell">
|
||||||
<input
|
<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"
|
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"
|
type="text"
|
||||||
autofocus
|
autofocus
|
||||||
:disabled="isApplicationPreviewEstimatePending(message)"
|
:disabled="isApplicationPreviewEstimatePending(message)"
|
||||||
@@ -437,7 +462,7 @@
|
|||||||
<select
|
<select
|
||||||
v-else-if="isApplicationPreviewEditing(message, row.key) && resolveInlineApplicationPreviewEditorControl(row.key) === 'select'"
|
v-else-if="isApplicationPreviewEditing(message, row.key) && resolveInlineApplicationPreviewEditorControl(row.key) === 'select'"
|
||||||
v-model="applicationPreviewEditor.draftValue"
|
v-model="applicationPreviewEditor.draftValue"
|
||||||
class="application-preview-input application-preview-select"
|
:class="['application-preview-input', 'application-preview-select', `application-preview-input--${row.key}`]"
|
||||||
autofocus
|
autofocus
|
||||||
:disabled="isApplicationPreviewEstimatePending(message)"
|
:disabled="isApplicationPreviewEstimatePending(message)"
|
||||||
@click.stop
|
@click.stop
|
||||||
@@ -548,6 +573,17 @@
|
|||||||
<span class="workbench-ai-file-card__body">
|
<span class="workbench-ai-file-card__body">
|
||||||
<strong :title="file.name">{{ file.name }}</strong>
|
<strong :title="file.name">{{ file.name }}</strong>
|
||||||
<small>{{ file.typeLabel }}</small>
|
<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>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import { usePersonalWorkbenchAiMode } from '../../composables/workbenchAiMode/us
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
sidebarCommand: { type: Object, default: () => ({ seq: 0, type: '', payload: null }) }
|
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 {
|
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)
|
} = usePersonalWorkbenchAiMode(props, emit)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
const {
|
const {
|
||||||
applicationPreviewEditor,
|
applicationPreviewEditor,
|
||||||
resolveApplicationPreviewEditorControl,
|
resolveApplicationPreviewEditorControl,
|
||||||
|
resolveApplicationPreviewEditorDateMax,
|
||||||
|
resolveApplicationPreviewEditorDateMin,
|
||||||
resolveApplicationPreviewEditorOptions,
|
resolveApplicationPreviewEditorOptions,
|
||||||
refreshApplicationPreviewEstimate,
|
refreshApplicationPreviewEstimate,
|
||||||
isApplicationPreviewEditing,
|
isApplicationPreviewEditing,
|
||||||
@@ -112,7 +114,6 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
} = useWorkbenchComposerDate({ draft: assistantDraft, focusInput: focusAiModeInput })
|
} = useWorkbenchComposerDate({ draft: assistantDraft, focusInput: focusAiModeInput })
|
||||||
|
|
||||||
const aiModeActionItems = AI_MODE_ACTION_ITEMS
|
const aiModeActionItems = AI_MODE_ACTION_ITEMS
|
||||||
const selectedFileCards = computed(() => buildSelectedFileCards(selectedFiles.value))
|
|
||||||
const displayUserName = computed(() => {
|
const displayUserName = computed(() => {
|
||||||
const user = currentUser.value || {}
|
const user = currentUser.value || {}
|
||||||
return String(user.name || user.username || '同事').trim() || '同事'
|
return String(user.name || user.username || '同事').trim() || '同事'
|
||||||
@@ -161,9 +162,19 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
scrollInlineConversationToBottom,
|
scrollInlineConversationToBottom,
|
||||||
sending,
|
sending,
|
||||||
streamOrSetInlineAssistantContent,
|
streamOrSetInlineAssistantContent,
|
||||||
|
notifyRequestUpdated: (payload) => emit('request-updated', payload),
|
||||||
toast
|
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({
|
const applicationFlow = useWorkbenchAiApplicationPreviewFlow({
|
||||||
activateInlineConversation,
|
activateInlineConversation,
|
||||||
applicationPreviewEditor,
|
applicationPreviewEditor,
|
||||||
@@ -189,6 +200,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
refreshApplicationPreviewEstimate,
|
refreshApplicationPreviewEstimate,
|
||||||
removeWorkbenchDateTag,
|
removeWorkbenchDateTag,
|
||||||
replaceInlineMessage,
|
replaceInlineMessage,
|
||||||
|
resolveApplicationPreviewEditorDateMax,
|
||||||
|
resolveApplicationPreviewEditorDateMin,
|
||||||
resolveApplicationPreviewEditorControl,
|
resolveApplicationPreviewEditorControl,
|
||||||
resolveApplicationPreviewEditorOptions,
|
resolveApplicationPreviewEditorOptions,
|
||||||
resolveInlineThinkingEvents,
|
resolveInlineThinkingEvents,
|
||||||
@@ -776,6 +789,8 @@ export function usePersonalWorkbenchAiMode(props, emit) {
|
|||||||
requestDeleteCurrentConversation: () => sessionCommands.requestDeleteCurrentConversation(deleteDialogOpen),
|
requestDeleteCurrentConversation: () => sessionCommands.requestDeleteCurrentConversation(deleteDialogOpen),
|
||||||
resolveApplicationPreviewEditorOptions: applicationFlow.resolveApplicationPreviewEditorOptions,
|
resolveApplicationPreviewEditorOptions: applicationFlow.resolveApplicationPreviewEditorOptions,
|
||||||
resolveInlineApplicationPreviewEditorControl: applicationFlow.resolveInlineApplicationPreviewEditorControl,
|
resolveInlineApplicationPreviewEditorControl: applicationFlow.resolveInlineApplicationPreviewEditorControl,
|
||||||
|
resolveInlineApplicationPreviewEditorDateMax: applicationFlow.resolveInlineApplicationPreviewEditorDateMax,
|
||||||
|
resolveInlineApplicationPreviewEditorDateMin: applicationFlow.resolveInlineApplicationPreviewEditorDateMin,
|
||||||
resolveInlineApplicationPreviewMissingFields: applicationFlow.resolveInlineApplicationPreviewMissingFields,
|
resolveInlineApplicationPreviewMissingFields: applicationFlow.resolveInlineApplicationPreviewMissingFields,
|
||||||
resolveInlineApplicationPreviewRows: applicationFlow.resolveInlineApplicationPreviewRows,
|
resolveInlineApplicationPreviewRows: applicationFlow.resolveInlineApplicationPreviewRows,
|
||||||
resolveInlineAttachmentOcrDocuments,
|
resolveInlineAttachmentOcrDocuments,
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
|||||||
refreshApplicationPreviewEstimate,
|
refreshApplicationPreviewEstimate,
|
||||||
removeWorkbenchDateTag,
|
removeWorkbenchDateTag,
|
||||||
replaceInlineMessage,
|
replaceInlineMessage,
|
||||||
|
resolveApplicationPreviewEditorDateMax,
|
||||||
|
resolveApplicationPreviewEditorDateMin,
|
||||||
resolveApplicationPreviewEditorControl,
|
resolveApplicationPreviewEditorControl,
|
||||||
resolveApplicationPreviewEditorOptions,
|
resolveApplicationPreviewEditorOptions,
|
||||||
resolveInlineThinkingEvents,
|
resolveInlineThinkingEvents,
|
||||||
@@ -105,8 +107,15 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveInlineApplicationPreviewEditorControl(fieldKey) {
|
function resolveInlineApplicationPreviewEditorControl(fieldKey) {
|
||||||
const control = resolveApplicationPreviewEditorControl(fieldKey)
|
return resolveApplicationPreviewEditorControl(fieldKey)
|
||||||
return control === 'date' ? 'text' : control
|
}
|
||||||
|
|
||||||
|
function resolveInlineApplicationPreviewEditorDateMin(message, fieldKey) {
|
||||||
|
return resolveApplicationPreviewEditorDateMin?.(message, fieldKey) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveInlineApplicationPreviewEditorDateMax(message, fieldKey) {
|
||||||
|
return resolveApplicationPreviewEditorDateMax?.(message, fieldKey) || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildInlineApplicationPreviewSuggestedActions(applicationPreview = {}, draftPayload = null) {
|
function buildInlineApplicationPreviewSuggestedActions(applicationPreview = {}, draftPayload = null) {
|
||||||
@@ -180,6 +189,14 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
|||||||
return '申请核对表已补齐,费用测算已同步。你仍可点击表格继续修改;确认无误后,可以点击下方按钮保存草稿或直接提交,也可以直接回复“保存草稿”或“提交”。'
|
return '申请核对表已补齐,费用测算已同步。你仍可点击表格继续修改;确认无误后,可以点击下方按钮保存草稿或直接提交,也可以直接回复“保存草稿”或“提交”。'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildInlineApplicationActionFailureText(error, isSubmit) {
|
||||||
|
return [
|
||||||
|
isSubmit ? '### 申请提交失败' : '### 申请草稿保存失败',
|
||||||
|
error?.message || (isSubmit ? '申请提交失败,请稍后重试。' : '申请草稿保存失败,请稍后重试。'),
|
||||||
|
'我已保留当前申请核对表,您可以修改后重试,也可以稍后再次保存。'
|
||||||
|
].join('\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
function resolveLatestApplicationPreviewMessage() {
|
function resolveLatestApplicationPreviewMessage() {
|
||||||
return [...conversationMessages.value]
|
return [...conversationMessages.value]
|
||||||
.reverse()
|
.reverse()
|
||||||
@@ -385,8 +402,14 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
replaceInlineMessage(
|
replaceInlineMessage(
|
||||||
pendingMessage.id,
|
pendingMessage.id,
|
||||||
createInlineMessage('assistant', error?.message || (isSubmit ? '申请提交失败,请稍后重试。' : '申请草稿保存失败,请稍后重试。'), {
|
createInlineMessage('assistant', buildInlineApplicationActionFailureText(error, isSubmit), {
|
||||||
id: pendingMessage.id,
|
id: pendingMessage.id,
|
||||||
|
applicationPreview: targetMessage.applicationPreview,
|
||||||
|
draftPayload: targetMessage.draftPayload || options.draftPayload || null,
|
||||||
|
suggestedActions: buildInlineApplicationPreviewSuggestedActions(
|
||||||
|
targetMessage.applicationPreview,
|
||||||
|
targetMessage.draftPayload || options.draftPayload || null
|
||||||
|
),
|
||||||
stewardPlan: {
|
stewardPlan: {
|
||||||
streamStatus: 'failed',
|
streamStatus: 'failed',
|
||||||
thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({
|
thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({
|
||||||
@@ -504,6 +527,8 @@ export function useWorkbenchAiApplicationPreviewFlow({
|
|||||||
openApplicationPreviewEditor,
|
openApplicationPreviewEditor,
|
||||||
resolveApplicationPreviewEditorOptions,
|
resolveApplicationPreviewEditorOptions,
|
||||||
resolveInlineApplicationPreviewEditorControl,
|
resolveInlineApplicationPreviewEditorControl,
|
||||||
|
resolveInlineApplicationPreviewEditorDateMax,
|
||||||
|
resolveInlineApplicationPreviewEditorDateMin,
|
||||||
resolveInlineApplicationPreviewMissingFields,
|
resolveInlineApplicationPreviewMissingFields,
|
||||||
resolveInlineApplicationPreviewRows,
|
resolveInlineApplicationPreviewRows,
|
||||||
startAiApplicationPreview
|
startAiApplicationPreview
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
import * as aiAttachmentAssociationModel from '../../utils/aiAttachmentAssociationModel.js'
|
import * as aiAttachmentAssociationModel from '../../utils/aiAttachmentAssociationModel.js'
|
||||||
import { syncExpenseClaimFilesToDraft } from '../../utils/expenseClaimAttachmentSync.js'
|
import { syncExpenseClaimFilesToDraft } from '../../utils/expenseClaimAttachmentSync.js'
|
||||||
import { collectReceiptFiles } from '../../views/scripts/travelReimbursementAttachmentModel.js'
|
import {
|
||||||
|
buildFileIdentity,
|
||||||
|
collectReceiptFiles
|
||||||
|
} from '../../views/scripts/travelReimbursementAttachmentModel.js'
|
||||||
import {
|
import {
|
||||||
createExpenseClaimItem,
|
createExpenseClaimItem,
|
||||||
extractExpenseClaimItems,
|
extractExpenseClaimItems,
|
||||||
@@ -76,42 +81,256 @@ export function useWorkbenchAiAttachmentAssociationFlow({
|
|||||||
scrollInlineConversationToBottom,
|
scrollInlineConversationToBottom,
|
||||||
sending,
|
sending,
|
||||||
streamOrSetInlineAssistantContent,
|
streamOrSetInlineAssistantContent,
|
||||||
|
notifyRequestUpdated,
|
||||||
toast
|
toast
|
||||||
}) {
|
}) {
|
||||||
async function collectAiModeReceiptContext(files = []) {
|
const aiModeReceiptContextCache = new Map()
|
||||||
const safeFiles = Array.isArray(files) ? files : []
|
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
|
const attachmentNames = safeFiles
|
||||||
.map((file) => String(file?.name || '').trim())
|
.map((file) => String(file?.name || '').trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
const ocrFiles = safeFiles.filter((file) => isLikelyAiModeOcrFile(file))
|
|
||||||
const ocrSourceFileNames = ocrFiles
|
const ocrSourceFileNames = ocrFiles
|
||||||
.map((file) => String(file?.name || '').trim())
|
.map((file) => String(file?.name || '').trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
return {
|
||||||
const baseContext = {
|
|
||||||
attachmentNames,
|
attachmentNames,
|
||||||
attachmentCount: attachmentNames.length,
|
attachmentCount: attachmentNames.length,
|
||||||
ocrSourceFileNames,
|
ocrSourceFileNames,
|
||||||
ocrSummary: '',
|
ocrSummary: '',
|
||||||
ocrDocuments: []
|
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) {
|
if (!ocrFiles.length) {
|
||||||
return baseContext
|
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 {
|
try {
|
||||||
|
setAiModeReceiptRecognitionState(ocrFiles, buildAiModeReceiptRecognitionPendingState())
|
||||||
const collected = await collectReceiptFiles({
|
const collected = await collectReceiptFiles({
|
||||||
files: ocrFiles,
|
files: ocrFiles,
|
||||||
recognizeOcrFiles
|
recognizeOcrFiles
|
||||||
})
|
})
|
||||||
return {
|
const context = buildAiModeReceiptContextFromCollected(baseContext, collected)
|
||||||
...baseContext,
|
rememberAiModeReceiptContext(cacheKey, context)
|
||||||
ocrSummary: String(collected.ocrSummary || '').trim(),
|
applyAiModeReceiptRecognitionResult(ocrFiles, context)
|
||||||
ocrDocuments: Array.isArray(collected.ocrDocuments) ? collected.ocrDocuments : []
|
return context
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('AI mode OCR request failed:', error)
|
console.warn('AI mode OCR request failed:', error)
|
||||||
|
setAiModeReceiptRecognitionState(ocrFiles, {
|
||||||
|
status: 'failed',
|
||||||
|
label: '识别失败',
|
||||||
|
title: error?.message || '智能录入 OCR 识别失败'
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
...baseContext,
|
...baseContext,
|
||||||
ocrError: error?.message || 'OCR识别失败,已继续使用附件名称。'
|
ocrError: error?.message || 'OCR识别失败,已继续使用附件名称。'
|
||||||
@@ -220,6 +439,13 @@ export function useWorkbenchAiAttachmentAssociationFlow({
|
|||||||
createExpenseClaimItem,
|
createExpenseClaimItem,
|
||||||
uploadExpenseClaimItemAttachment
|
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({
|
const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationResultMessage({
|
||||||
claimNo: runtime.claimNo,
|
claimNo: runtime.claimNo,
|
||||||
fileNames: runtime.fileNames,
|
fileNames: runtime.fileNames,
|
||||||
@@ -281,10 +507,7 @@ export function useWorkbenchAiAttachmentAssociationFlow({
|
|||||||
scrollInlineConversationToBottom()
|
scrollInlineConversationToBottom()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const collected = await collectReceiptFiles({
|
const collected = await collectAiModeReceiptContext(files)
|
||||||
files,
|
|
||||||
recognizeOcrFiles
|
|
||||||
})
|
|
||||||
const attachmentOcrDetails = buildInlineAttachmentOcrDetails(collected, files)
|
const attachmentOcrDetails = buildInlineAttachmentOcrDetails(collected, files)
|
||||||
const claimsPayload = await fetchExpenseClaims({ page: 1, pageSize: 100 })
|
const claimsPayload = await fetchExpenseClaims({ page: 1, pageSize: 100 })
|
||||||
const claims = extractExpenseClaimItems(claimsPayload)
|
const claims = extractExpenseClaimItems(claimsPayload)
|
||||||
@@ -351,7 +574,9 @@ export function useWorkbenchAiAttachmentAssociationFlow({
|
|||||||
return {
|
return {
|
||||||
collectAiModeReceiptContext,
|
collectAiModeReceiptContext,
|
||||||
confirmAiAttachmentAssociation,
|
confirmAiAttachmentAssociation,
|
||||||
|
primeAiModeReceiptContext,
|
||||||
requestAiAttachmentAssociationReply,
|
requestAiAttachmentAssociationReply,
|
||||||
|
resolveAiModeReceiptRecognitionState,
|
||||||
resolveAiAttachmentAssociationClaimNo
|
resolveAiAttachmentAssociationClaimNo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import {
|
|||||||
buildRequiredApplicationSelectionText,
|
buildRequiredApplicationSelectionText,
|
||||||
filterRequiredApplicationCandidates
|
filterRequiredApplicationCandidates
|
||||||
} from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
|
} from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
|
||||||
|
import {
|
||||||
|
buildInlineAttachmentOcrDetails
|
||||||
|
} from './workbenchAiMessageModel.js'
|
||||||
|
|
||||||
function shouldCheckAiRequiredApplicationGate(prompt) {
|
function shouldCheckAiRequiredApplicationGate(prompt) {
|
||||||
const compact = String(prompt || '').replace(/\s+/g, '')
|
const compact = String(prompt || '').replace(/\s+/g, '')
|
||||||
@@ -269,6 +272,7 @@ export function useWorkbenchAiStewardFlow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const receiptContext = await collectAiModeReceiptContext(files)
|
const receiptContext = await collectAiModeReceiptContext(files)
|
||||||
|
const attachmentOcrDetails = buildInlineAttachmentOcrDetails(receiptContext, files)
|
||||||
const planRequest = buildStewardPlanRequest({
|
const planRequest = buildStewardPlanRequest({
|
||||||
rawText: prompt,
|
rawText: prompt,
|
||||||
files,
|
files,
|
||||||
@@ -330,7 +334,8 @@ export function useWorkbenchAiStewardFlow({
|
|||||||
},
|
},
|
||||||
suggestedActions: requiredApplicationContinuationFlow
|
suggestedActions: requiredApplicationContinuationFlow
|
||||||
? buildAiRequiredApplicationGateSuggestedActions(requiredApplicationContinuationFlow, prompt)
|
? buildAiRequiredApplicationGateSuggestedActions(requiredApplicationContinuationFlow, prompt)
|
||||||
: buildStewardSuggestedActions(plan)
|
: buildStewardSuggestedActions(plan),
|
||||||
|
attachmentOcrDetails
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
persistCurrentConversation()
|
persistCurrentConversation()
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export function fetchReceiptFolderAsset(pathOrUrl) {
|
|||||||
throw new Error('票据文件地址为空。')
|
throw new Error('票据文件地址为空。')
|
||||||
}
|
}
|
||||||
return apiRequest(target, {
|
return apiRequest(target, {
|
||||||
|
cache: 'no-store',
|
||||||
responseType: 'blob'
|
responseType: 'blob'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,7 @@
|
|||||||
@ai-conversation-history-change="handleAiConversationHistoryChange"
|
@ai-conversation-history-change="handleAiConversationHistoryChange"
|
||||||
@open-assistant="openSmartEntry"
|
@open-assistant="openSmartEntry"
|
||||||
@open-document="openWorkbenchDocument"
|
@open-document="openWorkbenchDocument"
|
||||||
|
@request-updated="handleRequestUpdated"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TravelRequestDetailView
|
<TravelRequestDetailView
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
@conversation-change="emit('ai-conversation-change', $event)"
|
@conversation-change="emit('ai-conversation-change', $event)"
|
||||||
@conversation-history-change="emit('ai-conversation-history-change', $event)"
|
@conversation-history-change="emit('ai-conversation-history-change', $event)"
|
||||||
@open-document="emit('open-document', $event)"
|
@open-document="emit('open-document', $event)"
|
||||||
|
@request-updated="emit('request-updated', $event)"
|
||||||
/>
|
/>
|
||||||
<PersonalWorkbench
|
<PersonalWorkbench
|
||||||
v-else
|
v-else
|
||||||
@@ -31,7 +32,7 @@ defineProps({
|
|||||||
aiSidebarCommand: { type: Object, default: () => ({ seq: 0, type: '', payload: null }) }
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped src="../assets/styles/views/personal-workbench-view.css"></style>
|
<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 EnterpriseDetailPage from '../components/shared/EnterpriseDetailPage.vue'
|
||||||
import TableEmptyState from '../components/shared/TableEmptyState.vue'
|
import TableEmptyState from '../components/shared/TableEmptyState.vue'
|
||||||
import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
import TableLoadingState from '../components/shared/TableLoadingState.vue'
|
||||||
|
import { useToast } from '../composables/useToast.js'
|
||||||
import { fetchExpenseClaims } from '../services/reimbursements.js'
|
import { fetchExpenseClaims } from '../services/reimbursements.js'
|
||||||
import {
|
import {
|
||||||
buildReceiptFile,
|
buildReceiptFile,
|
||||||
@@ -383,6 +384,7 @@ import { createReceiptFolderListFilterModel } from './scripts/receiptFolderListF
|
|||||||
const NEW_CLAIM_VALUE = '__new_claim__'
|
const NEW_CLAIM_VALUE = '__new_claim__'
|
||||||
const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size }))
|
const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size }))
|
||||||
const emit = defineEmits(['open-assistant', 'detail-open-change', 'detail-topbar-change'])
|
const emit = defineEmits(['open-assistant', 'detail-open-change', 'detail-topbar-change'])
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
const activeStatus = ref('all')
|
const activeStatus = ref('all')
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
@@ -687,6 +689,7 @@ async function deleteCurrentReceipt() {
|
|||||||
await deleteReceiptFolderItem(selectedReceipt.value.id)
|
await deleteReceiptFolderItem(selectedReceipt.value.id)
|
||||||
backToList()
|
backToList()
|
||||||
await reloadReceipts()
|
await reloadReceipts()
|
||||||
|
toast('已从票据夹删除;已关联到报销单的附件副本会保留。')
|
||||||
} finally {
|
} finally {
|
||||||
deleting.value = false
|
deleting.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,24 @@ export function buildUnsavedDraftAttachmentConfirmationMessage({ fileNames = []
|
|||||||
].join('\n').trim()
|
].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) {
|
export function normalizeOcrDocuments(payload) {
|
||||||
const documents = Array.isArray(payload?.documents) ? payload.documents : []
|
const documents = Array.isArray(payload?.documents) ? payload.documents : []
|
||||||
return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => ({
|
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_status: String(item.receipt_status || item.receiptStatus || '').trim(),
|
||||||
receipt_preview_url: String(item.receipt_preview_url || item.receiptPreviewUrl || '').trim(),
|
receipt_preview_url: String(item.receipt_preview_url || item.receiptPreviewUrl || '').trim(),
|
||||||
receipt_source_url: String(item.receipt_source_url || item.receiptSourceUrl || '').trim(),
|
receipt_source_url: String(item.receipt_source_url || item.receiptSourceUrl || '').trim(),
|
||||||
document_fields: Array.isArray(item.document_fields)
|
document_fields: normalizeOcrDocumentFields(item),
|
||||||
? 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)
|
|
||||||
: [],
|
|
||||||
warnings: Array.isArray(item.warnings) ? item.warnings : []
|
warnings: Array.isArray(item.warnings) ? item.warnings : []
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
} from '../../services/reimbursements.js'
|
} from '../../services/reimbursements.js'
|
||||||
import {
|
import {
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
|
isSystemGeneratedExpenseItemSource,
|
||||||
normalizeIsoDateValue
|
normalizeIsoDateValue
|
||||||
} from './travelRequestDetailExpenseModel.js'
|
} from './travelRequestDetailExpenseModel.js'
|
||||||
|
|
||||||
@@ -109,11 +110,24 @@ export function subscribeSmartEntryRecognitionTask(claimId, listener) {
|
|||||||
|
|
||||||
function resolveSmartEntryTaskAvailableItems(itemSnapshots) {
|
function resolveSmartEntryTaskAvailableItems(itemSnapshots) {
|
||||||
return (Array.isArray(itemSnapshots) ? 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() }))
|
.map((item) => ({ id: String(item.id || '').trim() }))
|
||||||
.filter((item) => item.id)
|
.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) {
|
async function resolveSmartEntryRecognitionTaskItem(task) {
|
||||||
const availableItem = task.availableItems.shift()
|
const availableItem = task.availableItems.shift()
|
||||||
if (availableItem?.id) {
|
if (availableItem?.id) {
|
||||||
@@ -122,10 +136,7 @@ async function resolveSmartEntryRecognitionTaskItem(task) {
|
|||||||
|
|
||||||
const claim = await createExpenseClaimItem(task.claimId, {})
|
const claim = await createExpenseClaimItem(task.claimId, {})
|
||||||
const items = Array.isArray(claim?.items) ? claim.items : []
|
const items = Array.isArray(claim?.items) ? claim.items : []
|
||||||
const createdItem = items.find((entry) => {
|
const createdItem = resolveCreatedSmartEntryRecognitionItem(items, task.knownItemIds)
|
||||||
const itemId = String(entry?.id || '').trim()
|
|
||||||
return itemId && !task.knownItemIds.has(itemId)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!createdItem) {
|
if (!createdItem) {
|
||||||
throw new Error('新增费用明细失败,请稍后重试。')
|
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) {
|
function shouldRefreshTransportEstimate(fieldKey) {
|
||||||
return ['transportMode', 'time', 'time_return', 'location', 'days'].includes(fieldKey)
|
return ['transportMode', 'time', 'time_return', 'location', 'days'].includes(fieldKey)
|
||||||
}
|
}
|
||||||
@@ -271,6 +300,18 @@ export function useApplicationPreviewEditor({
|
|||||||
return fieldKey === 'transportMode' ? APPLICATION_TRANSPORT_MODE_OPTIONS : []
|
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) {
|
function isApplicationPreviewEditing(message, fieldKey) {
|
||||||
return (
|
return (
|
||||||
String(applicationPreviewEditor.value.messageId || '') === String(message?.id || '') &&
|
String(applicationPreviewEditor.value.messageId || '') === String(message?.id || '') &&
|
||||||
@@ -288,12 +329,15 @@ export function useApplicationPreviewEditor({
|
|||||||
const dateState = isApplicationPreviewDateField(fieldKey)
|
const dateState = isApplicationPreviewDateField(fieldKey)
|
||||||
? parseEditorDateValue(fields.time || normalizedValue)
|
? parseEditorDateValue(fields.time || normalizedValue)
|
||||||
: {}
|
: {}
|
||||||
|
const draftValue = isApplicationPreviewDateField(fieldKey)
|
||||||
|
? resolveApplicationPreviewDateDraftValue(fieldKey, dateState)
|
||||||
|
: fieldKey === 'transportMode' && !APPLICATION_TRANSPORT_MODE_OPTIONS.includes(normalizedValue)
|
||||||
|
? ''
|
||||||
|
: normalizedValue
|
||||||
applicationPreviewEditor.value = {
|
applicationPreviewEditor.value = {
|
||||||
messageId: String(message.id || ''),
|
messageId: String(message.id || ''),
|
||||||
fieldKey,
|
fieldKey,
|
||||||
draftValue: fieldKey === 'transportMode' && !APPLICATION_TRANSPORT_MODE_OPTIONS.includes(normalizedValue)
|
draftValue,
|
||||||
? ''
|
|
||||||
: normalizedValue,
|
|
||||||
committing: false,
|
committing: false,
|
||||||
...dateState
|
...dateState
|
||||||
}
|
}
|
||||||
@@ -351,6 +395,17 @@ export function useApplicationPreviewEditor({
|
|||||||
}
|
}
|
||||||
return false
|
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({
|
const nextPreview = normalizeApplicationPreview({
|
||||||
...message.applicationPreview,
|
...message.applicationPreview,
|
||||||
fields: buildEditedApplicationPreviewFields(
|
fields: buildEditedApplicationPreviewFields(
|
||||||
@@ -403,6 +458,8 @@ export function useApplicationPreviewEditor({
|
|||||||
resolveApplicationPreviewRows,
|
resolveApplicationPreviewRows,
|
||||||
resolveApplicationPreviewEditorControl,
|
resolveApplicationPreviewEditorControl,
|
||||||
resolveApplicationPreviewEditorOptions,
|
resolveApplicationPreviewEditorOptions,
|
||||||
|
resolveApplicationPreviewEditorDateMin,
|
||||||
|
resolveApplicationPreviewEditorDateMax,
|
||||||
refreshApplicationPreviewEstimate,
|
refreshApplicationPreviewEstimate,
|
||||||
isApplicationPreviewEditing,
|
isApplicationPreviewEditing,
|
||||||
isApplicationPreviewDateEditorOpen,
|
isApplicationPreviewDateEditorOpen,
|
||||||
|
|||||||
@@ -80,10 +80,14 @@ export function useTravelReimbursementCreateViewControls({
|
|||||||
pendingText: `已选择草稿 ${record.claimNo},正在识别并归集附件...`,
|
pendingText: `已选择草稿 ${record.claimNo},正在识别并归集附件...`,
|
||||||
files,
|
files,
|
||||||
uploadDisposition: 'continue_existing',
|
uploadDisposition: 'continue_existing',
|
||||||
|
skipDraftAssociationPrompt: true,
|
||||||
|
associationConfirmed: true,
|
||||||
extraContext: {
|
extraContext: {
|
||||||
|
review_action: 'link_to_existing_draft',
|
||||||
draft_claim_id: claimId,
|
draft_claim_id: claimId,
|
||||||
selected_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'
|
const fallbackAnswer = reviewActionResult === 'link_to_existing_draft'
|
||||||
? (resultClaimNo ? `已将本次上传的票据关联到草稿 ${resultClaimNo}。` : '已将本次上传的票据关联到现有草稿。')
|
? (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'
|
const operationFeedbackContext = String(payload?.status || '').trim() === 'succeeded'
|
||||||
? emitOperationCompleted?.(payload, {
|
? emitOperationCompleted?.(payload, {
|
||||||
operationType: feedbackOperationType || reviewActionResult || (files.length ? 'attachment_review' : 'assistant_round')
|
operationType: feedbackOperationType || reviewActionResult || (files.length ? 'attachment_review' : 'assistant_round')
|
||||||
@@ -702,31 +732,15 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
}
|
}
|
||||||
persistSessionState()
|
persistSessionState()
|
||||||
nextTick(scrollToBottom)
|
nextTick(scrollToBottom)
|
||||||
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
|
|
||||||
if (!effectiveIsKnowledgeSession && resolvedDraftClaimId && files.length) {
|
if (!effectiveIsKnowledgeSession && resolvedDraftClaimId && files.length) {
|
||||||
const persistComposerFilesToDraft = async () => {
|
if (!attachmentSyncCompleted) {
|
||||||
try {
|
const persistTask = persistComposerFilesToDraft()
|
||||||
const syncResult = await syncComposerFilesToDraft(resolvedDraftClaimId, files)
|
if (detailScopedUpload) {
|
||||||
persistSessionState()
|
await persistTask
|
||||||
if (detailScopedUpload) {
|
} else {
|
||||||
emitRequestUpdated?.({
|
void persistTask
|
||||||
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 || '票据已归集到草稿,但附件原件保存失败,请在单据详情中重新上传。')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const persistTask = persistComposerFilesToDraft()
|
|
||||||
if (detailScopedUpload) {
|
|
||||||
await persistTask
|
|
||||||
} else {
|
|
||||||
void persistTask
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearFlowSimulationTimers()
|
clearFlowSimulationTimers()
|
||||||
|
|||||||
@@ -263,6 +263,36 @@ test('OCR documents keep full recognized text for backend context', () => {
|
|||||||
assert.match(documents[0].text, /电子客票号:E1234567890/)
|
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 () => {
|
test('receipt files are collected through a single OCR persistence entry before draft association', async () => {
|
||||||
const files = [
|
const files = [
|
||||||
{ name: 'invoice.png' }
|
{ name: 'invoice.png' }
|
||||||
|
|||||||
@@ -1902,6 +1902,80 @@ test('application preview editor can edit return date from inline table input',
|
|||||||
assert.equal(message.applicationPreview.fields.days, '5\u5929')
|
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 () => {
|
test('application preview editor estimates after shorthand return date input', async () => {
|
||||||
const preview = normalizeApplicationPreview({
|
const preview = normalizeApplicationPreview({
|
||||||
fields: {
|
fields: {
|
||||||
|
|||||||
90
web/tests/expense-attachment-draft-selection.test.mjs
Normal file
90
web/tests/expense-attachment-draft-selection.test.mjs
Normal 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\(\)/
|
||||||
|
)
|
||||||
|
})
|
||||||
14
web/tests/receipt-folder-asset-cache.test.mjs
Normal file
14
web/tests/receipt-folder-asset-cache.test.mjs
Normal 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'/)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -229,12 +229,17 @@ test('AI mode screen follows the approved reference structure', () => {
|
|||||||
assert.match(aiModeSurface, /rows="3"/)
|
assert.match(aiModeSurface, /rows="3"/)
|
||||||
assert.match(aiModeSurface, /workbench-ai-composer-toolbar/)
|
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, /<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, /:aria-label="`移除附件 \$\{file\.name\}`"/)
|
||||||
assert.match(aiModeSurface, /function removeAiModeFile\(fileKey\)/)
|
assert.match(aiModeSurface, /function removeAiModeFile\(fileKey\)/)
|
||||||
assert.match(aiModeSurface, /const selectedFileCards = computed/)
|
assert.match(aiModeSurface, /const selectedFileCards = computed/)
|
||||||
assert.match(aiModeSurface, /resolveAiComposerFileType\(file\)/)
|
assert.match(aiModeSurface, /resolveAiComposerFileType\(file\)/)
|
||||||
assert.match(aiModeSurface, /AI_COMPOSER_FILE_TYPE_META = \{[\s\S]*pdf:\s*\{ label:\s*'PDF'/)
|
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, /MAX_ATTACHMENTS,[\s\S]*mergeFilesWithLimit[\s\S]*travelReimbursementAttachmentModel\.js/)
|
||||||
assert.match(aiModeSurface, /import \* as aiAttachmentAssociationModel from '\.\.\/\.\.\/utils\/aiAttachmentAssociationModel\.js'/)
|
assert.match(aiModeSurface, /import \* as aiAttachmentAssociationModel from '\.\.\/\.\.\/utils\/aiAttachmentAssociationModel\.js'/)
|
||||||
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.resolveAiAttachmentAssociationMatch/)
|
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, /function findAiAttachmentAssociationRuntime\(options = \{\}\)/)
|
||||||
assert.match(aiModeSurface, /resolveAiAttachmentAssociationClaimNo\(actionPayload\)/)
|
assert.match(aiModeSurface, /resolveAiAttachmentAssociationClaimNo\(actionPayload\)/)
|
||||||
assert.match(aiModeSurface, /if \(actionType === AI_ATTACHMENT_OCR_DETAIL_ACTION\)/)
|
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, /const claims = extractExpenseClaimItems\(claimsPayload\)/)
|
||||||
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.resolveAiAttachmentAssociationMatch\(claims, collected\.ocrDocuments\)/)
|
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.resolveAiAttachmentAssociationMatch\(claims, collected\.ocrDocuments\)/)
|
||||||
assert.match(aiModeSurface, /aiAttachmentAssociationRuntime\.set\(associationId/)
|
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, /mdi mdi-calendar-range/)
|
||||||
assert.match(aiModeSurface, /workbench-ai-date-popover/)
|
assert.match(aiModeSurface, /workbench-ai-date-popover/)
|
||||||
assert.match(aiModeSurface, /type="date"/)
|
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.doesNotMatch(aiModeSurface, /mdi mdi-web/)
|
||||||
assert.match(aiModeSurface, /mdi mdi-microphone-outline/)
|
assert.match(aiModeSurface, /mdi mdi-microphone-outline/)
|
||||||
assert.match(aiModeSurface, /mdi mdi-arrow-up/)
|
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-action-link\)/)
|
||||||
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-table-wrap\)/)
|
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, /\.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, /import \{ fetchSettings \} from '\.\.\/\.\.\/services\/settings\.js'/)
|
||||||
assert.match(aiModeSurface, /fetchStewardPlan,[\s\S]*fetchStewardPlanStream[\s\S]*services\/steward\.js'/)
|
assert.match(aiModeSurface, /fetchStewardPlan,[\s\S]*fetchStewardPlanStream[\s\S]*services\/steward\.js'/)
|
||||||
assert.match(aiModeSurface, /import \{ useWorkbenchComposerDate \} from '\.\.\/useWorkbenchComposerDate\.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, /buildStewardPlanRequest/)
|
||||||
assert.match(aiModeSurface, /buildStewardPlanMessageText/)
|
assert.match(aiModeSurface, /buildStewardPlanMessageText/)
|
||||||
assert.match(aiModeSurface, /buildStewardSuggestedActions/)
|
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, /function startInlineConversation\(prompt, entry = \{\}, files = \[\]\)/)
|
||||||
assert.match(aiModeSurface, /activateInlineConversation\(\{[\s\S]*title:[\s\S]*\}\)[\s\S]*conversationMessages\.value\.push\(createInlineMessage\('user'/)
|
assert.match(aiModeSurface, /activateInlineConversation\(\{[\s\S]*title:[\s\S]*\}\)[\s\S]*conversationMessages\.value\.push\(createInlineMessage\('user'/)
|
||||||
assert.match(aiModeSurface, /persistCurrentConversation\(\)/)
|
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 \(!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, /if \(actionType === AI_APPLICATION_ACTION_SUBMIT\) \{[\s\S]*buildInlineApplicationResultTable\(draftPayload/)
|
||||||
assert.match(aiModeSurface, /需要查看完整详情时,请点击卡片“操作”行的“查看”进入单据详情。/)
|
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, /\*\*申请单号:\*\*/)
|
||||||
assert.doesNotMatch(aiModeSurface, /createInlineMessage\('assistant', buildStewardPlanMessageText\(plan\)/)
|
assert.doesNotMatch(aiModeSurface, /createInlineMessage\('assistant', buildStewardPlanMessageText\(plan\)/)
|
||||||
assert.doesNotMatch(aiModeSurface, /runOrchestrator\(/)
|
assert.doesNotMatch(aiModeSurface, /runOrchestrator\(/)
|
||||||
@@ -489,14 +512,51 @@ test('AI mode screen follows the approved reference structure', () => {
|
|||||||
assert.ok(pngPresentation.minimumForegroundHeightRatio > 0.9)
|
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', () => {
|
test('AI mode normal assistant requests include OCR context for uploaded receipts', () => {
|
||||||
assert.match(aiModeSurface, /function isLikelyAiModeOcrFile\(file = \{\}\)/)
|
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, /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, /collectReceiptFiles\(\{[\s\S]*files:\s*ocrFiles,[\s\S]*recognizeOcrFiles[\s\S]*\}\)/)
|
||||||
assert.match(aiModeSurface, /const receiptContext = await collectAiModeReceiptContext\(files\)/)
|
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_summary:\s*receiptContext\.ocrSummary/)
|
||||||
assert.match(aiModeSurface, /ocr_documents:\s*receiptContext\.ocrDocuments/)
|
assert.match(aiModeSurface, /ocr_documents:\s*receiptContext\.ocrDocuments/)
|
||||||
assert.match(aiModeSurface, /attachment_names:\s*receiptContext\.attachmentNames/)
|
assert.match(aiModeSurface, /attachment_names:\s*receiptContext\.attachmentNames/)
|
||||||
assert.match(aiModeSurface, /attachment_count:\s*receiptContext\.attachmentCount/)
|
assert.match(aiModeSurface, /attachment_count:\s*receiptContext\.attachmentCount/)
|
||||||
assert.match(aiModeSurface, /ocr_source_file_names:\s*receiptContext\.ocrSourceFileNames/)
|
assert.match(aiModeSurface, /ocr_source_file_names:\s*receiptContext\.ocrSourceFileNames/)
|
||||||
|
assert.match(aiModeSurface, /attachmentOcrDetails/)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user