feat(web): AI 文档详情引用解析与查询卡片增强

- 新增 aiDocumentDetailReference,统一解析 #ai-open-document-detail / #ai-open-application-detail 引用,兼容 A/R/D 短格式与 AP-/RE-/AD- 旧格式单号,提供 isBusinessDocumentReference 判定
- aiDocumentQueryModel 文档卡片接入详情引用,按申请单/报销单生成对应 href,HTML 渲染器识别单据记录表格并生成卡片链接
- PersonalWorkbenchAiMode 处理文档详情点击跳转,卡片样式重构为结构化布局并更新背景资源
- expenseApplicationPreview 补充事由字段,同步新增/更新 ai-document-detail-reference、document-query-model、html-renderer、workbench-ai-mode 等测试
- 更新公司通信费报销规则表
This commit is contained in:
caoxiaozhu
2026-06-21 22:49:53 +08:00
parent 3b74a330a3
commit 8b3495455b
15 changed files with 832 additions and 318 deletions

View File

@@ -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*ai-trusted-html:start\s*-->\s*([\s\S]*?)\s*<!--\s*ai-trusted-html:end\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 [
'<span class="ai-html-record-meta-item">',
`<small>${escapeHtml(label)}</small>`,
`<b>${renderInlineHtml(value)}</b>`,
'</span>'
`<div class="ai-document-card__field${options.fieldClass ? ` ${options.fieldClass}` : ''}">`,
`<span class="ai-document-card__label">${escapeHtml(label)}</span>`,
`<strong class="ai-document-card__value${valueClass}">${renderInlineHtml(value)}</strong>`,
'</div>'
].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 [
'<div class="ai-document-card__field ai-document-card__field--action">',
'<span class="ai-document-card__label">操作</span>',
actionHtml,
'</div>'
].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 [
'<article class="ai-html-record-item" role="listitem">',
'<div class="ai-html-record-main">',
hasMeaningfulTableValue(documentType) ? `<span class="ai-html-record-kicker">${renderInlineHtml(documentType)}</span>` : '',
hasMeaningfulTableValue(documentNo) ? `<strong class="ai-html-record-id">${renderInlineHtml(documentNo)}</strong>` : '',
hasMeaningfulTableValue(reason) ? `<p class="ai-html-record-reason">${renderInlineHtml(reason)}</p>` : '',
`<article class="ai-document-card ${tone}" role="listitem" aria-label="单据详情">`,
'<header class="ai-document-card__head">',
`<strong class="ai-document-card__reason">${renderInlineHtml(title)}</strong>`,
hasMeaningfulTableValue(status) ? `<span class="ai-document-card__status">${renderInlineHtml(status)}</span>` : '',
'</header>',
'<div class="ai-document-card__body">',
summaryHtml ? `<div class="ai-document-card__summary">${summaryHtml}</div>` : '',
'<div class="ai-document-card__details">',
detailsHtml,
'</div>',
'<div class="ai-html-record-meta">',
renderRecordMeta('申请时间', applyTime),
renderRecordMeta('状态', status),
renderRecordMeta('当前节点', stage),
'</div>',
hasMeaningfulTableValue(action) ? `<div class="ai-html-record-action">${renderInlineHtml(action)}</div>` : '',
'</article>'
].join('')
}).filter(Boolean)
return [
'<div class="ai-html-record-list" role="list">',
'<section class="ai-document-card-list" role="list" aria-label="单据结果">',
...items,
'</div>'
'</section>'
].join('')
}

View File

@@ -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'
}
}

View File

@@ -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, '&amp;')
@@ -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 [
`<div class="ai-document-card__field${fieldClass}">`,
`<span class="ai-document-card__label">${escapeHtml(label)}</span>`,
`<strong class="ai-document-card__value${valueClass}">${escapeHtml(text)}</strong>`,
'</div>'
].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),
[
'<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>`
: '<span class="ai-document-card__value">暂无详情</span>',
'</div>'
].join(''),
buildDocumentCardFieldHtml('单据类型', documentTypeText)
].join('')
return [
`<article class="ai-document-card ai-document-card--${typeClass} ${statusTone}" aria-label="单据详情">`,
`<article class="ai-document-card ai-document-card--${typeClass}${approvalTaskClass} ${statusTone}" aria-label="单据详情">`,
'<header class="ai-document-card__head">',
`<strong class="ai-document-card__reason">${escapeHtml(record.reason)}</strong>`,
`<strong class="ai-document-card__reason">${escapeHtml(title)}</strong>`,
`<span class="ai-document-card__status">${escapeHtml(record.statusLabel)}</span>`,
'</header>',
'<div class="ai-document-card__body">',
summaryHtml ? `<div class="ai-document-card__summary">${summaryHtml}</div>` : '',
'<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 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>`
: '<span class="ai-document-card__value">暂无详情</span>',
'</div>',
detailsHtml,
'</div>',
'</div>',
'</article>'

View File

@@ -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, '')