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

@@ -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(