From 0cda750ff051df5d7979941eb9df81b16b510d6f Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Sat, 20 Jun 2026 21:44:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20AI=20=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E4=B8=8E=E6=96=87=E6=A1=A3=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - aiConversationHtmlRenderer 识别单据记录类表格并渲染为卡片列表,新增删除申请单详情的禁用占位链接 - aiWorkbenchConversationStore 增加草稿删除后会话链接失效处理,避免点击已删除单据跳转 - aiApplicationPreviewActions 调整提交/草稿调用路径,PersonalWorkbenchAiMode 接入新的会话存储与渲染 - ConfirmDialog/TravelRequestDeleteDialog/useAppShell/AppShellRouteView 配套适配,同步更新相关前端测试 --- .../components/personal-workbench-ai-mode.css | 128 +++++++++++++ .../components/stage-risk-advice-card.css | 83 +++++---- .../business/PersonalWorkbenchAiMode.vue | 66 +++++-- web/src/components/shared/ConfirmDialog.vue | 49 +++++ .../travel/TravelRequestDeleteDialog.vue | 2 + web/src/composables/useAppShell.js | 2 + .../services/aiApplicationPreviewActions.js | 20 +- web/src/utils/aiConversationHtmlRenderer.js | 94 ++++++++++ web/src/utils/aiWorkbenchConversationStore.js | 176 ++++++++++++++++++ web/src/views/AppShellRouteView.vue | 7 +- .../views/scripts/TravelRequestDetailView.js | 31 ++- .../ai-application-preview-actions.test.mjs | 27 ++- .../ai-conversation-html-renderer.test.mjs | 38 +++- .../assistant-session-draft-delete.test.mjs | 41 ++++ ...travel-request-detail-risk-advice.test.mjs | 7 +- ...vel-request-detail-submit-confirm.test.mjs | 36 +++- ...ench-ai-mode-expense-scene-action.test.mjs | 3 + web/tests/workbench-ai-mode-switch.test.mjs | 12 ++ web/tests/workbench-detail-return.test.mjs | 4 + 19 files changed, 734 insertions(+), 92 deletions(-) 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 0fed0e1..50cfd73 100644 --- a/web/src/assets/styles/components/personal-workbench-ai-mode.css +++ b/web/src/assets/styles/components/personal-workbench-ai-mode.css @@ -1321,6 +1321,8 @@ margin-top: 18px; border: 1px solid rgba(226, 232, 240, 0.9); border-radius: 14px; + background: #ffffff; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.04); } .workbench-ai-answer-markdown :deep(table) { @@ -1342,6 +1344,123 @@ font-weight: 850; } +.workbench-ai-answer-markdown :deep(.ai-html-record-list) { + display: grid; + gap: 10px; + margin-top: 18px; +} + +.workbench-ai-answer-markdown :deep(.ai-html-record-item) { + display: grid; + grid-template-columns: minmax(220px, 1.15fr) minmax(260px, 0.85fr) auto; + gap: 16px; + align-items: center; + padding: 15px 16px; + border: 1px solid rgba(203, 213, 225, 0.86); + border-left: 3px solid #60a5fa; + border-radius: 14px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.9)); + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.045); +} + +.workbench-ai-answer-markdown :deep(.ai-html-record-main) { + min-width: 0; + display: grid; + gap: 5px; +} + +.workbench-ai-answer-markdown :deep(.ai-html-record-kicker) { + width: fit-content; + max-width: 100%; + padding: 2px 8px; + border-radius: 999px; + background: rgba(37, 99, 235, 0.08); + color: #1d4ed8; + font-size: 12px; + font-weight: 850; + line-height: 1.35; +} + +.workbench-ai-answer-markdown :deep(.ai-html-record-id) { + color: #0f172a; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 15px; + font-weight: 860; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.workbench-ai-answer-markdown :deep(.ai-html-record-reason) { + color: #475569; + font-size: 14px; + font-weight: 660; + line-height: 1.55; +} + +.workbench-ai-answer-markdown :deep(.ai-html-record-meta) { + min-width: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); + gap: 10px; +} + +.workbench-ai-answer-markdown :deep(.ai-html-record-meta-item) { + min-width: 0; + display: grid; + gap: 2px; +} + +.workbench-ai-answer-markdown :deep(.ai-html-record-meta-item small) { + color: #94a3b8; + font-size: 12px; + font-weight: 760; + line-height: 1.3; +} + +.workbench-ai-answer-markdown :deep(.ai-html-record-meta-item b) { + color: #334155; + font-size: 14px; + font-weight: 780; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.workbench-ai-answer-markdown :deep(.ai-html-record-action) { + justify-self: end; + display: inline-flex; + align-items: center; +} + +.workbench-ai-answer-markdown :deep(.ai-html-record-action .ai-html-action-link) { + min-height: 32px; + padding: 0 15px; + border-radius: 10px; + background: #2563eb; + color: #ffffff; + box-shadow: none; +} + +.workbench-ai-answer-markdown :deep(.ai-html-record-action .ai-html-action-link:hover) { + background: #1d4ed8; + color: #ffffff; + transform: translateY(-1px); +} + +.workbench-ai-answer-markdown :deep(.ai-html-action-link.is-disabled) { + cursor: not-allowed; + pointer-events: none; + background: rgba(100, 116, 139, 0.14); + color: #64748b; + box-shadow: none; +} + +.workbench-ai-answer-markdown :deep(.ai-html-record-action .ai-html-action-link.is-disabled:hover) { + background: rgba(100, 116, 139, 0.14); + color: #64748b; + transform: none; +} + .workbench-ai-answer-markdown :deep(.ai-html-image-frame) { margin: 0; overflow: hidden; @@ -1418,6 +1537,15 @@ } @media (max-width: 720px) { + .workbench-ai-answer-markdown :deep(.ai-html-record-item) { + grid-template-columns: 1fr; + align-items: stretch; + } + + .workbench-ai-answer-markdown :deep(.ai-html-record-action) { + justify-self: start; + } + .workbench-ai-answer-markdown :deep(.ai-document-card) { padding: 14px; } diff --git a/web/src/assets/styles/components/stage-risk-advice-card.css b/web/src/assets/styles/components/stage-risk-advice-card.css index 024481d..55bff7b 100644 --- a/web/src/assets/styles/components/stage-risk-advice-card.css +++ b/web/src/assets/styles/components/stage-risk-advice-card.css @@ -1,7 +1,7 @@ .employee-risk-profile-card { display: grid; - gap: 12px; - padding: 14px 16px; + gap: 14px; + padding: 16px 18px; } .employee-risk-head { @@ -74,17 +74,17 @@ .employee-risk-body { display: grid; - gap: 12px; + gap: 14px; } .employee-risk-decision-panel { display: grid; - grid-template-columns: minmax(0, 1.15fr) minmax(220px, .85fr); + grid-template-columns: minmax(0, 1fr) minmax(320px, .72fr); align-items: stretch; - gap: 12px; - padding: 12px; + gap: 18px; + padding: 16px 18px; border: 1px solid #e5e7eb; - border-radius: 2px; + border-radius: 4px; background: #ffffff; } @@ -101,7 +101,9 @@ .employee-risk-decision-main { min-width: 0; display: grid; - gap: 4px; + align-content: center; + gap: 8px; + padding: 4px 0; } .employee-risk-decision-main > span, @@ -117,7 +119,7 @@ .employee-risk-decision-main strong { min-width: 0; color: #0f172a; - font-size: 15px; + font-size: 16px; font-weight: 900; overflow-wrap: anywhere; } @@ -143,10 +145,10 @@ flex-direction: column; align-items: flex-start; justify-content: center; - gap: 5px; - padding: 10px 12px; + gap: 7px; + padding: 13px 15px; border: 1px solid #e5e7eb; - border-radius: 2px; + border-radius: 4px; background: #fff; } @@ -175,20 +177,21 @@ } .employee-risk-review-summary { - display: flex; - flex-wrap: wrap; - gap: 8px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; margin: 0; } .employee-risk-review-item { min-width: 0; - flex: 1 1 180px; display: grid; - gap: 4px; - padding: 9px 10px; + align-content: start; + gap: 7px; + min-height: 66px; + padding: 12px 14px; border: 1px solid #e5e7eb; - border-radius: 2px; + border-radius: 4px; background: #fff; } @@ -232,10 +235,10 @@ .employee-risk-profile-section { display: grid; - gap: 8px; - padding: 10px 12px; + gap: 12px; + padding: 14px 16px; border: 1px solid #e5e7eb; - border-radius: 2px; + border-radius: 4px; background: #fff; } @@ -264,7 +267,7 @@ .employee-risk-profile-list { display: grid; grid-template-columns: 1fr; - gap: 8px; + gap: 10px; } .employee-risk-evidence-row { @@ -272,7 +275,7 @@ display: grid; gap: 0; border: 1px solid #e2e8f0; - border-radius: 2px; + border-radius: 4px; background: #f8fafc; overflow: hidden; } @@ -301,14 +304,14 @@ } .employee-risk-evidence-title { - min-height: 40px; - display: flex; + min-height: 48px; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(72px, auto) 48px; align-items: center; - justify-content: space-between; - gap: 8px; - padding: 8px 10px; + column-gap: 14px; + padding: 10px 14px; color: #0f172a; - font-size: 11px; + font-size: 12px; font-weight: 850; } @@ -319,10 +322,11 @@ .employee-risk-evidence-title strong { height: 20px; - flex: 0 0 auto; + min-width: 48px; display: inline-grid; place-items: center; - padding: 0 6px; + justify-self: center; + padding: 0 7px; border-radius: 4px; background: #eef2f7; color: #475569; @@ -343,10 +347,11 @@ .employee-risk-evidence-title::after { content: '展开'; - flex: 0 0 auto; + justify-self: end; color: #94a3b8; font-size: 10px; font-weight: 800; + text-align: right; } .employee-risk-evidence-row[open] .employee-risk-evidence-title::after { @@ -355,9 +360,9 @@ .employee-risk-evidence-row ul { display: grid; - gap: 3px; + gap: 6px; margin: 0; - padding: 0 10px 10px 10px; + padding: 0 14px 14px 14px; list-style: none; align-content: start; border-top: 1px solid #e2e8f0; @@ -366,8 +371,8 @@ .employee-risk-evidence-row li { min-width: 0; color: #475569; - font-size: 11px; - line-height: 1.45; + font-size: 12px; + line-height: 1.58; overflow-wrap: anywhere; white-space: normal; } @@ -383,8 +388,8 @@ grid-template-columns: 1fr; } - .employee-risk-review-item { - flex-basis: 100%; + .employee-risk-review-summary { + grid-template-columns: 1fr; } .employee-risk-title-wrap, diff --git a/web/src/components/business/PersonalWorkbenchAiMode.vue b/web/src/components/business/PersonalWorkbenchAiMode.vue index 44aa21d..3246284 100644 --- a/web/src/components/business/PersonalWorkbenchAiMode.vue +++ b/web/src/components/business/PersonalWorkbenchAiMode.vue @@ -1140,8 +1140,11 @@ function canShowInlineSuggestedActions(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) + Boolean(action?.disabled) || + ( + [AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT].includes(actionType) && + isApplicationPreviewEstimatePending(message) + ) ) } @@ -1267,8 +1270,21 @@ function normalizeInlineApplicationResultTableCell(value, fallback = '-') { } function buildInlineApplicationActionDetailHref(reference = '') { - const value = String(reference || '').trim() - return value ? `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(value)}` : '' + const source = reference && typeof reference === 'object' ? reference : { reference } + const claimId = String(source.claimId || source.claim_id || source.id || '').trim() + const claimNo = String(source.claimNo || source.claim_no || source.documentNo || source.document_no || '').trim() + const fallback = String(source.reference || '').trim() + if (claimId || claimNo) { + const params = new URLSearchParams() + if (claimId) { + params.set('claim_id', claimId) + } + if (claimNo) { + params.set('claim_no', claimNo) + } + return `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(params.toString())}` + } + return fallback ? `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(fallback)}` : '' } function resolveInlineApplicationActionDocumentInfo(draftPayload = {}) { @@ -1293,7 +1309,7 @@ function resolveInlineApplicationActionDocumentInfo(draftPayload = {}) { function buildInlineApplicationResultTable(draftPayload = {}, options = {}) { const info = resolveInlineApplicationActionDocumentInfo(draftPayload) const reference = info.claimNo || info.claimId - const href = buildInlineApplicationActionDetailHref(reference) + const href = buildInlineApplicationActionDetailHref(info) const actionText = href ? `[查看](${href})` : '-' return [ '| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 操作 |', @@ -1945,22 +1961,40 @@ function parseAiApplicationDetailHref(href = '') { if (!encodedReference) { return null } + let reference = '' try { - const reference = decodeURIComponent(encodedReference).trim() - return reference ? { reference } : null + reference = decodeURIComponent(encodedReference).trim() } catch { - return { reference: encodedReference } + reference = encodedReference.trim() } + if (!reference) { + return null + } + const params = new URLSearchParams(reference) + const claimId = String(params.get('claim_id') || '').trim() + const claimNo = String(params.get('claim_no') || '').trim() + if (claimId || claimNo) { + return { + reference: claimNo || claimId, + claimId, + claimNo + } + } + return { reference } } function buildAiDocumentDetailRequest(detailReference = {}) { const reference = String(detailReference.reference || '').trim() - const isApplication = /^APP?-/i.test(reference) + const claimId = String(detailReference.claimId || detailReference.claim_id || '').trim() + const claimNo = String(detailReference.claimNo || detailReference.claim_no || '').trim() + const lookupReference = claimId || reference + const displayReference = claimNo || reference + const isApplication = /^APP?-/i.test(displayReference) || Boolean(claimId || claimNo) return { - id: reference, - claimId: reference, - claimNo: reference, - documentNo: reference, + id: lookupReference, + claimId: claimId || reference, + claimNo: claimNo || reference, + documentNo: displayReference, documentType: isApplication ? 'application' : 'reimbursement', documentTypeCode: isApplication ? 'application' : 'reimbursement', detailLookupOnly: true, @@ -2371,7 +2405,11 @@ function handleInlineSuggestedAction(action = {}, sourceMessage = null) { 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 })) + emit('open-document', buildAiDocumentDetailRequest({ + reference: claimNo || claimId, + claimId, + claimNo + })) return } if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_reimbursement') { diff --git a/web/src/components/shared/ConfirmDialog.vue b/web/src/components/shared/ConfirmDialog.vue index a2047f0..86da43c 100644 --- a/web/src/components/shared/ConfirmDialog.vue +++ b/web/src/components/shared/ConfirmDialog.vue @@ -321,6 +321,55 @@ function handleCancel() { max-height: min(420px, calc(100dvh - 292px)); } +.shared-confirm-card--destructive { + width: min(420px, calc(100vw - 40px)); + gap: 12px; + padding: 20px 22px; + border-color: rgba(var(--danger-rgb), 0.16); + border-radius: 6px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.99), rgba(248, 250, 252, 0.97)); + box-shadow: + 0 18px 42px rgba(15, 23, 42, 0.16), + 0 1px 0 rgba(255, 255, 255, 0.92) inset; +} + +.shared-confirm-card--destructive .shared-confirm-badge { + min-height: 24px; + padding: 0 9px; + font-size: 11px; + font-weight: 850; +} + +.shared-confirm-card--destructive h4 { + font-size: 19px; + line-height: 1.42; + font-weight: 850; +} + +.shared-confirm-card--destructive p { + max-width: 34em; + color: #64748b; + font-size: 13px; + line-height: 1.65; +} + +.shared-confirm-card--destructive .shared-confirm-actions { + gap: 8px; + padding-top: 2px; +} + +.shared-confirm-card--destructive .shared-confirm-btn { + min-width: 112px; + min-height: 38px; + border-radius: 6px; + font-size: 13px; +} + +.shared-confirm-card--destructive .shared-confirm-btn.confirm.danger { + box-shadow: 0 10px 20px rgba(var(--danger-rgb), 0.18); +} + .shared-confirm-card--compact h4 { font-size: 15px; line-height: 1.35; diff --git a/web/src/components/travel/TravelRequestDeleteDialog.vue b/web/src/components/travel/TravelRequestDeleteDialog.vue index 2c2321d..261805b 100644 --- a/web/src/components/travel/TravelRequestDeleteDialog.vue +++ b/web/src/components/travel/TravelRequestDeleteDialog.vue @@ -5,6 +5,8 @@ badge-tone="danger" :title="title" :description="description" + size="destructive" + actions-align="end" cancel-text="取消" confirm-text="确认删除" busy-text="删除中..." diff --git a/web/src/composables/useAppShell.js b/web/src/composables/useAppShell.js index fb25908..87c2bcf 100644 --- a/web/src/composables/useAppShell.js +++ b/web/src/composables/useAppShell.js @@ -8,6 +8,7 @@ import { useToast } from './useToast.js' import { fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail } from '../services/reimbursements.js' import { fetchOntologyParse } from '../services/ontology.js' import { fetchLatestConversation } from '../services/orchestrator.js' +import { markAiWorkbenchConversationDraftDeleted } from '../utils/aiWorkbenchConversationStore.js' import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js' import { ASSISTANT_SCOPE_SESSION_STEWARD, @@ -607,6 +608,7 @@ export function useAppShell() { const deletedClaimId = String(payload.claimId || payload.claim_id || '').trim() if (deletedClaimId) { clearAssistantSessionSnapshotForDraftClaim(resolveCurrentUserId(), deletedClaimId, SESSION_TYPE_EXPENSE) + markAiWorkbenchConversationDraftDeleted(currentUser.value || {}, payload) smartEntryInvalidatedDraftClaimId.value = deletedClaimId } diff --git a/web/src/services/aiApplicationPreviewActions.js b/web/src/services/aiApplicationPreviewActions.js index f5f35bb..3156790 100644 --- a/web/src/services/aiApplicationPreviewActions.js +++ b/web/src/services/aiApplicationPreviewActions.js @@ -1,5 +1,4 @@ import { apiRequest } from './api.js' -import { runOrchestrator } from './orchestrator.js' import { buildApplicationPreviewRows, buildApplicationPreviewSubmitText, @@ -128,19 +127,12 @@ export function buildAiApplicationPreviewActionPayload({ export function runAiApplicationPreviewAction(params = {}, options = {}) { 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: '申请草稿保存超时,请稍后重试。', + const isSubmit = params.actionType === AI_APPLICATION_ACTION_SUBMIT + return apiRequest('/reimbursements/application-preview-action', { + method: 'POST', + body: JSON.stringify(payload), + timeoutMs: isSubmit ? 45000 : 30000, + timeoutMessage: isSubmit ? '申请提交处理超时,请稍后重试。' : '申请草稿保存超时,请稍后重试。', ...options }) } diff --git a/web/src/utils/aiConversationHtmlRenderer.js b/web/src/utils/aiConversationHtmlRenderer.js index 83f5e76..88d6841 100644 --- a/web/src/utils/aiConversationHtmlRenderer.js +++ b/web/src/utils/aiConversationHtmlRenderer.js @@ -21,6 +21,7 @@ const BUSINESS_FIELD_LABELS = new Set([ ]) const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:' +const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:' const DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:' const TRUSTED_HTML_BLOCK_RE = /\s*([\s\S]*?)\s*/g const TRUSTED_HTML_PLACEHOLDER_PREFIX = 'AI_TRUSTED_HTML_BLOCK_' @@ -62,6 +63,10 @@ function isApplicationDetailHref(href = '') { return String(href || '').trim().startsWith(APPLICATION_DETAIL_HREF_PREFIX) } +function isDeletedApplicationDetailHref(href = '') { + return String(href || '').trim().startsWith(DELETED_APPLICATION_DETAIL_HREF_PREFIX) +} + function isDocumentDetailHref(href = '') { return String(href || '').trim().startsWith(DOCUMENT_DETAIL_HREF_PREFIX) } @@ -79,6 +84,17 @@ function sanitizeImageSrc(src = '') { function renderLinkHtml(label = '', href = '') { const sanitizedHref = sanitizeHref(href) + if (isDeletedApplicationDetailHref(href)) { + return [ + '', + label, + '' + ].join('') + } if (isApplicationDetailHref(href)) { return [ ` normalizedHeader.indexOf(label)) + .find((index) => index >= 0) ?? -1 +} + +function resolveTableCell(row = [], normalizedHeader = [], labels = []) { + const columnIndex = findTableColumnIndex(normalizedHeader, labels) + return columnIndex >= 0 ? String(row[columnIndex] || '').trim() : '' +} + +function hasMeaningfulTableValue(value = '') { + const text = String(value || '').trim() + return Boolean(text && text !== '-') +} + +function isDocumentRecordTable(normalizedHeader = []) { + return ( + normalizedHeader.includes('单据编号') && + normalizedHeader.includes('操作') && + normalizedHeader.some((label) => ['单据类型', '申请时间', '单据状态', '状态', '当前节点', '事由'].includes(label)) + ) +} + +function renderRecordMeta(label = '', value = '') { + if (!hasMeaningfulTableValue(value)) { + return '' + } + return [ + '', + `${escapeHtml(label)}`, + `${renderInlineHtml(value)}`, + '' + ].join('') +} + +function renderDocumentRecordList(header = [], bodyRows = []) { + const normalizedHeader = header.map((cell) => normalizeTableHeaderCell(cell)) + const items = bodyRows.map((row) => { + const documentType = resolveTableCell(row, normalizedHeader, ['单据类型']) + const documentNo = resolveTableCell(row, normalizedHeader, ['单据编号']) + const applyTime = resolveTableCell(row, normalizedHeader, ['申请时间']) + const status = resolveTableCell(row, normalizedHeader, ['单据状态', '状态']) + const stage = resolveTableCell(row, normalizedHeader, ['当前节点']) + const reason = resolveTableCell(row, normalizedHeader, ['事由']) + const action = resolveTableCell(row, normalizedHeader, ['操作']) + return [ + '
', + '
', + hasMeaningfulTableValue(documentType) ? `${renderInlineHtml(documentType)}` : '', + hasMeaningfulTableValue(documentNo) ? `${renderInlineHtml(documentNo)}` : '', + hasMeaningfulTableValue(reason) ? `

${renderInlineHtml(reason)}

` : '', + '
', + '
', + renderRecordMeta('申请时间', applyTime), + renderRecordMeta('状态', status), + renderRecordMeta('当前节点', stage), + '
', + hasMeaningfulTableValue(action) ? `
${renderInlineHtml(action)}
` : '', + '
' + ].join('') + }).filter(Boolean) + + return [ + '
', + ...items, + '
' + ].join('') +} + function renderTable(lines = []) { const rows = lines.map((line) => parseTableRow(line)).filter((row) => row.length) if (rows.length < 2) { @@ -489,6 +579,10 @@ function renderTable(lines = []) { } const header = rows[0] const bodyRows = rows.slice(2) + const normalizedHeader = header.map((cell) => normalizeTableHeaderCell(cell)) + if (isDocumentRecordTable(normalizedHeader)) { + return renderDocumentRecordList(header, bodyRows) + } return [ '
', diff --git a/web/src/utils/aiWorkbenchConversationStore.js b/web/src/utils/aiWorkbenchConversationStore.js index 753559b..77dd23d 100644 --- a/web/src/utils/aiWorkbenchConversationStore.js +++ b/web/src/utils/aiWorkbenchConversationStore.js @@ -1,11 +1,144 @@ const STORAGE_KEY_PREFIX = 'x-financial:workbench-ai-conversations' const MAX_CONVERSATION_HISTORY = 30 const MAX_STORED_MESSAGES = 80 +const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:' +const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:' +const APPLICATION_DETAIL_MARKDOWN_LINK_RE = /\[([^\]]+)\]\((#ai-open-application-detail:[^)]+)\)/g function safeString(value) { return String(value || '').trim() } +function normalizeIdentifier(value) { + return safeString(value) +} + +function collectDeletedDraftIdentifiers(payload = {}) { + return new Set([ + payload.claimId, + payload.claim_id, + payload.id, + payload.claimNo, + payload.claim_no, + payload.documentNo, + payload.document_no + ].map((item) => normalizeIdentifier(item)).filter(Boolean)) +} + +function decodeApplicationDetailHref(href = '') { + const value = safeString(href) + if (!value.startsWith(APPLICATION_DETAIL_HREF_PREFIX)) { + return [] + } + const encodedReference = value.slice(APPLICATION_DETAIL_HREF_PREFIX.length) + if (!encodedReference) { + return [] + } + + let reference = '' + try { + reference = decodeURIComponent(encodedReference).trim() + } catch { + reference = encodedReference.trim() + } + + const identifiers = new Set([reference, encodedReference].map((item) => normalizeIdentifier(item)).filter(Boolean)) + const params = new URLSearchParams(reference) + const detailParamKeys = ['claim_id', 'claim_no', 'document_no'] + detailParamKeys.forEach((key) => { + const paramValue = normalizeIdentifier(params.get(key)) + if (paramValue) { + identifiers.add(paramValue) + } + }) + return [...identifiers] +} + +function applicationDetailHrefMatchesDeletedDraft(href = '', identifiers = new Set()) { + return decodeApplicationDetailHref(href).some((item) => identifiers.has(item)) +} + +function buildDeletedApplicationDetailHref(href = '') { + const value = safeString(href) + if (!value.startsWith(APPLICATION_DETAIL_HREF_PREFIX)) { + return '' + } + return `${DELETED_APPLICATION_DETAIL_HREF_PREFIX}${value.slice(APPLICATION_DETAIL_HREF_PREFIX.length)}` +} + +function markApplicationDetailLinksDeleted(content = '', identifiers = new Set()) { + let changed = false + const nextContent = String(content || '').replace(APPLICATION_DETAIL_MARKDOWN_LINK_RE, (match, _label, href) => { + if (!applicationDetailHrefMatchesDeletedDraft(href, identifiers)) { + return match + } + const deletedHref = buildDeletedApplicationDetailHref(href) + if (!deletedHref) { + return '草稿已删除' + } + changed = true + return `[草稿已删除](${deletedHref})` + }) + return { content: nextContent, changed } +} + +function actionMatchesDeletedDraft(action = {}, identifiers = new Set()) { + const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {} + return [ + payload.claim_id, + payload.claimId, + payload.id, + payload.claim_no, + payload.claimNo, + payload.document_no, + payload.documentNo + ].map((item) => normalizeIdentifier(item)).some((item) => item && identifiers.has(item)) +} + +function markSuggestedActionsDeleted(actions = [], identifiers = new Set()) { + let changed = false + const nextActions = (Array.isArray(actions) ? actions : []).map((action) => { + if (String(action?.action_type || '').trim() !== 'open_application_detail') { + return action + } + if (!actionMatchesDeletedDraft(action, identifiers)) { + return action + } + changed = true + return { + ...action, + label: '草稿已删除', + description: '草稿单据已经删除,请重新再次申请。', + icon: 'mdi mdi-trash-can-outline', + disabled: true, + action_type: 'deleted_application_detail' + } + }) + return { actions: nextActions, changed } +} + +function buildDraftDeletedMessage(payload = {}) { + const claimNo = safeString(payload.claimNo || payload.claim_no || payload.documentNo || payload.document_no) + return { + id: `draft-deleted-${safeString(payload.claimId || payload.claim_id || payload.id || claimNo) || Date.now()}`, + role: 'assistant', + content: [ + `用户已经删除了草稿单据${claimNo ? ` ${claimNo}` : ''}。`, + '草稿单据已经删除,请重新再次申请。' + ].join('\n\n'), + feedback: '', + stewardPlan: null, + suggestedActions: [] + } +} + +function conversationHasDeletionNotice(messages = [], identifiers = new Set()) { + return messages.some((message) => { + const content = safeString(message?.content) + return content.includes('用户已经删除了草稿单据') && [...identifiers].some((item) => content.includes(item)) + }) +} + function resolveUserStorageKey(user = {}) { const identity = safeString(user.username || user.email || user.name || 'anonymous') return `${STORAGE_KEY_PREFIX}:${identity || 'anonymous'}` @@ -153,3 +286,46 @@ export function deleteAiWorkbenchConversation(user = {}, conversationId = '') { writeStoredList(user, nextList) return loadAiWorkbenchConversationHistory(user) } + +export function markAiWorkbenchConversationDraftDeleted(user = {}, payload = {}) { + const identifiers = collectDeletedDraftIdentifiers(payload) + if (!identifiers.size) { + return loadAiWorkbenchConversationHistory(user) + } + + const nextList = readStoredList(user).map((conversation) => { + const normalized = normalizeConversation(conversation) + let conversationChanged = false + const messages = normalized.messages.map((message) => { + const contentResult = markApplicationDetailLinksDeleted(message.content, identifiers) + const actionsResult = markSuggestedActionsDeleted(message.suggestedActions, identifiers) + if (!contentResult.changed && !actionsResult.changed) { + return message + } + conversationChanged = true + return { + ...message, + content: contentResult.content, + suggestedActions: actionsResult.actions + } + }) + + if (!conversationChanged) { + return normalized + } + + if (!conversationHasDeletionNotice(messages, identifiers)) { + messages.push(buildDraftDeletedMessage(payload)) + } + + return { + ...normalized, + desc: '草稿单据已经删除,请重新再次申请。', + messages, + updatedAt: Date.now() + } + }) + + writeStoredList(user, nextList) + return loadAiWorkbenchConversationHistory(user) +} diff --git a/web/src/views/AppShellRouteView.vue b/web/src/views/AppShellRouteView.vue index f3b55a5..53bed1b 100644 --- a/web/src/views/AppShellRouteView.vue +++ b/web/src/views/AppShellRouteView.vue @@ -154,7 +154,7 @@ @back-to-requests="closeRequestDetail" @open-assistant="openSmartEntry" @request-updated="handleRequestUpdated" - @request-deleted="handleRequestDeleted" + @request-deleted="handleDetailRequestDeleted" />
canManageExpenseClaims(currentUser.value)) const isArchivedRequest = computed(() => isArchivedRequestView(request.value)) - const canDeleteRequest = computed(() => isPlatformAdminUser(currentUser.value)) + const isApplicantDeletableRequest = computed(() => { + if (!isCurrentApplicant.value) { + return false + } + const status = String(request.value.status || request.value.approvalKey || '').trim().toLowerCase() + return ['draft', 'supplement', 'returned'].includes(status) + }) + const canDeleteRequest = computed(() => { + if (isPlatformAdminUser(currentUser.value)) { + return true + } + return isApplicantDeletableRequest.value + }) const isDirectManagerApprovalStage = computed(() => { const node = String(request.value.node || request.value.approvalStage || '').trim() return node === '直属领导审批' @@ -926,11 +938,12 @@ export default { } return isDraftRequest.value ? '删除草稿' : '删除单据' }) - const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`) + const deleteDialogTarget = computed(() => request.value.documentNo || request.value.id || '当前单据') + const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value}吗?`) const deleteDialogDescription = computed(() => isDraftRequest.value - ? '删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。' - : `删除后该${isApplicationDocument.value ? '申请单' : '报销单'}及费用明细将不可恢复,请确认本次操作。` + ? `${deleteDialogTarget.value} 删除后,该草稿及其当前费用明细将不可恢复。` + : `${deleteDialogTarget.value} 删除后,该${isApplicationDocument.value ? '申请单' : '报销单'}及费用明细将不可恢复。` ) const actionBusy = computed(() => Boolean(savingExpenseId.value) @@ -2514,8 +2527,8 @@ export default { isArchivedRequest.value ? '已归档单据不能删除,只有高级管理员可以执行删除。' : isApplicationDocument.value - ? '当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。' - : '当前单据已进入流程,只有高级财务人员可以删除。' + ? '当前申请单已进入审批流程,只有草稿、待补充或退回待提交阶段的申请人本人或系统管理员可以删除。' + : '当前单据已进入流程,只有草稿、待补充或退回待提交阶段的申请人本人或系统管理员可以删除。' ) return } @@ -2542,7 +2555,11 @@ export default { const payload = await deleteExpenseClaim(request.value.claimId) deleteDialogOpen.value = false toast(payload?.message || `${request.value.id} ${isApplicationDocument.value ? '申请单' : '报销单'}已删除。`) - emit('request-deleted', { claimId: request.value.claimId }) + emit('request-deleted', { + claimId: request.value.claimId, + claimNo: request.value.claimNo || request.value.documentNo || request.value.id, + documentNo: request.value.documentNo || request.value.id + }) } catch (error) { toast(error?.message || '删除单据失败,请稍后重试。') } finally { diff --git a/web/tests/ai-application-preview-actions.test.mjs b/web/tests/ai-application-preview-actions.test.mjs index 29f5461..4094729 100644 --- a/web/tests/ai-application-preview-actions.test.mjs +++ b/web/tests/ai-application-preview-actions.test.mjs @@ -56,15 +56,27 @@ async function testSubmitActionUsesFastPreviewEndpoint() { assert.equal(body.context_json.application_preview.fields.transportMode, '火车') } -async function testSaveDraftActionKeepsOrchestratorPath() { +async function testSaveDraftActionUsesFastPreviewEndpoint() { let capturedUrl = '' + let capturedOptions = null - global.fetch = async (url) => { + global.fetch = async (url, options) => { capturedUrl = String(url) + capturedOptions = options return { ok: true, async json() { - return { status: 'succeeded', result: {} } + return { + status: 'succeeded', + result: { + draft_payload: { + claim_id: 'claim-fast-draft', + claim_no: 'AP-20260620-DRAFT', + status: 'draft', + approval_stage: '待提交' + } + } + } } } } @@ -75,12 +87,17 @@ async function testSaveDraftActionKeepsOrchestratorPath() { currentUser: { username: 'zhangsan@example.com', name: '张三' } }) - assert.equal(capturedUrl, '/api/v1/orchestrator/run') + 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.application_action, 'save_draft') + assert.equal(body.context_json.application_save_mode, true) + assert.equal(body.context_json.application_stage, 'expense_application') } async function run() { await testSubmitActionUsesFastPreviewEndpoint() - await testSaveDraftActionKeepsOrchestratorPath() + await testSaveDraftActionUsesFastPreviewEndpoint() console.log('ai-application-preview-actions tests passed') } diff --git a/web/tests/ai-conversation-html-renderer.test.mjs b/web/tests/ai-conversation-html-renderer.test.mjs index 5a1cadd..4d66720 100644 --- a/web/tests/ai-conversation-html-renderer.test.mjs +++ b/web/tests/ai-conversation-html-renderer.test.mjs @@ -47,17 +47,49 @@ test('AI conversation renderer supports tables and escapes unsafe HTML', () => { test('AI conversation renderer renders application detail action links as buttons', () => { const rendered = renderAiConversationHtml([ - '| 单据编号 | 操作 |', - '| --- | --- |', - '| AP-OVERLAP | [查看](#ai-open-application-detail:AP-OVERLAP) |' + '| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 操作 |', + '| --- | --- | --- | --- | --- |', + '| 出差申请 | AP-OVERLAP | 草稿 | 待提交 | [查看](#ai-open-application-detail:AP-OVERLAP) |' ].join('\n')) + assert.match(rendered, /
/) + assert.match(rendered, /
/) + assert.match(rendered, /AP-OVERLAP<\/strong>/) assert.match(rendered, /class="ai-html-action-link ai-html-action-link-application"/) assert.match(rendered, /data-ai-action="open-application-detail"/) assert.match(rendered, /href="#ai-open-application-detail:AP-OVERLAP"/) + assert.doesNotMatch(rendered, //) assert.doesNotMatch(rendered, /target="_blank"[\s\S]{0,120}#ai-open-application-detail/) }) +test('AI conversation renderer renders deleted application detail actions as disabled buttons', () => { + const rendered = renderAiConversationHtml([ + '| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 操作 |', + '| --- | --- | --- | --- | --- |', + '| 出差申请 | AP-20260620-DRAFT | 已删除 | 已删除 | [草稿已删除](#ai-deleted-application-detail:claim-draft-1) |' + ].join('\n')) + + assert.match(rendered, /class="ai-html-action-link ai-html-action-link-application is-disabled"/) + assert.match(rendered, /aria-disabled="true"/) + assert.match(rendered, /data-ai-action="deleted-application-detail"/) + assert.doesNotMatch(rendered, /href="#ai-deleted-application-detail/) +}) + +test('AI conversation renderer turns application conflict tables into record lists', () => { + const rendered = renderAiConversationHtml([ + '| 单据编号 | 申请时间 | 状态 | 事由 | 操作 |', + '| --- | --- | --- | --- | --- |', + '| AP-20260620063557-4JU2MWEF | 2026-02-20 至 2026-02-23 | 审批中 | 辅助国网仿生产服务器部署 | [查看](#ai-open-application-detail:AP-20260620063557-4JU2MWEF) |' + ].join('\n')) + + assert.match(rendered, /
/) + assert.match(rendered, /申请时间/) + assert.match(rendered, /2026-02-20 至 2026-02-23/) + assert.match(rendered, /辅助国网仿生产服务器部署/) + assert.match(rendered, /
/) + assert.doesNotMatch(rendered, /
/) +}) + test('AI conversation renderer renders document detail action links as buttons', () => { const rendered = renderAiConversationHtml('[查看单据](#ai-open-document-detail:CL-20260221001)') diff --git a/web/tests/assistant-session-draft-delete.test.mjs b/web/tests/assistant-session-draft-delete.test.mjs index ae0ac30..400edd6 100644 --- a/web/tests/assistant-session-draft-delete.test.mjs +++ b/web/tests/assistant-session-draft-delete.test.mjs @@ -3,6 +3,11 @@ import { readFileSync } from 'node:fs' import test from 'node:test' import { fileURLToPath } from 'node:url' +import { + markAiWorkbenchConversationDraftDeleted, + loadAiWorkbenchConversationHistory, + saveAiWorkbenchConversation +} from '../src/utils/aiWorkbenchConversationStore.js' import { clearAssistantSessionSnapshotForDraftClaim, readAssistantSessionSnapshot, @@ -79,6 +84,42 @@ test('claim delete flow invalidates the matching financial assistant session', ( assert.match(createViewScript, /toast\('该草稿单据已删除,相关财务助手会话已清空。'\)/) }) +test('deleting an application draft marks AI workbench detail links as unavailable', () => { + installWindowStub() + const user = { username: 'zhangsan@example.com' } + + saveAiWorkbenchConversation(user, { + id: 'conversation-application-draft', + title: '申请草稿', + messages: [ + { + id: 'assistant-draft-saved', + role: 'assistant', + content: [ + '### 申请草稿已保存', + '', + '| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 操作 |', + '| --- | --- | --- | --- | --- |', + '| 出差申请 | AP-20260620-DRAFT | 草稿 | 待提交 | [查看](#ai-open-application-detail:claim_id%3Dclaim-draft-1%26claim_no%3DAP-20260620-DRAFT) |' + ].join('\n') + } + ] + }) + + const nextHistory = markAiWorkbenchConversationDraftDeleted(user, { + claimId: 'claim-draft-1', + claimNo: 'AP-20260620-DRAFT' + }) + const conversation = nextHistory.find((item) => item.id === 'conversation-application-draft') + + assert.ok(conversation) + assert.match(conversation.messages[0].content, /#ai-deleted-application-detail:/) + assert.doesNotMatch(conversation.messages[0].content, /#ai-open-application-detail:/) + assert.match(conversation.messages.at(-1).content, /用户已经删除了草稿单据 AP-20260620-DRAFT/) + assert.match(conversation.messages.at(-1).content, /草稿单据已经删除,请重新再次申请。/) + assert.equal(loadAiWorkbenchConversationHistory(user)[0].messages.length, 2) +}) + test('saving a draft keeps the financial assistant open for continued work', () => { const appShellScript = readFileSync( fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)), diff --git a/web/tests/travel-request-detail-risk-advice.test.mjs b/web/tests/travel-request-detail-risk-advice.test.mjs index 79b6332..9cd9ae1 100644 --- a/web/tests/travel-request-detail-risk-advice.test.mjs +++ b/web/tests/travel-request-detail-risk-advice.test.mjs @@ -322,11 +322,12 @@ test('stage risk advice card focuses on document risks without profile or budget assert.match(stageRiskAdviceCard, /stripEmbeddedExplanationText/) assert.match(stageRiskAdviceCard, /if \(summary\) \{[\s\S]*return \[`已补充异常说明:\$\{summary\}`\]/) assert.match(stageRiskAdviceCard, /employee-risk-tone-pill/) - assert.match(stageRiskAdviceStyles, /\.employee-risk-decision-panel \{[\s\S]*grid-template-columns: minmax\(0, 1\.15fr\) minmax\(220px, \.85fr\);/) - assert.match(stageRiskAdviceStyles, /\.employee-risk-review-summary \{[\s\S]*display: flex;[\s\S]*flex-wrap: wrap;/) - assert.match(stageRiskAdviceStyles, /\.employee-risk-review-item \{[\s\S]*flex: 1 1 180px;/) + assert.match(stageRiskAdviceStyles, /\.employee-risk-decision-panel \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) minmax\(320px, \.72fr\);/) + assert.match(stageRiskAdviceStyles, /\.employee-risk-review-summary \{[\s\S]*display: grid;[\s\S]*grid-template-columns: repeat\(3, minmax\(0, 1fr\)\);/) + assert.match(stageRiskAdviceStyles, /\.employee-risk-review-item \{[\s\S]*min-height: 66px;/) assert.match(stageRiskAdviceStyles, /\.employee-risk-profile-list \{[\s\S]*grid-template-columns: 1fr;/) assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-row summary \{[\s\S]*cursor: pointer;/) + assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-title \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) minmax\(72px, auto\) 48px;/) assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-title::after \{[\s\S]*content: '展开';/) assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-row li \{[\s\S]*white-space: normal;/) assert.doesNotMatch(stageRiskAdviceStyles, /grid-row: span 2/) diff --git a/web/tests/travel-request-detail-submit-confirm.test.mjs b/web/tests/travel-request-detail-submit-confirm.test.mjs index aa3ca24..86c8492 100644 --- a/web/tests/travel-request-detail-submit-confirm.test.mjs +++ b/web/tests/travel-request-detail-submit-confirm.test.mjs @@ -19,6 +19,10 @@ const confirmDialogComponent = readFileSync( fileURLToPath(new URL('../src/components/shared/ConfirmDialog.vue', import.meta.url)), 'utf8' ) +const deleteDialogComponent = readFileSync( + fileURLToPath(new URL('../src/components/travel/TravelRequestDeleteDialog.vue', import.meta.url)), + 'utf8' +) function extractFunction(source, name) { let signatureIndex = source.indexOf(`function ${name}(`) @@ -138,6 +142,17 @@ test('submit confirm dialog is constrained for laptop viewport height', () => { assert.match(confirmDialogComponent, /@media \(max-width: 720px\) \{[\s\S]*max-height: calc\(100dvh - 28px\)/) }) +test('delete request dialog uses a compact destructive confirmation layout', () => { + assert.match(deleteDialogComponent, /size="destructive"/) + assert.match(deleteDialogComponent, /actions-align="end"/) + assert.match(detailViewScript, /const deleteDialogTarget = computed\(\(\) => request\.value\.documentNo \|\| request\.value\.id \|\| '当前单据'\)/) + assert.match(detailViewScript, /const deleteDialogTitle = computed\(\(\) => `确认\$\{deleteActionLabel\.value\}吗?`\)/) + assert.doesNotMatch(detailViewScript, /const deleteDialogTitle = computed\(\(\) => `确认\$\{deleteActionLabel\.value\} \$\{request\.value\.id\} 吗?`\)/) + assert.match(confirmDialogComponent, /\.shared-confirm-card--destructive \{[\s\S]*width: min\(420px, calc\(100vw - 40px\)\);/) + assert.match(confirmDialogComponent, /\.shared-confirm-card--destructive h4 \{[\s\S]*font-size: 19px;/) + assert.match(confirmDialogComponent, /\.shared-confirm-card--destructive \.shared-confirm-btn \{[\s\S]*min-width: 112px;[\s\S]*min-height: 38px;/) +}) + test('detail header and fallback progress use reimbursement wording', () => { assert.match(detailViewScript, /label:\s*'单据申请日期'/) assert.match(detailExpenseModelScript, /label:\s*'关联单据'/) @@ -145,15 +160,24 @@ test('detail header and fallback progress use reimbursement wording', () => { assert.doesNotMatch(detailViewScript, /label:\s*'保存草稿'/) }) -test('detail delete action is gated by admin-only permission', () => { - assert.match(detailViewScript, /const canDeleteRequest = computed\(\(\) => isPlatformAdminUser\(currentUser\.value\)\)/) +test('detail delete action allows admins or the applicant while the request is editable', () => { + assert.match(detailViewScript, /const canDeleteRequest = computed\(\(\) => \{/) + assert.match(detailViewScript, /if \(isPlatformAdminUser\(currentUser\.value\)\) \{[\s\S]*return true/) + assert.match(detailViewScript, /return isApplicantDeletableRequest\.value/) + assert.match(detailViewScript, /const isApplicantDeletableRequest = computed\(\(\) => \{/) + assert.match(detailViewScript, /isCurrentApplicant\.value/) + assert.match(detailViewScript, /\['draft', 'supplement', 'returned'\]\.includes\(status\)/) assert.match(detailViewTemplate, /v-else-if="canReturnRequest \|\| canApproveRequest \|\| canPayRequest \|\| canDeleteRequest"/) assert.doesNotMatch(detailViewTemplate, /v-if="canManageCurrentClaim"/) }) -test('detail delete action does not allow applicant or claim manager fallback', () => { - assert.doesNotMatch(detailViewScript, /const canDeleteRequest = computed\(\(\) => \{[\s\S]*isCurrentApplicant[\s\S]*\}\)/) - assert.doesNotMatch(detailViewScript, /const canDeleteRequest = computed\(\(\) => \{[\s\S]*canManageCurrentClaim[\s\S]*\}\)/) +test('detail delete action does not allow in-progress applicant or claim manager fallback', () => { + const canDeleteStart = detailViewScript.indexOf('const canDeleteRequest = computed') + const canDeleteEnd = detailViewScript.indexOf('\n const isDirectManagerApprovalStage', canDeleteStart) + assert.ok(canDeleteStart >= 0) + assert.ok(canDeleteEnd > canDeleteStart) + const canDeleteBlock = detailViewScript.slice(canDeleteStart, canDeleteEnd) + assert.doesNotMatch(canDeleteBlock, /canManageCurrentClaim/) assert.match(detailViewScript, /if \(isApplicationDocument\.value\) {\s*return '删除申请'\s*}/) - assert.match(detailViewScript, /当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。/) + assert.match(detailViewScript, /当前申请单已进入审批流程,只有草稿、待补充或退回待提交阶段的申请人本人或系统管理员可以删除。/) }) 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 e984a04..6eee94e 100644 --- a/web/tests/workbench-ai-mode-expense-scene-action.test.mjs +++ b/web/tests/workbench-ai-mode-expense-scene-action.test.mjs @@ -181,6 +181,9 @@ test('AI mode formats saved application draft as a detail table without continui assert.match(aiMode, /function buildInlineApplicationResultTable\(draftPayload = \{\}, options = \{\}\)/) assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 操作 \|/) assert.match(aiMode, /\[查看\]\(\$\{href\}\)/) + assert.match(aiMode, /buildInlineApplicationActionDetailHref\(info\)/) + assert.match(aiMode, /params\.set\('claim_id', claimId\)/) + assert.match(aiMode, /params\.set\('claim_no', claimNo\)/) const resultStart = aiMode.indexOf('function buildInlineApplicationPreviewActionResultText') const resultEnd = aiMode.indexOf('\nfunction buildInlineApplicationDetailAction', resultStart) diff --git a/web/tests/workbench-ai-mode-switch.test.mjs b/web/tests/workbench-ai-mode-switch.test.mjs index 0ca5370..f99cb1c 100644 --- a/web/tests/workbench-ai-mode-switch.test.mjs +++ b/web/tests/workbench-ai-mode-switch.test.mjs @@ -267,6 +267,18 @@ test('AI mode screen follows the approved reference structure', () => { assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__meta\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-action-link\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-table-wrap\)/) + assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-record-list\)/) + assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-record-item\)/) + assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-record-meta\)/) + assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-record-action \.ai-html-action-link\)/) + assert.match( + aiModeStyles, + /\.workbench-ai-answer-markdown :deep\(\.ai-html-record-item\)\s*\{[\s\S]*grid-template-columns:\s*minmax\(220px,\s*1\.15fr\)\s*minmax\(260px,\s*0\.85fr\)\s*auto;/ + ) + assert.match( + aiModeStyles, + /\.workbench-ai-answer-markdown :deep\(\.ai-html-record-action \.ai-html-action-link\)[\s\S]*background:\s*#2563eb;/ + ) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-image-frame\)/) assert.match(aiMode, /import \{ fetchSettings \} from '\.\.\/\.\.\/services\/settings\.js'/) assert.match(aiMode, /import \{ fetchStewardPlan, fetchStewardPlanStream \} from '\.\.\/\.\.\/services\/steward\.js'/) diff --git a/web/tests/workbench-detail-return.test.mjs b/web/tests/workbench-detail-return.test.mjs index 1d46220..5391f08 100644 --- a/web/tests/workbench-detail-return.test.mjs +++ b/web/tests/workbench-detail-return.test.mjs @@ -37,6 +37,10 @@ test('workbench document detail keeps workbench as the return target', () => { test('AI detail links wait for full document detail instead of rendering a half snapshot', () => { assert.match(aiMode, /detailLookupOnly:\s*true/) + assert.match(aiMode, /params\.get\('claim_id'\)/) + assert.match(aiMode, /params\.get\('claim_no'\)/) + assert.match(aiMode, /claimId:\s*claimId \|\| reference/) + assert.match(aiMode, /claimNo:\s*claimNo \|\| reference/) assert.match( appShell, /v-else-if="activeView === 'documents' && detailMode && !selectedRequest"[\s\S]*正在加载完整单据详情/