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 [
- '
',
+ '
'
+ ''
].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)
assert.doesNotMatch(message, /\| 单据编号 \|/)
assert.doesNotMatch(message, /^> /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\)/