diff --git a/server/rules/finance-rules/公司通信费报销规则.xlsx b/server/rules/finance-rules/公司通信费报销规则.xlsx index f7aeef7..cb89ac0 100644 Binary files a/server/rules/finance-rules/公司通信费报销规则.xlsx and b/server/rules/finance-rules/公司通信费报销规则.xlsx differ diff --git a/web/src/assets/ai-document-card-bg.png b/web/src/assets/ai-document-card-bg.png index 74a7e0c..781a19e 100644 Binary files a/web/src/assets/ai-document-card-bg.png and b/web/src/assets/ai-document-card-bg.png differ 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 3142704..37f7295 100644 --- a/web/src/assets/styles/components/personal-workbench-ai-mode.css +++ b/web/src/assets/styles/components/personal-workbench-ai-mode.css @@ -1151,27 +1151,34 @@ } .workbench-ai-answer-markdown :deep(.ai-document-card) { + --ai-document-card-head-bg: rgba(37, 99, 235, 0.075); position: relative; display: grid; gap: 0; overflow: hidden; padding: 0; - border: 1px solid rgba(203, 213, 225, 0.76); - border-left: 0; - border-radius: 12px; - background: rgba(255, 255, 255, 0.96); + border: 0; + border-radius: 14px; + background-color: #ffffff; + background-image: + linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.9)), + url("../../ai-document-card-bg.png"); + background-position: center; + background-size: cover; box-shadow: + inset 0 0 0 1px rgba(203, 213, 225, 0.5), 0 1px 2px rgba(15, 23, 42, 0.035), - 0 10px 26px rgba(15, 23, 42, 0.045); + 0 14px 34px rgba(15, 23, 42, 0.05); color: #334155; animation: workbenchDocumentCardReveal 360ms cubic-bezier(0.2, 0.8, 0.2, 1) both; - transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease; + transition: box-shadow 180ms ease, transform 180ms ease; } .workbench-ai-answer-markdown :deep(.ai-document-card:hover) { - border-color: rgba(148, 163, 184, 0.72); - background: #ffffff; - box-shadow: 0 8px 22px rgba(15, 23, 42, 0.065); + box-shadow: + inset 0 0 0 1px rgba(148, 163, 184, 0.46), + 0 1px 2px rgba(15, 23, 42, 0.04), + 0 18px 38px rgba(15, 23, 42, 0.07); transform: translateY(-1px); } @@ -1194,8 +1201,8 @@ justify-content: space-between; gap: 16px; min-width: 0; - padding: 13px 18px; - background: rgba(37, 99, 235, 0.11); + padding: 13px 18px 13px 20px; + background: var(--ai-document-card-head-bg); } .workbench-ai-answer-markdown :deep(.ai-document-card__status) { @@ -1213,15 +1220,15 @@ } .workbench-ai-answer-markdown :deep(.ai-document-card.is-success .ai-document-card__head) { - background: rgba(22, 163, 74, 0.1); + background: rgba(22, 163, 74, 0.08); } .workbench-ai-answer-markdown :deep(.ai-document-card.is-warning .ai-document-card__head) { - background: rgba(217, 119, 6, 0.12); + background: rgba(217, 119, 6, 0.09); } .workbench-ai-answer-markdown :deep(.ai-document-card.is-danger .ai-document-card__head) { - background: rgba(220, 38, 38, 0.1); + background: rgba(220, 38, 38, 0.08); } .workbench-ai-answer-markdown :deep(.ai-document-card.is-pending .ai-document-card__status) { @@ -1242,9 +1249,9 @@ .workbench-ai-answer-markdown :deep(.ai-document-card__body) { display: grid; - gap: 14px; + gap: 15px; min-width: 0; - padding: 16px 18px 18px; + padding: 15px 18px 18px; } .workbench-ai-answer-markdown :deep(.ai-document-card__reason) { @@ -1271,12 +1278,68 @@ color: #991b1b; } +.workbench-ai-answer-markdown :deep(.ai-document-card--application) { + --ai-document-card-head-bg: rgba(37, 99, 235, 0.075); +} + +.workbench-ai-answer-markdown :deep(.ai-document-card--application .ai-document-card__head) { + background: var(--ai-document-card-head-bg); +} + +.workbench-ai-answer-markdown :deep(.ai-document-card--application .ai-document-card__reason) { + color: #1e40af; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card--reimbursement) { + --ai-document-card-head-bg: rgba(13, 148, 136, 0.075); +} + +.workbench-ai-answer-markdown :deep(.ai-document-card--reimbursement .ai-document-card__head) { + background: var(--ai-document-card-head-bg); +} + +.workbench-ai-answer-markdown :deep(.ai-document-card--reimbursement .ai-document-card__reason) { + color: #0f766e; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card--approval-task) { + --ai-document-card-head-bg: rgba(245, 158, 11, 0.1); + box-shadow: + inset 0 0 0 1px rgba(245, 158, 11, 0.18), + 0 1px 2px rgba(120, 53, 15, 0.04), + 0 14px 34px rgba(120, 53, 15, 0.06); +} + +.workbench-ai-answer-markdown :deep(.ai-document-card--approval-task .ai-document-card__head) { + background: var(--ai-document-card-head-bg); +} + +.workbench-ai-answer-markdown :deep(.ai-document-card--approval-task .ai-document-card__reason) { + color: #92400e; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card--approval-task .ai-document-card__status) { + min-height: 26px; + padding: 0 10px; + border-radius: 999px; + background: rgba(245, 158, 11, 0.18); + color: #b45309; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card__summary), .workbench-ai-answer-markdown :deep(.ai-document-card__details) { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 28px; - padding-top: 2px; - border-top: 1px solid rgba(203, 213, 225, 0.76); +} + +.workbench-ai-answer-markdown :deep(.ai-document-card__summary) { + padding-bottom: 14px; + border-bottom: 1px solid rgba(203, 213, 225, 0.76); +} + +.workbench-ai-answer-markdown :deep(.ai-document-card__details) { + padding-top: 1px; } .workbench-ai-answer-markdown :deep(.ai-document-card__field) { @@ -1288,7 +1351,11 @@ } .workbench-ai-answer-markdown :deep(.ai-document-card__field--action) { - grid-column: 1 / -1; + align-items: center; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card__field--wide) { + align-items: flex-start; } .workbench-ai-answer-markdown :deep(.ai-document-card__label) { @@ -1373,109 +1440,6 @@ 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; @@ -1484,12 +1448,6 @@ 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; @@ -1547,6 +1505,29 @@ color: #1e40af; } +.workbench-ai-answer-markdown :deep(.ai-document-card__field--action .ai-document-card__action) { + min-height: 26px; + padding: 0; + border-radius: 0; + background: transparent; + color: #1d4ed8; + font-size: 14px; + font-weight: 820; + box-shadow: none; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card__field--action .ai-document-card__action:hover) { + background: transparent; + color: #1e40af; + text-decoration: underline; + transform: none; +} + +.workbench-ai-answer-markdown :deep(.ai-document-card__field--action .ai-document-card__action.is-disabled) { + background: transparent; + color: #64748b; +} + @keyframes workbenchDocumentCardReveal { from { opacity: 0; @@ -1566,15 +1547,6 @@ } @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: 0; } @@ -1595,15 +1567,17 @@ gap: 10px; } + .workbench-ai-answer-markdown :deep(.ai-document-card__summary) { + grid-template-columns: 1fr; + gap: 10px; + padding-bottom: 12px; + } + .workbench-ai-answer-markdown :deep(.ai-document-card__field) { grid-template-columns: 76px minmax(0, 1fr); gap: 10px; } - .workbench-ai-answer-markdown :deep(.ai-document-card__field--action) { - grid-column: auto; - } - .workbench-ai-answer-markdown :deep(.ai-document-card__number) { text-align: left; } diff --git a/web/src/components/business/PersonalWorkbenchAiMode.vue b/web/src/components/business/PersonalWorkbenchAiMode.vue index 3246284..d578f16 100644 --- a/web/src/components/business/PersonalWorkbenchAiMode.vue +++ b/web/src/components/business/PersonalWorkbenchAiMode.vue @@ -727,8 +727,15 @@ import { buildAiDocumentQueryConditionSummary, buildAiDocumentQueryMessage, filterAiDocumentQueryRecords, + mergeAiDocumentQueryPayloads, resolveAiDocumentQueryIntent } from '../../utils/aiDocumentQueryModel.js' +import { + AI_APPLICATION_DETAIL_HREF_PREFIX, + buildAiDocumentDetailRequest, + parseAiApplicationDetailHref, + parseAiDocumentDetailHref +} from '../../utils/aiDocumentDetailReference.js' import { buildRequiredApplicationActions, buildRequiredApplicationMissingText, @@ -780,9 +787,6 @@ const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6 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, resolveApplicationPreviewEditorControl, @@ -1269,6 +1273,27 @@ function normalizeInlineApplicationResultTableCell(value, fallback = '-') { return text || fallback } +const INLINE_APPLICATION_STATUS_LABELS = { + draft: '草稿', + submitted: '审批中', + pending: '待处理', + approved: '已审批', + completed: '已完成', + archived: '已归档', + returned: '已退回', + rejected: '已驳回', + pending_payment: '待付款', + paid: '已付款' +} + +function normalizeInlineApplicationStatusLabel(value, fallback = '') { + const text = String(value || '').trim() + if (!text) { + return fallback + } + return INLINE_APPLICATION_STATUS_LABELS[text.toLowerCase()] || text +} + function buildInlineApplicationActionDetailHref(reference = '') { const source = reference && typeof reference === 'object' ? reference : { reference } const claimId = String(source.claimId || source.claim_id || source.id || '').trim() @@ -1289,11 +1314,63 @@ function buildInlineApplicationActionDetailHref(reference = '') { function resolveInlineApplicationActionDocumentInfo(draftPayload = {}) { const source = draftPayload && typeof draftPayload === 'object' ? draftPayload : {} + const body = String(source.body || source.markdown || '').trim() + const resolveBodyField = (labels = []) => { + for (const label of labels) { + const pattern = new RegExp(`${label}\\s*[::]\\s*([^\\n|]+)`, 'u') + const match = body.match(pattern) + if (match?.[1]) { + return String(match[1]).replace(/\*\*/g, '').trim() + } + } + return '' + } + const startDate = String(source.start_date || source.startDate || source.trip_start_date || source.tripStartDate || '').trim() + const endDate = String(source.end_date || source.endDate || source.trip_end_date || source.tripEndDate || '').trim() + const dateText = String( + source.business_time || + source.businessTime || + source.time || + source.occurred_at || + source.occurredAt || + source.apply_time || + source.applyTime || + '' + ).trim() + const rangeText = startDate && endDate && startDate !== endDate + ? `${startDate} 至 ${endDate}` + : startDate || endDate 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(), + statusLabel: normalizeInlineApplicationStatusLabel(source.status_label || source.statusLabel || source.status), approvalStage: String(source.approval_stage || source.approvalStage || '').trim(), + dateLabel: rangeText || dateText || resolveBodyField(['时间', '日期', '申请时间']) || '待补充', + locationLabel: String( + source.location || + source.application_location || + source.applicationLocation || + source.destination || + source.destination_city || + source.destinationCity || + '' + ).trim() || resolveBodyField(['地点', '目的地']) || '待补充', + reasonLabel: String( + source.reason || + source.business_reason || + source.businessReason || + source.description || + source.title || + '' + ).trim() || resolveBodyField(['事由', '事件', '申请事由']) || '待补充', + amountLabel: String( + source.amount || + source.application_amount || + source.applicationAmount || + source.estimated_amount || + source.estimatedAmount || + '' + ).trim() || resolveBodyField(['金额', '预计金额', '申请金额']) || '-', documentTypeLabel: String( source.document_type_label || source.documentTypeLabel || @@ -1311,10 +1388,11 @@ function buildInlineApplicationResultTable(draftPayload = {}, options = {}) { const reference = info.claimNo || info.claimId const href = buildInlineApplicationActionDetailHref(info) const actionText = href ? `[查看](${href})` : '-' + const statusLabel = normalizeInlineApplicationStatusLabel(info.statusLabel, options.statusLabel) return [ - '| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 操作 |', - '| --- | --- | --- | --- | --- |', - `| ${normalizeInlineApplicationResultTableCell(info.documentTypeLabel || options.documentTypeLabel, '出差申请')} | ${normalizeInlineApplicationResultTableCell(reference)} | ${normalizeInlineApplicationResultTableCell(info.statusLabel || options.statusLabel)} | ${normalizeInlineApplicationResultTableCell(info.approvalStage || options.stageLabel)} | ${actionText} |` + '| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 日期 | 地点 | 事由 | 金额 | 操作 |', + '| --- | --- | --- | --- | --- | --- | --- | --- | --- |', + `| ${normalizeInlineApplicationResultTableCell(info.documentTypeLabel || options.documentTypeLabel, '出差申请')} | ${normalizeInlineApplicationResultTableCell(reference)} | ${normalizeInlineApplicationResultTableCell(statusLabel)} | ${normalizeInlineApplicationResultTableCell(info.approvalStage || options.stageLabel)} | ${normalizeInlineApplicationResultTableCell(info.dateLabel)} | ${normalizeInlineApplicationResultTableCell(info.locationLabel)} | ${normalizeInlineApplicationResultTableCell(info.reasonLabel)} | ${normalizeInlineApplicationResultTableCell(info.amountLabel, '-')} | ${actionText} |` ].join('\n') } @@ -1331,7 +1409,7 @@ function buildInlineApplicationPreviewActionResultText(actionType, payload = {}) stageLabel: approvalStage || '直属领导审批', documentTypeLabel: '出差申请' }), - '需要查看完整详情时,请点击列表最后一列的“查看”进入单据详情。' + '需要查看完整详情时,请点击卡片“操作”行的“查看”进入单据详情。' ].filter(Boolean).join('\n\n') } return [ @@ -1342,7 +1420,7 @@ function buildInlineApplicationPreviewActionResultText(actionType, payload = {}) stageLabel: '待提交', documentTypeLabel: '出差申请' }), - '后续请点击表格最后一列的“查看”进入详情页继续核对。' + '后续请点击卡片“操作”行的“查看”进入详情页继续核对。' ].filter(Boolean).join('\n\n') } @@ -1935,74 +2013,6 @@ async function fetchInlineStewardPlan(messageId, payload) { } } -function parseAiDocumentDetailHref(href = '') { - const value = String(href || '').trim() - if (!value.startsWith(AI_DOCUMENT_DETAIL_HREF_PREFIX)) { - return null - } - const encodedReference = value.slice(AI_DOCUMENT_DETAIL_HREF_PREFIX.length) - if (!encodedReference) { - return null - } - try { - const reference = decodeURIComponent(encodedReference).trim() - return reference ? { reference } : null - } catch { - return { reference: encodedReference } - } -} - -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 - } - let reference = '' - try { - reference = decodeURIComponent(encodedReference).trim() - } catch { - 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 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: lookupReference, - claimId: claimId || reference, - claimNo: claimNo || reference, - documentNo: displayReference, - documentType: isApplication ? 'application' : 'reimbursement', - documentTypeCode: isApplication ? 'application' : 'reimbursement', - detailLookupOnly: true, - source: 'workbench', - returnTo: 'workbench' - } -} - function handleAiAnswerMarkdownClick(event) { const target = event?.target const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]') @@ -2055,6 +2065,47 @@ function failAiDocumentQueryEvents(events) { })) } +function resolveAiDocumentQueryFetchPendingText(intent = {}) { + if (intent.source === 'approval') { + return '等待调用待我审核单据接口。' + } + if (intent.source === 'mine') { + return '等待调用我名下单据接口。' + } + return '等待同时调用我名下单据和待我审核单据接口。' +} + +function resolveAiDocumentQueryFetchRunningText(intent = {}) { + if (intent.source === 'approval') { + return '正在查询待我审核的单据,接口范围为待办/待审单据列表。' + } + if (intent.source === 'mine') { + return '正在查询我名下的单据,接口范围为当前用户本人单据列表。' + } + return '正在查询我可见的单据,接口范围包含我名下单据和待我审核单据列表。' +} + +async function fetchAiDocumentQueryPayload(intent = {}) { + const requestParams = { page: 1, pageSize: 100 } + if (intent.source === 'approval') { + return fetchApprovalExpenseClaims(requestParams) + } + if (intent.source === 'mine') { + return fetchExpenseClaims(requestParams) + } + const [ownPayload, approvalPayload] = await Promise.all([ + fetchExpenseClaims(requestParams), + fetchApprovalExpenseClaims(requestParams) + ]) + return mergeAiDocumentQueryPayloads( + ownPayload, + { + items: extractExpenseClaimItems(approvalPayload), + querySource: 'approval' + } + ) +} + async function handleAiDocumentQueryIntent(prompt, pendingMessage) { const intent = resolveAiDocumentQueryIntent(prompt) if (!intent) { @@ -2072,7 +2123,7 @@ async function handleAiDocumentQueryIntent(prompt, pendingMessage) { { eventId: 'document-query-fetch', title: '查询业务单据接口', - content: intent.source === 'approval' ? '等待调用待我审核单据接口。' : '等待调用我名下单据接口。', + content: resolveAiDocumentQueryFetchPendingText(intent), status: 'pending' }, { @@ -2090,9 +2141,7 @@ async function handleAiDocumentQueryIntent(prompt, pendingMessage) { event.eventId === 'document-query-fetch' ? { ...event, - content: intent.source === 'approval' - ? '正在查询待我审核的单据,接口范围为待办/待审单据列表。' - : '正在查询我名下的单据,接口范围为当前用户可见单据列表。', + content: resolveAiDocumentQueryFetchRunningText(intent), status: 'running' } : event @@ -2100,9 +2149,7 @@ async function handleAiDocumentQueryIntent(prompt, pendingMessage) { await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents) try { - const payload = intent.source === 'approval' - ? await fetchApprovalExpenseClaims({ page: 1, pageSize: 100 }) - : await fetchExpenseClaims({ page: 1, pageSize: 100 }) + const payload = await fetchAiDocumentQueryPayload(intent) const rawCount = extractExpenseClaimItems(payload).length const filteredRecords = filterAiDocumentQueryRecords(payload, intent) thinkingEvents = completeAiDocumentQueryEvent( diff --git a/web/src/utils/aiConversationHtmlRenderer.js b/web/src/utils/aiConversationHtmlRenderer.js index 88d6841..bedc15e 100644 --- a/web/src/utils/aiConversationHtmlRenderer.js +++ b/web/src/utils/aiConversationHtmlRenderer.js @@ -25,6 +25,18 @@ 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_' +const DOCUMENT_STATUS_LABELS = { + draft: '草稿', + submitted: '审批中', + pending: '待处理', + approved: '已审批', + completed: '已完成', + archived: '已归档', + returned: '已退回', + rejected: '已驳回', + pending_payment: '待付款', + paid: '已付款' +} const TRUSTED_HTML_ALLOWED_TAGS = new Set([ 'section', 'article', @@ -518,6 +530,29 @@ function hasMeaningfulTableValue(value = '') { return Boolean(text && text !== '-') } +function normalizeDocumentStatusLabel(status = '') { + const text = String(status || '').trim() + if (!text || text === '-') { + return '' + } + return DOCUMENT_STATUS_LABELS[text.toLowerCase()] || text +} + +function resolveDocumentRecordTone(status = '', stage = '') { + const normalizedStatus = normalizeDocumentStatusLabel(status) + const text = `${normalizedStatus || String(status || '')} ${String(stage || '')}`.trim() + if (/已删除|已驳回|驳回|拒绝|失败/.test(text)) { + return 'is-danger' + } + if (/已审批|审批通过|已完成|已归档|已付款|已支付|可通过/.test(text)) { + return 'is-success' + } + if (/草稿|待提交|待补充|已退回|退回/.test(text)) { + return 'is-warning' + } + return 'is-pending' +} + function isDocumentRecordTable(normalizedHeader = []) { return ( normalizedHeader.includes('单据编号') && @@ -526,15 +561,32 @@ function isDocumentRecordTable(normalizedHeader = []) { ) } -function renderRecordMeta(label = '', value = '') { +function renderDocumentCardField(label = '', value = '', options = {}) { if (!hasMeaningfulTableValue(value)) { return '' } + const valueClass = options.valueClass ? ` ${options.valueClass}` : '' return [ - '', - `${escapeHtml(label)}`, - `${renderInlineHtml(value)}`, - '' + `
`, + `${escapeHtml(label)}`, + `${renderInlineHtml(value)}`, + '
' + ].join('') +} + +function renderDocumentCardAction(action = '') { + if (!hasMeaningfulTableValue(action)) { + return '' + } + const actionHtml = renderInlineHtml(action).replace( + /class="ai-html-action-link\s+/g, + 'class="ai-html-action-link ai-document-card__action ' + ) + return [ + '
', + '操作', + actionHtml, + '
' ].join('') } @@ -543,32 +595,50 @@ function renderDocumentRecordList(header = [], bodyRows = []) { 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 applyTime = resolveTableCell(row, normalizedHeader, ['申请时间', '日期', '时间']) + const location = resolveTableCell(row, normalizedHeader, ['地点', '目的地']) + const amount = resolveTableCell(row, normalizedHeader, ['金额', '预计金额', '报销金额']) + const status = normalizeDocumentStatusLabel(resolveTableCell(row, normalizedHeader, ['单据状态', '状态'])) const stage = resolveTableCell(row, normalizedHeader, ['当前节点']) const reason = resolveTableCell(row, normalizedHeader, ['事由']) const action = resolveTableCell(row, normalizedHeader, ['操作']) + const tone = resolveDocumentRecordTone(status, stage) + const title = documentType || reason || documentNo || '单据详情' + const summarySecondField = amount + ? renderDocumentCardField('金额', amount, { valueClass: 'ai-document-card__amount' }) + : renderDocumentCardField('当前节点', stage || status || '待确认') + const summaryHtml = [ + renderDocumentCardField('日期', applyTime || '待补充'), + summarySecondField + ].join('') + const detailsHtml = [ + renderDocumentCardField('地点', location || '待补充'), + renderDocumentCardField('单据编号', documentNo, { valueClass: 'ai-document-card__number' }), + renderDocumentCardField('事由', reason || '待补充'), + amount ? renderDocumentCardField('当前节点', stage || status || '待确认') : '', + renderDocumentCardAction(action), + renderDocumentCardField('单据类型', documentType) + ].join('') return [ - '
', - '
', - hasMeaningfulTableValue(documentType) ? `${renderInlineHtml(documentType)}` : '', - hasMeaningfulTableValue(documentNo) ? `${renderInlineHtml(documentNo)}` : '', - hasMeaningfulTableValue(reason) ? `

${renderInlineHtml(reason)}

` : '', + `
`, + '
', + `${renderInlineHtml(title)}`, + hasMeaningfulTableValue(status) ? `${renderInlineHtml(status)}` : '', + '
', + '
', + summaryHtml ? `
${summaryHtml}
` : '', + '
', + detailsHtml, '
', - '
', - renderRecordMeta('申请时间', applyTime), - renderRecordMeta('状态', status), - renderRecordMeta('当前节点', stage), '
', - hasMeaningfulTableValue(action) ? `
${renderInlineHtml(action)}
` : '', '
' ].join('') }).filter(Boolean) return [ - '
', + '
', ...items, - '
' + '' ].join('') } diff --git a/web/src/utils/aiDocumentDetailReference.js b/web/src/utils/aiDocumentDetailReference.js new file mode 100644 index 0000000..ede95a9 --- /dev/null +++ b/web/src/utils/aiDocumentDetailReference.js @@ -0,0 +1,141 @@ +import { isApplicationDocumentNo } from './documentClassification.js' + +export const AI_DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:' +export const AI_APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-application-detail:' + +const SHORT_BUSINESS_DOCUMENT_NO_PATTERN = /^(?:A|R|D)[A-HJ-NP-Z2-9]{8}$/ +const LEGACY_BUSINESS_DOCUMENT_NO_PATTERN = /^(?:AP|APP|RE|AD|EXP|CL|BX)-/ + +function normalizeText(value = '') { + return String(value ?? '').trim() +} + +export function isBusinessDocumentReference(value = '') { + const text = normalizeText(value) + return Boolean( + text && + ( + SHORT_BUSINESS_DOCUMENT_NO_PATTERN.test(text) || + LEGACY_BUSINESS_DOCUMENT_NO_PATTERN.test(text) + ) + ) +} + +function parseDetailReferencePayload(reference = '', options = {}) { + const text = normalizeText(reference) + if (!text) { + return null + } + + const params = new URLSearchParams(text) + const claimId = normalizeText(params.get('claim_id') || params.get('claimId')) + const claimNo = normalizeText( + params.get('claim_no') || + params.get('claimNo') || + params.get('document_no') || + params.get('documentNo') + ) + const documentType = normalizeText(options.documentType) + + if (claimId || claimNo) { + return { + reference: claimNo || claimId, + claimId, + claimNo, + ...(documentType ? { documentType } : {}) + } + } + + return { + reference: text, + ...(documentType ? { documentType } : {}) + } +} + +function parseAiDetailHref(href = '', prefix = '', options = {}) { + const value = normalizeText(href) + if (!value.startsWith(prefix)) { + return null + } + const encodedReference = value.slice(prefix.length) + if (!encodedReference) { + return null + } + try { + return parseDetailReferencePayload(decodeURIComponent(encodedReference), options) + } catch { + return parseDetailReferencePayload(encodedReference, options) + } +} + +export function parseAiDocumentDetailHref(href = '') { + return parseAiDetailHref(href, AI_DOCUMENT_DETAIL_HREF_PREFIX) +} + +export function parseAiApplicationDetailHref(href = '') { + return parseAiDetailHref(href, AI_APPLICATION_DETAIL_HREF_PREFIX, { documentType: 'application' }) +} + +function resolveExplicitClaimId(source = {}) { + const claimNo = normalizeText(source.claimNo || source.claim_no || source.documentNo || source.document_no) + const rawClaimId = normalizeText(source.claimId || source.claim_id || source.id) + if (!rawClaimId || rawClaimId === claimNo || isBusinessDocumentReference(rawClaimId)) { + return '' + } + return rawClaimId +} + +export function buildAiDocumentDetailHref(source = {}, options = {}) { + const prefix = normalizeText(options.prefix) || AI_DOCUMENT_DETAIL_HREF_PREFIX + const claimId = resolveExplicitClaimId(source) + const claimNo = normalizeText(source.claimNo || source.claim_no || source.documentNo || source.document_no) + const fallback = normalizeText(source.reference) + + if (claimId || claimNo) { + const params = new URLSearchParams() + if (claimId) { + params.set('claim_id', claimId) + } + if (claimNo) { + params.set('claim_no', claimNo) + } + return `${prefix}${encodeURIComponent(params.toString())}` + } + + return fallback ? `${prefix}${encodeURIComponent(fallback)}` : '' +} + +export function buildAiDocumentDetailRequest(detailReference = {}) { + const reference = normalizeText(detailReference.reference) + const explicitClaimId = resolveExplicitClaimId(detailReference) + const explicitClaimNo = normalizeText( + detailReference.claimNo || + detailReference.claim_no || + detailReference.documentNo || + detailReference.document_no + ) + const referenceIsBusinessNo = isBusinessDocumentReference(reference) + const claimId = explicitClaimId || (!referenceIsBusinessNo ? reference : '') + const claimNo = explicitClaimNo || (referenceIsBusinessNo ? reference : '') + const lookupReference = claimId || claimNo || reference + const displayReference = claimNo || reference || lookupReference + const documentTypeCode = normalizeText( + detailReference.documentTypeCode || + detailReference.document_type_code || + detailReference.documentType || + detailReference.document_type + ).toLowerCase() + const isApplication = documentTypeCode === 'application' || isApplicationDocumentNo(displayReference) + + return { + id: lookupReference, + claimId, + claimNo, + documentNo: displayReference, + documentType: isApplication ? 'application' : 'reimbursement', + documentTypeCode: isApplication ? 'application' : 'reimbursement', + detailLookupOnly: true, + source: 'workbench', + returnTo: 'workbench' + } +} diff --git a/web/src/utils/aiDocumentQueryModel.js b/web/src/utils/aiDocumentQueryModel.js index 9cb63d5..89dd801 100644 --- a/web/src/utils/aiDocumentQueryModel.js +++ b/web/src/utils/aiDocumentQueryModel.js @@ -1,4 +1,5 @@ import { extractExpenseClaimItems } from '../services/reimbursements.js' +import { buildAiDocumentDetailHref } from './aiDocumentDetailReference.js' import { isApplicationDocumentNo } from './documentClassification.js' const DOCUMENT_QUERY_LIMIT = 8 @@ -66,6 +67,14 @@ function normalizeText(value) { return String(value ?? '').trim() } +function resolveStatusDisplayLabel(value = '') { + const text = normalizeText(value) + if (!text) { + return '' + } + return STATUS_LABELS[text.toLowerCase()] || text +} + function escapeHtml(value = '') { return String(value || '') .replace(/&/g, '&') @@ -279,9 +288,15 @@ function resolveSource(prompt) { sourceLabel: '待我审核的单据' } } + if (/我名下|我发起|我提交|我创建|我的申请|我的报销/.test(text)) { + return { + source: 'mine', + sourceLabel: '我的单据' + } + } return { - source: 'mine', - sourceLabel: '我的单据' + source: 'accessible', + sourceLabel: '我可见的单据' } } @@ -323,6 +338,36 @@ function resolveClaimId(claim = {}) { return normalizeText(claim.id || claim.claim_id || claim.claimId || resolveDocumentNo(claim)) } +export function mergeAiDocumentQueryPayloads(...payloads) { + const records = [] + const seen = new Set() + + payloads.forEach((payload) => { + const querySource = normalizeText(payload?.querySource || payload?.aiQuerySource || payload?.ai_query_source) + extractExpenseClaimItems(payload).forEach((claim) => { + const normalizedClaim = querySource + ? { ...claim, ai_query_source: querySource } + : claim + const documentNo = resolveDocumentNo(normalizedClaim) + const claimId = resolveClaimId(normalizedClaim) + const documentType = resolveDocumentTypeCode(normalizedClaim) + const stableId = documentNo || claimId + if (!stableId) { + records.push(normalizedClaim) + return + } + const key = `${documentType}:${stableId}` + if (seen.has(key)) { + return + } + seen.add(key) + records.push(normalizedClaim) + }) + }) + + return records +} + function resolveDocumentTypeCode(claim = {}) { const explicitType = normalizeText( claim.document_type_code @@ -346,7 +391,10 @@ function resolveDocumentTypeCode(claim = {}) { function resolveStatusLabel(claim = {}) { const key = normalizeText(claim.status || claim.state || claim.approval_status || claim.approvalStatus).toLowerCase() - return normalizeText(claim.status_label || claim.statusLabel || claim.approval_stage || claim.approvalStage) || STATUS_LABELS[key] || '待确认' + const explicitLabel = resolveStatusDisplayLabel( + claim.status_label || claim.statusLabel || claim.approval_stage || claim.approvalStage + ) + return explicitLabel || STATUS_LABELS[key] || '待确认' } // 状态语义化分类,驱动卡片着色:进行中 / 正向终态 / 需关注 / 异常终态 @@ -368,6 +416,28 @@ function resolveStatusKey(claim = {}) { return normalizeText(claim.status || claim.state || claim.approval_status || claim.approvalStatus).toLowerCase() } +function resolveRecordQuerySource(claim = {}, intent = {}) { + return normalizeText( + claim.ai_query_source + || claim.aiQuerySource + || claim.querySource + || (intent.source === 'approval' ? 'approval' : '') + ) +} + +function isApprovalTaskClaim(claim = {}, intent = {}) { + return resolveRecordQuerySource(claim, intent) === 'approval' +} + +function isPendingApprovalStatus(statusKey = '', statusLabel = '') { + const normalizedStatusKey = normalizeText(statusKey).toLowerCase() + const normalizedStatusLabel = normalizeText(statusLabel) + if (['approved', 'completed', 'archived', 'paid', 'rejected', 'returned', 'cancelled'].includes(normalizedStatusKey)) { + return false + } + return !/已审批|已批准|审批通过|已完成|已付款|已支付|已归档|已驳回|已退回|已拒绝|拒绝|取消/.test(normalizedStatusLabel) +} + function resolveReason(claim = {}) { return normalizeText(claim.reason || claim.business_reason || claim.description || claim.title || claim.note) || '未填写事由' } @@ -569,7 +639,7 @@ function toTimestamp(dateText) { return date ? date.getTime() : 0 } -function normalizeRecord(claim = {}) { +function normalizeRecord(claim = {}, intent = {}) { const documentType = resolveDocumentTypeCode(claim) const documentNo = resolveDocumentNo(claim) const date = resolveRecordDate(claim) @@ -577,7 +647,13 @@ function normalizeRecord(claim = {}) { const reason = resolveReason(claim) const expenseTypeCode = resolveExpenseTypeCode(claim) const typeLabel = resolveExpenseTypeLabel(claim) - const statusLabel = resolveStatusLabel(claim) + const statusKey = resolveStatusKey(claim) + const rawStatusLabel = resolveStatusLabel(claim) + const querySource = resolveRecordQuerySource(claim, intent) + const isApprovalTask = isApprovalTaskClaim(claim, intent) + const statusLabel = isApprovalTask && isPendingApprovalStatus(statusKey, rawStatusLabel) + ? '待审批' + : rawStatusLabel const ownerLabel = resolveOwnerLabel(claim) const departmentLabel = resolveDepartmentLabel(claim) const locationLabel = resolveLocationLabel(claim) @@ -593,9 +669,11 @@ function normalizeRecord(claim = {}) { time: resolveTimeLabel(claim, date), dateKey: date, updatedTime: updatedDate || '未显示', - statusKey: resolveStatusKey(claim), + statusKey, statusLabel, statusTone: resolveStatusTone(statusLabel), + querySource, + isApprovalTask, reason, amountLabel: resolveAmountLabel(claim), amountValue: resolveAmountValue(claim), @@ -653,7 +731,7 @@ function matchesAmountFilter(record = {}, amountFilter = null) { export function filterAiDocumentQueryRecords(claimsPayload, intent = {}) { const rows = extractExpenseClaimItems(claimsPayload) - .map((claim) => normalizeRecord(claim)) + .map((claim) => normalizeRecord(claim, intent)) .filter((record) => ( !intent?.documentType || intent.documentType === 'all' || @@ -669,50 +747,62 @@ export function filterAiDocumentQueryRecords(claimsPayload, intent = {}) { return rows } -function buildDocumentDetailHref(record = {}) { - const reference = normalizeText(record.documentNo || record.claimNo || record.claimId || record.id) - return reference ? `#ai-open-document-detail:${encodeURIComponent(reference)}` : '' +function buildDocumentCardFieldHtml(label = '', value = '', options = {}) { + const text = normalizeText(value) + if (!text || text === '-') { + return '' + } + const valueClass = options.valueClass ? ` ${options.valueClass}` : '' + const fieldClass = options.fieldClass ? ` ${options.fieldClass}` : '' + return [ + `
`, + `${escapeHtml(label)}`, + `${escapeHtml(text)}`, + '
' + ].join('') } function buildDocumentCardHtml(record = {}) { - const href = buildDocumentDetailHref(record) + const href = buildAiDocumentDetailHref(record) const typeClass = record.documentType === 'application' ? 'application' : 'reimbursement' + const approvalTaskClass = record.isApprovalTask ? ' ai-document-card--approval-task' : '' const statusTone = record.statusTone || 'is-pending' const amountLabel = record.documentType === 'application' ? '预计金额' : '报销金额' + const documentTypeText = [record.documentTypeLabel, record.typeLabel].filter(Boolean).join(' · ') + const title = record.typeLabel || record.documentTypeLabel || record.reason || '单据详情' const ownerText = [record.ownerLabel, record.departmentLabel] .filter((item) => item && item !== '未显示') .join(' · ') || '未显示' + const summaryHtml = [ + buildDocumentCardFieldHtml('日期', record.time || '待补充'), + buildDocumentCardFieldHtml(amountLabel, record.amountLabel, { valueClass: 'ai-document-card__amount' }) + ].join('') + const detailsHtml = [ + buildDocumentCardFieldHtml('地点', record.locationLabel || '待补充'), + buildDocumentCardFieldHtml('单据编号', record.documentNo || '未编号单据', { valueClass: 'ai-document-card__number' }), + buildDocumentCardFieldHtml('事由', record.reason || '待补充'), + buildDocumentCardFieldHtml('申请人', ownerText), + [ + '
', + '操作', + href + ? `查看详情` + : '暂无详情', + '
' + ].join(''), + buildDocumentCardFieldHtml('单据类型', documentTypeText) + ].join('') return [ - `
`, + `
`, '
', - `${escapeHtml(record.reason)}`, + `${escapeHtml(title)}`, `${escapeHtml(record.statusLabel)}`, '
', '
', + summaryHtml ? `
${summaryHtml}
` : '', '
', - '
', - '单据类型', - `${escapeHtml(record.documentTypeLabel)} · ${escapeHtml(record.typeLabel)}`, - '
', - '
', - `${escapeHtml(amountLabel)}`, - `${escapeHtml(record.amountLabel)}`, - '
', - '
', - '申请人', - `${escapeHtml(ownerText)}`, - '
', - '
', - '单据编号', - `${escapeHtml(record.documentNo || '未编号单据')}`, - '
', - '
', - '操作', - href - ? `查看详情` - : '暂无详情', - '
', + detailsHtml, '
', '
', '
' diff --git a/web/src/utils/expenseApplicationPreview.js b/web/src/utils/expenseApplicationPreview.js index d69a811..0294b3f 100644 --- a/web/src/utils/expenseApplicationPreview.js +++ b/web/src/utils/expenseApplicationPreview.js @@ -623,6 +623,8 @@ function stripKnownContextFromReason(value, context = {}) { .replace(/(?:地点|业务地点|发生地点|目的地)\s*[::]\s*(?=[,,、。;;\s]|$)/gu, '') .replace(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}\s*(?:至|到|~|—|–|--)\s*20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/gu, '') .replace(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/gu, '') + .replace(/(?:\d{1,2}月)?\d{1,2}日?\s*(?:至|到|~|—|–|--|-)\s*(?:\d{1,2}月)?\d{1,2}日?/gu, '') + .replace(/\d{1,2}月\d{1,2}日?/gu, '') .replace(/(?:出差|申请)?\d+\s*天/gu, '') .replace(/(?:用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额|费用)\s*[::]?\s*\d+(?:\.\d+)?\s*(?:元|块|人民币)?/gu, '') .replace(/(?:高铁|动车|火车|铁路|飞机|机票|航班|轮船|船票|客轮|渡轮|出租车|的士|网约车|打车|自驾)/gu, '') diff --git a/web/tests/ai-conversation-html-renderer.test.mjs b/web/tests/ai-conversation-html-renderer.test.mjs index 4d66720..40a6e3b 100644 --- a/web/tests/ai-conversation-html-renderer.test.mjs +++ b/web/tests/ai-conversation-html-renderer.test.mjs @@ -47,18 +47,30 @@ 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 | submitted | 直属领导审批 | 2026-02-20 至 2026-02-23 | 上海 | 辅助国网仿生产服务器部署 | [查看](#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, /
/) + assert.match(rendered, /
/) + assert.match(rendered, /出差申请<\/strong>/) + assert.match(rendered, /审批中<\/span>/) + assert.match(rendered, /
/) + assert.match(rendered, /日期<\/span>/) + assert.match(rendered, /2026-02-20 至 2026-02-23/) + assert.match(rendered, /当前节点<\/span>/) + assert.match(rendered, /直属领导审批/) + assert.match(rendered, /地点<\/span>/) + assert.match(rendered, /上海/) + assert.match(rendered, /事由<\/span>/) + assert.match(rendered, /辅助国网仿生产服务器部署/) + assert.match(rendered, /AP-OVERLAP<\/strong>/) + assert.match(rendered, /class="ai-html-action-link ai-document-card__action 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, /ai-html-record-item/) assert.doesNotMatch(rendered, /target="_blank"[\s\S]{0,120}#ai-open-application-detail/) }) @@ -69,7 +81,7 @@ test('AI conversation renderer renders deleted application detail actions as dis '| 出差申请 | 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, /class="ai-html-action-link ai-document-card__action 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/) @@ -82,12 +94,16 @@ test('AI conversation renderer turns application conflict tables into record lis '| 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, /
/) + assert.match(rendered, /
/) + assert.match(rendered, /
/) + assert.match(rendered, /日期<\/span>/) assert.match(rendered, /2026-02-20 至 2026-02-23/) + assert.match(rendered, /当前节点<\/span>/) assert.match(rendered, /辅助国网仿生产服务器部署/) - assert.match(rendered, /
/) + assert.match(rendered, /ai-document-card__field--action/) assert.doesNotMatch(rendered, /
/) + assert.doesNotMatch(rendered, /ai-html-record-item/) }) test('AI conversation renderer renders document detail action links as buttons', () => { diff --git a/web/tests/ai-document-detail-reference.test.mjs b/web/tests/ai-document-detail-reference.test.mjs new file mode 100644 index 0000000..b41e3c9 --- /dev/null +++ b/web/tests/ai-document-detail-reference.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + buildAiDocumentDetailRequest, + parseAiApplicationDetailHref, + parseAiDocumentDetailHref +} from '../src/utils/aiDocumentDetailReference.js' + +test('AI detail request keeps business application number out of claimId for legacy links', () => { + const detailReference = parseAiApplicationDetailHref('#ai-open-application-detail:AP-202606200001-ABCDEFGH') + const request = buildAiDocumentDetailRequest(detailReference) + + assert.deepEqual(detailReference, { + reference: 'AP-202606200001-ABCDEFGH', + documentType: 'application' + }) + assert.equal(request.id, 'AP-202606200001-ABCDEFGH') + assert.equal(request.claimId, '') + assert.equal(request.claimNo, 'AP-202606200001-ABCDEFGH') + assert.equal(request.documentNo, 'AP-202606200001-ABCDEFGH') + assert.equal(request.documentTypeCode, 'application') + assert.equal(request.detailLookupOnly, true) +}) + +test('AI detail request uses explicit claim_id as lookup identity', () => { + const detailReference = parseAiDocumentDetailHref( + '#ai-open-document-detail:claim_id%3Dapproval-1%26claim_no%3DAP-APPROVAL-001' + ) + const request = buildAiDocumentDetailRequest(detailReference) + + assert.deepEqual(detailReference, { + reference: 'AP-APPROVAL-001', + claimId: 'approval-1', + claimNo: 'AP-APPROVAL-001' + }) + assert.equal(request.id, 'approval-1') + assert.equal(request.claimId, 'approval-1') + assert.equal(request.claimNo, 'AP-APPROVAL-001') + assert.equal(request.documentNo, 'AP-APPROVAL-001') + assert.equal(request.documentTypeCode, 'application') +}) + +test('AI detail request treats non-number references as internal claim ids', () => { + const request = buildAiDocumentDetailRequest({ reference: 'approval-internal-id' }) + + assert.equal(request.id, 'approval-internal-id') + assert.equal(request.claimId, 'approval-internal-id') + assert.equal(request.claimNo, '') + assert.equal(request.documentNo, 'approval-internal-id') +}) diff --git a/web/tests/ai-document-query-model.test.mjs b/web/tests/ai-document-query-model.test.mjs index 983c66d..88dccd0 100644 --- a/web/tests/ai-document-query-model.test.mjs +++ b/web/tests/ai-document-query-model.test.mjs @@ -5,6 +5,7 @@ import { buildAiDocumentQueryConditionSummary, buildAiDocumentQueryMessage, filterAiDocumentQueryRecords, + mergeAiDocumentQueryPayloads, resolveAiDocumentQueryIntent } from '../src/utils/aiDocumentQueryModel.js' import { renderAiConversationHtml } from '../src/utils/aiConversationHtmlRenderer.js' @@ -55,9 +56,9 @@ const claims = [ test('AI document query intent detects my document list questions', () => { const intent = resolveAiDocumentQueryIntent('我现在有哪些单据?', { today }) - assert.equal(intent?.source, 'mine') + assert.equal(intent?.source, 'accessible') assert.equal(intent?.documentType, 'all') - assert.equal(intent?.sourceLabel, '我的单据') + assert.equal(intent?.sourceLabel, '我可见的单据') }) test('AI document query intent detects approval document questions', () => { @@ -67,6 +68,38 @@ test('AI document query intent detects approval document questions', () => { assert.equal(intent?.sourceLabel, '待我审核的单据') }) +test('AI document query keeps explicit own-document scope separate from accessible documents', () => { + const intent = resolveAiDocumentQueryIntent('我名下有哪些单据?', { today }) + + assert.equal(intent?.source, 'mine') + assert.equal(intent?.sourceLabel, '我的单据') +}) + +test('AI document query merges own and approval records for generic document list questions', () => { + const intent = resolveAiDocumentQueryIntent('我现在有哪些单据?', { today }) + const approvalClaims = [{ + id: 'approval-1', + claim_no: 'CL-APPROVAL-001', + document_type_code: 'reimbursement', + expense_type: 'meeting', + status: 'pending', + approval_stage: '直属领导审批', + reason: '待我审核的会议费', + employee_name: '李文静', + department_name: '市场部', + occurred_at: '2026-02-22T09:00:00Z', + amount: 880 + }] + const merged = mergeAiDocumentQueryPayloads(claims, { items: approvalClaims, querySource: 'approval' }, [claims[0]]) + const records = filterAiDocumentQueryRecords(merged, intent) + + assert.equal(intent?.source, 'accessible') + assert.deepEqual( + records.map((record) => record.documentNo), + ['CL-20260305001', 'CL-APPROVAL-001', 'CL-20260221001', 'AP-20260220001'] + ) +}) + test('AI document query filters by month and document type', () => { const intent = resolveAiDocumentQueryIntent('我2月有哪些申请单?', { today }) const records = filterAiDocumentQueryRecords(claims, intent) @@ -136,26 +169,64 @@ test('AI document query message renders html document cards with detail actions' assert.match(message, /
/) assert.match(message, /
/) assert.match(message, /已审批<\/span>/) - assert.match(message, /辅助国网仿生产服务器部署<\/strong>/) + assert.match(message, /差旅费用申请<\/strong>/) + assert.match(message, /
/) assert.match(message, /
/) + assert.match(message, /日期<\/span>/) + assert.match(message, /2026-02-20/) + assert.match(message, /预计金额<\/span>/) + assert.match(message, /¥3,000\.00<\/strong>/) + assert.doesNotMatch(message, /当前节点<\/span>/) assert.match(message, /单据类型<\/span>/) assert.match(message, /申请单 · 差旅费用申请<\/strong>/) + assert.match(message, /地点<\/span>/) + assert.match(message, /上海/) + assert.match(message, /事由<\/span>/) + assert.match(message, /辅助国网仿生产服务器部署/) assert.match(message, /申请人<\/span>/) assert.match(message, /曹小筑 · 交付部<\/strong>/) assert.match(message, /AP-20260220001<\/strong>/) - assert.match(message, /¥3,000\.00<\/strong>/) assert.match(message, /
/) assert.doesNotMatch(message, /ai-document-card__meta/) assert.doesNotMatch(message, /ai-document-card__meta-item/) - assert.match(message, /href="#ai-open-document-detail:AP-20260220001"/) + assert.match(message, /href="#ai-open-document-detail:claim_id%3Dapp-1%26claim_no%3DAP-20260220001"/) // 报销单 claim-1 状态为 submitted → is-pending 语义类 assert.match(message, /
/) - assert.match(message, /href="#ai-open-document-detail:CL-20260221001"/) + assert.match(message, /审批中<\/span>/) + assert.match(message, /href="#ai-open-document-detail:claim_id%3Dclaim-1%26claim_no%3DCL-20260221001"/) + assert.doesNotMatch(message, />submitted /m) assert.doesNotMatch(message, /\*\*查询范围\*\*/) }) +test('AI document query highlights approval-task cards and shows pending review status', () => { + const intent = resolveAiDocumentQueryIntent('我有哪些审核单', { today }) + const message = buildAiDocumentQueryMessage(intent, [{ + id: 'approval-1', + claim_no: 'AP-APPROVAL-001', + document_type_code: 'application', + expense_type: 'travel_application', + status: 'submitted', + approval_stage: '直属领导审批', + reason: '参加相关残联会议', + employee_name: '曹笑竹', + department_name: '技术部', + location: '上海', + occurred_at: '2026-02-20T09:00:00Z', + amount: 2120 + }]) + + assert.match(message, /ai-document-card--application ai-document-card--approval-task is-pending/) + assert.match(message, /待审批<\/span>/) + assert.match(message, /预计金额<\/span>/) + assert.match(message, /¥2,120\.00<\/strong>/) + assert.doesNotMatch(message, /当前节点<\/span>/) + assert.match(message, /单据类型<\/span>/) + assert.match(message, /申请单 · 差旅费用申请<\/strong>/) + assert.match(message, /href="#ai-open-document-detail:claim_id%3Dapproval-1%26claim_no%3DAP-APPROVAL-001"/) +}) + test('AI document query html cards render as trusted card markup', () => { const intent = resolveAiDocumentQueryIntent('我2月有哪些单据?', { today }) const rendered = renderAiConversationHtml(buildAiDocumentQueryMessage(intent, claims)) @@ -169,7 +240,7 @@ test('AI document query html cards render as trusted card markup', () => { assert.match(rendered, /class="ai-document-card__field"/) assert.match(rendered, /class="ai-document-card__label"/) assert.match(rendered, /class="ai-html-action-link ai-html-action-link-document ai-document-card__action"/) - assert.match(rendered, /href="#ai-open-document-detail:CL-20260221001"/) + assert.match(rendered, /href="#ai-open-document-detail:claim_id%3Dclaim-1%26claim_no%3DCL-20260221001"/) assert.doesNotMatch(rendered, /ai-document-card__meta/) assert.doesNotMatch(rendered, /<section class="ai-document-card-list/) assert.doesNotMatch(rendered, /
/) diff --git a/web/tests/expense-application-preview-reason.test.mjs b/web/tests/expense-application-preview-reason.test.mjs new file mode 100644 index 0000000..1523ee0 --- /dev/null +++ b/web/tests/expense-application-preview-reason.test.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + buildLocalApplicationPreview +} from '../src/utils/expenseApplicationPreview.js' + +test('application preview keeps compact travel meeting reason without date or location prefix', () => { + const preview = buildLocalApplicationPreview( + '2月20-23日去上海出差参加相关残联会议', + { + name: '曹笑竹', + departmentName: '技术部', + position: '财务智能化产品经理', + managerName: '向万红', + grade: 'P5' + }, + { today: '2026-06-09' } + ) + + assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23') + assert.equal(preview.fields.days, '4天') + assert.equal(preview.fields.location, '上海') + assert.equal(preview.fields.reason, '参加相关残联会议') + assert.doesNotMatch(preview.fields.reason, /2月20|23日|上海|出差/) +}) 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 6eee94e..c6be272 100644 --- a/web/tests/workbench-ai-mode-expense-scene-action.test.mjs +++ b/web/tests/workbench-ai-mode-expense-scene-action.test.mjs @@ -84,7 +84,10 @@ test('AI mode handles document query prompts locally before steward planning', ( assert.match(aiMode, /async function handleAiDocumentQueryIntent/) assert.match(aiMode, /buildAiDocumentQueryConditionSummary/) assert.match(aiMode, /filterAiDocumentQueryRecords\(payload, intent\)/) + assert.match(aiMode, /mergeAiDocumentQueryPayloads/) assert.match(aiMode, /fetchApprovalExpenseClaims/) + assert.match(aiMode, /Promise\.all\(\[/) + assert.match(aiMode, /fetchAiDocumentQueryPayload\(intent\)/) assert.match(aiMode, /buildAiDocumentQueryMessage/) assert.match(aiMode, /AI_DOCUMENT_QUERY_STEP_DELAY_MS/) assert.match(aiMode, /async function updateAiDocumentQueryThinking/) @@ -149,6 +152,16 @@ test('AI mode handles application preview save and submit through buttons or tex assert.match(aiMode, /#ai-open-application-detail:/) }) +test('AI mode keeps missing application fields editable in the preview table without quick template action', () => { + assert.match(aiMode, /function buildInlineApplicationPreviewSuggestedActions\(applicationPreview = \{\}, draftPayload = null\)/) + assert.match(aiMode, /label:\s*'保存草稿'/) + assert.match(aiMode, /function handleInlineApplicationPreviewTextAction\(prompt\)/) + assert.doesNotMatch(aiMode, /label:\s*'快速模板'/) + assert.doesNotMatch(aiMode, /action_type:\s*'prefill_composer'/) + assert.doesNotMatch(aiMode, /buildInlineApplicationPreviewTemplatePrefill/) + assert.doesNotMatch(aiMode, /applyInlineApplicationPreviewTemplateText/) +}) + 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) @@ -179,8 +192,14 @@ test('AI mode waits for submit confirmation before adding submit action to the c 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, /function normalizeInlineApplicationStatusLabel\(value, fallback = ''\)/) + assert.match(aiMode, /submitted:\s*'审批中'/) + assert.match(aiMode, /const statusLabel = normalizeInlineApplicationStatusLabel\(info\.statusLabel, options\.statusLabel\)/) + assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \| 操作 \|/) assert.match(aiMode, /\[查看\]\(\$\{href\}\)/) + assert.match(aiMode, /dateLabel:\s*rangeText \|\| dateText \|\| resolveBodyField\(\['时间', '日期', '申请时间'\]\) \|\| '待补充'/) + assert.match(aiMode, /locationLabel:[\s\S]*resolveBodyField\(\['地点', '目的地'\]\) \|\| '待补充'/) + assert.match(aiMode, /reasonLabel:[\s\S]*resolveBodyField\(\['事由', '事件', '申请事由'\]\) \|\| '待补充'/) assert.match(aiMode, /buildInlineApplicationActionDetailHref\(info\)/) assert.match(aiMode, /params\.set\('claim_id', claimId\)/) assert.match(aiMode, /params\.set\('claim_no', claimNo\)/) diff --git a/web/tests/workbench-ai-mode-switch.test.mjs b/web/tests/workbench-ai-mode-switch.test.mjs index 14b5cfd..a436084 100644 --- a/web/tests/workbench-ai-mode-switch.test.mjs +++ b/web/tests/workbench-ai-mode-switch.test.mjs @@ -266,32 +266,25 @@ test('AI mode screen follows the approved reference structure', () => { assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-query-summary\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-query-summary__scope\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card-list\) \{[\s\S]*gap:\s*16px;/) - assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__head\) \{[\s\S]*background: rgba\(37, 99, 235, 0\.11\);/) + assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\) \{[\s\S]*url\("\.\.\/\.\.\/ai-document-card-bg\.png"\);/) + assert.doesNotMatch(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\)::before/) + assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__head\) \{[\s\S]*background: var\(--ai-document-card-head-bg\);/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\.is-success \.ai-document-card__head\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__head\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__body\)/) + assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__summary\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__details\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__field\)/) + assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__field--wide\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__label\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__amount\)/) assert.match( aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__details\) \{[\s\S]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\);/ ) + assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__field--action \.ai-document-card__action\)/) 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'/) @@ -320,7 +313,7 @@ test('AI mode screen follows the approved reference structure', () => { 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.match(aiMode, /需要查看完整详情时,请点击卡片“操作”行的“查看”进入单据详情。/) assert.doesNotMatch(aiMode, /\*\*申请单号:\*\*/) assert.doesNotMatch(aiMode, /createInlineMessage\('assistant', buildStewardPlanMessageText\(plan\)/) assert.doesNotMatch(aiMode, /runOrchestrator\(/) diff --git a/web/tests/workbench-detail-return.test.mjs b/web/tests/workbench-detail-return.test.mjs index 5391f08..a4e3162 100644 --- a/web/tests/workbench-detail-return.test.mjs +++ b/web/tests/workbench-detail-return.test.mjs @@ -19,6 +19,10 @@ const aiMode = readFileSync( fileURLToPath(new URL('../src/components/business/PersonalWorkbenchAiMode.vue', import.meta.url)), 'utf8' ) +const aiDetailReference = readFileSync( + fileURLToPath(new URL('../src/utils/aiDocumentDetailReference.js', import.meta.url)), + 'utf8' +) test('workbench document detail keeps workbench as the return target', () => { assert.match(workbench, /source:\s*'workbench'/) @@ -35,12 +39,16 @@ test('workbench document detail keeps workbench as the return target', () => { 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(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/) +test('AI detail links resolve real claim identity before opening document detail', () => { + assert.match(aiMode, /buildAiDocumentDetailRequest/) + assert.match(aiMode, /parseAiApplicationDetailHref/) + assert.match(aiMode, /parseAiDocumentDetailHref/) + assert.match(aiDetailReference, /detailLookupOnly:\s*true/) + assert.match(aiDetailReference, /params\.get\('claim_id'\)/) + assert.match(aiDetailReference, /params\.get\('claim_no'\)/) + assert.match(aiDetailReference, /isBusinessDocumentReference/) + assert.match(aiDetailReference, /const claimId = explicitClaimId \|\| \(!referenceIsBusinessNo \? reference : ''\)/) + assert.match(aiDetailReference, /const claimNo = explicitClaimNo \|\| \(referenceIsBusinessNo \? reference : ''\)/) assert.match( appShell, /v-else-if="activeView === 'documents' && detailMode && !selectedRequest"[\s\S]*正在加载完整单据详情/ @@ -49,10 +57,16 @@ test('AI detail links wait for full document detail instead of rendering a half appShell, /const detailPayload = request \|\| \{[\s\S]*detailLookupOnly:\s*true[\s\S]*\}/ ) + assert.match(appShell, /isBusinessDocumentReference/) + assert.match(appShell, /const requestCandidates = Array\.isArray\(workbenchRequests\.value\)/) + assert.match(appShell, /claimId:\s*fallbackClaimId/) + assert.match(appShell, /claimNo:\s*fallbackClaimNo/) assert.match( appShellComposable, /const isDetailLookupOnlyRequest = isDetailLookupOnlyPayload\(request\)[\s\S]*selectedRequestSnapshot\.value = isDetailLookupOnlyRequest \? null : request \|\| null/ ) + assert.match(appShellComposable, /const workbenchRequests = computed/) + assert.match(appShellComposable, /workbenchRequests\.value\.find\(\(item\) => isSameRequestIdentity\(item, requestId\)\)/) assert.match( appShellComposable, /void refreshSelectedRequestDetail\(isDetailLookupOnlyRequest \? requestId : request\)/