feat(web): 工作台 AI 模式报销预审与文档查询模型拆分
- 新增 aiApplicationPrecheckModel/aiDocumentQueryModel/aiApplicationPreviewActions/aiConversationHtmlRenderer 四个独立模型与服务,按职责从主组件拆出 - PersonalWorkbenchAiMode 接入拆分后的预审、文档查询与 HTML 渲染逻辑,配合 markdown 工具增强结构化展示 - 文档中心与归档筛选、风险可见性、申请预览等工具同步适配,补充对应单元测试 - 新增 AI 文档卡片背景资源
This commit is contained in:
345
web/src/utils/aiApplicationPrecheckModel.js
Normal file
345
web/src/utils/aiApplicationPrecheckModel.js
Normal 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')
|
||||
}
|
||||
647
web/src/utils/aiConversationHtmlRenderer.js
Normal file
647
web/src/utils/aiConversationHtmlRenderer.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
784
web/src/utils/aiDocumentQueryModel.js
Normal file
784
web/src/utils/aiDocumentQueryModel.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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) : ''
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user