feat(web): AI 文档查询卡片重构与单号判定统一

- documentClassification 抽出 isApplicationDocumentNo,统一兼容 AP-/APP- 旧格式与 A+8 新格式,aiDocumentQueryModel 复用
- aiDocumentQueryModel 文档卡片改为结构化字段布局(单据类型/金额/申请人/编号/操作),新增查询范围摘要区,渲染走 HTML 信任块
- AppShellRouteView/useAppShell/useRequests/detailAlerts/riskVisibility 等差旅详情模型适配单号判定
- 同步更新 ai-document-query-model/workbench-ai-mode-switch 测试,新增 document-classification 测试
This commit is contained in:
caoxiaozhu
2026-06-20 22:04:37 +08:00
parent 8158716e23
commit 3b74a330a3
17 changed files with 348 additions and 209 deletions

View File

@@ -1094,30 +1094,84 @@
list-style: decimal; list-style: decimal;
} }
.workbench-ai-answer-markdown :deep(.ai-document-query-summary) {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 12px;
margin-top: 0;
padding: 2px 0 0;
border: 0;
background: transparent;
color: #334155;
}
.workbench-ai-answer-markdown :deep(.ai-document-query-summary__label) {
display: inline-flex;
align-items: center;
min-height: auto;
padding: 0;
border-radius: 0;
background: transparent;
color: #64748b;
font-size: 13px;
font-weight: 760;
line-height: 1.2;
}
.workbench-ai-answer-markdown :deep(.ai-document-query-summary__scope) {
min-width: 0;
color: #0f172a;
font-size: 15px;
font-weight: 860;
line-height: 1.5;
overflow-wrap: anywhere;
}
.workbench-ai-answer-markdown :deep(.ai-document-query-summary__count) {
display: inline-flex;
align-items: baseline;
gap: 4px;
color: #64748b;
font-size: 14px;
font-weight: 700;
line-height: 1.4;
}
.workbench-ai-answer-markdown :deep(.ai-document-query-summary__count strong) {
color: #1d4ed8;
font-size: 14px;
font-weight: 900;
}
.workbench-ai-answer-markdown :deep(.ai-document-card-list) { .workbench-ai-answer-markdown :deep(.ai-document-card-list) {
display: grid; display: grid;
gap: 12px; gap: 16px;
margin-top: 18px; margin-top: 14px;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card) { .workbench-ai-answer-markdown :deep(.ai-document-card) {
position: relative; position: relative;
display: grid; display: grid;
gap: 12px; gap: 0;
padding: 16px 18px; overflow: hidden;
border: 1px solid rgba(226, 232, 240, 0.9); padding: 0;
border-left: 3px solid #cbd5e1; border: 1px solid rgba(203, 213, 225, 0.76);
border-left: 0;
border-radius: 12px; border-radius: 12px;
background: #ffffff; background: rgba(255, 255, 255, 0.96);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 4px 12px rgba(15, 23, 42, 0.04); box-shadow:
0 1px 2px rgba(15, 23, 42, 0.035),
0 10px 26px rgba(15, 23, 42, 0.045);
color: #334155; color: #334155;
animation: workbenchDocumentCardReveal 360ms cubic-bezier(0.2, 0.8, 0.2, 1) both; 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: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card:hover) { .workbench-ai-answer-markdown :deep(.ai-document-card:hover) {
border-color: rgba(148, 163, 184, 0.7); border-color: rgba(148, 163, 184, 0.72);
box-shadow: 0 2px 4px rgba(15, 23, 42, 0.05), 0 8px 20px rgba(15, 23, 42, 0.07); background: #ffffff;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.065);
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -1133,186 +1187,161 @@
animation-delay: 120ms; animation-delay: 120ms;
} }
/* 状态语义色:左侧边条颜色随状态变化,一眼判断当前阶段 */ /* 状态语义色:头部浅底色和状态文字随单据状态变化 */
.workbench-ai-answer-markdown :deep(.ai-document-card.is-pending) {
border-left-color: #2563eb;
}
.workbench-ai-answer-markdown :deep(.ai-document-card.is-success) {
border-left-color: #16a34a;
}
.workbench-ai-answer-markdown :deep(.ai-document-card.is-warning) {
border-left-color: #d97706;
}
.workbench-ai-answer-markdown :deep(.ai-document-card.is-danger) {
border-left-color: #dc2626;
}
/* 卡片头部:状态 + 类型(左) · 单据编号(右) */
.workbench-ai-answer-markdown :deep(.ai-document-card__head) { .workbench-ai-answer-markdown :deep(.ai-document-card__head) {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 16px;
min-width: 0; min-width: 0;
} padding: 13px 18px;
background: rgba(37, 99, 235, 0.11);
.workbench-ai-answer-markdown :deep(.ai-document-card__head-left) {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex-wrap: wrap;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__status) { .workbench-ai-answer-markdown :deep(.ai-document-card__status) {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
min-height: 22px; min-height: 24px;
padding: 0 9px; padding: 0;
border-radius: 6px; border-radius: 0;
background: rgba(148, 163, 184, 0.16); background: transparent;
color: #475569; color: #1d4ed8;
font-size: 12px; font-size: 15px;
font-weight: 700; font-weight: 860;
line-height: 1.2; line-height: 1.3;
white-space: nowrap; white-space: nowrap;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card.is-success .ai-document-card__head) {
background: rgba(22, 163, 74, 0.1);
}
.workbench-ai-answer-markdown :deep(.ai-document-card.is-warning .ai-document-card__head) {
background: rgba(217, 119, 6, 0.12);
}
.workbench-ai-answer-markdown :deep(.ai-document-card.is-danger .ai-document-card__head) {
background: rgba(220, 38, 38, 0.1);
}
.workbench-ai-answer-markdown :deep(.ai-document-card.is-pending .ai-document-card__status) { .workbench-ai-answer-markdown :deep(.ai-document-card.is-pending .ai-document-card__status) {
background: rgba(37, 99, 235, 0.1);
color: #1d4ed8; color: #1d4ed8;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card.is-success .ai-document-card__status) { .workbench-ai-answer-markdown :deep(.ai-document-card.is-success .ai-document-card__status) {
background: rgba(22, 163, 74, 0.1);
color: #15803d; color: #15803d;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card.is-warning .ai-document-card__status) { .workbench-ai-answer-markdown :deep(.ai-document-card.is-warning .ai-document-card__status) {
background: rgba(217, 119, 6, 0.1);
color: #b45309; color: #b45309;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card.is-danger .ai-document-card__status) { .workbench-ai-answer-markdown :deep(.ai-document-card.is-danger .ai-document-card__status) {
background: rgba(220, 38, 38, 0.1);
color: #b91c1c; color: #b91c1c;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__type) {
color: #64748b;
font-size: 12px;
font-weight: 500;
line-height: 1.3;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__number) {
flex: 0 0 auto;
color: #94a3b8;
font-size: 12px;
font-weight: 500;
line-height: 1.3;
overflow-wrap: anywhere;
}
/* 卡片主体:事由(主焦点) + 申请人/部门(次焦点) */
.workbench-ai-answer-markdown :deep(.ai-document-card__body) { .workbench-ai-answer-markdown :deep(.ai-document-card__body) {
display: grid; display: grid;
gap: 6px; gap: 14px;
min-width: 0; min-width: 0;
padding: 16px 18px 18px;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__reason) { .workbench-ai-answer-markdown :deep(.ai-document-card__reason) {
display: -webkit-box; display: -webkit-box;
color: #0f172a; min-width: 0;
font-size: 16px; color: #1e40af;
font-weight: 700; font-size: 15px;
font-weight: 760;
line-height: 1.45; line-height: 1.45;
overflow: hidden; overflow: hidden;
-webkit-line-clamp: 2; -webkit-line-clamp: 1;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__owner-line) { .workbench-ai-answer-markdown :deep(.ai-document-card.is-success .ai-document-card__reason) {
display: flex; color: #166534;
align-items: center;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__owner) { .workbench-ai-answer-markdown :deep(.ai-document-card.is-warning .ai-document-card__reason) {
color: #1e293b; color: #92400e;
font-size: 13px;
font-weight: 600;
line-height: 1.3;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__dept) { .workbench-ai-answer-markdown :deep(.ai-document-card.is-danger .ai-document-card__reason) {
color: #64748b; color: #991b1b;
font-size: 13px;
font-weight: 500;
line-height: 1.3;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__dot) { .workbench-ai-answer-markdown :deep(.ai-document-card__details) {
color: #cbd5e1;
font-size: 12px;
font-weight: 700;
}
/* 卡片底部:辅助元信息(左) · 金额(右) · 操作 */
.workbench-ai-answer-markdown :deep(.ai-document-card__foot) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 12px;
border-top: 1px solid rgba(226, 232, 240, 0.9);
}
.workbench-ai-answer-markdown :deep(.ai-document-card__meta) {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
flex: 1 1 auto;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__meta-item) {
color: #64748b;
font-size: 12px;
font-weight: 500;
line-height: 1.3;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__amount-block) {
display: grid; display: grid;
justify-items: end; grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px; gap: 12px 28px;
flex: 0 0 auto; padding-top: 2px;
border-top: 1px solid rgba(203, 213, 225, 0.76);
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__amount-label) { .workbench-ai-answer-markdown :deep(.ai-document-card__field) {
color: #94a3b8; display: grid;
font-size: 11px; grid-template-columns: 86px minmax(0, 1fr);
font-weight: 500; align-items: center;
line-height: 1.2; gap: 14px;
min-width: 0;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__field--action) {
grid-column: 1 / -1;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__label) {
color: #8a94a6;
font-size: 13px;
font-weight: 640;
line-height: 1.4;
white-space: nowrap;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__value) {
min-width: 0;
color: #334155;
font-size: 14px;
font-weight: 720;
line-height: 1.45;
overflow-wrap: anywhere;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__amount) { .workbench-ai-answer-markdown :deep(.ai-document-card__amount) {
color: #0f172a; color: #0f172a;
font-size: 17px; font-size: 18px;
font-weight: 700; font-weight: 900;
line-height: 1.2; line-height: 1.2;
white-space: nowrap; white-space: nowrap;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__number) {
color: #64748b;
font-size: 13px;
font-weight: 740;
letter-spacing: 0;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__action) { .workbench-ai-answer-markdown :deep(.ai-document-card__action) {
flex: 0 0 auto; display: inline-flex;
align-items: center;
width: fit-content;
min-height: 26px;
padding: 0;
border-radius: 0;
background: transparent;
color: #1d4ed8;
font-size: 14px;
font-weight: 820;
box-shadow: none;
white-space: nowrap;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__action:hover) {
background: transparent;
color: #1e40af;
text-decoration: underline;
} }
.workbench-ai-answer-markdown :deep(.markdown-table-wrap), .workbench-ai-answer-markdown :deep(.markdown-table-wrap),
@@ -1547,31 +1576,37 @@
} }
.workbench-ai-answer-markdown :deep(.ai-document-card) { .workbench-ai-answer-markdown :deep(.ai-document-card) {
padding: 14px; padding: 0;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__head) { .workbench-ai-answer-markdown :deep(.ai-document-card__head) {
align-items: flex-start; align-items: flex-start;
flex-direction: column;
gap: 8px;
padding: 12px 14px;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__body) {
padding: 14px;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__details) {
grid-template-columns: 1fr;
gap: 10px;
}
.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) { .workbench-ai-answer-markdown :deep(.ai-document-card__number) {
flex-basis: 100%;
text-align: left; text-align: left;
} }
.workbench-ai-answer-markdown :deep(.ai-document-card__foot) {
flex-wrap: wrap;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__amount-block) {
justify-items: start;
order: 2;
}
.workbench-ai-answer-markdown :deep(.ai-document-card__action) {
order: 3;
margin-left: auto;
}
} }
.workbench-ai-application-preview { .workbench-ai-application-preview {

View File

@@ -10,6 +10,7 @@ import { fetchOntologyParse } from '../services/ontology.js'
import { fetchLatestConversation } from '../services/orchestrator.js' import { fetchLatestConversation } from '../services/orchestrator.js'
import { markAiWorkbenchConversationDraftDeleted } from '../utils/aiWorkbenchConversationStore.js' import { markAiWorkbenchConversationDraftDeleted } from '../utils/aiWorkbenchConversationStore.js'
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js' import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js'
import { isApplicationDocumentNo } from '../utils/documentClassification.js'
import { import {
ASSISTANT_SCOPE_SESSION_STEWARD, ASSISTANT_SCOPE_SESSION_STEWARD,
buildUnsupportedBusinessScopeConversation, buildUnsupportedBusinessScopeConversation,
@@ -428,8 +429,7 @@ export function useAppShell() {
return ( return (
documentType === 'application' documentType === 'application'
|| documentType === 'expense_application' || documentType === 'expense_application'
|| normalizedClaimNo.startsWith('AP-') || isApplicationDocumentNo(normalizedClaimNo)
|| normalizedClaimNo.startsWith('APP-')
) )
} }

View File

@@ -1,6 +1,7 @@
import { computed, reactive, ref } from 'vue' import { computed, reactive, ref } from 'vue'
import { fetchAllExpenseClaims } from '../services/reimbursements.js' import { fetchAllExpenseClaims } from '../services/reimbursements.js'
import { isApplicationDocumentNo } from '../utils/documentClassification.js'
import { filterActionableRiskFlags, normalizeRiskFlagTone } from '../utils/riskFlags.js' import { filterActionableRiskFlags, normalizeRiskFlagTone } from '../utils/riskFlags.js'
const EXPENSE_TYPE_LABELS = { const EXPENSE_TYPE_LABELS = {
@@ -212,8 +213,7 @@ function resolveDocumentTypeMeta(claim, typeCode) {
const isApplication = const isApplication =
explicitType === DOCUMENT_TYPE_APPLICATION explicitType === DOCUMENT_TYPE_APPLICATION
|| explicitType === 'expense_application' || explicitType === 'expense_application'
|| claimNo.startsWith('AP-') || isApplicationDocumentNo(claimNo)
|| claimNo.startsWith('APP-')
|| normalizedType === 'application' || normalizedType === 'application'
|| normalizedType.endsWith('_application') || normalizedType.endsWith('_application')

View File

@@ -1,4 +1,5 @@
import { extractExpenseClaimItems } from '../services/reimbursements.js' import { extractExpenseClaimItems } from '../services/reimbursements.js'
import { isApplicationDocumentNo } from './documentClassification.js'
const DOCUMENT_QUERY_LIMIT = 8 const DOCUMENT_QUERY_LIMIT = 8
@@ -336,8 +337,7 @@ function resolveDocumentTypeCode(claim = {}) {
|| explicitType === 'expense_application' || explicitType === 'expense_application'
|| expenseType === 'application' || expenseType === 'application'
|| expenseType.endsWith('_application') || expenseType.endsWith('_application')
|| documentNo.startsWith('AP-') || isApplicationDocumentNo(documentNo)
|| documentNo.startsWith('APP-')
) { ) {
return 'application' return 'application'
} }
@@ -679,49 +679,66 @@ function buildDocumentCardHtml(record = {}) {
const typeClass = record.documentType === 'application' ? 'application' : 'reimbursement' const typeClass = record.documentType === 'application' ? 'application' : 'reimbursement'
const statusTone = record.statusTone || 'is-pending' const statusTone = record.statusTone || 'is-pending'
const amountLabel = record.documentType === 'application' ? '预计金额' : '报销金额' const amountLabel = record.documentType === 'application' ? '预计金额' : '报销金额'
const ownerText = [record.ownerLabel, record.departmentLabel]
// footer 左侧辅助元信息:业务地点(可选)+ 时间 .filter((item) => item && item !== '未显示')
const metaParts = [] .join(' · ') || '未显示'
if (record.locationLabel) {
metaParts.push(`<span class="ai-document-card__meta-item">${escapeHtml(record.locationLabel)}</span>`)
}
metaParts.push(`<span class="ai-document-card__meta-item">${escapeHtml(record.time || '待补充')}</span>`)
const metaHtml = `<div class="ai-document-card__meta">${metaParts.join('<span class="ai-document-card__dot">·</span>')}</div>`
return [ return [
`<article class="ai-document-card ai-document-card--${typeClass} ${statusTone}" aria-label="单据详情">`, `<article class="ai-document-card ai-document-card--${typeClass} ${statusTone}" aria-label="单据详情">`,
'<header class="ai-document-card__head">', '<header class="ai-document-card__head">',
'<div class="ai-document-card__head-left">', `<strong class="ai-document-card__reason">${escapeHtml(record.reason)}</strong>`,
`<span class="ai-document-card__status">${escapeHtml(record.statusLabel)}</span>`, `<span class="ai-document-card__status">${escapeHtml(record.statusLabel)}</span>`,
`<span class="ai-document-card__type">${escapeHtml(record.documentTypeLabel)} · ${escapeHtml(record.typeLabel)}</span>`,
'</div>',
`<span class="ai-document-card__number">${escapeHtml(record.documentNo || '未编号单据')}</span>`,
'</header>', '</header>',
'<div class="ai-document-card__body">', '<div class="ai-document-card__body">',
`<strong class="ai-document-card__reason">${escapeHtml(record.reason)}</strong>`, '<div class="ai-document-card__details">',
'<div class="ai-document-card__owner-line">', '<div class="ai-document-card__field">',
`<span class="ai-document-card__owner">${escapeHtml(record.ownerLabel)}</span>`, '<span class="ai-document-card__label">单据类型</span>',
'<span class="ai-document-card__dot">·</span>', `<strong class="ai-document-card__value">${escapeHtml(record.documentTypeLabel)} · ${escapeHtml(record.typeLabel)}</strong>`,
`<span class="ai-document-card__dept">${escapeHtml(record.departmentLabel)}</span>`,
'</div>', '</div>',
'</div>', '<div class="ai-document-card__field">',
'<footer class="ai-document-card__foot">', `<span class="ai-document-card__label">${escapeHtml(amountLabel)}</span>`,
metaHtml,
'<div class="ai-document-card__amount-block">',
`<span class="ai-document-card__amount-label">${escapeHtml(amountLabel)}</span>`,
`<strong class="ai-document-card__amount">${escapeHtml(record.amountLabel)}</strong>`, `<strong class="ai-document-card__amount">${escapeHtml(record.amountLabel)}</strong>`,
'</div>', '</div>',
'<div class="ai-document-card__field">',
'<span class="ai-document-card__label">申请人</span>',
`<strong class="ai-document-card__value">${escapeHtml(ownerText)}</strong>`,
'</div>',
'<div class="ai-document-card__field">',
'<span class="ai-document-card__label">单据编号</span>',
`<strong class="ai-document-card__value ai-document-card__number">${escapeHtml(record.documentNo || '未编号单据')}</strong>`,
'</div>',
'<div class="ai-document-card__field ai-document-card__field--action">',
'<span class="ai-document-card__label">操作</span>',
href href
? `<a class="ai-html-action-link ai-html-action-link-document ai-document-card__action" data-ai-action="open-document-detail" href="${escapeHtml(href)}">查看详情</a>` ? `<a class="ai-html-action-link ai-html-action-link-document ai-document-card__action" data-ai-action="open-document-detail" href="${escapeHtml(href)}">查看详情</a>`
: '', : '<span class="ai-document-card__value">暂无详情</span>',
'</footer>', '</div>',
'</div>',
'</div>',
'</article>' '</article>'
].join('') ].join('')
} }
function buildDocumentCardsHtml(records = []) { function buildDocumentQuerySummaryHtml(scopeText = '', totalCount = 0, visibleCount = 0) {
return [
'<section class="ai-document-query-summary" aria-label="单据查询范围">',
'<span class="ai-document-query-summary__label">查询范围</span>',
`<strong class="ai-document-query-summary__scope">${escapeHtml(scopeText || '相关单据')}</strong>`,
'<span class="ai-document-query-summary__count">',
`共 <strong>${escapeHtml(String(totalCount))}</strong> 张`,
'</span>',
'<span class="ai-document-query-summary__count">',
`展示最近 <strong>${escapeHtml(String(visibleCount))}</strong> 张`,
'</span>',
'</section>'
].join('')
}
function buildDocumentCardsHtml(records = [], options = {}) {
const querySummaryHtml = options.querySummaryHtml || ''
return [ return [
'<!-- ai-trusted-html:start -->', '<!-- ai-trusted-html:start -->',
querySummaryHtml,
'<section class="ai-document-card-list" aria-label="单据查询结果">', '<section class="ai-document-card-list" aria-label="单据查询结果">',
...records.map((record) => buildDocumentCardHtml(record)), ...records.map((record) => buildDocumentCardHtml(record)),
'</section>', '</section>',
@@ -772,9 +789,9 @@ export function buildAiDocumentQueryMessage(intent = {}, claimsPayload = []) {
const lines = [ const lines = [
'### 已查询到相关单据', '### 已查询到相关单据',
'', '',
`**查询范围**${scopeText || '相关单据'};共找到 **${records.length}** 张,先展示最近 **${visibleRecords.length}** 张。`, buildDocumentCardsHtml(visibleRecords, {
'', querySummaryHtml: buildDocumentQuerySummaryHtml(scopeText, records.length, visibleRecords.length)
buildDocumentCardsHtml(visibleRecords) })
] ]
if (records.length > visibleRecords.length) { if (records.length > visibleRecords.length) {

View File

@@ -1,5 +1,6 @@
import { filterActionableRiskFlags, normalizeRiskFlagTone } from './riskFlags.js' import { filterActionableRiskFlags, normalizeRiskFlagTone } from './riskFlags.js'
import { canViewRiskForContext } from './riskVisibility.js' import { canViewRiskForContext } from './riskVisibility.js'
import { isApplicationDocumentNo } from './documentClassification.js'
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance']) const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set(['travel', 'meeting', 'entertainment']) const LOCATION_REQUIRED_EXPENSE_TYPES = new Set(['travel', 'meeting', 'entertainment'])
@@ -37,8 +38,7 @@ function isApplicationDocumentRequest(request) {
return ( return (
documentType === 'application' documentType === 'application'
|| documentType === 'expense_application' || documentType === 'expense_application'
|| claimNo.startsWith('AP-') || isApplicationDocumentNo(claimNo)
|| claimNo.startsWith('APP-')
|| typeCode === 'application' || typeCode === 'application'
|| typeCode.endsWith('_application') || typeCode.endsWith('_application')
) )

View File

@@ -1,3 +1,14 @@
const APPLICATION_DOCUMENT_NO_PATTERN = /^A[A-HJ-NP-Z2-9]{8}$/i
export function isApplicationDocumentNo(value) {
const claimNo = String(value || '').trim().toUpperCase()
return (
APPLICATION_DOCUMENT_NO_PATTERN.test(claimNo)
|| claimNo.startsWith('AP-')
|| claimNo.startsWith('APP-')
)
}
export function isApplicationRequestLike(value) { export function isApplicationRequestLike(value) {
const explicitType = String( const explicitType = String(
value?.documentTypeCode value?.documentTypeCode
@@ -14,8 +25,7 @@ export function isApplicationRequestLike(value) {
return ( return (
explicitType === 'application' explicitType === 'application'
|| explicitType === 'expense_application' || explicitType === 'expense_application'
|| claimNo.startsWith('AP-') || isApplicationDocumentNo(claimNo)
|| claimNo.startsWith('APP-')
|| typeCode === 'application' || typeCode === 'application'
|| typeCode.endsWith('_application') || typeCode.endsWith('_application')
) )

View File

@@ -6,6 +6,7 @@ import {
isFinanceUser, isFinanceUser,
isPlatformAdminUser isPlatformAdminUser
} from './accessControl.js' } from './accessControl.js'
import { isApplicationDocumentNo } from './documentClassification.js'
const APPLICATION_STAGE_ALIASES = new Set([ const APPLICATION_STAGE_ALIASES = new Set([
'expense_application', 'expense_application',
@@ -159,8 +160,7 @@ export function isApplicationRiskStageRequest(request = {}) {
return ( return (
documentType === 'application' || documentType === 'application' ||
documentType === 'expense_application' || documentType === 'expense_application' ||
claimNo.startsWith('AP-') || isApplicationDocumentNo(claimNo) ||
claimNo.startsWith('APP-') ||
typeCode === 'application' || typeCode === 'application' ||
typeCode.endsWith('_application') typeCode.endsWith('_application')
) )

View File

@@ -273,7 +273,27 @@ const sidebarCollapsed = ref(false)
const sidebarCollapsedBeforeAiMode = ref(false) const sidebarCollapsedBeforeAiMode = ref(false)
const mobileSidebarOpen = ref(false) const mobileSidebarOpen = ref(false)
const overviewDashboard = ref('finance') const overviewDashboard = ref('finance')
const workbenchMode = ref('traditional') const { companyProfile, currentUser, logout } = useSystemState()
function resolveDefaultWorkbenchMode(user) {
return isPlatformAdminUser(user) ? 'traditional' : 'ai'
}
function resolveWorkbenchUserKey(user = {}) {
const roleCodes = Array.isArray(user?.roleCodes) ? user.roleCodes.join(',') : ''
return [
user?.id,
user?.userId,
user?.username,
user?.account,
user?.name,
user?.role,
roleCodes,
user?.isAdmin ? 'admin' : 'user'
].map((item) => String(item || '').trim()).join('|')
}
const workbenchMode = ref(resolveDefaultWorkbenchMode(currentUser.value))
const aiSidebarCommandSeq = ref(0) const aiSidebarCommandSeq = ref(0)
const aiSidebarCommand = ref({ seq: 0, type: '', payload: null }) const aiSidebarCommand = ref({ seq: 0, type: '', payload: null })
const aiActiveConversationId = ref('') const aiActiveConversationId = ref('')
@@ -343,7 +363,6 @@ const {
topBarView topBarView
} = useAppShell() } = useAppShell()
const { companyProfile, currentUser, logout } = useSystemState()
const PRODUCT_DISPLAY_NAME = '易财费控' const PRODUCT_DISPLAY_NAME = '易财费控'
const ENTERPRISE_DISPLAY_NAME = '远光软件股份有限公司' const ENTERPRISE_DISPLAY_NAME = '远光软件股份有限公司'
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value)) const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
@@ -496,7 +515,14 @@ function handleLogout() {
watch( watch(
() => currentUser.value, () => currentUser.value,
(user) => { (user, previousUser) => {
if (resolveWorkbenchUserKey(user) !== resolveWorkbenchUserKey(previousUser)) {
const nextMode = resolveDefaultWorkbenchMode(user)
workbenchMode.value = nextMode
if (nextMode === 'ai') {
sidebarCollapsed.value = false
}
}
aiConversationHistory.value = loadAiWorkbenchConversationHistory(user || {}) aiConversationHistory.value = loadAiWorkbenchConversationHistory(user || {})
}, },
{ immediate: true } { immediate: true }

View File

@@ -1,3 +1,5 @@
import { isApplicationDocumentNo } from '../../utils/documentClassification.js'
const REQUIRED_APPLICATION_EXPENSE_TYPES = new Set(['travel', 'meal']) const REQUIRED_APPLICATION_EXPENSE_TYPES = new Set(['travel', 'meal'])
const APPLICATION_TYPE_ALIASES = { const APPLICATION_TYPE_ALIASES = {
@@ -302,8 +304,7 @@ export function isExpenseApplicationClaim(claim) {
return documentType === 'application' return documentType === 'application'
|| documentType === 'expense_application' || documentType === 'expense_application'
|| claimNo.startsWith('AP-') || isApplicationDocumentNo(claimNo)
|| claimNo.startsWith('APP-')
|| expenseType === 'application' || expenseType === 'application'
|| expenseType.endsWith('_application') || expenseType.endsWith('_application')
} }

View File

@@ -1,3 +1,5 @@
import { isApplicationDocumentNo } from '../../utils/documentClassification.js'
function normalizeText(value) { function normalizeText(value) {
return String(value || '').trim() return String(value || '').trim()
} }
@@ -22,7 +24,7 @@ function isApplicationDocumentRequest(requestModel) {
|| requestModel?.document_type || requestModel?.document_type
).toLowerCase() ).toLowerCase()
const claimNo = normalizeText(requestModel?.claimNo || requestModel?.claim_no || requestModel?.documentNo).toUpperCase() const claimNo = normalizeText(requestModel?.claimNo || requestModel?.claim_no || requestModel?.documentNo).toUpperCase()
return documentType === 'application' || claimNo.startsWith('AP-') || claimNo.startsWith('APP-') return documentType === 'application' || isApplicationDocumentNo(claimNo)
} }
function isHotelExpenseItem(item) { function isHotelExpenseItem(item) {

View File

@@ -1,3 +1,5 @@
import { isApplicationDocumentNo } from '../../utils/documentClassification.js'
function normalizeText(value) { function normalizeText(value) {
return String(value || '').trim() return String(value || '').trim()
} }
@@ -112,7 +114,7 @@ export function resolveRequestBusinessStage(request = {}) {
|| request?.document_no || request?.document_no
|| request?.id || request?.id
).toUpperCase() ).toUpperCase()
if (claimNo.startsWith('AP-') || claimNo.startsWith('APP-')) { if (isApplicationDocumentNo(claimNo)) {
return 'expense_application' return 'expense_application'
} }

View File

@@ -1,3 +1,5 @@
import { isApplicationDocumentNo } from '../../utils/documentClassification.js'
export const EXPENSE_TYPE_OPTIONS = [ export const EXPENSE_TYPE_OPTIONS = [
{ value: 'travel', label: '差旅费' }, { value: 'travel', label: '差旅费' },
{ value: 'train_ticket', label: '火车票' }, { value: 'train_ticket', label: '火车票' },
@@ -83,8 +85,7 @@ export function isApplicationDocumentRequest(request) {
return ( return (
documentType === 'application' documentType === 'application'
|| documentType === 'expense_application' || documentType === 'expense_application'
|| claimNo.startsWith('AP-') || isApplicationDocumentNo(claimNo)
|| claimNo.startsWith('APP-')
|| typeCode === 'application' || typeCode === 'application'
|| typeCode.endsWith('_application') || typeCode.endsWith('_application')
) )

View File

@@ -718,7 +718,7 @@ export function useTravelReimbursementFlow({
function buildApplicationDuplicateDetail(payload) { function buildApplicationDuplicateDetail(payload) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {} const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const answer = String(result.answer || result.message || '').trim() const answer = String(result.answer || result.message || '').trim()
const claimNo = answer.match(/AP-\d{14}-[A-HJ-NP-Z2-9]{8}/)?.[0] || '' const claimNo = answer.match(/A[A-HJ-NP-Z2-9]{8}|AP-\d{14}-[A-HJ-NP-Z2-9]{8}|APP-\d{8}-[A-Z0-9]{6}/)?.[0] || ''
return claimNo return claimNo
? `已拦截重复申请,已有申请单:${claimNo}` ? `已拦截重复申请,已有申请单:${claimNo}`
: '已拦截重复申请,未创建新申请单' : '已拦截重复申请,未创建新申请单'

View File

@@ -5,6 +5,7 @@ import {
collectReceiptFiles collectReceiptFiles
} from './travelReimbursementAttachmentModel.js' } from './travelReimbursementAttachmentModel.js'
import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js' import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js'
import { isApplicationDocumentNo } from '../../utils/documentClassification.js'
import { import {
APPLICATION_TRANSPORT_MODE_OPTIONS, APPLICATION_TRANSPORT_MODE_OPTIONS,
applyApplicationBusinessTimeContext, applyApplicationBusinessTimeContext,
@@ -148,8 +149,7 @@ function isApplicationClaimRecord(claim) {
expenseType === 'application' || expenseType === 'application' ||
expenseType === 'expense_application' || expenseType === 'expense_application' ||
expenseType.endsWith('_application') || expenseType.endsWith('_application') ||
claimNo.startsWith('AP-') || isApplicationDocumentNo(claimNo) ||
claimNo.startsWith('APP-') ||
Boolean(extractApplicationDetailFromClaim(claim)) Boolean(extractApplicationDetailFromClaim(claim))
) )
} }

View File

@@ -127,24 +127,33 @@ test('AI document query message renders html document cards with detail actions'
assert.match(message, /### 已查询到相关单据/) assert.match(message, /### 已查询到相关单据/)
assert.match(message, /<!-- ai-trusted-html:start -->/) assert.match(message, /<!-- ai-trusted-html:start -->/)
assert.match(message, /<section class="ai-document-query-summary" aria-label="单据查询范围">/)
assert.match(message, /<span class="ai-document-query-summary__label">查询范围<\/span>/)
assert.match(message, /<strong class="ai-document-query-summary__scope">/)
assert.match(message, /<span class="ai-document-query-summary__count">/)
assert.match(message, /<section class="ai-document-card-list" aria-label="单据查询结果">/) assert.match(message, /<section class="ai-document-card-list" aria-label="单据查询结果">/)
// 申请单 app-1 状态为 approved → is-success 语义类 // 申请单 app-1 状态为 approved → is-success 语义类
assert.match(message, /<article class="ai-document-card ai-document-card--application is-success" aria-label="单据详情">/) assert.match(message, /<article class="ai-document-card ai-document-card--application is-success" aria-label="单据详情">/)
assert.match(message, /<header class="ai-document-card__head">/) assert.match(message, /<header class="ai-document-card__head">/)
assert.match(message, /<span class="ai-document-card__status">已审批<\/span>/) assert.match(message, /<span class="ai-document-card__status">已审批<\/span>/)
assert.match(message, /<strong class="ai-document-card__reason">辅助国网仿生产服务器部署<\/strong>/) assert.match(message, /<strong class="ai-document-card__reason">辅助国网仿生产服务器部署<\/strong>/)
assert.match(message, /<span class="ai-document-card__owner">曹小筑<\/span>/) assert.match(message, /<div class="ai-document-card__details">/)
assert.match(message, /<span class="ai-document-card__dept">交付部<\/span>/) assert.match(message, /<span class="ai-document-card__label">单据类型<\/span>/)
assert.match(message, /<span class="ai-document-card__number">AP-20260220001<\/span>/) assert.match(message, /<strong class="ai-document-card__value">申请单 · 差旅费用申请<\/strong>/)
assert.match(message, /<span class="ai-document-card__label">申请人<\/span>/)
assert.match(message, /<strong class="ai-document-card__value">曹小筑 · 交付部<\/strong>/)
assert.match(message, /<strong class="ai-document-card__value ai-document-card__number">AP-20260220001<\/strong>/)
assert.match(message, /<strong class="ai-document-card__amount">¥3,000\.00<\/strong>/) assert.match(message, /<strong class="ai-document-card__amount">¥3,000\.00<\/strong>/)
assert.match(message, /<div class="ai-document-card__meta">/) assert.match(message, /<div class="ai-document-card__field ai-document-card__field--action">/)
assert.match(message, /<span class="ai-document-card__meta-item">上海<\/span>/) 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:AP-20260220001"/)
// 报销单 claim-1 状态为 submitted → is-pending 语义类 // 报销单 claim-1 状态为 submitted → is-pending 语义类
assert.match(message, /<article class="ai-document-card ai-document-card--reimbursement is-pending" aria-label="单据详情">/) assert.match(message, /<article class="ai-document-card ai-document-card--reimbursement is-pending" aria-label="单据详情">/)
assert.match(message, /href="#ai-open-document-detail:CL-20260221001"/) assert.match(message, /href="#ai-open-document-detail:CL-20260221001"/)
assert.doesNotMatch(message, /\| 单据编号 \|/) assert.doesNotMatch(message, /\| 单据编号 \|/)
assert.doesNotMatch(message, /^> /m) assert.doesNotMatch(message, /^> /m)
assert.doesNotMatch(message, /\*\*查询范围\*\*/)
}) })
test('AI document query html cards render as trusted card markup', () => { test('AI document query html cards render as trusted card markup', () => {
@@ -152,13 +161,16 @@ test('AI document query html cards render as trusted card markup', () => {
const rendered = renderAiConversationHtml(buildAiDocumentQueryMessage(intent, claims)) const rendered = renderAiConversationHtml(buildAiDocumentQueryMessage(intent, claims))
assert.match(rendered, /<h3 class="ai-html-title">已查询到相关单据<\/h3>/) assert.match(rendered, /<h3 class="ai-html-title">已查询到相关单据<\/h3>/)
assert.match(rendered, /<section class="ai-document-query-summary" aria-label="单据查询范围">/)
assert.match(rendered, /<section class="ai-document-card-list" aria-label="单据查询结果">/) assert.match(rendered, /<section class="ai-document-card-list" aria-label="单据查询结果">/)
assert.match(rendered, /<article class="ai-document-card ai-document-card--application is-success" aria-label="单据详情">/) assert.match(rendered, /<article class="ai-document-card ai-document-card--application is-success" aria-label="单据详情">/)
assert.match(rendered, /class="ai-document-card__head"/) assert.match(rendered, /class="ai-document-card__head"/)
assert.match(rendered, /class="ai-document-card__meta"/) assert.match(rendered, /class="ai-document-card__details"/)
assert.match(rendered, /class="ai-document-card__meta-item"/) 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, /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:CL-20260221001"/)
assert.doesNotMatch(rendered, /ai-document-card__meta/)
assert.doesNotMatch(rendered, /&lt;section class=&quot;ai-document-card-list/) assert.doesNotMatch(rendered, /&lt;section class=&quot;ai-document-card-list/)
assert.doesNotMatch(rendered, /<blockquote>/) assert.doesNotMatch(rendered, /<blockquote>/)
}) })

View File

@@ -0,0 +1,20 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
isApplicationDocumentNo,
isApplicationRequestLike
} from '../src/utils/documentClassification.js'
test('application document number detection supports short and legacy formats', () => {
assert.equal(isApplicationDocumentNo('A7K3M9Q2P'), true)
assert.equal(isApplicationDocumentNo('AP-20260525103045-ABCDEFGH'), true)
assert.equal(isApplicationDocumentNo('APP-20260525-ABC123'), true)
assert.equal(isApplicationDocumentNo('R7K3M9Q2P'), false)
assert.equal(isApplicationDocumentNo('RE-20260525103045-HGFEDCBA'), false)
})
test('application request classification can rely on a short document number', () => {
assert.equal(isApplicationRequestLike({ claim_no: 'A7K3M9Q2P' }), true)
assert.equal(isApplicationRequestLike({ claim_no: 'R7K3M9Q2P' }), false)
})

View File

@@ -172,7 +172,10 @@ const orbIconPngAsset = fileURLToPath(
const orbIconBuffer = readFileSync(orbIconAsset) const orbIconBuffer = readFileSync(orbIconAsset)
test('app shell owns the workbench mode and wires it through topbar and content', () => { test('app shell owns the workbench mode and wires it through topbar and content', () => {
assert.match(appShell, /const workbenchMode = ref\('traditional'\)/) assert.match(appShell, /function resolveDefaultWorkbenchMode\(user\)\s*\{[\s\S]*isPlatformAdminUser\(user\)[\s\S]*'traditional'[\s\S]*'ai'/)
assert.match(appShell, /const workbenchMode = ref\(resolveDefaultWorkbenchMode\(currentUser\.value\)\)/)
assert.doesNotMatch(appShell, /const workbenchMode = ref\('traditional'\)/)
assert.match(appShell, /watch\(\s*\(\) => currentUser\.value,[\s\S]*resolveDefaultWorkbenchMode\(user\)/)
assert.match(appShell, /function toggleWorkbenchMode\(\)/) assert.match(appShell, /function toggleWorkbenchMode\(\)/)
assert.match(appShell, /const nextMode = workbenchMode\.value === 'ai' \? 'traditional' : 'ai'/) assert.match(appShell, /const nextMode = workbenchMode\.value === 'ai' \? 'traditional' : 'ai'/)
assert.match(appShell, /sidebarCollapsedBeforeAiMode\.value = sidebarCollapsed\.value/) assert.match(appShell, /sidebarCollapsedBeforeAiMode\.value = sidebarCollapsed\.value/)
@@ -260,11 +263,21 @@ test('AI mode screen follows the approved reference structure', () => {
assert.doesNotMatch(aiMode, /message\.pending \?/) assert.doesNotMatch(aiMode, /message\.pending \?/)
assert.match(aiMode, /继续和小财管家对话\.\.\./) assert.match(aiMode, /继续和小财管家对话\.\.\./)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\)/)
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\.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__head\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__body\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__body\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__foot\)/) 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__label\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__amount\)/) assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__amount\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__meta\)/) 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-html-action-link\)/) 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-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-list\)/)