feat(web): 工作台 AI 模式报销预审与文档查询模型拆分

- 新增 aiApplicationPrecheckModel/aiDocumentQueryModel/aiApplicationPreviewActions/aiConversationHtmlRenderer 四个独立模型与服务,按职责从主组件拆出
- PersonalWorkbenchAiMode 接入拆分后的预审、文档查询与 HTML 渲染逻辑,配合 markdown 工具增强结构化展示
- 文档中心与归档筛选、风险可见性、申请预览等工具同步适配,补充对应单元测试
- 新增 AI 文档卡片背景资源
This commit is contained in:
caoxiaozhu
2026-06-20 10:17:37 +08:00
parent 3d69f8501f
commit 304bbe1fd4
26 changed files with 3974 additions and 117 deletions

View File

@@ -0,0 +1,345 @@
import { extractExpenseClaimItems } from '../services/reimbursements.js'
import {
isClaimOwnedByCurrentUser,
isExpenseApplicationClaim,
matchesRequiredApplicationExpenseType,
normalizeRequiredApplicationCandidate
} from '../views/scripts/travelReimbursementApplicationLinkModel.js'
import {
normalizeApplicationPreview,
resolveApplicationDateRange
} from './expenseApplicationPreview.js'
const APPLICATION_BUDGET_REVIEW_THRESHOLD = 90
function normalizeText(value) {
return String(value || '').trim()
}
function normalizeMoney(value) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : 0
}
const normalized = normalizeText(value).replace(/,/g, '')
const match = normalized.match(/-?\d+(?:\.\d+)?/)
const amount = match ? Number(match[0]) : 0
return Number.isFinite(amount) && amount > 0 ? amount : 0
}
function formatMoney(value) {
const amount = normalizeMoney(value)
if (!amount) {
return ''
}
return `${new Intl.NumberFormat('zh-CN', {
maximumFractionDigits: Number.isInteger(amount) ? 0 : 2,
minimumFractionDigits: Number.isInteger(amount) ? 0 : 2
}).format(amount)}元`
}
function escapeMarkdownCell(value) {
return normalizeText(value).replace(/\|/g, '\\|') || '-'
}
function buildApplicationDetailHref(item = {}) {
const claimNo = normalizeText(item.claimNo)
const reference = claimNo && claimNo !== '未编号申请单'
? claimNo
: normalizeText(item.claimId)
return reference ? `#ai-open-application-detail:${encodeURIComponent(reference)}` : ''
}
function buildApplicationDetailActionCell(item = {}) {
const href = buildApplicationDetailHref(item)
return href ? `[查看](${href})` : '-'
}
function parseDate(value) {
const dateText = normalizeText(value)
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateText)) {
return null
}
const date = new Date(`${dateText}T00:00:00Z`)
return Number.isNaN(date.getTime()) ? null : date
}
function resolveDateRange(value, daysText = '') {
const resolved = resolveApplicationDateRange(value, daysText)
if (!resolved) {
return null
}
const startText = normalizeText(resolved.startDate)
const endText = normalizeText(resolved.endDate || resolved.startDate)
const startDate = parseDate(startText)
const endDate = parseDate(endText)
if (!startDate || !endDate) {
return null
}
return startDate <= endDate
? { startText, endText, startDate, endDate }
: { startText: endText, endText: startText, startDate: endDate, endDate: startDate }
}
function rangesOverlap(left, right) {
return Boolean(left && right && left.startDate <= right.endDate && right.startDate <= left.endDate)
}
function resolvePreviewDateRange(preview) {
const fields = normalizeApplicationPreview(preview).fields || {}
return resolveDateRange(fields.time, fields.days)
}
function resolvePreviewAmount(preview) {
const normalized = normalizeApplicationPreview(preview)
const fields = normalized.fields || {}
const policyEstimate = normalized.policyEstimate && typeof normalized.policyEstimate === 'object'
? normalized.policyEstimate
: {}
return normalizeMoney(
fields.amount ||
fields.policyTotalAmount ||
fields.reimbursementAmount ||
policyEstimate.system_total_amount
)
}
function resolveApplicationClaims(claimsPayload, currentUser, expenseType) {
return extractExpenseClaimItems(claimsPayload)
.filter((claim) => (
isExpenseApplicationClaim(claim) &&
isClaimOwnedByCurrentUser(claim, currentUser) &&
matchesRequiredApplicationExpenseType(claim, expenseType)
))
.map((claim) => normalizeRequiredApplicationCandidate(claim))
}
function buildOverlapPrecheck(preview, claimsPayload, currentUser, expenseType) {
const targetRange = resolvePreviewDateRange(preview)
if (!targetRange) {
return {
status: 'unknown',
summary: '暂未识别到完整出差日期,无法判断是否与已有申请时间重叠。'
}
}
const applications = resolveApplicationClaims(claimsPayload, currentUser, expenseType)
const matches = applications
.map((application) => {
const range = resolveDateRange(application.business_time)
return {
...application,
range
}
})
.filter((application) => rangesOverlap(targetRange, application.range))
.slice(0, 3)
if (!matches.length) {
return {
status: 'ok',
summary: `未发现 ${targetRange.startText}${targetRange.endText} 期间已有重叠的差旅申请单。`,
matches: []
}
}
return {
status: 'warning',
summary: `发现 ${matches.length} 张同时间段可能重叠的申请单,暂不能继续发起新的出差申请。`,
matches: matches.map((item) => ({
claimId: item.id || '',
claimNo: item.claim_no || '未编号申请单',
time: item.business_time || '',
statusLabel: item.status_label || '',
reason: item.reason || ''
}))
}
}
function isBlockingPrecheck(precheck = {}) {
return precheck?.overlap?.status === 'warning'
}
function buildOverlapMatchTable(matches = []) {
const rows = Array.isArray(matches) ? matches : []
if (!rows.length) {
return ''
}
return [
'| 单据编号 | 申请时间 | 状态 | 事由 | 操作 |',
'| --- | --- | --- | --- | --- |',
...rows.map((item) => [
escapeMarkdownCell(item.claimNo),
escapeMarkdownCell(item.time),
escapeMarkdownCell(item.statusLabel),
escapeMarkdownCell(item.reason),
buildApplicationDetailActionCell(item)
].join(' | ')).map((row) => `| ${row} |`)
].join('\n')
}
function resolveBudgetNumbers(summary = {}) {
const totalAmount = normalizeMoney(summary.total_amount || summary.totalAmount)
const reservedAmount = normalizeMoney(summary.reserved_amount || summary.reservedAmount)
const consumedAmount = normalizeMoney(summary.consumed_amount || summary.consumedAmount)
const availableAmount = normalizeMoney(summary.available_amount || summary.availableAmount)
return {
totalAmount,
reservedAmount,
consumedAmount,
availableAmount,
usedAmount: reservedAmount + consumedAmount
}
}
function buildBudgetPrecheck(preview, budgetSummary) {
const amount = resolvePreviewAmount(preview)
const missingFields = normalizeApplicationPreview(preview).missingFields || []
if (!amount) {
const reason = missingFields.includes('出行方式')
? '当前还缺出行方式,交通费用和申请总额暂未完成测算。'
: '当前申请总额暂未完成测算。'
return {
status: 'pending',
requiresBudgetReview: false,
summary: `${reason}补齐后会刷新预算占用;若达到 ${APPLICATION_BUDGET_REVIEW_THRESHOLD}% 预算复核线或超预算,系统会增加预算管理者审核。`
}
}
if (!budgetSummary || typeof budgetSummary !== 'object') {
return {
status: 'unknown',
requiresBudgetReview: false,
summary: `本次预计申请金额 ${formatMoney(amount)}。预算接口暂未返回,以提交时系统预算复核为准;若达到 ${APPLICATION_BUDGET_REVIEW_THRESHOLD}% 复核线或超预算,会增加预算管理者审核。`
}
}
const budget = resolveBudgetNumbers(budgetSummary)
if (!budget.totalAmount) {
return {
status: 'unknown',
requiresBudgetReview: false,
summary: `本次预计申请金额 ${formatMoney(amount)}。当前部门预算总额暂未配置或暂未返回,提交时会继续做预算归口复核。`
}
}
const afterUsed = budget.usedAmount + amount
const afterUsageRate = Number(((afterUsed / budget.totalAmount) * 100).toFixed(2))
if (amount > budget.availableAmount) {
return {
status: 'warning',
requiresBudgetReview: true,
summary: `本次预计申请金额 ${formatMoney(amount)},当前可用预算 ${formatMoney(budget.availableAmount)},预计超出 ${formatMoney(amount - budget.availableAmount)},提交后需要预算管理者审核。`
}
}
if (afterUsageRate >= APPLICATION_BUDGET_REVIEW_THRESHOLD) {
return {
status: 'warning',
requiresBudgetReview: true,
summary: `本次预计申请金额 ${formatMoney(amount)},审批后预算占用约 ${afterUsageRate}%,达到 ${APPLICATION_BUDGET_REVIEW_THRESHOLD}% 复核线,提交后需要预算管理者审核。`
}
}
return {
status: 'ok',
requiresBudgetReview: false,
summary: `本次预计申请金额 ${formatMoney(amount)},审批后预算占用约 ${afterUsageRate}%,未达到 ${APPLICATION_BUDGET_REVIEW_THRESHOLD}% 复核线。`
}
}
export function buildAiApplicationPrecheck(preview = {}, {
claimsPayload = null,
budgetSummary = null,
currentUser = {},
expenseType = 'travel',
budgetError = null
} = {}) {
const normalizedPreview = normalizeApplicationPreview(preview)
const budget = budgetError
? {
status: 'unknown',
requiresBudgetReview: false,
summary: `预算接口暂未返回:${normalizeText(budgetError?.message || budgetError) || '当前无可用预算数据'}。提交时系统仍会按预算余额、风险规则判断是否增加预算管理者审核。`
}
: buildBudgetPrecheck(normalizedPreview, budgetSummary)
return {
overlap: buildOverlapPrecheck(normalizedPreview, claimsPayload, currentUser, expenseType),
budget,
missingFields: Array.isArray(normalizedPreview.missingFields) ? normalizedPreview.missingFields : []
}
}
export function buildAiApplicationPrecheckThinkingEvents(precheck = {}) {
const blocked = isBlockingPrecheck(precheck)
return [
{
eventId: 'application-precheck-overlap',
title: '核查同时间段申请单',
content: precheck?.overlap?.summary || '已完成已有申请单核查。',
status: precheck?.overlap?.status === 'warning' ? 'completed' : 'completed'
},
{
eventId: 'application-precheck-budget',
title: '评估预算与审批影响',
content: precheck?.budget?.summary || '已完成预算影响评估。',
status: 'completed'
},
{
eventId: 'application-precheck-form',
title: blocked ? '暂停生成申请表' : '生成申请表草稿',
content: blocked
? '因发现同时间段已有申请单,已暂停生成新的申请表,等待用户核对申请时间。'
: '已将识别到的时间、地点、事由和申请人信息预填到申请表。',
status: 'completed'
}
]
}
export function buildAiApplicationPrecheckMessage(preview = {}, precheck = {}) {
if (isBlockingPrecheck(precheck)) {
const matchTable = buildOverlapMatchTable(precheck?.overlap?.matches)
const lines = [
'### 发现同时间段已有申请单',
'',
'**我已完成发起前的单据重叠核查**,当前不能继续生成新的出差申请表。',
'',
`> **时间重叠提醒**${precheck?.overlap?.summary || '发现同时间段已有申请单,暂不能继续发起新的出差申请。'}`,
]
if (matchTable) {
lines.push('', matchTable)
}
lines.push(
'',
'> **请先核对**:请先检查本次申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;若日期无误,请先处理或关联已有申请单,避免重复申请。',
'',
'我会先暂停本次申请表生成,不会开放保存草稿或提交入口。'
)
return lines.join('\n')
}
const normalized = normalizeApplicationPreview(preview)
const missingFields = Array.isArray(normalized.missingFields) ? normalized.missingFields : []
const missingText = missingFields.length ? missingFields.join('、') : '暂无'
const budgetPrefix = precheck?.budget?.requiresBudgetReview ? '**预算管理者审核提示**' : '**预算与审批影响**'
const overlapPrefix = precheck?.overlap?.status === 'warning' ? '**时间重叠提醒**' : '**单据重叠核查**'
const lines = [
'### 出差申请表草稿已生成',
'',
'**我已完成发起前的单据与预算预审**,并为您生成一张完整的出差申请表。',
'',
`> ${overlapPrefix}${precheck?.overlap?.summary || '已完成已有单据核查。'}`,
'',
`> ${budgetPrefix}${precheck?.budget?.summary || '已完成预算影响评估。'}`,
'',
`> **仍需补充**${missingText}`,
'',
'请直接点击表格中的字段补充或修改;费用测算会根据地点、天数和出行方式自动更新。'
]
if (missingFields.length) {
lines.push('', `当前还需要补充:**${missingText}**。`)
} else {
lines.push('', '信息已基本齐全,您可以保存草稿,或直接提交进入审批。')
}
return lines.join('\n')
}

View File

@@ -0,0 +1,647 @@
const ALLOWED_COLON_HEADING_TITLES = new Set([
'基础信息识别结果',
'报销测算参考',
'补充信息'
])
const BUSINESS_FIELD_LABELS = new Set([
'时间',
'地点',
'事由',
'金额',
'费用类型',
'报销类型',
'商户',
'商户/开票方',
'客户',
'客户/项目对象',
'附件',
'附件/凭证',
'出行方式'
])
const APPLICATION_DETAIL_HREF_PREFIX = '#ai-open-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 TRUSTED_HTML_ALLOWED_TAGS = new Set([
'section',
'article',
'header',
'footer',
'div',
'span',
'strong',
'a'
])
const TRUSTED_HTML_ALLOWED_ATTRS = new Set([
'aria-label',
'class',
'data-ai-action',
'href'
])
function escapeHtml(value = '') {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function sanitizeHref(href = '') {
const value = String(href || '').trim()
if (/^(https?:\/\/|#)/i.test(value)) {
return escapeHtml(value)
}
return '#'
}
function isApplicationDetailHref(href = '') {
return String(href || '').trim().startsWith(APPLICATION_DETAIL_HREF_PREFIX)
}
function isDocumentDetailHref(href = '') {
return String(href || '').trim().startsWith(DOCUMENT_DETAIL_HREF_PREFIX)
}
function sanitizeImageSrc(src = '') {
const value = String(src || '').trim()
if (/^(https?:\/\/|blob:|\/)/i.test(value)) {
return escapeHtml(value)
}
if (/^data:image\/(?:png|jpe?g|webp|gif);base64,[a-z0-9+/=]+$/i.test(value)) {
return escapeHtml(value)
}
return ''
}
function renderLinkHtml(label = '', href = '') {
const sanitizedHref = sanitizeHref(href)
if (isApplicationDetailHref(href)) {
return [
`<a href="${sanitizedHref}"`,
' class="ai-html-action-link ai-html-action-link-application"',
' data-ai-action="open-application-detail"',
'>',
label,
'</a>'
].join('')
}
if (isDocumentDetailHref(href)) {
return [
`<a href="${sanitizedHref}"`,
' class="ai-html-action-link ai-html-action-link-document"',
' data-ai-action="open-document-detail"',
'>',
label,
'</a>'
].join('')
}
return `<a href="${sanitizedHref}" target="_blank" rel="noreferrer">${label}</a>`
}
function renderInlineImageHtml(alt = '', src = '') {
const sanitizedSrc = sanitizeImageSrc(src)
if (!sanitizedSrc) {
return escapeHtml(alt || src)
}
return [
`<img class="ai-html-inline-image" src="${sanitizedSrc}"`,
` alt="${escapeHtml(alt)}" loading="lazy" />`
].join('')
}
function renderInlineHtml(value = '') {
let html = escapeHtml(value)
html = html.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_match, alt, src) => (
renderInlineImageHtml(alt, src)
))
html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+|#[^\s)]+)\)/g, (_match, label, href) => (
renderLinkHtml(label, href)
))
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>')
return html
}
function splitColonHeadingLine(line) {
const rawLine = String(line || '')
const trimmed = rawLine.trim()
if (!trimmed || trimmed.startsWith('|') || /^#{1,6}\s/.test(trimmed)) {
return [rawLine]
}
const chineseColonIndex = trimmed.indexOf('')
const asciiColonIndex = trimmed.indexOf(':')
const colonIndexes = [chineseColonIndex, asciiColonIndex].filter((index) => index > 0)
if (!colonIndexes.length) {
return [rawLine]
}
const colonIndex = Math.min(...colonIndexes)
const title = trimmed.slice(0, colonIndex)
const body = trimmed.slice(colonIndex + 1).trim()
if (!ALLOWED_COLON_HEADING_TITLES.has(title)) {
return [rawLine]
}
return body ? [`### ${title}`, '', body] : [`### ${title}`]
}
function normalizeBusinessFieldLine(line) {
const rawLine = String(line || '')
const trimmed = rawLine.trim()
if (
!trimmed ||
trimmed.startsWith('|') ||
/^[-*+]\s/.test(trimmed) ||
/^#{1,6}\s/.test(trimmed)
) {
return rawLine
}
const match = trimmed.match(/^([^:\n]{1,16})[:]\s*(.+)$/u)
if (!match) {
return rawLine
}
const label = match[1].trim()
const value = match[2].trim()
if (!BUSINESS_FIELD_LABELS.has(label) || !value) {
return rawLine
}
return `- **${label}**${value}`
}
function normalizeConversationText(text = '') {
const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n')
const normalizedLines = []
let inFence = false
lines.forEach((line) => {
if (/^\s*(```|~~~)/.test(line)) {
inFence = !inFence
normalizedLines.push(line)
return
}
if (inFence) {
normalizedLines.push(line)
return
}
const nextLines = splitColonHeadingLine(line)
if (nextLines[0]?.startsWith('### ') && normalizedLines.length) {
const previousLine = normalizedLines[normalizedLines.length - 1]
if (String(previousLine || '').trim()) {
normalizedLines.push('')
}
}
normalizedLines.push(...nextLines.map((nextLine) => normalizeBusinessFieldLine(nextLine)))
})
return normalizedLines.join('\n').replace(/\n{3,}/g, '\n\n').trim()
}
function hasOnlyTrustedHtmlTags(html = '') {
const tagPattern = /<\/?([a-z][\w-]*)([^>]*)>/gi
let match = tagPattern.exec(html)
while (match) {
const tagName = String(match[1] || '').toLowerCase()
if (!TRUSTED_HTML_ALLOWED_TAGS.has(tagName)) {
return false
}
const attrText = String(match[2] || '')
const attrPattern = /\s([:@\w-]+)\s*=/g
let attrMatch = attrPattern.exec(attrText)
while (attrMatch) {
const attrName = String(attrMatch[1] || '').toLowerCase()
if (!TRUSTED_HTML_ALLOWED_ATTRS.has(attrName)) {
return false
}
attrMatch = attrPattern.exec(attrText)
}
match = tagPattern.exec(html)
}
return true
}
function sanitizeTrustedHtmlBlock(html = '') {
const value = String(html || '').trim()
if (!value || !value.includes('class="ai-document-card-list"')) {
return ''
}
if (/<(?:script|style|iframe|object|embed|link|meta|form|input|button|textarea|select)\b/i.test(value)) {
return ''
}
if (/\son[a-z]+\s*=/i.test(value) || /javascript\s*:/i.test(value)) {
return ''
}
if (!hasOnlyTrustedHtmlTags(value)) {
return ''
}
const hrefs = [...value.matchAll(/\shref="([^"]*)"/gi)].map((match) => String(match[1] || '').trim())
if (hrefs.some((href) => !href.startsWith(DOCUMENT_DETAIL_HREF_PREFIX))) {
return ''
}
return value
}
function extractTrustedHtmlBlocks(text = '') {
const trustedHtmlBlocks = []
const content = String(text || '').replace(TRUSTED_HTML_BLOCK_RE, (_match, html) => {
const sanitizedHtml = sanitizeTrustedHtmlBlock(html)
if (!sanitizedHtml) {
return ''
}
const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${trustedHtmlBlocks.length}`
trustedHtmlBlocks.push(sanitizedHtml)
return `\n\n${placeholder}\n\n`
})
return { content, trustedHtmlBlocks }
}
function restoreTrustedHtmlBlocks(html = '', trustedHtmlBlocks = []) {
return trustedHtmlBlocks.reduce((nextHtml, block, index) => {
const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${index}`
const paragraphPattern = new RegExp(`<p class="ai-html-paragraph">${placeholder}</p>`, 'g')
return nextHtml
.replace(paragraphPattern, block)
.replaceAll(placeholder, block)
}, html)
}
function isFenceLine(line = '') {
return /^\s*(```|~~~)/.test(String(line || ''))
}
function isHeadingLine(line = '') {
return /^#{1,6}\s+/.test(String(line || '').trim())
}
function isQuoteLine(line = '') {
return /^>\s?/.test(String(line || '').trim())
}
function isUnorderedListLine(line = '') {
return /^[-*+]\s+/.test(String(line || '').trim())
}
function isOrderedListLine(line = '') {
return /^\d+\.\s+/.test(String(line || '').trim())
}
function isHorizontalRuleLine(line = '') {
return /^(-{3,}|\*{3,}|_{3,})$/.test(String(line || '').trim())
}
function isTableDivider(line = '') {
const cells = parseTableRow(line)
return cells.length > 1 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()))
}
function isTableStart(lines, index) {
const current = String(lines[index] || '').trim()
const next = String(lines[index + 1] || '').trim()
return current.startsWith('|') && next.startsWith('|') && isTableDivider(next)
}
function parseImageLine(line = '') {
const match = String(line || '').trim().match(/^!\[([^\]]*)\]\(([^)\s]+)\)$/)
if (!match) {
return null
}
const src = sanitizeImageSrc(match[2])
if (!src) {
return null
}
return {
alt: String(match[1] || '').trim(),
src
}
}
function parseTableRow(line = '') {
const trimmed = String(line || '').trim()
if (!trimmed.startsWith('|')) {
return []
}
return trimmed
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((cell) => cell.trim())
}
function splitLabelAndBody(rawText = '') {
const text = String(rawText || '').trim()
const strongMatch = text.match(/^\*\*([^*]+)\*\*[:]\s*(.*)$/u)
if (strongMatch) {
return {
label: strongMatch[1].trim(),
body: strongMatch[2].trim()
}
}
const plainText = text.replace(/\*\*/g, '')
const match = plainText.match(/^([^:\n]{2,20})[:]\s*(.*)$/u)
if (!match) {
return null
}
return {
label: match[1].trim(),
body: match[2].trim()
}
}
function isSpecialBlockStart(lines, index) {
const line = String(lines[index] || '').trim()
return (
!line ||
isFenceLine(line) ||
isHeadingLine(line) ||
isQuoteLine(line) ||
isUnorderedListLine(line) ||
isOrderedListLine(line) ||
isHorizontalRuleLine(line) ||
Boolean(parseImageLine(line)) ||
isTableStart(lines, index)
)
}
function nextNonEmptyLineMatches(lines, index, predicate) {
let cursor = index + 1
while (cursor < lines.length) {
const nextLine = String(lines[cursor] || '').trim()
if (nextLine) {
return predicate(nextLine)
}
cursor += 1
}
return false
}
function renderHeading(line = '') {
const match = String(line || '').trim().match(/^(#{1,6})\s+(.+)$/)
if (!match) {
return ''
}
const level = Math.min(Math.max(match[1].length, 2), 4)
const className = level === 3 ? 'ai-html-title' : `ai-html-title ai-html-title--level-${level}`
return `<h${level} class="${className}">${renderInlineHtml(match[2])}</h${level}>`
}
function renderParagraph(lines = []) {
const text = lines.map((line) => String(line || '').trim()).filter(Boolean).join(' ')
return text ? `<p class="ai-html-paragraph">${renderInlineHtml(text)}</p>` : ''
}
function renderImageBlock(line = '') {
const image = parseImageLine(line)
if (!image) {
return ''
}
return [
'<figure class="ai-html-image-frame">',
`<img class="ai-html-image" src="${image.src}" alt="${escapeHtml(image.alt)}" loading="lazy" />`,
image.alt ? `<figcaption class="ai-html-image-caption">${escapeHtml(image.alt)}</figcaption>` : '',
'</figure>'
].join('')
}
function renderQuoteBlock(items = []) {
const normalizedItems = items
.map((item) => String(item || '').replace(/^>\s?/, '').trim())
.filter(Boolean)
if (!normalizedItems.length) {
return ''
}
const focusItems = normalizedItems
.map((item) => splitLabelAndBody(item))
.filter(Boolean)
if (focusItems.length === normalizedItems.length) {
return [
'<section class="ai-html-focus-grid" aria-label="重点信息">',
...focusItems.map((item) => [
'<article class="ai-html-focus-card">',
`<span class="ai-html-focus-label">${renderInlineHtml(item.label)}</span>`,
`<p>${renderInlineHtml(item.body)}</p>`,
'</article>'
].join('')),
'</section>'
].join('')
}
return [
'<aside class="ai-html-callout">',
...normalizedItems.map((item) => `<p>${renderInlineHtml(item)}</p>`),
'</aside>'
].join('')
}
function renderUnorderedList(items = []) {
const parsedItems = items
.map((item) => String(item || '').trim().replace(/^[-*+]\s+/, '').trim())
.filter(Boolean)
const structuredItems = parsedItems
.map((item) => splitLabelAndBody(item))
.filter(Boolean)
if (structuredItems.length === parsedItems.length && parsedItems.length > 0) {
return [
'<ul class="ai-html-steps">',
...structuredItems.map((item, index) => [
'<li>',
`<span class="ai-html-step-index">${index + 1}</span>`,
'<div class="ai-html-step-copy">',
`<strong>${renderInlineHtml(item.label)}</strong>`,
item.body ? `<p>${renderInlineHtml(item.body)}</p>` : '',
'</div>',
'</li>'
].join('')),
'</ul>'
].join('')
}
return [
'<ul class="ai-html-list">',
...parsedItems.map((item) => `<li>${renderInlineHtml(item)}</li>`),
'</ul>'
].join('')
}
function renderOrderedList(items = []) {
const parsedItems = items
.map((item) => String(item || '').trim().replace(/^\d+\.\s+/, '').trim())
.filter(Boolean)
return [
'<ol class="ai-html-list ai-html-list--ordered">',
...parsedItems.map((item) => `<li>${renderInlineHtml(item)}</li>`),
'</ol>'
].join('')
}
function renderTable(lines = []) {
const rows = lines.map((line) => parseTableRow(line)).filter((row) => row.length)
if (rows.length < 2) {
return ''
}
const header = rows[0]
const bodyRows = rows.slice(2)
return [
'<div class="ai-html-table-wrap">',
'<table>',
'<thead><tr>',
...header.map((cell) => `<th>${renderInlineHtml(cell)}</th>`),
'</tr></thead>',
'<tbody>',
...bodyRows.map((row) => [
'<tr>',
...header.map((_cell, index) => `<td>${renderInlineHtml(row[index] || '')}</td>`),
'</tr>'
].join('')),
'</tbody>',
'</table>',
'</div>'
].join('')
}
function renderCodeBlock(lines = []) {
const code = lines.join('\n').replace(/\n$/, '')
return `<pre class="ai-html-code"><code>${escapeHtml(code)}</code></pre>`
}
export function renderAiConversationHtml(content = '') {
const extracted = extractTrustedHtmlBlocks(content)
const normalized = normalizeConversationText(extracted.content)
if (!normalized) {
return ''
}
const lines = normalized.split('\n')
const blocks = []
let index = 0
while (index < lines.length) {
const line = String(lines[index] || '')
const trimmed = line.trim()
if (!trimmed) {
index += 1
continue
}
if (isFenceLine(trimmed)) {
index += 1
const codeLines = []
while (index < lines.length && !isFenceLine(lines[index])) {
codeLines.push(lines[index])
index += 1
}
if (index < lines.length) {
index += 1
}
blocks.push(renderCodeBlock(codeLines))
continue
}
if (isHeadingLine(trimmed)) {
blocks.push(renderHeading(trimmed))
index += 1
continue
}
if (isTableStart(lines, index)) {
const tableLines = []
while (index < lines.length && String(lines[index] || '').trim().startsWith('|')) {
tableLines.push(lines[index])
index += 1
}
blocks.push(renderTable(tableLines))
continue
}
if (parseImageLine(trimmed)) {
blocks.push(renderImageBlock(trimmed))
index += 1
continue
}
if (isQuoteLine(trimmed)) {
const quoteItems = []
while (index < lines.length) {
const current = String(lines[index] || '').trim()
if (isQuoteLine(current)) {
quoteItems.push(current)
index += 1
continue
}
if (!current && isQuoteLine(String(lines[index + 1] || '').trim())) {
index += 1
continue
}
break
}
blocks.push(renderQuoteBlock(quoteItems))
continue
}
if (isUnorderedListLine(trimmed)) {
const listItems = []
while (index < lines.length) {
const current = String(lines[index] || '').trim()
if (isUnorderedListLine(current)) {
listItems.push(lines[index])
index += 1
continue
}
if (!current && nextNonEmptyLineMatches(lines, index, isUnorderedListLine)) {
index += 1
continue
}
break
}
blocks.push(renderUnorderedList(listItems))
continue
}
if (isOrderedListLine(trimmed)) {
const listItems = []
while (index < lines.length) {
const current = String(lines[index] || '').trim()
if (isOrderedListLine(current)) {
listItems.push(lines[index])
index += 1
continue
}
if (!current && nextNonEmptyLineMatches(lines, index, isOrderedListLine)) {
index += 1
continue
}
break
}
blocks.push(renderOrderedList(listItems))
continue
}
if (isHorizontalRuleLine(trimmed)) {
blocks.push('<hr class="ai-html-divider" />')
index += 1
continue
}
const paragraphLines = []
while (index < lines.length && !isSpecialBlockStart(lines, index)) {
paragraphLines.push(lines[index])
index += 1
}
blocks.push(renderParagraph(paragraphLines))
}
return restoreTrustedHtmlBlocks(
`<div class="ai-html-flow">${blocks.filter(Boolean).join('')}</div>`,
extracted.trustedHtmlBlocks
)
}

View File

@@ -0,0 +1,784 @@
import { extractExpenseClaimItems } from '../services/reimbursements.js'
const DOCUMENT_QUERY_LIMIT = 8
const STATUS_LABELS = {
draft: '草稿',
submitted: '审批中',
pending: '待处理',
approved: '已审批',
completed: '已完成',
archived: '已归档',
returned: '已退回',
rejected: '已驳回',
pending_payment: '待付款',
paid: '已付款'
}
const TYPE_LABELS = {
travel: '差旅费',
travel_application: '差旅费用申请',
expense_application: '费用申请',
application: '费用申请',
office: '办公用品费',
transport: '交通费',
hotel: '住宿费',
meal: '业务招待费',
entertainment: '业务招待费',
meeting: '会务费',
training: '培训费',
software: '软件服务费',
other: '其他费用'
}
const STATUS_FILTERS = [
{ label: '草稿', keys: ['draft'], pattern: /草稿|未提交/ },
{ label: '审批中', keys: ['submitted', 'pending'], pattern: /审批中|审核中|待审批|待审核|待审|已提交/ },
{ label: '已审批', keys: ['approved'], pattern: /已审批|审批通过|已通过|已批准/ },
{ label: '已完成', keys: ['completed'], pattern: /已完成|完成/ },
{ label: '已归档', keys: ['archived'], pattern: /已归档|归档/ },
{ label: '已退回', keys: ['returned'], pattern: /已退回|退回|待补充/ },
{ label: '已驳回', keys: ['rejected'], pattern: /已驳回|驳回|拒绝/ },
{ label: '待付款', keys: ['pending_payment'], pattern: /待付款|待支付/ },
{ label: '已付款', keys: ['paid'], pattern: /已付款|已支付/ }
]
const EXPENSE_TYPE_FILTERS = [
{ label: '差旅费', codes: ['travel', 'travel_application'], pattern: /差旅|出差|差旅费|差旅费用/ },
{ label: '交通费', codes: ['transport'], pattern: /交通|火车|机票|打车|出租|网约车/ },
{ label: '住宿费', codes: ['hotel'], pattern: /住宿|酒店|宾馆/ },
{ label: '业务招待费', codes: ['meal', 'entertainment'], pattern: /招待|餐饮|宴请|客户餐/ },
{ label: '办公用品费', codes: ['office'], pattern: /办公|办公用品|采购/ },
{ label: '会务费', codes: ['meeting'], pattern: /会务|会议/ },
{ label: '培训费', codes: ['training'], pattern: /培训/ },
{ label: '软件服务费', codes: ['software'], pattern: /软件|服务费|订阅/ }
]
const MONEY_FORMATTER = new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
function normalizeText(value) {
return String(value ?? '').trim()
}
function escapeHtml(value = '') {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function compactText(value) {
return normalizeText(value).replace(/\s+/g, '')
}
function normalizeDateText(value) {
const text = normalizeText(value)
const matched = text.match(/^(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})/)
if (!matched) {
return ''
}
return [
matched[1],
String(matched[2]).padStart(2, '0'),
String(matched[3]).padStart(2, '0')
].join('-')
}
function parseDate(value) {
const text = normalizeDateText(value)
if (!text) {
return null
}
const date = new Date(`${text}T00:00:00Z`)
return Number.isNaN(date.getTime()) ? null : date
}
function formatDate(date) {
return date.toISOString().slice(0, 10)
}
function resolveToday(options = {}) {
return parseDate(options.today) || new Date()
}
function lastDayOfMonth(year, month) {
return new Date(Date.UTC(year, month, 0)).getUTCDate()
}
function buildMonthRange(year, month) {
const normalizedMonth = String(month).padStart(2, '0')
return {
start: `${year}-${normalizedMonth}-01`,
end: `${year}-${normalizedMonth}-${String(lastDayOfMonth(year, month)).padStart(2, '0')}`,
label: `${year}${month}`
}
}
function resolveTimeRange(prompt, options = {}) {
const text = compactText(prompt)
const today = resolveToday(options)
const todayText = formatDate(today)
const explicitMonth = text.match(/(?:(?<year>20\d{2})年?)?(?<month>\d{1,2})月(?!\d{1,2})/)
if (explicitMonth?.groups) {
const year = Number(explicitMonth.groups.year || today.getUTCFullYear())
const month = Number(explicitMonth.groups.month)
if (month >= 1 && month <= 12) {
return buildMonthRange(year, month)
}
}
const explicitRange = text.match(/(?:(?<year>20\d{2})年?)?(?<startMonth>\d{1,2})月(?<startDay>\d{1,2})日?(?:至|到|~|-|—|)(?:(?<endMonth>\d{1,2})月)?(?<endDay>\d{1,2})日?/)
if (explicitRange?.groups) {
const year = Number(explicitRange.groups.year || today.getUTCFullYear())
const startMonth = Number(explicitRange.groups.startMonth)
const endMonth = Number(explicitRange.groups.endMonth || startMonth)
const start = `${year}-${String(startMonth).padStart(2, '0')}-${String(explicitRange.groups.startDay).padStart(2, '0')}`
const end = `${year}-${String(endMonth).padStart(2, '0')}-${String(explicitRange.groups.endDay).padStart(2, '0')}`
return { start, end, label: `${start}${end}` }
}
const explicitDay = text.match(/(?:(?<year>20\d{2})年?)?(?<month>\d{1,2})月(?<day>\d{1,2})日?/)
if (explicitDay?.groups) {
const year = Number(explicitDay.groups.year || today.getUTCFullYear())
const value = `${year}-${String(explicitDay.groups.month).padStart(2, '0')}-${String(explicitDay.groups.day).padStart(2, '0')}`
return { start: value, end: value, label: value }
}
if (/今天|今日/.test(text)) {
return { start: todayText, end: todayText, label: '今天' }
}
if (/昨天/.test(text)) {
const date = new Date(today.getTime())
date.setUTCDate(date.getUTCDate() - 1)
const value = formatDate(date)
return { start: value, end: value, label: '昨天' }
}
if (/本月|这个月|当月/.test(text)) {
return buildMonthRange(today.getUTCFullYear(), today.getUTCMonth() + 1)
}
if (/上月|上个月/.test(text)) {
const date = new Date(today.getTime())
date.setUTCMonth(date.getUTCMonth() - 1)
return buildMonthRange(date.getUTCFullYear(), date.getUTCMonth() + 1)
}
if (/今年|本年/.test(text)) {
const year = today.getUTCFullYear()
return { start: `${year}-01-01`, end: `${year}-12-31`, label: `${year}` }
}
const recent = text.match(/近(?<days>\d{1,3})天/)
if (recent?.groups?.days) {
const days = Math.max(1, Number(recent.groups.days))
const start = new Date(today.getTime())
start.setUTCDate(start.getUTCDate() - days + 1)
return { start: formatDate(start), end: todayText, label: `${days}` }
}
return null
}
function resolveDocumentType(prompt) {
const text = compactText(prompt)
if (/申请单|申请类单据|申请类/.test(text)) {
return 'application'
}
if (/报销单|报销类单据|报销类/.test(text)) {
return 'reimbursement'
}
return 'all'
}
function resolveStatusFilter(prompt) {
const text = compactText(prompt)
return STATUS_FILTERS.find((item) => item.pattern.test(text)) || null
}
function resolveExpenseTypeFilter(prompt) {
const text = compactText(prompt)
return EXPENSE_TYPE_FILTERS.find((item) => item.pattern.test(text)) || null
}
function normalizeAmountText(value = '') {
const matched = compactText(value).replace(/,/g, '').match(/-?\d+(?:\.\d+)?/)
if (!matched) {
return null
}
const amount = Number(matched[0])
return Number.isFinite(amount) ? amount : null
}
function resolveAmountFilter(prompt) {
const text = compactText(prompt)
const range = text.match(/金额(?:在|为)?(?<min>\d+(?:\.\d+)?)(?:元)?(?:到|至|~|-|—|)(?<max>\d+(?:\.\d+)?)(?:元)?/)
if (range?.groups) {
const min = normalizeAmountText(range.groups.min)
const max = normalizeAmountText(range.groups.max)
if (min !== null && max !== null) {
return {
min: Math.min(min, max),
max: Math.max(min, max),
label: `${Math.min(min, max)}-${Math.max(min, max)}`
}
}
}
const minMatch = text.match(/(?:金额)?(?:大于|超过|高于|不少于|不低于|>=|以上)(?<amount>\d+(?:\.\d+)?)(?:元)?/)
|| text.match(/(?<amount>\d+(?:\.\d+)?)(?:元)?以上/)
if (minMatch?.groups?.amount) {
const min = normalizeAmountText(minMatch.groups.amount)
return min === null ? null : { min, max: null, label: `不少于${min}` }
}
const maxMatch = text.match(/(?:金额)?(?:小于|低于|少于|不超过|<=|以下)(?<amount>\d+(?:\.\d+)?)(?:元)?/)
|| text.match(/(?<amount>\d+(?:\.\d+)?)(?:元)?以下/)
if (maxMatch?.groups?.amount) {
const max = normalizeAmountText(maxMatch.groups.amount)
return max === null ? null : { min: null, max, label: `不超过${max}` }
}
return null
}
function normalizeKeywordCandidate(value = '') {
return normalizeText(value)
.replace(/^(的|是|为|包含|含有)+/u, '')
.replace(/(相关的?|有关的?|单据|单子|申请单|报销单|审核单|审批单|有哪些|有吗|查询|查看)$/u, '')
.replace(/的$/u, '')
.trim()
}
function resolveKeywordFilter(prompt) {
const text = normalizeText(prompt)
const compact = compactText(prompt)
const explicitMatch = text.match(/(?:关于|有关|包含|含有|关键词|关键字|事由(?:是|为|包含|含有)?)[:\s]*(?<keyword>[\u4e00-\u9fa5A-Za-z0-9_-]{2,32})/u)
const relatedMatch = compact.match(/(?<keyword>[\u4e00-\u9fa5A-Za-z0-9_-]{2,32})相关(?:的)?(?:单据|单子|申请单|报销单|审核单|审批单)/u)
const keyword = normalizeKeywordCandidate(explicitMatch?.groups?.keyword || relatedMatch?.groups?.keyword || '')
if (!keyword || /^(现在|当前|哪些|审核|审批|申请|报销|单据|单子)$/u.test(keyword)) {
return null
}
return { keyword, label: keyword }
}
function resolveSource(prompt) {
const text = compactText(prompt)
if (/审核单|审批单|待审|待审核|待审批|我审批|我审核/.test(text)) {
return {
source: 'approval',
sourceLabel: '待我审核的单据'
}
}
return {
source: 'mine',
sourceLabel: '我的单据'
}
}
export function resolveAiDocumentQueryIntent(prompt, options = {}) {
const text = compactText(prompt)
if (!text || !/(单据|单子|申请单|报销单|审核单|审批单|待审|待审批|待审核)/.test(text)) {
return null
}
if (/(发起|创建|新增|填写|生成|申请出差|我要报销|提交).*(单据|申请单|报销单)?/.test(text)) {
return null
}
const source = resolveSource(text)
const documentType = resolveDocumentType(text)
const statusFilter = resolveStatusFilter(text)
const expenseTypeFilter = resolveExpenseTypeFilter(text)
const keywordFilter = resolveKeywordFilter(prompt)
const amountFilter = resolveAmountFilter(text)
return {
...source,
documentType,
documentTypeLabel: documentType === 'application'
? '申请单'
: documentType === 'reimbursement'
? '报销单'
: '全部单据',
timeRange: resolveTimeRange(text, options),
statusFilter,
expenseTypeFilter,
keywordFilter,
amountFilter
}
}
function resolveDocumentNo(claim = {}) {
return normalizeText(claim.claim_no || claim.claimNo || claim.documentNo || claim.id || claim.claim_id)
}
function resolveClaimId(claim = {}) {
return normalizeText(claim.id || claim.claim_id || claim.claimId || resolveDocumentNo(claim))
}
function resolveDocumentTypeCode(claim = {}) {
const explicitType = normalizeText(
claim.document_type_code
|| claim.documentTypeCode
|| claim.document_type
|| claim.documentType
).toLowerCase()
const expenseType = normalizeText(claim.expense_type || claim.expenseType || claim.typeCode).toLowerCase()
const documentNo = resolveDocumentNo(claim).toUpperCase()
if (
explicitType === 'application'
|| explicitType === 'expense_application'
|| expenseType === 'application'
|| expenseType.endsWith('_application')
|| documentNo.startsWith('AP-')
|| documentNo.startsWith('APP-')
) {
return 'application'
}
return 'reimbursement'
}
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] || '待确认'
}
// 状态语义化分类,驱动卡片着色:进行中 / 正向终态 / 需关注 / 异常终态
function resolveStatusTone(statusLabel = '') {
const text = normalizeText(statusLabel)
if (/草稿|已退回|退回|待补充/.test(text)) {
return 'is-warning'
}
if (/已驳回|驳回|已拒绝|拒绝/.test(text)) {
return 'is-danger'
}
if (/已批准|已审批|已完成|已付款|已支付|已归档|已报销/.test(text)) {
return 'is-success'
}
return 'is-pending'
}
function resolveStatusKey(claim = {}) {
return normalizeText(claim.status || claim.state || claim.approval_status || claim.approvalStatus).toLowerCase()
}
function resolveReason(claim = {}) {
return normalizeText(claim.reason || claim.business_reason || claim.description || claim.title || claim.note) || '未填写事由'
}
function resolveExpenseTypeLabel(claim = {}) {
const key = normalizeText(claim.expense_type || claim.expenseType || claim.type_code || claim.typeCode).toLowerCase()
return TYPE_LABELS[key] || TYPE_LABELS.other
}
function resolveExpenseTypeCode(claim = {}) {
return normalizeText(claim.expense_type || claim.expenseType || claim.type_code || claim.typeCode).toLowerCase()
}
function pickText(source = {}, keys = [], fallback = '') {
for (const key of keys) {
const value = normalizeText(source[key])
if (value) {
return value
}
}
return fallback
}
function pickRawValue(source = {}, keys = []) {
for (const key of keys) {
const value = source[key]
if (value !== undefined && value !== null && normalizeText(value)) {
return value
}
}
return null
}
function normalizeMoneyValue(value) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null
}
const normalized = normalizeText(value).replace(/,/g, '')
if (!normalized) {
return null
}
const matched = normalized.match(/-?\d+(?:\.\d+)?/)
if (!matched) {
return null
}
const amount = Number(matched[0])
return Number.isFinite(amount) ? amount : null
}
function resolveAmountLabel(claim = {}) {
const rawValue = pickRawValue(claim, [
'amount',
'total_amount',
'totalAmount',
'claimed_amount',
'claimedAmount',
'application_amount',
'applicationAmount',
'budget_amount',
'budgetAmount',
'estimated_amount',
'estimatedAmount'
])
const amount = normalizeMoneyValue(rawValue)
return amount === null ? '待确认' : MONEY_FORMATTER.format(amount)
}
function resolveAmountValue(claim = {}) {
return normalizeMoneyValue(pickRawValue(claim, [
'amount',
'total_amount',
'totalAmount',
'claimed_amount',
'claimedAmount',
'application_amount',
'applicationAmount',
'budget_amount',
'budgetAmount',
'estimated_amount',
'estimatedAmount'
]))
}
function resolveOwnerLabel(claim = {}) {
return pickText(claim, [
'applicant_name',
'applicantName',
'employee_name',
'employeeName',
'claimant_name',
'claimantName',
'created_by_name',
'createdByName',
'user_name',
'userName',
'applicant',
'employee'
], '未显示')
}
function resolveDepartmentLabel(claim = {}) {
return pickText(claim, [
'department_name',
'departmentName',
'dept_name',
'deptName',
'org_name',
'orgName',
'department'
], '未显示')
}
function resolveLocationLabel(claim = {}) {
return pickText(claim, [
'location',
'destination',
'destination_city',
'destinationCity',
'city',
'business_location',
'businessLocation',
'place'
])
}
function resolveUpdatedDate(claim = {}) {
return normalizeDateText(
claim.updated_at
|| claim.updatedAt
|| claim.submitted_at
|| claim.submittedAt
|| claim.created_at
|| claim.createdAt
)
}
function resolveRecordDate(claim = {}) {
return normalizeDateText(
claim.occurred_at
|| claim.occurredAt
|| claim.business_time
|| claim.businessTime
|| claim.submitted_at
|| claim.submittedAt
|| claim.created_at
|| claim.createdAt
|| claim.updated_at
|| claim.updatedAt
)
}
function resolveTimeLabel(claim = {}, fallbackDate = '') {
const businessTime = pickText(claim, [
'business_time',
'businessTime',
'trip_time',
'tripTime',
'travel_time',
'travelTime'
])
if (businessTime) {
return businessTime
}
const startDate = normalizeDateText(
claim.start_date
|| claim.startDate
|| claim.trip_start_date
|| claim.tripStartDate
|| claim.departure_date
|| claim.departureDate
)
const endDate = normalizeDateText(
claim.end_date
|| claim.endDate
|| claim.trip_end_date
|| claim.tripEndDate
|| claim.return_date
|| claim.returnDate
)
if (startDate && endDate && startDate !== endDate) {
return `${startDate}${endDate}`
}
return startDate || endDate || fallbackDate || '待补充'
}
function dateInRange(dateText, range) {
if (!range || !range.start || !range.end) {
return true
}
if (!dateText) {
return false
}
return dateText >= range.start && dateText <= range.end
}
function toTimestamp(dateText) {
const date = parseDate(dateText)
return date ? date.getTime() : 0
}
function normalizeRecord(claim = {}) {
const documentType = resolveDocumentTypeCode(claim)
const documentNo = resolveDocumentNo(claim)
const date = resolveRecordDate(claim)
const updatedDate = resolveUpdatedDate(claim)
const reason = resolveReason(claim)
const expenseTypeCode = resolveExpenseTypeCode(claim)
const typeLabel = resolveExpenseTypeLabel(claim)
const statusLabel = resolveStatusLabel(claim)
const ownerLabel = resolveOwnerLabel(claim)
const departmentLabel = resolveDepartmentLabel(claim)
const locationLabel = resolveLocationLabel(claim)
return {
id: resolveClaimId(claim),
claimId: resolveClaimId(claim),
claimNo: documentNo,
documentNo,
documentType,
documentTypeLabel: documentType === 'application' ? '申请单' : '报销单',
expenseTypeCode,
typeLabel,
time: resolveTimeLabel(claim, date),
dateKey: date,
updatedTime: updatedDate || '未显示',
statusKey: resolveStatusKey(claim),
statusLabel,
statusTone: resolveStatusTone(statusLabel),
reason,
amountLabel: resolveAmountLabel(claim),
amountValue: resolveAmountValue(claim),
ownerLabel,
departmentLabel,
locationLabel,
searchableText: compactText([
documentNo,
reason,
ownerLabel,
departmentLabel,
locationLabel,
typeLabel,
statusLabel
].join(' '))
}
}
function matchesStatusFilter(record = {}, statusFilter = null) {
if (!statusFilter) {
return true
}
return statusFilter.keys.includes(record.statusKey) || statusFilter.label === record.statusLabel
}
function matchesExpenseTypeFilter(record = {}, expenseTypeFilter = null) {
if (!expenseTypeFilter) {
return true
}
return expenseTypeFilter.codes.includes(record.expenseTypeCode)
}
function matchesKeywordFilter(record = {}, keywordFilter = null) {
if (!keywordFilter?.keyword) {
return true
}
return record.searchableText.includes(compactText(keywordFilter.keyword))
}
function matchesAmountFilter(record = {}, amountFilter = null) {
if (!amountFilter) {
return true
}
if (record.amountValue === null || record.amountValue === undefined) {
return false
}
if (amountFilter.min !== null && amountFilter.min !== undefined && record.amountValue < amountFilter.min) {
return false
}
if (amountFilter.max !== null && amountFilter.max !== undefined && record.amountValue > amountFilter.max) {
return false
}
return true
}
export function filterAiDocumentQueryRecords(claimsPayload, intent = {}) {
const rows = extractExpenseClaimItems(claimsPayload)
.map((claim) => normalizeRecord(claim))
.filter((record) => (
!intent?.documentType ||
intent.documentType === 'all' ||
record.documentType === intent.documentType
))
.filter((record) => dateInRange(record.dateKey, intent?.timeRange))
.filter((record) => matchesStatusFilter(record, intent?.statusFilter))
.filter((record) => matchesExpenseTypeFilter(record, intent?.expenseTypeFilter))
.filter((record) => matchesKeywordFilter(record, intent?.keywordFilter))
.filter((record) => matchesAmountFilter(record, intent?.amountFilter))
.sort((left, right) => toTimestamp(right.dateKey) - toTimestamp(left.dateKey))
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 buildDocumentCardHtml(record = {}) {
const href = buildDocumentDetailHref(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>`
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">',
`<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>',
'</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>`,
`<strong class="ai-document-card__amount">${escapeHtml(record.amountLabel)}</strong>`,
'</div>',
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>',
'</article>'
].join('')
}
function buildDocumentCardsHtml(records = []) {
return [
'<!-- ai-trusted-html:start -->',
'<section class="ai-document-card-list" aria-label="单据查询结果">',
...records.map((record) => buildDocumentCardHtml(record)),
'</section>',
'<!-- ai-trusted-html:end -->'
].join('\n')
}
function buildQueryScopeText(intent = {}) {
return [
intent.sourceLabel || '相关单据',
intent.documentTypeLabel && intent.documentTypeLabel !== '全部单据' ? intent.documentTypeLabel : '',
intent.timeRange?.label || '',
intent.statusFilter?.label ? `状态:${intent.statusFilter.label}` : '',
intent.expenseTypeFilter?.label ? `费用类型:${intent.expenseTypeFilter.label}` : '',
intent.keywordFilter?.label ? `关键词:${intent.keywordFilter.label}` : '',
intent.amountFilter?.label ? `金额:${intent.amountFilter.label}` : ''
].filter(Boolean).join(' / ')
}
export function buildAiDocumentQueryConditionSummary(intent = {}) {
const conditions = [
`查询来源:${intent.sourceLabel || '相关单据'}`,
`单据类型:${intent.documentTypeLabel || '全部单据'}`,
`时间范围:${intent.timeRange?.label || '不限'}`,
intent.statusFilter?.label ? `状态:${intent.statusFilter.label}` : '',
intent.expenseTypeFilter?.label ? `费用类型:${intent.expenseTypeFilter.label}` : '',
intent.keywordFilter?.label ? `关键词:${intent.keywordFilter.label}` : '',
intent.amountFilter?.label ? `金额:${intent.amountFilter.label}` : ''
].filter(Boolean)
return conditions.join('')
}
export function buildAiDocumentQueryMessage(intent = {}, claimsPayload = []) {
const records = filterAiDocumentQueryRecords(claimsPayload, intent)
const visibleRecords = records.slice(0, DOCUMENT_QUERY_LIMIT)
const scopeText = buildQueryScopeText(intent)
if (!records.length) {
return [
'### 未查询到相关单据',
'',
`**查询范围**${scopeText || '相关单据'}`,
'',
'当前没有匹配的单据。可以继续告诉我更具体的单据类型、时间范围或状态,我会重新筛选。'
].join('\n')
}
const lines = [
'### 已查询到相关单据',
'',
`**查询范围**${scopeText || '相关单据'};共找到 **${records.length}** 张,先展示最近 **${visibleRecords.length}** 张。`,
'',
buildDocumentCardsHtml(visibleRecords)
]
if (records.length > visibleRecords.length) {
lines.push('', `还有 ${records.length - visibleRecords.length} 张未展示;可以继续补充时间、类型或状态缩小范围。`)
}
return lines.join('\n')
}

View File

@@ -3,13 +3,33 @@ import {
isRiskSummaryWithRisk,
normalizeRiskFlagTone
} from './riskFlags.js'
import { canViewRiskForContext } from './riskVisibility.js'
export const ARCHIVE_FILTER_ALL = 'all'
export function countClaimRisks(riskFlags, riskSummary) {
// 按当前查看者可见性过滤风险 flag确保列表与详情页对同一用户展示一致的风险口径。
// viewerOptions 为空时(如未提供用户上下文)原样返回,保持向后兼容。
function filterRiskFlagsForViewer(riskFlags, viewerOptions) {
const flags = Array.isArray(riskFlags) ? riskFlags : []
if (!viewerOptions || !viewerOptions.request) {
return flags
}
return flags.filter((flag) => {
if (!isActionableRiskFlag(flag)) {
return false
}
if (flag && typeof flag === 'object') {
return canViewRiskForContext(flag, viewerOptions)
}
return true
})
}
export function countClaimRisks(riskFlags, riskSummary, viewerOptions) {
let count = 0
for (const flag of Array.isArray(riskFlags) ? riskFlags : []) {
const visibleFlags = filterRiskFlagsForViewer(riskFlags, viewerOptions)
for (const flag of visibleFlags) {
if (!isActionableRiskFlag(flag)) {
continue
}
@@ -44,10 +64,11 @@ export function countClaimRisks(riskFlags, riskSummary) {
return count
}
export function resolveArchiveRiskTone(riskFlags, riskSummary) {
export function resolveArchiveRiskTone(riskFlags, riskSummary, viewerOptions) {
let tone = 'low'
for (const flag of Array.isArray(riskFlags) ? riskFlags : []) {
const visibleFlags = filterRiskFlagsForViewer(riskFlags, viewerOptions)
for (const flag of visibleFlags) {
if (!isActionableRiskFlag(flag)) {
continue
}

View File

@@ -756,14 +756,6 @@ export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser
const transportMode = String(fields.transportMode || '').trim()
const shouldEstimate = /差旅|住宿|交通/.test(applicationType) || Boolean(transportMode)
if (/差旅|出差/.test(applicationType) && !transportMode) {
return {
canCalculate: false,
reason: '缺少出行方式',
payload: null
}
}
if (!shouldEstimate || !days || !location) {
return {
canCalculate: false,
@@ -794,12 +786,8 @@ export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser
}
export function applyApplicationPolicyEstimateResult(preview = {}, result = {}, currentUser = {}) {
const resultTransportMode = String(result?.transport_mode || '').trim()
const fields = {
...(preview?.fields || {}),
...(!String(preview?.fields?.transportMode || '').trim() && resultTransportMode
? { transportMode: resultTransportMode }
: {})
...(preview?.fields || {})
}
const hotelRate = formatPolicyMoney(result?.hotel_rate)
const hotelAmount = formatPolicyMoney(result?.hotel_amount)
@@ -808,6 +796,11 @@ export function applyApplicationPolicyEstimateResult(preview = {}, result = {},
const matchedCity = String(result?.matched_city || fields.location || '').trim()
const grade = String(result?.grade || fields.grade || resolveCurrentUserGrade(currentUser)).trim()
if (isTravelApplicationType(fields.applicationType) && !String(fields.transportMode || '').trim()) {
const days = Number(result?.days) || parseApplicationDaysValue(fields.days) || 1
const baseTotalAmount = parseMoneyNumber(result?.hotel_amount) + parseMoneyNumber(result?.allowance_amount)
const baseTotalDisplay = Number.isFinite(baseTotalAmount) && baseTotalAmount > 0
? formatPolicyMoney(baseTotalAmount)
: ''
return normalizeApplicationPreview({
...preview,
fields: {
@@ -816,7 +809,10 @@ export function applyApplicationPolicyEstimateResult(preview = {}, result = {},
lodgingDailyCap: formatDailyPolicyMoney(result?.hotel_rate),
subsidyDailyCap: formatDailyPolicyMoney(result?.total_allowance_rate),
transportPolicy: APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT,
policyEstimate: APPLICATION_POLICY_PENDING_TEXT,
policyEstimate: baseTotalDisplay
? `交通待补充 + 住宿 ${hotelAmount}元 + 补贴 ${allowanceAmount}元 = ${baseTotalDisplay}元(${days}天,不含交通)`
: APPLICATION_POLICY_PENDING_TEXT,
amount: baseTotalDisplay ? `${baseTotalDisplay}元(不含交通)` : fields.amount,
matchedCity,
ruleName: String(result?.rule_name || '').trim(),
ruleVersion: String(result?.rule_version || '').trim(),
@@ -827,7 +823,7 @@ export function applyApplicationPolicyEstimateResult(preview = {}, result = {},
transportQueryLatencyMs: '',
transportEstimateSource: '',
transportEstimateConfidence: '',
policyTotalAmount: ''
policyTotalAmount: baseTotalDisplay ? `${baseTotalDisplay}元(不含交通)` : ''
},
policyEstimateStatus: 'pending'
})

View File

@@ -25,6 +25,25 @@ const ACTION_LINK_CLASS_BY_HREF = {
'#review-quick-edit': 'markdown-action-link-edit',
'#review-risk-panel': 'markdown-action-link-risk'
}
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 TRUSTED_HTML_ALLOWED_TAGS = new Set([
'section',
'article',
'header',
'footer',
'div',
'span',
'strong',
'a'
])
const TRUSTED_HTML_ALLOWED_ATTRS = new Set([
'aria-label',
'class',
'data-ai-action',
'href'
])
function escapeHtml(text) {
return String(text || '')
@@ -43,6 +62,9 @@ function renderRiskText(text) {
function resolveActionLinkClass(href) {
const normalizedHref = String(href || '').trim()
if (normalizedHref.startsWith(DOCUMENT_DETAIL_HREF_PREFIX)) {
return 'markdown-action-link-document'
}
return ACTION_LINK_CLASS_BY_HREF[normalizedHref] || ''
}
@@ -214,7 +236,76 @@ function normalizeColonHeadings(text) {
return normalizedLines.join('\n').replace(/\n{3,}/g, '\n\n')
}
export function renderMarkdown(text = '') {
const normalized = normalizeColonHeadings(text).trim()
return normalized ? markdown.render(normalized) : ''
function hasOnlyTrustedHtmlTags(html = '') {
const tagPattern = /<\/?([a-z][\w-]*)([^>]*)>/gi
let match = tagPattern.exec(html)
while (match) {
const tagName = String(match[1] || '').toLowerCase()
if (!TRUSTED_HTML_ALLOWED_TAGS.has(tagName)) {
return false
}
const attrText = String(match[2] || '')
const attrPattern = /\s([:@\w-]+)\s*=/g
let attrMatch = attrPattern.exec(attrText)
while (attrMatch) {
const attrName = String(attrMatch[1] || '').toLowerCase()
if (!TRUSTED_HTML_ALLOWED_ATTRS.has(attrName)) {
return false
}
attrMatch = attrPattern.exec(attrText)
}
match = tagPattern.exec(html)
}
return true
}
function sanitizeTrustedHtmlBlock(html = '') {
const value = String(html || '').trim()
if (!value || !value.includes('class="ai-document-card-list"')) {
return ''
}
if (/<(?:script|style|iframe|object|embed|link|meta|form|input|button|textarea|select)\b/i.test(value)) {
return ''
}
if (/\son[a-z]+\s*=/i.test(value) || /javascript\s*:/i.test(value)) {
return ''
}
if (!hasOnlyTrustedHtmlTags(value)) {
return ''
}
const hrefs = [...value.matchAll(/\shref="([^"]*)"/gi)].map((match) => String(match[1] || '').trim())
if (hrefs.some((href) => !href.startsWith(DOCUMENT_DETAIL_HREF_PREFIX))) {
return ''
}
return value
}
function extractTrustedHtmlBlocks(text = '') {
const trustedHtmlBlocks = []
const content = String(text || '').replace(TRUSTED_HTML_BLOCK_RE, (_match, html) => {
const sanitizedHtml = sanitizeTrustedHtmlBlock(html)
if (!sanitizedHtml) {
return ''
}
const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${trustedHtmlBlocks.length}`
trustedHtmlBlocks.push(sanitizedHtml)
return `\n\n${placeholder}\n\n`
})
return { content, trustedHtmlBlocks }
}
function restoreTrustedHtmlBlocks(html = '', trustedHtmlBlocks = []) {
return trustedHtmlBlocks.reduce((nextHtml, block, index) => {
const placeholder = `${TRUSTED_HTML_PLACEHOLDER_PREFIX}${index}`
const paragraphPattern = new RegExp(`<p>${placeholder}</p>\\n?`, 'g')
return nextHtml
.replace(paragraphPattern, block)
.replaceAll(placeholder, block)
}, html)
}
export function renderMarkdown(text = '') {
const { content, trustedHtmlBlocks } = extractTrustedHtmlBlocks(text)
const normalized = normalizeColonHeadings(content).trim()
return normalized ? restoreTrustedHtmlBlocks(markdown.render(normalized), trustedHtmlBlocks) : ''
}

View File

@@ -121,9 +121,6 @@ export function resolveRiskActionability(flag, options = {}) {
if (source === 'attachment_analysis') {
return 'fixable_by_submitter'
}
if (stage === 'expense_application') {
return 'review_decision'
}
if (['policy', 'invoice', 'trip', 'amount'].includes(domain)) {
return 'fixable_by_submitter'
}
@@ -147,9 +144,6 @@ export function resolveRiskVisibilityScope(flag, options = {}) {
if (actionability === 'fixable_by_submitter') {
return 'submitter'
}
if (stage === 'expense_application') {
return 'leader'
}
return 'finance'
}
@@ -226,8 +220,10 @@ export function canViewRiskForContext(flag, options = {}) {
return false
}
if (stage === 'expense_application') {
// 申请单阶段:申请人可见可自行整改的风险(信息完整性/差旅/金额等),
// 以便申请时知晓风险及原因;预算类仅预算审批人可见;其余(画像/审批流程)仅领导/审批人可见。
if (context.isCurrentApplicant) {
return false
return actionability === 'fixable_by_submitter' || visibilityScope === 'submitter'
}
if (riskDomain === 'budget' || actionability === 'budget_governance' || visibilityScope === 'budget_manager') {
return context.isBudgetReviewer