From 96c2e1099a6bbe91fe43e666cd6c17140d8cd48c Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Sat, 20 Jun 2026 14:42:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E7=BB=9F=E4=B8=80=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E7=AE=A1=E7=90=86=E5=91=98=E5=88=A4=E5=AE=9A=E4=B8=8E?= =?UTF-8?q?=20AI=20=E5=B7=A5=E4=BD=9C=E5=8F=B0=E7=94=B3=E8=AF=B7=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E5=8A=A8=E4=BD=9C=E6=8E=A5=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - authUser 抽出 resolveAuthUserAdminFlag,统一 isAdmin 解析(含 superadmin、role_codes、中英文角色名),accessControl 复用同一逻辑 - 登录态、应用外壳路由、系统状态接入统一管理员判定,LoginView 与相关 composable 配套调整 - AI 工作台申请提交改为调用新的 /application-preview-action 接口,草稿保存仍走 orchestrator;预审模型补充重叠冲突提示与阻断判断 - 同步更新 accessControl/api-request/ai 预览动作等前端测试 --- web/src/assets/styles/app.css | 68 +- .../components/personal-workbench-ai-mode.css | 36 +- web/src/assets/styles/views/login-view.css | 63 +- .../business/PersonalWorkbenchAiMode.vue | 807 ++++++++++++++++-- web/src/composables/useAppShell.js | 17 +- web/src/composables/useSystemState.js | 15 +- .../services/aiApplicationPreviewActions.js | 20 +- web/src/services/api.js | 21 +- web/src/utils/accessControl.js | 15 +- web/src/utils/aiApplicationPrecheckModel.js | 33 + web/src/utils/authUser.js | 46 +- web/src/views/AppShellRouteView.vue | 73 +- web/src/views/LoginView.vue | 31 +- web/tests/accessControl.test.mjs | 4 + .../ai-application-precheck-model.test.mjs | 40 +- .../ai-application-preview-actions.test.mjs | 183 ++-- web/tests/api-request.test.mjs | 41 + ...p-shell-financial-assistant-entry.test.mjs | 5 +- ...ench-ai-mode-expense-scene-action.test.mjs | 142 ++- web/tests/workbench-ai-mode-switch.test.mjs | 8 +- web/tests/workbench-detail-return.test.mjs | 27 +- 21 files changed, 1364 insertions(+), 331 deletions(-) diff --git a/web/src/assets/styles/app.css b/web/src/assets/styles/app.css index 50f7a36..891a7b6 100644 --- a/web/src/assets/styles/app.css +++ b/web/src/assets/styles/app.css @@ -72,38 +72,6 @@ min-width: 0; } -.login-entry-veil { - position: absolute; - inset: 0; - z-index: 380; - display: grid; - place-items: center; - background: rgba(248, 250, 252, 0.9); - backdrop-filter: blur(3px); - pointer-events: none; -} - -.login-entry-veil-enter-active { - transition: opacity 180ms var(--ease); -} - -.login-entry-veil-leave-active { - transition: opacity 260ms cubic-bezier(0.4, 0, 0.2, 1); -} - -.login-entry-veil-enter-from, -.login-entry-veil-leave-to { - opacity: 0; -} - -.app.login-entry-active .app-sidebar { - animation: loginEntrySidebarIn 520ms cubic-bezier(0.16, 1, 0.3, 1) both; -} - -.app.login-entry-active > .main { - animation: loginEntryMainIn 620ms 90ms cubic-bezier(0.16, 1, 0.3, 1) both; -} - .boot-state { min-height: var(--desktop-stage-height, 100dvh); display: grid; @@ -234,6 +202,28 @@ padding: 0; background: transparent; } +.document-detail-loading { + min-height: 280px; + display: grid; + place-items: center; + align-content: center; + gap: 14px; + text-align: center; + color: #475569; +} +.document-detail-loading i { + font-size: 30px; + color: var(--theme-primary-active); +} +.document-detail-loading strong { + color: #0f172a; + font-size: 16px; +} +.document-detail-loading p { + margin: 6px 0 0; + color: #64748b; + font-size: 13px; +} .workarea.settings-workarea { padding: 0; background: #fff; @@ -299,10 +289,6 @@ transform: translateX(0); } - .app.login-entry-active .app-sidebar { - animation: none; - } - .app > .main { flex: 1 1 100%; width: 100vw; @@ -374,14 +360,4 @@ flex-basis 120ms ease-out !important; transition-duration: 120ms, 120ms !important; } - - .app.login-entry-active .app-sidebar, - .app.login-entry-active > .main { - animation: none !important; - } - - .login-entry-veil-enter-active, - .login-entry-veil-leave-active { - transition: opacity 120ms ease-out !important; - } } diff --git a/web/src/assets/styles/components/personal-workbench-ai-mode.css b/web/src/assets/styles/components/personal-workbench-ai-mode.css index f89cd42..0fed0e1 100644 --- a/web/src/assets/styles/components/personal-workbench-ai-mode.css +++ b/web/src/assets/styles/components/personal-workbench-ai-mode.css @@ -1556,12 +1556,28 @@ cursor: pointer; } +.application-preview-row.is-disabled { + cursor: wait; +} + +.application-preview-row.is-disabled .application-preview-label, +.application-preview-row.is-disabled .application-preview-value { + background: rgba(248, 250, 252, 0.84); + color: #64748b; +} + .application-preview-row.editable:hover, .application-preview-row.editable:hover .application-preview-label, .application-preview-row.editable:hover .application-preview-value { background: rgba(239, 246, 255, 0.58); } +.application-preview-row.is-disabled:hover, +.application-preview-row.is-disabled:hover .application-preview-label, +.application-preview-row.is-disabled:hover .application-preview-value { + background: rgba(248, 250, 252, 0.84); +} + .application-preview-row.editable:focus-visible { z-index: 1; outline: 2px solid rgba(37, 99, 235, 0.42); @@ -1655,6 +1671,12 @@ transform: translateY(-1px); } +.application-preview-edit-btn:disabled { + cursor: wait; + opacity: 0.46; + transform: none; +} + .application-preview-footer { color: #334155; font-size: 15px; @@ -1748,11 +1770,17 @@ transform 160ms ease; } -.workbench-ai-suggested-actions button:hover { +.workbench-ai-suggested-actions button:hover:not(:disabled) { transform: translateY(-1px); background: #eff6ff; } +.workbench-ai-suggested-actions button:disabled { + cursor: wait; + opacity: 0.6; + transform: none; +} + .workbench-ai-message-actions { display: flex; align-items: center; @@ -1940,6 +1968,12 @@ box-shadow: 0 12px 22px rgba(220, 38, 38, 0.2); } +.workbench-ai-confirm-actions .primary { + background: #2563eb; + color: #fff; + box-shadow: 0 12px 22px rgba(37, 99, 235, 0.2); +} + .workbench-ai-confirm-fade-enter-active, .workbench-ai-confirm-fade-leave-active { transition: opacity 180ms ease; diff --git a/web/src/assets/styles/views/login-view.css b/web/src/assets/styles/views/login-view.css index 6fec840..1323e70 100644 --- a/web/src/assets/styles/views/login-view.css +++ b/web/src/assets/styles/views/login-view.css @@ -559,11 +559,72 @@ .submit-btn:disabled, .sso-btn:disabled { - opacity: .6; cursor: not-allowed; +} + +/* 登录中:SSO 按钮置灰,登录按钮保持主色并显示 spinner */ +.sso-btn:disabled { + opacity: .6; box-shadow: none; } +.submit-btn:disabled { + opacity: 1; + box-shadow: 0 16px 30px rgba(var(--theme-primary-rgb, 58, 124, 165), .20); +} + +/* + * 登录中表单态:用户名 / 密码 / 租户 / 记住账号 / 忘记密码全部置灰禁用, + * 让视觉焦点集中在正在校验的登录按钮上 + */ +.login-form.is-submitting .field input, +.login-form.is-submitting .field select { + background: #f1f5f9; + border-color: #e2e8f0; + color: #94a3b8; + cursor: not-allowed; +} + +.login-form.is-submitting .field input::placeholder { + color: #cbd5e1; +} + +.login-form.is-submitting .field > .mdi, +.login-form.is-submitting .field-icon-btn, +.login-form.is-submitting .field-select-chevron { + color: #cbd5e1; + opacity: .5; + pointer-events: none; +} + +.login-form.is-submitting .remember, +.login-form.is-submitting .link-btn { + opacity: .55; + pointer-events: none; +} + +/* 登录按钮内的旋转 loading */ +.submit-btn__spinner { + width: 20px; + height: 20px; + border: 2.5px solid rgba(255, 255, 255, .4); + border-top-color: #fff; + border-radius: 999px; + animation: loginSubmitSpin 720ms linear infinite; +} + +@keyframes loginSubmitSpin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .submit-btn__spinner { + animation-duration: 1800ms; + } +} + .divider { position: relative; display: grid; diff --git a/web/src/components/business/PersonalWorkbenchAiMode.vue b/web/src/components/business/PersonalWorkbenchAiMode.vue index d56e249..44aa21d 100644 --- a/web/src/components/business/PersonalWorkbenchAiMode.vue +++ b/web/src/components/business/PersonalWorkbenchAiMode.vue @@ -10,6 +10,7 @@ type="file" multiple accept=".pdf,.jpg,.jpeg,.png,.webp,.doc,.docx,.xls,.xlsx" + :disabled="isAiModeInputLocked" @change="handleAiModeFilesChange" /> @@ -33,7 +34,7 @@
{{ workbenchDateTagLabel }} -
@@ -43,7 +44,8 @@ v-model="assistantDraft" maxlength="1000" rows="3" - placeholder="今天我能帮您做点什么?" + :placeholder="isAiModeInputLocked ? '费用测算中,请稍等...' : '今天我能帮您做点什么?'" + :disabled="isAiModeInputLocked" @keydown.enter.exact.prevent="submitAiModePrompt" > @@ -58,6 +60,7 @@ title="选择日期" aria-label="选择日期" :aria-expanded="workbenchDatePickerOpen" + :disabled="isAiModeInputLocked" @click.stop="toggleWorkbenchDatePicker" > @@ -74,6 +77,7 @@ + +
@@ -178,6 +188,7 @@ :key="item.label" type="button" class="workbench-ai-action" + :disabled="isAiModeInputLocked" @click="runAiModeAction(item)" >
@@ -329,14 +340,15 @@ :class="{ missing: row.missing, editable: row.editable, - highlight: row.highlight + highlight: row.highlight, + 'is-disabled': isApplicationPreviewEstimatePending(message) }" role="row" - :tabindex="row.editable ? 0 : -1" + :tabindex="row.editable && !isApplicationPreviewEstimatePending(message) ? 0 : -1" :aria-label="row.editable ? `编辑${row.label}` : row.label" - @click.stop="row.editable && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)" - @keydown.enter.prevent="row.editable && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)" - @keydown.space.prevent="row.editable && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)" + @click.stop="row.editable && !isApplicationPreviewEstimatePending(message) && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)" + @keydown.enter.prevent="row.editable && !isApplicationPreviewEstimatePending(message) && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)" + @keydown.space.prevent="row.editable && !isApplicationPreviewEstimatePending(message) && !isApplicationPreviewEditing(message, row.key) && openApplicationPreviewEditor(message, row.key, row.value)" > {{ row.label }} @@ -346,6 +358,7 @@ class="application-preview-input" type="text" autofocus + :disabled="isApplicationPreviewEstimatePending(message)" @click.stop @keydown.stop="handleInlineApplicationPreviewEditorKeydown($event, message)" @blur="commitInlineApplicationPreviewEditor(message)" @@ -355,6 +368,7 @@ v-model="applicationPreviewEditor.draftValue" class="application-preview-input application-preview-select" autofocus + :disabled="isApplicationPreviewEstimatePending(message)" @click.stop @change="commitInlineApplicationPreviewEditor(message)" @keydown.stop="handleInlineApplicationPreviewEditorKeydown($event, message)" @@ -377,6 +391,7 @@ class="application-preview-edit-btn" title="修改内容" aria-label="修改内容" + :disabled="isApplicationPreviewEstimatePending(message)" @click.stop="openApplicationPreviewEditor(message, row.key, row.value)" > @@ -421,12 +436,13 @@ 小财管家正在识别任务、拆解流程并准备下一步建议...
-
+
+
@@ -463,7 +479,7 @@
{{ workbenchDateTagLabel }} -
@@ -473,7 +489,8 @@ v-model="assistantDraft" maxlength="1000" rows="3" - placeholder="继续和小财管家对话..." + :placeholder="isAiModeInputLocked ? '费用测算中,请稍等...' : '继续和小财管家对话...'" + :disabled="isAiModeInputLocked" @keydown.enter.exact.prevent="submitAiModePrompt" >
@@ -488,6 +505,7 @@ title="选择日期" aria-label="选择日期" :aria-expanded="workbenchDatePickerOpen" + :disabled="isAiModeInputLocked" @click.stop="toggleWorkbenchDatePicker" > @@ -504,6 +522,7 @@
- +
+ + + + @@ -667,6 +716,12 @@ import { buildLocalApplicationPreviewMessage, normalizeApplicationPreview } from '../../utils/expenseApplicationPreview.js' +import { + buildAiApplicationPrecheck, + buildAiApplicationPrecheckThinkingEvents, + buildAiApplicationSubmitConflictMessage, + isAiApplicationPrecheckBlocking +} from '../../utils/aiApplicationPrecheckModel.js' import { useApplicationPreviewEditor } from '../../views/scripts/useApplicationPreviewEditor.js' import { buildAiDocumentQueryConditionSummary, @@ -686,6 +741,11 @@ import { fetchApprovalExpenseClaims, fetchExpenseClaims } from '../../services/reimbursements.js' +import { + AI_APPLICATION_ACTION_SAVE_DRAFT, + AI_APPLICATION_ACTION_SUBMIT, + runAiApplicationPreviewAction +} from '../../services/aiApplicationPreviewActions.js' const props = defineProps({ sidebarCommand: { type: Object, default: () => ({ seq: 0, type: '', payload: null }) } @@ -712,6 +772,8 @@ const aiExpenseDraft = ref(null) const thinkingExpandedMessageIds = ref(new Set()) const thinkingCollapsedMessageIds = ref(new Set()) const deleteDialogOpen = ref(false) +const applicationSubmitConfirmOpen = ref(false) +const applicationSubmitConfirmContext = ref(null) let messageSeq = 0 const AI_SEARCH_CONVERSATION_ID = 'ai-search' const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6 @@ -719,6 +781,7 @@ const INLINE_ANSWER_STREAM_DELAY_MS = 24 const INLINE_AUTO_SCROLL_THRESHOLD = 96 const INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS = 260 const AI_DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:' +const AI_APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:' const { applicationPreviewEditor, @@ -809,10 +872,18 @@ const modelSelectorTitle = computed(() => { return provider ? `当前模型:${provider} / ${model}` : `当前模型:${model}` }) +const applicationPreviewEstimatePending = computed(() => ( + conversationMessages.value.some((message) => isApplicationPreviewEstimatePending(message)) +)) + +const isAiModeInputLocked = computed(() => applicationPreviewEstimatePending.value) + const canSubmitAiModePrompt = computed(() => ( - Boolean(assistantDraft.value.trim()) - || selectedFiles.value.length > 0 - || Boolean(workbenchDateTagLabel.value) + !isAiModeInputLocked.value && ( + Boolean(assistantDraft.value.trim()) + || selectedFiles.value.length > 0 + || Boolean(workbenchDateTagLabel.value) + ) )) async function loadSystemSettings() { @@ -896,6 +967,7 @@ function createInlineMessage(role, content, options = {}) { stewardPlan: options.stewardPlan || null, suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : [], applicationPreview: options.applicationPreview || null, + draftPayload: options.draftPayload || null, text: options.text || normalizedContent, createdAt: options.createdAt || Date.now() } @@ -953,6 +1025,7 @@ function normalizeRuntimeMessage(message = {}) { stewardPlan: message.stewardPlan || null, suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [], applicationPreview: message.applicationPreview || null, + draftPayload: message.draftPayload || null, text: message.text || message.content || '' }) } @@ -966,7 +1039,8 @@ function serializeRuntimeMessage(message = {}) { feedback: message.feedback || '', stewardPlan: message.stewardPlan || null, suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [], - applicationPreview: message.applicationPreview || null + applicationPreview: message.applicationPreview || null, + draftPayload: message.draftPayload || null } } @@ -1013,6 +1087,8 @@ function resetInlineConversationState() { thinkingExpandedMessageIds.value = new Set() thinkingCollapsedMessageIds.value = new Set() deleteDialogOpen.value = false + applicationSubmitConfirmOpen.value = false + applicationSubmitConfirmContext.value = null clearWorkbenchDateSelection() clearAiModeFiles() } @@ -1039,6 +1115,36 @@ function renderInlineConversationHtml(content) { return renderAiConversationHtml(content) } +function isApplicationPreviewEstimatePendingPreview(applicationPreview = {}) { + const fields = normalizeApplicationPreview(applicationPreview).fields || {} + return [ + fields.transportPolicy, + fields.policyEstimate, + fields.transportEstimatedAmount, + fields.amount + ].some((value) => /正在|查询中/.test(String(value || ''))) +} + +function isApplicationPreviewEstimatePending(message = {}) { + return Boolean(message?.applicationPreview && isApplicationPreviewEstimatePendingPreview(message.applicationPreview)) +} + +function shouldRefreshInlineApplicationPreviewEstimate(fieldKey = '') { + return ['transportMode', 'time', 'location', 'days'].includes(String(fieldKey || '').trim()) +} + +function canShowInlineSuggestedActions(message = {}) { + return Boolean(message?.suggestedActions?.length) && !isApplicationPreviewEstimatePending(message) +} + +function isInlineSuggestedActionDisabled(action = {}, message = {}) { + const actionType = String(action?.action_type || '').trim() + return ( + [AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT].includes(actionType) && + isApplicationPreviewEstimatePending(message) + ) +} + function resolveInlineApplicationPreviewRows(message) { return buildApplicationPreviewRows(message?.applicationPreview || {}) } @@ -1059,9 +1165,15 @@ function syncInlineApplicationPreviewMessageContent(message) { const nextContent = buildLocalApplicationPreviewMessage(message.applicationPreview) message.content = nextContent message.text = nextContent + message.suggestedActions = buildInlineApplicationPreviewSuggestedActions(message.applicationPreview, message.draftPayload) } async function commitInlineApplicationPreviewEditor(message) { + const shouldLockForEstimate = shouldRefreshInlineApplicationPreviewEstimate(applicationPreviewEditor.value.fieldKey) + if (shouldLockForEstimate) { + message.suggestedActions = [] + persistCurrentConversation() + } const committed = await commitApplicationPreviewEditor(message) syncInlineApplicationPreviewMessageContent(message) persistCurrentConversation() @@ -1084,10 +1196,156 @@ function handleInlineApplicationPreviewEditorKeydown(event, message) { function buildInlineApplicationPreviewFooterText(message) { const normalized = normalizeApplicationPreview(message?.applicationPreview || {}) + if (isApplicationPreviewEstimatePending(message)) { + return '费用测算正在同步,请稍等,完成后才能保存草稿或直接提交。' + } if (normalized.validationIssues?.length || normalized.missingFields?.length) { return buildApplicationPreviewFooterMessage(normalized) } - return '申请核对表已补齐,费用测算已同步。你仍可点击表格继续修改;确认无误后,可以回复“保存草稿”或“提交申请”。' + return '申请核对表已补齐,费用测算已同步。你仍可点击表格继续修改;确认无误后,可以点击下方按钮保存草稿或直接提交,也可以直接回复“保存草稿”或“提交”。' +} + +function buildInlineApplicationPreviewSuggestedActions(applicationPreview = {}, draftPayload = null) { + if (isApplicationPreviewEstimatePendingPreview(applicationPreview)) { + return [] + } + const normalized = normalizeApplicationPreview(applicationPreview) + const actions = [{ + label: '保存草稿', + description: '先保存当前申请表,后续可以继续补充或提交。', + icon: 'mdi mdi-content-save-outline', + action_type: AI_APPLICATION_ACTION_SAVE_DRAFT, + payload: { draftPayload } + }] + if (normalized.readyToSubmit) { + actions.push({ + label: '直接提交', + description: '提交前先核查相同日期申请单,确认通过后进入审批流程。', + icon: 'mdi mdi-send-check-outline', + action_type: AI_APPLICATION_ACTION_SUBMIT, + payload: { draftPayload } + }) + } + return actions +} + +function resolveLatestApplicationPreviewMessage() { + return [...conversationMessages.value] + .reverse() + .find((message) => message.role === 'assistant' && message.applicationPreview) +} + +function resolveInlineApplicationPreviewActionFromText(text = '') { + const normalized = String(text || '').replace(/\s+/g, '').trim() + if (!normalized) { + return '' + } + if (/^(保存草稿|保存|存草稿|先保存)$/.test(normalized)) { + return AI_APPLICATION_ACTION_SAVE_DRAFT + } + if (/^(提交|提交申请|确认提交|提交审批|直接提交)$/.test(normalized)) { + return AI_APPLICATION_ACTION_SUBMIT + } + return '' +} + +function extractInlineApplicationDraftPayload(payload = {}) { + const result = payload?.result && typeof payload.result === 'object' ? payload.result : {} + return result.draft_payload && typeof result.draft_payload === 'object' + ? result.draft_payload + : payload?.draft_payload && typeof payload.draft_payload === 'object' + ? payload.draft_payload + : null +} + +function normalizeInlineApplicationResultTableCell(value, fallback = '-') { + const text = String(value || '') + .replace(/\s*\n+\s*/g, ' ') + .replace(/\|/g, '|') + .trim() + return text || fallback +} + +function buildInlineApplicationActionDetailHref(reference = '') { + const value = String(reference || '').trim() + return value ? `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(value)}` : '' +} + +function resolveInlineApplicationActionDocumentInfo(draftPayload = {}) { + const source = draftPayload && typeof draftPayload === 'object' ? draftPayload : {} + return { + claimNo: String(source.claim_no || source.claimNo || source.document_no || source.documentNo || '').trim(), + claimId: String(source.claim_id || source.claimId || source.id || '').trim(), + statusLabel: String(source.status_label || source.statusLabel || source.status || '').trim(), + approvalStage: String(source.approval_stage || source.approvalStage || '').trim(), + documentTypeLabel: String( + source.document_type_label || + source.documentTypeLabel || + source.application_type_label || + source.applicationTypeLabel || + source.expense_type_label || + source.expenseTypeLabel || + '' + ).trim() + } +} + +function buildInlineApplicationResultTable(draftPayload = {}, options = {}) { + const info = resolveInlineApplicationActionDocumentInfo(draftPayload) + const reference = info.claimNo || info.claimId + const href = buildInlineApplicationActionDetailHref(reference) + const actionText = href ? `[查看](${href})` : '-' + return [ + '| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 操作 |', + '| --- | --- | --- | --- | --- |', + `| ${normalizeInlineApplicationResultTableCell(info.documentTypeLabel || options.documentTypeLabel, '出差申请')} | ${normalizeInlineApplicationResultTableCell(reference)} | ${normalizeInlineApplicationResultTableCell(info.statusLabel || options.statusLabel)} | ${normalizeInlineApplicationResultTableCell(info.approvalStage || options.stageLabel)} | ${actionText} |` + ].join('\n') +} + +function buildInlineApplicationPreviewActionResultText(actionType, payload = {}) { + const draftPayload = extractInlineApplicationDraftPayload(payload) || {} + const claimNo = String(draftPayload.claim_no || draftPayload.claimNo || '').trim() + const approvalStage = String(draftPayload.approval_stage || draftPayload.approvalStage || '').trim() + if (actionType === AI_APPLICATION_ACTION_SUBMIT) { + return [ + '### 申请单据已生成,并已进入审批流程', + approvalStage ? `系统已推送到 **${approvalStage}**,当前节点:${approvalStage}。` : '系统已推送到审批流程,当前节点:审批中。', + buildInlineApplicationResultTable(draftPayload, { + statusLabel: '审批中', + stageLabel: approvalStage || '直属领导审批', + documentTypeLabel: '出差申请' + }), + '需要查看完整详情时,请点击列表最后一列的“查看”进入单据详情。' + ].filter(Boolean).join('\n\n') + } + return [ + '### 申请草稿已保存', + claimNo ? `系统已保存当前申请草稿,草稿单号:**${claimNo}**。` : '系统已保存当前申请草稿。', + buildInlineApplicationResultTable(draftPayload, { + statusLabel: '草稿', + stageLabel: '待提交', + documentTypeLabel: '出差申请' + }), + '后续请点击表格最后一列的“查看”进入详情页继续核对。' + ].filter(Boolean).join('\n\n') +} + +function buildInlineApplicationDetailAction(draftPayload = {}) { + const claimNo = String(draftPayload?.claim_no || draftPayload?.claimNo || '').trim() + if (!claimNo) { + return [] + } + return [{ + label: '查看单据详情', + description: '打开刚生成的申请单详情。', + icon: 'mdi mdi-open-in-new', + action_type: 'open_application_detail', + payload: { + claim_no: claimNo, + claim_id: String(draftPayload.claim_id || draftPayload.claimId || '').trim(), + document_type: 'application' + } + }] } function resolveInlineThinkingEvents(message) { @@ -1220,33 +1478,371 @@ function buildAiRequiredApplicationGateAutoMessage(normalizedPlan, flow) { if (flow?.flowId === 'travel_application') { return [ contextText || baseText, - '我会继续在当前对话里为你发起出差申请。' + '这类操作需要你手动确认。请点击下方 **确认发起出差申请**,我再在当前对话里生成完整申请表,并把已识别的信息自动预填。' ].filter(Boolean).join('\n\n') } if (flow?.flowId === 'travel_reimbursement') { return [ contextText || baseText, - '我会继续进入申请单关联步骤,请你确认要关联哪张单据。' + '这类操作需要你手动确认。请点击下方 **确认关联已有申请单**,我再继续查询并展示可关联单据。' ].filter(Boolean).join('\n\n') } return baseText } -function continueAiRequiredApplicationGateFromPlan(normalizedPlan, prompt = '') { - const flow = resolveRequiredApplicationGateContinuationFlow(normalizedPlan) - if (!flow) { - return false - } +function buildAiRequiredApplicationGateSuggestedActions(flow, prompt = '') { if (flow.flowId === 'travel_application') { - aiExpenseDraft.value = null - void startAiApplicationPreview('travel', '差旅费', prompt) - return true + return [{ + label: '确认发起出差申请', + description: '确认后生成完整申请表,并预填已识别的时间、地点和事由。', + icon: 'mdi mdi-file-plus-outline', + action_type: 'ai_application_start_inline', + payload: { + expense_type: 'travel', + expense_type_label: '差旅费', + carry_text: prompt + } + }] } if (flow.flowId === 'travel_reimbursement') { - startAiExpenseDraft('travel', '差旅费', true) + return [{ + label: '确认关联已有申请单', + description: '确认后查询你名下可关联的差旅申请单,并进入关联步骤。', + icon: 'mdi mdi-link-variant', + action_type: 'steward_confirm_flow', + payload: { + steward_confirm_flow: true, + flow_id: 'travel_reimbursement', + expense_type: 'travel', + expense_type_label: '差旅费', + carry_text: prompt + } + }] + } + return [] +} + +function resolveInlineApplicationDraftIdentity(payload = {}) { + const source = payload && typeof payload === 'object' ? payload : {} + return { + claimId: String(source.claim_id || source.claimId || source.id || '').trim(), + claimNo: String(source.claim_no || source.claimNo || source.document_no || source.documentNo || '').trim() + } +} + +function isSameInlineApplicationDraftClaim(claim = {}, draftPayload = {}) { + const draftIdentity = resolveInlineApplicationDraftIdentity(draftPayload) + if (!draftIdentity.claimId && !draftIdentity.claimNo) { + return false + } + const claimIdentity = resolveInlineApplicationDraftIdentity(claim) + return Boolean( + (draftIdentity.claimId && claimIdentity.claimId && draftIdentity.claimId === claimIdentity.claimId) || + (draftIdentity.claimNo && claimIdentity.claimNo && draftIdentity.claimNo === claimIdentity.claimNo) + ) +} + +function buildInlineApplicationSubmitPrecheckPayload(claimsPayload, draftPayload = {}) { + const items = extractExpenseClaimItems(claimsPayload) + .filter((claim) => !isSameInlineApplicationDraftClaim(claim, draftPayload)) + return { items } +} + +function completeInlineThinkingEvents(events = []) { + return events.map((event) => ({ + ...event, + status: event.status === 'failed' ? 'failed' : 'completed' + })) +} + +function buildInitialInlineApplicationSubmitThinkingEvents() { + return [ + { + eventId: 'application-precheck-overlap', + title: '核查同时间段申请单', + content: '正在查询你名下可见申请单,检查是否存在相同或重叠日期。', + status: 'running' + }, + { + eventId: 'application-precheck-budget', + title: '评估预算与审批影响', + content: '等待单据重叠核查完成后,继续评估预算占用和审批影响。', + status: 'pending' + }, + { + eventId: 'application-submit', + title: '提交申请单据', + content: '等待提交前核查完成。', + status: 'pending' + } + ] +} + +function buildInlineApplicationSubmitThinkingEvents(precheck = {}) { + const blocked = isAiApplicationPrecheckBlocking(precheck) + return buildAiApplicationPrecheckThinkingEvents(precheck).map((event) => { + if (event.eventId !== 'application-precheck-form') { + return event + } + return { + eventId: 'application-submit', + title: blocked ? '暂停提交申请' : '提交申请单据', + content: blocked + ? '发现相同或重叠日期已有申请单,已暂停本次提交。' + : '提交前核查通过,正在生成申请单据并推送审批流程。', + status: blocked ? 'completed' : 'running' + } + }) +} + +function buildFailedInlineApplicationSubmitThinkingEvents(error) { + return [ + { + eventId: 'application-precheck-overlap', + title: '核查同时间段申请单', + content: `查询已有申请单失败:${String(error?.message || error || '未知错误')}`, + status: 'failed' + }, + { + eventId: 'application-submit', + title: '暂停提交申请', + content: '因为未能完成提交前重复日期核查,系统没有提交本次申请。', + status: 'failed' + } + ] +} + +function requestInlineApplicationSubmitConfirmation(targetMessage, options = {}) { + applicationSubmitConfirmContext.value = { + messageId: String(targetMessage?.id || '').trim(), + draftPayload: targetMessage?.draftPayload || options.draftPayload || null, + userText: String(options.userText || '直接提交').trim() || '直接提交' + } + applicationSubmitConfirmOpen.value = true + persistCurrentConversation() +} + +function cancelInlineApplicationSubmitConfirm() { + applicationSubmitConfirmOpen.value = false + applicationSubmitConfirmContext.value = null + focusAiModeInput() +} + +function confirmInlineApplicationSubmit() { + const context = applicationSubmitConfirmContext.value || {} + applicationSubmitConfirmOpen.value = false + applicationSubmitConfirmContext.value = null + const sourceMessage = conversationMessages.value.find((message) => message.id === context.messageId) + if (!sourceMessage?.applicationPreview) { + toast('当前申请表已变化,请重新点击直接提交。') + return + } + void executeInlineApplicationPreviewAction(AI_APPLICATION_ACTION_SUBMIT, sourceMessage, { + confirmed: true, + skipUserMessage: false, + draftPayload: context.draftPayload || null, + userText: context.userText || '直接提交' + }) +} + +async function runInlineApplicationSubmitPrecheck(targetMessage, pendingMessage, normalizedPreview, options = {}) { + try { + const claimsPayload = await fetchExpenseClaims({ page: 1, pageSize: 100 }) + const precheck = buildAiApplicationPrecheck(normalizedPreview, { + claimsPayload: buildInlineApplicationSubmitPrecheckPayload( + claimsPayload, + targetMessage.draftPayload || options.draftPayload || null + ), + currentUser: currentUser.value || {}, + expenseType: 'travel' + }) + const thinkingEvents = buildInlineApplicationSubmitThinkingEvents(precheck) + const blocked = isAiApplicationPrecheckBlocking(precheck) + + if (blocked) { + replaceInlineMessage( + pendingMessage.id, + createInlineMessage('assistant', buildAiApplicationSubmitConflictMessage(normalizedPreview, precheck), { + id: pendingMessage.id, + stewardPlan: { + streamStatus: 'completed', + thinkingEvents + } + }) + ) + persistCurrentConversation() + scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value }) + return false + } + + const message = conversationMessages.value.find((item) => item.id === pendingMessage.id) || pendingMessage + updateInlineMessageContent(message, '提交前核查通过,正在提交申请并进入审批流程...') + message.stewardPlan = { + ...(message.stewardPlan || {}), + streamStatus: 'streaming', + thinkingEvents + } + await nextTick() + scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value }) + return true + } catch (error) { + replaceInlineMessage( + pendingMessage.id, + createInlineMessage('assistant', [ + '### 提交前核查失败', + '系统未能完成相同日期申请单查询,所以本次申请没有提交。', + '请稍后重试;如果仍然失败,请先到单据中心核对是否已有同日期申请单。' + ].join('\n\n'), { + id: pendingMessage.id, + stewardPlan: { + streamStatus: 'failed', + thinkingEvents: buildFailedInlineApplicationSubmitThinkingEvents(error) + } + }) + ) + toast('提交前核查失败,已暂停提交。') + persistCurrentConversation() + return false + } +} + +async function executeInlineApplicationPreviewAction(actionType, sourceMessage = null, options = {}) { + const targetMessage = sourceMessage?.applicationPreview ? sourceMessage : resolveLatestApplicationPreviewMessage() + if (!targetMessage?.applicationPreview) { + toast('当前没有可提交的申请表。') + return false + } + + const normalizedPreview = normalizeApplicationPreview(targetMessage.applicationPreview) + const isSubmit = actionType === AI_APPLICATION_ACTION_SUBMIT + const userText = String(options.userText || (isSubmit ? '直接提交' : '保存草稿')).trim() + + if (isSubmit && !normalizedPreview.readyToSubmit) { + if (!options.skipUserMessage) { + pushInlineApplicationActionUserMessage(userText) + } + const missingText = normalizedPreview.missingFields?.length + ? `当前还缺少:${normalizedPreview.missingFields.join('、')}。` + : '' + const validationText = normalizedPreview.validationIssues?.length + ? normalizedPreview.validationIssues.map((item) => item.message).join(';') + : '' + conversationMessages.value.push(createInlineMessage('assistant', [ + '### 暂不能提交申请', + missingText || validationText || '当前申请表还未通过提交校验。', + '请先点击表格中的字段补充或修正,补齐后我会开放“直接提交”入口。' + ].filter(Boolean).join('\n\n'))) + persistCurrentConversation() + scrollInlineConversationToBottom() return true } - return false + + if (isSubmit && !options.confirmed) { + requestInlineApplicationSubmitConfirmation(targetMessage, { ...options, userText }) + return true + } + + if (!options.skipUserMessage) { + pushInlineApplicationActionUserMessage(userText) + } + + sending.value = true + const pendingMessage = createInlineMessage( + 'assistant', + isSubmit ? '正在提交前核查相同日期申请单...' : '正在保存申请草稿...', + { + pending: true, + stewardPlan: { + streamStatus: 'streaming', + thinkingEvents: isSubmit + ? buildInitialInlineApplicationSubmitThinkingEvents() + : [ + { + eventId: 'application-save-draft', + title: '保存申请草稿', + content: '正在按当前申请表内容保存草稿。', + status: 'running' + } + ] + } + } + ) + conversationMessages.value.push(pendingMessage) + scrollInlineConversationToBottom() + + try { + if (isSubmit) { + const precheckPassed = await runInlineApplicationSubmitPrecheck( + targetMessage, + pendingMessage, + normalizedPreview, + options + ) + if (!precheckPassed) { + return true + } + } + + const payload = await runAiApplicationPreviewAction({ + actionType, + applicationPreview: normalizedPreview, + currentUser: currentUser.value || {}, + conversationId: conversationId.value, + draftPayload: targetMessage.draftPayload || options.draftPayload || null + }) + const draftPayload = extractInlineApplicationDraftPayload(payload) + if (draftPayload) { + targetMessage.draftPayload = draftPayload + } + targetMessage.suggestedActions = [] + replaceInlineMessage( + pendingMessage.id, + createInlineMessage('assistant', buildInlineApplicationPreviewActionResultText(actionType, payload), { + id: pendingMessage.id, + stewardPlan: { + streamStatus: 'completed', + thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage)) + }, + suggestedActions: isSubmit ? buildInlineApplicationDetailAction(draftPayload) : [] + }) + ) + persistCurrentConversation() + scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value }) + return true + } catch (error) { + replaceInlineMessage( + pendingMessage.id, + createInlineMessage('assistant', error?.message || (isSubmit ? '申请提交失败,请稍后重试。' : '申请草稿保存失败,请稍后重试。'), { + id: pendingMessage.id, + stewardPlan: { + streamStatus: 'failed', + thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({ + ...item, + status: 'failed' + })) + } + }) + ) + toast(error?.message || (isSubmit ? '申请提交失败。' : '申请草稿保存失败。')) + persistCurrentConversation() + return true + } finally { + sending.value = false + scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value }) + } +} + +function handleInlineApplicationPreviewTextAction(prompt) { + if (applicationPreviewEstimatePending.value) { + toast('请等待费用测算完成后再继续操作。') + return true + } + const actionType = resolveInlineApplicationPreviewActionFromText(prompt) + if (!actionType || !resolveLatestApplicationPreviewMessage()) { + return false + } + void executeInlineApplicationPreviewAction(actionType, null, { userText: prompt }) + return true } function normalizeStreamThinkingEvent(event = {}) { @@ -1340,6 +1936,23 @@ function parseAiDocumentDetailHref(href = '') { } } +function parseAiApplicationDetailHref(href = '') { + const value = String(href || '').trim() + if (!value.startsWith(AI_APPLICATION_DETAIL_HREF_PREFIX)) { + return null + } + const encodedReference = value.slice(AI_APPLICATION_DETAIL_HREF_PREFIX.length) + if (!encodedReference) { + return null + } + try { + const reference = decodeURIComponent(encodedReference).trim() + return reference ? { reference } : null + } catch { + return { reference: encodedReference } + } +} + function buildAiDocumentDetailRequest(detailReference = {}) { const reference = String(detailReference.reference || '').trim() const isApplication = /^APP?-/i.test(reference) @@ -1350,6 +1963,7 @@ function buildAiDocumentDetailRequest(detailReference = {}) { documentNo: reference, documentType: isApplication ? 'application' : 'reimbursement', documentTypeCode: isApplication ? 'application' : 'reimbursement', + detailLookupOnly: true, source: 'workbench', returnTo: 'workbench' } @@ -1357,11 +1971,12 @@ function buildAiDocumentDetailRequest(detailReference = {}) { function handleAiAnswerMarkdownClick(event) { const target = event?.target - const link = target?.closest?.('a[href^="#ai-open-document-detail:"]') + const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]') if (!link) { return } - const detailReference = parseAiDocumentDetailHref(link.getAttribute('href')) + const href = link.getAttribute('href') + const detailReference = parseAiDocumentDetailHref(href) || parseAiApplicationDetailHref(href) if (!detailReference) { return } @@ -1586,12 +2201,11 @@ async function requestInlineAssistantReply(prompt, entry = {}, files = []) { thinkingEvents: nextThinkingEvents, streamStatus: 'completed' }, - suggestedActions: requiredApplicationContinuationFlow ? [] : buildStewardSuggestedActions(plan) + suggestedActions: requiredApplicationContinuationFlow + ? buildAiRequiredApplicationGateSuggestedActions(requiredApplicationContinuationFlow, prompt) + : buildStewardSuggestedActions(plan) }) ) - if (continueAiRequiredApplicationGateFromPlan(normalizedPlan, prompt)) { - shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value - } persistCurrentConversation() } catch (error) { shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value @@ -1621,6 +2235,10 @@ async function requestInlineAssistantReply(prompt, entry = {}, files = []) { } function startInlineConversation(prompt, entry = {}, files = []) { + if (isAiModeInputLocked.value) { + toast('请等待费用测算完成后再继续操作。') + return + } const cleanPrompt = buildInlinePromptText(prompt, files) if (!cleanPrompt || sending.value) { return @@ -1631,6 +2249,10 @@ function startInlineConversation(prompt, entry = {}, files = []) { return } + if (handleInlineApplicationPreviewTextAction(cleanPrompt)) { + return + } + if (conversationId.value === AI_SEARCH_CONVERSATION_ID) { conversationId.value = '' conversationMessages.value = [] @@ -1725,7 +2347,7 @@ function regenerateLastReply() { void requestInlineAssistantReply(lastUserMessage.content, { source: 'workbench', sessionType: 'steward' }, []) } -function handleInlineSuggestedAction(action = {}) { +function handleInlineSuggestedAction(action = {}, sourceMessage = null) { const prefillText = resolveSuggestedActionPrefill(action) if (prefillText) { assistantDraft.value = mergeComposerPrefill(assistantDraft.value, prefillText) @@ -1735,6 +2357,23 @@ function handleInlineSuggestedAction(action = {}) { const actionType = String(action?.action_type || '').trim() const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {} + if ([AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT].includes(actionType)) { + if (isInlineSuggestedActionDisabled(action, sourceMessage)) { + toast('请等待费用测算完成后再继续操作。') + return + } + void executeInlineApplicationPreviewAction(actionType, sourceMessage, { + userText: action.label, + draftPayload: actionPayload.draftPayload || actionPayload.draft_payload || null + }) + return + } + if (actionType === 'open_application_detail') { + const claimNo = String(actionPayload.claim_no || actionPayload.claimNo || '').trim() + const claimId = String(actionPayload.claim_id || actionPayload.claimId || '').trim() + emit('open-document', buildAiDocumentDetailRequest({ reference: claimNo || claimId })) + return + } if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_reimbursement') { const expenseType = String(actionPayload.expense_type || 'travel').trim() || 'travel' const expenseTypeLabel = String(actionPayload.expense_type_label || '差旅费').trim() || '差旅费' @@ -1814,6 +2453,14 @@ function pushInlineUserMessage(text) { conversationMessages.value.push(createInlineMessage('user', String(text || '').trim())) } +function pushInlineApplicationActionUserMessage(text) { + pushInlineUserMessage(text) + assistantDraft.value = '' + removeWorkbenchDateTag() + closeWorkbenchDatePicker() + clearAiModeFiles() +} + function resolveLatestInlineUserPrompt() { const latestUserMessage = [...conversationMessages.value].reverse().find((message) => message.role === 'user') return String(latestUserMessage?.content || '').trim() @@ -1915,8 +2562,8 @@ async function resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel) { if (!candidates.length) { conversationMessages.value.push(createInlineMessage('assistant', buildRequiredApplicationMissingText(expenseType), { suggestedActions: [{ - label: '在当前对话里发起申请', - description: '逐项收集出差申请要点,整理后你可以提交到申请助手', + label: '确认发起出差申请', + description: '生成完整申请表,并预填已识别的时间、地点和事由', icon: 'mdi mdi-file-plus-outline', action_type: 'ai_application_start_inline', payload: { @@ -1982,16 +2629,62 @@ async function startAiApplicationPreview(expenseType, expenseTypeLabel, sourceTe if (options.pushUserMessage !== false) { pushInlineUserMessage(options.userMessage || '确认发起出差申请') } - const preview = await refreshApplicationPreviewEstimate( - buildInlineApplicationPreview(expenseTypeLabel || expenseType, previewSourceText) - ) - const content = buildLocalApplicationPreviewMessage(preview) - conversationMessages.value.push(createInlineMessage('assistant', content, { - applicationPreview: preview, - text: content - })) + + const pendingMessage = createInlineMessage('assistant', '正在生成申请核对表,请稍等...', { + pending: true, + stewardPlan: { + streamStatus: 'streaming', + thinkingEvents: [ + { + eventId: 'application-preview-build', + title: '整理申请表字段', + content: '正在把已识别的时间、地点、事由和差旅类型整理成可编辑表格。', + status: 'running' + }, + { + eventId: 'application-preview-estimate', + title: '同步费用测算', + content: '正在刷新差旅规则和费用测算,完成后会直接展示核对表。', + status: 'pending' + } + ] + } + }) + conversationMessages.value.push(pendingMessage) persistCurrentConversation() scrollInlineConversationToBottom() + + try { + const preview = await refreshApplicationPreviewEstimate( + buildInlineApplicationPreview(expenseTypeLabel || expenseType, previewSourceText) + ) + const content = buildLocalApplicationPreviewMessage(preview) + replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', content, { + id: pendingMessage.id, + applicationPreview: preview, + suggestedActions: buildInlineApplicationPreviewSuggestedActions(preview), + stewardPlan: { + streamStatus: 'completed', + thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage)) + }, + text: content + })) + } catch (error) { + replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', error?.message || '申请核对表生成失败,请稍后重试。', { + id: pendingMessage.id, + stewardPlan: { + streamStatus: 'failed', + thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({ + ...item, + status: 'failed' + })) + } + })) + toast(error?.message || '申请核对表生成失败。') + } finally { + persistCurrentConversation() + scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value }) + } } function requestDeleteCurrentConversation() { @@ -2038,6 +2731,10 @@ function markInlineMessageFeedback(message, feedback) { } function triggerAiModeFileUpload() { + if (isAiModeInputLocked.value) { + toast('请等待费用测算完成后再继续操作。') + return + } fileInputRef.value?.click() } @@ -2057,6 +2754,10 @@ function clearAiModeFiles() { } function handleVoiceInput() { + if (isAiModeInputLocked.value) { + toast('请等待费用测算完成后再继续操作。') + return + } toast('语音输入正在准备中,您可以先输入文字需求。') focusAiModeInput() } diff --git a/web/src/composables/useAppShell.js b/web/src/composables/useAppShell.js index 8bc3401..fb25908 100644 --- a/web/src/composables/useAppShell.js +++ b/web/src/composables/useAppShell.js @@ -158,6 +158,10 @@ export function useAppShell() { ].some((value) => String(value || '').trim() === normalizedId) } + function isDetailLookupOnlyPayload(payload = {}) { + return Boolean(payload?.detailLookupOnly || payload?.detail_lookup_only) + } + function resolveRequestDetailLookupId(requestOrId = selectedRequestSnapshot.value) { if (typeof requestOrId === 'string') { return requestOrId.trim() @@ -168,6 +172,8 @@ export function useAppShell() { || requestOrId?.id || requestOrId?.claimNo || requestOrId?.claim_no + || requestOrId?.documentNo + || requestOrId?.document_no || '' ).trim() } @@ -564,13 +570,18 @@ export function useAppShell() { } function openRequestDetail(request, options = {}) { - selectedRequestSnapshot.value = request || null + const requestId = resolveRequestDetailLookupId(request) + if (!requestId) { + return + } + const isDetailLookupOnlyRequest = isDetailLookupOnlyPayload(request) + selectedRequestSnapshot.value = isDetailLookupOnlyRequest ? null : request || null router.push({ name: 'app-document-detail', - params: { requestId: request.claimId || request.id }, + params: { requestId }, query: buildDocumentDetailQuery(options) }) - void refreshSelectedRequestDetail(request) + void refreshSelectedRequestDetail(isDetailLookupOnlyRequest ? requestId : request) } function closeRequestDetail() { diff --git a/web/src/composables/useSystemState.js b/web/src/composables/useSystemState.js index 513cf8d..efefa94 100644 --- a/web/src/composables/useSystemState.js +++ b/web/src/composables/useSystemState.js @@ -15,7 +15,7 @@ import { resolveDefaultAuthorizedRoute } from '../utils/accessControl.js' import { useToast } from './useToast.js' import { fetchSettings } from '../services/settings.js' import { setThemeSkin } from './useThemeSkin.js' -import { normalizeAuthUserSnapshot } from '../utils/authUser.js' +import { normalizeAuthUserSnapshot, resolveAuthUserAdminFlag } from '../utils/authUser.js' import { clearAuthSessionMetrics, finalizeAuthSession, @@ -142,18 +142,7 @@ function buildLegacyAdminUser(username = '') { } function resolvePlatformAdminFlag(payload, roleCodes = []) { - const username = String(payload?.username || payload?.account || '').trim().toLowerCase() - const role = String(payload?.role || '').trim().toLowerCase() - const normalizedRoleCodes = roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean) - - return ( - Boolean(payload?.isAdmin) - || username === 'admin' - || role === 'admin' - || role === '管理员' - || role === '系统管理员' - || normalizedRoleCodes.includes('admin') - ) + return resolveAuthUserAdminFlag(payload, roleCodes) } function normalizeStoredAuthUser(payload = {}) { diff --git a/web/src/services/aiApplicationPreviewActions.js b/web/src/services/aiApplicationPreviewActions.js index 079f87c..f5f35bb 100644 --- a/web/src/services/aiApplicationPreviewActions.js +++ b/web/src/services/aiApplicationPreviewActions.js @@ -1,3 +1,4 @@ +import { apiRequest } from './api.js' import { runOrchestrator } from './orchestrator.js' import { buildApplicationPreviewRows, @@ -126,11 +127,20 @@ export function buildAiApplicationPreviewActionPayload({ } export function runAiApplicationPreviewAction(params = {}, options = {}) { - return runOrchestrator(buildAiApplicationPreviewActionPayload(params), { - timeoutMs: params.actionType === AI_APPLICATION_ACTION_SUBMIT ? 120000 : 75000, - timeoutMessage: params.actionType === AI_APPLICATION_ACTION_SUBMIT - ? '申请提交处理超时,请稍后重试。' - : '申请草稿保存超时,请稍后重试。', + const payload = buildAiApplicationPreviewActionPayload(params) + if (params.actionType === AI_APPLICATION_ACTION_SUBMIT) { + return apiRequest('/reimbursements/application-preview-action', { + method: 'POST', + body: JSON.stringify(payload), + timeoutMs: 45000, + timeoutMessage: '申请提交处理超时,请稍后重试。', + ...options + }) + } + + return runOrchestrator(payload, { + timeoutMs: 75000, + timeoutMessage: '申请草稿保存超时,请稍后重试。', ...options }) } diff --git a/web/src/services/api.js b/web/src/services/api.js index 8ed107d..6175e25 100644 --- a/web/src/services/api.js +++ b/web/src/services/api.js @@ -1,4 +1,4 @@ -import { normalizeAuthUserSnapshot } from '../utils/authUser.js' +import { normalizeAuthUserSnapshot, resolveAuthUserAdminFlag } from '../utils/authUser.js' const API_BASE_STORAGE_KEY = 'x-financial-api-base-url' const AUTH_USER_STORAGE_KEY = 'x-financial-auth-user' @@ -49,7 +49,7 @@ function readCurrentUserHeaders() { const username = user.username const name = user.name || username const roleCodes = user.roleCodes - const isAdmin = resolveStoredUserAdminFlag(payload, roleCodes) + const isAdmin = resolveAuthUserAdminFlag(payload, roleCodes) const department = user.department || user.departmentName const costCenter = user.costCenter const position = user.position @@ -112,22 +112,7 @@ function readCurrentUserHeaders() { } } -function resolveStoredUserAdminFlag(payload, roleCodes = []) { - const username = String(payload?.username || payload?.account || '').trim().toLowerCase() - const role = String(payload?.role || '').trim().toLowerCase() - const normalizedRoleCodes = roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean) - - return ( - Boolean(payload?.isAdmin) - || username === 'admin' - || role === 'admin' - || role === '管理员' - || role === '系统管理员' - || normalizedRoleCodes.includes('admin') - ) -} - -function normalizeApiBaseUrl(value) { +function normalizeApiBaseUrl(value) { return String(value || '/api/v1').replace(/\/$/, '') } diff --git a/web/src/utils/accessControl.js b/web/src/utils/accessControl.js index 47cf3a9..563c485 100644 --- a/web/src/utils/accessControl.js +++ b/web/src/utils/accessControl.js @@ -1,3 +1,5 @@ +import { resolveAuthUserAdminFlag } from './authUser.js' + export const DEFAULT_APP_VIEW_ORDER = [ 'workbench', 'documents', @@ -81,18 +83,7 @@ function hasPlatformAdminIdentity(user) { return false } - const username = String(user.username || user.account || '').trim().toLowerCase() - const role = String(user.role || '').trim().toLowerCase() - const roleCodes = normalizedRoleCodes(user) - - return ( - Boolean(user.isAdmin) - || username === 'admin' - || role === 'admin' - || role === '管理员' - || role === '系统管理员' - || roleCodes.includes('admin') - ) + return resolveAuthUserAdminFlag(user, normalizedRoleCodes(user)) } export function isManagerUser(user) { diff --git a/web/src/utils/aiApplicationPrecheckModel.js b/web/src/utils/aiApplicationPrecheckModel.js index 9fdd174..568b169 100644 --- a/web/src/utils/aiApplicationPrecheckModel.js +++ b/web/src/utils/aiApplicationPrecheckModel.js @@ -159,6 +159,10 @@ function isBlockingPrecheck(precheck = {}) { return precheck?.overlap?.status === 'warning' } +export function isAiApplicationPrecheckBlocking(precheck = {}) { + return isBlockingPrecheck(precheck) +} + function buildOverlapMatchTable(matches = []) { const rows = Array.isArray(matches) ? matches : [] if (!rows.length) { @@ -343,3 +347,32 @@ export function buildAiApplicationPrecheckMessage(preview = {}, precheck = {}) { return lines.join('\n') } + +export function buildAiApplicationSubmitConflictMessage(preview = {}, precheck = {}) { + const matchTable = buildOverlapMatchTable(precheck?.overlap?.matches) + const normalized = normalizeApplicationPreview(preview) + const fields = normalized.fields || {} + const currentRange = resolveDateRange(fields.time, fields.days) + const currentRangeText = currentRange + ? `${currentRange.startText} 至 ${currentRange.endText}` + : normalizeText(fields.time) || '待确认' + const lines = [ + '### 发现相同日期已有申请单', + '', + '**我已完成提交前的单据重叠核查**,发现相同或重叠日期已有差旅申请单,当前不能继续提交。', + '', + `> **相同日期提醒**:${precheck?.overlap?.summary || '发现相同日期已有申请单,请先核对后再提交。'}`, + '', + `> **本次申请时间**:${currentRangeText}`, + ] + if (matchTable) { + lines.push('', matchTable) + } + lines.push( + '', + '> **请先核对**:请先核对申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;若日期无误,请先查看或处理已有申请单,避免重复申请。', + '', + '我会先暂停本次提交,不会生成新的审批流。' + ) + return lines.join('\n') +} diff --git a/web/src/utils/authUser.js b/web/src/utils/authUser.js index 43fdf89..ea0d96f 100644 --- a/web/src/utils/authUser.js +++ b/web/src/utils/authUser.js @@ -8,10 +8,45 @@ function pickText(payload = {}, keys = [], fallback = '') { return String(fallback || '').trim() } +const PLATFORM_ADMIN_IDENTITIES = new Set(['admin', 'superadmin']) +const PLATFORM_ADMIN_ROLES = new Set(['admin', 'superadmin', '管理员', '系统管理员']) + +function isTruthyAdminFlag(value) { + if (value === true) { + return true + } + + if (typeof value === 'number') { + return value === 1 + } + + return ['1', 'true', 'yes', 'on'].includes(String(value || '').trim().toLowerCase()) +} + function normalizeRoleCodes(payload = {}) { - return Array.isArray(payload.roleCodes) - ? payload.roleCodes.map((item) => String(item || '').trim()).filter(Boolean) - : [] + const rawRoleCodes = Array.isArray(payload.roleCodes) + ? payload.roleCodes + : Array.isArray(payload.role_codes) + ? payload.role_codes + : typeof payload.roleCodes === 'string' + ? payload.roleCodes.split(',') + : [] + + return rawRoleCodes.map((item) => String(item || '').trim()).filter(Boolean) +} + +export function resolveAuthUserAdminFlag(payload = {}, roleCodes = []) { + const username = String(payload?.username || payload?.account || '').trim().toLowerCase() + const role = String(payload?.role || '').trim().toLowerCase() + const normalizedRoleCodes = roleCodes.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean) + + return ( + isTruthyAdminFlag(payload?.isAdmin) + || isTruthyAdminFlag(payload?.is_admin) + || PLATFORM_ADMIN_IDENTITIES.has(username) + || PLATFORM_ADMIN_ROLES.has(role) + || normalizedRoleCodes.some((item) => PLATFORM_ADMIN_IDENTITIES.has(item)) + ) } export function normalizeAuthUserSnapshot(payload = {}, defaults = {}) { @@ -47,6 +82,7 @@ export function normalizeAuthUserSnapshot(payload = {}, defaults = {}) { 'leaderName', 'leader_name' ]) + const roleCodes = normalizeRoleCodes(payload) return { username, @@ -62,9 +98,9 @@ export function normalizeAuthUserSnapshot(payload = {}, defaults = {}) { costCenter, financeOwnerName, riskProfile: payload.riskProfile && typeof payload.riskProfile === 'object' ? payload.riskProfile : {}, - roleCodes: normalizeRoleCodes(payload), + roleCodes, email: pickText(payload, ['email'], username), avatar: pickText(payload, ['avatar'], name.slice(0, 1).toUpperCase()), - isAdmin: Boolean(payload.isAdmin) + isAdmin: resolveAuthUserAdminFlag(payload, roleCodes) } } diff --git a/web/src/views/AppShellRouteView.vue b/web/src/views/AppShellRouteView.vue index 9d111cc..f3b55a5 100644 --- a/web/src/views/AppShellRouteView.vue +++ b/web/src/views/AppShellRouteView.vue @@ -4,8 +4,7 @@ :class="{ 'sidebar-collapsed': sidebarCollapsed, 'workbench-ai-sidebar-active': isAiShellMode, - 'mobile-sidebar-open': mobileSidebarOpen, - 'login-entry-active': loginEntryAnimating + 'mobile-sidebar-open': mobileSidebarOpen }" > @@ -18,17 +17,6 @@ > - - -
+
+ +
+ 正在加载完整单据详情 +

正在读取申请表、审批进度和详情字段,加载完成后再展示详情表格。

+
+
+ diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue index 788cb78..91ebddf 100644 --- a/web/src/views/LoginView.vue +++ b/web/src/views/LoginView.vue @@ -74,11 +74,22 @@

使用员工邮箱或管理员账号进入系统

- +

{{ errorMessage }}

diff --git a/web/tests/accessControl.test.mjs b/web/tests/accessControl.test.mjs index cd3fc7a..d1a4004 100644 --- a/web/tests/accessControl.test.mjs +++ b/web/tests/accessControl.test.mjs @@ -62,6 +62,7 @@ test('archived claims can only be deleted by admin users', () => { assert.equal(canDeleteArchivedExpenseClaims({ roleCodes: ['executive'] }), false) assert.equal(canDeleteArchivedExpenseClaims({ roleCodes: ['finance'] }), false) assert.equal(canDeleteArchivedExpenseClaims({ isAdmin: true, roleCodes: ['manager'] }), true) + assert.equal(canDeleteArchivedExpenseClaims({ username: 'superadmin', roleCodes: ['manager'] }), true) }) test('legacy reimbursement approval and archive centers are no longer accessible app views', () => { @@ -76,6 +77,7 @@ test('legacy reimbursement approval and archive centers are no longer accessible test('platform admin users do not enter the personal workbench', () => { const adminUser = { username: 'admin', isAdmin: true, roleCodes: ['manager', 'finance'] } + const legacyAdminUser = { username: 'superadmin', roleCodes: ['manager'] } const employeeUser = { username: 'employee@example.com', roleCodes: [] } const navItems = [ { id: 'workbench', label: '个人工作台' }, @@ -85,8 +87,10 @@ test('platform admin users do not enter the personal workbench', () => { ] assert.equal(canAccessAppView(adminUser, 'workbench'), false) + assert.equal(canAccessAppView(legacyAdminUser, 'workbench'), false) assert.equal(canAccessAppView(employeeUser, 'workbench'), true) assert.equal(getAccessibleViewIds(adminUser).includes('workbench'), false) + assert.deepEqual(resolveDefaultAuthorizedRoute(legacyAdminUser), { name: 'app-documents' }) assert.deepEqual(resolveDefaultAuthorizedRoute(adminUser), { name: 'app-documents' }) assert.deepEqual( filterNavItemsByAccess(navItems, adminUser).map((item) => item.id), diff --git a/web/tests/ai-application-precheck-model.test.mjs b/web/tests/ai-application-precheck-model.test.mjs index aecf1ca..ed2a3e9 100644 --- a/web/tests/ai-application-precheck-model.test.mjs +++ b/web/tests/ai-application-precheck-model.test.mjs @@ -4,7 +4,9 @@ import test from 'node:test' import { buildAiApplicationPrecheck, buildAiApplicationPrecheckMessage, - buildAiApplicationPrecheckThinkingEvents + buildAiApplicationPrecheckThinkingEvents, + buildAiApplicationSubmitConflictMessage, + isAiApplicationPrecheckBlocking } from '../src/utils/aiApplicationPrecheckModel.js' const preview = { @@ -71,6 +73,42 @@ test('application precheck blocks application generation when existing applicati assert.doesNotMatch(message, /出差申请表草稿已生成/) }) +test('application submit precheck blocks submit and keeps application detail action link', () => { + const precheck = buildAiApplicationPrecheck(preview, { + currentUser: { name: '曹笑竹', departmentName: '技术部' }, + claimsPayload: { + items: [ + { + claim_no: 'AP-OVERLAP', + document_type: 'expense_application', + expense_type: 'travel_application', + employee_name: '曹笑竹', + status: 'submitted', + risk_flags_json: [ + { + source: 'application_detail', + application_detail: { + business_time: '2026-02-20 至 2026-02-23', + reason: '辅助国网仿生产服务器部署', + location: '上海' + } + } + ] + } + ] + } + }) + + assert.equal(isAiApplicationPrecheckBlocking(precheck), true) + + const message = buildAiApplicationSubmitConflictMessage(preview, precheck) + assert.match(message, /### 发现相同日期已有申请单/) + assert.match(message, /当前不能继续提交/) + assert.match(message, /请先核对申请时间是否填写正确/) + assert.match(message, /\[查看\]\(#ai-open-application-detail:AP-OVERLAP\)/) + assert.doesNotMatch(message, /生成新的出差申请表/) +}) + test('application precheck emits thinking events for overlap, budget, and form generation', () => { const precheck = buildAiApplicationPrecheck(preview, { currentUser: { name: '曹笑竹' }, diff --git a/web/tests/ai-application-preview-actions.test.mjs b/web/tests/ai-application-preview-actions.test.mjs index 256a6cb..29f5461 100644 --- a/web/tests/ai-application-preview-actions.test.mjs +++ b/web/tests/ai-application-preview-actions.test.mjs @@ -1,127 +1,90 @@ import assert from 'node:assert/strict' -import test from 'node:test' import { AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT, - buildAiApplicationPreviewActionPayload + runAiApplicationPreviewAction } from '../src/services/aiApplicationPreviewActions.js' -import { - applyApplicationPolicyEstimateResult, - buildApplicationPolicyEstimateRequest, - buildLocalApplicationPreview -} from '../src/utils/expenseApplicationPreview.js' -const applicationPreview = { - fields: { - applicationType: '差旅费用申请', - applicant: '曹笑竹', - grade: 'P5', - department: '技术部', - position: '财务智能化产品经理', - managerName: '向万红', - time: '2026-02-20 至 2026-02-23', - location: '上海', - reason: '辅助国网仿生产服务器部署', - days: '4天', - transportMode: '火车', - lodgingDailyCap: '250元/天', - subsidyDailyCap: '100元/天', - transportPolicy: '按交通费用预估表暂估', - policyEstimate: '交通 720元 + 住宿 1,000元 + 补贴 400元 = 2,120元(4天)', - amount: '2,120元' - } -} +async function testSubmitActionUsesFastPreviewEndpoint() { + let capturedUrl = '' + let capturedOptions = null -const currentUser = { - username: 'caoxiaozhu@xf.com', - name: '曹笑竹', - departmentName: '技术部', - position: '财务智能化产品经理', - grade: 'P5', - managerName: '向万红', - roleCodes: ['employee'] -} - -test('save application preview payload uses save draft action without submit wording', () => { - const payload = buildAiApplicationPreviewActionPayload({ - actionType: AI_APPLICATION_ACTION_SAVE_DRAFT, - applicationPreview, - currentUser, - conversationId: 'inline-1' - }) - - assert.equal(payload.user_id, 'caoxiaozhu@xf.com') - assert.equal(payload.conversation_id, 'inline-1') - assert.equal(payload.context_json.session_type, 'application') - assert.equal(payload.context_json.review_action, undefined) - assert.equal(payload.context_json.application_action, 'save_draft') - assert.equal(payload.context_json.application_preview.fields.transportMode, '火车') - assert.match(payload.message, /费用申请保存草稿/) - assert.match(payload.message, /保存草稿/) - assert.doesNotMatch(payload.message, /确认提交/) -}) - -test('submit application preview payload keeps existing draft id for resubmission', () => { - const payload = buildAiApplicationPreviewActionPayload({ - actionType: AI_APPLICATION_ACTION_SUBMIT, - applicationPreview, - currentUser, - conversationId: 'inline-1', - draftPayload: { - claim_id: 'draft-001', - claim_no: 'AP-202602200001' + global.fetch = async (url, options) => { + capturedUrl = String(url) + capturedOptions = options + return { + ok: true, + async json() { + return { + status: 'succeeded', + result: { + draft_payload: { + claim_id: 'claim-fast-submit', + claim_no: 'AP-20260620-FAST', + status: 'submitted', + approval_stage: '直属领导审批' + } + } + } + } } + } + + await runAiApplicationPreviewAction({ + actionType: AI_APPLICATION_ACTION_SUBMIT, + applicationPreview: { + fields: { + applicationType: '差旅费用申请', + time: '2026-07-01 至 2026-07-03', + location: '北京', + reason: '项目实施', + days: '3天', + transportMode: '火车', + amount: '1000元' + } + }, + currentUser: { username: 'zhangsan@example.com', name: '张三' }, + conversationId: 'conversation-fast-submit' }) - assert.equal(payload.context_json.review_action, undefined) - assert.equal(payload.context_json.application_edit_claim_id, 'draft-001') - assert.equal(payload.context_json.draft_claim_id, 'draft-001') - assert.match(payload.message, /费用申请确认提交/) - assert.match(payload.message, /确认提交/) -}) + assert.equal(capturedUrl, '/api/v1/reimbursements/application-preview-action') + assert.equal(capturedOptions.method, 'POST') + const body = JSON.parse(capturedOptions.body) + assert.equal(body.context_json.session_type, 'application') + assert.equal(body.context_json.application_stage, 'expense_application') + assert.equal(body.context_json.application_preview.fields.transportMode, '火车') +} -test('travel application preview calculates base standards before transport mode is selected', () => { - const preview = buildLocalApplicationPreview( - '2月20-23日去上海出差,辅助国网仿生产服务器部署', - { name: '曹笑竹', grade: 'P5', location: '武汉' }, - { today: '2026-06-20' } - ) - const request = buildApplicationPolicyEstimateRequest(preview, { grade: 'P5', location: '武汉' }) +async function testSaveDraftActionKeepsOrchestratorPath() { + let capturedUrl = '' - assert.equal(request.canCalculate, true) - assert.deepEqual(request.payload, { - days: 4, - location: '上海', - grade: 'P5', - transport_mode: null, - origin_location: '武汉', - travel_date: '2026-02-20' + global.fetch = async (url) => { + capturedUrl = String(url) + return { + ok: true, + async json() { + return { status: 'succeeded', result: {} } + } + } + } + + await runAiApplicationPreviewAction({ + actionType: AI_APPLICATION_ACTION_SAVE_DRAFT, + applicationPreview: { fields: { reason: '项目实施' } }, + currentUser: { username: 'zhangsan@example.com', name: '张三' } }) - const estimatedPreview = applyApplicationPolicyEstimateResult(preview, { - days: 4, - location: '上海', - matched_city: '上海', - grade: 'P5', - hotel_rate: 450, - hotel_amount: 1800, - total_allowance_rate: 100, - allowance_amount: 400, - transport_mode: '火车', - transport_origin: '武汉', - transport_destination: '上海', - transport_estimated_amount: 720, - total_amount: 2200, - rule_name: '公司差旅费报销规则', - rule_version: 'v1.0.0' - }, { grade: 'P5', location: '武汉' }) + assert.equal(capturedUrl, '/api/v1/orchestrator/run') +} - assert.equal(estimatedPreview.fields.transportMode, '') - assert.equal(estimatedPreview.missingFields.includes('出行方式'), true) - assert.equal(estimatedPreview.fields.lodgingDailyCap, '450元/天') - assert.equal(estimatedPreview.fields.subsidyDailyCap, '100元/天') - assert.equal(estimatedPreview.fields.transportPolicy, '选择火车、飞机或轮船后自动预估交通费用') - assert.equal(estimatedPreview.fields.policyEstimate, '交通待补充 + 住宿 1,800元 + 补贴 400元 = 2,200元(4天,不含交通)') - assert.equal(estimatedPreview.fields.amount, '2,200元(不含交通)') +async function run() { + await testSubmitActionUsesFastPreviewEndpoint() + await testSaveDraftActionKeepsOrchestratorPath() + console.log('ai-application-preview-actions tests passed') +} + +run().catch((error) => { + console.error(error) + process.exit(1) }) diff --git a/web/tests/api-request.test.mjs b/web/tests/api-request.test.mjs index 1ffbd70..f4d2c02 100644 --- a/web/tests/api-request.test.mjs +++ b/web/tests/api-request.test.mjs @@ -94,6 +94,46 @@ async function testInjectsAuthenticatedUserHeaders() { assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true') } +async function testInjectsLegacyAdminHeaderFromSnakeCaseFlag() { + const sessionStorage = new Map([ + [ + 'x-financial-auth-user', + JSON.stringify({ + username: 'superadmin', + name: 'superadmin', + roleCodes: ['manager'], + is_admin: true + }) + ] + ]) + + global.window = { + sessionStorage: { + getItem(key) { + return sessionStorage.get(key) ?? null + } + } + } + + let capturedOptions = null + + global.fetch = async (_url, options) => { + capturedOptions = options + return { + ok: true, + async json() { + return { ok: true } + } + } + } + + await apiRequest('/reimbursements/claims/demo', { method: 'DELETE' }) + + assert.equal(capturedOptions.headers['x-auth-username'], 'superadmin') + assert.equal(capturedOptions.headers['x-auth-role-codes'], 'manager') + assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true') +} + async function testFormatsValidationErrors() { global.fetch = async () => ({ ok: false, @@ -153,6 +193,7 @@ async function run() { await testUsesCustomContentTypeHeader() await testSupportsBlobResponses() await testInjectsAuthenticatedUserHeaders() + await testInjectsLegacyAdminHeaderFromSnakeCaseFlag() await testFormatsValidationErrors() await testRejectsWithCustomTimeoutMessage() console.log('api-request tests passed') diff --git a/web/tests/app-shell-financial-assistant-entry.test.mjs b/web/tests/app-shell-financial-assistant-entry.test.mjs index 934e9a6..0580a9a 100644 --- a/web/tests/app-shell-financial-assistant-entry.test.mjs +++ b/web/tests/app-shell-financial-assistant-entry.test.mjs @@ -118,7 +118,7 @@ test('workbench progress refresh is silent to avoid homepage flashing', () => { test('document detail navigation preserves document center list query', () => { assert.match( appShellComposable, - /function openRequestDetail\(request, options = \{\}\) \{[\s\S]*name: 'app-document-detail'[\s\S]*params: \{ requestId: request\.claimId \|\| request\.id \},[\s\S]*query: buildDocumentDetailQuery\(options\)/ + /function openRequestDetail\(request, options = \{\}\) \{[\s\S]*const requestId = resolveRequestDetailLookupId\(request\)[\s\S]*name: 'app-document-detail'[\s\S]*params: \{ requestId \},[\s\S]*query: buildDocumentDetailQuery\(options\)/ ) assert.match( appShellComposable, @@ -135,7 +135,8 @@ test('document detail refreshes claim detail instead of relying on stale list ca assert.match(appShellComposable, /import \{ mapExpenseClaimToRequest, useRequests \} from '\.\/useRequests\.js'/) assert.match(appShellComposable, /const snapshot = normalizeRequestForUi\(selectedRequestSnapshot\.value\)[\s\S]*if \(isSameRequestIdentity\(snapshot, requestId\)\) \{[\s\S]*return snapshot/) assert.match(appShellComposable, /async function refreshSelectedRequestDetail\(requestOrId = selectedRequestSnapshot\.value\) \{[\s\S]*fetchExpenseClaimDetail\(lookupId\)[\s\S]*mapExpenseClaimToRequest\(payload\)[\s\S]*upsertRequestSnapshot\(mappedRequest\)/) - assert.match(appShellComposable, /function openRequestDetail\(request, options = \{\}\) \{[\s\S]*void refreshSelectedRequestDetail\(request\)/) + assert.match(appShellComposable, /function isDetailLookupOnlyPayload\(payload = \{\}\) \{[\s\S]*payload\?\.detailLookupOnly/) + assert.match(appShellComposable, /function openRequestDetail\(request, options = \{\}\) \{[\s\S]*selectedRequestSnapshot\.value = isDetailLookupOnlyRequest \? null : request \|\| null[\s\S]*void refreshSelectedRequestDetail\(isDetailLookupOnlyRequest \? requestId : request\)/) assert.match(appShellComposable, /async function handleRequestUpdated\(payload = \{\}\) \{[\s\S]*await reloadWorkbenchRequests\(\)[\s\S]*await refreshSelectedRequestDetail\(claimId\)/) assert.match(appShellComposable, /route\.name === 'app-document-detail'[\s\S]*void refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/) }) 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 1f38988..e984a04 100644 --- a/web/tests/workbench-ai-mode-expense-scene-action.test.mjs +++ b/web/tests/workbench-ai-mode-expense-scene-action.test.mjs @@ -53,6 +53,7 @@ test('AI mode offers an inline application shortcut when no candidate applicatio assert.match(aiMode, /buildLocalApplicationPreviewMessage/) assert.match(aiMode, /refreshApplicationPreviewEstimate/) assert.match(aiMode, /applicationPreview:\s*preview/) + assert.match(aiMode, /suggestedActions:\s*buildInlineApplicationPreviewSuggestedActions\(preview\)/) assert.doesNotMatch(aiMode, /function startAiApplicationDraft/) assert.doesNotMatch(aiMode, /buildAiApplicationStepPrompt/) }) @@ -94,12 +95,143 @@ test('AI mode handles document query prompts locally before steward planning', ( assert.match(aiMode, /emit\('open-document', buildAiDocumentDetailRequest\(detailReference\)\)/) }) -test('AI mode continues required application gate decisions into table preview from steward plan', () => { - assert.match(aiMode, /function continueAiRequiredApplicationGateFromPlan\(normalizedPlan, prompt = ''\)/) - assert.match(aiMode, /flow\.flowId === 'travel_application'[\s\S]*void startAiApplicationPreview\('travel', '差旅费', prompt/) - assert.match(aiMode, /flow\.flowId === 'travel_reimbursement'[\s\S]*startAiExpenseDraft\('travel', '差旅费', true/) - assert.match(aiMode, /continueAiRequiredApplicationGateFromPlan\(normalizedPlan, prompt\)/) +test('AI mode asks for manual confirmation before generating application preview table', () => { + assert.match(aiMode, /function buildAiRequiredApplicationGateSuggestedActions\(flow, prompt = ''\)/) + assert.match(aiMode, /label:\s*'确认发起出差申请'/) + assert.match(aiMode, /action_type:\s*'ai_application_start_inline'/) + assert.match(aiMode, /carry_text:\s*prompt/) + assert.match(aiMode, /label:\s*'确认关联已有申请单'/) + assert.match(aiMode, /flow_id:\s*'travel_reimbursement'/) + assert.match(aiMode, /suggestedActions:\s*requiredApplicationContinuationFlow[\s\S]*buildAiRequiredApplicationGateSuggestedActions\(requiredApplicationContinuationFlow, prompt\)/) + assert.doesNotMatch(aiMode, /continueAiRequiredApplicationGateFromPlan\(normalizedPlan, prompt\)/) + assert.doesNotMatch(aiMode, /flow\.flowId === 'travel_application'[\s\S]*void startAiApplicationPreview\('travel', '差旅费', prompt\)/) assert.match(aiMode, /class="workbench-ai-application-preview application-preview-shell"/) assert.match(aiMode, /resolveInlineApplicationPreviewRows\(message\)/) assert.match(aiMode, /commitInlineApplicationPreviewEditor\(message\)/) }) + +test('AI mode shows pending feedback before async application preview estimate refresh', () => { + const startPreviewFunction = aiMode.match( + /async function startAiApplicationPreview[\s\S]*?\n}\n\nfunction requestDeleteCurrentConversation/ + )?.[0] || '' + + assert.match(startPreviewFunction, /const pendingMessage = createInlineMessage\(\s*'assistant',\s*'正在生成申请核对表/) + assert.ok( + startPreviewFunction.indexOf('conversationMessages.value.push(pendingMessage)') < + startPreviewFunction.indexOf('await refreshApplicationPreviewEstimate(') + ) + assert.match(startPreviewFunction, /pending:\s*true/) + assert.match(startPreviewFunction, /replaceInlineMessage\(\s*pendingMessage\.id/) +}) + +test('AI mode handles application preview save and submit through buttons or text commands', () => { + assert.match(aiMode, /AI_APPLICATION_ACTION_SAVE_DRAFT/) + assert.match(aiMode, /AI_APPLICATION_ACTION_SUBMIT/) + assert.match(aiMode, /runAiApplicationPreviewAction/) + assert.match(aiMode, /buildAiApplicationPrecheck/) + assert.match(aiMode, /buildAiApplicationSubmitConflictMessage/) + assert.match(aiMode, /isAiApplicationPrecheckBlocking/) + assert.match(aiMode, /applicationSubmitConfirmOpen/) + assert.match(aiMode, /确认直接提交申请/) + assert.match(aiMode, /function buildInlineApplicationPreviewSuggestedActions\(applicationPreview = \{\}, draftPayload = null\)/) + assert.match(aiMode, /label:\s*'直接提交'/) + assert.match(aiMode, /function resolveInlineApplicationPreviewActionFromText\(text = ''\)/) + assert.match(aiMode, /function executeInlineApplicationPreviewAction\(actionType, sourceMessage = null, options = \{\}\)/) + assert.match(aiMode, /function confirmInlineApplicationSubmit\(\)/) + assert.match(aiMode, /function cancelInlineApplicationSubmitConfirm\(\)/) + assert.match(aiMode, /function handleInlineApplicationPreviewTextAction\(prompt\)/) + assert.match(aiMode, /if \(handleInlineApplicationPreviewTextAction\(cleanPrompt\)\) \{[\s\S]*return[\s\S]*\}/) + assert.match(aiMode, /\[AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT\]\.includes\(actionType\)/) + assert.match(aiMode, /normalizedPreview\.readyToSubmit/) + assert.match(aiMode, /fetchExpenseClaims\(\{ page: 1, pageSize: 100 \}\)/) + assert.match(aiMode, /skipUserMessage/) + assert.match(aiMode, /暂不能提交申请/) + assert.match(aiMode, /#ai-open-application-detail:/) +}) + +test('AI mode waits for submit confirmation before adding submit action to the conversation', () => { + const executeStart = aiMode.indexOf('async function executeInlineApplicationPreviewAction') + const executeEnd = aiMode.indexOf('\nfunction handleInlineApplicationPreviewTextAction', executeStart) + const executeBlock = aiMode.slice(executeStart, executeEnd) + const confirmGateIndex = executeBlock.indexOf('if (isSubmit && !options.confirmed)') + const requestConfirmIndex = executeBlock.indexOf('requestInlineApplicationSubmitConfirmation', confirmGateIndex) + const confirmedActionPushIndex = executeBlock.indexOf('pushInlineApplicationActionUserMessage(userText)', requestConfirmIndex) + + assert.ok(confirmGateIndex >= 0, '直接提交应先进入确认分支') + assert.ok(requestConfirmIndex > confirmGateIndex, '直接提交确认分支应先打开确认弹窗') + assert.ok(confirmedActionPushIndex > requestConfirmIndex, '确认弹窗打开前不应追加“直接提交”用户消息') + assert.match( + executeBlock, + /requestInlineApplicationSubmitConfirmation\(targetMessage,\s*\{\s*\.\.\.options,\s*userText\s*\}\)/ + ) + + const confirmStart = aiMode.indexOf('function confirmInlineApplicationSubmit()') + const confirmEnd = aiMode.indexOf('\nasync function runInlineApplicationSubmitPrecheck', confirmStart) + const confirmBlock = aiMode.slice(confirmStart, confirmEnd) + assert.match(confirmBlock, /userText:\s*context\.userText \|\| '直接提交'/) + assert.match(confirmBlock, /skipUserMessage:\s*false/) + + const cancelStart = aiMode.indexOf('function cancelInlineApplicationSubmitConfirm()') + const cancelEnd = aiMode.indexOf('\nfunction confirmInlineApplicationSubmit', cancelStart) + const cancelBlock = aiMode.slice(cancelStart, cancelEnd) + assert.doesNotMatch(cancelBlock, /pushInlineUserMessage|pushInlineApplicationActionUserMessage/) +}) + +test('AI mode formats saved application draft as a detail table without continuing submit flow', () => { + assert.match(aiMode, /function buildInlineApplicationResultTable\(draftPayload = \{\}, options = \{\}\)/) + assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 操作 \|/) + assert.match(aiMode, /\[查看\]\(\$\{href\}\)/) + + const resultStart = aiMode.indexOf('function buildInlineApplicationPreviewActionResultText') + const resultEnd = aiMode.indexOf('\nfunction buildInlineApplicationDetailAction', resultStart) + const resultBlock = aiMode.slice(resultStart, resultEnd) + const submitBranchIndex = resultBlock.indexOf('actionType === AI_APPLICATION_ACTION_SUBMIT') + const saveBranchIndex = resultBlock.indexOf("'### 申请草稿已保存'") + const saveBranch = resultBlock.slice(saveBranchIndex) + + assert.ok(submitBranchIndex >= 0) + assert.ok(saveBranchIndex > submitBranchIndex, '保存草稿结果应走非提交分支') + assert.match( + saveBranch, + /buildInlineApplicationResultTable\(draftPayload,\s*\{[\s\S]*statusLabel:\s*'草稿'[\s\S]*stageLabel:\s*'待提交'/ + ) + assert.doesNotMatch(saveBranch, /进入审批流程/) + + const executeStart = aiMode.indexOf('async function executeInlineApplicationPreviewAction') + const executeEnd = aiMode.indexOf('\nfunction handleInlineApplicationPreviewTextAction', executeStart) + const executeBlock = aiMode.slice(executeStart, executeEnd) + assert.match(executeBlock, /targetMessage\.suggestedActions = \[\]/) + assert.doesNotMatch( + executeBlock, + /targetMessage\.suggestedActions = isSubmit[\s\S]*buildInlineApplicationPreviewSuggestedActions\(targetMessage\.applicationPreview, draftPayload\)/ + ) + assert.match(executeBlock, /suggestedActions:\s*isSubmit\s*\?\s*buildInlineApplicationDetailAction\(draftPayload\)\s*:\s*\[\]/) +}) + +test('AI mode locks application preview actions while estimate refresh is pending', () => { + assert.match(aiMode, /function isApplicationPreviewEstimatePendingPreview\(applicationPreview = \{\}\)/) + assert.match( + aiMode, + /function buildInlineApplicationPreviewSuggestedActions\(applicationPreview = \{\}, draftPayload = null\) \{[\s\S]*if \(isApplicationPreviewEstimatePendingPreview\(applicationPreview\)\) \{[\s\S]*return \[\]/ + ) + assert.match(aiMode, /const isAiModeInputLocked = computed\(\(\) => applicationPreviewEstimatePending\.value\)/) + assert.match(aiMode, /:disabled="isAiModeInputLocked"/) + assert.match(aiMode, /v-if="canShowInlineSuggestedActions\(message\)"/) + assert.match(aiMode, /:disabled="isInlineSuggestedActionDisabled\(action, message\)"/) + assert.match( + aiMode, + /message\.suggestedActions = \[\][\s\S]*const committed = await commitApplicationPreviewEditor\(message\)/ + ) + assert.match( + aiMode, + /if \(applicationPreviewEstimatePending\.value\) \{[\s\S]*toast\('请等待费用测算完成后再继续操作。'\)[\s\S]*return true/ + ) + assert.match( + aiMode, + /row\.editable && !isApplicationPreviewEstimatePending\(message\) \? 0 : -1/ + ) + assert.match( + aiMode, + /费用测算正在同步,请稍等,完成后才能保存草稿或直接提交。/ + ) +}) diff --git a/web/tests/workbench-ai-mode-switch.test.mjs b/web/tests/workbench-ai-mode-switch.test.mjs index 968c350..0ca5370 100644 --- a/web/tests/workbench-ai-mode-switch.test.mjs +++ b/web/tests/workbench-ai-mode-switch.test.mjs @@ -211,7 +211,8 @@ test('AI mode screen follows the approved reference structure', () => { assert.match(aiMode, / { assert.doesNotMatch(aiMode, /小财管家正在思考/) assert.doesNotMatch(aiMode, /思考过程/) assert.doesNotMatch(aiMode, /message\.pending \?/) - assert.match(aiMode, /placeholder="继续和小财管家对话\.\.\."/) + assert.match(aiMode, /继续和小财管家对话\.\.\./) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__head\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__body\)/) @@ -293,6 +294,9 @@ test('AI mode screen follows the approved reference structure', () => { assert.match(aiMode, /const finalMessageText = requiredApplicationContinuationFlow[\s\S]*buildAiRequiredApplicationGateAutoMessage\(normalizedPlan, requiredApplicationContinuationFlow\)[\s\S]*buildStewardPlanMessageText\(plan\)/) assert.match(aiMode, /const hasServerStreamedContent = Boolean\(String\(pendingMessage\.content \|\| ''\)\.trim\(\)\)/) assert.match(aiMode, /if \(!hasServerStreamedContent\) \{[\s\S]*await streamInlineAssistantContent\(pendingMessage\.id, finalMessageText\)[\s\S]*\}/) + assert.match(aiMode, /if \(actionType === AI_APPLICATION_ACTION_SUBMIT\) \{[\s\S]*buildInlineApplicationResultTable\(draftPayload/) + assert.match(aiMode, /需要查看完整详情时,请点击列表最后一列的“查看”进入单据详情。/) + assert.doesNotMatch(aiMode, /\*\*申请单号:\*\*/) assert.doesNotMatch(aiMode, /createInlineMessage\('assistant', buildStewardPlanMessageText\(plan\)/) assert.doesNotMatch(aiMode, /runOrchestrator\(/) assert.doesNotMatch(aiMode, /buildFallbackAnswer/) diff --git a/web/tests/workbench-detail-return.test.mjs b/web/tests/workbench-detail-return.test.mjs index d9fe2a6..1d46220 100644 --- a/web/tests/workbench-detail-return.test.mjs +++ b/web/tests/workbench-detail-return.test.mjs @@ -15,6 +15,10 @@ const workbench = readFileSync( fileURLToPath(new URL('../src/components/business/PersonalWorkbench.vue', import.meta.url)), 'utf8' ) +const aiMode = readFileSync( + fileURLToPath(new URL('../src/components/business/PersonalWorkbenchAiMode.vue', import.meta.url)), + 'utf8' +) test('workbench document detail keeps workbench as the return target', () => { assert.match(workbench, /source:\s*'workbench'/) @@ -22,10 +26,31 @@ test('workbench document detail keeps workbench as the return target', () => { assert.match(appShell, /:back-label="detailBackLabel"/) assert.match(appShell, /String\(payload\.returnTo \|\| ''\)\.trim\(\) === 'workbench'/) assert.match(appShell, /String\(payload\.source \|\| ''\)\.trim\(\) === 'workbench'/) - assert.match(appShell, /openRequestDetail\(request \|\| payload,\s*\{ returnTo \}\)/) + assert.match(appShell, /const detailPayload = request \|\| \{[\s\S]*detailLookupOnly:\s*true[\s\S]*\}/) + assert.match(appShell, /openRequestDetail\(detailPayload,\s*\{ returnTo \}\)/) assert.match(appShellComposable, /const detailReturnTarget = computed/) assert.match(appShellComposable, /detailReturnTarget\.value === 'workbench' \? '返回首页' : '返回单据中心'/) assert.match(appShellComposable, /nextQuery\.returnTo = 'workbench'/) assert.match(appShellComposable, /router\.push\(\{ name: 'app-workbench' \}\)/) assert.match(appShellComposable, /router\.push\(\{ name: 'app-documents', query: buildDocumentReturnQuery\(\) \}\)/) }) + +test('AI detail links wait for full document detail instead of rendering a half snapshot', () => { + assert.match(aiMode, /detailLookupOnly:\s*true/) + assert.match( + appShell, + /v-else-if="activeView === 'documents' && detailMode && !selectedRequest"[\s\S]*正在加载完整单据详情/ + ) + assert.match( + appShell, + /const detailPayload = request \|\| \{[\s\S]*detailLookupOnly:\s*true[\s\S]*\}/ + ) + assert.match( + appShellComposable, + /const isDetailLookupOnlyRequest = isDetailLookupOnlyPayload\(request\)[\s\S]*selectedRequestSnapshot\.value = isDetailLookupOnlyRequest \? null : request \|\| null/ + ) + assert.match( + appShellComposable, + /void refreshSelectedRequestDetail\(isDetailLookupOnlyRequest \? requestId : request\)/ + ) +})