diff --git a/web/src/assets/styles/components/workbench-ai-file-preview-dialog.css b/web/src/assets/styles/components/workbench-ai-file-preview-dialog.css new file mode 100644 index 0000000..b329044 --- /dev/null +++ b/web/src/assets/styles/components/workbench-ai-file-preview-dialog.css @@ -0,0 +1,276 @@ +.workbench-ai-file-preview-mask { + --workbench-ai-preview-sidebar-offset: var(--sidebar-expanded-width, 304px); + --workbench-ai-preview-edge-padding: 24px; + + position: fixed; + inset: 0; + z-index: 1200; + display: grid; + grid-template-columns: var(--workbench-ai-preview-sidebar-offset) minmax(0, 1fr); + align-items: center; + justify-items: center; + padding: var(--workbench-ai-preview-edge-padding); + background: rgba(15, 23, 42, 0.42); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +:global(.app.sidebar-collapsed) .workbench-ai-file-preview-mask { + --workbench-ai-preview-sidebar-offset: var(--sidebar-collapsed-width, 64px); +} + +.workbench-ai-file-preview-dialog { + grid-column: 2; + justify-self: center; + width: min( + 1160px, + calc(100vw - var(--workbench-ai-preview-sidebar-offset) - (var(--workbench-ai-preview-edge-padding) * 2)) + ); + max-height: calc(100vh - (var(--workbench-ai-preview-edge-padding) * 2)); + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 14px; + padding: 22px; + border: 1px solid rgba(var(--ai-theme-rgb), 0.16); + border-radius: 6px; + background: #ffffff; + box-shadow: 0 28px 56px rgba(15, 23, 42, 0.2); +} + +.workbench-ai-file-preview-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.workbench-ai-file-preview-badge { + min-height: 28px; + display: inline-flex; + align-items: center; + padding: 0 10px; + border-radius: 4px; + background: rgba(47, 124, 255, 0.11); + color: #1d4ed8; + font-size: 12px; + font-weight: 800; +} + +.workbench-ai-file-preview-head h3 { + margin: 10px 0 0; + color: #0f172a; + font-size: 20px; + line-height: 1.4; + font-weight: 850; +} + +.workbench-ai-file-preview-close { + width: 36px; + height: 36px; + display: inline-grid; + place-items: center; + border: 1px solid #d7e0ea; + border-radius: 4px; + background: rgba(255, 255, 255, 0.94); + color: #475569; + font-size: 18px; +} + +.workbench-ai-file-preview-body { + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.75fr); + gap: 16px; + overflow: hidden; +} + +.workbench-ai-file-preview-source, +.workbench-ai-file-preview-insight { + min-height: 0; + border: 1px solid #e2e8f0; + border-radius: 4px; + overflow: hidden; +} + +.workbench-ai-file-preview-source { + display: grid; + place-items: center; + background: #f8fafc; +} + +.workbench-ai-file-preview-image, +.workbench-ai-file-preview-frame { + width: 100%; + height: 100%; + min-height: 520px; + border: 0; + object-fit: contain; + background: #ffffff; +} + +.workbench-ai-file-preview-insight { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + align-content: start; + gap: 14px; + padding: 18px; + overflow-y: auto; + background: #ffffff; +} + +.workbench-ai-file-preview-insight-head { + display: grid; + gap: 6px; + padding-bottom: 14px; + border-bottom: 1px solid #e2e8f0; +} + +.workbench-ai-file-preview-insight-head span, +.workbench-ai-file-preview-section > span { + color: #64748b; + font-size: 12px; + font-weight: 800; +} + +.workbench-ai-file-preview-insight-head strong { + color: #0f172a; + font-size: 18px; + line-height: 1.35; + font-weight: 850; +} + +.workbench-ai-file-preview-status { + min-height: 34px; + display: inline-flex; + align-items: center; + gap: 8px; + justify-self: start; + padding: 0 12px; + border: 1px solid #dbeafe; + border-radius: 4px; + background: #eff6ff; + color: #1d4ed8; + font-size: 13px; + font-weight: 800; +} + +.workbench-ai-file-preview-status.is-recognizing i { + animation: workbenchAiOcrSpin 840ms linear infinite; +} + +@keyframes workbenchAiOcrSpin { + to { + transform: rotate(360deg); + } +} + +.workbench-ai-file-preview-status.is-recognized { + border-color: #bbf7d0; + background: #f0fdf4; + color: #047857; +} + +.workbench-ai-file-preview-status.is-failed { + border-color: #fecaca; + background: #fff1f2; + color: #dc2626; +} + +.workbench-ai-file-preview-section { + display: grid; + gap: 10px; + padding: 12px; + border-radius: 4px; + background: #f8fafc; +} + +.workbench-ai-file-preview-section dl { + display: grid; + grid-template-columns: 88px minmax(0, 1fr); + gap: 8px 12px; + margin: 0; +} + +.workbench-ai-file-preview-section dt, +.workbench-ai-file-preview-section dd, +.workbench-ai-file-preview-section p { + margin: 0; + color: #334155; + font-size: 13px; + line-height: 1.55; +} + +.workbench-ai-file-preview-section dt { + color: #64748b; + font-weight: 750; +} + +.workbench-ai-file-preview-section dd { + min-width: 0; + overflow-wrap: anywhere; + font-weight: 760; +} + +.workbench-ai-file-preview-state { + min-height: 320px; + display: grid; + place-items: center; + gap: 10px; + color: #475569; + font-size: 14px; + font-weight: 750; + text-align: center; +} + +.workbench-ai-file-preview-state i { + font-size: 26px; +} + +.workbench-ai-preview-fade-enter-active, +.workbench-ai-preview-fade-leave-active { + transition: opacity 160ms ease; +} + +.workbench-ai-preview-fade-enter-from, +.workbench-ai-preview-fade-leave-to { + opacity: 0; +} + +@media (max-width: 900px) { + .workbench-ai-file-preview-mask { + --workbench-ai-preview-sidebar-offset: 0px; + --workbench-ai-preview-edge-padding: 12px; + + grid-template-columns: minmax(0, 1fr); + padding: var(--workbench-ai-preview-edge-padding); + } + + .workbench-ai-file-preview-dialog { + grid-column: 1; + width: calc(100vw - (var(--workbench-ai-preview-edge-padding) * 2)); + max-height: calc(100vh - (var(--workbench-ai-preview-edge-padding) * 2)); + padding: 14px; + } + + .workbench-ai-file-preview-body { + grid-template-columns: 1fr; + overflow-y: auto; + } + + .workbench-ai-file-preview-image, + .workbench-ai-file-preview-frame { + min-height: 300px; + max-height: 46vh; + } + + .workbench-ai-file-preview-insight { + overflow: visible; + } +} + +@media (prefers-reduced-motion: reduce) { + .workbench-ai-preview-fade-enter-active, + .workbench-ai-preview-fade-leave-active { + transition: none; + } +} diff --git a/web/src/components/business/PersonalWorkbench.vue b/web/src/components/business/PersonalWorkbench.vue index d036565..add9c67 100644 --- a/web/src/components/business/PersonalWorkbench.vue +++ b/web/src/components/business/PersonalWorkbench.vue @@ -286,7 +286,7 @@ const reimbursementTrendHasSignal = computed(() => ) const reimbursementTrendRows = computed(() => sourceReimbursementTrendRows.value) const reimbursementTrendSignalLabel = computed(() => - reimbursementTrendHasSignal.value ? '来自你的真实单据' : '暂无单据时展示空走势' + reimbursementTrendHasSignal.value ? '来自您的真实单据' : '暂无单据时展示空走势' ) const reimbursementTrendLabels = computed(() => reimbursementTrendRows.value.map((item) => item.label)) const reimbursementTrendAmounts = computed(() => reimbursementTrendRows.value.map((item) => item.amount)) diff --git a/web/src/components/business/PersonalWorkbenchAiMode.template.html b/web/src/components/business/PersonalWorkbenchAiMode.template.html index a81a5af..8eda5a9 100644 --- a/web/src/components/business/PersonalWorkbenchAiMode.template.html +++ b/web/src/components/business/PersonalWorkbenchAiMode.template.html @@ -405,6 +405,8 @@ + + diff --git a/web/src/views/scripts/ApprovalCenterView.js b/web/src/views/scripts/ApprovalCenterView.js index 65a54d6..80592ee 100644 --- a/web/src/views/scripts/ApprovalCenterView.js +++ b/web/src/views/scripts/ApprovalCenterView.js @@ -3,7 +3,11 @@ import { computed, ref, watch } from 'vue' import EnterpriseListPage from '../../components/shared/EnterpriseListPage.vue' import { useApprovalInbox } from '../../composables/useApprovalInbox.js' import { useSystemState } from '../../composables/useSystemState.js' -import { fetchApprovalExpenseClaims } from '../../services/reimbursements.js' +import { + REIMBURSEMENT_LIST_PREVIEW_PARAMS, + extractExpenseClaimItems, + fetchApprovalExpenseClaims +} from '../../services/reimbursements.js' import { listPendingApprovalRequests } from '../../utils/approvalInbox.js' import { filterActionableRiskFlags, @@ -181,7 +185,7 @@ export default { actionIcon: null, tone: 'slate', artLabel: 'QUEUE', - tips: ['当前仅展示你有权限处理的单据', '高风险和即将超时单据会优先高亮'] + tips: ['当前仅展示您有权限处理的单据', '高风险和即将超时单据会优先高亮'] } } @@ -228,8 +232,8 @@ export default { error.value = '' try { - const payload = await fetchApprovalExpenseClaims() - const pendingRequests = listPendingApprovalRequests(payload, currentUser.value) + const payload = await fetchApprovalExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS) + const pendingRequests = listPendingApprovalRequests(extractExpenseClaimItems(payload), currentUser.value) const mappedRows = pendingRequests.map((item) => buildApprovalRow(item)) rows.value = mappedRows syncPendingClaimIds(mappedRows.map((item) => item.claimId)) diff --git a/web/src/views/scripts/ArchiveCenterView.js b/web/src/views/scripts/ArchiveCenterView.js index 23db615..28ffb2f 100644 --- a/web/src/views/scripts/ArchiveCenterView.js +++ b/web/src/views/scripts/ArchiveCenterView.js @@ -3,7 +3,11 @@ import { ElDropdown, ElDropdownItem, ElDropdownMenu } from 'element-plus/es/comp import EnterpriseListPage from '../../components/shared/EnterpriseListPage.vue' import { mapExpenseClaimToRequest } from '../../composables/useRequests.js' -import { fetchArchivedExpenseClaims } from '../../services/reimbursements.js' +import { + REIMBURSEMENT_LIST_PREVIEW_PARAMS, + extractExpenseClaimItems, + fetchArchivedExpenseClaims +} from '../../services/reimbursements.js' import { ARCHIVE_FILTER_ALL, applyArchiveListFilters, @@ -244,8 +248,8 @@ export default { error.value = '' try { - const payload = await fetchArchivedExpenseClaims() - const mappedRows = (Array.isArray(payload) ? payload : []) + const payload = await fetchArchivedExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS) + const mappedRows = extractExpenseClaimItems(payload) .map((item) => mapExpenseClaimToRequest(item)) .filter(Boolean) .map((item) => buildArchiveRow(item)) diff --git a/web/src/views/scripts/BackendUnavailableRouteView.js b/web/src/views/scripts/BackendUnavailableRouteView.js index 31dac0a..2447759 100644 --- a/web/src/views/scripts/BackendUnavailableRouteView.js +++ b/web/src/views/scripts/BackendUnavailableRouteView.js @@ -1,9 +1,11 @@ -import { computed, ref } from 'vue' +import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { useRouter } from 'vue-router' import { useBackendHealth } from '../../composables/useBackendHealth.js' import { useSystemState } from '../../composables/useSystemState.js' +const AUTO_RECOVER_INTERVAL_MS = 1500 + export default { name: 'BackendUnavailableRouteView', setup() { @@ -11,24 +13,74 @@ export default { const { backendChecking, backendError, checkBackendHealth } = useBackendHealth() const { loggedIn, resolveEntryRoute } = useSystemState() const retrying = ref(false) + let autoRecoverTimer = 0 + let autoRecovering = false const statusMessage = computed(() => { return backendError.value || '后端服务尚未就绪,请先检查 FastAPI 和数据库连接。' }) + async function navigateToAvailableRoute() { + await router.replace(loggedIn.value ? resolveEntryRoute() : { name: 'login' }) + } + + function stopAutoRecover() { + if (!autoRecoverTimer) { + return + } + + globalThis.clearInterval(autoRecoverTimer) + autoRecoverTimer = 0 + } + + async function recoverWhenBackendReady() { + if (autoRecovering) { + return + } + + autoRecovering = true + + try { + const ok = await checkBackendHealth({ force: true }) + if (ok) { + stopAutoRecover() + await navigateToAvailableRoute() + } + } finally { + autoRecovering = false + } + } + + function startAutoRecover() { + stopAutoRecover() + void recoverWhenBackendReady() + autoRecoverTimer = globalThis.setInterval(() => { + void recoverWhenBackendReady() + }, AUTO_RECOVER_INTERVAL_MS) + } + async function retry() { retrying.value = true try { const ok = await checkBackendHealth({ force: true }) if (ok) { - await router.replace(loggedIn.value ? resolveEntryRoute() : { name: 'login' }) + stopAutoRecover() + await navigateToAvailableRoute() } } finally { retrying.value = false } } + onMounted(() => { + startAutoRecover() + }) + + onBeforeUnmount(() => { + stopAutoRecover() + }) + return { backendChecking, retrying, diff --git a/web/src/views/scripts/TravelReimbursementCreateView.js b/web/src/views/scripts/TravelReimbursementCreateView.js index 64a4060..f165c34 100644 --- a/web/src/views/scripts/TravelReimbursementCreateView.js +++ b/web/src/views/scripts/TravelReimbursementCreateView.js @@ -401,7 +401,7 @@ export default { }) const { continueStewardApplicationFieldCompletion, handleSuggestedAction, isSuggestedActionSelected, runShortcut } = useTravelReimbursementSuggestedActions({ - applicationPreviewEditor, attachedFiles, buildExpenseSceneSelectionActions, buildExpenseSceneSelectionMessage, commitApplicationPreviewEditor, composerDraft, composerFilesExpanded, composerTextareaRef, createMessage, currentUser, emit, fetchExpenseClaims, handleGuidedShortcut, handleGuidedSuggestedAction, handleSceneSelectionApplicationGate, lockSuggestedActionMessage, mergeFilesWithLimit, messages, nextTick, openApplicationPreviewEditor: openApplicationPreviewEditorFromUi, persistSessionState, resolveApplicationPreviewMissingFields, reviewActionBusy, router, scrollToBottom, sessionSwitchBusy, startExpenseSceneSelectionAfterIntentConfirmation, submitComposer, submitComposerInternal, submitting, switchSessionType, toast, adjustComposerTextareaHeight + applicationPreviewEditor, attachedFiles, buildExpenseSceneSelectionActions, buildExpenseSceneSelectionMessage, commitApplicationPreviewEditor, composerDraft, composerFilesExpanded, composerTextareaRef, composerUploadIntent, createMessage, currentUser, draftClaimId, emit, fetchExpenseClaims, handleGuidedShortcut, handleGuidedSuggestedAction, handleSceneSelectionApplicationGate, lockSuggestedActionMessage, mergeFilesWithLimit, messages, nextTick, openApplicationPreviewEditor: openApplicationPreviewEditorFromUi, persistSessionState, resolveApplicationPreviewMissingFields, reviewActionBusy, router, scrollToBottom, sessionSwitchBusy, startExpenseSceneSelectionAfterIntentConfirmation, submitComposer, submitComposerInternal, submitting, switchSessionType, toast, adjustComposerTextareaHeight }) const { canShowTravelCalculator, diff --git a/web/src/views/scripts/employeeManagementModel.js b/web/src/views/scripts/employeeManagementModel.js index c0d5399..48a3529 100644 --- a/web/src/views/scripts/employeeManagementModel.js +++ b/web/src/views/scripts/employeeManagementModel.js @@ -489,7 +489,7 @@ export function buildEmployeeEmptyState(options = {}) { title: hasEmployeeFilters ? '当前条件下没有匹配员工' : `“${activeTab}”里暂时没有员工`, desc: hasEmployeeFilters ? '可以切回“全部员工”,或者清空关键词、部门、职级和角色条件后再试。' - : '这个状态标签下目前还没有记录,你可以切换到其他状态继续查看。', + : '这个状态标签下目前还没有记录,您可以切换到其他状态继续查看。', icon: hasEmployeeFilters ? 'mdi mdi-account-search-outline' : 'mdi mdi-badge-account-horizontal-outline', actionLabel: hasEmployeeFilters ? '清空筛选' : '查看全部员工', actionIcon: hasEmployeeFilters ? 'mdi mdi-filter-remove-outline' : 'mdi mdi-format-list-bulleted', diff --git a/web/src/views/scripts/stewardPlanModel.js b/web/src/views/scripts/stewardPlanModel.js index 984d662..b1f8e66 100644 --- a/web/src/views/scripts/stewardPlanModel.js +++ b/web/src/views/scripts/stewardPlanModel.js @@ -155,13 +155,13 @@ export function buildStewardPlanMessageText(plan) { `${index + 1}. **${buildTaskOrderVerb(index)}${buildTaskOrderTarget(task)}**\n - ${buildTaskOrderActionDescription(task)}` ) return [ - '### 我先帮你把步骤理清楚', + '### 我先帮您把步骤理清楚', '', buildStewardPlanFriendlyIntro(normalized), '', ...taskLines, '', - '你看这个顺序是否合适?如果没问题,回复 **确定** 就行。我会先帮你进入第一步,需要补充的信息会在具体步骤里再温和提醒你。' + '您看这个顺序是否合适?如果没问题,回复 **确定** 即可。我会先带您进入第一步,需要补充的信息会在具体步骤里再温和提醒。' ].filter((line, index, lines) => line || lines[index - 1]).join('\n') } @@ -457,9 +457,9 @@ function buildPendingFlowConfirmationMessageText(normalized) { '', knownTable ? ['我识别到这是一项财务事项,已提取到:', '', knownTable].join('\n') - : '我识别到这是一项财务事项,但还需要确认你要进入哪个流程。', + : '我识别到这是一项财务事项,但还需要确认您要进入哪个流程。', '', - normalized.pendingFlowConfirmation.reason || normalized.summary || '当前还不能确定你要补办申请还是发起报销。', + normalized.pendingFlowConfirmation.reason || normalized.summary || '当前还不能确定您是要补办申请,还是发起报销。', '', ...candidateLines, '', @@ -471,14 +471,14 @@ function buildPendingFlowConfirmationMessageText(normalized) { function buildGenericReimbursementIntentMessageText() { return [ - '### 我来带你发起报销', + '### 我来带您发起报销', '', - '你现在只说了要报销,还没告诉我具体是哪类费用。先不用一次性补全所有信息,我会按报销流程一步步带你填。', + '您现在只说了要报销,还没告诉我具体是哪类费用。先不用一次性补全所有信息,我会按报销流程一步步带您填。', '', '1. **先选报销场景**', ' - 例如差旅费、交通费、住宿费、业务招待费或办公用品费,不同场景需要的材料不一样。', '2. **再补关键材料**', - ' - 我会继续追问事由、发生时间、金额和票据附件;如果是差旅或招待,还会先帮你核对是否需要关联事前申请。', + ' - 我会继续追问事由、发生时间、金额和票据附件;如果是差旅或招待,还会先帮您核对是否需要关联事前申请。', '', '点击下面的 **确定,选择报销场景**,我会进入报销助手继续引导。' ].join('\n') @@ -602,22 +602,26 @@ function buildTaskOrderTarget(task) { function buildTaskOrderActionDescription(task) { const agent = task.assignedAgentLabel || '对应助手' if (task.taskType === 'expense_application') { - return `我会请${agent}先把申请单草稿整理出来,方便你核对关键信息,再决定是否继续。` + // 申请类:先给行动,再说目的,主语后置 + return `这步交给${agent}——先把申请单草稿拉出来给您过目,没问题了再往下走。` } if (task.taskType === 'reimbursement') { if (isGenericReimbursementTask(task)) { - return `我会请${agent}先带你选择报销场景,再逐步补齐事由、时间、金额和票据。` + // 通用报销:换个句式,省掉主语,突出"先定方向" + return `报销还差一个关键信息:具体是哪类费用。${agent}会先带您把报销场景定下来,再逐项补事由、时间、金额和票据。` } - return `我会请${agent}把票据、金额和制度口径先核清楚,前一步确认后再继续往下走。` + // 有明确场景的报销:直接说动作,不绕弯 + return `票据、金额和制度口径,${agent}会一并核清楚;前一步确认后才会继续,不会越级往下推。` } - return `我会请${agent}先整理可核对的结果,真正执行前仍会让你确认。` + // 兜底:用"等您点头"的语气,区别于上面三条 + return `${agent}先把能核对的结果摆出来,真正动手前仍会等您点头。` } function buildStewardPlanFriendlyIntro(normalized) { const taskCountText = normalized.tasks.length > 1 ? `${normalized.tasks.length} 个相关事项` : '1 个事项' - return `我先看了一下,你这次主要是 **${taskCountText}**。为了不让步骤混在一起,我会先把要做的事拆开,让你每一步都能看清楚、确认后再继续。` + return `我先看了一下,您这次主要是 **${taskCountText}**。为了不让步骤混在一起,我会先把要做的事拆开,让您每一步都能看清楚、确认后再继续。` } function buildTaskOrderDescription(normalized) { @@ -627,12 +631,12 @@ function buildTaskOrderDescription(normalized) { return '处理顺序是:先创建申请单,再引导填写报销单。' } if (hasApplication) { - return '我会先引导创建申请单并等待你确认。' + return '我会先引导创建申请单,并等待您确认。' } if (hasReimbursement) { - return '我会引导填写报销单并等待你确认。' + return '我会引导填写报销单,并等待您确认。' } - return '我会按识别顺序逐项推进,并在执行前等待你确认。' + return '我会按识别顺序逐项推进,并在执行前等待您确认。' } function buildNextTaskLead(task) { diff --git a/web/src/views/scripts/travelReimbursementApplicationLinkModel.js b/web/src/views/scripts/travelReimbursementApplicationLinkModel.js index b05b84a..2cdf069 100644 --- a/web/src/views/scripts/travelReimbursementApplicationLinkModel.js +++ b/web/src/views/scripts/travelReimbursementApplicationLinkModel.js @@ -567,7 +567,7 @@ export function buildRequiredApplicationSelectionText(expenseType, applications) `发起“${label}”报销前,需要先关联对应的申请单。`, '', `我查到 ${applications.length} 个可关联申请单,请先选择其中一个。`, - '选择后,我再继续向你收集本次报销依据。' + '选择后,我会继续向您收集本次报销依据。' ].join('\n') } @@ -576,7 +576,7 @@ export function buildRequiredApplicationMissingText(expenseType) { return [ `发起“${label}”报销前,需要先关联对应的申请单。`, '', - `我没有查到你名下可关联的“${label}”申请单,所以当前不能继续这类报销流程。`, + `我没有查到您名下可关联的“${label}”申请单,所以当前不能继续这类报销流程。`, '请先切换到申请助手发起相关申请;申请单存在后,再回到报销助手继续。' ].join('\n') } diff --git a/web/src/views/scripts/travelReimbursementAssociationGateModel.js b/web/src/views/scripts/travelReimbursementAssociationGateModel.js index 2ec15cf..2137136 100644 --- a/web/src/views/scripts/travelReimbursementAssociationGateModel.js +++ b/web/src/views/scripts/travelReimbursementAssociationGateModel.js @@ -7,6 +7,27 @@ import { normalizeRequiredApplicationCandidate, resolveRequiredApplicationReimbursementType } from './travelReimbursementApplicationLinkModel.js' +import { + buildReimbursementDraftActions, + buildSkipRequiredApplicationLinkAction +} from './travelReimbursementDraftBranchModel.js' + +export { + CANCEL_STANDALONE_REIMBURSEMENT_DRAFT_ACTION, + CONTINUE_REIMBURSEMENT_DRAFT_ACTION, + CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION, + SKIP_REQUIRED_APPLICATION_LINK_ACTION, + SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION, + buildContinueReimbursementDraftAction, + buildCreateStandaloneReimbursementDraftAction, + buildReimbursementDraftActions, + buildReimbursementDraftContinuationText, + buildSkipReimbursementDraftCheckAction, + buildSkipRequiredApplicationLinkAction, + buildStandaloneReimbursementDraftConfirmationActions, + buildStandaloneReimbursementDraftConfirmationText, + buildViewReimbursementDraftAction +} from './travelReimbursementDraftBranchModel.js' const REIMBURSEMENT_DRAFT_STATUSES = new Set(['draft', 'supplement', 'returned']) @@ -31,9 +52,6 @@ export const REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MS = 12000 const REIMBURSEMENT_ASSOCIATION_QUERY_PARAMS = Object.freeze({ page: 1, pageSize: 100 }) const REIMBURSEMENT_ASSOCIATION_QUERY_TIMEOUT_MESSAGE = '查询可关联申请单超时,请稍后重试。' -export const SKIP_REQUIRED_APPLICATION_LINK_ACTION = 'skip_required_application_link' -export const SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION = 'skip_reimbursement_draft_check' - function normalizeText(value) { return String(value || '').trim() } @@ -85,6 +103,26 @@ function formatAmount(value) { }).format(numberValue)}` } +function formatCompactDateTime(value) { + const text = normalizeText(value) + if (!text) { + return '' + } + const matched = text.match(/^(\d{4}-\d{2}-\d{2})[T\s](\d{2}:\d{2})/) + if (matched) { + return `${matched[1]} ${matched[2]}` + } + return text +} + +function formatDraftAmount(value, label = '') { + const explicitLabel = normalizeText(label) + if (explicitLabel) { + return explicitLabel + } + return formatAmount(value) || '待确认' +} + function resolveCurrentUser(currentUser) { return currentUser?.value && typeof currentUser.value === 'object' ? currentUser.value @@ -111,9 +149,9 @@ export function isReimbursementAssociationQueryTimeoutError(error) { export function buildReimbursementAssociationQueryFailedText(error) { if (isReimbursementAssociationQueryTimeoutError(error)) { - return '查询可关联申请单超时。你可以稍后重试,也可以选择不关联申请单,单独新建报销单。' + return '查询可关联申请单超时。您可以稍后重试,也可以选择不关联申请单,单独新建报销单。' } - return '查询可关联申请单时出现异常。你可以稍后重试,也可以选择不关联申请单,单独新建报销单。' + return '查询可关联申请单时出现异常。您可以稍后重试,也可以选择不关联申请单,单独新建报销单。' } export async function fetchReimbursementAssociationClaims({ @@ -189,7 +227,7 @@ export function normalizeReimbursementDraftCandidate(claim = {}) { amount_label: formatAmount(amount), status, status_label: STATUS_LABELS[status] || normalizeText(claim?.status_label || claim?.statusLabel || claim?.approval_stage || claim?.approvalStage || status), - created_at: createdAt, + created_at: formatCompactDateTime(createdAt), application_date: createdAt } } @@ -210,11 +248,11 @@ export function buildReimbursementAssociationSelectionText(applications) { return [ '### 可关联申请单', '', - '我先检查你名下是否有可继续的报销草稿,没有查到可继续的报销草稿。', + '我先检查了您名下的报销草稿,没有查到可继续的报销草稿。', '', - '我先查询可关联申请单,并筛选了你名下已审批且未关联报销的记录。', + '接下来先查询可关联申请单,为您筛选出名下已审批且尚未关联报销的记录。', '', - `查到 ${candidates.length} 个已审批且尚未关联报销的申请单。你可以选择关联其中一个,也可以选择不关联、单独新建报销单。`, + `查到 ${candidates.length} 个已审批且尚未关联报销的申请单。您可以从中选择一个进行关联,也可以不关联、直接单独新建报销单。`, '', buildReimbursementAssociationCardsHtml(candidates), '', @@ -224,11 +262,11 @@ export function buildReimbursementAssociationSelectionText(applications) { export function buildReimbursementAssociationMissingText() { return [ - '我先检查你名下是否有可继续的报销草稿,没有查到可继续的报销草稿。', + '我先检查了您名下的报销草稿,没有查到可继续的报销草稿。', '', - '我先查询可关联申请单,并筛选了你名下已审批且未关联报销的记录。', + '接下来先查询可关联申请单,为您筛选出名下已审批且尚未关联报销的记录。', '', - '暂时没有查到已审批且尚未关联报销的申请单。你仍然可以选择单独新建报销单,后续按报销类型继续补充信息。' + '暂时没有查到已审批且尚未关联报销的申请单。您仍然可以选择单独新建报销单,后续按报销类型继续补充信息。' ].join('\n') } @@ -237,67 +275,16 @@ export function buildReimbursementDraftSelectionText(drafts) { return [ '### 可继续报销草稿', '', - '我先检查你名下是否有可继续的报销草稿。', + '我先检查了您名下的报销草稿。', '', - `查到 ${candidates.length} 个可继续的报销草稿。你可以先继续草稿;如果这次是新的报销,可以跳过草稿后再关联申请单新建报销单。`, + `查到 ${candidates.length} 个可继续的报销草稿。您可以查看草稿详情,或继续把附件、说明关联到该草稿;如果这是一次新的报销,请独立新建报销单。`, '', buildReimbursementDraftCardsHtml(candidates), '', - '请通过下方按钮确认继续草稿,或跳过草稿进入申请单关联。' + '请通过下方三个按钮选择下一步。' ].join('\n') } -export function buildSkipRequiredApplicationLinkAction(originalMessage = '') { - return { - label: '不关联,单独新建报销单', - description: '跳过申请单关联,继续选择报销类型并新建报销单。', - icon: 'mdi mdi-file-plus-outline', - action_type: SKIP_REQUIRED_APPLICATION_LINK_ACTION, - payload: { - original_message: normalizeText(originalMessage) || '我要报销' - } - } -} - -export function buildSkipReimbursementDraftCheckAction(originalMessage = '') { - return { - label: '不用草稿,关联申请单新建报销单', - description: '跳过已有报销草稿,继续查询可关联申请单。', - icon: 'mdi mdi-file-search-outline', - action_type: SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION, - payload: { - original_message: normalizeText(originalMessage) || '我要报销' - } - } -} - -export function buildReimbursementDraftActions(drafts, originalMessage = '') { - const sourceText = normalizeText(originalMessage) || '我要报销' - return [ - ...(Array.isArray(drafts) ? drafts : []).map((draft) => { - const claimNo = normalizeText(draft.claim_no) || '未编号草稿' - return { - label: `继续草稿 ${claimNo}`, - description: [ - draft.status_label, - draft.created_at && `更新时间:${draft.created_at}`, - draft.location && `地点:${draft.location}`, - draft.amount_label && `金额:${draft.amount_label}`, - draft.reason && `事由:${draft.reason}` - ].filter(Boolean).join(' · '), - icon: 'mdi mdi-file-document-edit-outline', - action_type: 'open_application_detail', - payload: { - claim_id: draft.id, - claim_no: draft.claim_no, - original_message: sourceText - } - } - }), - buildSkipReimbursementDraftCheckAction(sourceText) - ] -} - export function buildReimbursementAssociationActions(applications, originalMessage = '') { const sourceText = normalizeText(originalMessage) || '我要报销' return [ @@ -331,7 +318,7 @@ function buildReimbursementDraftCardHtml(draft = {}) { const statusLabel = normalizeText(draft.status_label) || '草稿' const title = normalizeText(EXPENSE_TYPE_LABELS[normalizeLower(draft.expense_type)] || draft.expense_type) || '报销草稿' const summaryHtml = [ - buildAssociationCardFieldHtml('金额', draft.amount_label || draft.amount || '待确认', { + buildAssociationCardFieldHtml('金额', formatDraftAmount(draft.amount, draft.amount_label), { valueClass: 'ai-document-card__amount' }), buildAssociationCardFieldHtml('更新时间', draft.created_at || '待确认') @@ -343,7 +330,7 @@ function buildReimbursementDraftCardHtml(draft = {}) { }), buildAssociationCardFieldHtml('事由', draft.reason || '待补充'), buildAssociationCardFieldHtml('单据类型', `报销单 · ${title}`), - buildAssociationCardFieldHtml('操作', '使用下方按钮继续', { + buildAssociationCardFieldHtml('操作', '使用下方按钮查看、关联或新建', { fieldClass: 'ai-document-card__field--action' }) ].join('') @@ -545,7 +532,7 @@ export function buildReimbursementAssociationThinkingEvents(stage = 'intent', op title: '检查报销草稿', content: currentOrder > 1 ? '已完成报销草稿检查,继续判断是否需要进入申请单关联。' - : '正在查询你名下是否存在可继续的报销草稿。', + : '正在查询您名下是否存在可继续的报销草稿。', status: resolveStatus(1) }, { @@ -553,7 +540,7 @@ export function buildReimbursementAssociationThinkingEvents(stage = 'intent', op title: '查询可关联申请单', content: currentOrder > 2 ? `已完成申请单查询与筛选,命中 ${candidateCount} 张可推荐单据。` - : '如未发现可继续草稿,就查询你名下已审批且尚未关联报销的申请单。', + : '如未发现可继续草稿,就查询您名下已审批且尚未关联报销的申请单。', status: resolveStatus(2) }, { diff --git a/web/src/views/scripts/travelReimbursementConversationMessageModel.js b/web/src/views/scripts/travelReimbursementConversationMessageModel.js index 90c5191..86f099d 100644 --- a/web/src/views/scripts/travelReimbursementConversationMessageModel.js +++ b/web/src/views/scripts/travelReimbursementConversationMessageModel.js @@ -53,8 +53,8 @@ export function buildExpenseIntentConfirmationMessage(rawText) { text ? `我看到了「${text}」这类业务事项描述。` : '我看到了这类业务事项描述。', - '但现在还不能确定你是要发起报销,还是要处理其他事项,所以我先暂停后续识别。', - '如果你是想报销,请点击下面的“我要报销”,我再继续引导你选择具体报销场景。' + '但现在还不能确定您是要发起报销,还是要处理其他事项,所以我先暂停后续识别。', + '如果您是要报销,请点击下面的“我要报销”,我会继续引导您选择具体报销场景。' ].join('\n') } @@ -62,13 +62,13 @@ export function buildExpenseSceneSelectionMessage(rawText) { const text = String(rawText || '').trim() const hasBusinessTime = /业务发生时间|发生时间|20\d{2}[-年\/.]\d{1,2}/.test(text) const prefix = hasBusinessTime - ? '我已看到你提供了业务发生时间和报销意图。' + ? '我已看到您提供了业务发生时间和报销意图。' : '我已识别到这是报销申请。' return [ - `${prefix}先选一下这笔费用属于哪一类,我再按对应流程继续。`, + `${prefix}请先选一下这笔费用属于哪一类,我再按对应流程继续。`, '差旅和业务招待通常需要先关联申请单;交通、住宿、办公用品这类一般可以直接继续填写。', - '选完后我会把下一步需要准备的内容整理给你。' + '选完后我会把下一步需要准备的内容整理给您。' ].join('\n') } diff --git a/web/src/views/scripts/travelReimbursementConversationSessionModel.js b/web/src/views/scripts/travelReimbursementConversationSessionModel.js index f0b51ac..773a13d 100644 --- a/web/src/views/scripts/travelReimbursementConversationSessionModel.js +++ b/web/src/views/scripts/travelReimbursementConversationSessionModel.js @@ -57,7 +57,7 @@ export const ASSISTANT_SESSION_MODE_OPTIONS = [ key: SESSION_TYPE_BUDGET, label: '预算编制助手', icon: 'mdi mdi-calculator-variant-outline', - description: '帮助你进行预算编制与预算相关问题的整理' + description: '帮助您进行预算编制与预算相关问题的整理' } ] diff --git a/web/src/views/scripts/travelReimbursementDraftBranchModel.js b/web/src/views/scripts/travelReimbursementDraftBranchModel.js new file mode 100644 index 0000000..357db48 --- /dev/null +++ b/web/src/views/scripts/travelReimbursementDraftBranchModel.js @@ -0,0 +1,143 @@ +export const SKIP_REQUIRED_APPLICATION_LINK_ACTION = 'skip_required_application_link' +export const SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION = 'skip_reimbursement_draft_check' +export const CONTINUE_REIMBURSEMENT_DRAFT_ACTION = 'continue_reimbursement_draft_association' +export const CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION = 'create_standalone_reimbursement_draft' +export const CANCEL_STANDALONE_REIMBURSEMENT_DRAFT_ACTION = 'cancel_standalone_reimbursement_draft' + +function normalizeText(value) { + return String(value || '').trim() +} + +function resolvePrimaryReimbursementDraft(drafts = []) { + return (Array.isArray(drafts) ? drafts : []).find((draft) => ( + normalizeText(draft?.id || draft?.claim_id || draft?.claimId || draft?.claim_no || draft?.claimNo) + )) || {} +} + +function resolveDraftClaimNo(draft = {}) { + return normalizeText(draft.claim_no || draft.claimNo) || '未编号草稿' +} + +function resolveDraftClaimId(draft = {}) { + return normalizeText(draft.id || draft.claim_id || draft.claimId) +} + +export function buildSkipRequiredApplicationLinkAction(originalMessage = '') { + return { + label: '不关联,单独新建报销单', + description: '跳过申请单关联,继续选择报销类型并新建报销单。', + icon: 'mdi mdi-file-plus-outline', + action_type: SKIP_REQUIRED_APPLICATION_LINK_ACTION, + payload: { + original_message: normalizeText(originalMessage) || '我要报销' + } + } +} + +export function buildSkipReimbursementDraftCheckAction(originalMessage = '') { + return { + label: '不用草稿,关联申请单新建报销单', + description: '跳过已有报销草稿,继续查询可关联申请单。', + icon: 'mdi mdi-file-search-outline', + action_type: SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION, + payload: { + original_message: normalizeText(originalMessage) || '我要报销' + } + } +} + +export function buildViewReimbursementDraftAction(draft = {}, originalMessage = '') { + const claimNo = resolveDraftClaimNo(draft) + return { + label: `查看草稿 ${claimNo}`, + description: '打开草稿详情页核对已填内容。', + icon: 'mdi mdi-file-eye-outline', + action_type: 'open_application_detail', + payload: { + claim_id: resolveDraftClaimId(draft), + claim_no: normalizeText(draft.claim_no || draft.claimNo), + original_message: normalizeText(originalMessage) || '我要报销' + } + } +} + +export function buildContinueReimbursementDraftAction(draft = {}, originalMessage = '') { + const claimNo = resolveDraftClaimNo(draft) + return { + label: `继续关联草稿 ${claimNo}`, + description: '上传相关附件或补充说明,继续完善这张草稿。', + icon: 'mdi mdi-link-variant', + action_type: CONTINUE_REIMBURSEMENT_DRAFT_ACTION, + payload: { + claim_id: resolveDraftClaimId(draft), + claim_no: normalizeText(draft.claim_no || draft.claimNo), + original_message: normalizeText(originalMessage) || '我要报销' + } + } +} + +export function buildCreateStandaloneReimbursementDraftAction(originalMessage = '') { + return { + label: '独立新建报销单', + description: '不使用当前草稿,先确认是否创建新的报销草稿。', + icon: 'mdi mdi-file-plus-outline', + action_type: CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION, + payload: { + original_message: normalizeText(originalMessage) || '我要报销' + } + } +} + +export function buildStandaloneReimbursementDraftConfirmationText() { + return [ + '是否新建草稿单据?', + '', + '确认后我会跳过当前草稿,按新的报销单据继续收集类型、附件和说明。' + ].join('\n') +} + +export function buildStandaloneReimbursementDraftConfirmationActions(originalMessage = '') { + const sourceText = normalizeText(originalMessage) || '我要报销' + return [ + { + label: '新建草稿单据', + description: '进入单独新建报销单流程。', + icon: 'mdi mdi-file-plus-outline', + action_type: SKIP_REQUIRED_APPLICATION_LINK_ACTION, + payload: { + original_message: sourceText, + standalone_draft_confirmed: true + } + }, + { + label: '暂不新建', + description: '保留当前对话,不创建新的报销草稿。', + icon: 'mdi mdi-close-circle-outline', + action_type: CANCEL_STANDALONE_REIMBURSEMENT_DRAFT_ACTION, + payload: { + original_message: sourceText + } + } + ] +} + +export function buildReimbursementDraftContinuationText(draft = {}) { + const claimNo = normalizeText(draft.claim_no || draft.claimNo) || '当前草稿' + return [ + `已选择继续关联草稿 ${claimNo}。`, + '', + '请上传相关的附件,或者补充说明。', + '', + '收到后我会围绕这张草稿继续整理票据和报销信息。' + ].join('\n') +} + +export function buildReimbursementDraftActions(drafts, originalMessage = '') { + const sourceText = normalizeText(originalMessage) || '我要报销' + const primaryDraft = resolvePrimaryReimbursementDraft(drafts) + return [ + buildViewReimbursementDraftAction(primaryDraft, sourceText), + buildContinueReimbursementDraftAction(primaryDraft, sourceText), + buildCreateStandaloneReimbursementDraftAction(sourceText) + ] +} diff --git a/web/src/views/scripts/travelReimbursementGuidedFlowModel.js b/web/src/views/scripts/travelReimbursementGuidedFlowModel.js index b6cb95b..1776dd8 100644 --- a/web/src/views/scripts/travelReimbursementGuidedFlowModel.js +++ b/web/src/views/scripts/travelReimbursementGuidedFlowModel.js @@ -214,7 +214,7 @@ export function buildGuidedExpenseTypeActions() { export function buildGuidedReimbursementStartText() { return [ - '请问你要报销的类型?', + '请问您要报销的类型?', '', '先选一个最贴近的费用场景,我会按对应流程逐项询问。这个过程只做本地引导,不会自动创建草稿。' ].join('\n') @@ -403,7 +403,7 @@ export function buildGuidedReimbursementSummaryText(state) { lines.push('') lines.push( linkedApplication - ? '如果关联信息无误,我可以直接生成报销草稿;后续由你在草稿详情中上传和归集票据。' + ? '如果关联信息无误,我可以直接生成报销草稿;后续由您在草稿详情中上传和归集票据。' : '如果这些信息无误,我可以继续生成报销草稿;草稿生成后可继续上传票据或补充信息。' ) return lines.join('\n') @@ -510,9 +510,9 @@ export function shouldConfirmGuidedInterruption(text, state) { export function buildGuidedInterruptionText(text) { return [ - `我看到你刚才输入的是:“${normalizeText(text)}”。`, + `我看到您刚才输入的是:“${normalizeText(text)}”。`, '', - '这看起来像一个新的问题。你想继续填写当前引导,还是先暂停当前引导并处理这个问题?' + '这看起来像一个新的问题。您想继续填写当前引导,还是先暂停当前引导并处理这个问题?' ].join('\n') } @@ -543,9 +543,9 @@ export function createGuidedStatusQueryState() { export function buildGuidedStatusQueryStartText() { return [ - '你想按什么条件查询单据状态?', + '您想按什么条件查询单据状态?', '', - '先选查询方式,我再向你收集对应条件。' + '请先选查询方式,我再向您收集对应条件。' ].join('\n') } diff --git a/web/src/views/scripts/travelReimbursementReviewDisplayModel.js b/web/src/views/scripts/travelReimbursementReviewDisplayModel.js index 31bd648..3efe417 100644 --- a/web/src/views/scripts/travelReimbursementReviewDisplayModel.js +++ b/web/src/views/scripts/travelReimbursementReviewDisplayModel.js @@ -329,16 +329,54 @@ export function buildReviewTodoItems(reviewPayload) { +// 待补充字段提示:每个字段给 2~3 种说法轮换,避免每次同一句。 +// 取值用 buildStableTemplateIndex 按字段签名稳定选取,保证同一字段在同一次会话里文案一致。 const REVIEW_PENDING_HINT_COPY = { - expense_type: '请选择本次报销分类,后续票据会按这个分类继续核对。', - customer_name: '请补充客户单位全称。', - time_range: '请补充业务发生日期或时间范围。', - location: '请补充业务发生地点。', - merchant_name: '请补充酒店或商户名称。', - amount: '请补充本次费用金额。', - reason: '请补充本次费用场景或事由。', - participants: '请至少填写 1 名同行人员。', - attachments: '请上传或关联对应票据附件。' + expense_type: [ + '这笔费用先归个类,后续票据就按这个分类往下核。', + '还差费用类型,定下来我才能匹配对应的标准和材料。' + ], + customer_name: [ + '客户单位的全称填一下,合规校验要用。', + '还差客户单位名称,补上就行。' + ], + time_range: [ + '业务是哪天发生的?日期或时间范围都行。', + '还差业务发生时间,补个准确日期。' + ], + location: [ + '这笔费用发生在哪儿?', + '业务地点还空着,补一下。' + ], + merchant_name: [ + '住的是哪家酒店、或是在哪家商户消费的?', + '还差酒店或商户名称。' + ], + amount: [ + '这笔费用多少钱?', + '金额还没填,补上才能继续。' + ], + reason: [ + '这笔费用是为什么发生的?简单说两句背景。', + '事由还空着,补一下方便审核。' + ], + participants: [ + '一起出差的还有谁?至少填 1 位。', + '同行人员还没填,补 1 名以上。' + ], + attachments: [ + '对应的票据传一下,或者关联已有的也行。', + '还差票据附件,上传或关联一张。' + ] +} + +function resolveReviewPendingHint(fieldKey, signature = '') { + const candidates = REVIEW_PENDING_HINT_COPY[fieldKey] + if (!Array.isArray(candidates) || !candidates.length) { + return '' + } + const index = buildStableTemplateIndex(`${fieldKey}:${signature}`, candidates.length) + return candidates[index] } function normalizeReviewFollowupSentence(text) { @@ -355,10 +393,12 @@ function buildReviewPlainFollowupItem(item, pendingMode) { const key = String(item?.key || '').trim() const label = String(item?.title || item?.label || '').trim() || '待核查信息' if (pendingMode) { + const hintFromPool = resolveReviewPendingHint(key, `${key}:${label}`) + const fallbackHint = String(item?.hint || '').trim() || `请补充${label}` return { key: key || label, label, - text: normalizeReviewFollowupSentence(REVIEW_PENDING_HINT_COPY[key] || item?.hint || `请补充${label}`) + text: normalizeReviewFollowupSentence(hintFromPool || fallbackHint) } } @@ -370,30 +410,20 @@ function buildReviewPlainFollowupItem(item, pendingMode) { } } +// 4 条结构差异较大的轮播:①先点状态 ②先给选择 ③先共情 ④先给行动。 +// 数量收敛后由 buildStableTemplateIndex 自动落到新范围,行为不变。 const REVIEW_PENDING_SUMMARY_TEMPLATES = [ - ({ issueSummary }) => `当前还有 ${issueSummary}。请核查对话中的文字说明;如果想先暂存,也可以点击对话文字中的“草稿”。`, - ({ issueSummary }) => `我这边看到还有 ${issueSummary},建议先把下方内容核对一下;暂时不处理也没关系,可以点击“草稿”先保存。`, - ({ issueSummary }) => `下方还有 ${issueSummary},需要你确认。信息没补齐前可以先核查说明,后续需要暂存时点“草稿”。`, - ({ issueSummary }) => `这笔报销还有 ${issueSummary},尚未完全确认。请先看一下下面的补充项;需要中途保存时,可以点“草稿”。`, - ({ issueSummary }) => `目前还有 ${issueSummary}。你可以先按下面的提示补充,也可以稍后再处理,点击“草稿”即可暂存当前信息。`, - ({ issueSummary }) => `还有 ${issueSummary},建议先核对下面说明;如果票据或金额暂时不全,可以通过“草稿”保留当前进度。`, - ({ issueSummary }) => `这次识别结果里还有 ${issueSummary}。请重点看下面几项,暂不提交时可以点“草稿”保存。`, - ({ issueSummary }) => `我还需要你确认 ${issueSummary}。下面列出了具体内容;如果现在不方便补齐,可以先点“草稿”。`, - ({ issueSummary }) => `当前还有 ${issueSummary},需要进一步处理。请根据下面提示核查,待补充完再继续;临时保存可点击“草稿”。`, - ({ issueSummary }) => `本次报销还有 ${issueSummary},请先检查下面的补充项;想先留存当前识别结果时可以点“草稿”。` + ({ issueSummary }) => `还差 ${issueSummary},下面都列出来了,补齐就能继续。急着走的话,点“草稿”先把当前进度存下来。`, + ({ issueSummary }) => `别急,这单还剩 ${issueSummary}。您可以选择现在就按下面的提示补,也可以稍后再回来——点“草稿”随时暂存。`, + ({ issueSummary }) => `这边识别下来,${issueSummary} 还没到位。先看一眼下面的内容对不对,确认好了再往下;中途想停就点“草稿”。`, + ({ issueSummary }) => `差 ${issueSummary},所以暂时还不能提交。照着下面几项补一下,补完就能进下一步;实在没空先存草稿也行。` ] const REVIEW_SAVED_DRAFT_PENDING_SUMMARY_TEMPLATES = [ - ({ issueSummary }) => `当前还有 ${issueSummary}。草稿已保存,后续上传票据时请关联这张草稿,补齐后再继续提交审批。`, - ({ issueSummary }) => `这张草稿仍有 ${issueSummary} 需要补充。您可以继续上传或关联票据,系统会归集到已保存草稿中。`, - ({ issueSummary }) => `草稿已生成,当前还差 ${issueSummary}。请按下方提示补充字段或票据,完整后再进入下一步。`, - ({ issueSummary }) => `草稿已经留存,下面还有 ${issueSummary} 待处理。新增附件请关联当前草稿,避免重复建单。`, - ({ issueSummary }) => `当前草稿还有 ${issueSummary}。建议先补齐金额、票据等信息,再从草稿详情继续提交审批。`, - ({ issueSummary }) => `已保留当前进度,这笔草稿还需要 ${issueSummary}。后续补充内容会作为该草稿的更新处理。`, - ({ issueSummary }) => `这张单据已进入草稿状态,仍有 ${issueSummary}。请继续补充必要信息,补齐后再发起正式提交。`, - ({ issueSummary }) => `草稿保存完成后,当前还剩 ${issueSummary}。上传附件时请选择关联这张草稿,系统会继续合并识别结果。`, - ({ issueSummary }) => `当前草稿待完善:${issueSummary}。请先处理下方项目,确认完整后再继续下一步。`, - ({ issueSummary }) => `这笔草稿还存在 ${issueSummary}。可以继续补充票据和字段,系统会围绕已保存草稿继续更新。` + ({ issueSummary }) => `草稿存好了,但还差 ${issueSummary}。后面传票时记得关联到这张草稿,补齐了再提交审批。`, + ({ issueSummary }) => `这张草稿还留着 ${issueSummary} 没处理。您随时可以回来补,新增的附件我会归到这张草稿上,不会重复建单。`, + ({ issueSummary }) => `进度保住了,不过 ${issueSummary} 还得补。建议把金额、票据这些先弄齐,再从草稿详情发起正式提交。`, + ({ issueSummary }) => `草稿状态下还剩 ${issueSummary}。可以继续补字段和票据,补完了再提交;想先放着也没问题,草稿不会丢。` ] function buildStableTemplateIndex(signature, total) { @@ -545,7 +575,7 @@ export function buildReviewNextStepRichCopy(reviewPayload, { detailHref = '' } = if (reviewPayload?.can_proceed && counts.medium === 0 && counts.high === 0) { const editHref = String(detailHref || '').trim() || '#review-quick-edit' lines.push( - `系统确认您可以 [继续下一步](#review-next-step) 进行单据的提交,如果您确认信息无误,请点击富文本按钮;如果你还需要继续修改信息,请点击 [快速修改单据信息](${editHref})。` + `系统确认您可以 [继续下一步](#review-next-step) 进行单据的提交,如果您确认信息无误,请点击富文本按钮;如果您还需要继续修改信息,请点击 [快速修改单据信息](${editHref})。` ) } return lines.join('\n\n') @@ -660,7 +690,7 @@ export function buildReviewRecognitionNotes(reviewPayload) { const sourceLabels = [...new Set(recognized.map((item) => String(item?.source_label || '').trim()).filter(Boolean))] if (timeSlot?.raw_value && timeSlot.raw_value !== timeSlot.value && timeSlot.value) { - notes.push(`时间已按你的本地日期换算:${timeSlot.raw_value} -> ${timeSlot.value}`) + notes.push(`时间已按您的本地日期换算:${timeSlot.raw_value} -> ${timeSlot.value}`) } if (sourceLabels.length) { @@ -671,7 +701,7 @@ export function buildReviewRecognitionNotes(reviewPayload) { if (documentCards.length) { notes.push(`已关联 ${documentCards.length} 份附件,逐张识别结果已整理在下方`) } else { - notes.push('当前还没有上传票据,这一轮主要依据你的文字描述完成初步识别') + notes.push('当前还没有上传票据,这一轮主要依据您的文字描述完成初步识别') } return notes @@ -686,7 +716,7 @@ export function buildReviewMissingHint(reviewPayload) { if (reviewPayload?.can_proceed) { return '当前关键信息已经齐全,这里无需再补充。' } - return '下面这些字段还需要你再确认或补齐,补完后我就能继续往下处理。' + return '下面这些字段还需要您再确认或补齐,补完后我就能继续往下处理。' } diff --git a/web/src/views/scripts/travelReimbursementReviewPanelModel.js b/web/src/views/scripts/travelReimbursementReviewPanelModel.js index 05ccb34..21fa12e 100644 --- a/web/src/views/scripts/travelReimbursementReviewPanelModel.js +++ b/web/src/views/scripts/travelReimbursementReviewPanelModel.js @@ -41,7 +41,7 @@ const REVIEW_RISK_LEVEL_META = { } } -const REVIEW_PENDING_SUMMARY_PATTERN = /(^|\n)\s*(?:当前还有|我这边看到还有|下方还有|这笔报销还有|目前还有|还有|这次识别结果里还有|我还需要你确认|当前信息还差|本次报销还有)\s+[^\n]*(?:信息待补充|风险提醒|细节还需要进一步确认)[^\n]*(?:草稿)[^\n]*。\s*/g +const REVIEW_PENDING_SUMMARY_PATTERN = /(^|\n)\s*(?:当前还有|我这边看到还有|下方还有|这笔报销还有|目前还有|还有|这次识别结果里还有|我还需要(?:你|您)确认|当前信息还差|本次报销还有)\s+[^\n]*(?:信息待补充|风险提醒|细节还需要进一步确认)[^\n]*(?:草稿)[^\n]*。\s*/g export function normalizeReviewPanelScope(scope) { const normalized = String(scope || '').trim() diff --git a/web/src/views/scripts/travelReimbursementStewardFollowupFlow.js b/web/src/views/scripts/travelReimbursementStewardFollowupFlow.js index a540b10..a382600 100644 --- a/web/src/views/scripts/travelReimbursementStewardFollowupFlow.js +++ b/web/src/views/scripts/travelReimbursementStewardFollowupFlow.js @@ -118,7 +118,7 @@ export function useTravelReimbursementStewardFollowupFlow({ title: '判断下一步条件', content: nextMissing ? `这一步还需要补充${nextMissing},进入对应核对环节后我会继续追问,不会直接提交。` - : '我会先等你确认,再进入下一项核对;创建草稿、绑定附件或提交前仍会再次确认。' + : '我会先等您确认,再进入下一项核对;创建草稿、绑定附件或提交前仍会再次确认。' } ] } diff --git a/web/src/views/scripts/travelReimbursementSubmitApplicationConflicts.js b/web/src/views/scripts/travelReimbursementSubmitApplicationConflicts.js index e3bd046..d62f03d 100644 --- a/web/src/views/scripts/travelReimbursementSubmitApplicationConflicts.js +++ b/web/src/views/scripts/travelReimbursementSubmitApplicationConflicts.js @@ -142,7 +142,7 @@ function findOverlappingApplicationClaim(applicationPreview, claimsPayload) { function buildApplicationDateConflictMessage(conflict) { const claimNo = conflict?.claimNo || '已有申请' return [ - '我先检查了你的申请时间,发现同一天或重叠日期已经存在差旅申请,不能重复创建。', + '我先检查了您的申请时间,发现同一天或重叠日期已经存在差旅申请,不能重复创建。', '', '已有申请:', `- **单号**:${claimNo}`, diff --git a/web/src/views/scripts/travelReimbursementSubmitDraftPreflight.js b/web/src/views/scripts/travelReimbursementSubmitDraftPreflight.js index ad8576f..73564ca 100644 --- a/web/src/views/scripts/travelReimbursementSubmitDraftPreflight.js +++ b/web/src/views/scripts/travelReimbursementSubmitDraftPreflight.js @@ -1,4 +1,5 @@ import { buildUnsavedDraftAttachmentConfirmationMessage } from './travelReimbursementAttachmentModel.js' +import { REIMBURSEMENT_LIST_PREVIEW_PARAMS } from '../../services/reimbursements.js' export async function handleDraftAssociationPreflight({ activeReviewPayload, @@ -81,7 +82,7 @@ export async function handleDraftAssociationPreflight({ !reviewAction ) { try { - const claims = await fetchExpenseClaims() + const claims = await fetchExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS) const queryPayload = buildDraftAssociationQueryPayload(claims) if (queryPayload?.records?.length) { resetFlowRun() diff --git a/web/src/views/scripts/travelReimbursementSubmitStewardDelegation.js b/web/src/views/scripts/travelReimbursementSubmitStewardDelegation.js index da7741c..c3e2682 100644 --- a/web/src/views/scripts/travelReimbursementSubmitStewardDelegation.js +++ b/web/src/views/scripts/travelReimbursementSubmitStewardDelegation.js @@ -265,16 +265,16 @@ export function createStewardDelegationHelpers({ '', `本次申请是前往${fields.location || '目的地'}的差旅事项,出行方式会影响交通费用口径和系统预估金额。`, '', - '请先告诉我你打算怎么出行:**火车、飞机或轮船**。我会根据你的选择生成申请核对表并同步费用测算,再继续判断是否可以提交申请。' + '请先告诉我您打算怎么出行:**火车、飞机或轮船**。我会根据您的选择生成申请核对表,并同步费用测算,再继续判断是否可以提交申请。' ].join('\n') } return [ '我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表。', '', - `**还需要你补充:${missingFields.join('、')}。**`, + `**还需要您补充:${missingFields.join('、')}。**`, '', - `请先补充 **${missingFields[0]}**。补齐后我再生成申请核对表并继续推进下一步。` + `请先补充 **${missingFields[0]}**。补齐后我再生成申请核对表,并继续推进下一步。` ].join('\n') } @@ -349,8 +349,8 @@ export function createStewardDelegationHelpers({ eventId: `${eventPrefix}-intent`, title: '理解当前任务', content: taskSummary - ? `你确认先处理“${taskTitle}”。我把这一步理解为:${taskSummary}。` - : `你确认先处理“${taskTitle}”,我会先生成${actionLabel}结果。` + ? `您确认先处理“${taskTitle}”。我把这一步理解为:${taskSummary}。` + : `您确认先处理“${taskTitle}”,我会先生成${actionLabel}结果。` }, { eventId: `${eventPrefix}-known`, @@ -366,14 +366,14 @@ export function createStewardDelegationHelpers({ eventId: `${eventPrefix}-gap`, title: '判断待补充信息', content: transportMissing - ? '这一步还没有说明出行方式。出行方式会影响交通费用测算,所以我会先问你选择火车、飞机或轮船,不会直接推进提交。' - : `这一步还缺少${missingInfo},我会先向你确认这些信息,不直接推进提交。` + ? '这一步还没有说明出行方式。出行方式会影响交通费用测算,所以我会先请您选择火车、飞机或轮船,不会直接推进提交。' + : `这一步还缺少${missingInfo},我会先向您确认这些信息,不直接推进提交。` }) } else { events.push({ eventId: `${eventPrefix}-ready`, title: '判断下一步动作', - content: `这一步的关键业务信息已形成核对结果。我会先让你检查${actionLabel},确认后再继续入库、生成草稿或处理后续任务。` + content: `这一步的关键业务信息已形成核对结果。我会先请您检查${actionLabel},确认后再继续入库、生成草稿或处理后续任务。` }) } return events diff --git a/web/src/views/scripts/useTravelReimbursementGuidedFlow.js b/web/src/views/scripts/useTravelReimbursementGuidedFlow.js index 5106d7c..0ceff0f 100644 --- a/web/src/views/scripts/useTravelReimbursementGuidedFlow.js +++ b/web/src/views/scripts/useTravelReimbursementGuidedFlow.js @@ -4,7 +4,10 @@ import { buildApplicationTemplatePreview, buildLocalApplicationPreviewMessage } from '../../utils/expenseApplicationPreview.js' -import { fetchExpenseClaims } from '../../services/reimbursements.js' +import { + REIMBURSEMENT_LIST_PREVIEW_PARAMS, + fetchExpenseClaims +} from '../../services/reimbursements.js' import { buildRequiredApplicationActions, buildRequiredApplicationMissingText, @@ -198,7 +201,7 @@ export function useTravelReimbursementGuidedFlow({ } if (actionType === GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR) { openTravelCalculator?.() - pushAssistant('差旅计算器已打开。你可以直接填写目的地、天数和金额,我会按规则中心标准帮你测算。', { + pushAssistant('差旅计算器已打开。您可以直接填写目的地、天数和金额,我会按规则中心标准帮您测算。', { meta: ['差旅计算器'] }) persistAndScroll() @@ -253,7 +256,7 @@ export function useTravelReimbursementGuidedFlow({ let claimsPayload = null try { - claimsPayload = await fetchExpenseClaims() + claimsPayload = await fetchExpenseClaims(REIMBURSEMENT_LIST_PREVIEW_PARAMS) } catch (error) { console.warn('Fetch reimbursement applications failed:', error) guidedFlowState.value = createEmptyGuidedFlowState() @@ -599,7 +602,7 @@ export function useTravelReimbursementGuidedFlow({ await submitExistingComposer({ rawText: pendingText, userText: pendingText, - pendingText: '正在处理你的问题...', + pendingText: '正在处理您的问题...', skipUserMessage: true }) return true diff --git a/web/src/views/scripts/useTravelReimbursementStewardRuntimeDecision.js b/web/src/views/scripts/useTravelReimbursementStewardRuntimeDecision.js index 9d65984..04373bf 100644 --- a/web/src/views/scripts/useTravelReimbursementStewardRuntimeDecision.js +++ b/web/src/views/scripts/useTravelReimbursementStewardRuntimeDecision.js @@ -80,7 +80,7 @@ export function useTravelReimbursementStewardRuntimeDecision({ messages.value.push(createMessage( 'assistant', [ - '我理解你是在确认当前申请单,但这张申请单还不能提交。', + '我理解您是在确认当前申请单,但这张申请单还不能提交。', '', missingFields.length ? `还需要先补充:**${missingFields.join('、')}**。` @@ -438,7 +438,7 @@ export function useTravelReimbursementStewardRuntimeDecision({ if (isStewardRuntimeCancelText(normalizedText)) { return { next_action: 'cancel_current_action', - response_text: '已暂停当前等待动作。我不会继续提交或进入下一步;如果你要重新规划,请直接告诉我新的财务事项。' + response_text: '已暂停当前等待动作。我不会继续提交或进入下一步;如果您要重新规划,请直接告诉我新的财务事项。' } } const slotContext = findPendingSlotSuggestedActionContextByInput(normalizedText) @@ -476,7 +476,7 @@ export function useTravelReimbursementStewardRuntimeDecision({ return { next_action: 'ask_user', response_text: missingFields.length - ? `当前申请还不能继续提交,请先补充:${missingFields.join('、')}。你可以直接回复对应选项或填写具体内容。` + ? `当前申请还不能继续提交,请先补充:${missingFields.join('、')}。您可以直接回复对应选项或填写具体内容。` : '当前申请还有信息需要先补充。请先回复系统刚刚追问的内容,我再继续生成核对结果。' } } diff --git a/web/src/views/scripts/useTravelReimbursementSuggestedActions.js b/web/src/views/scripts/useTravelReimbursementSuggestedActions.js index cb45647..fdb8661 100644 --- a/web/src/views/scripts/useTravelReimbursementSuggestedActions.js +++ b/web/src/views/scripts/useTravelReimbursementSuggestedActions.js @@ -21,9 +21,16 @@ import { canUseBudgetAssistantSession } from './travelReimbursementConversationModel.js' import { + CANCEL_STANDALONE_REIMBURSEMENT_DRAFT_ACTION, + CONTINUE_REIMBURSEMENT_DRAFT_ACTION, + CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION, SKIP_REQUIRED_APPLICATION_LINK_ACTION, SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION, + buildReimbursementDraftContinuationText, buildReimbursementAssociationSubmitOptions, + buildStandaloneReimbursementDraftConfirmationActions, + buildStandaloneReimbursementDraftConfirmationText, + buildViewReimbursementDraftAction, pushReimbursementAssociationPromptMessage } from './travelReimbursementAssociationGateModel.js' import { STEWARD_ASSISTANT_NAME } from './useTravelReimbursementStewardRuntime.js' @@ -44,8 +51,10 @@ export function useTravelReimbursementSuggestedActions({ composerDraft, composerFilesExpanded, composerTextareaRef, + composerUploadIntent = { value: '' }, createMessage, currentUser, + draftClaimId = { value: '' }, emit, fetchExpenseClaims = async () => ({ items: [] }), handleGuidedShortcut, @@ -248,6 +257,53 @@ export function useTravelReimbursementSuggestedActions({ persistSessionState() } + function normalizeDraftActionPayload(payload = {}) { + return { + id: String(payload.id || payload.claim_id || payload.claimId || '').trim(), + claim_no: String(payload.claim_no || payload.claimNo || '').trim(), + original_message: String(payload.original_message || payload.originalMessage || '我要报销').trim() || '我要报销' + } + } + + function pushDraftContinuationPrompt(actionPayload = {}) { + const draft = normalizeDraftActionPayload(actionPayload) + const claimNo = draft.claim_no || '当前草稿' + if (!draft.id) { + toast('当前没有可继续关联的草稿单据。') + return + } + + draftClaimId.value = draft.id + composerUploadIntent.value = 'continue_existing' + messages.value.push(createMessage('user', `继续关联草稿 ${claimNo}`)) + messages.value.push(createMessage('assistant', buildReimbursementDraftContinuationText(draft), [], { + meta: ['等待上传附件或说明'], + suggestedActions: [buildViewReimbursementDraftAction(draft, draft.original_message)] + })) + nextTick(scrollToBottom) + persistSessionState() + } + + function pushStandaloneDraftCreationPrompt(originalMessage = '我要报销', selectedLabel = '独立新建报销单') { + const sourceText = String(originalMessage || '我要报销').trim() || '我要报销' + const userText = String(selectedLabel || '独立新建报销单').trim() || '独立新建报销单' + messages.value.push(createMessage('user', userText)) + messages.value.push(createMessage('assistant', buildStandaloneReimbursementDraftConfirmationText(), [], { + meta: ['等待确认新建草稿'], + suggestedActions: buildStandaloneReimbursementDraftConfirmationActions(sourceText) + })) + nextTick(scrollToBottom) + persistSessionState() + } + + function pushStandaloneDraftCreationCancelledPrompt() { + messages.value.push(createMessage('assistant', '好的,本次先不新建报销草稿。您可以继续查看已有草稿,或补充新的报销说明。', [], { + meta: ['已取消新建'] + })) + nextTick(scrollToBottom) + persistSessionState() + } + async function pushExpenseAssociationGatePrompt(originalMessage, options = {}) { const sourceText = String(originalMessage || '我要报销').trim() || '我要报销' startExpenseSceneSelectionAfterIntentConfirmation(sourceText) @@ -288,6 +344,25 @@ export function useTravelReimbursementSuggestedActions({ if (await handleGuidedSuggestedAction(message, action)) return if (await handleSceneSelectionApplicationGate(message, action)) return + if (actionType === CONTINUE_REIMBURSEMENT_DRAFT_ACTION) { + if (!lockSuggestedActionMessage(message, action)) return + pushDraftContinuationPrompt(action?.payload || {}) + return + } + + if (actionType === CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION) { + const originalMessage = String(action?.payload?.original_message || message?.text || '我要报销').trim() || '我要报销' + if (!lockSuggestedActionMessage(message, action)) return + pushStandaloneDraftCreationPrompt(originalMessage, action?.label || '独立新建报销单') + return + } + + if (actionType === CANCEL_STANDALONE_REIMBURSEMENT_DRAFT_ACTION) { + if (!lockSuggestedActionMessage(message, action)) return + pushStandaloneDraftCreationCancelledPrompt() + return + } + if (actionType === SKIP_REIMBURSEMENT_DRAFT_CHECK_ACTION) { const originalMessage = String(action?.payload?.original_message || message?.text || '我要报销').trim() || '我要报销' if (!lockSuggestedActionMessage(message, action)) return diff --git a/web/tests/backend-health-timeout.test.mjs b/web/tests/backend-health-timeout.test.mjs index d643b4d..3fa378f 100644 --- a/web/tests/backend-health-timeout.test.mjs +++ b/web/tests/backend-health-timeout.test.mjs @@ -9,6 +9,10 @@ const routerScript = readFileSync( fileURLToPath(new URL('../src/router/index.js', import.meta.url)), 'utf8' ) +const backendUnavailableScript = readFileSync( + fileURLToPath(new URL('../src/views/scripts/BackendUnavailableRouteView.js', import.meta.url)), + 'utf8' +) test('app route guard allows stale healthy state when health check times out', () => { assert.match(routerScript, /checkBackendHealth\(\{\s*allowStaleOnTimeout:\s*true\s*\}\)/) @@ -49,3 +53,11 @@ test('backend health timeout does not block app rendering when stale fallback is global.fetch = originalFetch } }) + +test('backend unavailable page automatically recovers after service startup race', () => { + assert.match(backendUnavailableScript, /onMounted\(\s*\(\)\s*=>\s*\{/) + assert.match(backendUnavailableScript, /startAutoRecover\(\)/) + assert.match(backendUnavailableScript, /globalThis\.setInterval/) + assert.match(backendUnavailableScript, /router\.replace\(loggedIn\.value \? resolveEntryRoute\(\) : \{ name: 'login' \}\)/) + assert.match(backendUnavailableScript, /onBeforeUnmount\(\s*\(\)\s*=>\s*\{/) +}) diff --git a/web/tests/expense-application-fast-preview.test.mjs b/web/tests/expense-application-fast-preview.test.mjs index 111fab2..16948c2 100644 --- a/web/tests/expense-application-fast-preview.test.mjs +++ b/web/tests/expense-application-fast-preview.test.mjs @@ -268,7 +268,7 @@ test('assistant scope guard blocks unsupported non-financial intent', () => { Array.from({ length: 4 }, () => ASSISTANT_SCOPE_ACTION_SWITCH) ) assert.match(greetingGuard.text, /小财管家暂时不处理「你好」/) - assert.match(greetingGuard.text, /你可以直接点下面的场景继续/) + assert.match(greetingGuard.text, /您可以直接点下面的场景继续/) assert.equal(guard.suggestedActions.length, 4) assert.equal(guard.blocked, true) assert.equal(guard.targetSessionType, '') @@ -466,6 +466,28 @@ test('application preview parses same-month shorthand date range', () => { assert.doesNotMatch(preview.fields.reason, /小财管家继续执行/) }) +test('application preview splits compact destination and business purpose', () => { + const preview = buildLocalApplicationPreview( + '2026-02-20 至 2026-02-23,去上海辅助国网仿生产服务器部署,火车', + { + name: '曹笑竹', + departmentName: '技术部', + position: '财务智能化产品经理', + managerName: '向万红', + grade: 'P5' + }, + { today: '2026-06-09' } + ) + + assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23') + assert.equal(preview.fields.days, '4天') + assert.equal(preview.fields.location, '上海') + assert.equal(preview.fields.reason, '辅助国网仿生产服务器部署') + assert.equal(preview.fields.transportMode, '火车') + assert.equal(preview.readyToSubmit, true) + assert.deepEqual(preview.validationIssues, []) +}) + test('application preview blocks submit when date range conflicts with explicit days', () => { const preview = buildLocalApplicationPreview( '申请2月20-23日去上海出差3天,辅助国网仿生产服务器部署,火车', @@ -569,6 +591,40 @@ test('application preview trusts model-refined fields over noisy source candidat assert.deepEqual(preview.validationIssues, []) }) +test('application preview normalizes model-refined location mixed with business content', () => { + const rawText = '申请2月20日-23日火车出差,事由:辅助国网仿生产服务器部署' + const preview = buildModelRefinedApplicationPreview( + buildLocalApplicationPreview(rawText, { name: '曹笑竹', grade: 'P5' }, { today: '2026-06-09' }), + { + parse_strategy: 'llm_primary', + entities: [ + { type: 'expense_type', value: '差旅费', normalized_value: 'travel' }, + { type: 'location', value: '上海辅助国网仿生产服务器', normalized_value: '上海辅助国网仿生产服务器' }, + { type: 'reason', value: '辅助国网仿生产服务器部署', normalized_value: '辅助国网仿生产服务器部署' }, + { type: 'transport_mode', value: '火车', normalized_value: '火车' }, + { type: 'policy_total_amount', value: '2120元', normalized_value: '2120' } + ], + time_range: { + start_date: '2026-02-20', + end_date: '2026-02-23' + }, + missing_slots: [] + }, + rawText, + { name: '曹笑竹', grade: 'P5' } + ) + const estimateRequest = buildApplicationPolicyEstimateRequest(preview, { grade: 'P5', location: '武汉' }) + const footer = buildApplicationPreviewFooterMessage(preview) + + assert.equal(preview.fields.location, '上海') + assert.equal(preview.fields.reason, '辅助国网仿生产服务器部署') + assert.equal(preview.readyToSubmit, true) + assert.deepEqual(preview.validationIssues, []) + assert.match(footer, /#application-submit/) + assert.equal(estimateRequest.canCalculate, true) + assert.equal(estimateRequest.payload.location, '上海') +}) + test('application preview blocks submit when transport candidates conflict', () => { const preview = buildLocalApplicationPreview( '申请2月20-23日去上海出差4天,辅助国网仿生产服务器部署,出行方式:飞机,坐火车', @@ -1054,7 +1110,7 @@ test('steward application missing transport blocks preview table', () => { assert.match(submitComposerScript, /applicationPreview:\s*pauseForMissingFields \? null : applicationPreview/) assert.match(submitComposerScript, /我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表/) assert.match(submitComposerScript, /applicationPreview:\s*normalized/) - assert.doesNotMatch(submitComposerScript, /请先告诉我你打算怎么出行:\*\*火车、飞机或轮船\*\*/) + assert.doesNotMatch(submitComposerScript, /请先告诉我您打算怎么出行:\*\*火车、飞机或轮船\*\*/) assert.match(suggestedActionsScript, /payload\.applicationPreview/) assert.match(suggestedActionsScript, /function continueStewardApplicationFieldCompletion/) diff --git a/web/tests/receipt-folder-view.test.mjs b/web/tests/receipt-folder-view.test.mjs index 6229c48..7847cc5 100644 --- a/web/tests/receipt-folder-view.test.mjs +++ b/web/tests/receipt-folder-view.test.mjs @@ -57,6 +57,9 @@ function testReceiptFolderViewSurface() { assert.match(view, /buildReceiptFile\(item\)/) assert.match(view, /source: selectedDraft \? 'detail' : 'receipt-folder'/) assert.match(view, /emit\('open-assistant'/) + assert.match(view, /REIMBURSEMENT_LIST_PREVIEW_PARAMS/) + assert.match(view, /fetchExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/) + assert.doesNotMatch(view, /const claims = await fetchExpenseClaims\(\)/) } function testReceiptFolderServiceContract() { @@ -160,18 +163,19 @@ function testReceiptFolderDetailLayoutAdjustments() { function testAssistantUnlinkedReceiptPrompt() { const submitComposer = readProjectFile('web/src/views/scripts/useTravelReimbursementSubmitComposer.js') - const assistantView = readProjectFile('web/src/views/scripts/TravelReimbursementCreateView.js') + const attachmentFlow = readProjectFile('web/src/views/scripts/travelReimbursementSubmitAttachmentFlow.js') + const suggestedActions = readProjectFile('web/src/views/scripts/useTravelReimbursementSuggestedActions.js') assert.match(submitComposer, /fetchReceiptFolderItems/) assert.match(submitComposer, /promptUnlinkedReceiptFolderIfNeeded/) - assert.match(submitComposer, /fetchReceiptFolderItems\('unlinked'\)/) - assert.match(submitComposer, /skipReceiptFolderUnlinkedPrompt/) - assert.match(submitComposer, /open_receipt_folder/) - assert.match(submitComposer, /continue_upload_with_unlinked_receipts/) - assert.match(assistantView, /actionType === 'open_receipt_folder'/) - assert.match(assistantView, /router\.push\(\{ name: 'app-receiptFolder' \}\)/) - assert.match(assistantView, /actionType === 'continue_upload_with_unlinked_receipts'/) - assert.match(assistantView, /skipReceiptFolderUnlinkedPrompt: true/) + assert.match(attachmentFlow, /fetchReceiptFolderItems\('unlinked'\)/) + assert.match(attachmentFlow, /skipReceiptFolderUnlinkedPrompt/) + assert.match(attachmentFlow, /open_receipt_folder/) + assert.match(attachmentFlow, /continue_upload_with_unlinked_receipts/) + assert.match(suggestedActions, /actionType === 'open_receipt_folder'/) + assert.match(suggestedActions, /router\.push\(\{ name: 'app-receiptFolder' \}\)/) + assert.match(suggestedActions, /actionType === 'continue_upload_with_unlinked_receipts'/) + assert.match(suggestedActions, /skipReceiptFolderUnlinkedPrompt: true/) } function run() { diff --git a/web/tests/reimbursement-list-preview-fetch.test.mjs b/web/tests/reimbursement-list-preview-fetch.test.mjs new file mode 100644 index 0000000..1815542 --- /dev/null +++ b/web/tests/reimbursement-list-preview-fetch.test.mjs @@ -0,0 +1,37 @@ +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() + +function readProjectFile(path) { + return readFileSync(join(root, path), 'utf8') +} + +test('workbench and document list refreshes use preview pagination', () => { + const useRequests = readProjectFile('web/src/composables/useRequests.js') + const useAppShell = readProjectFile('web/src/composables/useAppShell.js') + const documentsCenter = readProjectFile('web/src/views/DocumentsCenterView.vue') + const approvalCenter = readProjectFile('web/src/views/scripts/ApprovalCenterView.js') + const archiveCenter = readProjectFile('web/src/views/scripts/ArchiveCenterView.js') + + assert.match(useRequests, /REIMBURSEMENT_LIST_PREVIEW_PARAMS/) + assert.match(useRequests, /fetchExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/) + assert.doesNotMatch(useRequests, /fetchAllExpenseClaims\(\)/) + + assert.match(useAppShell, /REIMBURSEMENT_LIST_PREVIEW_PARAMS/) + assert.match(useAppShell, /fetchApprovalExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/) + assert.doesNotMatch(useAppShell, /fetchAllApprovalExpenseClaims\(\)/) + + assert.match(documentsCenter, /fetchApprovalExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/) + assert.match(documentsCenter, /fetchArchivedExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/) + assert.doesNotMatch(documentsCenter, /fetchAllApprovalExpenseClaims\(\)/) + assert.doesNotMatch(documentsCenter, /fetchAllArchivedExpenseClaims\(\)/) + + assert.match(approvalCenter, /fetchApprovalExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/) + assert.doesNotMatch(approvalCenter, /fetchApprovalExpenseClaims\(\)/) + + assert.match(archiveCenter, /fetchArchivedExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/) + assert.doesNotMatch(archiveCenter, /fetchArchivedExpenseClaims\(\)/) +}) diff --git a/web/tests/steward-plan-message-copy.test.mjs b/web/tests/steward-plan-message-copy.test.mjs index 96d14f9..e1ef61d 100644 --- a/web/tests/steward-plan-message-copy.test.mjs +++ b/web/tests/steward-plan-message-copy.test.mjs @@ -20,12 +20,12 @@ test('steward plan summary uses warm guidance copy for application flow', () => next_action: 'confirm_create_application' }) - assert.match(message, /我先帮你把步骤理清楚/) - assert.match(message, /我先看了一下,你这次主要是 \*\*1 个事项\*\*/) + assert.match(message, /我先帮您把步骤理清楚/) + assert.match(message, /我先看了一下,您这次主要是 \*\*1 个事项\*\*/) assert.match(message, /为了不让步骤混在一起/) - assert.match(message, /我会请申请助手先把申请单草稿整理出来/) - assert.match(message, /你看这个顺序是否合适/) - assert.match(message, /需要补充的信息会在具体步骤里再温和提醒你/) + assert.match(message, /这步交给申请助手——先把申请单草稿拉出来给您过目/) + assert.match(message, /您看这个顺序是否合适/) + assert.match(message, /需要补充的信息会在具体步骤里再温和提醒/) assert.doesNotMatch(message, /我会这样推进/) assert.doesNotMatch(message, /不会一次性把所有动作都执行掉/) assert.doesNotMatch(message, /交给申请助手生成申请单核对结果/) @@ -59,8 +59,8 @@ test('steward plan summary guides bare reimbursement intent into scene selection const message = buildStewardPlanMessageText(plan) - assert.match(message, /我来带你发起报销/) - assert.match(message, /你现在只说了要报销/) + assert.match(message, /我来带您发起报销/) + assert.match(message, /您现在只说了要报销/) assert.match(message, /先选报销场景/) assert.match(message, /差旅费、交通费、住宿费/) assert.doesNotMatch(message, /步骤混在一起/) diff --git a/web/tests/travel-reimbursement-guided-flow.test.mjs b/web/tests/travel-reimbursement-guided-flow.test.mjs index 268d391..67b18bc 100644 --- a/web/tests/travel-reimbursement-guided-flow.test.mjs +++ b/web/tests/travel-reimbursement-guided-flow.test.mjs @@ -94,6 +94,10 @@ const submitComposerScript = readFileSync( fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)), 'utf8' ) +const submitDraftPreflightScript = readFileSync( + fileURLToPath(new URL('../src/views/scripts/travelReimbursementSubmitDraftPreflight.js', import.meta.url)), + 'utf8' +) const messageHandlersScript = readFileSync( fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementCreateViewMessageHandlers.js', import.meta.url)), 'utf8' @@ -477,6 +481,12 @@ test('guided flow is local until final confirmation or collected query handoff', assert.doesNotMatch(guidedFlowScript, /startExpenseClaimDraftFlowStep/) assert.match(guidedModelScript, /review_action:\s*['"]save_draft['"]/) assert.match(guidedFlowScript, /fetchExpenseClaims/) + assert.match(guidedFlowScript, /REIMBURSEMENT_LIST_PREVIEW_PARAMS/) + assert.match(guidedFlowScript, /fetchExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/) + assert.match(submitDraftPreflightScript, /REIMBURSEMENT_LIST_PREVIEW_PARAMS/) + assert.match(submitDraftPreflightScript, /fetchExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/) + assert.doesNotMatch(guidedFlowScript, /fetchExpenseClaims\(\)/) + assert.doesNotMatch(submitDraftPreflightScript, /fetchExpenseClaims\(\)/) assert.match(guidedFlowScript, /GUIDED_ACTION_SELECT_REQUIRED_APPLICATION/) assert.match(guidedFlowScript, /isGuidedReimbursementReadyForReview\(guidedFlowState\.value\)[\s\S]*pushReimbursementSummary\(\)/) assert.match(guidedFlowScript, /isGuidedReimbursementReadyForReview\(currentState\) && fileNames\.length[\s\S]*buildGuidedReviewSubmitOptions\(currentState, mergedFiles\)[\s\S]*skipDraftAssociationPrompt:\s*true[\s\S]*skipUserMessage:\s*true[\s\S]*submitExistingComposer\(submitOptions\)/) diff --git a/web/tests/travel-request-detail-risk-advice.test.mjs b/web/tests/travel-request-detail-risk-advice.test.mjs index 487e256..e52f5da 100644 --- a/web/tests/travel-request-detail-risk-advice.test.mjs +++ b/web/tests/travel-request-detail-risk-advice.test.mjs @@ -434,6 +434,13 @@ test('AI advice ignores approval opinions and flow logs as risks', () => { severity: 'info', label: '财务审核通过', message: '周晓彤 已完成财务审核,进入归档入账。' + }, + { + source: 'application_link_sync', + event_type: 'expense_application_reimbursement_deleted', + severity: 'warning', + label: '关联报销单已删除', + message: '关联报销单 RDELETE01 已删除,申请单已回到待关联状态。' } ] }) diff --git a/web/tests/workbench-ai-action-router.test.mjs b/web/tests/workbench-ai-action-router.test.mjs index 9f03fb6..7bb9813 100644 --- a/web/tests/workbench-ai-action-router.test.mjs +++ b/web/tests/workbench-ai-action-router.test.mjs @@ -4,6 +4,10 @@ import test from 'node:test' import { buildInlineApplicationPreview } from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js' import { buildStewardSuggestedActions } from '../src/views/scripts/stewardPlanModel.js' import { useWorkbenchAiActionRouter } from '../src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js' +import { + CONTINUE_REIMBURSEMENT_DRAFT_ACTION, + CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION +} from '../src/views/scripts/travelReimbursementAssociationGateModel.js' test('workbench steward application confirmation opens inline application preview directly', () => { const [action] = buildStewardSuggestedActions({ @@ -136,3 +140,108 @@ test('workbench reimbursement skip link action opens new reimbursement flow', () label: '不关联,单独新建报销单' }) }) + +test('workbench draft continuation action asks for attachments or description', () => { + let continuationPayload = null + let fallbackConversationStarted = false + const router = useWorkbenchAiActionRouter({ + aiExpenseDraft: { value: null }, + applicationFlow: { + isInlineSuggestedActionDisabled: () => false, + executeInlineApplicationPreviewAction: () => {} + }, + assistantDraft: { value: '' }, + attachmentFlow: { + confirmAiAttachmentAssociation: () => {} + }, + emit: () => {}, + expenseFlow: { + linkAiExpenseApplication: () => {}, + promptAiReimbursementDraftContinuation: (payload) => { + continuationPayload = payload + }, + promptStandaloneReimbursementDraftCreation: () => {}, + pushInlineExpenseSceneSelectionPrompt: () => {}, + startAiApplicationPreviewFromAction: () => {}, + startAiExpenseDraft: () => {}, + startAiReimbursementAssociationGate: () => {} + }, + focusAiModeInput: () => {}, + hasInlineAttachmentOcrDetails: () => false, + resolveLatestInlineUserPrompt: () => '', + selectedFiles: { value: [] }, + startInlineConversation: () => { + fallbackConversationStarted = true + }, + toast: () => {}, + toggleInlineAttachmentOcrDetails: () => {} + }) + + router.handleInlineSuggestedAction({ + label: '继续关联草稿 RE-202606-010', + action_type: CONTINUE_REIMBURSEMENT_DRAFT_ACTION, + payload: { + claim_id: 'draft-travel-1', + claim_no: 'RE-202606-010', + original_message: '我要报销' + } + }) + + assert.equal(fallbackConversationStarted, false) + assert.deepEqual(continuationPayload, { + claim_id: 'draft-travel-1', + claim_no: 'RE-202606-010', + original_message: '我要报销' + }) +}) + +test('workbench standalone draft action asks before creating a new reimbursement draft', () => { + let standalonePrompt = null + let fallbackConversationStarted = false + const router = useWorkbenchAiActionRouter({ + aiExpenseDraft: { value: null }, + applicationFlow: { + isInlineSuggestedActionDisabled: () => false, + executeInlineApplicationPreviewAction: () => {} + }, + assistantDraft: { value: '' }, + attachmentFlow: { + confirmAiAttachmentAssociation: () => {} + }, + emit: () => {}, + expenseFlow: { + linkAiExpenseApplication: () => {}, + promptAiReimbursementDraftContinuation: () => {}, + promptStandaloneReimbursementDraftCreation: (sourceText, label) => { + standalonePrompt = { sourceText, label } + }, + pushInlineExpenseSceneSelectionPrompt: () => {}, + startAiApplicationPreviewFromAction: () => {}, + startAiExpenseDraft: () => {}, + startAiReimbursementAssociationGate: () => {} + }, + focusAiModeInput: () => {}, + hasInlineAttachmentOcrDetails: () => false, + resolveLatestInlineUserPrompt: () => '', + selectedFiles: { value: [] }, + startInlineConversation: () => { + fallbackConversationStarted = true + }, + toast: () => {}, + toggleInlineAttachmentOcrDetails: () => {} + }) + + router.handleInlineSuggestedAction({ + label: '独立新建报销单', + action_type: CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION, + payload: { + original_message: '我要报销' + } + }) + + assert.equal(fallbackConversationStarted, false) + assert.deepEqual(standalonePrompt, { + sourceText: '我要报销', + label: '独立新建报销单' + }) +}) diff --git a/web/tests/workbench-ai-application-result-card.test.mjs b/web/tests/workbench-ai-application-result-card.test.mjs new file mode 100644 index 0000000..fd375aa --- /dev/null +++ b/web/tests/workbench-ai-application-result-card.test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + buildInlineApplicationDetailAction, + buildInlineApplicationPreviewActionResultText +} from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js' +import { + AI_APPLICATION_ACTION_SAVE_DRAFT, + AI_APPLICATION_ACTION_SUBMIT +} from '../src/services/aiApplicationPreviewActions.js' + +const draftPayload = { + claim_no: 'AUKSNUCFD', + claim_id: 'claim-1001', + approval_stage: '直属领导审批', + start_date: '2026-02-20', + location: '上海辅助国网仿生产服务器', + reason: '差旅费用申请' +} + +test('application result card stays display-only while the detail shortcut keeps navigation', () => { + const resultText = buildInlineApplicationPreviewActionResultText(AI_APPLICATION_ACTION_SUBMIT, { + result: { draft_payload: draftPayload } + }) + const [detailAction] = buildInlineApplicationDetailAction(draftPayload) + + assert.match(resultText, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \|/) + assert.doesNotMatch(resultText, /\| 操作 \|/) + assert.doesNotMatch(resultText, /\[查看\]/) + assert.doesNotMatch(resultText, /点击卡片.*操作.*查看/) + assert.equal(detailAction?.label, '查看单据详情') + assert.equal(detailAction?.action_type, 'open_application_detail') + assert.equal(detailAction?.payload?.claim_no, 'AUKSNUCFD') +}) + +test('saved draft result also avoids the duplicate in-card view guidance', () => { + const resultText = buildInlineApplicationPreviewActionResultText(AI_APPLICATION_ACTION_SAVE_DRAFT, { + result: { draft_payload: draftPayload } + }) + + assert.match(resultText, /申请草稿已保存/) + assert.doesNotMatch(resultText, /\| 操作 \|/) + assert.doesNotMatch(resultText, /\[查看\]/) + assert.doesNotMatch(resultText, /点击卡片.*操作.*查看/) +}) diff --git a/web/tests/workbench-ai-composer-components.test.mjs b/web/tests/workbench-ai-composer-components.test.mjs index bd98c1d..751cd22 100644 --- a/web/tests/workbench-ai-composer-components.test.mjs +++ b/web/tests/workbench-ai-composer-components.test.mjs @@ -9,9 +9,13 @@ function readSource(path) { const aiModeComponent = readSource('../src/components/business/PersonalWorkbenchAiMode.vue') const aiModeTemplate = readSource('../src/components/business/PersonalWorkbenchAiMode.template.html') +const aiModeStyles = readSource('../src/assets/styles/components/personal-workbench-ai-mode.css') const composerComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiComposer.vue') const fileStripComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiFileStrip.vue') +const filePreviewComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiFilePreviewDialog.vue') +const filePreviewStyles = readSource('../src/assets/styles/components/workbench-ai-file-preview-dialog.css') const aiModeRuntime = readSource('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js') +const filePreviewRuntime = readSource('../src/composables/workbenchAiMode/useWorkbenchAiFilePreview.js') function countOccurrences(source, pattern) { return source.match(pattern)?.length || 0 @@ -44,3 +48,57 @@ test('shared workbench file strip preserves OCR status badges', () => { assert.match(fileStripComponent, /mdi mdi-text-recognition/) assert.match(fileStripComponent, /:title="file\.ocrState\.title \|\| file\.ocrState\.label"/) }) + +test('AI mode primes attachment OCR synchronously after file selection', () => { + assert.match( + filePreviewRuntime, + /watch\(\s*selectedFiles,\s*\(files(?:,\s*previousFiles\s*=\s*\[\])?\)\s*=>\s*\{[\s\S]*attachmentFlow\.primeAiModeReceiptContext\(files\)[\s\S]*\},\s*\{\s*flush:\s*'sync'\s*\}\s*\)/ + ) +}) + +test('AI mode keeps conversation anchored above selected attachments', () => { + assert.match( + filePreviewRuntime, + /watch\(\s*selectedFiles,\s*\(files,\s*previousFiles\s*=\s*\[\]\)\s*=>\s*\{[\s\S]*const fileCountChanged = files\.length !== previousFiles\.length[\s\S]*scrollInlineConversationToBottom\(\{\s*force:\s*true\s*\}\)[\s\S]*\},\s*\{\s*flush:\s*'sync'\s*\}\s*\)/ + ) + assert.match(aiModeStyles, /\.workbench-ai-conversation-bottom\s*\{[\s\S]*gap:\s*14px;/) + assert.match(aiModeStyles, /\.workbench-ai-thread\s*\{[\s\S]*scroll-padding-bottom:\s*42px;/) +}) + +test('AI mode lays selected attachments in a horizontal scroll strip', () => { + assert.match(aiModeStyles, /\.workbench-ai-file-strip\s*\{[\s\S]*flex-wrap:\s*nowrap;[\s\S]*overflow-x:\s*auto;[\s\S]*overflow-y:\s*hidden;/) + assert.match(aiModeStyles, /\.workbench-ai-file-card\s*\{[\s\S]*flex:\s*0 0 312px;/) + assert.match(fileStripComponent, /role="button"/) + assert.match(fileStripComponent, /@click="runtime\.openAiModeFilePreview\(file\.key\)"/) + assert.match(fileStripComponent, /@click\.stop="runtime\.removeAiModeFile\(file\.key\)"/) +}) + +test('AI mode attachment preview opens a split source and recognition dialog', () => { + assert.match(aiModeComponent, /import WorkbenchAiFilePreviewDialog from '\.\/workbench-ai\/WorkbenchAiFilePreviewDialog\.vue'/) + assert.match(aiModeTemplate, //) + assert.match(aiModeRuntime, /useWorkbenchAiFilePreview\(/) + assert.match(aiModeRuntime, /\.\.\.filePreview,/) + assert.match(filePreviewRuntime, /URL\.createObjectURL\(rawFile\)/) + assert.match(filePreviewRuntime, /attachmentFlow\.resolveAiModeReceiptRecognitionState\(rawFile\)/) + assert.match(filePreviewComponent, /class="workbench-ai-file-preview-source"/) + assert.match(filePreviewComponent, /class="workbench-ai-file-preview-insight"/) + assert.match(filePreviewComponent, / { + assert.match(filePreviewStyles, /--workbench-ai-preview-sidebar-offset:\s*var\(--sidebar-expanded-width,\s*304px\);/) + assert.match( + filePreviewStyles, + /\.workbench-ai-file-preview-mask\s*\{[\s\S]*grid-template-columns:\s*var\(--workbench-ai-preview-sidebar-offset\) minmax\(0,\s*1fr\);/ + ) + assert.match( + filePreviewStyles, + /\.workbench-ai-file-preview-dialog\s*\{[\s\S]*grid-column:\s*2;[\s\S]*justify-self:\s*center;/ + ) + assert.match( + filePreviewStyles, + /@media \(max-width:\s*900px\)\s*\{[\s\S]*--workbench-ai-preview-sidebar-offset:\s*0px;[\s\S]*grid-template-columns:\s*minmax\(0,\s*1fr\);/ + ) +}) diff --git a/web/tests/workbench-ai-mode-expense-scene-action.test.mjs b/web/tests/workbench-ai-mode-expense-scene-action.test.mjs index c6be272..bc99252 100644 --- a/web/tests/workbench-ai-mode-expense-scene-action.test.mjs +++ b/web/tests/workbench-ai-mode-expense-scene-action.test.mjs @@ -195,14 +195,14 @@ test('AI mode formats saved application draft as a detail table without continui assert.match(aiMode, /function normalizeInlineApplicationStatusLabel\(value, fallback = ''\)/) assert.match(aiMode, /submitted:\s*'审批中'/) assert.match(aiMode, /const statusLabel = normalizeInlineApplicationStatusLabel\(info\.statusLabel, options\.statusLabel\)/) - assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \| 操作 \|/) - assert.match(aiMode, /\[查看\]\(\$\{href\}\)/) + assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \|/) + assert.doesNotMatch(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \| 操作 \|/) + assert.doesNotMatch(aiMode, /\[查看\]\(\$\{href\}\)/) assert.match(aiMode, /dateLabel:\s*rangeText \|\| dateText \|\| resolveBodyField\(\['时间', '日期', '申请时间'\]\) \|\| '待补充'/) assert.match(aiMode, /locationLabel:[\s\S]*resolveBodyField\(\['地点', '目的地'\]\) \|\| '待补充'/) assert.match(aiMode, /reasonLabel:[\s\S]*resolveBodyField\(\['事由', '事件', '申请事由'\]\) \|\| '待补充'/) - assert.match(aiMode, /buildInlineApplicationActionDetailHref\(info\)/) - assert.match(aiMode, /params\.set\('claim_id', claimId\)/) - assert.match(aiMode, /params\.set\('claim_no', claimNo\)/) + assert.match(aiMode, /function buildInlineApplicationDetailAction\(draftPayload = \{\}\)/) + assert.match(aiMode, /action_type:\s*'open_application_detail'/) const resultStart = aiMode.indexOf('function buildInlineApplicationPreviewActionResultText') const resultEnd = aiMode.indexOf('\nfunction buildInlineApplicationDetailAction', resultStart) @@ -227,7 +227,7 @@ test('AI mode formats saved application draft as a detail table without continui executeBlock, /targetMessage\.suggestedActions = isSubmit[\s\S]*buildInlineApplicationPreviewSuggestedActions\(targetMessage\.applicationPreview, draftPayload\)/ ) - assert.match(executeBlock, /suggestedActions:\s*isSubmit\s*\?\s*buildInlineApplicationDetailAction\(draftPayload\)\s*:\s*\[\]/) + assert.match(executeBlock, /suggestedActions:\s*buildInlineApplicationDetailAction\(draftPayload\)/) }) test('AI mode locks application preview actions while estimate refresh is pending', () => { diff --git a/web/tests/workbench-ai-mode-switch.test.mjs b/web/tests/workbench-ai-mode-switch.test.mjs index 1214bb5..bfa2a7d 100644 --- a/web/tests/workbench-ai-mode-switch.test.mjs +++ b/web/tests/workbench-ai-mode-switch.test.mjs @@ -244,9 +244,10 @@ test('AI mode screen follows the approved reference structure', () => { 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/) assert.match(aiModeSurface, /aiAttachmentAssociationModel\.buildAiAttachmentAssociationResultMessage/) assert.match(aiModeSurface, /syncExpenseClaimFilesToDraft/) + assert.match(aiModeSurface, /createAttachmentAssociationJob[\s\S]*services\/attachmentAssociationJobs\.js/) + assert.match(aiModeSurface, /fetchAttachmentAssociationJob[\s\S]*services\/attachmentAssociationJobs\.js/) assert.match(aiModeSurface, /import \{ recognizeOcrFiles \} from '\.\.\/\.\.\/services\/ocr\.js'/) assert.match(aiModeSurface, /const AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION = 'confirm_ai_attachment_association'/) assert.match(aiModeSurface, /const AI_ATTACHMENT_OCR_DETAIL_ACTION = 'show_ai_attachment_ocr_details'/) @@ -269,10 +270,16 @@ test('AI mode screen follows the approved reference structure', () => { assert.match(aiModeSurface, /resolveAiAttachmentAssociationClaimNo\(actionPayload\)/) assert.match(aiModeSurface, /if \(actionType === AI_ATTACHMENT_OCR_DETAIL_ACTION\)/) 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/) - assert.match(aiModeSurface, /attachmentOcrDetails,\s*[\s\S]*includeOcrDetails: Boolean\(attachmentOcrDetails\)/) + assert.match(aiModeSurface, /function extractReceiptIdsFromOcrDocuments\(documents = \[\]\)/) + assert.match(aiModeSurface, /const receiptIds = attachmentJobFlow\.extractReceiptIdsFromOcrDocuments\(collected\.ocrDocuments\)/) + assert.match(aiModeSurface, /await createAttachmentAssociationJob\(\{[\s\S]*receipt_ids: receiptIds,[\s\S]*conversation_id: conversationId\?\.value/) + assert.match(aiModeSurface, /attachmentAssociationJob: job/) + assert.match(aiModeSurface, /async function pollJob\(/) + assert.match(aiModeSurface, /fetchAttachmentAssociationJob\(normalizedJobId\)/) + assert.match(aiModeSurface, /function resumePendingJobs\(\)/) + assert.match(aiModeSurface, /resumePendingAiAttachmentAssociationJobs: attachmentJobFlow\.resumePendingJobs/) + assert.match(aiModeSurface, /attachmentFlow\.resumePendingAiAttachmentAssociationJobs\(\)/) + assert.match(aiModeSurface, /attachmentAssociationJob: normalizeInlineAttachmentAssociationJob/) assert.match(aiModeSurface, /async function confirmAiAttachmentAssociation\(actionPayload = \{\}, sourceMessage = null\)/) assert.match(aiModeSurface, /syncExpenseClaimFilesToDraft\(\{[\s\S]*fetchExpenseClaimDetail,[\s\S]*createExpenseClaimItem,[\s\S]*uploadExpenseClaimItemAttachment/) assert.match(aiModeSurface, /if \(actionType === AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION\)/) @@ -388,7 +395,8 @@ test('AI mode screen follows the approved reference structure', () => { assert.match(aiModeSurface, /const hasServerStreamedContent = Boolean\(String\(pendingMessage\.content \|\| ''\)\.trim\(\)\)/) 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, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \|/) + assert.doesNotMatch(aiModeSurface, /需要查看完整详情时,请点击卡片“操作”行的“查看”进入单据详情。/) assert.match(aiModeSurface, /function buildInlineApplicationActionFailureText\(error, isSubmit\)/) assert.match(aiModeSurface, /我已保留当前申请核对表/) assert.match(aiModeSurface, /applicationPreview:\s*targetMessage\.applicationPreview/) @@ -549,12 +557,25 @@ test('AI mode normal assistant requests include OCR context for uploaded receipt assert.match(aiModeSurface, /const aiModeReceiptContextCache = new Map\(\)/) assert.match(aiModeSurface, /const aiModeReceiptRecognitionState = reactive\(\{\}\)/) assert.match(aiModeSurface, /function resolveAiModeReceiptRecognitionState\(file\)/) + assert.match(aiModeSurface, /function hasPendingAiModeReceiptRecognition\(files = \[\]\)/) + assert.match(aiModeSurface, /function hasFailedAiModeReceiptRecognition\(files = \[\]\)/) 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*'recognized'[\s\S]*label:\s*detail \? `当前会话已识别/) + assert.match(aiModeSurface, /本状态不代表票据夹已有记录/) assert.match(aiModeSurface, /status:\s*'failed'[\s\S]*label:\s*'识别失败'/) + assert.match(aiModeSurface, /const isAiModeReceiptRecognitionPending = computed\(\(\) => attachmentFlow\.hasPendingAiModeReceiptRecognition\(selectedFiles\.value\)\)/) + assert.match(aiModeSurface, /const hasAiModeReceiptRecognitionFailure = computed\(\(\) => attachmentFlow\.hasFailedAiModeReceiptRecognition\(selectedFiles\.value\)\)/) + assert.match(aiModeSurface, /const isAiModeInputLocked = computed\(\(\) => applicationPreviewEstimatePending\.value \|\| isAiModeReceiptRecognitionPending\.value\)/) + assert.match(aiModeSurface, /!hasAiModeReceiptRecognitionFailure\.value[\s\S]*Boolean\(assistantDraft\.value\.trim\(\)\)/) + assert.match(aiModeSurface, /function resolveAiModeInputLockMessage\(\) \{[\s\S]*附件识别中,请稍等/) + assert.match(aiModeSurface, /hasAiModeReceiptRecognitionFailure\.value[\s\S]*请先移除识别失败的附件或重新上传/) + assert.match(aiModeSurface, /:placeholder="runtime\.isAiModeInputLocked \? runtime\.aiModeInputLockMessage : placeholder"/) assert.match(aiModeSurface, /function primeAiModeReceiptContext\(files = \[\]\)/) - assert.match(aiModeSurface, /function startAiModeReceiptRecognition\(files = \[\]\)/) + assert.match(aiModeSurface, /function startAiModeReceiptRecognition\(files = \[\], options = \{\}\)/) + assert.match(aiModeSurface, /const forceRefresh = Boolean\(options\.forceRefresh\)/) + assert.match(aiModeSurface, /if \(!forceRefresh && cached\?\.status === 'resolved'\) \{/) + assert.match(aiModeSurface, /startAiModeReceiptRecognition\(files, \{ forceRefresh: true \}\)/) assert.match(aiModeSurface, /function buildAiModeReceiptContextCacheKey\(ocrFiles = \[\]\)/) assert.match(aiModeSurface, /applyAiModeReceiptRecognitionResult\(ocrFiles, context\)/) assert.match(aiModeSurface, /buildFileIdentity\(file\)/) diff --git a/web/tests/workbench-ai-reimbursement-association-gate.test.mjs b/web/tests/workbench-ai-reimbursement-association-gate.test.mjs index da061ba..e88d481 100644 --- a/web/tests/workbench-ai-reimbursement-association-gate.test.mjs +++ b/web/tests/workbench-ai-reimbursement-association-gate.test.mjs @@ -3,7 +3,14 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import test from 'node:test' -import { useWorkbenchAiExpenseFlow } from '../src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js' +import { + buildLinkedDraftRunningText, + useWorkbenchAiExpenseFlow +} from '../src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js' +import { + CONTINUE_REIMBURSEMENT_DRAFT_ACTION, + CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION +} from '../src/views/scripts/travelReimbursementAssociationGateModel.js' const personalWorkbenchAiMode = readFileSync( join(process.cwd(), 'web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js'), @@ -40,7 +47,11 @@ function buildFlow(options = {}) { conversationStarted, createInlineMessage, currentUser: { value: options.currentUser || { name: '张小青', username: 'xiaoqing.zhang' } }, + createLinkedReimbursementDraftJobForAi: options.createLinkedReimbursementDraftJobForAi, fetchExpenseClaimsForAi: options.fetchExpenseClaimsForAi, + fetchLinkedReimbursementDraftJobForAi: options.fetchLinkedReimbursementDraftJobForAi, + linkedDraftJobPollIntervalMs: options.linkedDraftJobPollIntervalMs ?? 0, + linkedDraftJobMaxPolls: options.linkedDraftJobMaxPolls ?? 2, runOrchestratorForAi: options.runOrchestratorForAi, associationQueryTimeoutMs: options.associationQueryTimeoutMs, persistCurrentConversation: () => { @@ -61,6 +72,18 @@ function buildFlow(options = {}) { const conversationStarted = { value: false } +test('linked reimbursement draft running text avoids duplicate status wording', () => { + const content = buildLinkedDraftRunningText( + { message: '正在后台生成报销草稿...' }, + 'AVF9ST8TT' + ) + + const repeated = content.match(/正在后台生成报销草稿/g) || [] + assert.equal(repeated.length, 1) + assert.doesNotMatch(content, /处理状态:\s*正在后台生成报销草稿/) + assert.match(content, /回来后我会继续查询任务结果/) +}) + test('reimbursement intent checks drafts before recommending approved application documents', async () => { conversationStarted.value = false let queried = 0 @@ -135,7 +158,7 @@ test('reimbursement intent stops at existing reimbursement drafts before applica reason: '北京客户现场实施报销', location: '北京', status: 'draft', - amount: 650, + amount: '0.00', created_at: '2026-06-23T10:00:00Z' }, { @@ -160,10 +183,20 @@ test('reimbursement intent stops at existing reimbursement drafts before applica assert.match(assistantMessage.content, /先检查.*报销草稿/) assert.match(assistantMessage.content, /查到 1 个可继续的报销草稿/) assert.match(assistantMessage.content, /RE-202606-010/) + assert.match(assistantMessage.content, /待确认/) + assert.doesNotMatch(assistantMessage.content, />0\.00 { @@ -274,8 +307,33 @@ test('reimbursement association gate matches short username with returned employ test('linked application selection can create reimbursement draft from association gate', async () => { conversationStarted.value = false const orchestratorCalls = [] + const createJobCalls = [] + const fetchJobCalls = [] const { aiExpenseDraft, conversationMessages, flow } = buildFlow({ fetchExpenseClaimsForAi: async () => ({ items: [] }), + createLinkedReimbursementDraftJobForAi: async (payload) => { + createJobCalls.push(payload) + return { + job_id: 'linked-draft-job-1', + status: 'queued', + message: '已创建后台生成任务。' + } + }, + fetchLinkedReimbursementDraftJobForAi: async (jobId) => { + fetchJobCalls.push(jobId) + return { + job_id: jobId, + status: 'succeeded', + message: '报销草稿已生成。', + draft_payload: { + claim_id: 'draft-linked-1', + claim_no: 'RE-202606-009', + status: 'draft', + expense_type: 'travel', + reason: '北京客户现场实施' + } + } + }, runOrchestratorForAi: async (payload, options) => { orchestratorCalls.push({ payload, options }) return { @@ -303,16 +361,93 @@ test('linked application selection can create reimbursement draft from associati application_amount_label: '1,650元' }) - assert.equal(orchestratorCalls.length, 1) - assert.equal(orchestratorCalls[0].payload.context_json.review_action, 'save_draft') - assert.equal(orchestratorCalls[0].payload.context_json.expense_scene_selection.application_claim_no, 'AP-202606-001') - assert.equal(orchestratorCalls[0].payload.context_json.review_form_values.application_claim_no, 'AP-202606-001') + assert.equal(orchestratorCalls.length, 0) + assert.equal(createJobCalls.length, 1) + assert.equal(createJobCalls[0].context_json.review_action, 'save_draft') + assert.equal(createJobCalls[0].context_json.expense_scene_selection.application_claim_no, 'AP-202606-001') + assert.equal(createJobCalls[0].context_json.review_form_values.application_claim_no, 'AP-202606-001') + assert.deepEqual(fetchJobCalls, ['linked-draft-job-1']) assert.equal(aiExpenseDraft.value, null) assert.match(conversationMessages.value.at(-1).content, /报销草稿 RE-202606-009 已生成/) assert.equal(conversationMessages.value.at(-1).draftPayload.claim_no, 'RE-202606-009') assert.equal(conversationMessages.value.at(-1).suggestedActions[0].action_type, 'open_application_detail') }) +test('linked reimbursement draft job resumes from pending history message', async () => { + conversationStarted.value = true + const fetchJobCalls = [] + const { conversationMessages, flow } = buildFlow({ + fetchLinkedReimbursementDraftJobForAi: async (jobId) => { + fetchJobCalls.push(jobId) + return { + job_id: jobId, + status: 'succeeded', + message: '报销草稿已生成。', + draft_payload: { + claim_id: 'draft-resumed-1', + claim_no: 'RE-202606-011', + status: 'draft', + expense_type: 'travel' + } + } + } + }) + conversationMessages.value.push(createInlineMessage('assistant', '已关联申请单 AP-202606-001,正在后台生成报销草稿...', { + id: 'pending-linked-draft-message', + pending: true, + linkedReimbursementDraftJob: { + jobId: 'linked-draft-job-resume', + status: 'queued', + applicationClaimNo: 'AP-202606-001' + } + })) + + flow.resumePendingLinkedReimbursementDraftJobs() + await new Promise((resolve) => setTimeout(resolve, 5)) + + assert.deepEqual(fetchJobCalls, ['linked-draft-job-resume']) + assert.match(conversationMessages.value.at(-1).content, /报销草稿 RE-202606-011 已生成/) + assert.match(conversationMessages.value.at(-1).content, /AP-202606-001/) + assert.equal(conversationMessages.value.at(-1).pending, false) + assert.equal(conversationMessages.value.at(-1).draftPayload.claim_no, 'RE-202606-011') +}) + +test('continuing an existing reimbursement draft prompts for attachments or description', () => { + conversationStarted.value = false + const { conversationMessages, flow } = buildFlow() + + flow.promptAiReimbursementDraftContinuation({ + claim_id: 'draft-travel-1', + claim_no: 'RE-202606-010', + original_message: '我要报销' + }) + + assert.equal(conversationMessages.value.at(-2).role, 'user') + assert.equal(conversationMessages.value.at(-2).content, '继续关联草稿 RE-202606-010') + const assistantMessage = conversationMessages.value.at(-1) + assert.equal(assistantMessage.role, 'assistant') + assert.match(assistantMessage.content, /请上传相关的附件/) + assert.match(assistantMessage.content, /补充说明/) + assert.equal(assistantMessage.suggestedActions[0].action_type, 'open_application_detail') + assert.equal(assistantMessage.suggestedActions[0].label, '查看草稿 RE-202606-010') +}) + +test('standalone reimbursement draft branch asks before creating a new draft', () => { + conversationStarted.value = false + const { conversationMessages, flow } = buildFlow() + + flow.promptStandaloneReimbursementDraftCreation('我要报销', '独立新建报销单') + + assert.equal(conversationMessages.value.at(-2).role, 'user') + assert.equal(conversationMessages.value.at(-2).content, '独立新建报销单') + const assistantMessage = conversationMessages.value.at(-1) + assert.equal(assistantMessage.role, 'assistant') + assert.match(assistantMessage.content, /是否新建草稿单据/) + assert.equal(assistantMessage.suggestedActions[0].label, '新建草稿单据') + assert.equal(assistantMessage.suggestedActions[0].action_type, 'skip_required_application_link') + assert.equal(assistantMessage.suggestedActions[1].label, '暂不新建') +}) + test('personal workbench routes reimbursement creation intent to association gate before steward', () => { assert.match( personalWorkbenchAiMode, diff --git a/web/tests/workbench-composer-date.test.mjs b/web/tests/workbench-composer-date.test.mjs index 1ad44af..d0460f7 100644 --- a/web/tests/workbench-composer-date.test.mjs +++ b/web/tests/workbench-composer-date.test.mjs @@ -2,7 +2,9 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' import { fileURLToPath } from 'node:url' +import { ref } from 'vue' +import { useWorkbenchComposerDate } from '../src/composables/useWorkbenchComposerDate.js' import { buildWorkbenchDateLabel, canApplyWorkbenchDateSelection, @@ -76,3 +78,34 @@ test('workbench date helper builds labels and inserts them into draft text', () ) assert.equal(canApplyWorkbenchDateSelection({ mode: 'range', rangeStartDate: '2026-06-01', rangeEndDate: '2026-05-31' }), false) }) + +test('workbench range end date changes keep the picker open until the user confirms', () => { + const draft = ref('') + let focusCount = 0 + const dateRuntime = useWorkbenchComposerDate({ + draft, + focusInput: () => { + focusCount += 1 + } + }) + + dateRuntime.workbenchDatePickerOpen.value = true + dateRuntime.workbenchDateMode.value = 'range' + dateRuntime.workbenchRangeStartDate.value = '2026-02-20' + dateRuntime.workbenchRangeEndDate.value = '2026-03-23' + + dateRuntime.handleWorkbenchDateInputChange('range-end') + + assert.equal(dateRuntime.workbenchDatePickerOpen.value, true) + assert.equal(dateRuntime.workbenchDateTagLabel.value, '') + assert.equal(draft.value, '') + assert.equal(focusCount, 0) + + dateRuntime.applyWorkbenchDateSelection() + + assert.equal(dateRuntime.workbenchDatePickerOpen.value, false) + assert.equal(dateRuntime.workbenchDateTagLabel.value, '2026-02-20 至 2026-03-23') + assert.equal(draft.value, '') + assert.equal(dateRuntime.buildWorkbenchPromptText(), '2026-02-20 至 2026-03-23') + assert.equal(focusCount, 1) +})