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

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

View File

@@ -1,5 +1,6 @@
import { filterActionableRiskFlags, normalizeRiskFlagTone } from './riskFlags.js'
import { canViewRiskForContext } from './riskVisibility.js'
import { isApplicationDocumentNo } from './documentClassification.js'
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set(['travel', 'meeting', 'entertainment'])
@@ -37,8 +38,7 @@ function isApplicationDocumentRequest(request) {
return (
documentType === 'application'
|| documentType === 'expense_application'
|| claimNo.startsWith('AP-')
|| claimNo.startsWith('APP-')
|| isApplicationDocumentNo(claimNo)
|| typeCode === '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) {
const explicitType = String(
value?.documentTypeCode
@@ -14,8 +25,7 @@ export function isApplicationRequestLike(value) {
return (
explicitType === 'application'
|| explicitType === 'expense_application'
|| claimNo.startsWith('AP-')
|| claimNo.startsWith('APP-')
|| isApplicationDocumentNo(claimNo)
|| typeCode === 'application'
|| typeCode.endsWith('_application')
)

View File

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