feat: 完善文档中心与报销申请交互及侧边栏重构

后端优化编排器报销查询和本体检测精度,增强报销单草稿保
存和附件回填逻辑,前端重构侧边栏组件支持折叠和图标导
航,完善文档中心状态筛选和详情提示,报销创建和审批详情
页优化会话管理和费用明细交互,新增助手应用服务和预设动
作工具函数,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-25 13:35:39 +08:00
parent 50b1c3f9a9
commit d0e946cf47
59 changed files with 5117 additions and 416 deletions

View File

@@ -0,0 +1,173 @@
export const ASSISTANT_SCOPE_ACTION_SWITCH = 'switch_assistant_session'
export const ASSISTANT_SCOPE_SESSION_APPLICATION = 'application'
export const ASSISTANT_SCOPE_SESSION_EXPENSE = 'expense'
export const ASSISTANT_SCOPE_SESSION_APPROVAL = 'approval'
export const ASSISTANT_SCOPE_SESSION_KNOWLEDGE = 'knowledge'
const SESSION_SCOPE_CONFIG = {
[ASSISTANT_SCOPE_SESSION_APPLICATION]: {
label: '申请助手',
icon: 'mdi mdi-file-plus-outline',
scope: '费用申请、事前审批、申请材料清单、申请单状态查询'
},
[ASSISTANT_SCOPE_SESSION_EXPENSE]: {
label: '报销助手',
icon: 'mdi mdi-receipt-text-plus-outline',
scope: '发起报销、票据识别、草稿归集、报销单状态查询和报销信息核对'
},
[ASSISTANT_SCOPE_SESSION_APPROVAL]: {
label: '审核助手',
icon: 'mdi mdi-clipboard-check-outline',
scope: '待审单据查询、审批动作、风险解释和审核意见草稿'
},
[ASSISTANT_SCOPE_SESSION_KNOWLEDGE]: {
label: '财务知识助手',
icon: 'mdi mdi-book-open-page-variant-outline',
scope: '财务制度、报销标准、票据要求、流程规则和政策口径解释'
}
}
const SESSION_SCOPE_TYPES = Object.keys(SESSION_SCOPE_CONFIG)
const APPLICATION_PATTERN =
/费用申请|发起申请|申请单|事前申请|事前审批|前置审批|出差申请|采购申请|用款申请|预算申请|申请材料|材料清单|先申请|立项申请/
const EXPENSE_PATTERN =
/报销|报销单|票据|发票|火车票|高铁票|机票|飞机票|的士票|出租车|网约车|酒店票|住宿票|住宿单据|保存草稿|草稿|费用明细|归集|上传.*票|关联单据|继续下一步/
const APPROVAL_PATTERN =
/待我审核|待审|审核|审批|审核意见|审批意见|审批通过|审批驳回|驳回|退回|审核中心|审批中心|领导审批|财务审核|处理意见/
const KNOWLEDGE_PATTERN =
/制度|政策|标准|规则|规定|流程|口径|依据|上限|额度|补贴|住宿标准|差旅标准|报销标准|票据要求|可不可以|能不能|怎么规定|如何计算|怎么算/
const EXPENSE_OPERATION_PATTERN = /发起报销|报销单|票据|发票|火车票|高铁票|机票|的士票|草稿|归集|上传|关联单据|继续下一步/
const CURRENT_CLAIM_RISK_PATTERN = /这张|当前|本单|该单|单据|风险|超标|异常|重复|待补/
function normalizeSessionType(sessionType) {
const normalized = String(sessionType || '').trim()
return SESSION_SCOPE_TYPES.includes(normalized) ? normalized : ASSISTANT_SCOPE_SESSION_EXPENSE
}
function normalizeText(rawText) {
return String(rawText || '')
.replace(/\s+/g, '')
.toLowerCase()
}
function resolveScopeConfig(sessionType) {
return SESSION_SCOPE_CONFIG[normalizeSessionType(sessionType)] || SESSION_SCOPE_CONFIG[ASSISTANT_SCOPE_SESSION_EXPENSE]
}
export function inferAssistantScopeTarget(rawText, options = {}) {
const text = normalizeText(rawText)
if (!text) {
return ''
}
const applicationMatched = APPLICATION_PATTERN.test(text)
const expenseMatched = EXPENSE_PATTERN.test(text)
const approvalMatched = APPROVAL_PATTERN.test(text)
const knowledgeMatched = KNOWLEDGE_PATTERN.test(text)
if (approvalMatched && /(待我审核|待审|审核意见|审批意见|审批通过|审批驳回|驳回|退回|审核中心|审批中心|处理意见)/.test(text)) {
return ASSISTANT_SCOPE_SESSION_APPROVAL
}
if (knowledgeMatched && !options.hasActiveReviewPayload && !EXPENSE_OPERATION_PATTERN.test(text)) {
return ASSISTANT_SCOPE_SESSION_KNOWLEDGE
}
if (expenseMatched && !applicationMatched) {
return ASSISTANT_SCOPE_SESSION_EXPENSE
}
if (applicationMatched && !expenseMatched) {
return ASSISTANT_SCOPE_SESSION_APPLICATION
}
if (knowledgeMatched && !expenseMatched && !approvalMatched && !applicationMatched) {
return ASSISTANT_SCOPE_SESSION_KNOWLEDGE
}
if (knowledgeMatched && !options.hasActiveReviewPayload) {
return ASSISTANT_SCOPE_SESSION_KNOWLEDGE
}
if (approvalMatched) {
return ASSISTANT_SCOPE_SESSION_APPROVAL
}
if (expenseMatched) {
return ASSISTANT_SCOPE_SESSION_EXPENSE
}
if (applicationMatched) {
return ASSISTANT_SCOPE_SESSION_APPLICATION
}
return ''
}
function shouldAllowCurrentExpensePolicyQuestion(rawText, currentSessionType, targetSessionType, options = {}) {
if (
normalizeSessionType(currentSessionType) !== ASSISTANT_SCOPE_SESSION_EXPENSE ||
targetSessionType !== ASSISTANT_SCOPE_SESSION_KNOWLEDGE ||
!options.hasActiveReviewPayload
) {
return false
}
return CURRENT_CLAIM_RISK_PATTERN.test(normalizeText(rawText))
}
function buildScopeSwitchAction(targetSessionType, rawText, options = {}) {
const target = resolveScopeConfig(targetSessionType)
const carryText = String(rawText || '').trim()
return {
label: `切换到${target.label}`,
description: `带着这条内容进入${target.label}继续处理`,
icon: target.icon,
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
payload: {
session_type: normalizeSessionType(targetSessionType),
carry_text: carryText,
carry_files: Boolean(options.attachmentCount)
}
}
}
function buildScopeBoundaryText(currentSessionType, targetSessionType) {
const current = resolveScopeConfig(currentSessionType)
const target = resolveScopeConfig(targetSessionType)
return [
`我先暂停在「${current.label}」里继续处理这条消息。`,
'',
`当前助手的业务范围是:${current.scope}`,
'',
`您这条内容更适合交给「${target.label}」处理;它的业务范围是:${target.scope}`,
'',
`建议切换到「${target.label}」后继续,我会尽量把这条内容带过去,避免在错误的会话里把流程跑偏。`
].join('\n')
}
export function resolveAssistantScopeGuard(rawText, currentSessionType, options = {}) {
const normalizedCurrent = normalizeSessionType(currentSessionType)
const targetSessionType = inferAssistantScopeTarget(rawText, options)
if (!targetSessionType || targetSessionType === normalizedCurrent) {
return null
}
if (shouldAllowCurrentExpensePolicyQuestion(rawText, normalizedCurrent, targetSessionType, options)) {
return null
}
const target = resolveScopeConfig(targetSessionType)
return {
targetSessionType,
targetLabel: target.label,
text: buildScopeBoundaryText(normalizedCurrent, targetSessionType),
meta: [`建议切换至${target.label}`],
suggestedActions: [buildScopeSwitchAction(targetSessionType, rawText, options)]
}
}

View File

@@ -0,0 +1,46 @@
const APPLICATION_FIELD_PREFILLS = {
time: '申请时间段:',
time_range: '申请时间段:',
location: '地点:',
reason: '事由:',
days: '天数:',
transport_mode: '出行方式:',
amount: '预计总费用:'
}
export function resolveSuggestedActionPrefill(action = {}) {
const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const explicitPrefill = String(
payload.prompt_prefill
|| payload.input_prefill
|| payload.prefill_text
|| ''
).trim()
if (explicitPrefill) {
return explicitPrefill
}
const actionType = String(action?.action_type || '').trim()
if (actionType !== 'prefill_composer') {
return ''
}
const applicationField = String(payload.application_field || '').trim()
return APPLICATION_FIELD_PREFILLS[applicationField] || ''
}
export function mergeComposerPrefill(currentDraft = '', prefill = '') {
const normalizedPrefill = String(prefill || '').trim()
if (!normalizedPrefill) {
return String(currentDraft || '')
}
const current = String(currentDraft || '')
if (!current.trim()) {
return normalizedPrefill
}
if (current.includes(normalizedPrefill)) {
return current
}
return `${current.trimEnd()}\n${normalizedPrefill}`
}

View File

@@ -14,6 +14,32 @@ function normalizeExpenseType(value) {
return String(value || '').trim() || 'other'
}
function isApplicationDocumentRequest(request) {
const documentType = String(
request?.documentTypeCode
|| request?.document_type_code
|| request?.documentType
|| request?.document_type
|| ''
).trim()
const claimNo = String(
request?.claimNo
|| request?.claim_no
|| request?.documentNo
|| request?.id
|| ''
).trim().toUpperCase()
const typeCode = normalizeExpenseType(request?.typeCode || request?.expense_type)
return (
documentType === 'application'
|| documentType === 'expense_application'
|| claimNo.startsWith('APP-')
|| typeCode === 'application'
|| typeCode.endsWith('_application')
)
}
function isSystemGeneratedExpenseItem(item) {
const itemType = normalizeExpenseType(item?.itemType || item?.item_type)
return Boolean(item?.isSystemGenerated || item?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
@@ -29,6 +55,10 @@ function getExpenseItems(request) {
}
export function hasMissingAttachment(request) {
if (isApplicationDocumentRequest(request)) {
return false
}
const expenseItems = getExpenseItems(request)
if (expenseItems.length) {

View File

@@ -0,0 +1,71 @@
const STORAGE_KEY = 'x-financial.documents.viewed'
const SCOPE_STORAGE_KEY = 'x-financial.documents.scope'
function getStorage() {
return typeof window === 'undefined' ? null : window.localStorage
}
export function resolveDocumentNewKey(row) {
const source = String(row?.source || 'document').trim()
const id = String(row?.claimId || row?.documentNo || row?.documentKey || row?.id || '').trim()
return id ? `${source}:${id}` : ''
}
export function readViewedDocumentKeys(storage = getStorage()) {
if (!storage) {
return new Set()
}
try {
const parsed = JSON.parse(storage.getItem(STORAGE_KEY) || '[]')
return new Set(Array.isArray(parsed) ? parsed.map((item) => String(item || '').trim()).filter(Boolean) : [])
} catch {
return new Set()
}
}
export function writeViewedDocumentKeys(keys, storage = getStorage()) {
if (!storage) {
return
}
storage.setItem(STORAGE_KEY, JSON.stringify(Array.from(keys).filter(Boolean)))
}
export function readDocumentScope(fallback, allowedScopes = [], storage = getStorage()) {
if (!storage) {
return fallback
}
const storedScope = String(storage.getItem(SCOPE_STORAGE_KEY) || '').trim()
return allowedScopes.includes(storedScope) ? storedScope : fallback
}
export function writeDocumentScope(scope, allowedScopes = [], storage = getStorage()) {
if (!storage || !allowedScopes.includes(scope)) {
return
}
storage.setItem(SCOPE_STORAGE_KEY, scope)
}
export function isNewDocument(row, viewedKeys) {
const key = resolveDocumentNewKey(row)
return Boolean(key) && !viewedKeys.has(key)
}
export function countNewDocuments(rows, viewedKeys) {
return rows.filter((row) => isNewDocument(row, viewedKeys)).length
}
export function markDocumentViewed(row, viewedKeys, storage = getStorage()) {
const key = resolveDocumentNewKey(row)
if (!key) {
return viewedKeys
}
const nextKeys = new Set(viewedKeys)
nextKeys.add(key)
writeViewedDocumentKeys(nextKeys, storage)
return nextKeys
}

View File

@@ -16,7 +16,10 @@ const SLOT_LABELS = {
expense_type: '费用场景',
amount: '申请金额',
time_range: '业务时间',
location: '业务地点',
reason: '申请事由',
days: '天数',
transport_mode: '出行方式',
attachments: '附件说明',
customer_name: '客户名称',
participants: '参与人员'
@@ -24,6 +27,33 @@ const SLOT_LABELS = {
const PRE_APPROVAL_TYPES = new Set(['travel', 'meeting', 'office', 'training'])
const ATTACHMENT_REQUIRED_TYPES = new Set(['meeting', 'training'])
const PLACEHOLDER_VALUES = new Set(['', '待补充', '暂无', '无', '未知'])
const PROMPT_FIELD_LABELS = [
'发生时间',
'业务发生时间',
'申请时间',
'时间',
'地点',
'业务地点',
'发生地点',
'事由',
'申请事由',
'出差事由',
'原因',
'用途',
'天数',
'出差天数',
'申请天数',
'出行方式',
'交通方式',
'交通工具',
'预计总费用',
'预计费用',
'预计金额',
'申请金额',
'预算',
'金额'
]
export const APPLICATION_EXAMPLES = [
'申请下周去北京做客户现场验收差旅预算18000元',
@@ -89,6 +119,112 @@ export function resolveTimeRangeText(ontology) {
return String(range.raw || '').trim()
}
function parseApplicationDate(value) {
const normalized = String(value || '')
.trim()
.replace(/日$/, '')
.replace(/年|月|\//g, '-')
.replace(/\./g, '-')
const match = normalized.match(/^(20\d{2})-(\d{1,2})-(\d{1,2})$/)
if (!match) return null
const [, year, month, day] = match
const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)))
if (Number.isNaN(date.getTime())) return null
return date
}
function formatApplicationDate(date) {
return date.toISOString().slice(0, 10)
}
function parseChineseNumber(value) {
const digits = {
: 1,
: 2,
: 2,
: 3,
: 4,
: 5,
: 6,
: 7,
: 8,
: 9
}
const text = String(value || '').trim()
if (!text) return 0
if (text === '十') return 10
if (text.includes('十')) {
const [left, right] = text.split('十')
const tens = left ? digits[left] || 0 : 1
const ones = right ? digits[right] || 0 : 0
return tens * 10 + ones
}
return digits[text] || 0
}
export function resolvePromptDays(prompt) {
const labeled = resolvePromptField(prompt, ['天数', '出差天数', '申请天数'])
const source = labeled || String(prompt || '')
const match = source.match(/(?<days>\d+|[一二两三四五六七八九十]{1,3})\s*天/)
if (!match?.groups?.days) return 0
if (/^\d+$/.test(match.groups.days)) return Number(match.groups.days)
return parseChineseNumber(match.groups.days)
}
export function expandApplicationTimeWithDays(timeText, days = 0) {
const normalizedTime = String(timeText || '').trim()
const dayCount = Number(days || 0)
if (!normalizedTime || !dayCount) return normalizedTime
if (/\s*(至|到|~|--|—)\s*/.test(normalizedTime)) return normalizedTime
const match = normalizedTime.match(/20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?/)
const startDate = parseApplicationDate(match?.[0] || '')
if (!startDate) return normalizedTime
const endDate = new Date(startDate.getTime())
endDate.setUTCDate(endDate.getUTCDate() + dayCount)
return `${formatApplicationDate(startDate)}${formatApplicationDate(endDate)}`
}
export function resolveApplicationTimeRange(ontology, prompt) {
const range = ontology?.time_range || {}
const baseTime = resolveTimeRangeText(ontology)
|| resolvePromptField(prompt, ['发生时间', '业务发生时间', '申请时间', '时间'])
if (range.start_date && range.end_date && range.start_date !== range.end_date) {
return `${range.start_date}${range.end_date}`
}
return expandApplicationTimeWithDays(baseTime, resolvePromptDays(prompt)) || baseTime
}
function escapeRegExp(value) {
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
export function resolvePromptField(prompt, labels = []) {
const text = String(prompt || '').trim()
if (!text) return ''
const labelSet = new Set(labels.map((item) => String(item || '').trim()).filter(Boolean))
for (const line of text.split(/\r?\n/)) {
const match = line.match(/^\s*([^:\s]+)\s*[:]\s*(.+?)\s*$/)
if (match && labelSet.has(match[1].trim())) {
return match[2].trim()
}
}
const labelPattern = labels.map(escapeRegExp).join('|')
const nextLabelPattern = PROMPT_FIELD_LABELS.map(escapeRegExp).join('|')
if (!labelPattern) return ''
const match = text.match(
new RegExp(`(?:${labelPattern})\\s*[:]\\s*([\\s\\S]*?)(?=\\s*(?:${nextLabelPattern})\\s*[:]|$)`)
)
return match ? match[1].trim().replace(/[,。;;]$/, '') : ''
}
export function resolveApplicationReason(prompt) {
const labeled = resolvePromptField(prompt, ['事由', '申请事由', '出差事由', '原因', '用途'])
return labeled || String(prompt || '').trim()
}
export function resolveAttachmentPolicy(expenseTypeCode, amount = 0) {
const code = String(expenseTypeCode || '').trim()
if (ATTACHMENT_REQUIRED_TYPES.has(code)) {
@@ -128,8 +264,15 @@ export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser
const documentTypeEntity = resolveEntity(ontology, 'document_type')
const workflowStageEntity = resolveEntity(ontology, 'workflow_stage')
const attachmentPolicy = resolveAttachmentPolicy(expenseTypeCode, amount.value)
const timeRange = resolveApplicationTimeRange(ontology, prompt)
|| '待补充'
const location = locationEntity?.normalized_value
|| locationEntity?.value
|| resolvePromptField(prompt, ['地点', '业务地点', '发生地点'])
|| '待补充'
const reason = resolveApplicationReason(prompt) || '待补充'
return {
const fields = {
documentType: documentTypeEntity?.normalized_value || 'expense_application',
documentTypeLabel: documentTypeEntity?.value || '费用申请',
workflowStage: workflowStageEntity?.normalized_value || 'pre_approval',
@@ -138,21 +281,40 @@ export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser
expenseTypeLabel: resolveExpenseTypeLabel(expenseTypeCode),
amount: amount.value,
amountDisplay: amount.value ? `¥${amount.value.toLocaleString('zh-CN')}` : '待补充',
timeRange: resolveTimeRangeText(ontology) || '待补充',
location: locationEntity?.normalized_value || locationEntity?.value || '待补充',
reason: String(prompt || '').trim() || '待补充',
timeRange,
location,
reason,
applicant: currentUser.name || currentUser.username || '当前用户',
department: currentUser.department || currentUser.departmentName || '待补充',
preApprovalRequired: PRE_APPROVAL_TYPES.has(expenseTypeCode),
attachmentPolicy,
missingSlots: normalizeMissingSlots(ontology?.missing_slots || [])
attachmentPolicy
}
return {
...fields,
missingSlots: normalizeMissingSlots(ontology?.missing_slots || [], fields)
}
}
export function normalizeMissingSlots(slots = []) {
function hasProvidedValue(value) {
const normalized = String(value || '').trim()
return !PLACEHOLDER_VALUES.has(normalized)
}
function isSlotAlreadyResolved(slot, fields = {}) {
const key = String(slot || '').trim()
if (key === 'reason') return hasProvidedValue(fields.reason)
if (key === 'time_range' || key === 'time') return hasProvidedValue(fields.timeRange)
if (key === 'location') return hasProvidedValue(fields.location)
if (key === 'amount') return Number(fields.amount || 0) > 0
if (key === 'transport_mode') return hasProvidedValue(fields.transportMode)
return false
}
export function normalizeMissingSlots(slots = [], fields = {}) {
const normalized = Array.isArray(slots) ? slots : []
return normalized.map((item) => ({
key: String(item || '').trim(),
label: SLOT_LABELS[String(item || '').trim()] || String(item || '').trim()
})).filter((item) => item.key)
})).filter((item) => item.key && !isSlotAlreadyResolved(item.key, fields))
}

View File

@@ -20,6 +20,7 @@ const RISK_TEXT_CLASS_BY_LABEL = {
const ACTION_LINK_CLASS_BY_HREF = {
'#confirm-attachment-association': 'markdown-action-link-confirm',
'#application-submit': 'markdown-action-link-confirm',
'#review-next-step': 'markdown-action-link-next',
'#review-quick-edit': 'markdown-action-link-edit',
'#review-risk-panel': 'markdown-action-link-risk'

View File

@@ -1,4 +1,6 @@
const DEFAULT_SESSION_TYPE_EXPENSE = 'expense'
const DEFAULT_SESSION_TYPE_APPLICATION = 'application'
const DEFAULT_SESSION_TYPE_APPROVAL = 'approval'
const DEFAULT_SESSION_TYPE_KNOWLEDGE = 'knowledge'
const DEFAULT_INTENT_LABELS = {
@@ -145,7 +147,8 @@ export function inferLocalFlowCandidates(rawText) {
}
export function shouldRequestExpenseSceneSelection(rawText, options = {}) {
if (options.sessionType === DEFAULT_SESSION_TYPE_KNOWLEDGE) {
const sessionType = options.sessionType || DEFAULT_SESSION_TYPE_EXPENSE
if (sessionType !== DEFAULT_SESSION_TYPE_EXPENSE) {
return false
}
if (Number(options.attachmentCount || 0) > 0) {
@@ -172,7 +175,8 @@ export function shouldRequestExpenseSceneSelection(rawText, options = {}) {
}
export function shouldRequestExpenseIntentConfirmation(rawText, options = {}) {
if (options.sessionType === DEFAULT_SESSION_TYPE_KNOWLEDGE) {
const sessionType = options.sessionType || DEFAULT_SESSION_TYPE_EXPENSE
if (sessionType !== DEFAULT_SESSION_TYPE_EXPENSE) {
return false
}
if (Number(options.attachmentCount || 0) > 0) {
@@ -203,6 +207,12 @@ export function buildLocalIntentPreview(rawText, sessionType = DEFAULT_SESSION_T
if (sessionType === DEFAULT_SESSION_TYPE_KNOWLEDGE) {
return '初步识别为财务知识问答,正在准备检索范围'
}
if (sessionType === DEFAULT_SESSION_TYPE_APPLICATION) {
return '初步识别为费用申请事项,准备进入申请信息识别'
}
if (sessionType === DEFAULT_SESSION_TYPE_APPROVAL) {
return '初步识别为审核处理事项,准备进入单据查询或风险核对'
}
if (shouldRequestExpenseIntentConfirmation(rawText, { ...options, sessionType })) {
return '识别到业务事项描述,但是否发起报销尚不明确,需要先由用户确认'

View File

@@ -5,6 +5,30 @@ const REQUEST_TYPE_META = {
tone: 'travel',
secondaryStatusLabel: '行程状态'
},
travel_application: {
label: '差旅费用申请',
detailVariant: 'travel',
tone: 'travel',
secondaryStatusLabel: '申请材料'
},
expense_application: {
label: '费用申请',
detailVariant: 'general',
tone: 'other',
secondaryStatusLabel: '申请材料'
},
purchase_application: {
label: '采购费用申请',
detailVariant: 'general',
tone: 'office',
secondaryStatusLabel: '申请材料'
},
meeting_application: {
label: '会务费用申请',
detailVariant: 'general',
tone: 'meeting',
secondaryStatusLabel: '申请材料'
},
train_ticket: {
label: '火车票',
detailVariant: 'travel',