@@ -370,43 +370,53 @@
-
-
-
-
+
+onBeforeUnmount(() => {
+ clearDocumentInboxInitialRefreshTimer()
+ stopDocumentInboxPolling()
+})
+
+
+
+
diff --git a/web/src/components/layout/topBarKpis.js b/web/src/components/layout/topBarKpis.js
new file mode 100644
index 0000000..b3f908d
--- /dev/null
+++ b/web/src/components/layout/topBarKpis.js
@@ -0,0 +1,132 @@
+export const CHAT_KPIS = [
+ { label: '今日已问数', value: 86, unit: '次', meta: '较昨日 +18', trend: 'up', color: 'var(--theme-primary)' },
+ { label: '已解决问题', value: 72, unit: '条', meta: '解决率 83.7%', trend: 'up', color: '#3b82f6' },
+ { label: '知识命中率', value: '92.3', unit: '%', meta: '较昨日 +2.6%', trend: 'up', color: '#8b5cf6' },
+ { label: '平均响应时长', value: 2.1, unit: 's', meta: '较昨日 -0.3s', trend: 'down', color: '#f59e0b' }
+]
+
+export const APPROVAL_KPIS = [
+ { label: '待审批单据', value: 12, unit: '单', meta: '较昨日 +3', trend: 'up', color: 'var(--theme-primary)' },
+ { label: '高风险单据', value: 4, unit: '单', meta: '较昨日 +1', trend: 'up', color: '#ef4444' },
+ { label: '即将超时', value: 3, unit: '单', meta: '30 分钟内', trend: 'down', color: '#f59e0b' },
+ { label: '今日已处理', value: 28, unit: '单', meta: '通过率 86%', trend: 'up', color: 'var(--success)' }
+]
+
+export function buildRequestKpis(summary = {}) {
+ const total = Number(summary.total ?? 0)
+ const draft = Number(summary.draft ?? 0)
+ const inProgress = Number(summary.inProgress ?? 0)
+ const completed = Number(summary.completed ?? 0)
+
+ return [
+ { label: '全部单据', value: total, delta: '当前', trend: 'up', arrow: 'mdi mdi-minus', color: 'var(--theme-primary)' },
+ { label: '草稿', value: draft, delta: '待提交', trend: draft > 0 ? 'down' : 'up', arrow: draft > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#f59e0b' },
+ { label: '审批中', value: inProgress, delta: '处理中', trend: inProgress > 0 ? 'up' : 'down', arrow: inProgress > 0 ? 'mdi mdi-arrow-up' : 'mdi mdi-minus', color: '#3b82f6' },
+ { label: '已完成', value: completed, delta: '已归档', trend: 'up', arrow: 'mdi mdi-arrow-up' , color: 'var(--success)' }
+ ]
+}
+
+export function buildDocumentKpis(summary = {}) {
+ const total = Number(summary.total ?? 0)
+ const toSubmit = Number(summary.toSubmit ?? 0)
+ const toProcess = Number(summary.toProcess ?? 0)
+ const archived = Number(summary.archived ?? 0)
+
+ return [
+ { label: '全部单据', value: total, delta: '当前', trend: 'up', arrow: 'mdi mdi-minus', color: 'var(--theme-primary)' },
+ { label: '待提交', value: toSubmit, delta: '草稿待办', trend: toSubmit > 0 ? 'down' : 'up', arrow: toSubmit > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#f59e0b' },
+ { label: '待我处理', value: toProcess, delta: '审批待办', trend: toProcess > 0 ? 'down' : 'up', arrow: toProcess > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#3b82f6' },
+ { label: '已归档', value: archived, delta: '归档入账', trend: 'up', arrow: 'mdi mdi-arrow-up', color: 'var(--success)' }
+ ]
+}
+
+export function buildDigitalEmployeeWorkRecordKpis(summary = {}) {
+ const total = Number(summary.total ?? 0)
+ const succeeded = Number(summary.succeeded ?? 0)
+ const failed = Number(summary.failed ?? 0)
+
+ return [
+ {
+ label: '日志总数',
+ value: total,
+ delta: '当前',
+ trend: 'up',
+ arrow: 'mdi mdi-minus',
+ color: 'var(--theme-primary)'
+ },
+ {
+ label: '成功数量',
+ value: succeeded,
+ delta: total ? `占比 ${Math.round((succeeded / total) * 100)}%` : '等待数据',
+ trend: 'up',
+ arrow: succeeded > 0 ? 'mdi mdi-arrow-up' : 'mdi mdi-minus',
+ color: 'var(--success)'
+ },
+ {
+ label: '失败数量',
+ value: failed,
+ delta: failed > 0 ? '需要关注' : '暂无失败',
+ trend: failed > 0 ? 'down' : 'up',
+ arrow: failed > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus',
+ color: '#ef4444'
+ }
+ ]
+}
+
+export function buildKnowledgeKpis(summary = {}) {
+ const totalDocuments = Number(summary.totalDocuments ?? 0)
+
+ return [
+ {
+ label: '文档总数',
+ value: String(totalDocuments),
+ meta: '',
+ trend: 'up',
+ color: 'var(--theme-primary)'
+ }
+ ]
+}
+
+export function buildEmployeeKpis(summary = {}) {
+ const total = Number(summary.total ?? 0)
+ const active = Number(summary.active ?? 0)
+ const onboarding = Number(summary.onboarding ?? 0)
+ const disabled = Number(summary.disabled ?? 0)
+ const followUp = Number(summary.followUp ?? 0)
+ const departments = Number(summary.departments ?? 0)
+
+ return [
+ {
+ label: '员工总数',
+ value: total,
+ unit: '人',
+ meta: `覆盖 ${departments} 个部门`,
+ trend: 'up',
+ color: 'var(--theme-primary)'
+ },
+ {
+ label: '在职账号',
+ value: active,
+ unit: '人',
+ meta: total ? `占比 ${Math.round((active / total) * 100)}%` : '等待数据',
+ trend: 'up',
+ color: '#3b82f6'
+ },
+ {
+ label: '待处理状态',
+ value: onboarding + disabled,
+ unit: '人',
+ meta: `试用 ${onboarding} / 停用 ${disabled}`,
+ trend: onboarding + disabled > 0 ? 'down' : 'up',
+ color: '#f59e0b'
+ },
+ {
+ label: '同步待处理',
+ value: followUp,
+ unit: '人',
+ meta: followUp > 0 ? '存在待同步账号' : '资料已同步',
+ trend: followUp > 0 ? 'down' : 'up',
+ color: '#8b5cf6'
+ }
+ ]
+}
diff --git a/web/src/components/layout/useTopBarOverviewRange.js b/web/src/components/layout/useTopBarOverviewRange.js
new file mode 100644
index 0000000..0d4e024
--- /dev/null
+++ b/web/src/components/layout/useTopBarOverviewRange.js
@@ -0,0 +1,114 @@
+import { computed, ref, watch } from 'vue'
+
+import { formatDateValue } from '../../utils/dateRangeDefaults.js'
+
+const OVERVIEW_DASHBOARD_OPTIONS = [
+ { label: '财务看板', value: 'finance' },
+ { label: '风险看板', value: 'risk' },
+ { label: '数字员工看板', value: 'digitalEmployee' },
+ { label: '系统看板', value: 'system' }
+]
+
+export function useTopBarOverviewRange(props, emit) {
+ const calendarOpen = ref(false)
+ const draftStart = ref(props.customRange.start)
+ const draftEnd = ref(props.customRange.end)
+ const overviewDashboardOptions = OVERVIEW_DASHBOARD_OPTIONS
+ const overviewDashboardValue = computed({
+ get: () => props.overviewDashboard,
+ set: (value) => emit('update:overviewDashboard', value)
+ })
+
+ const rangeOptions = computed(() =>
+ props.ranges.map((range) => ({
+ value: range,
+ label: String(range)
+ }))
+ )
+
+ const activeOption = computed(() =>
+ rangeOptions.value.find((option) => option.value === props.activeRange) ?? rangeOptions.value[0]
+ )
+
+ const isCustomRange = computed(() => props.activeRange === 'custom')
+ const activeDateLabel = computed(() => {
+ if (isCustomRange.value) return formatRangeLabel(props.customRange.start, props.customRange.end)
+ return buildPresetRangeLabel(activeOption.value?.label)
+ })
+
+ const canApplyCustomRange = computed(() =>
+ Boolean(draftStart.value && draftEnd.value && draftStart.value <= draftEnd.value)
+ )
+
+ watch(
+ () => props.customRange,
+ (range) => {
+ draftStart.value = range.start
+ draftEnd.value = range.end
+ },
+ { deep: true }
+ )
+
+ function setRange(range) {
+ emit('update:activeRange', range)
+ calendarOpen.value = false
+ }
+
+ function applyCustomRange() {
+ if (!canApplyCustomRange.value) return
+ emit('update:customRange', { start: draftStart.value, end: draftEnd.value })
+ emit('update:activeRange', 'custom')
+ calendarOpen.value = false
+ }
+
+ return {
+ calendarOpen,
+ draftStart,
+ draftEnd,
+ overviewDashboardOptions,
+ overviewDashboardValue,
+ rangeOptions,
+ activeOption,
+ isCustomRange,
+ activeDateLabel,
+ canApplyCustomRange,
+ setRange,
+ applyCustomRange
+ }
+}
+
+function formatRangeLabel(start, end) {
+ if (!start || !end) return '选择时间段'
+ if (start === end) return start
+ return `${start} ~ ${end}`
+}
+
+function buildPresetRangeLabel(label) {
+ const now = new Date()
+ const today = formatDateValue(now)
+
+ if (label === '今日') {
+ return today
+ }
+
+ if (label === '近10日') {
+ const start = new Date(now)
+ start.setHours(0, 0, 0, 0)
+ start.setDate(start.getDate() - 9)
+ return `${formatDateValue(start)} ~ ${today}`
+ }
+
+ if (label === '本周') {
+ const start = new Date(now)
+ const day = start.getDay() || 7
+ start.setHours(0, 0, 0, 0)
+ start.setDate(start.getDate() - day + 1)
+ return `${formatDateValue(start)} ~ ${today}`
+ }
+
+ if (label === '本月') {
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
+ }
+
+ return today
+}
diff --git a/web/src/components/shared/RiskRuleTestDialog.vue b/web/src/components/shared/RiskRuleTestDialog.vue
index 5387284..6b8700d 100644
--- a/web/src/components/shared/RiskRuleTestDialog.vue
+++ b/web/src/components/shared/RiskRuleTestDialog.vue
@@ -332,9 +332,13 @@ import { recognizeOcrFiles } from '../../services/ocr.js'
import { useToast } from '../../composables/useToast.js'
import {
createId,
+ documentHasMeaningfulText,
formatFileSize,
formatTestError,
- formatTime
+ formatTime,
+ mergeRecognizedDocuments,
+ normalizeOcrDocuments,
+ toAttachmentPayload
} from './riskRuleTestDialogUtils.js'
import {
buildDocumentBrief,
@@ -716,70 +720,6 @@ function buildTraceItems(result) {
return buildTraceItemsModel(result, fields.value)
}
-function toAttachmentPayload(file) {
- const document = file.ocrDocument || {}
- return {
- id: file.id,
- name: file.name,
- size: file.size,
- content_type: file.contentType,
- note: file.error || '',
- recognition_status: file.status,
- ocr_text: document.text || '',
- summary: document.summary || '',
- document_type: document.document_type || '',
- document_type_label: document.document_type_label || '',
- scene_code: document.scene_code || '',
- scene_label: document.scene_label || '',
- avg_score: document.avg_score || 0,
- document_fields: Array.isArray(document.document_fields) ? document.document_fields : []
- }
-}
-
-function normalizeOcrDocuments(payload) {
- const documents = Array.isArray(payload?.documents) ? payload.documents : []
- return documents.map((item) => ({
- filename: String(item?.filename || '').trim(),
- summary: String(item?.summary || '').trim(),
- text: String(item?.text || '').trim(),
- avg_score: Number(item?.avg_score || 0),
- document_type: String(item?.document_type || 'other').trim() || 'other',
- document_type_label: String(item?.document_type_label || '').trim(),
- scene_code: String(item?.scene_code || 'other').trim() || 'other',
- scene_label: String(item?.scene_label || '').trim(),
- document_fields: Array.isArray(item?.document_fields)
- ? item.document_fields
- .map((field) => ({
- key: String(field?.key || '').trim(),
- label: String(field?.label || '').trim(),
- value: String(field?.value || '').trim()
- }))
- .filter((field) => field.key && field.label && field.value)
- : [],
- warnings: Array.isArray(item?.warnings) ? item.warnings : []
- }))
-}
-
-function mergeRecognizedDocuments(current, incoming) {
- const next = [...current]
- incoming.forEach((document) => {
- const index = next.findIndex((item) => item.filename === document.filename)
- if (index >= 0) {
- next.splice(index, 1, document)
- } else {
- next.push(document)
- }
- })
- return next
-}
-
-function documentHasMeaningfulText(document) {
- return Boolean(
- String(document?.text || document?.summary || '').trim() ||
- (Array.isArray(document?.document_fields) && document.document_fields.length)
- )
-}
-
function buildRecognitionStepDescription() {
if (!requiresAttachment.value) return '当前规则不需要附件,直接根据文字测试事实抽取字段。'
if (recognitionBusy.value) return '正在读取临时附件并提取 OCR 字段。'
@@ -839,4 +779,3 @@ function isActiveSession(activeSessionId) {
-
diff --git a/web/src/components/shared/riskRuleTestDialogUtils.js b/web/src/components/shared/riskRuleTestDialogUtils.js
index 0588d1a..01baff4 100644
--- a/web/src/components/shared/riskRuleTestDialogUtils.js
+++ b/web/src/components/shared/riskRuleTestDialogUtils.js
@@ -26,3 +26,67 @@ export function formatTime() {
minute: '2-digit'
}).format(new Date())
}
+
+export function toAttachmentPayload(file) {
+ const document = file.ocrDocument || {}
+ return {
+ id: file.id,
+ name: file.name,
+ size: file.size,
+ content_type: file.contentType,
+ note: file.error || '',
+ recognition_status: file.status,
+ ocr_text: document.text || '',
+ summary: document.summary || '',
+ document_type: document.document_type || '',
+ document_type_label: document.document_type_label || '',
+ scene_code: document.scene_code || '',
+ scene_label: document.scene_label || '',
+ avg_score: document.avg_score || 0,
+ document_fields: Array.isArray(document.document_fields) ? document.document_fields : []
+ }
+}
+
+export function normalizeOcrDocuments(payload) {
+ const documents = Array.isArray(payload?.documents) ? payload.documents : []
+ return documents.map((item) => ({
+ filename: String(item?.filename || '').trim(),
+ summary: String(item?.summary || '').trim(),
+ text: String(item?.text || '').trim(),
+ avg_score: Number(item?.avg_score || 0),
+ document_type: String(item?.document_type || 'other').trim() || 'other',
+ document_type_label: String(item?.document_type_label || '').trim(),
+ scene_code: String(item?.scene_code || 'other').trim() || 'other',
+ scene_label: String(item?.scene_label || '').trim(),
+ document_fields: Array.isArray(item?.document_fields)
+ ? item.document_fields
+ .map((field) => ({
+ key: String(field?.key || '').trim(),
+ label: String(field?.label || '').trim(),
+ value: String(field?.value || '').trim()
+ }))
+ .filter((field) => field.key && field.label && field.value)
+ : [],
+ warnings: Array.isArray(item?.warnings) ? item.warnings : []
+ }))
+}
+
+export function mergeRecognizedDocuments(current, incoming) {
+ const next = [...current]
+ incoming.forEach((document) => {
+ const index = next.findIndex((item) => item.filename === document.filename)
+ if (index >= 0) {
+ next.splice(index, 1, document)
+ } else {
+ next.push(document)
+ }
+ })
+ return next
+}
+
+export function documentHasMeaningfulText(document) {
+ return Boolean(
+ String(document?.text || document?.summary || '').trim() ||
+ (Array.isArray(document?.document_fields) && document.document_fields.length)
+ )
+}
diff --git a/web/src/components/travel/TravelRequestDetailHero.vue b/web/src/components/travel/TravelRequestDetailHero.vue
new file mode 100644
index 0000000..e48a157
--- /dev/null
+++ b/web/src/components/travel/TravelRequestDetailHero.vue
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+

+
+
+
+
{{ profile.name }}
+ {{ profile.identity }}
+
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
{{ item.value }}
+
+
+
+
+
+
+
+
diff --git a/web/src/components/travel/TravelRequestProgressCard.vue b/web/src/components/travel/TravelRequestProgressCard.vue
new file mode 100644
index 0000000..8478491
--- /dev/null
+++ b/web/src/components/travel/TravelRequestProgressCard.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
{{ isApplicationDocument ? '申请进度' : '报销进度' }}
+
+
+
+
+
+
+ {{ step.index }}
+
+
+ {{ step.label }}
+ {{ step.time }}
+ {{ step.detail }}
+
+
+
+
+
+
+
+
diff --git a/web/src/components/travel/TravelRequestRelatedApplicationCard.vue b/web/src/components/travel/TravelRequestRelatedApplicationCard.vue
new file mode 100644
index 0000000..ccecdc3
--- /dev/null
+++ b/web/src/components/travel/TravelRequestRelatedApplicationCard.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
关联单据信息
+
展示本次报销关联的前置申请,便于核对申请内容、天数、事由和预计金额。
+
+
+
+
+
+
+
+
diff --git a/web/src/composables/overviewViewDisplayModel.js b/web/src/composables/overviewViewDisplayModel.js
new file mode 100644
index 0000000..dd5d669
--- /dev/null
+++ b/web/src/composables/overviewViewDisplayModel.js
@@ -0,0 +1,199 @@
+import { formatRiskSignalLabel } from '../utils/riskLabels.js'
+
+export const emptyFinanceTotals = {
+ reimbursementAmount: 0,
+ reimbursementCount: 0,
+ pendingPaymentAmount: 0,
+ avgClaimAmount: 0,
+ budgetUsageRate: 0,
+ paymentClearanceRate: 0
+}
+
+export const emptyFinanceTrend = {
+ labels: [],
+ claimCount: [],
+ claimAmount: [],
+ categoryAmountSeries: [],
+ applications: [],
+ approved: [],
+ avgHours: []
+}
+
+export const emptyFinanceDonut = [
+ { name: '暂无数据', value: 0, color: '#cbd5e1' }
+]
+
+export const emptyFinanceBudgetSummary = {
+ ratio: 0,
+ total: '¥0',
+ used: '¥0',
+ left: '¥0'
+}
+
+export const emptyFinanceBudgetMetrics = [
+ { label: '预算池数量', value: '0 个', detail: '年度有效预算池', tone: 'neutral', icon: 'mdi mdi-database-outline' },
+ { label: '总预算', value: '¥0', detail: '原始预算 + 调整', tone: 'neutral', icon: 'mdi mdi-cash-register' },
+ { label: '已用预算', value: '¥0', detail: '使用率 0.0%', tone: 'success', icon: 'mdi mdi-chart-arc' },
+ { label: '预占预算', value: '¥0', detail: '待流转单据占用', tone: 'success', icon: 'mdi mdi-lock-outline' },
+ { label: '可用预算', value: '¥0', detail: '可继续使用额度', tone: 'success', icon: 'mdi mdi-wallet-outline' },
+ { label: '预警预算池', value: '0 个', detail: '超支 0 个', tone: 'success', icon: 'mdi mdi-alert-outline' }
+]
+
+export const emptySystemDashboardTotals = {
+ toolCalls: 0,
+ modelTokens: 0,
+ onlineUsers: 0,
+ avgOnlineMinutes: 0,
+ executionSuccessRate: 0,
+ positiveFeedback: 0,
+ negativeFeedback: 0,
+ failedRuns: 0,
+ toolCallsChange: 0,
+ modelTokensChange: 0
+}
+
+export const emptySystemLoginWave = {
+ labels: Array.from({ length: 24 }, (_, hour) => `${String(hour).padStart(2, '0')}:00`),
+ loginUsers: Array.from({ length: 24 }, () => 0),
+ interactions: Array.from({ length: 24 }, () => 0)
+}
+
+export function formatCompact(value) {
+ if (value >= 1_000_000) return `¥${(value / 1_000_000).toFixed(1)}M`
+ if (value >= 1_000) return `¥${(value / 1_000).toFixed(1)}K`
+ return `¥${value}`
+}
+
+export function formatCurrency(value) {
+ return formatCompact(value)
+}
+
+export function formatNumberCompact(value) {
+ const number = Number(value || 0)
+ if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1)}M`
+ if (number >= 1_000) return `${(number / 1_000).toFixed(1)}K`
+ return `${Math.round(number)}`
+}
+
+export function formatPercent(value) {
+ return `${Math.round(Number(value || 0) * 100)}%`
+}
+
+export function formatMetricValue(metric, value) {
+ if (['reimbursementAmount', 'pendingPaymentAmount', 'avgClaimAmount'].includes(metric.key)) {
+ return formatCurrency(Math.round(value))
+ }
+ if (metric.unit === '%') return `${Math.round(value)} ${metric.unit}`
+ if (metric.unit) return `${Math.round(value)} ${metric.unit}`
+ return `${Math.round(value)}`
+}
+
+export function formatSystemMetricValue(metric, value, totals = emptySystemDashboardTotals) {
+ const numericValue = Number(value || 0)
+ if (metric.key === 'modelTokens') return formatNumberCompact(numericValue)
+ if (metric.key === 'avgOnlineMinutes') return `${numericValue.toFixed(1)} ${metric.unit}`
+ if (metric.key === 'executionSuccessRate') return `${numericValue.toFixed(1)}${metric.unit}`
+ if (metric.key === 'positiveFeedback') {
+ const negativeFeedback = Math.round(Number(totals.negativeFeedback || 0))
+ return `${Math.round(numericValue)} / ${negativeFeedback}`
+ }
+ if (metric.unit) return `${formatNumberCompact(numericValue)} ${metric.unit}`
+ return formatNumberCompact(numericValue)
+}
+
+export function resolveSystemMetricMeta(metric, totals = emptySystemDashboardTotals, realDashboardLoaded = false) {
+ if (!realDashboardLoaded) {
+ return {
+ changeText: metric.change,
+ delta: metric.delta,
+ trend: metric.trend
+ }
+ }
+
+ if (metric.key === 'toolCalls' || metric.key === 'modelTokens') {
+ const changeValue = Number(totals[`${metric.key}Change`] || 0)
+ return {
+ changeText: `${changeValue >= 0 ? '+' : ''}${changeValue.toFixed(1)}%`,
+ delta: '较上一周期',
+ trend: changeValue < 0 ? 'down' : 'up'
+ }
+ }
+
+ if (metric.key === 'executionSuccessRate') {
+ const errorRate = Math.max(0, 100 - Number(totals.executionSuccessRate || 0))
+ return {
+ changeText: '实时',
+ delta: `错误率 ${errorRate.toFixed(1)}%`,
+ trend: 'up'
+ }
+ }
+
+ if (metric.key === 'positiveFeedback') {
+ return {
+ changeText: '实时',
+ delta: `差评 ${Math.round(Number(totals.negativeFeedback || 0))} 次`,
+ trend: 'up'
+ }
+ }
+
+ return {
+ changeText: '实时',
+ delta: metric.key === 'onlineUsers' ? '活跃会话' : '按最近会话统计',
+ trend: metric.trend
+ }
+}
+
+export function resolveFinanceMetricMeta({
+ metric,
+ meta,
+ dashboardLoaded,
+ loading,
+ error
+}) {
+ if (!dashboardLoaded || !meta) {
+ return {
+ changeText: loading ? '加载中' : '实时',
+ delta: error ? '真实数据加载失败' : '等待真实数据',
+ trend: metric.trend
+ }
+ }
+
+ return {
+ changeText: meta.changeText || metric.change,
+ delta: meta.delta || metric.delta,
+ trend: meta.trend || metric.trend
+ }
+}
+
+export function buildRiskDistributionLegend(distribution, labels, colors, formatter = formatRiskSignalName) {
+ const fallbackColors = ['#ef4444', '#f59e0b', 'var(--theme-primary)', '#3b82f6', '#8b5cf6', '#0f766e']
+ const entries = Object.entries(distribution || {})
+ .filter(([, value]) => Number(value || 0) > 0)
+
+ if (!entries.length) {
+ return [
+ {
+ name: '暂无数据',
+ value: 1,
+ display: '0项',
+ color: '#cbd5e1'
+ }
+ ]
+ }
+
+ return entries.map(([key, value], index) => ({
+ name: labels[key] || formatter(key),
+ value: Number(value || 0),
+ display: `${Number(value || 0)}项`,
+ color: colors[key] || fallbackColors[index % fallbackColors.length]
+ }))
+}
+
+export function formatRiskSignalName(value) {
+ return formatRiskSignalLabel(value)
+}
+
+export function isMissingDimension(value) {
+ const text = String(value || '').trim()
+ return !text || ['待补充', '待确认', '未归属部门', '未归属', 'N/A', 'n/a', '-'].includes(text)
+}
diff --git a/web/src/composables/overviewViewRangeModel.js b/web/src/composables/overviewViewRangeModel.js
new file mode 100644
index 0000000..9da9696
--- /dev/null
+++ b/web/src/composables/overviewViewRangeModel.js
@@ -0,0 +1,122 @@
+export const DEFAULT_OVERVIEW_RANGE = '近10日'
+
+const DAY_MS = 24 * 60 * 60 * 1000
+const RISK_DAILY_TREND_MAX_BUCKETS = 14
+
+function parseLocalDate(value) {
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || '').trim())
+ if (!match) {
+ return null
+ }
+ const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
+ return Number.isNaN(date.getTime()) ? null : date
+}
+
+function clampWindowDays(value) {
+ const days = Number(value || 0)
+ if (!Number.isFinite(days) || days <= 0) {
+ return 10
+ }
+ return Math.max(1, Math.min(Math.round(days), 90))
+}
+
+function resolveCustomRangeDays(customRange = {}) {
+ const start = parseLocalDate(customRange.start)
+ const end = parseLocalDate(customRange.end)
+ if (!start || !end) {
+ return 10
+ }
+ return clampWindowDays(Math.abs(end.getTime() - start.getTime()) / DAY_MS + 1)
+}
+
+export function resolveTopRangeDays(range, customRange = {}) {
+ const key = String(range || DEFAULT_OVERVIEW_RANGE).trim()
+ if (key === 'custom') {
+ return resolveCustomRangeDays(customRange)
+ }
+ if (key === '\u4eca\u65e5') {
+ return 1
+ }
+ if (key === '\u672c\u5468') {
+ const today = new Date()
+ const weekday = today.getDay() || 7
+ return clampWindowDays(weekday)
+ }
+ if (key === '\u672c\u6708') {
+ return clampWindowDays(new Date().getDate())
+ }
+ const match = key.match(/\d+/)
+ return clampWindowDays(match ? Number(match[0]) : 10)
+}
+
+export function resolveTopRangeKey(range, customRange = {}) {
+ const key = String(range || DEFAULT_OVERVIEW_RANGE).trim()
+ if (key === 'custom') {
+ return 'custom'
+ }
+ if (key === '\u672c\u5468' || key === '\u4eca\u65e5') {
+ return `recent-${resolveTopRangeDays(key, customRange)}-days`
+ }
+ if (/\d+/.test(key)) {
+ return `recent-${resolveTopRangeDays(key, customRange)}-days`
+ }
+ return key || DEFAULT_OVERVIEW_RANGE
+}
+
+function formatRiskTrendDateLabel(value) {
+ const date = parseLocalDate(value)
+ if (!date) {
+ return String(value || '-').trim() || '-'
+ }
+ const month = String(date.getMonth() + 1).padStart(2, '0')
+ const day = String(date.getDate()).padStart(2, '0')
+ return `${month}-${day}`
+}
+
+function buildRiskTrendBucketLabel(first, last) {
+ const start = String(first?.date || '').trim()
+ const end = String(last?.date || '').trim()
+ if (!start || start === end) {
+ return formatRiskTrendDateLabel(start)
+ }
+ return `${formatRiskTrendDateLabel(start)}~${formatRiskTrendDateLabel(end)}`
+}
+
+function normalizeRiskTrendRow(item) {
+ return {
+ date: String(item.date || '').trim() || '-',
+ total: Number(item.total || 0),
+ highOrAbove: Number(item.high_or_above ?? item.highOrAbove ?? 0)
+ }
+}
+
+export function aggregateRiskDailyTrendRows(rows, maxBuckets = RISK_DAILY_TREND_MAX_BUCKETS) {
+ const normalizedRows = rows
+ .map(normalizeRiskTrendRow)
+ .filter((item) => item.date !== '-' || item.total > 0 || item.highOrAbove > 0)
+
+ if (normalizedRows.length <= maxBuckets) {
+ return normalizedRows.map((item) => ({
+ ...item,
+ date: formatRiskTrendDateLabel(item.date),
+ sourceStartDate: item.date,
+ sourceEndDate: item.date
+ }))
+ }
+
+ const bucketSize = Math.ceil(normalizedRows.length / maxBuckets)
+ const buckets = []
+ for (let index = 0; index < normalizedRows.length; index += bucketSize) {
+ const bucketRows = normalizedRows.slice(index, index + bucketSize)
+ const first = bucketRows[0]
+ const last = bucketRows[bucketRows.length - 1]
+ buckets.push({
+ date: buildRiskTrendBucketLabel(first, last),
+ sourceStartDate: first?.date || '',
+ sourceEndDate: last?.date || '',
+ total: bucketRows.reduce((sum, item) => sum + item.total, 0),
+ highOrAbove: bucketRows.reduce((sum, item) => sum + item.highOrAbove, 0)
+ })
+ }
+ return buckets
+}
diff --git a/web/src/composables/requests/requestClaimMapper.js b/web/src/composables/requests/requestClaimMapper.js
new file mode 100644
index 0000000..8f7d1c0
--- /dev/null
+++ b/web/src/composables/requests/requestClaimMapper.js
@@ -0,0 +1,133 @@
+import {
+ DOCUMENT_TYPE_APPLICATION,
+ buildOccurredDisplay,
+ buildRiskMeta,
+ formatDateTime,
+ parseNumber,
+ parseOptionalAmount,
+ resolveApprovalMeta,
+ resolveDisplayName,
+ resolveDocumentTypeMeta,
+ resolveTypeLabel,
+ resolveWorkflowNode
+} from './requestShared.js'
+import { buildExpenseItems } from './requestExpenseItems.js'
+import { resolveRelatedApplicationInfo } from './requestRelatedApplication.js'
+import {
+ buildProgressSteps,
+ isApplicationArchivedWorkflow,
+ resolveApplicationLinkedReimbursementNo
+} from './requestProgressSteps.js'
+
+export function mapExpenseClaimToRequest(claim) {
+ const typeCode = String(claim?.expense_type || '').trim() || 'other'
+ const typeLabel = resolveTypeLabel(typeCode)
+ const documentTypeMeta = resolveDocumentTypeMeta(claim, typeCode)
+ const isApplicationDocument = documentTypeMeta.documentTypeCode === DOCUMENT_TYPE_APPLICATION
+ const approvalMeta = resolveApprovalMeta(claim?.status)
+ const workflowNode = resolveWorkflowNode(claim, approvalMeta, isApplicationDocument)
+ const applicationArchived = isApplicationDocument && isApplicationArchivedWorkflow(claim, workflowNode)
+ const applicationLinkedReimbursementNo = isApplicationDocument ? resolveApplicationLinkedReimbursementNo(claim) : ''
+ const applicationLinkStatusText = applicationLinkedReimbursementNo
+ ? `关联中 ${applicationLinkedReimbursementNo}`
+ : '未关联'
+ const invoiceCount = Math.max(0, parseNumber(claim?.invoice_count))
+ const riskMeta = buildRiskMeta(claim?.risk_flags_json)
+ const riskSummary = riskMeta.summary
+ const relatedApplication = isApplicationDocument ? null : resolveRelatedApplicationInfo(claim, typeLabel)
+ const expenseItems = buildExpenseItems(claim, riskMeta)
+ const visibleExpenseAmount = expenseItems.reduce((sum, item) => {
+ const amount = parseOptionalAmount(item.reimbursableAmount) ?? parseNumber(item.itemAmount)
+ return sum + amount
+ }, 0)
+ const amountValue = relatedApplication
+ ? expenseItems.length
+ ? visibleExpenseAmount
+ : invoiceCount === 0
+ ? 0
+ : parseNumber(claim?.amount)
+ : parseNumber(claim?.amount)
+ const applyDateTime = claim?.submitted_at || claim?.created_at
+ const employeeId = String(claim?.employee_id || claim?.employeeId || '').trim()
+ const employeeName = String(claim?.employee_name || claim?.employeeName || '').trim()
+
+ return {
+ id: String(claim?.claim_no || claim?.id || '').trim(),
+ claimNo: String(claim?.claim_no || claim?.id || '').trim(),
+ claimId: String(claim?.id || '').trim(),
+ status: String(claim?.status || '').trim(),
+ employeeId,
+ employee_id: employeeId,
+ profileEmployeeId: employeeId || employeeName,
+ person: String(claim?.employee_name || '').trim() || '待补充',
+ dept: String(claim?.department_name || '').trim() || '待补充',
+ departmentName: String(claim?.department_name || '').trim() || '待补充',
+ employeeName: String(claim?.employee_name || '').trim() || '待补充',
+ employeePosition: String(claim?.employee_position || '').trim(),
+ employeeGrade: String(claim?.employee_grade || '').trim(),
+ managerName: resolveDisplayName(claim?.manager_name),
+ financeApproverName: resolveDisplayName(claim?.finance_approver_name, claim?.financeApproverName),
+ financeOwnerName: resolveDisplayName(claim?.finance_owner_name, claim?.financeOwnerName),
+ budgetApproverName: resolveDisplayName(claim?.budget_approver_name, claim?.budgetApproverName),
+ budgetApproverGrade: String(claim?.budget_approver_grade || claim?.budgetApproverGrade || '').trim(),
+ budgetApproverRoleCode: String(claim?.budget_approver_role_code || claim?.budgetApproverRoleCode || '').trim(),
+ roleLabels: Array.isArray(claim?.role_labels) ? claim.role_labels.filter(Boolean) : [],
+ entity: '',
+ typeCode,
+ typeLabel,
+ ...documentTypeMeta,
+ detailVariant: typeCode === 'travel' || typeCode === 'travel_application' ? 'travel' : 'general',
+ title: String(claim?.reason || '').trim() || (isApplicationDocument ? typeLabel : `${typeLabel}报销`),
+ sceneLabel: typeLabel,
+ sceneTarget: String(claim?.location || '').trim() || '待补充',
+ location: String(claim?.location || '').trim() || '待补充',
+ relatedCustomer: '',
+ occurredDisplay: buildOccurredDisplay(claim),
+ occurredAt: claim?.occurred_at || '',
+ applyTime: formatDateTime(applyDateTime) || '待补充',
+ submittedAt: applyDateTime || '',
+ createdAt: claim?.created_at || '',
+ updatedAt: claim?.updated_at || '',
+ amount: amountValue,
+ riskFlags: Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : [],
+ riskTone: riskMeta.tone,
+ riskLabel: riskMeta.label,
+ invoiceCount,
+ workflowNode,
+ approvalKey: approvalMeta.key,
+ approvalStatus: approvalMeta.label,
+ approvalTone: approvalMeta.tone,
+ secondaryStatusLabel: isApplicationDocument ? '申请材料' : (typeCode === 'travel' ? '行程状态' : '票据状态'),
+ secondaryStatusValue: isApplicationDocument
+ ? approvalMeta.key === 'supplement'
+ ? '领导已退回,待重新提交'
+ : applicationArchived
+ ? '已归档'
+ : approvalMeta.key === 'completed'
+ ? applicationLinkStatusText
+ : '已进入审批流程'
+ : (invoiceCount > 0 ? `已关联 ${invoiceCount} 张票据` : '待上传票据'),
+ secondaryStatusTone: isApplicationDocument
+ ? approvalMeta.key === 'supplement'
+ ? 'warning'
+ : approvalMeta.key === 'completed' && !applicationArchived && !applicationLinkedReimbursementNo
+ ? 'warning'
+ : 'success'
+ : (invoiceCount > 0 ? 'success' : 'warning'),
+ riskSummary,
+ attachmentSummary: isApplicationDocument ? '申请单' : (invoiceCount > 0 ? `${invoiceCount} 张票据` : '无'),
+ expenseTableSummary: isApplicationDocument
+ ? '预计金额已随申请提交'
+ : expenseItems.length
+ ? (invoiceCount > 0
+ ? `共 ${expenseItems.length} 条费用明细,已关联 ${invoiceCount} 张票据`
+ : `共 ${expenseItems.length} 条费用明细,待补充票据`)
+ : '暂无费用明细',
+ note: String(claim?.reason || '').trim(),
+ relatedApplication,
+ progressSteps: buildProgressSteps(approvalMeta, workflowNode, claim, {
+ documentTypeCode: documentTypeMeta.documentTypeCode
+ }),
+ expenseItems
+ }
+}
diff --git a/web/src/composables/requests/requestExpenseItems.js b/web/src/composables/requests/requestExpenseItems.js
new file mode 100644
index 0000000..1a0fd39
--- /dev/null
+++ b/web/src/composables/requests/requestExpenseItems.js
@@ -0,0 +1,306 @@
+import {
+ DOCUMENT_BACKED_EXPENSE_TYPES,
+ HOTEL_DESCRIPTION_EXPENSE_TYPES,
+ LONG_DISTANCE_TRAVEL_EXPENSE_TYPES,
+ LOCATION_REQUIRED_EXPENSE_TYPES,
+ ROUTE_DESCRIPTION_EXPENSE_TYPES,
+ STANDARD_ADJUSTMENT_RISK_SOURCE,
+ SYSTEM_GENERATED_EXPENSE_TYPES,
+ formatAmount,
+ formatDate,
+ formatDateTime,
+ normalizeText,
+ parseNumber,
+ parseOptionalAmount,
+ resolveTypeLabel
+} from './requestShared.js'
+import {
+ findRelatedApplicationEvent,
+ resolveRelatedApplicationInfo
+} from './requestRelatedApplication.js'
+
+function buildStandardAdjustmentMapFromClaim(claim = {}) {
+ const flags = Array.isArray(claim?.risk_flags_json)
+ ? claim.risk_flags_json
+ : Array.isArray(claim?.riskFlags)
+ ? claim.riskFlags
+ : []
+ const adjustmentMap = new Map()
+
+ flags.forEach((flag) => {
+ if (!flag || typeof flag !== 'object') {
+ return
+ }
+ if (String(flag.source || '').trim() !== STANDARD_ADJUSTMENT_RISK_SOURCE) {
+ return
+ }
+ const itemId = String(flag.item_id || flag.itemId || '').trim()
+ const reimbursableAmount = parseOptionalAmount(flag.reimbursable_amount ?? flag.reimbursableAmount)
+ if (!itemId || reimbursableAmount === null) {
+ return
+ }
+ adjustmentMap.set(itemId, {
+ originalAmount: parseOptionalAmount(flag.original_amount ?? flag.originalAmount),
+ reimbursableAmount,
+ employeeAbsorbedAmount: parseOptionalAmount(flag.employee_absorbed_amount ?? flag.employeeAbsorbedAmount) || 0,
+ message: String(flag.message || flag.summary || '').trim()
+ })
+ })
+
+ return adjustmentMap
+}
+
+function normalizeExpenseType(typeCode) {
+ return String(typeCode || '').trim() || 'other'
+}
+
+function isLocationRequiredExpenseType(typeCode) {
+ return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(typeCode))
+}
+
+function resolveLocationDisplay(location, typeCode) {
+ const normalized = String(location || '').trim()
+ if (normalized) {
+ return normalized
+ }
+
+ return isLocationRequiredExpenseType(typeCode) ? '待补充' : '非必填'
+}
+
+function resolveExpenseDescriptionDetail(itemType, itemLocation) {
+ const normalizedType = normalizeExpenseType(itemType)
+ if (ROUTE_DESCRIPTION_EXPENSE_TYPES.has(normalizedType)) {
+ return '起始地-目的地'
+ }
+ if (HOTEL_DESCRIPTION_EXPENSE_TYPES.has(normalizedType)) {
+ return '目的地酒店'
+ }
+ return resolveLocationDisplay(itemLocation, normalizedType)
+}
+
+function resolveExpenseItemViewId(item, index, claim) {
+ return String(item?.id || `${claim?.id || 'claim'}-item-${index}`)
+}
+
+function buildTravelTimeLabelMap(items, claim) {
+ const travelItems = items
+ .map((item, index) => {
+ const itemType = normalizeExpenseType(item?.item_type || claim?.expense_type)
+ return {
+ id: resolveExpenseItemViewId(item, index, claim),
+ index,
+ itemType,
+ itemDate: formatDate(item?.item_date),
+ isSystemGenerated: Boolean(item?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
+ }
+ })
+ .filter((item) => !item.isSystemGenerated && LONG_DISTANCE_TRAVEL_EXPENSE_TYPES.has(item.itemType))
+ .sort((left, right) => {
+ const dateCompare = String(left.itemDate || '').localeCompare(String(right.itemDate || ''))
+ return dateCompare || left.index - right.index
+ })
+
+ const labels = new Map()
+ travelItems.forEach((item, index) => {
+ if (index === 0) {
+ labels.set(item.id, '出发时间')
+ } else if (index === travelItems.length - 1) {
+ labels.set(item.id, '返回时间')
+ } else {
+ labels.set(item.id, '中转时间')
+ }
+ })
+ return labels
+}
+
+function resolveExpenseTimeLabel({ id, itemType, isSystemGenerated, claim, travelTimeLabelMap }) {
+ if (isSystemGenerated) {
+ return '系统自动计算'
+ }
+ if (travelTimeLabelMap?.has(id)) {
+ return travelTimeLabelMap.get(id)
+ }
+ if (itemType === 'ride_ticket') {
+ return '乘车时间'
+ }
+ if (itemType === 'hotel_ticket') {
+ return '住宿时间'
+ }
+ return claim?.expense_type === 'travel' ? '出行时间' : '业务发生时间'
+}
+
+function resolveAttachmentDisplayName(value) {
+ const normalized = String(value || '').trim()
+ if (!normalized) {
+ return ''
+ }
+
+ return normalized.split('/').filter(Boolean).pop() || normalized
+}
+
+function hasRelatedApplicationContext(claim) {
+ return Boolean(findRelatedApplicationEvent(claim))
+}
+
+function isDocumentBackedRawExpenseItem(item) {
+ const invoiceId = normalizeText(item?.invoice_id || item?.invoiceId)
+ if (invoiceId) {
+ return true
+ }
+
+ return DOCUMENT_BACKED_EXPENSE_TYPES.has(normalizeExpenseType(item?.item_type || item?.itemType))
+}
+
+function extractTravelDayCount(value) {
+ const matched = normalizeText(value).replace(/\s+/g, '').match(/(\d{1,2})天/)
+ return matched ? parseNumber(matched[1]) : 0
+}
+
+function isStaleApplicationAllowanceRawItem(item, claim) {
+ const itemType = normalizeExpenseType(item?.item_type || item?.itemType)
+ if (itemType !== 'travel_allowance') {
+ return false
+ }
+
+ const related = resolveRelatedApplicationInfo(claim)
+ const applicationDays = extractTravelDayCount(related?.days)
+ const itemDays = extractTravelDayCount(item?.item_reason || item?.itemReason)
+ return applicationDays > 0 && itemDays > 0 && applicationDays !== itemDays
+}
+
+function isApplicationLinkPlaceholderRawItem(item, claim) {
+ const itemType = normalizeExpenseType(item?.item_type || item?.itemType)
+ if (SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType)) {
+ return true
+ }
+
+ const claimType = normalizeExpenseType(claim?.expense_type || claim?.expenseType)
+ if (itemType && claimType && itemType !== claimType) {
+ return false
+ }
+
+ const reason = normalizeText(item?.item_reason || item?.itemReason)
+ if (!reason || reason === '待补充') {
+ return true
+ }
+
+ const related = resolveRelatedApplicationInfo(claim)
+ const linkedReasons = new Set([
+ normalizeText(claim?.reason),
+ normalizeText(related?.reason)
+ ].filter(Boolean))
+ return linkedReasons.has(reason)
+}
+
+function filterVisibleExpenseRawItems(items, claim) {
+ const rawItems = Array.isArray(items) ? items : []
+ if (!rawItems.length || !hasRelatedApplicationContext(claim)) {
+ return rawItems
+ }
+
+ const hasRealExpenseItem = rawItems.some((item) => (
+ isDocumentBackedRawExpenseItem(item)
+ && !SYSTEM_GENERATED_EXPENSE_TYPES.has(normalizeExpenseType(item?.item_type || item?.itemType))
+ ))
+ if (!hasRealExpenseItem) {
+ return rawItems.filter((item) => !isApplicationLinkPlaceholderRawItem(item, claim))
+ }
+
+ return rawItems.filter((item) => {
+ const itemType = normalizeExpenseType(item?.item_type || item?.itemType)
+ if (SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType)) {
+ return !isStaleApplicationAllowanceRawItem(item, claim)
+ }
+ return !isApplicationLinkPlaceholderRawItem(item, claim)
+ })
+}
+
+function buildExpenseItems(claim, riskMeta) {
+ if (!Array.isArray(claim?.items)) {
+ return []
+ }
+
+ const normalizedRiskMeta = typeof riskMeta === 'string'
+ ? { summary: riskMeta, tone: riskMeta === '无' ? 'low' : 'medium', label: riskMeta === '无' ? '无' : '待关注' }
+ : {
+ summary: String(riskMeta?.summary || '无').trim() || '无',
+ tone: String(riskMeta?.tone || 'low').trim() || 'low',
+ label: String(riskMeta?.label || '').trim() || (String(riskMeta?.summary || '').trim() === '无' ? '无' : '待关注')
+ }
+
+ const visibleItems = filterVisibleExpenseRawItems(claim.items, claim)
+ const sortedItems = [...visibleItems].sort((left, right) => {
+ const leftType = normalizeExpenseType(left?.item_type)
+ const rightType = normalizeExpenseType(right?.item_type)
+ return Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(leftType)) - Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(rightType))
+ })
+ const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, claim)
+ const standardAdjustmentMap = buildStandardAdjustmentMapFromClaim(claim)
+
+ return sortedItems.map((item, index) => {
+ const invoiceId = String(item?.invoice_id || '').trim()
+ const attachmentName = resolveAttachmentDisplayName(invoiceId)
+ const attachments = invoiceId ? [attachmentName || invoiceId] : []
+ const itemType = normalizeExpenseType(item?.item_type || claim?.expense_type)
+ const isSystemGenerated = Boolean(item?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
+ const id = resolveExpenseItemViewId(item, index, claim)
+ const itemTypeLabel = resolveTypeLabel(itemType)
+ const itemLocation = String(item?.item_location || '').trim()
+ const itemReason = String(item?.item_reason || '').trim()
+ const itemNote = String(item?.item_note || item?.itemNote || '').trim()
+ const itemAmount = parseNumber(item?.item_amount)
+ const itemAmountDisplay = itemAmount > 0 ? formatAmount(itemAmount) : '待补充'
+ const standardAdjustment = standardAdjustmentMap.get(id) || null
+ const originalItemAmount = standardAdjustment?.originalAmount ?? itemAmount
+ const reimbursableAmount = standardAdjustment?.reimbursableAmount ?? itemAmount
+ const employeeAbsorbedAmount = standardAdjustment?.employeeAbsorbedAmount || Math.max(originalItemAmount - reimbursableAmount, 0)
+
+ return {
+ id,
+ time: formatDate(item?.item_date) || '待补充',
+ itemDate: formatDate(item?.item_date) || '',
+ filledAt: formatDateTime(item?.created_at) || '待同步',
+ itemType,
+ itemReason,
+ itemLocation,
+ itemNote,
+ itemAmount,
+ originalItemAmount,
+ originalAmountDisplay: originalItemAmount > 0 ? formatAmount(originalItemAmount) : itemAmountDisplay,
+ reimbursableAmount,
+ reimbursableAmountDisplay: reimbursableAmount > 0 ? formatAmount(reimbursableAmount) : '待补充',
+ employeeAbsorbedAmount,
+ employeeAbsorbedAmountDisplay: employeeAbsorbedAmount > 0 ? formatAmount(employeeAbsorbedAmount) : '',
+ hasStandardAdjustment: reimbursableAmount >= 0 && reimbursableAmount < originalItemAmount,
+ standardAdjustmentAccepted: Boolean(standardAdjustment),
+ standardAdjustmentMessage: standardAdjustment?.message || '',
+ invoiceId,
+ isSystemGenerated,
+ dayLabel: resolveExpenseTimeLabel({
+ id,
+ itemType,
+ isSystemGenerated,
+ claim,
+ travelTimeLabelMap
+ }),
+ name: itemTypeLabel,
+ category: itemTypeLabel,
+ desc: itemReason || '待补充',
+ detail: resolveExpenseDescriptionDetail(itemType, itemLocation),
+ amount: itemAmountDisplay,
+ status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充',
+ tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad',
+ attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
+ attachmentHint: isSystemGenerated ? '根据出差天数与职级自动测算' : attachments.length ? attachments[0] : '仅支持上传 1 张 JPG、PNG、PDF 单据',
+ attachmentTone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'missing',
+ attachments,
+ riskLabel: normalizedRiskMeta.summary === '无' ? '无' : normalizedRiskMeta.label,
+ riskText: normalizedRiskMeta.summary === '无' ? '' : normalizedRiskMeta.summary,
+ riskTone: normalizedRiskMeta.summary === '无' ? 'low' : normalizedRiskMeta.tone
+ }
+ })
+}
+
+export {
+ buildExpenseItems
+}
diff --git a/web/src/composables/requests/requestProgressSteps.js b/web/src/composables/requests/requestProgressSteps.js
new file mode 100644
index 0000000..077a0bf
--- /dev/null
+++ b/web/src/composables/requests/requestProgressSteps.js
@@ -0,0 +1,704 @@
+import {
+ APPLICATION_ARCHIVE_STAGE_LABEL,
+ APPLICATION_LINK_STATUS_STEP_LABEL,
+ APPLICATION_PROGRESS_LABELS,
+ APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET,
+ ARCHIVED_STEP_LABEL,
+ DOCUMENT_TYPE_APPLICATION,
+ RELATED_APPLICATION_STEP_LABEL,
+ REIMBURSEMENT_PROGRESS_LABELS,
+ formatDateTime,
+ formatDurationFrom,
+ getLatestEvent,
+ getRiskFlags,
+ normalizeText,
+ parseNumber,
+ resolveDisplayName
+} from './requestShared.js'
+import { resolveRelatedApplicationInfo } from './requestRelatedApplication.js'
+
+function resolveProgressCurrentIndex(approvalMeta, workflowNode) {
+ const normalizedNode = String(workflowNode || '').trim()
+
+ if (approvalMeta.key === 'completed') {
+ return 6
+ }
+
+ if (approvalMeta.key === 'pending_payment') {
+ return 4
+ }
+
+ if (normalizedNode.includes('已付款')) {
+ return 5
+ }
+ if (normalizedNode.includes('待付款')) {
+ return 4
+ }
+ if (normalizedNode.includes('归档') || normalizedNode.includes('入账')) {
+ return 6
+ }
+ if (normalizedNode.includes('财务')) {
+ return 3
+ }
+ if (
+ normalizedNode.includes('直属领导')
+ || normalizedNode.includes('领导审批')
+ || normalizedNode.includes('部门负责人')
+ || normalizedNode.includes('负责人审批')
+ ) {
+ return 2
+ }
+ if (normalizedNode.includes('AI预审') || normalizedNode.includes('AI验审') || normalizedNode.includes('审批流转')) {
+ return approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' ? 1 : 2
+ }
+ if (normalizedNode.includes('待提交')) {
+ return 1
+ }
+
+ if (approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' || approvalMeta.key === 'rejected') {
+ return 1
+ }
+
+ return 2
+}
+
+function resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode) {
+ const normalizedNode = String(workflowNode || '').trim()
+
+ if (approvalMeta.key === 'completed') {
+ return normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) ? 3 : 2
+ }
+
+ if (normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) || normalizedNode.includes(ARCHIVED_STEP_LABEL)) {
+ return 3
+ }
+ if (normalizedNode.includes(APPLICATION_LINK_STATUS_STEP_LABEL)) {
+ return 2
+ }
+ if (normalizedNode.includes('审批完成') || normalizedNode.includes('申请完成')) {
+ return 2
+ }
+ if (normalizedNode.includes('预算')) {
+ return 2
+ }
+ if (
+ normalizedNode.includes('直属领导')
+ || normalizedNode.includes('领导审批')
+ || normalizedNode.includes('部门负责人')
+ || normalizedNode.includes('负责人审批')
+ ) {
+ return 1
+ }
+ if (approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' || approvalMeta.key === 'rejected') {
+ return 0
+ }
+
+ return 1
+}
+
+function isApplicationArchivedWorkflow(claim, workflowNode) {
+ const normalizedNode = normalizeText(workflowNode || claim?.approval_stage || claim?.workflowNode)
+ if (normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) || normalizedNode.includes(ARCHIVED_STEP_LABEL)) {
+ return true
+ }
+ return getRiskFlags(claim).some((flag) => (
+ flag
+ && typeof flag === 'object'
+ && normalizeText(flag.source) === 'application_archive_sync'
+ ))
+}
+
+function resolveApplicationLinkedReimbursementNo(claim) {
+ for (const flag of [...getRiskFlags(claim)].reverse()) {
+ if (!flag || typeof flag !== 'object') {
+ continue
+ }
+ const generatedNo = normalizeText(
+ flag.generated_draft_claim_no
+ || flag.generatedDraftClaimNo
+ || flag.reimbursement_claim_no
+ || flag.reimbursementClaimNo
+ )
+ if (generatedNo) {
+ return generatedNo
+ }
+ }
+ return ''
+}
+
+function buildApplicationLinkStatusStepMeta(claim) {
+ const reimbursementNo = resolveApplicationLinkedReimbursementNo(claim)
+ const updatedAt = formatDateTime(claim?.updated_at)
+ return reimbursementNo
+ ? buildProgressStepMeta(`关联中 ${reimbursementNo}`, updatedAt)
+ : buildProgressStepMeta('未关联', updatedAt)
+}
+
+
+function resolveApplicationApproverName(claim) {
+ return resolveDisplayName(
+ claim?.manager_name,
+ claim?.managerName,
+ claim?.profile_manager,
+ claim?.profileManager,
+ claim?.direct_manager_name,
+ claim?.directManagerName
+ ) || '直属领导'
+}
+
+function resolveReimbursementApproverName(claim, label) {
+ const stepLabel = normalizeText(label)
+ if (stepLabel === '直属领导审批') {
+ return resolveDisplayName(
+ claim?.manager_name,
+ claim?.managerName,
+ claim?.profile_manager,
+ claim?.profileManager,
+ claim?.direct_manager_name,
+ claim?.directManagerName
+ ) || '直属领导'
+ }
+
+ if (stepLabel === '财务审批') {
+ const routeEvent = findReimbursementFinanceRouteEvent(claim)
+ return resolveDisplayName(
+ claim?.finance_approver_name,
+ claim?.financeApproverName,
+ routeEvent?.next_approver_name,
+ routeEvent?.nextApproverName,
+ routeEvent?.finance_approver_name,
+ routeEvent?.financeApproverName,
+ claim?.finance_owner_name,
+ claim?.financeOwnerName
+ ) || '财务'
+ }
+
+ return stepLabel.replace(/审批$/, '') || '审批人'
+}
+
+function resolveApplicationBudgetApproverName(claim) {
+ const routeEvent = findApprovalEventForStep(claim, '直属领导审批')
+ return resolveDisplayName(
+ claim?.budget_approver_name,
+ claim?.budgetApproverName,
+ routeEvent?.next_approver_name,
+ routeEvent?.nextApproverName,
+ routeEvent?.budget_approver_name,
+ routeEvent?.budgetApproverName
+ ) || '预算管理者'
+}
+
+function resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta) {
+ const normalizedLabel = normalizeText(label)
+ const workflowNode = normalizeText(claim?.approval_stage || claim?.workflowNode)
+ if (
+ documentTypeCode !== DOCUMENT_TYPE_APPLICATION
+ && approvalMeta.key !== 'completed'
+ && normalizedLabel === '直属领导审批'
+ && workflowNode.includes(normalizedLabel.replace(/审批$/, ''))
+ ) {
+ return '等待批复'
+ }
+
+ if (
+ documentTypeCode !== DOCUMENT_TYPE_APPLICATION
+ && approvalMeta.key !== 'completed'
+ && normalizedLabel === '财务审批'
+ && workflowNode.includes(normalizedLabel.replace(/审批$/, ''))
+ ) {
+ return `等待 ${resolveReimbursementApproverName(claim, normalizedLabel)} 批复`
+ }
+
+ if (
+ documentTypeCode === DOCUMENT_TYPE_APPLICATION
+ && approvalMeta.key !== 'completed'
+ && normalizedLabel === '直属领导审批'
+ && (
+ workflowNode.includes('直属领导')
+ || workflowNode.includes('领导审批')
+ || workflowNode.includes('部门负责人')
+ || workflowNode.includes('负责人审批')
+ )
+ ) {
+ return `等待 ${resolveApplicationApproverName(claim)} 批复`
+ }
+
+ if (
+ documentTypeCode === DOCUMENT_TYPE_APPLICATION
+ && approvalMeta.key !== 'completed'
+ && normalizedLabel === '预算管理者审批'
+ && workflowNode.includes('预算')
+ ) {
+ return `等待 ${resolveApplicationBudgetApproverName(claim)} 批复`
+ }
+
+ return label
+}
+
+
+function findApprovalEventForStep(claim, label) {
+ const stepLabel = normalizeText(label)
+ const events = getRiskFlags(claim).filter((flag) => {
+ if (!flag || typeof flag !== 'object') {
+ return false
+ }
+
+ const source = normalizeText(flag.source)
+ if (!['manual_approval', 'budget_approval', 'finance_approval'].includes(source)) {
+ return false
+ }
+
+ const previousStage = normalizeText(flag.previous_approval_stage || flag.previousApprovalStage)
+ const nextStage = normalizeText(flag.next_approval_stage || flag.nextApprovalStage)
+
+ if (stepLabel === '直属领导审批') {
+ return (
+ previousStage.includes('直属领导')
+ || previousStage.includes('领导审批')
+ || nextStage.includes('预算')
+ || nextStage.includes('财务')
+ )
+ }
+
+ if (stepLabel === '预算管理者审批') {
+ return (
+ source === 'budget_approval'
+ || previousStage.includes('预算')
+ || nextStage.includes('审批完成')
+ )
+ }
+
+ if (stepLabel === '财务审批') {
+ return (
+ previousStage.includes('财务')
+ || nextStage.includes('待付款')
+ || nextStage.includes('归档')
+ || nextStage.includes('入账')
+ || nextStage.includes('完成')
+ )
+ }
+
+ return false
+ })
+
+ return getLatestEvent(events)
+}
+
+function findReimbursementFinanceRouteEvent(claim) {
+ return getLatestEvent(
+ getRiskFlags(claim).filter((flag) => {
+ if (!flag || typeof flag !== 'object') {
+ return false
+ }
+
+ const source = normalizeText(flag.source)
+ if (!['manual_approval', 'budget_approval'].includes(source)) {
+ return false
+ }
+
+ const nextStage = normalizeText(flag.next_approval_stage || flag.nextApprovalStage)
+ return nextStage.includes('财务')
+ })
+ )
+}
+
+function findLatestReturnEvent(claim) {
+ return getLatestEvent(
+ getRiskFlags(claim).filter((flag) => (
+ flag
+ && typeof flag === 'object'
+ && normalizeText(flag.source) === 'manual_return'
+ ))
+ )
+}
+
+function findLatestPaymentEvent(claim) {
+ return getLatestEvent(
+ getRiskFlags(claim).filter((flag) => (
+ flag
+ && typeof flag === 'object'
+ && (
+ normalizeText(flag.source) === 'payment'
+ || normalizeText(flag.event_type || flag.eventType) === 'expense_claim_payment_completed'
+ )
+ ))
+ )
+}
+
+
+function findLatestApplicationReturnEvent(claim) {
+ return getLatestEvent(
+ getRiskFlags(claim).filter((flag) => {
+ if (!flag || typeof flag !== 'object' || normalizeText(flag.source) !== 'manual_return') {
+ return false
+ }
+ const eventType = normalizeText(flag.event_type || flag.eventType)
+ const returnStage = normalizeText(flag.return_stage || flag.returnStage || flag.previous_approval_stage)
+ const stageKey = normalizeText(flag.return_stage_key || flag.returnStageKey)
+ return (
+ eventType === 'expense_application_return'
+ || stageKey === 'direct_manager'
+ || returnStage.includes('直属领导')
+ || returnStage.includes('领导审批')
+ )
+ })
+ )
+}
+
+function findMergedApplicationBudgetApprovalEvent(claim) {
+ return getLatestEvent(
+ getRiskFlags(claim).filter((flag) => {
+ if (!flag || typeof flag !== 'object') {
+ return false
+ }
+ const source = normalizeText(flag.source)
+ const eventType = normalizeText(flag.event_type || flag.eventType)
+ const previousStage = normalizeText(flag.previous_approval_stage || flag.previousApprovalStage)
+ const nextStage = normalizeText(flag.next_approval_stage || flag.nextApprovalStage)
+ const mergedFlag = Boolean(flag.budget_approval_merged || flag.budgetApprovalMerged)
+ return (
+ source === 'manual_approval'
+ && eventType === 'expense_application_approval'
+ && previousStage.includes('直属领导')
+ && (
+ nextStage.includes('审批完成')
+ || nextStage.includes(APPLICATION_LINK_STATUS_STEP_LABEL)
+ || nextStage.includes('申请完成')
+ )
+ && mergedFlag
+ )
+ })
+ )
+}
+
+function resolveBudgetRouteResult(flag, routeDecision = {}) {
+ if (routeDecision && typeof routeDecision === 'object') {
+ const routeBudgetResult = routeDecision.budget_result || routeDecision.budgetResult
+ if (routeBudgetResult && typeof routeBudgetResult === 'object') {
+ return routeBudgetResult
+ }
+ }
+
+ const flagBudgetResult = flag?.budget_result || flag?.budgetResult
+ return flagBudgetResult && typeof flagBudgetResult === 'object' ? flagBudgetResult : {}
+}
+
+function applicationBudgetRouteMeetsThreshold(flag, routeDecision = {}) {
+ const budgetResult = resolveBudgetRouteResult(flag, routeDecision)
+ const metrics = budgetResult.metrics && typeof budgetResult.metrics === 'object' ? budgetResult.metrics : {}
+ const overBudgetAmount = parseNumber(metrics.over_budget_amount ?? metrics.overBudgetAmount)
+ const afterUsageRate = parseNumber(metrics.after_usage_rate ?? metrics.afterUsageRate)
+ const claimAmountRatio = parseNumber(metrics.claim_amount_ratio ?? metrics.claimAmountRatio)
+
+ return overBudgetAmount > 0 || Math.max(afterUsageRate, claimAmountRatio) >= 90
+}
+
+function applicationRequiresBudgetReviewStep(claim, workflowNode) {
+ const node = normalizeText(workflowNode || claim?.approval_stage || claim?.workflowNode)
+ if (node.includes('预算')) {
+ return true
+ }
+
+ return getRiskFlags(claim).some((flag) => {
+ if (!flag || typeof flag !== 'object') {
+ return false
+ }
+
+ const source = normalizeText(flag.source)
+ const eventType = normalizeText(flag.event_type || flag.eventType)
+ const previousStage = normalizeText(flag.previous_approval_stage || flag.previousApprovalStage)
+ const routeDecision = flag.route_decision || flag.routeDecision || {}
+
+ if (source === 'approval_routing' && flag.requires_budget_review === true) {
+ return applicationBudgetRouteMeetsThreshold(flag, flag)
+ }
+ if (
+ routeDecision
+ && typeof routeDecision === 'object'
+ && routeDecision.requires_budget_review === true
+ ) {
+ return applicationBudgetRouteMeetsThreshold(flag, routeDecision)
+ }
+ return (
+ source === 'budget_approval'
+ || eventType === 'expense_application_budget_approval'
+ || previousStage.includes('预算')
+ )
+ })
+}
+
+function buildProgressStepMeta(time, detail = '', title = '') {
+ return {
+ time,
+ detail,
+ title: title || [time, detail].filter(Boolean).join(' ')
+ }
+}
+
+function buildCompletedStepMeta(claim, label) {
+ const stepLabel = normalizeText(label)
+ const employeeName = normalizeText(claim?.employee_name) || '申请人'
+
+ if (stepLabel === RELATED_APPLICATION_STEP_LABEL) {
+ const relatedApplication = resolveRelatedApplicationInfo(claim)
+ const createdAt = formatDateTime(claim?.created_at)
+ if (relatedApplication?.claimNo) {
+ return buildProgressStepMeta(`已关联 ${relatedApplication.claimNo}`, createdAt)
+ }
+ return buildProgressStepMeta('待核对关联单据', createdAt)
+ }
+
+ if (stepLabel === APPLICATION_LINK_STATUS_STEP_LABEL) {
+ return buildApplicationLinkStatusStepMeta(claim)
+ }
+
+ if (stepLabel === '创建单据' || stepLabel === '创建申请') {
+ const createdAt = formatDateTime(claim?.created_at)
+ return buildProgressStepMeta(stepLabel === '创建申请' ? `${employeeName}发起申请` : `${employeeName}创建`, createdAt)
+ }
+
+ if (stepLabel === '待提交') {
+ const submittedAt = formatDateTime(claim?.submitted_at)
+ return buildProgressStepMeta(`${employeeName}提交`, submittedAt)
+ }
+
+ if (stepLabel === '直属领导审批' || stepLabel === '预算管理者审批' || stepLabel === '财务审批') {
+ const approvalEvent = findApprovalEventForStep(claim, stepLabel)
+ if (approvalEvent) {
+ const operator = resolveDisplayName(
+ approvalEvent.operator,
+ approvalEvent.operator_name,
+ approvalEvent.operatorName,
+ stepLabel === '直属领导审批' ? claim?.manager_name : '',
+ stepLabel === '预算管理者审批' ? approvalEvent.next_approver_name : ''
+ ) || (stepLabel === '财务审批' ? '财务' : stepLabel === '预算管理者审批' ? '预算管理者' : '直属领导')
+ const approvedAt = formatDateTime(approvalEvent.created_at || approvalEvent.createdAt)
+ return buildProgressStepMeta(`${operator}通过`, approvedAt, `${operator}审批通过 ${approvedAt}`.trim())
+ }
+
+ if (stepLabel === '财务审批') {
+ const updatedAt = formatDateTime(claim?.updated_at)
+ return buildProgressStepMeta('财务通过', updatedAt, `财务审批通过 ${updatedAt}`.trim())
+ }
+
+ if (stepLabel === '直属领导审批') {
+ const returnEvent = findLatestApplicationReturnEvent(claim)
+ if (returnEvent) {
+ const handledAt = formatDateTime(returnEvent.created_at || returnEvent.createdAt)
+ return buildProgressStepMeta('已处理', handledAt, `直属领导已处理 ${handledAt}`.trim())
+ }
+ }
+ }
+
+ if (stepLabel === '退回') {
+ const returnEvent = findLatestApplicationReturnEvent(claim) || findLatestReturnEvent(claim)
+ if (returnEvent) {
+ const operator = resolveDisplayName(
+ returnEvent.operator,
+ returnEvent.operator_name,
+ returnEvent.operatorName,
+ claim?.manager_name
+ ) || '直属领导'
+ const returnedAt = formatDateTime(returnEvent.created_at || returnEvent.createdAt)
+ return buildProgressStepMeta(`${operator}退回`, returnedAt, `${operator}退回 ${returnedAt}`.trim())
+ }
+ }
+
+ if (stepLabel === '待付款') {
+ const approvalEvent = findApprovalEventForStep(claim, '财务审批')
+ const pendingAt = formatDateTime(approvalEvent?.created_at || approvalEvent?.createdAt || claim?.updated_at)
+ return buildProgressStepMeta('待付款', pendingAt)
+ }
+
+ if (stepLabel === '已付款') {
+ const paymentEvent = findLatestPaymentEvent(claim)
+ const paidAt = formatDateTime(paymentEvent?.created_at || paymentEvent?.createdAt || claim?.updated_at)
+ return buildProgressStepMeta('已付款', paidAt)
+ }
+
+ if (stepLabel === '归档入账') {
+ const archivedAt = formatDateTime(claim?.updated_at)
+ return buildProgressStepMeta('归档入账', archivedAt)
+ }
+
+ if (stepLabel === ARCHIVED_STEP_LABEL) {
+ const archivedAt = formatDateTime(claim?.updated_at)
+ return buildProgressStepMeta(ARCHIVED_STEP_LABEL, archivedAt)
+ }
+
+ if (stepLabel === '审批完成') {
+ const completedAt = formatDateTime(claim?.updated_at)
+ return buildProgressStepMeta('审批完成', completedAt)
+ }
+
+ return buildProgressStepMeta('已完成')
+}
+
+function resolveCurrentStepStartedAt(claim, label) {
+ const stepLabel = normalizeText(label)
+ if (stepLabel === RELATED_APPLICATION_STEP_LABEL || stepLabel === '创建单据' || stepLabel === '创建申请') {
+ return claim?.created_at
+ }
+ if (stepLabel === '待提交') {
+ const returnEvent = findLatestReturnEvent(claim)
+ return returnEvent?.created_at || returnEvent?.createdAt || claim?.updated_at || claim?.created_at
+ }
+ if (stepLabel === '直属领导审批') {
+ return claim?.submitted_at || claim?.updated_at || claim?.created_at
+ }
+ if (stepLabel === '预算管理者审批') {
+ const leaderApprovalEvent = findApprovalEventForStep(claim, '直属领导审批')
+ return leaderApprovalEvent?.created_at || leaderApprovalEvent?.createdAt || claim?.updated_at || claim?.submitted_at
+ }
+ if (stepLabel === '财务审批') {
+ const leaderApprovalEvent = findApprovalEventForStep(claim, '直属领导审批')
+ return leaderApprovalEvent?.created_at || leaderApprovalEvent?.createdAt || claim?.updated_at || claim?.submitted_at
+ }
+ if (stepLabel === '待付款') {
+ const approvalEvent = findApprovalEventForStep(claim, '财务审批')
+ return approvalEvent?.created_at || approvalEvent?.createdAt || claim?.updated_at || claim?.submitted_at
+ }
+ if (stepLabel === '已付款') {
+ const paymentEvent = findLatestPaymentEvent(claim)
+ return paymentEvent?.created_at || paymentEvent?.createdAt || claim?.updated_at || claim?.submitted_at
+ }
+ if (stepLabel === '归档入账' || stepLabel === ARCHIVED_STEP_LABEL || stepLabel === '审批完成') {
+ return claim?.updated_at || claim?.submitted_at
+ }
+ return ''
+}
+
+function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}) {
+ const documentTypeCode = String(options.documentTypeCode || '').trim()
+ const hasApplicationReturnStep = (
+ documentTypeCode === DOCUMENT_TYPE_APPLICATION
+ && Boolean(findLatestApplicationReturnEvent(claim))
+ && approvalMeta.key === 'supplement'
+ )
+ const hasMergedApplicationBudgetApproval = (
+ documentTypeCode === DOCUMENT_TYPE_APPLICATION
+ && Boolean(findMergedApplicationBudgetApprovalEvent(claim))
+ )
+ const shouldShowApplicationBudgetStep = (
+ documentTypeCode === DOCUMENT_TYPE_APPLICATION
+ && !hasMergedApplicationBudgetApproval
+ && applicationRequiresBudgetReviewStep(claim, workflowNode)
+ )
+ const isApplicationDocument = documentTypeCode === DOCUMENT_TYPE_APPLICATION
+ const applicationArchived = isApplicationDocument && isApplicationArchivedWorkflow(claim, workflowNode)
+ const progressLabels =
+ isApplicationDocument
+ ? hasApplicationReturnStep
+ ? ['创建申请', '直属领导审批', '退回', '待提交']
+ : hasMergedApplicationBudgetApproval
+ ? ['创建申请', '直属领导审批', APPLICATION_LINK_STATUS_STEP_LABEL, ARCHIVED_STEP_LABEL]
+ : shouldShowApplicationBudgetStep
+ ? APPLICATION_PROGRESS_LABELS
+ : APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET
+ : REIMBURSEMENT_PROGRESS_LABELS
+ const applicationLinkIndex = progressLabels.indexOf(APPLICATION_LINK_STATUS_STEP_LABEL)
+ const applicationArchiveIndex = progressLabels.indexOf(ARCHIVED_STEP_LABEL)
+ const currentIndex =
+ isApplicationDocument
+ ? hasApplicationReturnStep
+ ? 3
+ : applicationArchived && applicationArchiveIndex >= 0
+ ? applicationArchiveIndex
+ : approvalMeta.key === 'completed' && applicationLinkIndex >= 0
+ ? applicationLinkIndex
+ : Math.min(
+ resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode),
+ Math.max(0, progressLabels.length - 1)
+ )
+ : resolveProgressCurrentIndex(approvalMeta, workflowNode)
+ const currentTime =
+ approvalMeta.key === 'completed'
+ ? '已完成'
+ : approvalMeta.key === 'pending_payment'
+ ? '待付款'
+ : approvalMeta.key === 'supplement'
+ ? '待补充'
+ : approvalMeta.key === 'rejected'
+ ? '已退回'
+ : '进行中'
+
+ return progressLabels.map((label, index) => {
+ const displayLabel = resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta)
+ if (approvalMeta.key === 'completed' && (!isApplicationDocument || applicationArchived)) {
+ const stepMeta = buildCompletedStepMeta(claim, label)
+ return {
+ index: index + 1,
+ label: displayLabel,
+ rawLabel: label,
+ time: stepMeta.time,
+ detail: stepMeta.detail,
+ title: stepMeta.title,
+ done: true,
+ active: true,
+ current: false
+ }
+ }
+
+ if (index < currentIndex) {
+ const stepMeta = buildCompletedStepMeta(claim, label)
+ return {
+ index: index + 1,
+ label: displayLabel,
+ rawLabel: label,
+ time: stepMeta.time,
+ detail: stepMeta.detail,
+ title: stepMeta.title,
+ done: true,
+ active: true,
+ current: false
+ }
+ }
+
+ if (index === currentIndex) {
+ if (isApplicationDocument && label === APPLICATION_LINK_STATUS_STEP_LABEL) {
+ const stepMeta = buildApplicationLinkStatusStepMeta(claim)
+ return {
+ index: index + 1,
+ label: displayLabel,
+ rawLabel: label,
+ time: stepMeta.time,
+ detail: stepMeta.detail,
+ title: stepMeta.title,
+ done: false,
+ active: true,
+ current: true
+ }
+ }
+ const stayDuration = formatDurationFrom(resolveCurrentStepStartedAt(claim, label))
+ return {
+ index: index + 1,
+ label: displayLabel,
+ rawLabel: label,
+ time: stayDuration ? `停留 ${stayDuration}` : currentTime,
+ detail: '',
+ title: stayDuration ? `当前${displayLabel}已停留 ${stayDuration}` : currentTime,
+ done: false,
+ active: true,
+ current: true
+ }
+ }
+
+ return {
+ index: index + 1,
+ label: displayLabel,
+ rawLabel: label,
+ time: '待处理',
+ detail: '',
+ title: '待处理',
+ done: false,
+ active: false,
+ current: false
+ }
+ })
+}
+
+export {
+ buildProgressSteps,
+ isApplicationArchivedWorkflow,
+ resolveApplicationLinkedReimbursementNo
+}
diff --git a/web/src/composables/requests/requestRelatedApplication.js b/web/src/composables/requests/requestRelatedApplication.js
new file mode 100644
index 0000000..40f9518
--- /dev/null
+++ b/web/src/composables/requests/requestRelatedApplication.js
@@ -0,0 +1,227 @@
+import {
+ formatAmount,
+ formatDate,
+ getLatestEvent,
+ getRiskFlags,
+ normalizeText,
+ parseNumber
+} from './requestShared.js'
+
+function normalizeApplicationHandoffDetail(flag = {}) {
+ const detail = flag?.application_detail || flag?.applicationDetail || {}
+ const reviewValues = flag?.review_form_values || flag?.reviewFormValues || {}
+ const sceneSelection = flag?.expense_scene_selection || flag?.expenseSceneSelection || {}
+ return [sceneSelection, reviewValues, detail]
+ .filter((item) => item && typeof item === 'object')
+ .reduce((acc, item) => ({ ...acc, ...item }), {})
+}
+
+function resolveApplicationField(flag = {}, detail = {}, snakeKey, camelKey = '') {
+ return normalizeText(
+ flag?.[snakeKey]
+ || (camelKey ? flag?.[camelKey] : '')
+ || detail?.[snakeKey]
+ || (camelKey ? detail?.[camelKey] : '')
+ )
+}
+
+function resolveApplicationValue(flag = {}, detail = {}, keys = []) {
+ for (const key of keys) {
+ const detailValue = normalizeText(detail?.[key])
+ if (detailValue) {
+ return detailValue
+ }
+ const flagValue = normalizeText(flag?.[key])
+ if (flagValue) {
+ return flagValue
+ }
+ }
+
+ return ''
+}
+
+function extractDateRange(value) {
+ const dates = normalizeText(value).match(/\d{4}-\d{2}-\d{2}/g) || []
+ if (!dates.length) {
+ return { startDate: '', endDate: '' }
+ }
+
+ return {
+ startDate: dates[0],
+ endDate: dates[dates.length - 1]
+ }
+}
+
+function resolveRelatedApplicationClaimNo(flag = {}) {
+ const detail = normalizeApplicationHandoffDetail(flag)
+ return resolveApplicationField(flag, detail, 'application_claim_no', 'applicationClaimNo')
+}
+
+function findRelatedApplicationEvent(claim) {
+ const events = getRiskFlags(claim).filter((flag) => (
+ flag
+ && typeof flag === 'object'
+ && resolveRelatedApplicationClaimNo(flag)
+ ))
+ return getLatestEvent(events) || events[events.length - 1] || null
+}
+
+function resolveRelatedApplicationAmountLabel(flag, detail, claim) {
+ const explicitLabel = normalizeText(
+ flag?.application_amount_label
+ || flag?.applicationAmountLabel
+ || detail?.application_amount_label
+ || detail?.applicationAmountLabel
+ )
+ if (explicitLabel) return explicitLabel
+
+ const rawAmount = normalizeText(
+ flag?.application_amount
+ || flag?.applicationAmount
+ || flag?.application_budget_amount
+ || flag?.applicationBudgetAmount
+ || detail?.application_amount
+ || detail?.applicationAmount
+ || detail?.amount
+ || claim?.amount
+ )
+ const amountValue = parseNumber(rawAmount)
+ return amountValue > 0 ? formatAmount(amountValue) : rawAmount
+}
+
+function resolveRelatedApplicationInfo(claim, typeLabel = '') {
+ const relatedEvent = findRelatedApplicationEvent(claim)
+ if (!relatedEvent) {
+ return null
+ }
+
+ const detail = normalizeApplicationHandoffDetail(relatedEvent)
+ const claimNo = resolveRelatedApplicationClaimNo(relatedEvent)
+ const applicationType = normalizeText(
+ detail.application_type
+ || detail.applicationType
+ || relatedEvent.application_type
+ || relatedEvent.applicationType
+ || typeLabel
+ )
+ const location = normalizeText(
+ detail.application_location
+ || detail.applicationLocation
+ || detail.location
+ || relatedEvent.application_location
+ || relatedEvent.applicationLocation
+ || claim?.location
+ )
+ const reason = normalizeText(
+ detail.application_reason
+ || detail.applicationReason
+ || detail.reason
+ || relatedEvent.application_reason
+ || relatedEvent.applicationReason
+ || claim?.reason
+ )
+ const content = normalizeText(
+ detail.application_content
+ || detail.applicationContent
+ || relatedEvent.application_content
+ || relatedEvent.applicationContent
+ ) || [applicationType, location].filter(Boolean).join(' / ')
+ const rawTime = normalizeText(
+ detail.application_time
+ || detail.applicationTime
+ || detail.application_business_time
+ || detail.applicationBusinessTime
+ || detail.business_time
+ || detail.businessTime
+ || detail.time_range
+ || detail.timeRange
+ || detail.time
+ || detail.application_date
+ || detail.applicationDate
+ || relatedEvent.application_time
+ || relatedEvent.applicationTime
+ || relatedEvent.application_business_time
+ || relatedEvent.applicationBusinessTime
+ || relatedEvent.business_time
+ || relatedEvent.businessTime
+ || relatedEvent.time_range
+ || relatedEvent.timeRange
+ || relatedEvent.application_date
+ || relatedEvent.applicationDate
+ || claim?.occurred_at
+ )
+ const displayTime = formatDate(rawTime) || rawTime
+ const dateRange = extractDateRange(rawTime || displayTime)
+ const ruleName = resolveApplicationValue(relatedEvent, detail, [
+ 'application_rule_name',
+ 'applicationRuleName',
+ 'rule_name',
+ 'ruleName'
+ ])
+ const ruleVersion = resolveApplicationValue(relatedEvent, detail, [
+ 'application_rule_version',
+ 'applicationRuleVersion',
+ 'rule_version',
+ 'ruleVersion'
+ ])
+
+ return {
+ id: resolveApplicationField(relatedEvent, detail, 'application_claim_id', 'applicationClaimId'),
+ claimNo,
+ content,
+ reason,
+ days: normalizeText(
+ detail.application_days
+ || detail.applicationDays
+ || detail.days
+ || relatedEvent.application_days
+ || relatedEvent.applicationDays
+ ),
+ location,
+ time: displayTime,
+ tripStartDate: dateRange.startDate,
+ tripEndDate: dateRange.endDate,
+ amountLabel: resolveRelatedApplicationAmountLabel(relatedEvent, detail, claim),
+ statusLabel: resolveApplicationField(relatedEvent, detail, 'application_status_label', 'applicationStatusLabel'),
+ transportMode: normalizeText(
+ detail.application_transport_mode
+ || detail.applicationTransportMode
+ || detail.transport_mode
+ || relatedEvent.application_transport_mode
+ || relatedEvent.applicationTransportMode
+ ),
+ lodgingDailyCap: resolveApplicationValue(relatedEvent, detail, [
+ 'application_lodging_daily_cap',
+ 'applicationLodgingDailyCap',
+ 'lodging_daily_cap',
+ 'lodgingDailyCap'
+ ]),
+ subsidyDailyCap: resolveApplicationValue(relatedEvent, detail, [
+ 'application_subsidy_daily_cap',
+ 'applicationSubsidyDailyCap',
+ 'subsidy_daily_cap',
+ 'subsidyDailyCap'
+ ]),
+ transportPolicy: resolveApplicationValue(relatedEvent, detail, [
+ 'application_transport_policy',
+ 'applicationTransportPolicy',
+ 'transport_policy',
+ 'transportPolicy'
+ ]),
+ policyEstimate: resolveApplicationValue(relatedEvent, detail, [
+ 'application_policy_estimate',
+ 'applicationPolicyEstimate',
+ 'policy_estimate',
+ 'policyEstimate'
+ ]),
+ ruleName,
+ ruleVersion,
+ ruleLabel: [ruleName, ruleVersion].filter(Boolean).join(' / ')
+ }
+}
+
+
+export {
+ findRelatedApplicationEvent,
+ resolveRelatedApplicationInfo
+}
diff --git a/web/src/composables/requests/requestShared.js b/web/src/composables/requests/requestShared.js
new file mode 100644
index 0000000..8ff01ba
--- /dev/null
+++ b/web/src/composables/requests/requestShared.js
@@ -0,0 +1,426 @@
+import { isApplicationDocumentNo } from '../../utils/documentClassification.js'
+import { filterActionableRiskFlags, normalizeRiskFlagTone } from '../../utils/riskFlags.js'
+
+const EXPENSE_TYPE_LABELS = {
+ travel: '差旅费',
+ travel_application: '差旅费用申请',
+ expense_application: '费用申请',
+ purchase_application: '采购费用申请',
+ meeting_application: '会务费用申请',
+ train_ticket: '火车票',
+ flight_ticket: '机票',
+ ship_ticket: '轮船票',
+ ferry_ticket: '轮船票',
+ hotel_ticket: '住宿票',
+ ride_ticket: '乘车',
+ travel_allowance: '出差补贴',
+ entertainment: '业务招待费',
+ marketing: '市场推广费',
+ office: '办公用品费',
+ meeting: '会务费',
+ training: '培训费',
+ software: '软件服务费',
+ hotel: '住宿费',
+ transport: '交通费',
+ meal: '业务招待费',
+ other: '其他费用'
+}
+
+const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
+ 'travel',
+ 'train_ticket',
+ 'flight_ticket',
+ 'hotel_ticket',
+ 'ride_ticket',
+ 'meeting',
+ 'entertainment'
+])
+
+const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
+const STANDARD_ADJUSTMENT_RISK_SOURCE = 'reimbursement_standard_adjustment'
+const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
+const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
+const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
+const DOCUMENT_BACKED_EXPENSE_TYPES = new Set([
+ 'train_ticket',
+ 'flight_ticket',
+ 'ship_ticket',
+ 'ferry_ticket',
+ 'hotel_ticket',
+ 'ride_ticket'
+])
+const DOCUMENT_TYPE_APPLICATION = 'application'
+const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
+const RELATED_APPLICATION_STEP_LABEL = '关联单据'
+const APPLICATION_LINK_STATUS_STEP_LABEL = '关联单据状态'
+const APPLICATION_ARCHIVE_STAGE_LABEL = '申请归档'
+const ARCHIVED_STEP_LABEL = '已归档'
+
+const REIMBURSEMENT_PROGRESS_LABELS = [
+ RELATED_APPLICATION_STEP_LABEL,
+ '待提交',
+ '直属领导审批',
+ '财务审批',
+ '待付款',
+ '已付款',
+ ARCHIVED_STEP_LABEL
+]
+
+const APPLICATION_PROGRESS_LABELS = [
+ '创建申请',
+ '直属领导审批',
+ '预算管理者审批',
+ APPLICATION_LINK_STATUS_STEP_LABEL,
+ ARCHIVED_STEP_LABEL
+]
+
+const APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET = [
+ '创建申请',
+ '直属领导审批',
+ APPLICATION_LINK_STATUS_STEP_LABEL,
+ ARCHIVED_STEP_LABEL
+]
+
+function parseNumber(value) {
+ const nextValue = Number(value)
+ return Number.isFinite(nextValue) ? nextValue : 0
+}
+
+function parseOptionalAmount(value) {
+ if (value === null || value === undefined || String(value).trim() === '') {
+ return null
+ }
+ const amount = Number(value)
+ return Number.isFinite(amount) && amount >= 0 ? amount : null
+}
+
+
+function toDate(value) {
+ if (!value) {
+ return null
+ }
+
+ const nextDate = new Date(value)
+ return Number.isNaN(nextDate.getTime()) ? null : nextDate
+}
+
+function formatDate(value) {
+ const nextDate = toDate(value)
+ if (!nextDate) {
+ return ''
+ }
+
+ const year = nextDate.getFullYear()
+ const month = String(nextDate.getMonth() + 1).padStart(2, '0')
+ const day = String(nextDate.getDate()).padStart(2, '0')
+ return `${year}-${month}-${day}`
+}
+
+function formatDateTime(value) {
+ const nextDate = toDate(value)
+ if (!nextDate) {
+ return ''
+ }
+
+ const hours = String(nextDate.getHours()).padStart(2, '0')
+ const minutes = String(nextDate.getMinutes()).padStart(2, '0')
+ return `${formatDate(nextDate)} ${hours}:${minutes}`
+}
+
+function formatDurationFrom(value, now = Date.now()) {
+ const startAt = toDate(value)
+ if (!startAt) {
+ return ''
+ }
+
+ const diffMs = Math.max(0, Number(now) - startAt.getTime())
+ const totalMinutes = Math.floor(diffMs / (60 * 1000))
+ if (totalMinutes < 1) {
+ return '刚刚'
+ }
+
+ const days = Math.floor(totalMinutes / (24 * 60))
+ const hours = Math.floor((totalMinutes % (24 * 60)) / 60)
+ const minutes = totalMinutes % 60
+
+ if (days > 0) {
+ return hours > 0 ? `${days}天${hours}小时` : `${days}天`
+ }
+
+ if (hours > 0) {
+ return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时`
+ }
+
+ return `${minutes}分钟`
+}
+
+function formatAmount(value) {
+ return new Intl.NumberFormat('zh-CN', {
+ style: 'currency',
+ currency: 'CNY',
+ minimumFractionDigits: 0,
+ maximumFractionDigits: Number.isInteger(value) ? 0 : 2
+ }).format(parseNumber(value))
+}
+
+function resolveTypeLabel(typeCode) {
+ return EXPENSE_TYPE_LABELS[String(typeCode || '').trim()] || EXPENSE_TYPE_LABELS.other
+}
+
+function resolveDocumentTypeMeta(claim, typeCode) {
+ const explicitType = String(
+ claim?.document_type_code
+ || claim?.documentTypeCode
+ || claim?.document_type
+ || claim?.documentType
+ || ''
+ ).trim()
+ const claimNo = String(claim?.claim_no || claim?.claimNo || '').trim().toUpperCase()
+ const normalizedType = String(typeCode || '').trim()
+ const isApplication =
+ explicitType === DOCUMENT_TYPE_APPLICATION
+ || explicitType === 'expense_application'
+ || isApplicationDocumentNo(claimNo)
+ || normalizedType === 'application'
+ || normalizedType.endsWith('_application')
+
+ return isApplication
+ ? { documentTypeCode: DOCUMENT_TYPE_APPLICATION, documentTypeLabel: '申请单' }
+ : { documentTypeCode: DOCUMENT_TYPE_REIMBURSEMENT, documentTypeLabel: '报销单' }
+}
+
+function normalizeExpenseType(typeCode) {
+ return String(typeCode || '').trim() || 'other'
+}
+
+function resolveApprovalMeta(status) {
+ const normalized = String(status || '').trim().toLowerCase()
+
+ if (normalized === 'draft') {
+ return { key: 'draft', label: '草稿', tone: 'draft' }
+ }
+
+ if (normalized === 'returned') {
+ return { key: 'supplement', label: '待提交', tone: 'warning' }
+ }
+
+ if (normalized === 'supplement') {
+ return { key: 'supplement', label: '待补充', tone: 'warning' }
+ }
+
+ if (normalized === 'pending_payment') {
+ return { key: 'pending_payment', label: '待付款', tone: 'warning' }
+ }
+
+ if (normalized === 'paid') {
+ return { key: 'completed', label: '已付款', tone: 'success' }
+ }
+
+ if (['approved', 'completed', 'paid'].includes(normalized)) {
+ return { key: 'completed', label: '已完成', tone: 'success' }
+ }
+
+ if (['rejected', 'cancelled'].includes(normalized)) {
+ return { key: 'rejected', label: '已退回', tone: 'danger' }
+ }
+
+ return { key: 'in_progress', label: '审批中', tone: 'info' }
+}
+
+function resolveWorkflowNode(claim, approvalMeta, isApplicationDocument = false) {
+ if (String(claim?.status || '').trim().toLowerCase() === 'returned') {
+ return '待提交'
+ }
+
+ const rawNode = String(claim?.approval_stage || '').trim()
+
+ if (rawNode) {
+ if (
+ isApplicationDocument
+ && approvalMeta.key === 'completed'
+ && (
+ rawNode === '审批完成'
+ || rawNode.includes('审批完成')
+ || rawNode.includes('申请完成')
+ )
+ ) {
+ return APPLICATION_LINK_STATUS_STEP_LABEL
+ }
+ if (rawNode === '审批流转' || rawNode.includes('AI预审') || rawNode.includes('AI验审')) {
+ return approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' ? '待提交' : '直属领导审批'
+ }
+ if (rawNode === '待补充') {
+ return '待提交'
+ }
+ return rawNode
+ }
+
+ if (approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' || approvalMeta.key === 'rejected') {
+ return '待提交'
+ }
+
+ if (approvalMeta.key === 'pending_payment') {
+ return '待付款'
+ }
+
+ if (approvalMeta.key === 'completed') {
+ const normalizedStatus = String(claim?.status || '').trim().toLowerCase()
+ return isApplicationDocument ? APPLICATION_LINK_STATUS_STEP_LABEL : normalizedStatus === 'paid' ? '已付款' : '归档入账'
+ }
+
+ return '直属领导审批'
+}
+
+function stringifyRiskFlag(value) {
+ if (typeof value === 'string') {
+ return value.trim()
+ }
+
+ if (!value || typeof value !== 'object') {
+ return ''
+ }
+
+ for (const key of ['message', 'label', 'reason', 'name']) {
+ const nextValue = String(value[key] || '').trim()
+ if (nextValue) {
+ return nextValue
+ }
+ }
+
+ return ''
+}
+
+const RISK_TONE_LABELS = {
+ high: '高风险',
+ medium: '中风险',
+ low: '低风险'
+}
+
+function resolveHighestRiskTone(flags) {
+ const tones = flags.map((item) => normalizeRiskFlagTone(item)).filter(Boolean)
+ if (tones.includes('high')) {
+ return 'high'
+ }
+ if (tones.includes('medium')) {
+ return 'medium'
+ }
+ if (tones.includes('low')) {
+ return 'low'
+ }
+ return 'low'
+}
+
+function buildRiskMeta(riskFlags) {
+ if (!Array.isArray(riskFlags) || !riskFlags.length) {
+ return { summary: '无', tone: 'low', label: '无' }
+ }
+
+ const actionableFlags = filterActionableRiskFlags(riskFlags)
+ const items = actionableFlags.map((item) => stringifyRiskFlag(item)).filter(Boolean)
+ if (!items.length) {
+ return { summary: '无', tone: 'low', label: '无' }
+ }
+
+ const tone = resolveHighestRiskTone(actionableFlags)
+ return {
+ summary: items.join(';'),
+ tone,
+ label: RISK_TONE_LABELS[tone] || '待关注'
+ }
+}
+
+function buildRiskSummary(riskFlags) {
+ return buildRiskMeta(riskFlags).summary
+}
+
+function buildOccurredDisplay(claim) {
+ const itemDates = Array.isArray(claim?.items)
+ ? claim.items.map((item) => formatDate(item?.item_date)).filter(Boolean)
+ : []
+
+ if (!itemDates.length) {
+ return formatDate(claim?.occurred_at) || '待补充'
+ }
+
+ const sortedDates = [...new Set(itemDates)].sort()
+ if (sortedDates.length === 1) {
+ return sortedDates[0]
+ }
+
+ return `${sortedDates[0]} ~ ${sortedDates[sortedDates.length - 1]}`
+}
+
+
+function normalizeText(value) {
+ return String(value || '').trim()
+}
+
+function isEmailLike(value) {
+ return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(normalizeText(value))
+}
+
+function resolveDisplayName(...values) {
+ for (const value of values) {
+ const normalized = normalizeText(value)
+ if (normalized && !isEmailLike(normalized)) {
+ return normalized
+ }
+ }
+
+ return ''
+}
+
+
+function getRiskFlags(claim) {
+ return Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : []
+}
+
+function getLatestEvent(events) {
+ const sortedEvents = events
+ .filter((item) => item && typeof item === 'object')
+ .map((item) => ({ ...item, eventDate: toDate(item.created_at || item.createdAt) }))
+ .filter((item) => item.eventDate)
+ .sort((a, b) => a.eventDate.getTime() - b.eventDate.getTime())
+
+ return sortedEvents.length ? sortedEvents[sortedEvents.length - 1] : null
+}
+
+export {
+ APPLICATION_ARCHIVE_STAGE_LABEL,
+ APPLICATION_LINK_STATUS_STEP_LABEL,
+ APPLICATION_PROGRESS_LABELS,
+ APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET,
+ ARCHIVED_STEP_LABEL,
+ DOCUMENT_BACKED_EXPENSE_TYPES,
+ DOCUMENT_TYPE_APPLICATION,
+ DOCUMENT_TYPE_REIMBURSEMENT,
+ EXPENSE_TYPE_LABELS,
+ HOTEL_DESCRIPTION_EXPENSE_TYPES,
+ LOCATION_REQUIRED_EXPENSE_TYPES,
+ LONG_DISTANCE_TRAVEL_EXPENSE_TYPES,
+ RELATED_APPLICATION_STEP_LABEL,
+ REIMBURSEMENT_PROGRESS_LABELS,
+ ROUTE_DESCRIPTION_EXPENSE_TYPES,
+ STANDARD_ADJUSTMENT_RISK_SOURCE,
+ SYSTEM_GENERATED_EXPENSE_TYPES,
+ buildOccurredDisplay,
+ buildRiskMeta,
+ buildRiskSummary,
+ formatAmount,
+ formatDate,
+ formatDateTime,
+ formatDurationFrom,
+ getLatestEvent,
+ getRiskFlags,
+ isEmailLike,
+ normalizeExpenseType,
+ normalizeText,
+ parseNumber,
+ parseOptionalAmount,
+ resolveApprovalMeta,
+ resolveDisplayName,
+ resolveDocumentTypeMeta,
+ resolveTypeLabel,
+ resolveWorkflowNode,
+ toDate
+}
diff --git a/web/src/composables/useAppShell.js b/web/src/composables/useAppShell.js
index 3396c5d..5a09a20 100644
--- a/web/src/composables/useAppShell.js
+++ b/web/src/composables/useAppShell.js
@@ -29,6 +29,12 @@ import { createCurrentYearDateRange } from '../utils/dateRangeDefaults.js'
const SESSION_TYPE_EXPENSE = 'expense'
const SMART_ENTRY_SOURCE_APPLICATION = 'application'
const SMART_ENTRY_SOURCE_REIMBURSEMENT = 'topbar'
+const DOCUMENT_DETAIL_RETURN_TARGETS = new Set(['workbench', 'conversation'])
+
+function resolveDocumentDetailReturnTarget(value) {
+ const target = String(value || '').trim()
+ return DOCUMENT_DETAIL_RETURN_TARGETS.has(target) ? target : ''
+}
export function useAppShell() {
const route = useRoute()
@@ -99,10 +105,13 @@ export function useAppShell() {
})
const detailMode = computed(() => route.name === 'app-document-detail')
- const detailReturnTarget = computed(() => String(route.query.returnTo || '').trim())
- const detailBackLabel = computed(() => (
- detailReturnTarget.value === 'workbench' ? '返回首页' : '返回单据中心'
- ))
+ const detailReturnTarget = computed(() => resolveDocumentDetailReturnTarget(route.query.returnTo))
+ const detailBackLabel = computed(() => {
+ if (detailReturnTarget.value === 'conversation') {
+ return '返回对话'
+ }
+ return detailReturnTarget.value === 'workbench' ? '返回首页' : '返回单据中心'
+ })
const detailAlerts = computed(() => (
detailMode.value
? buildDetailAlerts(selectedRequest.value, { currentUser: currentUser.value })
@@ -553,9 +562,9 @@ export function useAppShell() {
function buildDocumentDetailQuery(options = {}) {
const nextQuery = { ...route.query }
- const returnTo = String(options.returnTo || '').trim()
- if (returnTo === 'workbench') {
- nextQuery.returnTo = 'workbench'
+ const returnTo = resolveDocumentDetailReturnTarget(options.returnTo)
+ if (returnTo) {
+ nextQuery.returnTo = returnTo
} else {
delete nextQuery.returnTo
}
@@ -583,12 +592,15 @@ export function useAppShell() {
}
function closeRequestDetail() {
- if (detailReturnTarget.value === 'workbench') {
- router.push({ name: 'app-workbench' })
- return
+ if (detailReturnTarget.value === 'conversation') {
+ return router.push({ name: 'app-workbench' })
}
- router.push({ name: 'app-documents', query: buildDocumentReturnQuery() })
+ if (detailReturnTarget.value === 'workbench') {
+ return router.push({ name: 'app-workbench' })
+ }
+
+ return router.push({ name: 'app-documents', query: buildDocumentReturnQuery() })
}
async function handleRequestUpdated(payload = {}) {
@@ -655,6 +667,7 @@ export function useAppShell() {
smartEntrySessionId,
detailAlerts,
detailBackLabel,
+ detailReturnTarget,
toast,
topBarView
}
diff --git a/web/src/composables/useOverviewView.js b/web/src/composables/useOverviewView.js
index a9b9bfc..8331c7f 100644
--- a/web/src/composables/useOverviewView.js
+++ b/web/src/composables/useOverviewView.js
@@ -6,7 +6,6 @@ import {
fetchSystemDashboard
} from '../services/analytics.js'
import { fetchRiskObservationDashboard } from '../services/riskObservations.js'
-import { formatRiskSignalLabel } from '../utils/riskLabels.js'
import {
buildDigitalEmployeeCategoryRows,
buildDigitalEmployeeDailyRows,
@@ -14,6 +13,32 @@ import {
buildDigitalEmployeeTaskRanking,
emptyDigitalEmployeeDashboard
} from '../views/scripts/overviewDigitalEmployeeDashboardModel.js'
+import {
+ buildRiskDistributionLegend,
+ emptyFinanceBudgetMetrics,
+ emptyFinanceBudgetSummary,
+ emptyFinanceDonut,
+ emptyFinanceTotals,
+ emptyFinanceTrend,
+ emptySystemDashboardTotals,
+ emptySystemLoginWave,
+ formatCompact,
+ formatCurrency,
+ formatMetricValue,
+ formatNumberCompact,
+ formatPercent,
+ formatRiskSignalName,
+ formatSystemMetricValue as formatSystemMetricValueModel,
+ isMissingDimension,
+ resolveFinanceMetricMeta as resolveFinanceMetricMetaModel,
+ resolveSystemMetricMeta as resolveSystemMetricMetaModel
+} from './overviewViewDisplayModel.js'
+import {
+ aggregateRiskDailyTrendRows,
+ DEFAULT_OVERVIEW_RANGE,
+ resolveTopRangeDays,
+ resolveTopRangeKey
+} from './overviewViewRangeModel.js'
import {
metricBlueprints,
@@ -33,186 +58,6 @@ import {
systemToolDetailRows as fallbackSystemToolDetailRows
} from '../data/metrics.js'
-const DEFAULT_OVERVIEW_RANGE = '近10日'
-const DAY_MS = 24 * 60 * 60 * 1000
-const RISK_DAILY_TREND_MAX_BUCKETS = 14
-
-const emptyFinanceTotals = {
- reimbursementAmount: 0,
- reimbursementCount: 0,
- pendingPaymentAmount: 0,
- avgClaimAmount: 0,
- budgetUsageRate: 0,
- paymentClearanceRate: 0
-}
-
-const emptyFinanceTrend = {
- labels: [],
- claimCount: [],
- claimAmount: [],
- categoryAmountSeries: [],
- applications: [],
- approved: [],
- avgHours: []
-}
-
-const emptyFinanceDonut = [
- { name: '暂无数据', value: 0, color: '#cbd5e1' }
-]
-
-const emptyFinanceBudgetSummary = {
- ratio: 0,
- total: '¥0',
- used: '¥0',
- left: '¥0'
-}
-
-const emptyFinanceBudgetMetrics = [
- { label: '预算池数量', value: '0 个', detail: '年度有效预算池', tone: 'neutral', icon: 'mdi mdi-database-outline' },
- { label: '总预算', value: '¥0', detail: '原始预算 + 调整', tone: 'neutral', icon: 'mdi mdi-cash-register' },
- { label: '已用预算', value: '¥0', detail: '使用率 0.0%', tone: 'success', icon: 'mdi mdi-chart-arc' },
- { label: '预占预算', value: '¥0', detail: '待流转单据占用', tone: 'success', icon: 'mdi mdi-lock-outline' },
- { label: '可用预算', value: '¥0', detail: '可继续使用额度', tone: 'success', icon: 'mdi mdi-wallet-outline' },
- { label: '预警预算池', value: '0 个', detail: '超支 0 个', tone: 'success', icon: 'mdi mdi-alert-outline' }
-]
-
-const emptySystemDashboardTotals = {
- toolCalls: 0,
- modelTokens: 0,
- onlineUsers: 0,
- avgOnlineMinutes: 0,
- executionSuccessRate: 0,
- positiveFeedback: 0,
- negativeFeedback: 0,
- failedRuns: 0,
- toolCallsChange: 0,
- modelTokensChange: 0
-}
-
-const emptySystemLoginWave = {
- labels: Array.from({ length: 24 }, (_, hour) => `${String(hour).padStart(2, '0')}:00`),
- loginUsers: Array.from({ length: 24 }, () => 0),
- interactions: Array.from({ length: 24 }, () => 0)
-}
-
-function parseLocalDate(value) {
- const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || '').trim())
- if (!match) {
- return null
- }
- const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
- return Number.isNaN(date.getTime()) ? null : date
-}
-
-function clampWindowDays(value) {
- const days = Number(value || 0)
- if (!Number.isFinite(days) || days <= 0) {
- return 10
- }
- return Math.max(1, Math.min(Math.round(days), 90))
-}
-
-function resolveCustomRangeDays(customRange = {}) {
- const start = parseLocalDate(customRange.start)
- const end = parseLocalDate(customRange.end)
- if (!start || !end) {
- return 10
- }
- return clampWindowDays(Math.abs(end.getTime() - start.getTime()) / DAY_MS + 1)
-}
-
-function resolveTopRangeDays(range, customRange = {}) {
- const key = String(range || DEFAULT_OVERVIEW_RANGE).trim()
- if (key === 'custom') {
- return resolveCustomRangeDays(customRange)
- }
- if (key === '\u4eca\u65e5') {
- return 1
- }
- if (key === '\u672c\u5468') {
- const today = new Date()
- const weekday = today.getDay() || 7
- return clampWindowDays(weekday)
- }
- if (key === '\u672c\u6708') {
- return clampWindowDays(new Date().getDate())
- }
- const match = key.match(/\d+/)
- return clampWindowDays(match ? Number(match[0]) : 10)
-}
-
-function resolveTopRangeKey(range, customRange = {}) {
- const key = String(range || DEFAULT_OVERVIEW_RANGE).trim()
- if (key === 'custom') {
- return 'custom'
- }
- if (key === '\u672c\u5468' || key === '\u4eca\u65e5') {
- return `recent-${resolveTopRangeDays(key, customRange)}-days`
- }
- if (/\d+/.test(key)) {
- return `recent-${resolveTopRangeDays(key, customRange)}-days`
- }
- return key || DEFAULT_OVERVIEW_RANGE
-}
-
-function formatRiskTrendDateLabel(value) {
- const date = parseLocalDate(value)
- if (!date) {
- return String(value || '-').trim() || '-'
- }
- const month = String(date.getMonth() + 1).padStart(2, '0')
- const day = String(date.getDate()).padStart(2, '0')
- return `${month}-${day}`
-}
-
-function buildRiskTrendBucketLabel(first, last) {
- const start = String(first?.date || '').trim()
- const end = String(last?.date || '').trim()
- if (!start || start === end) {
- return formatRiskTrendDateLabel(start)
- }
- return `${formatRiskTrendDateLabel(start)}~${formatRiskTrendDateLabel(end)}`
-}
-
-function normalizeRiskTrendRow(item) {
- return {
- date: String(item.date || '').trim() || '-',
- total: Number(item.total || 0),
- highOrAbove: Number(item.high_or_above ?? item.highOrAbove ?? 0)
- }
-}
-
-function aggregateRiskDailyTrendRows(rows, maxBuckets = RISK_DAILY_TREND_MAX_BUCKETS) {
- const normalizedRows = rows
- .map(normalizeRiskTrendRow)
- .filter((item) => item.date !== '-' || item.total > 0 || item.highOrAbove > 0)
-
- if (normalizedRows.length <= maxBuckets) {
- return normalizedRows.map((item) => ({
- ...item,
- date: formatRiskTrendDateLabel(item.date),
- sourceStartDate: item.date,
- sourceEndDate: item.date
- }))
- }
-
- const bucketSize = Math.ceil(normalizedRows.length / maxBuckets)
- const buckets = []
- for (let index = 0; index < normalizedRows.length; index += bucketSize) {
- const bucketRows = normalizedRows.slice(index, index + bucketSize)
- const first = bucketRows[0]
- const last = bucketRows[bucketRows.length - 1]
- buckets.push({
- date: buildRiskTrendBucketLabel(first, last),
- sourceStartDate: first?.date || '',
- sourceEndDate: last?.date || '',
- total: bucketRows.reduce((sum, item) => sum + item.total, 0),
- highOrAbove: bucketRows.reduce((sum, item) => sum + item.highOrAbove, 0)
- })
- }
- return buckets
-}
-
export function useOverviewView(options = {}) {
const activeDashboardKey = computed(() => {
const dashboard = String(options.dashboard || '').trim()
@@ -254,44 +99,9 @@ export function useOverviewView(options = {}) {
const digitalEmployeeDashboardError = ref(null)
const digitalEmployeeDashboardLoaded = computed(() => Boolean(digitalEmployeeDashboardPayload.value))
- const formatCompact = (value) => {
- if (value >= 1_000_000) return `¥${(value / 1_000_000).toFixed(1)}M`
- if (value >= 1_000) return `¥${(value / 1_000).toFixed(1)}K`
- return `¥${value}`
- }
-
- const formatCurrency = (value) => formatCompact(value)
-
- const formatNumberCompact = (value) => {
- const number = Number(value || 0)
- if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1)}M`
- if (number >= 1_000) return `${(number / 1_000).toFixed(1)}K`
- return `${Math.round(number)}`
- }
-
- const formatPercent = (value) => `${Math.round(Number(value || 0) * 100)}%`
-
- const formatMetricValue = (metric, value) => {
- if (['reimbursementAmount', 'pendingPaymentAmount', 'avgClaimAmount'].includes(metric.key)) {
- return formatCurrency(Math.round(value))
- }
- if (metric.unit === '%') return `${Math.round(value)} ${metric.unit}`
- if (metric.unit) return `${Math.round(value)} ${metric.unit}`
- return `${Math.round(value)}`
- }
-
- const formatSystemMetricValue = (metric, value) => {
- const numericValue = Number(value || 0)
- if (metric.key === 'modelTokens') return formatNumberCompact(numericValue)
- if (metric.key === 'avgOnlineMinutes') return `${numericValue.toFixed(1)} ${metric.unit}`
- if (metric.key === 'executionSuccessRate') return `${numericValue.toFixed(1)}${metric.unit}`
- if (metric.key === 'positiveFeedback') {
- const negativeFeedback = Math.round(Number(systemDashboardTotals.value.negativeFeedback || 0))
- return `${Math.round(numericValue)} / ${negativeFeedback}`
- }
- if (metric.unit) return `${formatNumberCompact(numericValue)} ${metric.unit}`
- return formatNumberCompact(numericValue)
- }
+ const formatSystemMetricValue = (metric, value) => (
+ formatSystemMetricValueModel(metric, value, systemDashboardTotals.value)
+ )
const getFinanceRangeParams = () => {
const activeRange = activeRangeValue.value
@@ -556,68 +366,17 @@ export function useOverviewView(options = {}) {
financeDashboardPayload.value?.budgetMetrics || emptyFinanceBudgetMetrics
))
- const resolveSystemMetricMeta = (metric) => {
- const totals = systemDashboardTotals.value
- const realDashboardLoaded = Boolean(systemDashboardPayload.value)
+ const resolveSystemMetricMeta = (metric) => (
+ resolveSystemMetricMetaModel(metric, systemDashboardTotals.value, Boolean(systemDashboardPayload.value))
+ )
- if (!realDashboardLoaded) {
- return {
- changeText: metric.change,
- delta: metric.delta,
- trend: metric.trend
- }
- }
-
- if (metric.key === 'toolCalls' || metric.key === 'modelTokens') {
- const changeValue = Number(totals[`${metric.key}Change`] || 0)
- return {
- changeText: `${changeValue >= 0 ? '+' : ''}${changeValue.toFixed(1)}%`,
- delta: '较上一周期',
- trend: changeValue < 0 ? 'down' : 'up'
- }
- }
-
- if (metric.key === 'executionSuccessRate') {
- const errorRate = Math.max(0, 100 - Number(totals.executionSuccessRate || 0))
- return {
- changeText: '实时',
- delta: `错误率 ${errorRate.toFixed(1)}%`,
- trend: 'up'
- }
- }
-
- if (metric.key === 'positiveFeedback') {
- return {
- changeText: '实时',
- delta: `差评 ${Math.round(Number(totals.negativeFeedback || 0))} 次`,
- trend: 'up'
- }
- }
-
- return {
- changeText: '实时',
- delta: metric.key === 'onlineUsers' ? '活跃会话' : '按最近会话统计',
- trend: metric.trend
- }
- }
-
- const resolveFinanceMetricMeta = (metric) => {
- const meta = financeMetricMeta.value[metric.key]
-
- if (!financeDashboardPayload.value || !meta) {
- return {
- changeText: financeDashboardLoading.value ? '加载中' : '实时',
- delta: financeDashboardError.value ? '真实数据加载失败' : '等待真实数据',
- trend: metric.trend
- }
- }
-
- return {
- changeText: meta.changeText || metric.change,
- delta: meta.delta || metric.delta,
- trend: meta.trend || metric.trend
- }
- }
+ const resolveFinanceMetricMeta = (metric) => resolveFinanceMetricMetaModel({
+ metric,
+ meta: financeMetricMeta.value[metric.key],
+ dashboardLoaded: Boolean(financeDashboardPayload.value),
+ loading: financeDashboardLoading.value,
+ error: financeDashboardError.value
+ })
const kpiMetrics = computed(() => metricBlueprints.map((metric, index) => {
const rawValue = Number(financeDashboardTotals.value[metric.key] || 0)
@@ -919,39 +678,6 @@ export function useOverviewView(options = {}) {
const digitalEmployeeTaskRanking = computed(() => buildDigitalEmployeeTaskRanking(digitalEmployeeDashboard.value))
const digitalEmployeeCategoryRows = computed(() => buildDigitalEmployeeCategoryRows(digitalEmployeeDashboard.value))
- function buildRiskDistributionLegend(distribution, labels, colors, formatter = formatRiskSignalName) {
- const fallbackColors = ['#ef4444', '#f59e0b', 'var(--theme-primary)', '#3b82f6', '#8b5cf6', '#0f766e']
- const entries = Object.entries(distribution || {})
- .filter(([, value]) => Number(value || 0) > 0)
-
- if (!entries.length) {
- return [
- {
- name: '暂无数据',
- value: 1,
- display: '0项',
- color: '#cbd5e1'
- }
- ]
- }
-
- return entries.map(([key, value], index) => ({
- name: labels[key] || formatter(key),
- value: Number(value || 0),
- display: `${Number(value || 0)}项`,
- color: colors[key] || fallbackColors[index % fallbackColors.length]
- }))
- }
-
- function formatRiskSignalName(value) {
- return formatRiskSignalLabel(value)
- }
-
- function isMissingDimension(value) {
- const text = String(value || '').trim()
- return !text || ['待补充', '待确认', '未归属部门', '未归属', 'N/A', 'n/a', '-'].includes(text)
- }
-
const bottlenecks = financeBottlenecks
const budgetSummary = financeBudgetSummary
const budgetMetrics = financeBudgetMetrics
diff --git a/web/src/composables/useRequests.js b/web/src/composables/useRequests.js
index ddae117..7a122c0 100644
--- a/web/src/composables/useRequests.js
+++ b/web/src/composables/useRequests.js
@@ -1,1670 +1,10 @@
import { computed, reactive, ref } from 'vue'
import { fetchAllExpenseClaims } from '../services/reimbursements.js'
-import { isApplicationDocumentNo } from '../utils/documentClassification.js'
-import { filterActionableRiskFlags, normalizeRiskFlagTone } from '../utils/riskFlags.js'
+import { formatDate, toDate } from './requests/requestShared.js'
+import { mapExpenseClaimToRequest } from './requests/requestClaimMapper.js'
-const EXPENSE_TYPE_LABELS = {
- travel: '差旅费',
- travel_application: '差旅费用申请',
- expense_application: '费用申请',
- purchase_application: '采购费用申请',
- meeting_application: '会务费用申请',
- train_ticket: '火车票',
- flight_ticket: '机票',
- ship_ticket: '轮船票',
- ferry_ticket: '轮船票',
- hotel_ticket: '住宿票',
- ride_ticket: '乘车',
- travel_allowance: '出差补贴',
- entertainment: '业务招待费',
- marketing: '市场推广费',
- office: '办公用品费',
- meeting: '会务费',
- training: '培训费',
- software: '软件服务费',
- hotel: '住宿费',
- transport: '交通费',
- meal: '业务招待费',
- other: '其他费用'
-}
-
-const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
- 'travel',
- 'train_ticket',
- 'flight_ticket',
- 'hotel_ticket',
- 'ride_ticket',
- 'meeting',
- 'entertainment'
-])
-
-const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
-const STANDARD_ADJUSTMENT_RISK_SOURCE = 'reimbursement_standard_adjustment'
-const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
-const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
-const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
-const DOCUMENT_BACKED_EXPENSE_TYPES = new Set([
- 'train_ticket',
- 'flight_ticket',
- 'ship_ticket',
- 'ferry_ticket',
- 'hotel_ticket',
- 'ride_ticket'
-])
-const DOCUMENT_TYPE_APPLICATION = 'application'
-const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
-const RELATED_APPLICATION_STEP_LABEL = '关联单据'
-const APPLICATION_LINK_STATUS_STEP_LABEL = '关联单据状态'
-const APPLICATION_ARCHIVE_STAGE_LABEL = '申请归档'
-const ARCHIVED_STEP_LABEL = '已归档'
-
-const REIMBURSEMENT_PROGRESS_LABELS = [
- RELATED_APPLICATION_STEP_LABEL,
- '待提交',
- '直属领导审批',
- '财务审批',
- '待付款',
- '已付款',
- ARCHIVED_STEP_LABEL
-]
-
-const APPLICATION_PROGRESS_LABELS = [
- '创建申请',
- '直属领导审批',
- '预算管理者审批',
- APPLICATION_LINK_STATUS_STEP_LABEL,
- ARCHIVED_STEP_LABEL
-]
-
-const APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET = [
- '创建申请',
- '直属领导审批',
- APPLICATION_LINK_STATUS_STEP_LABEL,
- ARCHIVED_STEP_LABEL
-]
-
-function parseNumber(value) {
- const nextValue = Number(value)
- return Number.isFinite(nextValue) ? nextValue : 0
-}
-
-function parseOptionalAmount(value) {
- if (value === null || value === undefined || String(value).trim() === '') {
- return null
- }
- const amount = Number(value)
- return Number.isFinite(amount) && amount >= 0 ? amount : null
-}
-
-function buildStandardAdjustmentMapFromClaim(claim = {}) {
- const flags = Array.isArray(claim?.risk_flags_json)
- ? claim.risk_flags_json
- : Array.isArray(claim?.riskFlags)
- ? claim.riskFlags
- : []
- const adjustmentMap = new Map()
-
- flags.forEach((flag) => {
- if (!flag || typeof flag !== 'object') {
- return
- }
- if (String(flag.source || '').trim() !== STANDARD_ADJUSTMENT_RISK_SOURCE) {
- return
- }
- const itemId = String(flag.item_id || flag.itemId || '').trim()
- const reimbursableAmount = parseOptionalAmount(flag.reimbursable_amount ?? flag.reimbursableAmount)
- if (!itemId || reimbursableAmount === null) {
- return
- }
- adjustmentMap.set(itemId, {
- originalAmount: parseOptionalAmount(flag.original_amount ?? flag.originalAmount),
- reimbursableAmount,
- employeeAbsorbedAmount: parseOptionalAmount(flag.employee_absorbed_amount ?? flag.employeeAbsorbedAmount) || 0,
- message: String(flag.message || flag.summary || '').trim()
- })
- })
-
- return adjustmentMap
-}
-
-function toDate(value) {
- if (!value) {
- return null
- }
-
- const nextDate = new Date(value)
- return Number.isNaN(nextDate.getTime()) ? null : nextDate
-}
-
-function formatDate(value) {
- const nextDate = toDate(value)
- if (!nextDate) {
- return ''
- }
-
- const year = nextDate.getFullYear()
- const month = String(nextDate.getMonth() + 1).padStart(2, '0')
- const day = String(nextDate.getDate()).padStart(2, '0')
- return `${year}-${month}-${day}`
-}
-
-function formatDateTime(value) {
- const nextDate = toDate(value)
- if (!nextDate) {
- return ''
- }
-
- const hours = String(nextDate.getHours()).padStart(2, '0')
- const minutes = String(nextDate.getMinutes()).padStart(2, '0')
- return `${formatDate(nextDate)} ${hours}:${minutes}`
-}
-
-function formatDurationFrom(value, now = Date.now()) {
- const startAt = toDate(value)
- if (!startAt) {
- return ''
- }
-
- const diffMs = Math.max(0, Number(now) - startAt.getTime())
- const totalMinutes = Math.floor(diffMs / (60 * 1000))
- if (totalMinutes < 1) {
- return '刚刚'
- }
-
- const days = Math.floor(totalMinutes / (24 * 60))
- const hours = Math.floor((totalMinutes % (24 * 60)) / 60)
- const minutes = totalMinutes % 60
-
- if (days > 0) {
- return hours > 0 ? `${days}天${hours}小时` : `${days}天`
- }
-
- if (hours > 0) {
- return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时`
- }
-
- return `${minutes}分钟`
-}
-
-function formatAmount(value) {
- return new Intl.NumberFormat('zh-CN', {
- style: 'currency',
- currency: 'CNY',
- minimumFractionDigits: 0,
- maximumFractionDigits: Number.isInteger(value) ? 0 : 2
- }).format(parseNumber(value))
-}
-
-function resolveTypeLabel(typeCode) {
- return EXPENSE_TYPE_LABELS[String(typeCode || '').trim()] || EXPENSE_TYPE_LABELS.other
-}
-
-function resolveDocumentTypeMeta(claim, typeCode) {
- const explicitType = String(
- claim?.document_type_code
- || claim?.documentTypeCode
- || claim?.document_type
- || claim?.documentType
- || ''
- ).trim()
- const claimNo = String(claim?.claim_no || claim?.claimNo || '').trim().toUpperCase()
- const normalizedType = String(typeCode || '').trim()
- const isApplication =
- explicitType === DOCUMENT_TYPE_APPLICATION
- || explicitType === 'expense_application'
- || isApplicationDocumentNo(claimNo)
- || normalizedType === 'application'
- || normalizedType.endsWith('_application')
-
- return isApplication
- ? { documentTypeCode: DOCUMENT_TYPE_APPLICATION, documentTypeLabel: '申请单' }
- : { documentTypeCode: DOCUMENT_TYPE_REIMBURSEMENT, documentTypeLabel: '报销单' }
-}
-
-function normalizeExpenseType(typeCode) {
- return String(typeCode || '').trim() || 'other'
-}
-
-function isLocationRequiredExpenseType(typeCode) {
- return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(typeCode))
-}
-
-function resolveLocationDisplay(location, typeCode) {
- const normalized = String(location || '').trim()
- if (normalized) {
- return normalized
- }
-
- return isLocationRequiredExpenseType(typeCode) ? '待补充' : '非必填'
-}
-
-function resolveExpenseDescriptionDetail(itemType, itemLocation) {
- const normalizedType = normalizeExpenseType(itemType)
- if (ROUTE_DESCRIPTION_EXPENSE_TYPES.has(normalizedType)) {
- return '起始地-目的地'
- }
- if (HOTEL_DESCRIPTION_EXPENSE_TYPES.has(normalizedType)) {
- return '目的地酒店'
- }
- return resolveLocationDisplay(itemLocation, normalizedType)
-}
-
-function resolveExpenseItemViewId(item, index, claim) {
- return String(item?.id || `${claim?.id || 'claim'}-item-${index}`)
-}
-
-function buildTravelTimeLabelMap(items, claim) {
- const travelItems = items
- .map((item, index) => {
- const itemType = normalizeExpenseType(item?.item_type || claim?.expense_type)
- return {
- id: resolveExpenseItemViewId(item, index, claim),
- index,
- itemType,
- itemDate: formatDate(item?.item_date),
- isSystemGenerated: Boolean(item?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
- }
- })
- .filter((item) => !item.isSystemGenerated && LONG_DISTANCE_TRAVEL_EXPENSE_TYPES.has(item.itemType))
- .sort((left, right) => {
- const dateCompare = String(left.itemDate || '').localeCompare(String(right.itemDate || ''))
- return dateCompare || left.index - right.index
- })
-
- const labels = new Map()
- travelItems.forEach((item, index) => {
- if (index === 0) {
- labels.set(item.id, '出发时间')
- } else if (index === travelItems.length - 1) {
- labels.set(item.id, '返回时间')
- } else {
- labels.set(item.id, '中转时间')
- }
- })
- return labels
-}
-
-function resolveExpenseTimeLabel({ id, itemType, isSystemGenerated, claim, travelTimeLabelMap }) {
- if (isSystemGenerated) {
- return '系统自动计算'
- }
- if (travelTimeLabelMap?.has(id)) {
- return travelTimeLabelMap.get(id)
- }
- if (itemType === 'ride_ticket') {
- return '乘车时间'
- }
- if (itemType === 'hotel_ticket') {
- return '住宿时间'
- }
- return claim?.expense_type === 'travel' ? '出行时间' : '业务发生时间'
-}
-
-function resolveAttachmentDisplayName(value) {
- const normalized = String(value || '').trim()
- if (!normalized) {
- return ''
- }
-
- return normalized.split('/').filter(Boolean).pop() || normalized
-}
-
-function hasRelatedApplicationContext(claim) {
- return Boolean(findRelatedApplicationEvent(claim))
-}
-
-function isDocumentBackedRawExpenseItem(item) {
- const invoiceId = normalizeText(item?.invoice_id || item?.invoiceId)
- if (invoiceId) {
- return true
- }
-
- return DOCUMENT_BACKED_EXPENSE_TYPES.has(normalizeExpenseType(item?.item_type || item?.itemType))
-}
-
-function extractTravelDayCount(value) {
- const matched = normalizeText(value).replace(/\s+/g, '').match(/(\d{1,2})天/)
- return matched ? parseNumber(matched[1]) : 0
-}
-
-function isStaleApplicationAllowanceRawItem(item, claim) {
- const itemType = normalizeExpenseType(item?.item_type || item?.itemType)
- if (itemType !== 'travel_allowance') {
- return false
- }
-
- const related = resolveRelatedApplicationInfo(claim)
- const applicationDays = extractTravelDayCount(related?.days)
- const itemDays = extractTravelDayCount(item?.item_reason || item?.itemReason)
- return applicationDays > 0 && itemDays > 0 && applicationDays !== itemDays
-}
-
-function isApplicationLinkPlaceholderRawItem(item, claim) {
- const itemType = normalizeExpenseType(item?.item_type || item?.itemType)
- if (SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType)) {
- return true
- }
-
- const claimType = normalizeExpenseType(claim?.expense_type || claim?.expenseType)
- if (itemType && claimType && itemType !== claimType) {
- return false
- }
-
- const reason = normalizeText(item?.item_reason || item?.itemReason)
- if (!reason || reason === '待补充') {
- return true
- }
-
- const related = resolveRelatedApplicationInfo(claim)
- const linkedReasons = new Set([
- normalizeText(claim?.reason),
- normalizeText(related?.reason)
- ].filter(Boolean))
- return linkedReasons.has(reason)
-}
-
-function filterVisibleExpenseRawItems(items, claim) {
- const rawItems = Array.isArray(items) ? items : []
- if (!rawItems.length || !hasRelatedApplicationContext(claim)) {
- return rawItems
- }
-
- const hasRealExpenseItem = rawItems.some((item) => (
- isDocumentBackedRawExpenseItem(item)
- && !SYSTEM_GENERATED_EXPENSE_TYPES.has(normalizeExpenseType(item?.item_type || item?.itemType))
- ))
- if (!hasRealExpenseItem) {
- return rawItems.filter((item) => !isApplicationLinkPlaceholderRawItem(item, claim))
- }
-
- return rawItems.filter((item) => {
- const itemType = normalizeExpenseType(item?.item_type || item?.itemType)
- if (SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType)) {
- return !isStaleApplicationAllowanceRawItem(item, claim)
- }
- return !isApplicationLinkPlaceholderRawItem(item, claim)
- })
-}
-
-function resolveApprovalMeta(status) {
- const normalized = String(status || '').trim().toLowerCase()
-
- if (normalized === 'draft') {
- return { key: 'draft', label: '草稿', tone: 'draft' }
- }
-
- if (normalized === 'returned') {
- return { key: 'supplement', label: '待提交', tone: 'warning' }
- }
-
- if (normalized === 'supplement') {
- return { key: 'supplement', label: '待补充', tone: 'warning' }
- }
-
- if (normalized === 'pending_payment') {
- return { key: 'pending_payment', label: '待付款', tone: 'warning' }
- }
-
- if (normalized === 'paid') {
- return { key: 'completed', label: '已付款', tone: 'success' }
- }
-
- if (['approved', 'completed', 'paid'].includes(normalized)) {
- return { key: 'completed', label: '已完成', tone: 'success' }
- }
-
- if (['rejected', 'cancelled'].includes(normalized)) {
- return { key: 'rejected', label: '已退回', tone: 'danger' }
- }
-
- return { key: 'in_progress', label: '审批中', tone: 'info' }
-}
-
-function resolveWorkflowNode(claim, approvalMeta, isApplicationDocument = false) {
- if (String(claim?.status || '').trim().toLowerCase() === 'returned') {
- return '待提交'
- }
-
- const rawNode = String(claim?.approval_stage || '').trim()
-
- if (rawNode) {
- if (
- isApplicationDocument
- && approvalMeta.key === 'completed'
- && (
- rawNode === '审批完成'
- || rawNode.includes('审批完成')
- || rawNode.includes('申请完成')
- )
- ) {
- return APPLICATION_LINK_STATUS_STEP_LABEL
- }
- if (rawNode === '审批流转' || rawNode.includes('AI预审') || rawNode.includes('AI验审')) {
- return approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' ? '待提交' : '直属领导审批'
- }
- if (rawNode === '待补充') {
- return '待提交'
- }
- return rawNode
- }
-
- if (approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' || approvalMeta.key === 'rejected') {
- return '待提交'
- }
-
- if (approvalMeta.key === 'pending_payment') {
- return '待付款'
- }
-
- if (approvalMeta.key === 'completed') {
- const normalizedStatus = String(claim?.status || '').trim().toLowerCase()
- return isApplicationDocument ? APPLICATION_LINK_STATUS_STEP_LABEL : normalizedStatus === 'paid' ? '已付款' : '归档入账'
- }
-
- return '直属领导审批'
-}
-
-function stringifyRiskFlag(value) {
- if (typeof value === 'string') {
- return value.trim()
- }
-
- if (!value || typeof value !== 'object') {
- return ''
- }
-
- for (const key of ['message', 'label', 'reason', 'name']) {
- const nextValue = String(value[key] || '').trim()
- if (nextValue) {
- return nextValue
- }
- }
-
- return ''
-}
-
-const RISK_TONE_LABELS = {
- high: '高风险',
- medium: '中风险',
- low: '低风险'
-}
-
-function resolveHighestRiskTone(flags) {
- const tones = flags.map((item) => normalizeRiskFlagTone(item)).filter(Boolean)
- if (tones.includes('high')) {
- return 'high'
- }
- if (tones.includes('medium')) {
- return 'medium'
- }
- if (tones.includes('low')) {
- return 'low'
- }
- return 'low'
-}
-
-function buildRiskMeta(riskFlags) {
- if (!Array.isArray(riskFlags) || !riskFlags.length) {
- return { summary: '无', tone: 'low', label: '无' }
- }
-
- const actionableFlags = filterActionableRiskFlags(riskFlags)
- const items = actionableFlags.map((item) => stringifyRiskFlag(item)).filter(Boolean)
- if (!items.length) {
- return { summary: '无', tone: 'low', label: '无' }
- }
-
- const tone = resolveHighestRiskTone(actionableFlags)
- return {
- summary: items.join(';'),
- tone,
- label: RISK_TONE_LABELS[tone] || '待关注'
- }
-}
-
-function buildRiskSummary(riskFlags) {
- return buildRiskMeta(riskFlags).summary
-}
-
-function buildOccurredDisplay(claim) {
- const itemDates = Array.isArray(claim?.items)
- ? claim.items.map((item) => formatDate(item?.item_date)).filter(Boolean)
- : []
-
- if (!itemDates.length) {
- return formatDate(claim?.occurred_at) || '待补充'
- }
-
- const sortedDates = [...new Set(itemDates)].sort()
- if (sortedDates.length === 1) {
- return sortedDates[0]
- }
-
- return `${sortedDates[0]} ~ ${sortedDates[sortedDates.length - 1]}`
-}
-
-function resolveProgressCurrentIndex(approvalMeta, workflowNode) {
- const normalizedNode = String(workflowNode || '').trim()
-
- if (approvalMeta.key === 'completed') {
- return 6
- }
-
- if (approvalMeta.key === 'pending_payment') {
- return 4
- }
-
- if (normalizedNode.includes('已付款')) {
- return 5
- }
- if (normalizedNode.includes('待付款')) {
- return 4
- }
- if (normalizedNode.includes('归档') || normalizedNode.includes('入账')) {
- return 6
- }
- if (normalizedNode.includes('财务')) {
- return 3
- }
- if (
- normalizedNode.includes('直属领导')
- || normalizedNode.includes('领导审批')
- || normalizedNode.includes('部门负责人')
- || normalizedNode.includes('负责人审批')
- ) {
- return 2
- }
- if (normalizedNode.includes('AI预审') || normalizedNode.includes('AI验审') || normalizedNode.includes('审批流转')) {
- return approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' ? 1 : 2
- }
- if (normalizedNode.includes('待提交')) {
- return 1
- }
-
- if (approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' || approvalMeta.key === 'rejected') {
- return 1
- }
-
- return 2
-}
-
-function resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode) {
- const normalizedNode = String(workflowNode || '').trim()
-
- if (approvalMeta.key === 'completed') {
- return normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) ? 3 : 2
- }
-
- if (normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) || normalizedNode.includes(ARCHIVED_STEP_LABEL)) {
- return 3
- }
- if (normalizedNode.includes(APPLICATION_LINK_STATUS_STEP_LABEL)) {
- return 2
- }
- if (normalizedNode.includes('审批完成') || normalizedNode.includes('申请完成')) {
- return 2
- }
- if (normalizedNode.includes('预算')) {
- return 2
- }
- if (
- normalizedNode.includes('直属领导')
- || normalizedNode.includes('领导审批')
- || normalizedNode.includes('部门负责人')
- || normalizedNode.includes('负责人审批')
- ) {
- return 1
- }
- if (approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' || approvalMeta.key === 'rejected') {
- return 0
- }
-
- return 1
-}
-
-function isApplicationArchivedWorkflow(claim, workflowNode) {
- const normalizedNode = normalizeText(workflowNode || claim?.approval_stage || claim?.workflowNode)
- if (normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) || normalizedNode.includes(ARCHIVED_STEP_LABEL)) {
- return true
- }
- return getRiskFlags(claim).some((flag) => (
- flag
- && typeof flag === 'object'
- && normalizeText(flag.source) === 'application_archive_sync'
- ))
-}
-
-function resolveApplicationLinkedReimbursementNo(claim) {
- for (const flag of [...getRiskFlags(claim)].reverse()) {
- if (!flag || typeof flag !== 'object') {
- continue
- }
- const generatedNo = normalizeText(
- flag.generated_draft_claim_no
- || flag.generatedDraftClaimNo
- || flag.reimbursement_claim_no
- || flag.reimbursementClaimNo
- )
- if (generatedNo) {
- return generatedNo
- }
- }
- return ''
-}
-
-function buildApplicationLinkStatusStepMeta(claim) {
- const reimbursementNo = resolveApplicationLinkedReimbursementNo(claim)
- const updatedAt = formatDateTime(claim?.updated_at)
- return reimbursementNo
- ? buildProgressStepMeta(`关联中 ${reimbursementNo}`, updatedAt)
- : buildProgressStepMeta('未关联', updatedAt)
-}
-
-function normalizeText(value) {
- return String(value || '').trim()
-}
-
-function isEmailLike(value) {
- return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(normalizeText(value))
-}
-
-function resolveDisplayName(...values) {
- for (const value of values) {
- const normalized = normalizeText(value)
- if (normalized && !isEmailLike(normalized)) {
- return normalized
- }
- }
-
- return ''
-}
-
-function resolveApplicationApproverName(claim) {
- return resolveDisplayName(
- claim?.manager_name,
- claim?.managerName,
- claim?.profile_manager,
- claim?.profileManager,
- claim?.direct_manager_name,
- claim?.directManagerName
- ) || '直属领导'
-}
-
-function resolveReimbursementApproverName(claim, label) {
- const stepLabel = normalizeText(label)
- if (stepLabel === '直属领导审批') {
- return resolveDisplayName(
- claim?.manager_name,
- claim?.managerName,
- claim?.profile_manager,
- claim?.profileManager,
- claim?.direct_manager_name,
- claim?.directManagerName
- ) || '直属领导'
- }
-
- if (stepLabel === '财务审批') {
- const routeEvent = findReimbursementFinanceRouteEvent(claim)
- return resolveDisplayName(
- claim?.finance_approver_name,
- claim?.financeApproverName,
- routeEvent?.next_approver_name,
- routeEvent?.nextApproverName,
- routeEvent?.finance_approver_name,
- routeEvent?.financeApproverName,
- claim?.finance_owner_name,
- claim?.financeOwnerName
- ) || '财务'
- }
-
- return stepLabel.replace(/审批$/, '') || '审批人'
-}
-
-function resolveApplicationBudgetApproverName(claim) {
- const routeEvent = findApprovalEventForStep(claim, '直属领导审批')
- return resolveDisplayName(
- claim?.budget_approver_name,
- claim?.budgetApproverName,
- routeEvent?.next_approver_name,
- routeEvent?.nextApproverName,
- routeEvent?.budget_approver_name,
- routeEvent?.budgetApproverName
- ) || '预算管理者'
-}
-
-function resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta) {
- const normalizedLabel = normalizeText(label)
- const workflowNode = normalizeText(claim?.approval_stage || claim?.workflowNode)
- if (
- documentTypeCode !== DOCUMENT_TYPE_APPLICATION
- && approvalMeta.key !== 'completed'
- && normalizedLabel === '直属领导审批'
- && workflowNode.includes(normalizedLabel.replace(/审批$/, ''))
- ) {
- return '等待批复'
- }
-
- if (
- documentTypeCode !== DOCUMENT_TYPE_APPLICATION
- && approvalMeta.key !== 'completed'
- && normalizedLabel === '财务审批'
- && workflowNode.includes(normalizedLabel.replace(/审批$/, ''))
- ) {
- return `等待 ${resolveReimbursementApproverName(claim, normalizedLabel)} 批复`
- }
-
- if (
- documentTypeCode === DOCUMENT_TYPE_APPLICATION
- && approvalMeta.key !== 'completed'
- && normalizedLabel === '直属领导审批'
- && (
- workflowNode.includes('直属领导')
- || workflowNode.includes('领导审批')
- || workflowNode.includes('部门负责人')
- || workflowNode.includes('负责人审批')
- )
- ) {
- return `等待 ${resolveApplicationApproverName(claim)} 批复`
- }
-
- if (
- documentTypeCode === DOCUMENT_TYPE_APPLICATION
- && approvalMeta.key !== 'completed'
- && normalizedLabel === '预算管理者审批'
- && workflowNode.includes('预算')
- ) {
- return `等待 ${resolveApplicationBudgetApproverName(claim)} 批复`
- }
-
- return label
-}
-
-function getRiskFlags(claim) {
- return Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : []
-}
-
-function getLatestEvent(events) {
- const sortedEvents = events
- .filter((item) => item && typeof item === 'object')
- .map((item) => ({ ...item, eventDate: toDate(item.created_at || item.createdAt) }))
- .filter((item) => item.eventDate)
- .sort((a, b) => a.eventDate.getTime() - b.eventDate.getTime())
-
- return sortedEvents.length ? sortedEvents[sortedEvents.length - 1] : null
-}
-
-function findApprovalEventForStep(claim, label) {
- const stepLabel = normalizeText(label)
- const events = getRiskFlags(claim).filter((flag) => {
- if (!flag || typeof flag !== 'object') {
- return false
- }
-
- const source = normalizeText(flag.source)
- if (!['manual_approval', 'budget_approval', 'finance_approval'].includes(source)) {
- return false
- }
-
- const previousStage = normalizeText(flag.previous_approval_stage || flag.previousApprovalStage)
- const nextStage = normalizeText(flag.next_approval_stage || flag.nextApprovalStage)
-
- if (stepLabel === '直属领导审批') {
- return (
- previousStage.includes('直属领导')
- || previousStage.includes('领导审批')
- || nextStage.includes('预算')
- || nextStage.includes('财务')
- )
- }
-
- if (stepLabel === '预算管理者审批') {
- return (
- source === 'budget_approval'
- || previousStage.includes('预算')
- || nextStage.includes('审批完成')
- )
- }
-
- if (stepLabel === '财务审批') {
- return (
- previousStage.includes('财务')
- || nextStage.includes('待付款')
- || nextStage.includes('归档')
- || nextStage.includes('入账')
- || nextStage.includes('完成')
- )
- }
-
- return false
- })
-
- return getLatestEvent(events)
-}
-
-function findReimbursementFinanceRouteEvent(claim) {
- return getLatestEvent(
- getRiskFlags(claim).filter((flag) => {
- if (!flag || typeof flag !== 'object') {
- return false
- }
-
- const source = normalizeText(flag.source)
- if (!['manual_approval', 'budget_approval'].includes(source)) {
- return false
- }
-
- const nextStage = normalizeText(flag.next_approval_stage || flag.nextApprovalStage)
- return nextStage.includes('财务')
- })
- )
-}
-
-function findLatestReturnEvent(claim) {
- return getLatestEvent(
- getRiskFlags(claim).filter((flag) => (
- flag
- && typeof flag === 'object'
- && normalizeText(flag.source) === 'manual_return'
- ))
- )
-}
-
-function findLatestPaymentEvent(claim) {
- return getLatestEvent(
- getRiskFlags(claim).filter((flag) => (
- flag
- && typeof flag === 'object'
- && (
- normalizeText(flag.source) === 'payment'
- || normalizeText(flag.event_type || flag.eventType) === 'expense_claim_payment_completed'
- )
- ))
- )
-}
-
-function normalizeApplicationHandoffDetail(flag = {}) {
- const detail = flag?.application_detail || flag?.applicationDetail || {}
- const reviewValues = flag?.review_form_values || flag?.reviewFormValues || {}
- const sceneSelection = flag?.expense_scene_selection || flag?.expenseSceneSelection || {}
- return [sceneSelection, reviewValues, detail]
- .filter((item) => item && typeof item === 'object')
- .reduce((acc, item) => ({ ...acc, ...item }), {})
-}
-
-function resolveApplicationField(flag = {}, detail = {}, snakeKey, camelKey = '') {
- return normalizeText(
- flag?.[snakeKey]
- || (camelKey ? flag?.[camelKey] : '')
- || detail?.[snakeKey]
- || (camelKey ? detail?.[camelKey] : '')
- )
-}
-
-function resolveApplicationValue(flag = {}, detail = {}, keys = []) {
- for (const key of keys) {
- const detailValue = normalizeText(detail?.[key])
- if (detailValue) {
- return detailValue
- }
- const flagValue = normalizeText(flag?.[key])
- if (flagValue) {
- return flagValue
- }
- }
-
- return ''
-}
-
-function extractDateRange(value) {
- const dates = normalizeText(value).match(/\d{4}-\d{2}-\d{2}/g) || []
- if (!dates.length) {
- return { startDate: '', endDate: '' }
- }
-
- return {
- startDate: dates[0],
- endDate: dates[dates.length - 1]
- }
-}
-
-function resolveRelatedApplicationClaimNo(flag = {}) {
- const detail = normalizeApplicationHandoffDetail(flag)
- return resolveApplicationField(flag, detail, 'application_claim_no', 'applicationClaimNo')
-}
-
-function findRelatedApplicationEvent(claim) {
- const events = getRiskFlags(claim).filter((flag) => (
- flag
- && typeof flag === 'object'
- && resolveRelatedApplicationClaimNo(flag)
- ))
- return getLatestEvent(events) || events[events.length - 1] || null
-}
-
-function resolveRelatedApplicationAmountLabel(flag, detail, claim) {
- const explicitLabel = normalizeText(
- flag?.application_amount_label
- || flag?.applicationAmountLabel
- || detail?.application_amount_label
- || detail?.applicationAmountLabel
- )
- if (explicitLabel) return explicitLabel
-
- const rawAmount = normalizeText(
- flag?.application_amount
- || flag?.applicationAmount
- || flag?.application_budget_amount
- || flag?.applicationBudgetAmount
- || detail?.application_amount
- || detail?.applicationAmount
- || detail?.amount
- || claim?.amount
- )
- const amountValue = parseNumber(rawAmount)
- return amountValue > 0 ? formatAmount(amountValue) : rawAmount
-}
-
-function resolveRelatedApplicationInfo(claim, typeLabel = '') {
- const relatedEvent = findRelatedApplicationEvent(claim)
- if (!relatedEvent) {
- return null
- }
-
- const detail = normalizeApplicationHandoffDetail(relatedEvent)
- const claimNo = resolveRelatedApplicationClaimNo(relatedEvent)
- const applicationType = normalizeText(
- detail.application_type
- || detail.applicationType
- || relatedEvent.application_type
- || relatedEvent.applicationType
- || typeLabel
- )
- const location = normalizeText(
- detail.application_location
- || detail.applicationLocation
- || detail.location
- || relatedEvent.application_location
- || relatedEvent.applicationLocation
- || claim?.location
- )
- const reason = normalizeText(
- detail.application_reason
- || detail.applicationReason
- || detail.reason
- || relatedEvent.application_reason
- || relatedEvent.applicationReason
- || claim?.reason
- )
- const content = normalizeText(
- detail.application_content
- || detail.applicationContent
- || relatedEvent.application_content
- || relatedEvent.applicationContent
- ) || [applicationType, location].filter(Boolean).join(' / ')
- const rawTime = normalizeText(
- detail.application_time
- || detail.applicationTime
- || detail.application_business_time
- || detail.applicationBusinessTime
- || detail.business_time
- || detail.businessTime
- || detail.time_range
- || detail.timeRange
- || detail.time
- || detail.application_date
- || detail.applicationDate
- || relatedEvent.application_time
- || relatedEvent.applicationTime
- || relatedEvent.application_business_time
- || relatedEvent.applicationBusinessTime
- || relatedEvent.business_time
- || relatedEvent.businessTime
- || relatedEvent.time_range
- || relatedEvent.timeRange
- || relatedEvent.application_date
- || relatedEvent.applicationDate
- || claim?.occurred_at
- )
- const displayTime = formatDate(rawTime) || rawTime
- const dateRange = extractDateRange(rawTime || displayTime)
- const ruleName = resolveApplicationValue(relatedEvent, detail, [
- 'application_rule_name',
- 'applicationRuleName',
- 'rule_name',
- 'ruleName'
- ])
- const ruleVersion = resolveApplicationValue(relatedEvent, detail, [
- 'application_rule_version',
- 'applicationRuleVersion',
- 'rule_version',
- 'ruleVersion'
- ])
-
- return {
- id: resolveApplicationField(relatedEvent, detail, 'application_claim_id', 'applicationClaimId'),
- claimNo,
- content,
- reason,
- days: normalizeText(
- detail.application_days
- || detail.applicationDays
- || detail.days
- || relatedEvent.application_days
- || relatedEvent.applicationDays
- ),
- location,
- time: displayTime,
- tripStartDate: dateRange.startDate,
- tripEndDate: dateRange.endDate,
- amountLabel: resolveRelatedApplicationAmountLabel(relatedEvent, detail, claim),
- statusLabel: resolveApplicationField(relatedEvent, detail, 'application_status_label', 'applicationStatusLabel'),
- transportMode: normalizeText(
- detail.application_transport_mode
- || detail.applicationTransportMode
- || detail.transport_mode
- || relatedEvent.application_transport_mode
- || relatedEvent.applicationTransportMode
- ),
- lodgingDailyCap: resolveApplicationValue(relatedEvent, detail, [
- 'application_lodging_daily_cap',
- 'applicationLodgingDailyCap',
- 'lodging_daily_cap',
- 'lodgingDailyCap'
- ]),
- subsidyDailyCap: resolveApplicationValue(relatedEvent, detail, [
- 'application_subsidy_daily_cap',
- 'applicationSubsidyDailyCap',
- 'subsidy_daily_cap',
- 'subsidyDailyCap'
- ]),
- transportPolicy: resolveApplicationValue(relatedEvent, detail, [
- 'application_transport_policy',
- 'applicationTransportPolicy',
- 'transport_policy',
- 'transportPolicy'
- ]),
- policyEstimate: resolveApplicationValue(relatedEvent, detail, [
- 'application_policy_estimate',
- 'applicationPolicyEstimate',
- 'policy_estimate',
- 'policyEstimate'
- ]),
- ruleName,
- ruleVersion,
- ruleLabel: [ruleName, ruleVersion].filter(Boolean).join(' / ')
- }
-}
-
-function findLatestApplicationReturnEvent(claim) {
- return getLatestEvent(
- getRiskFlags(claim).filter((flag) => {
- if (!flag || typeof flag !== 'object' || normalizeText(flag.source) !== 'manual_return') {
- return false
- }
- const eventType = normalizeText(flag.event_type || flag.eventType)
- const returnStage = normalizeText(flag.return_stage || flag.returnStage || flag.previous_approval_stage)
- const stageKey = normalizeText(flag.return_stage_key || flag.returnStageKey)
- return (
- eventType === 'expense_application_return'
- || stageKey === 'direct_manager'
- || returnStage.includes('直属领导')
- || returnStage.includes('领导审批')
- )
- })
- )
-}
-
-function findMergedApplicationBudgetApprovalEvent(claim) {
- return getLatestEvent(
- getRiskFlags(claim).filter((flag) => {
- if (!flag || typeof flag !== 'object') {
- return false
- }
- const source = normalizeText(flag.source)
- const eventType = normalizeText(flag.event_type || flag.eventType)
- const previousStage = normalizeText(flag.previous_approval_stage || flag.previousApprovalStage)
- const nextStage = normalizeText(flag.next_approval_stage || flag.nextApprovalStage)
- const mergedFlag = Boolean(flag.budget_approval_merged || flag.budgetApprovalMerged)
- return (
- source === 'manual_approval'
- && eventType === 'expense_application_approval'
- && previousStage.includes('直属领导')
- && (
- nextStage.includes('审批完成')
- || nextStage.includes(APPLICATION_LINK_STATUS_STEP_LABEL)
- || nextStage.includes('申请完成')
- )
- && mergedFlag
- )
- })
- )
-}
-
-function resolveBudgetRouteResult(flag, routeDecision = {}) {
- if (routeDecision && typeof routeDecision === 'object') {
- const routeBudgetResult = routeDecision.budget_result || routeDecision.budgetResult
- if (routeBudgetResult && typeof routeBudgetResult === 'object') {
- return routeBudgetResult
- }
- }
-
- const flagBudgetResult = flag?.budget_result || flag?.budgetResult
- return flagBudgetResult && typeof flagBudgetResult === 'object' ? flagBudgetResult : {}
-}
-
-function applicationBudgetRouteMeetsThreshold(flag, routeDecision = {}) {
- const budgetResult = resolveBudgetRouteResult(flag, routeDecision)
- const metrics = budgetResult.metrics && typeof budgetResult.metrics === 'object' ? budgetResult.metrics : {}
- const overBudgetAmount = parseNumber(metrics.over_budget_amount ?? metrics.overBudgetAmount)
- const afterUsageRate = parseNumber(metrics.after_usage_rate ?? metrics.afterUsageRate)
- const claimAmountRatio = parseNumber(metrics.claim_amount_ratio ?? metrics.claimAmountRatio)
-
- return overBudgetAmount > 0 || Math.max(afterUsageRate, claimAmountRatio) >= 90
-}
-
-function applicationRequiresBudgetReviewStep(claim, workflowNode) {
- const node = normalizeText(workflowNode || claim?.approval_stage || claim?.workflowNode)
- if (node.includes('预算')) {
- return true
- }
-
- return getRiskFlags(claim).some((flag) => {
- if (!flag || typeof flag !== 'object') {
- return false
- }
-
- const source = normalizeText(flag.source)
- const eventType = normalizeText(flag.event_type || flag.eventType)
- const previousStage = normalizeText(flag.previous_approval_stage || flag.previousApprovalStage)
- const routeDecision = flag.route_decision || flag.routeDecision || {}
-
- if (source === 'approval_routing' && flag.requires_budget_review === true) {
- return applicationBudgetRouteMeetsThreshold(flag, flag)
- }
- if (
- routeDecision
- && typeof routeDecision === 'object'
- && routeDecision.requires_budget_review === true
- ) {
- return applicationBudgetRouteMeetsThreshold(flag, routeDecision)
- }
- return (
- source === 'budget_approval'
- || eventType === 'expense_application_budget_approval'
- || previousStage.includes('预算')
- )
- })
-}
-
-function buildProgressStepMeta(time, detail = '', title = '') {
- return {
- time,
- detail,
- title: title || [time, detail].filter(Boolean).join(' ')
- }
-}
-
-function buildCompletedStepMeta(claim, label) {
- const stepLabel = normalizeText(label)
- const employeeName = normalizeText(claim?.employee_name) || '申请人'
-
- if (stepLabel === RELATED_APPLICATION_STEP_LABEL) {
- const relatedApplication = resolveRelatedApplicationInfo(claim)
- const createdAt = formatDateTime(claim?.created_at)
- if (relatedApplication?.claimNo) {
- return buildProgressStepMeta(`已关联 ${relatedApplication.claimNo}`, createdAt)
- }
- return buildProgressStepMeta('待核对关联单据', createdAt)
- }
-
- if (stepLabel === APPLICATION_LINK_STATUS_STEP_LABEL) {
- return buildApplicationLinkStatusStepMeta(claim)
- }
-
- if (stepLabel === '创建单据' || stepLabel === '创建申请') {
- const createdAt = formatDateTime(claim?.created_at)
- return buildProgressStepMeta(stepLabel === '创建申请' ? `${employeeName}发起申请` : `${employeeName}创建`, createdAt)
- }
-
- if (stepLabel === '待提交') {
- const submittedAt = formatDateTime(claim?.submitted_at)
- return buildProgressStepMeta(`${employeeName}提交`, submittedAt)
- }
-
- if (stepLabel === '直属领导审批' || stepLabel === '预算管理者审批' || stepLabel === '财务审批') {
- const approvalEvent = findApprovalEventForStep(claim, stepLabel)
- if (approvalEvent) {
- const operator = resolveDisplayName(
- approvalEvent.operator,
- approvalEvent.operator_name,
- approvalEvent.operatorName,
- stepLabel === '直属领导审批' ? claim?.manager_name : '',
- stepLabel === '预算管理者审批' ? approvalEvent.next_approver_name : ''
- ) || (stepLabel === '财务审批' ? '财务' : stepLabel === '预算管理者审批' ? '预算管理者' : '直属领导')
- const approvedAt = formatDateTime(approvalEvent.created_at || approvalEvent.createdAt)
- return buildProgressStepMeta(`${operator}通过`, approvedAt, `${operator}审批通过 ${approvedAt}`.trim())
- }
-
- if (stepLabel === '财务审批') {
- const updatedAt = formatDateTime(claim?.updated_at)
- return buildProgressStepMeta('财务通过', updatedAt, `财务审批通过 ${updatedAt}`.trim())
- }
-
- if (stepLabel === '直属领导审批') {
- const returnEvent = findLatestApplicationReturnEvent(claim)
- if (returnEvent) {
- const handledAt = formatDateTime(returnEvent.created_at || returnEvent.createdAt)
- return buildProgressStepMeta('已处理', handledAt, `直属领导已处理 ${handledAt}`.trim())
- }
- }
- }
-
- if (stepLabel === '退回') {
- const returnEvent = findLatestApplicationReturnEvent(claim) || findLatestReturnEvent(claim)
- if (returnEvent) {
- const operator = resolveDisplayName(
- returnEvent.operator,
- returnEvent.operator_name,
- returnEvent.operatorName,
- claim?.manager_name
- ) || '直属领导'
- const returnedAt = formatDateTime(returnEvent.created_at || returnEvent.createdAt)
- return buildProgressStepMeta(`${operator}退回`, returnedAt, `${operator}退回 ${returnedAt}`.trim())
- }
- }
-
- if (stepLabel === '待付款') {
- const approvalEvent = findApprovalEventForStep(claim, '财务审批')
- const pendingAt = formatDateTime(approvalEvent?.created_at || approvalEvent?.createdAt || claim?.updated_at)
- return buildProgressStepMeta('待付款', pendingAt)
- }
-
- if (stepLabel === '已付款') {
- const paymentEvent = findLatestPaymentEvent(claim)
- const paidAt = formatDateTime(paymentEvent?.created_at || paymentEvent?.createdAt || claim?.updated_at)
- return buildProgressStepMeta('已付款', paidAt)
- }
-
- if (stepLabel === '归档入账') {
- const archivedAt = formatDateTime(claim?.updated_at)
- return buildProgressStepMeta('归档入账', archivedAt)
- }
-
- if (stepLabel === ARCHIVED_STEP_LABEL) {
- const archivedAt = formatDateTime(claim?.updated_at)
- return buildProgressStepMeta(ARCHIVED_STEP_LABEL, archivedAt)
- }
-
- if (stepLabel === '审批完成') {
- const completedAt = formatDateTime(claim?.updated_at)
- return buildProgressStepMeta('审批完成', completedAt)
- }
-
- return buildProgressStepMeta('已完成')
-}
-
-function resolveCurrentStepStartedAt(claim, label) {
- const stepLabel = normalizeText(label)
- if (stepLabel === RELATED_APPLICATION_STEP_LABEL || stepLabel === '创建单据' || stepLabel === '创建申请') {
- return claim?.created_at
- }
- if (stepLabel === '待提交') {
- const returnEvent = findLatestReturnEvent(claim)
- return returnEvent?.created_at || returnEvent?.createdAt || claim?.updated_at || claim?.created_at
- }
- if (stepLabel === '直属领导审批') {
- return claim?.submitted_at || claim?.updated_at || claim?.created_at
- }
- if (stepLabel === '预算管理者审批') {
- const leaderApprovalEvent = findApprovalEventForStep(claim, '直属领导审批')
- return leaderApprovalEvent?.created_at || leaderApprovalEvent?.createdAt || claim?.updated_at || claim?.submitted_at
- }
- if (stepLabel === '财务审批') {
- const leaderApprovalEvent = findApprovalEventForStep(claim, '直属领导审批')
- return leaderApprovalEvent?.created_at || leaderApprovalEvent?.createdAt || claim?.updated_at || claim?.submitted_at
- }
- if (stepLabel === '待付款') {
- const approvalEvent = findApprovalEventForStep(claim, '财务审批')
- return approvalEvent?.created_at || approvalEvent?.createdAt || claim?.updated_at || claim?.submitted_at
- }
- if (stepLabel === '已付款') {
- const paymentEvent = findLatestPaymentEvent(claim)
- return paymentEvent?.created_at || paymentEvent?.createdAt || claim?.updated_at || claim?.submitted_at
- }
- if (stepLabel === '归档入账' || stepLabel === ARCHIVED_STEP_LABEL || stepLabel === '审批完成') {
- return claim?.updated_at || claim?.submitted_at
- }
- return ''
-}
-
-function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}) {
- const documentTypeCode = String(options.documentTypeCode || '').trim()
- const hasApplicationReturnStep = (
- documentTypeCode === DOCUMENT_TYPE_APPLICATION
- && Boolean(findLatestApplicationReturnEvent(claim))
- && approvalMeta.key === 'supplement'
- )
- const hasMergedApplicationBudgetApproval = (
- documentTypeCode === DOCUMENT_TYPE_APPLICATION
- && Boolean(findMergedApplicationBudgetApprovalEvent(claim))
- )
- const shouldShowApplicationBudgetStep = (
- documentTypeCode === DOCUMENT_TYPE_APPLICATION
- && !hasMergedApplicationBudgetApproval
- && applicationRequiresBudgetReviewStep(claim, workflowNode)
- )
- const isApplicationDocument = documentTypeCode === DOCUMENT_TYPE_APPLICATION
- const applicationArchived = isApplicationDocument && isApplicationArchivedWorkflow(claim, workflowNode)
- const progressLabels =
- isApplicationDocument
- ? hasApplicationReturnStep
- ? ['创建申请', '直属领导审批', '退回', '待提交']
- : hasMergedApplicationBudgetApproval
- ? ['创建申请', '直属领导审批', APPLICATION_LINK_STATUS_STEP_LABEL, ARCHIVED_STEP_LABEL]
- : shouldShowApplicationBudgetStep
- ? APPLICATION_PROGRESS_LABELS
- : APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET
- : REIMBURSEMENT_PROGRESS_LABELS
- const applicationLinkIndex = progressLabels.indexOf(APPLICATION_LINK_STATUS_STEP_LABEL)
- const applicationArchiveIndex = progressLabels.indexOf(ARCHIVED_STEP_LABEL)
- const currentIndex =
- isApplicationDocument
- ? hasApplicationReturnStep
- ? 3
- : applicationArchived && applicationArchiveIndex >= 0
- ? applicationArchiveIndex
- : approvalMeta.key === 'completed' && applicationLinkIndex >= 0
- ? applicationLinkIndex
- : Math.min(
- resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode),
- Math.max(0, progressLabels.length - 1)
- )
- : resolveProgressCurrentIndex(approvalMeta, workflowNode)
- const currentTime =
- approvalMeta.key === 'completed'
- ? '已完成'
- : approvalMeta.key === 'pending_payment'
- ? '待付款'
- : approvalMeta.key === 'supplement'
- ? '待补充'
- : approvalMeta.key === 'rejected'
- ? '已退回'
- : '进行中'
-
- return progressLabels.map((label, index) => {
- const displayLabel = resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta)
- if (approvalMeta.key === 'completed' && (!isApplicationDocument || applicationArchived)) {
- const stepMeta = buildCompletedStepMeta(claim, label)
- return {
- index: index + 1,
- label: displayLabel,
- rawLabel: label,
- time: stepMeta.time,
- detail: stepMeta.detail,
- title: stepMeta.title,
- done: true,
- active: true,
- current: false
- }
- }
-
- if (index < currentIndex) {
- const stepMeta = buildCompletedStepMeta(claim, label)
- return {
- index: index + 1,
- label: displayLabel,
- rawLabel: label,
- time: stepMeta.time,
- detail: stepMeta.detail,
- title: stepMeta.title,
- done: true,
- active: true,
- current: false
- }
- }
-
- if (index === currentIndex) {
- if (isApplicationDocument && label === APPLICATION_LINK_STATUS_STEP_LABEL) {
- const stepMeta = buildApplicationLinkStatusStepMeta(claim)
- return {
- index: index + 1,
- label: displayLabel,
- rawLabel: label,
- time: stepMeta.time,
- detail: stepMeta.detail,
- title: stepMeta.title,
- done: false,
- active: true,
- current: true
- }
- }
- const stayDuration = formatDurationFrom(resolveCurrentStepStartedAt(claim, label))
- return {
- index: index + 1,
- label: displayLabel,
- rawLabel: label,
- time: stayDuration ? `停留 ${stayDuration}` : currentTime,
- detail: '',
- title: stayDuration ? `当前${displayLabel}已停留 ${stayDuration}` : currentTime,
- done: false,
- active: true,
- current: true
- }
- }
-
- return {
- index: index + 1,
- label: displayLabel,
- rawLabel: label,
- time: '待处理',
- detail: '',
- title: '待处理',
- done: false,
- active: false,
- current: false
- }
- })
-}
-
-function buildExpenseItems(claim, riskMeta) {
- if (!Array.isArray(claim?.items)) {
- return []
- }
-
- const normalizedRiskMeta = typeof riskMeta === 'string'
- ? { summary: riskMeta, tone: riskMeta === '无' ? 'low' : 'medium', label: riskMeta === '无' ? '无' : '待关注' }
- : {
- summary: String(riskMeta?.summary || '无').trim() || '无',
- tone: String(riskMeta?.tone || 'low').trim() || 'low',
- label: String(riskMeta?.label || '').trim() || (String(riskMeta?.summary || '').trim() === '无' ? '无' : '待关注')
- }
-
- const visibleItems = filterVisibleExpenseRawItems(claim.items, claim)
- const sortedItems = [...visibleItems].sort((left, right) => {
- const leftType = normalizeExpenseType(left?.item_type)
- const rightType = normalizeExpenseType(right?.item_type)
- return Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(leftType)) - Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(rightType))
- })
- const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, claim)
- const standardAdjustmentMap = buildStandardAdjustmentMapFromClaim(claim)
-
- return sortedItems.map((item, index) => {
- const invoiceId = String(item?.invoice_id || '').trim()
- const attachmentName = resolveAttachmentDisplayName(invoiceId)
- const attachments = invoiceId ? [attachmentName || invoiceId] : []
- const itemType = normalizeExpenseType(item?.item_type || claim?.expense_type)
- const isSystemGenerated = Boolean(item?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
- const id = resolveExpenseItemViewId(item, index, claim)
- const itemTypeLabel = resolveTypeLabel(itemType)
- const itemLocation = String(item?.item_location || '').trim()
- const itemReason = String(item?.item_reason || '').trim()
- const itemNote = String(item?.item_note || item?.itemNote || '').trim()
- const itemAmount = parseNumber(item?.item_amount)
- const itemAmountDisplay = itemAmount > 0 ? formatAmount(itemAmount) : '待补充'
- const standardAdjustment = standardAdjustmentMap.get(id) || null
- const originalItemAmount = standardAdjustment?.originalAmount ?? itemAmount
- const reimbursableAmount = standardAdjustment?.reimbursableAmount ?? itemAmount
- const employeeAbsorbedAmount = standardAdjustment?.employeeAbsorbedAmount || Math.max(originalItemAmount - reimbursableAmount, 0)
-
- return {
- id,
- time: formatDate(item?.item_date) || '待补充',
- itemDate: formatDate(item?.item_date) || '',
- filledAt: formatDateTime(item?.created_at) || '待同步',
- itemType,
- itemReason,
- itemLocation,
- itemNote,
- itemAmount,
- originalItemAmount,
- originalAmountDisplay: originalItemAmount > 0 ? formatAmount(originalItemAmount) : itemAmountDisplay,
- reimbursableAmount,
- reimbursableAmountDisplay: reimbursableAmount > 0 ? formatAmount(reimbursableAmount) : '待补充',
- employeeAbsorbedAmount,
- employeeAbsorbedAmountDisplay: employeeAbsorbedAmount > 0 ? formatAmount(employeeAbsorbedAmount) : '',
- hasStandardAdjustment: reimbursableAmount >= 0 && reimbursableAmount < originalItemAmount,
- standardAdjustmentAccepted: Boolean(standardAdjustment),
- standardAdjustmentMessage: standardAdjustment?.message || '',
- invoiceId,
- isSystemGenerated,
- dayLabel: resolveExpenseTimeLabel({
- id,
- itemType,
- isSystemGenerated,
- claim,
- travelTimeLabelMap
- }),
- name: itemTypeLabel,
- category: itemTypeLabel,
- desc: itemReason || '待补充',
- detail: resolveExpenseDescriptionDetail(itemType, itemLocation),
- amount: itemAmountDisplay,
- status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充',
- tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad',
- attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
- attachmentHint: isSystemGenerated ? '根据出差天数与职级自动测算' : attachments.length ? attachments[0] : '仅支持上传 1 张 JPG、PNG、PDF 单据',
- attachmentTone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'missing',
- attachments,
- riskLabel: normalizedRiskMeta.summary === '无' ? '无' : normalizedRiskMeta.label,
- riskText: normalizedRiskMeta.summary === '无' ? '' : normalizedRiskMeta.summary,
- riskTone: normalizedRiskMeta.summary === '无' ? 'low' : normalizedRiskMeta.tone
- }
- })
-}
-
-export function mapExpenseClaimToRequest(claim) {
- const typeCode = String(claim?.expense_type || '').trim() || 'other'
- const typeLabel = resolveTypeLabel(typeCode)
- const documentTypeMeta = resolveDocumentTypeMeta(claim, typeCode)
- const isApplicationDocument = documentTypeMeta.documentTypeCode === DOCUMENT_TYPE_APPLICATION
- const approvalMeta = resolveApprovalMeta(claim?.status)
- const workflowNode = resolveWorkflowNode(claim, approvalMeta, isApplicationDocument)
- const applicationArchived = isApplicationDocument && isApplicationArchivedWorkflow(claim, workflowNode)
- const applicationLinkedReimbursementNo = isApplicationDocument ? resolveApplicationLinkedReimbursementNo(claim) : ''
- const applicationLinkStatusText = applicationLinkedReimbursementNo
- ? `关联中 ${applicationLinkedReimbursementNo}`
- : '未关联'
- const invoiceCount = Math.max(0, parseNumber(claim?.invoice_count))
- const riskMeta = buildRiskMeta(claim?.risk_flags_json)
- const riskSummary = riskMeta.summary
- const relatedApplication = isApplicationDocument ? null : resolveRelatedApplicationInfo(claim, typeLabel)
- const expenseItems = buildExpenseItems(claim, riskMeta)
- const visibleExpenseAmount = expenseItems.reduce((sum, item) => {
- const amount = parseOptionalAmount(item.reimbursableAmount) ?? parseNumber(item.itemAmount)
- return sum + amount
- }, 0)
- const amountValue = relatedApplication
- ? expenseItems.length
- ? visibleExpenseAmount
- : invoiceCount === 0
- ? 0
- : parseNumber(claim?.amount)
- : parseNumber(claim?.amount)
- const applyDateTime = claim?.submitted_at || claim?.created_at
- const employeeId = String(claim?.employee_id || claim?.employeeId || '').trim()
- const employeeName = String(claim?.employee_name || claim?.employeeName || '').trim()
-
- return {
- id: String(claim?.claim_no || claim?.id || '').trim(),
- claimNo: String(claim?.claim_no || claim?.id || '').trim(),
- claimId: String(claim?.id || '').trim(),
- status: String(claim?.status || '').trim(),
- employeeId,
- employee_id: employeeId,
- profileEmployeeId: employeeId || employeeName,
- person: String(claim?.employee_name || '').trim() || '待补充',
- dept: String(claim?.department_name || '').trim() || '待补充',
- departmentName: String(claim?.department_name || '').trim() || '待补充',
- employeeName: String(claim?.employee_name || '').trim() || '待补充',
- employeePosition: String(claim?.employee_position || '').trim(),
- employeeGrade: String(claim?.employee_grade || '').trim(),
- managerName: resolveDisplayName(claim?.manager_name),
- financeApproverName: resolveDisplayName(claim?.finance_approver_name, claim?.financeApproverName),
- financeOwnerName: resolveDisplayName(claim?.finance_owner_name, claim?.financeOwnerName),
- budgetApproverName: resolveDisplayName(claim?.budget_approver_name, claim?.budgetApproverName),
- budgetApproverGrade: String(claim?.budget_approver_grade || claim?.budgetApproverGrade || '').trim(),
- budgetApproverRoleCode: String(claim?.budget_approver_role_code || claim?.budgetApproverRoleCode || '').trim(),
- roleLabels: Array.isArray(claim?.role_labels) ? claim.role_labels.filter(Boolean) : [],
- entity: '',
- typeCode,
- typeLabel,
- ...documentTypeMeta,
- detailVariant: typeCode === 'travel' || typeCode === 'travel_application' ? 'travel' : 'general',
- title: String(claim?.reason || '').trim() || (isApplicationDocument ? typeLabel : `${typeLabel}报销`),
- sceneLabel: typeLabel,
- sceneTarget: String(claim?.location || '').trim() || '待补充',
- location: String(claim?.location || '').trim() || '待补充',
- relatedCustomer: '',
- occurredDisplay: buildOccurredDisplay(claim),
- occurredAt: claim?.occurred_at || '',
- applyTime: formatDateTime(applyDateTime) || '待补充',
- submittedAt: applyDateTime || '',
- createdAt: claim?.created_at || '',
- updatedAt: claim?.updated_at || '',
- amount: amountValue,
- riskFlags: Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : [],
- riskTone: riskMeta.tone,
- riskLabel: riskMeta.label,
- invoiceCount,
- workflowNode,
- approvalKey: approvalMeta.key,
- approvalStatus: approvalMeta.label,
- approvalTone: approvalMeta.tone,
- secondaryStatusLabel: isApplicationDocument ? '申请材料' : (typeCode === 'travel' ? '行程状态' : '票据状态'),
- secondaryStatusValue: isApplicationDocument
- ? approvalMeta.key === 'supplement'
- ? '领导已退回,待重新提交'
- : applicationArchived
- ? '已归档'
- : approvalMeta.key === 'completed'
- ? applicationLinkStatusText
- : '已进入审批流程'
- : (invoiceCount > 0 ? `已关联 ${invoiceCount} 张票据` : '待上传票据'),
- secondaryStatusTone: isApplicationDocument
- ? approvalMeta.key === 'supplement'
- ? 'warning'
- : approvalMeta.key === 'completed' && !applicationArchived && !applicationLinkedReimbursementNo
- ? 'warning'
- : 'success'
- : (invoiceCount > 0 ? 'success' : 'warning'),
- riskSummary,
- attachmentSummary: isApplicationDocument ? '申请单' : (invoiceCount > 0 ? `${invoiceCount} 张票据` : '无'),
- expenseTableSummary: isApplicationDocument
- ? '预计金额已随申请提交'
- : expenseItems.length
- ? (invoiceCount > 0
- ? `共 ${expenseItems.length} 条费用明细,已关联 ${invoiceCount} 张票据`
- : `共 ${expenseItems.length} 条费用明细,待补充票据`)
- : '暂无费用明细',
- note: String(claim?.reason || '').trim(),
- relatedApplication,
- progressSteps: buildProgressSteps(approvalMeta, workflowNode, claim, {
- documentTypeCode: documentTypeMeta.documentTypeCode
- }),
- expenseItems
- }
-}
+export { mapExpenseClaimToRequest } from './requests/requestClaimMapper.js'
function getWeekStart(date) {
const nextDate = new Date(date)
diff --git a/web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js b/web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js
new file mode 100644
index 0000000..6915818
--- /dev/null
+++ b/web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js
@@ -0,0 +1,787 @@
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+
+import { useSystemState } from '../useSystemState.js'
+import { useToast } from '../useToast.js'
+import { useWorkbenchComposerDate } from '../useWorkbenchComposerDate.js'
+import { fetchSettings } from '../../services/settings.js'
+import { calculateTravelReimbursement } from '../../services/reimbursements.js'
+import { useApplicationPreviewEditor } from '../../views/scripts/useApplicationPreviewEditor.js'
+import {
+ deleteAiWorkbenchConversation,
+ loadAiWorkbenchConversationHistory,
+ saveAiWorkbenchConversation
+} from '../../utils/aiWorkbenchConversationStore.js'
+import { renderAiConversationHtml } from '../../utils/aiConversationHtmlRenderer.js'
+import {
+ buildAiDocumentDetailRequest,
+ parseAiApplicationDetailHref,
+ parseAiDocumentDetailHref
+} from '../../utils/aiDocumentDetailReference.js'
+import {
+ AI_MODE_ACTION_ITEMS,
+ buildSelectedFileCards,
+ shouldRunAiAttachmentAutoAssociation
+} from './workbenchAiComposerModel.js'
+import {
+ createWorkbenchAiMessageRuntime,
+ formatMessageTime,
+ normalizeInlineAttachmentOcrDetails
+} from './workbenchAiMessageModel.js'
+import { useWorkbenchAiActionRouter } from './useWorkbenchAiActionRouter.js'
+import { useWorkbenchAiAttachmentAssociationFlow } from './useWorkbenchAiAttachmentAssociationFlow.js'
+import { useWorkbenchAiApplicationPreviewFlow } from './useWorkbenchAiApplicationPreviewFlow.js'
+import { useWorkbenchAiComposerFiles } from './useWorkbenchAiComposerFiles.js'
+import { useWorkbenchAiDocumentQueryFlow } from './useWorkbenchAiDocumentQueryFlow.js'
+import { useWorkbenchAiExpenseFlow } from './useWorkbenchAiExpenseFlow.js'
+import { useWorkbenchAiMessageActions } from './useWorkbenchAiMessageActions.js'
+import { useWorkbenchAiSessionCommands } from './useWorkbenchAiSessionCommands.js'
+import { useWorkbenchAiStewardFlow } from './useWorkbenchAiStewardFlow.js'
+
+const AI_SEARCH_CONVERSATION_ID = 'ai-search'
+const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6
+const INLINE_ANSWER_STREAM_DELAY_MS = 24
+const INLINE_AUTO_SCROLL_THRESHOLD = 96
+const INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS = 260
+
+export function usePersonalWorkbenchAiMode(props, emit) {
+ const { currentUser } = useSystemState()
+ const { toast } = useToast()
+ const assistantDraft = ref('')
+ const assistantInputRef = ref(null)
+ const fileInputRef = ref(null)
+ const conversationScrollRef = ref(null)
+ const inlineConversationAutoScrollPinned = ref(true)
+ const selectedFiles = ref([])
+ const systemSettings = ref(null)
+ const conversationStarted = ref(false)
+ const conversationMessages = ref([])
+ const conversationId = ref('')
+ const activeConversationTitle = ref('')
+ const sending = ref(false)
+ const stewardState = ref(null)
+ const aiExpenseDraft = ref(null)
+ const thinkingExpandedMessageIds = ref(new Set())
+ const thinkingCollapsedMessageIds = ref(new Set())
+ const attachmentOcrExpandedMessageIds = ref(new Set())
+ const deleteDialogOpen = ref(false)
+ const applicationSubmitConfirmOpen = ref(false)
+ const applicationSubmitConfirmContext = ref(null)
+ const aiAttachmentAssociationRuntime = new Map()
+ const messageRuntime = createWorkbenchAiMessageRuntime()
+ const {
+ createAiAttachmentAssociationId,
+ createInlineMessage,
+ normalizeRuntimeMessage,
+ serializeRuntimeMessage
+ } = messageRuntime
+
+ const {
+ applicationPreviewEditor,
+ resolveApplicationPreviewEditorControl,
+ resolveApplicationPreviewEditorOptions,
+ refreshApplicationPreviewEstimate,
+ isApplicationPreviewEditing,
+ openApplicationPreviewEditor,
+ commitApplicationPreviewEditor,
+ cancelApplicationPreviewEditor,
+ handleApplicationPreviewEditorKeydown
+ } = useApplicationPreviewEditor({
+ persistSessionState: () => persistCurrentConversation(),
+ toast,
+ calculateTravelReimbursement,
+ currentUser
+ })
+
+ const {
+ workbenchDatePickerOpen,
+ workbenchDateMode,
+ workbenchSingleDate,
+ workbenchRangeStartDate,
+ workbenchRangeEndDate,
+ workbenchDateTagLabel,
+ workbenchCanApplyDateSelection,
+ clearWorkbenchDateSelection,
+ toggleWorkbenchDatePicker,
+ closeWorkbenchDatePicker,
+ setWorkbenchDateMode,
+ handleWorkbenchDatePickerOutside,
+ applyWorkbenchDateSelection,
+ handleWorkbenchDateInputChange,
+ removeWorkbenchDateTag,
+ buildWorkbenchPromptText
+ } = useWorkbenchComposerDate({ draft: assistantDraft, focusInput: focusAiModeInput })
+
+ const aiModeActionItems = AI_MODE_ACTION_ITEMS
+ const selectedFileCards = computed(() => buildSelectedFileCards(selectedFiles.value))
+ const displayUserName = computed(() => {
+ const user = currentUser.value || {}
+ return String(user.name || user.username || '同事').trim() || '同事'
+ })
+ const displayModelName = computed(() => {
+ const llmForm = systemSettings.value?.llmForm
+ if (!llmForm) return 'Axiom Ultra 3.1'
+ const model = llmForm.mainModel || ''
+ const provider = llmForm.mainProvider || ''
+ if (!model) return 'Axiom Ultra 3.1'
+ return provider ? `${provider} / ${model}` : model
+ })
+ const modelSelectorTitle = computed(() => {
+ const llmForm = systemSettings.value?.llmForm
+ if (!llmForm) return '当前模型:Axiom Ultra 3.1'
+ const model = llmForm.mainModel || 'Axiom Ultra 3.1'
+ const provider = llmForm.mainProvider || ''
+ return provider ? `当前模型:${provider} / ${model}` : `当前模型:${model}`
+ })
+
+ const filesFlow = useWorkbenchAiComposerFiles({
+ fileInputRef,
+ focusAiModeInput,
+ isInputLocked: () => isAiModeInputLocked.value,
+ selectedFiles,
+ toast
+ })
+
+ const documentQueryFlow = useWorkbenchAiDocumentQueryFlow({
+ conversationMessages,
+ createInlineMessage,
+ inlineConversationAutoScrollPinned,
+ persistCurrentConversation,
+ replaceInlineMessage,
+ scrollInlineConversationToBottom
+ })
+
+ const attachmentFlow = useWorkbenchAiAttachmentAssociationFlow({
+ aiAttachmentAssociationRuntime,
+ conversationMessages,
+ createAiAttachmentAssociationId,
+ createInlineMessage,
+ inlineConversationAutoScrollPinned,
+ persistCurrentConversation,
+ replaceInlineMessage,
+ scrollInlineConversationToBottom,
+ sending,
+ streamOrSetInlineAssistantContent,
+ toast
+ })
+
+ const applicationFlow = useWorkbenchAiApplicationPreviewFlow({
+ activateInlineConversation,
+ applicationPreviewEditor,
+ applicationSubmitConfirmContext,
+ applicationSubmitConfirmOpen,
+ assistantDraft,
+ cancelApplicationPreviewEditor,
+ clearAiModeFiles: filesFlow.clearAiModeFiles,
+ closeWorkbenchDatePicker,
+ commitApplicationPreviewEditor,
+ conversationId,
+ conversationMessages,
+ conversationStarted,
+ createInlineMessage,
+ currentUser,
+ handleApplicationPreviewEditorKeydown,
+ inlineConversationAutoScrollPinned,
+ isApplicationPreviewEditing,
+ openApplicationPreviewEditor,
+ persistCurrentConversation,
+ pushInlineApplicationActionUserMessage,
+ pushInlineUserMessage,
+ refreshApplicationPreviewEstimate,
+ removeWorkbenchDateTag,
+ replaceInlineMessage,
+ resolveApplicationPreviewEditorControl,
+ resolveApplicationPreviewEditorOptions,
+ resolveInlineThinkingEvents,
+ resolveLatestInlineUserPrompt,
+ scrollInlineConversationToBottom,
+ sending,
+ toast
+ })
+
+ const expenseFlow = useWorkbenchAiExpenseFlow({
+ activateInlineConversation,
+ aiExpenseDraft,
+ assistantDraft,
+ clearAiModeFiles: filesFlow.clearAiModeFiles,
+ closeWorkbenchDatePicker,
+ conversationMessages,
+ conversationStarted,
+ createInlineMessage,
+ currentUser,
+ persistCurrentConversation,
+ pushInlineUserMessage,
+ removeWorkbenchDateTag,
+ resolveLatestInlineUserPrompt,
+ scrollInlineConversationToBottom,
+ startAiApplicationPreview: applicationFlow.startAiApplicationPreview
+ })
+
+ const actionRouter = useWorkbenchAiActionRouter({
+ aiExpenseDraft,
+ applicationFlow,
+ assistantDraft,
+ attachmentFlow,
+ emit,
+ expenseFlow,
+ focusAiModeInput,
+ hasInlineAttachmentOcrDetails,
+ resolveLatestInlineUserPrompt,
+ selectedFiles,
+ startInlineConversation,
+ toast,
+ toggleInlineAttachmentOcrDetails
+ })
+
+ const sessionCommands = useWorkbenchAiSessionCommands({
+ activeConversationTitle,
+ attachmentOcrExpandedMessageIds,
+ conversationId,
+ conversationMessages,
+ conversationStarted,
+ createInlineMessage,
+ currentUser,
+ deleteAiWorkbenchConversation,
+ emit,
+ focusAiModeInput,
+ inlineConversationAutoScrollPinned,
+ normalizeRuntimeMessage,
+ refreshConversationHistory,
+ resetInlineConversationState,
+ scrollInlineConversationToBottom,
+ stewardState,
+ thinkingCollapsedMessageIds,
+ thinkingExpandedMessageIds,
+ toast
+ })
+
+ const messageActions = useWorkbenchAiMessageActions({
+ assistantDraft,
+ focusAiModeInput,
+ persistCurrentConversation,
+ toast
+ })
+
+ const stewardFlow = useWorkbenchAiStewardFlow({
+ activeConversationTitle,
+ collectAiModeReceiptContext: attachmentFlow.collectAiModeReceiptContext,
+ conversationId,
+ conversationMessages,
+ createInlineMessage,
+ currentUser,
+ deleteAiWorkbenchConversation,
+ emit,
+ handleAiDocumentQueryIntent: documentQueryFlow.handleAiDocumentQueryIntent,
+ inlineConversationAutoScrollPinned,
+ persistCurrentConversation,
+ replaceInlineMessage,
+ resolveInlineThinkingEvents,
+ scrollInlineConversationToBottom,
+ sending,
+ stewardState,
+ streamInlineAssistantContent,
+ updateInlineMessageContent,
+ appendInlineMessageContent,
+ toast
+ })
+
+ const applicationPreviewEstimatePending = computed(() => (
+ conversationMessages.value.some((message) => applicationFlow.isApplicationPreviewEstimatePending(message))
+ ))
+ const isAiModeInputLocked = computed(() => applicationPreviewEstimatePending.value)
+ const canSubmitAiModePrompt = computed(() => (
+ !isAiModeInputLocked.value && (
+ Boolean(assistantDraft.value.trim()) ||
+ selectedFiles.value.length > 0 ||
+ Boolean(workbenchDateTagLabel.value)
+ )
+ ))
+
+ async function loadSystemSettings() {
+ try {
+ systemSettings.value = await fetchSettings()
+ } catch {
+ systemSettings.value = { llmForm: {} }
+ }
+ }
+
+ function focusAiModeInput() {
+ nextTick(() => {
+ assistantInputRef.value?.focus()
+ })
+ }
+
+ function isInlineConversationNearBottom() {
+ const el = conversationScrollRef.value
+ if (!el) {
+ return true
+ }
+ return el.scrollHeight - el.clientHeight - el.scrollTop <= INLINE_AUTO_SCROLL_THRESHOLD
+ }
+
+ function handleInlineConversationScroll() {
+ inlineConversationAutoScrollPinned.value = isInlineConversationNearBottom()
+ }
+
+ function forceInlineConversationToBottom() {
+ const el = conversationScrollRef.value
+ if (el) {
+ el.scrollTop = el.scrollHeight
+ inlineConversationAutoScrollPinned.value = true
+ }
+ }
+
+ function scrollInlineConversationToBottom(options = {}) {
+ const shouldScroll = options.force !== false
+ nextTick(() => {
+ if (!shouldScroll) {
+ return
+ }
+ forceInlineConversationToBottom()
+ window.requestAnimationFrame(() => {
+ forceInlineConversationToBottom()
+ })
+ window.setTimeout(() => {
+ if (inlineConversationAutoScrollPinned.value) {
+ forceInlineConversationToBottom()
+ }
+ }, INLINE_LAYOUT_SETTLE_SCROLL_DELAY_MS)
+ })
+ }
+
+ function scrollInlineConversationToTop() {
+ nextTick(() => {
+ const el = conversationScrollRef.value
+ if (el) {
+ inlineConversationAutoScrollPinned.value = false
+ el.scrollTo({ top: 0, behavior: 'smooth' })
+ }
+ })
+ }
+
+ function updateInlineMessageContent(message, content) {
+ if (!message) {
+ return
+ }
+ message.content = String(content || '')
+ message.paragraphs = String(message.content || '')
+ .split(/\n{2,}|\n/)
+ .map((item) => item.trim())
+ .filter(Boolean)
+ }
+
+ function appendInlineMessageContent(message, delta) {
+ const nextDelta = String(delta || '')
+ if (!nextDelta) {
+ return
+ }
+ updateInlineMessageContent(message, `${message.content || ''}${nextDelta}`)
+ }
+
+ function waitInlineAnswerStreamFrame() {
+ return new Promise((resolve) => {
+ window.setTimeout(resolve, INLINE_ANSWER_STREAM_DELAY_MS)
+ })
+ }
+
+ async function streamInlineAssistantContent(messageId, content) {
+ const targetContent = String(content || '').trim()
+ let streamedContent = ''
+
+ for (let index = 0; index < targetContent.length; index += INLINE_ANSWER_STREAM_CHUNK_SIZE) {
+ const message = conversationMessages.value.find((item) => item.id === messageId)
+ if (!message || !message.pending) {
+ return
+ }
+ const shouldAutoScroll = inlineConversationAutoScrollPinned.value
+ streamedContent += targetContent.slice(index, index + INLINE_ANSWER_STREAM_CHUNK_SIZE)
+ updateInlineMessageContent(message, streamedContent)
+ scrollInlineConversationToBottom({ force: shouldAutoScroll })
+ await waitInlineAnswerStreamFrame()
+ }
+ }
+
+ async function streamOrSetInlineAssistantContent(messageId, content) {
+ const targetContent = String(content || '').trim()
+ if (//.test(targetContent)) {
+ const message = conversationMessages.value.find((item) => item.id === messageId)
+ if (message?.pending) {
+ updateInlineMessageContent(message, targetContent)
+ scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
+ }
+ return
+ }
+ await streamInlineAssistantContent(messageId, targetContent)
+ }
+
+ function refreshConversationHistory() {
+ const history = loadAiWorkbenchConversationHistory(currentUser.value || {})
+ emit('conversation-history-change', history)
+ return history
+ }
+
+ function isPersistableInlineConversation() {
+ return Boolean(
+ conversationId.value &&
+ conversationId.value !== AI_SEARCH_CONVERSATION_ID &&
+ conversationMessages.value.length
+ )
+ }
+
+ function persistCurrentConversation() {
+ if (!isPersistableInlineConversation()) {
+ refreshConversationHistory()
+ return []
+ }
+
+ const history = saveAiWorkbenchConversation(currentUser.value || {}, {
+ id: conversationId.value,
+ conversationId: conversationId.value,
+ title: activeConversationTitle.value,
+ source: 'workbench',
+ sessionType: 'steward',
+ stewardState: stewardState.value,
+ messages: conversationMessages.value.map((message) => serializeRuntimeMessage(message))
+ })
+ emit('conversation-history-change', history)
+ return history
+ }
+
+ function resetInlineConversationState() {
+ conversationStarted.value = false
+ conversationMessages.value = []
+ conversationId.value = ''
+ stewardState.value = null
+ activeConversationTitle.value = ''
+ assistantDraft.value = ''
+ thinkingExpandedMessageIds.value = new Set()
+ thinkingCollapsedMessageIds.value = new Set()
+ attachmentOcrExpandedMessageIds.value = new Set()
+ deleteDialogOpen.value = false
+ applicationSubmitConfirmOpen.value = false
+ applicationSubmitConfirmContext.value = null
+ clearWorkbenchDateSelection()
+ filesFlow.clearAiModeFiles()
+ }
+
+ function replaceInlineMessage(id, nextMessage) {
+ const index = conversationMessages.value.findIndex((item) => item.id === id)
+ if (index === -1) {
+ conversationMessages.value.push(nextMessage)
+ return
+ }
+ conversationMessages.value.splice(index, 1, nextMessage)
+ }
+
+ function activateInlineConversation(options = {}) {
+ conversationStarted.value = true
+ if (!conversationId.value) {
+ conversationId.value = options.id || `inline-${Date.now()}`
+ }
+ activeConversationTitle.value = options.title || activeConversationTitle.value || '新对话'
+ emit('conversation-change', { id: conversationId.value, title: activeConversationTitle.value })
+ }
+
+ function renderInlineConversationHtml(content) {
+ return renderAiConversationHtml(content)
+ }
+
+ function resolveInlineThinkingEvents(message) {
+ return Array.isArray(message?.stewardPlan?.thinkingEvents) ? message.stewardPlan.thinkingEvents : []
+ }
+
+ function hasInlineThinking(message) {
+ return resolveInlineThinkingEvents(message).length > 0
+ }
+
+ function isInlineThinkingExpanded(message) {
+ if (!message?.id) {
+ return Boolean(message?.pending)
+ }
+ if (thinkingCollapsedMessageIds.value.has(message.id)) {
+ return false
+ }
+ return Boolean(message.pending || thinkingExpandedMessageIds.value.has(message.id))
+ }
+
+ function toggleInlineThinking(message) {
+ if (!message?.id) {
+ return
+ }
+ const nextExpandedIds = new Set(thinkingExpandedMessageIds.value)
+ const nextCollapsedIds = new Set(thinkingCollapsedMessageIds.value)
+ if (isInlineThinkingExpanded(message)) {
+ nextExpandedIds.delete(message.id)
+ nextCollapsedIds.add(message.id)
+ } else {
+ nextCollapsedIds.delete(message.id)
+ nextExpandedIds.add(message.id)
+ }
+ thinkingExpandedMessageIds.value = nextExpandedIds
+ thinkingCollapsedMessageIds.value = nextCollapsedIds
+ }
+
+ function hasInlineAttachmentOcrDetails(message = {}) {
+ const details = normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)
+ return Boolean(details?.documents?.length || details?.fileNames?.length)
+ }
+
+ function resolveInlineAttachmentOcrDocuments(message = {}) {
+ return normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)?.documents || []
+ }
+
+ function resolveInlineAttachmentOcrFileCount(message = {}) {
+ const details = normalizeInlineAttachmentOcrDetails(message?.attachmentOcrDetails || null)
+ return Math.max(details?.documents?.length || 0, details?.fileNames?.length || 0)
+ }
+
+ function isInlineAttachmentOcrExpanded(message = {}) {
+ return Boolean(message?.id && attachmentOcrExpandedMessageIds.value.has(message.id))
+ }
+
+ function toggleInlineAttachmentOcrDetails(message = {}, forceExpanded = null) {
+ if (!message?.id || !hasInlineAttachmentOcrDetails(message)) {
+ return
+ }
+ const nextExpandedIds = new Set(attachmentOcrExpandedMessageIds.value)
+ const shouldExpand = forceExpanded === null
+ ? !nextExpandedIds.has(message.id)
+ : Boolean(forceExpanded)
+ if (shouldExpand) {
+ nextExpandedIds.add(message.id)
+ } else {
+ nextExpandedIds.delete(message.id)
+ }
+ attachmentOcrExpandedMessageIds.value = nextExpandedIds
+ nextTick(() => {
+ scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
+ })
+ }
+
+ function buildInlinePromptText(rawPrompt, files = []) {
+ const prompt = buildWorkbenchPromptText(rawPrompt)
+ if (prompt) {
+ return prompt
+ }
+ return files.length ? '请帮我处理已上传的附件。' : ''
+ }
+
+ function handleAiAnswerMarkdownClick(event) {
+ const target = event?.target
+ const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]')
+ if (!link) {
+ return
+ }
+ const href = link.getAttribute('href')
+ const detailReference = parseAiDocumentDetailHref(href) || parseAiApplicationDetailHref(href)
+ if (!detailReference) {
+ return
+ }
+ event.preventDefault()
+ event.stopPropagation()
+ emit('open-document', buildAiDocumentDetailRequest(detailReference))
+ }
+
+ function startInlineConversation(prompt, entry = {}, files = []) {
+ if (isAiModeInputLocked.value) {
+ toast('请等待费用测算完成后再继续操作。')
+ return
+ }
+ const cleanPrompt = buildInlinePromptText(prompt, files)
+ if (!cleanPrompt || sending.value) {
+ return
+ }
+
+ if (aiExpenseDraft.value && !isAiExpenseDraftComplete(aiExpenseDraft.value)) {
+ expenseFlow.advanceAiExpenseDraft(cleanPrompt, files)
+ return
+ }
+
+ if (applicationFlow.handleInlineApplicationPreviewTextAction(cleanPrompt, applicationPreviewEstimatePending)) {
+ return
+ }
+
+ if (conversationId.value === AI_SEARCH_CONVERSATION_ID) {
+ conversationId.value = ''
+ conversationMessages.value = []
+ activeConversationTitle.value = ''
+ }
+
+ sending.value = true
+ activateInlineConversation({
+ title: entry.label || cleanPrompt.slice(0, 18) || '新对话'
+ })
+ inlineConversationAutoScrollPinned.value = true
+ conversationMessages.value.push(createInlineMessage('user', cleanPrompt))
+ assistantDraft.value = ''
+ removeWorkbenchDateTag()
+ closeWorkbenchDatePicker()
+ filesFlow.clearAiModeFiles()
+ scrollInlineConversationToBottom()
+ persistCurrentConversation()
+ if (shouldRunAiAttachmentAutoAssociation(entry, files, cleanPrompt)) {
+ void attachmentFlow.requestAiAttachmentAssociationReply(cleanPrompt, entry, files)
+ return
+ }
+ void stewardFlow.requestInlineAssistantReply(cleanPrompt, entry, files)
+ }
+
+ function submitAiModePrompt() {
+ if (!canSubmitAiModePrompt.value) {
+ toast('请输入需求后再发送。')
+ focusAiModeInput()
+ return
+ }
+ startInlineConversation(assistantDraft.value, { source: 'workbench', sessionType: 'steward' }, Array.from(selectedFiles.value))
+ }
+
+ function runAiModeAction(item) {
+ if (String(item?.label || '').trim() === '发起报销') {
+ expenseFlow.pushInlineExpenseSceneSelectionPrompt(item.prompt, item.label)
+ return
+ }
+ startInlineConversation(item.prompt, item, Array.from(selectedFiles.value))
+ }
+
+ function regenerateLastReply() {
+ const lastUserMessage = [...conversationMessages.value].reverse().find((message) => message.role === 'user')
+ if (!lastUserMessage || sending.value) {
+ return
+ }
+ const lastAssistantIndex = conversationMessages.value.map((message) => message.role).lastIndexOf('assistant')
+ if (lastAssistantIndex >= 0) {
+ conversationMessages.value.splice(lastAssistantIndex, 1)
+ }
+ sending.value = true
+ void stewardFlow.requestInlineAssistantReply(lastUserMessage.content, { source: 'workbench', sessionType: 'steward' }, [])
+ }
+
+ function pushInlineUserMessage(text) {
+ conversationMessages.value.push(createInlineMessage('user', String(text || '').trim()))
+ }
+
+ function pushInlineApplicationActionUserMessage(text) {
+ pushInlineUserMessage(text)
+ assistantDraft.value = ''
+ removeWorkbenchDateTag()
+ closeWorkbenchDatePicker()
+ filesFlow.clearAiModeFiles()
+ }
+
+ function resolveLatestInlineUserPrompt() {
+ const latestUserMessage = [...conversationMessages.value].reverse().find((message) => message.role === 'user')
+ return String(latestUserMessage?.content || '').trim()
+ }
+
+ function handleVoiceInput() {
+ if (isAiModeInputLocked.value) {
+ toast('请等待费用测算完成后再继续操作。')
+ return
+ }
+ toast('语音输入正在准备中,您可以先输入文字需求。')
+ focusAiModeInput()
+ }
+
+ watch(
+ () => props.sidebarCommand?.seq,
+ () => {
+ const command = props.sidebarCommand || {}
+ if (command.type === 'new-chat') {
+ sessionCommands.startNewInlineConversation()
+ return
+ }
+ if (command.type === 'search-chat') {
+ sessionCommands.openInlineSearchConversation(activateInlineConversation)
+ return
+ }
+ if (command.type === 'open-recent') {
+ sessionCommands.openInlineRecentConversation(command.payload || {})
+ }
+ }
+ )
+
+ onMounted(() => {
+ loadSystemSettings()
+ refreshConversationHistory()
+ document.addEventListener('click', handleWorkbenchDatePickerOutside)
+ })
+
+ onBeforeUnmount(() => {
+ document.removeEventListener('click', handleWorkbenchDatePickerOutside)
+ })
+
+ return {
+ activeConversationTitle,
+ aiModeActionItems,
+ applicationPreviewEditor,
+ applicationSubmitConfirmOpen,
+ assistantInputRef,
+ assistantDraft,
+ canShowInlineSuggestedActions: applicationFlow.canShowInlineSuggestedActions,
+ canSubmitAiModePrompt,
+ cancelDeleteConversation: () => sessionCommands.cancelDeleteConversation(deleteDialogOpen),
+ cancelInlineApplicationSubmitConfirm: applicationFlow.cancelInlineApplicationSubmitConfirm,
+ clearWorkbenchDateSelection,
+ commitInlineApplicationPreviewEditor: applicationFlow.commitInlineApplicationPreviewEditor,
+ confirmDeleteConversation: () => sessionCommands.confirmDeleteConversation(deleteDialogOpen),
+ confirmInlineApplicationSubmit: applicationFlow.confirmInlineApplicationSubmit,
+ conversationMessages,
+ conversationScrollRef,
+ conversationStarted,
+ deleteDialogOpen,
+ displayModelName,
+ displayUserName,
+ fileInputRef,
+ handleAiAnswerMarkdownClick,
+ handleAiModeFilesChange: filesFlow.handleAiModeFilesChange,
+ handleInlineApplicationPreviewEditorKeydown: applicationFlow.handleInlineApplicationPreviewEditorKeydown,
+ handleInlineConversationScroll,
+ handleInlineSuggestedAction: actionRouter.handleInlineSuggestedAction,
+ handleVoiceInput,
+ hasInlineAttachmentOcrDetails,
+ hasInlineThinking,
+ isAiModeInputLocked,
+ isApplicationPreviewEditing: applicationFlow.isApplicationPreviewEditing,
+ isApplicationPreviewEstimatePending: applicationFlow.isApplicationPreviewEstimatePending,
+ isInlineAttachmentOcrExpanded,
+ isInlineSuggestedActionDisabled: applicationFlow.isInlineSuggestedActionDisabled,
+ isInlineThinkingExpanded,
+ markInlineMessageFeedback: messageActions.markInlineMessageFeedback,
+ modelSelectorTitle,
+ openApplicationPreviewEditor: applicationFlow.openApplicationPreviewEditor,
+ quoteInlineMessage: messageActions.quoteInlineMessage,
+ regenerateLastReply,
+ removeAiModeFile: filesFlow.removeAiModeFile,
+ removeWorkbenchDateTag,
+ renderInlineConversationHtml,
+ requestDeleteCurrentConversation: () => sessionCommands.requestDeleteCurrentConversation(deleteDialogOpen),
+ resolveApplicationPreviewEditorOptions: applicationFlow.resolveApplicationPreviewEditorOptions,
+ resolveInlineApplicationPreviewEditorControl: applicationFlow.resolveInlineApplicationPreviewEditorControl,
+ resolveInlineApplicationPreviewMissingFields: applicationFlow.resolveInlineApplicationPreviewMissingFields,
+ resolveInlineApplicationPreviewRows: applicationFlow.resolveInlineApplicationPreviewRows,
+ resolveInlineAttachmentOcrDocuments,
+ resolveInlineAttachmentOcrFileCount,
+ resolveInlineThinkingEvents,
+ runAiModeAction,
+ scrollInlineConversationToTop,
+ selectedFileCards,
+ sending,
+ setWorkbenchDateMode,
+ submitAiModePrompt,
+ toggleInlineAttachmentOcrDetails,
+ toggleInlineThinking,
+ toggleWorkbenchDatePicker,
+ triggerAiModeFileUpload: filesFlow.triggerAiModeFileUpload,
+ workbenchCanApplyDateSelection,
+ workbenchDateMode,
+ workbenchDatePickerOpen,
+ workbenchDateTagLabel,
+ workbenchRangeEndDate,
+ workbenchRangeStartDate,
+ workbenchSingleDate,
+ applyWorkbenchDateSelection,
+ buildInlineApplicationPreviewFooterText: applicationFlow.buildInlineApplicationPreviewFooterText,
+ copyInlineMessage: messageActions.copyInlineMessage,
+ formatMessageTime,
+ handleWorkbenchDateInputChange
+ }
+}
diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js b/web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js
new file mode 100644
index 0000000..a621592
--- /dev/null
+++ b/web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js
@@ -0,0 +1,125 @@
+import {
+ AI_APPLICATION_ACTION_SAVE_DRAFT,
+ AI_APPLICATION_ACTION_SUBMIT
+} from '../../services/aiApplicationPreviewActions.js'
+import { buildAiDocumentDetailRequest } from '../../utils/aiDocumentDetailReference.js'
+import {
+ mergeComposerPrefill,
+ resolveSuggestedActionPrefill
+} from '../../utils/assistantSuggestedActionPrefill.js'
+import {
+ AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION,
+ AI_ATTACHMENT_OCR_DETAIL_ACTION
+} from './workbenchAiMessageModel.js'
+import { SESSION_TYPE_EXPENSE } from './useWorkbenchAiExpenseFlow.js'
+
+export function useWorkbenchAiActionRouter({
+ aiExpenseDraft,
+ applicationFlow,
+ assistantDraft,
+ attachmentFlow,
+ emit,
+ expenseFlow,
+ focusAiModeInput,
+ hasInlineAttachmentOcrDetails,
+ resolveLatestInlineUserPrompt,
+ selectedFiles,
+ startInlineConversation,
+ toast,
+ toggleInlineAttachmentOcrDetails
+}) {
+ function handleInlineSuggestedAction(action = {}, sourceMessage = null) {
+ const prefillText = resolveSuggestedActionPrefill(action)
+ if (prefillText) {
+ assistantDraft.value = mergeComposerPrefill(assistantDraft.value, prefillText)
+ focusAiModeInput()
+ return
+ }
+
+ const actionType = String(action?.action_type || '').trim()
+ const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
+ if (actionType === AI_ATTACHMENT_OCR_DETAIL_ACTION) {
+ if (!hasInlineAttachmentOcrDetails(sourceMessage)) {
+ toast('当前消息没有可查看的附件识别明细。')
+ return
+ }
+ toggleInlineAttachmentOcrDetails(sourceMessage, true)
+ return
+ }
+ if (actionType === AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION) {
+ if (applicationFlow.isInlineSuggestedActionDisabled(action, sourceMessage)) {
+ return
+ }
+ void attachmentFlow.confirmAiAttachmentAssociation(actionPayload, sourceMessage)
+ return
+ }
+ if ([AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT].includes(actionType)) {
+ if (applicationFlow.isInlineSuggestedActionDisabled(action, sourceMessage)) {
+ toast('请等待费用测算完成后再继续操作。')
+ return
+ }
+ void applicationFlow.executeInlineApplicationPreviewAction(actionType, sourceMessage, {
+ userText: action.label,
+ draftPayload: actionPayload.draftPayload || actionPayload.draft_payload || null
+ })
+ return
+ }
+ if (actionType === 'open_application_detail') {
+ const claimNo = String(actionPayload.claim_no || actionPayload.claimNo || '').trim()
+ const claimId = String(actionPayload.claim_id || actionPayload.claimId || '').trim()
+ emit('open-document', buildAiDocumentDetailRequest({
+ reference: claimNo || claimId,
+ claimId,
+ claimNo
+ }))
+ return
+ }
+ if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_reimbursement') {
+ const expenseType = String(actionPayload.expense_type || 'travel').trim() || 'travel'
+ const expenseTypeLabel = String(actionPayload.expense_type_label || '差旅费').trim() || '差旅费'
+ expenseFlow.startAiExpenseDraft(expenseType, expenseTypeLabel, true)
+ return
+ }
+ if (actionPayload.steward_confirm_flow && actionPayload.flow_id === 'travel_application') {
+ aiExpenseDraft.value = null
+ void expenseFlow.startAiApplicationPreviewFromAction(actionPayload)
+ return
+ }
+ if (actionType === 'select_expense_type') {
+ const expenseType = String(action?.payload?.expense_type || '').trim()
+ const expenseTypeLabel = String(action?.payload?.expense_type_label || action?.label || '').trim()
+ const requiresApplicationBeforeReimbursement = Boolean(action?.payload?.requires_application_before_reimbursement)
+ expenseFlow.startAiExpenseDraft(expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement)
+ return
+ }
+
+ if (actionType === 'select_required_application') {
+ expenseFlow.linkAiExpenseApplication(action?.payload || {})
+ return
+ }
+
+ if (actionType === 'ai_application_start_inline') {
+ aiExpenseDraft.value = null
+ void expenseFlow.startAiApplicationPreviewFromAction(action?.payload || {}, action?.label)
+ return
+ }
+
+ const carryText = String(action?.payload?.carry_text || action?.label || '').trim()
+ if (!carryText) {
+ return
+ }
+ if (String(action?.payload?.session_type || '').trim() === SESSION_TYPE_EXPENSE && carryText === '我要报销') {
+ expenseFlow.pushInlineExpenseSceneSelectionPrompt(carryText, action.label)
+ return
+ }
+ startInlineConversation(carryText, {
+ label: action.label,
+ source: 'steward-action',
+ sessionType: action?.payload?.session_type || 'steward'
+ }, action?.payload?.carry_files ? Array.from(selectedFiles.value) : [])
+ }
+
+ return {
+ handleInlineSuggestedAction
+ }
+}
diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js b/web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js
new file mode 100644
index 0000000..0f95c4d
--- /dev/null
+++ b/web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js
@@ -0,0 +1,511 @@
+import {
+ buildApplicationPreviewFooterMessage,
+ buildApplicationPreviewRows,
+ buildLocalApplicationPreviewMessage,
+ normalizeApplicationPreview
+} from '../../utils/expenseApplicationPreview.js'
+import {
+ buildAiApplicationPrecheck,
+ buildAiApplicationSubmitConflictMessage,
+ isAiApplicationPrecheckBlocking
+} from '../../utils/aiApplicationPrecheckModel.js'
+import { fetchExpenseClaims } from '../../services/reimbursements.js'
+import {
+ AI_APPLICATION_ACTION_SAVE_DRAFT,
+ AI_APPLICATION_ACTION_SUBMIT,
+ runAiApplicationPreviewAction
+} from '../../services/aiApplicationPreviewActions.js'
+import {
+ buildFailedInlineApplicationSubmitThinkingEvents,
+ buildInitialInlineApplicationSubmitThinkingEvents,
+ buildInlineApplicationDetailAction,
+ buildInlineApplicationPreview,
+ buildInlineApplicationPreviewActionResultText,
+ buildInlineApplicationSubmitPrecheckPayload,
+ buildInlineApplicationSubmitThinkingEvents,
+ completeInlineThinkingEvents,
+ extractInlineApplicationDraftPayload,
+ resolveInlineApplicationPreviewActionFromText
+} from './workbenchAiApplicationPreviewModel.js'
+import { AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION } from './workbenchAiMessageModel.js'
+
+function isApplicationPreviewEstimatePendingPreview(applicationPreview = {}) {
+ const fields = normalizeApplicationPreview(applicationPreview).fields || {}
+ return [
+ fields.transportPolicy,
+ fields.policyEstimate,
+ fields.transportEstimatedAmount,
+ fields.amount
+ ].some((value) => /正在|查询中/.test(String(value || '')))
+}
+
+function shouldRefreshInlineApplicationPreviewEstimate(fieldKey = '') {
+ return ['transportMode', 'time', 'location', 'days'].includes(String(fieldKey || '').trim())
+}
+
+export function useWorkbenchAiApplicationPreviewFlow({
+ activateInlineConversation,
+ applicationPreviewEditor,
+ applicationSubmitConfirmContext,
+ applicationSubmitConfirmOpen,
+ assistantDraft,
+ cancelApplicationPreviewEditor,
+ clearAiModeFiles,
+ closeWorkbenchDatePicker,
+ commitApplicationPreviewEditor: commitBaseApplicationPreviewEditor,
+ conversationId,
+ conversationMessages,
+ conversationStarted,
+ createInlineMessage,
+ currentUser,
+ handleApplicationPreviewEditorKeydown,
+ inlineConversationAutoScrollPinned,
+ isApplicationPreviewEditing,
+ openApplicationPreviewEditor,
+ persistCurrentConversation,
+ pushInlineApplicationActionUserMessage,
+ pushInlineUserMessage,
+ refreshApplicationPreviewEstimate,
+ removeWorkbenchDateTag,
+ replaceInlineMessage,
+ resolveApplicationPreviewEditorControl,
+ resolveApplicationPreviewEditorOptions,
+ resolveInlineThinkingEvents,
+ resolveLatestInlineUserPrompt,
+ scrollInlineConversationToBottom,
+ sending,
+ toast
+}) {
+ function isApplicationPreviewEstimatePending(message = {}) {
+ return Boolean(message?.applicationPreview && isApplicationPreviewEstimatePendingPreview(message.applicationPreview))
+ }
+
+ function canShowInlineSuggestedActions(message = {}) {
+ return Boolean(message?.suggestedActions?.length) && !isApplicationPreviewEstimatePending(message)
+ }
+
+ function isInlineSuggestedActionDisabled(action = {}, message = {}) {
+ const actionType = String(action?.action_type || '').trim()
+ return (
+ Boolean(action?.disabled) ||
+ (actionType === AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION && sending.value) ||
+ (
+ [AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT].includes(actionType) &&
+ isApplicationPreviewEstimatePending(message)
+ )
+ )
+ }
+
+ function resolveInlineApplicationPreviewRows(message) {
+ return buildApplicationPreviewRows(message?.applicationPreview || {})
+ }
+
+ function resolveInlineApplicationPreviewMissingFields(message) {
+ return normalizeApplicationPreview(message?.applicationPreview || {}).missingFields || []
+ }
+
+ function resolveInlineApplicationPreviewEditorControl(fieldKey) {
+ const control = resolveApplicationPreviewEditorControl(fieldKey)
+ return control === 'date' ? 'text' : control
+ }
+
+ function buildInlineApplicationPreviewSuggestedActions(applicationPreview = {}, draftPayload = null) {
+ if (isApplicationPreviewEstimatePendingPreview(applicationPreview)) {
+ return []
+ }
+ const normalized = normalizeApplicationPreview(applicationPreview)
+ const actions = [{
+ label: '保存草稿',
+ description: '先保存当前申请表,后续可以继续补充或提交。',
+ icon: 'mdi mdi-content-save-outline',
+ action_type: AI_APPLICATION_ACTION_SAVE_DRAFT,
+ payload: { draftPayload }
+ }]
+ if (normalized.readyToSubmit) {
+ actions.push({
+ label: '直接提交',
+ description: '提交前先核查相同日期申请单,确认通过后进入审批流程。',
+ icon: 'mdi mdi-send-check-outline',
+ action_type: AI_APPLICATION_ACTION_SUBMIT,
+ payload: { draftPayload }
+ })
+ }
+ return actions
+ }
+
+ function syncInlineApplicationPreviewMessageContent(message) {
+ if (!message?.applicationPreview) {
+ return
+ }
+ const nextContent = buildLocalApplicationPreviewMessage(message.applicationPreview)
+ message.content = nextContent
+ message.text = nextContent
+ message.suggestedActions = buildInlineApplicationPreviewSuggestedActions(message.applicationPreview, message.draftPayload)
+ }
+
+ async function commitInlineApplicationPreviewEditor(message) {
+ const shouldLockForEstimate = shouldRefreshInlineApplicationPreviewEstimate(applicationPreviewEditor.value.fieldKey)
+ if (shouldLockForEstimate) {
+ message.suggestedActions = []
+ persistCurrentConversation()
+ }
+ const committed = await commitBaseApplicationPreviewEditor(message)
+ syncInlineApplicationPreviewMessageContent(message)
+ persistCurrentConversation()
+ return committed
+ }
+
+ function handleInlineApplicationPreviewEditorKeydown(event, message) {
+ if (event.key === 'Enter') {
+ event.preventDefault()
+ void commitInlineApplicationPreviewEditor(message)
+ return
+ }
+ if (event.key === 'Escape') {
+ event.preventDefault()
+ cancelApplicationPreviewEditor()
+ return
+ }
+ handleApplicationPreviewEditorKeydown(event, message)
+ }
+
+ function buildInlineApplicationPreviewFooterText(message) {
+ const normalized = normalizeApplicationPreview(message?.applicationPreview || {})
+ if (isApplicationPreviewEstimatePending(message)) {
+ return '费用测算正在同步,请稍等,完成后才能保存草稿或直接提交。'
+ }
+ if (normalized.validationIssues?.length || normalized.missingFields?.length) {
+ return buildApplicationPreviewFooterMessage(normalized)
+ }
+ return '申请核对表已补齐,费用测算已同步。你仍可点击表格继续修改;确认无误后,可以点击下方按钮保存草稿或直接提交,也可以直接回复“保存草稿”或“提交”。'
+ }
+
+ function resolveLatestApplicationPreviewMessage() {
+ return [...conversationMessages.value]
+ .reverse()
+ .find((message) => message.role === 'assistant' && message.applicationPreview)
+ }
+
+ function requestInlineApplicationSubmitConfirmation(targetMessage, options = {}) {
+ applicationSubmitConfirmContext.value = {
+ messageId: String(targetMessage?.id || '').trim(),
+ draftPayload: targetMessage?.draftPayload || options.draftPayload || null,
+ userText: String(options.userText || '直接提交').trim() || '直接提交'
+ }
+ applicationSubmitConfirmOpen.value = true
+ persistCurrentConversation()
+ }
+
+ function cancelInlineApplicationSubmitConfirm() {
+ applicationSubmitConfirmOpen.value = false
+ applicationSubmitConfirmContext.value = null
+ }
+
+ function confirmInlineApplicationSubmit() {
+ const context = applicationSubmitConfirmContext.value || {}
+ applicationSubmitConfirmOpen.value = false
+ applicationSubmitConfirmContext.value = null
+ const sourceMessage = conversationMessages.value.find((message) => message.id === context.messageId)
+ if (!sourceMessage?.applicationPreview) {
+ toast('当前申请表已变化,请重新点击直接提交。')
+ return
+ }
+ void executeInlineApplicationPreviewAction(AI_APPLICATION_ACTION_SUBMIT, sourceMessage, {
+ confirmed: true,
+ skipUserMessage: false,
+ draftPayload: context.draftPayload || null,
+ userText: context.userText || '直接提交'
+ })
+ }
+
+ async function runInlineApplicationSubmitPrecheck(targetMessage, pendingMessage, normalizedPreview, options = {}) {
+ try {
+ const claimsPayload = await fetchExpenseClaims({ page: 1, pageSize: 100 })
+ const precheck = buildAiApplicationPrecheck(normalizedPreview, {
+ claimsPayload: buildInlineApplicationSubmitPrecheckPayload(
+ claimsPayload,
+ targetMessage.draftPayload || options.draftPayload || null
+ ),
+ currentUser: currentUser.value || {},
+ expenseType: 'travel'
+ })
+ const thinkingEvents = buildInlineApplicationSubmitThinkingEvents(precheck)
+ const blocked = isAiApplicationPrecheckBlocking(precheck)
+
+ if (blocked) {
+ replaceInlineMessage(
+ pendingMessage.id,
+ createInlineMessage('assistant', buildAiApplicationSubmitConflictMessage(normalizedPreview, precheck), {
+ id: pendingMessage.id,
+ stewardPlan: {
+ streamStatus: 'completed',
+ thinkingEvents
+ }
+ })
+ )
+ persistCurrentConversation()
+ scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
+ return false
+ }
+
+ const message = conversationMessages.value.find((item) => item.id === pendingMessage.id) || pendingMessage
+ message.content = '提交前核查通过,正在提交申请并进入审批流程...'
+ message.paragraphs = ['提交前核查通过,正在提交申请并进入审批流程...']
+ message.stewardPlan = {
+ ...(message.stewardPlan || {}),
+ streamStatus: 'streaming',
+ thinkingEvents
+ }
+ scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
+ return true
+ } catch (error) {
+ replaceInlineMessage(
+ pendingMessage.id,
+ createInlineMessage('assistant', [
+ '### 提交前核查失败',
+ '系统未能完成相同日期申请单查询,所以本次申请没有提交。',
+ '请稍后重试;如果仍然失败,请先到单据中心核对是否已有同日期申请单。'
+ ].join('\n\n'), {
+ id: pendingMessage.id,
+ stewardPlan: {
+ streamStatus: 'failed',
+ thinkingEvents: buildFailedInlineApplicationSubmitThinkingEvents(error)
+ }
+ })
+ )
+ toast('提交前核查失败,已暂停提交。')
+ persistCurrentConversation()
+ return false
+ }
+ }
+
+ async function executeInlineApplicationPreviewAction(actionType, sourceMessage = null, options = {}) {
+ const targetMessage = sourceMessage?.applicationPreview ? sourceMessage : resolveLatestApplicationPreviewMessage()
+ if (!targetMessage?.applicationPreview) {
+ toast('当前没有可提交的申请表。')
+ return false
+ }
+
+ const normalizedPreview = normalizeApplicationPreview(targetMessage.applicationPreview)
+ const isSubmit = actionType === AI_APPLICATION_ACTION_SUBMIT
+ const userText = String(options.userText || (isSubmit ? '直接提交' : '保存草稿')).trim()
+
+ if (isSubmit && !normalizedPreview.readyToSubmit) {
+ if (!options.skipUserMessage) {
+ pushInlineApplicationActionUserMessage(userText)
+ }
+ const missingText = normalizedPreview.missingFields?.length
+ ? `当前还缺少:${normalizedPreview.missingFields.join('、')}。`
+ : ''
+ const validationText = normalizedPreview.validationIssues?.length
+ ? normalizedPreview.validationIssues.map((item) => item.message).join(';')
+ : ''
+ conversationMessages.value.push(createInlineMessage('assistant', [
+ '### 暂不能提交申请',
+ missingText || validationText || '当前申请表还未通过提交校验。',
+ '请先点击表格中的字段补充或修正,补齐后我会开放“直接提交”入口。'
+ ].filter(Boolean).join('\n\n')))
+ persistCurrentConversation()
+ scrollInlineConversationToBottom()
+ return true
+ }
+
+ if (isSubmit && !options.confirmed) {
+ requestInlineApplicationSubmitConfirmation(targetMessage, { ...options, userText })
+ return true
+ }
+
+ if (!options.skipUserMessage) {
+ pushInlineApplicationActionUserMessage(userText)
+ }
+
+ sending.value = true
+ const pendingMessage = createInlineMessage(
+ 'assistant',
+ isSubmit ? '正在提交前核查相同日期申请单...' : '正在保存申请草稿...',
+ {
+ pending: true,
+ stewardPlan: {
+ streamStatus: 'streaming',
+ thinkingEvents: isSubmit
+ ? buildInitialInlineApplicationSubmitThinkingEvents()
+ : [
+ {
+ eventId: 'application-save-draft',
+ title: '保存申请草稿',
+ content: '正在按当前申请表内容保存草稿。',
+ status: 'running'
+ }
+ ]
+ }
+ }
+ )
+ conversationMessages.value.push(pendingMessage)
+ scrollInlineConversationToBottom()
+
+ try {
+ if (isSubmit) {
+ const precheckPassed = await runInlineApplicationSubmitPrecheck(
+ targetMessage,
+ pendingMessage,
+ normalizedPreview,
+ options
+ )
+ if (!precheckPassed) {
+ return true
+ }
+ }
+
+ const payload = await runAiApplicationPreviewAction({
+ actionType,
+ applicationPreview: normalizedPreview,
+ currentUser: currentUser.value || {},
+ conversationId: conversationId.value,
+ draftPayload: targetMessage.draftPayload || options.draftPayload || null
+ })
+ const draftPayload = extractInlineApplicationDraftPayload(payload)
+ if (draftPayload) {
+ targetMessage.draftPayload = draftPayload
+ }
+ targetMessage.suggestedActions = []
+ replaceInlineMessage(
+ pendingMessage.id,
+ createInlineMessage('assistant', buildInlineApplicationPreviewActionResultText(actionType, payload), {
+ id: pendingMessage.id,
+ stewardPlan: {
+ streamStatus: 'completed',
+ thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
+ },
+ suggestedActions: isSubmit ? buildInlineApplicationDetailAction(draftPayload) : []
+ })
+ )
+ persistCurrentConversation()
+ scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
+ return true
+ } catch (error) {
+ replaceInlineMessage(
+ pendingMessage.id,
+ createInlineMessage('assistant', error?.message || (isSubmit ? '申请提交失败,请稍后重试。' : '申请草稿保存失败,请稍后重试。'), {
+ id: pendingMessage.id,
+ stewardPlan: {
+ streamStatus: 'failed',
+ thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({
+ ...item,
+ status: 'failed'
+ }))
+ }
+ })
+ )
+ toast(error?.message || (isSubmit ? '申请提交失败。' : '申请草稿保存失败。'))
+ persistCurrentConversation()
+ return true
+ } finally {
+ sending.value = false
+ scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
+ }
+ }
+
+ function handleInlineApplicationPreviewTextAction(prompt, applicationPreviewEstimatePending) {
+ if (applicationPreviewEstimatePending.value) {
+ toast('请等待费用测算完成后再继续操作。')
+ return true
+ }
+ const actionType = resolveInlineApplicationPreviewActionFromText(prompt)
+ if (!actionType || !resolveLatestApplicationPreviewMessage()) {
+ return false
+ }
+ void executeInlineApplicationPreviewAction(actionType, null, { userText: prompt })
+ return true
+ }
+
+ async function startAiApplicationPreview(expenseType, expenseTypeLabel, sourceText = '', options = {}) {
+ if (!conversationStarted.value) {
+ activateInlineConversation({ title: String(expenseTypeLabel || expenseType || '申请').trim().slice(0, 18) || '申请' })
+ }
+ const previewSourceText = String(sourceText || resolveLatestInlineUserPrompt()).trim()
+ assistantDraft.value = ''
+ removeWorkbenchDateTag()
+ closeWorkbenchDatePicker()
+ clearAiModeFiles()
+ if (options.pushUserMessage !== false) {
+ pushInlineUserMessage(options.userMessage || '确认发起出差申请')
+ }
+
+ const pendingMessage = createInlineMessage('assistant', '正在生成申请核对表,请稍等...', {
+ pending: true,
+ stewardPlan: {
+ streamStatus: 'streaming',
+ thinkingEvents: [
+ {
+ eventId: 'application-preview-build',
+ title: '整理申请表字段',
+ content: '正在把已识别的时间、地点、事由和差旅类型整理成可编辑表格。',
+ status: 'running'
+ },
+ {
+ eventId: 'application-preview-estimate',
+ title: '同步费用测算',
+ content: '正在刷新差旅规则和费用测算,完成后会直接展示核对表。',
+ status: 'pending'
+ }
+ ]
+ }
+ })
+ conversationMessages.value.push(pendingMessage)
+ persistCurrentConversation()
+ scrollInlineConversationToBottom()
+
+ try {
+ const preview = await refreshApplicationPreviewEstimate(
+ buildInlineApplicationPreview(expenseTypeLabel || expenseType, previewSourceText, currentUser.value || {})
+ )
+ const content = buildLocalApplicationPreviewMessage(preview)
+ replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', content, {
+ id: pendingMessage.id,
+ applicationPreview: preview,
+ suggestedActions: buildInlineApplicationPreviewSuggestedActions(preview),
+ stewardPlan: {
+ streamStatus: 'completed',
+ thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
+ },
+ text: content
+ }))
+ } catch (error) {
+ replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', error?.message || '申请核对表生成失败,请稍后重试。', {
+ id: pendingMessage.id,
+ stewardPlan: {
+ streamStatus: 'failed',
+ thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({
+ ...item,
+ status: 'failed'
+ }))
+ }
+ }))
+ toast(error?.message || '申请核对表生成失败。')
+ } finally {
+ persistCurrentConversation()
+ scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
+ }
+ }
+
+ return {
+ buildInlineApplicationPreviewFooterText,
+ buildInlineApplicationPreviewSuggestedActions,
+ canShowInlineSuggestedActions,
+ cancelInlineApplicationSubmitConfirm,
+ commitInlineApplicationPreviewEditor,
+ confirmInlineApplicationSubmit,
+ executeInlineApplicationPreviewAction,
+ handleInlineApplicationPreviewEditorKeydown,
+ handleInlineApplicationPreviewTextAction,
+ isApplicationPreviewEditing,
+ isApplicationPreviewEstimatePending,
+ isInlineSuggestedActionDisabled,
+ openApplicationPreviewEditor,
+ resolveApplicationPreviewEditorOptions,
+ resolveInlineApplicationPreviewEditorControl,
+ resolveInlineApplicationPreviewMissingFields,
+ resolveInlineApplicationPreviewRows,
+ startAiApplicationPreview
+ }
+}
diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiAttachmentAssociationFlow.js b/web/src/composables/workbenchAiMode/useWorkbenchAiAttachmentAssociationFlow.js
new file mode 100644
index 0000000..321e706
--- /dev/null
+++ b/web/src/composables/workbenchAiMode/useWorkbenchAiAttachmentAssociationFlow.js
@@ -0,0 +1,357 @@
+import * as aiAttachmentAssociationModel from '../../utils/aiAttachmentAssociationModel.js'
+import { syncExpenseClaimFilesToDraft } from '../../utils/expenseClaimAttachmentSync.js'
+import { collectReceiptFiles } from '../../views/scripts/travelReimbursementAttachmentModel.js'
+import {
+ createExpenseClaimItem,
+ extractExpenseClaimItems,
+ fetchExpenseClaimDetail,
+ fetchExpenseClaims,
+ uploadExpenseClaimItemAttachment
+} from '../../services/reimbursements.js'
+import { recognizeOcrFiles } from '../../services/ocr.js'
+import {
+ AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION,
+ buildInlineAttachmentOcrDetails
+} from './workbenchAiMessageModel.js'
+import { isLikelyAiModeOcrFile } from './workbenchAiComposerModel.js'
+
+function buildAiAttachmentAssociationThinkingEvents(status = 'running') {
+ const completed = status === 'completed'
+ const failed = status === 'failed'
+ const eventStatus = failed ? 'failed' : completed ? 'completed' : 'running'
+ return [
+ {
+ eventId: 'attachment-ocr',
+ title: '识别上传票据',
+ content: '提取票据里的日期、地点和行程信息。',
+ status: eventStatus
+ },
+ {
+ eventId: 'claim-lookup',
+ title: '查询可关联报销单',
+ content: '查找草稿、待补充和退回状态的可归集单据。',
+ status: eventStatus
+ },
+ {
+ eventId: 'claim-match',
+ title: '匹配票据与报销单',
+ content: '根据票据时间、城市和报销事由判断最可能的关联单据。',
+ status: eventStatus
+ }
+ ]
+}
+
+function resolveAiAttachmentAssociationClaimNo(payload = {}) {
+ return String(payload?.claim_no || payload?.claimNo || '').trim()
+}
+
+function buildAiAttachmentAssociationResultThinkingEvents(status = 'running') {
+ const completed = status === 'completed'
+ const failed = status === 'failed'
+ const eventStatus = failed ? 'failed' : completed ? 'completed' : 'running'
+ return [
+ {
+ eventId: 'attachment-confirm',
+ title: '确认自动归集',
+ content: '正在读取匹配单据并准备写入附件。',
+ status: eventStatus
+ },
+ {
+ eventId: 'attachment-upload',
+ title: '归集票据附件',
+ content: '把本次上传的票据写入报销单明细。',
+ status: eventStatus
+ }
+ ]
+}
+
+export function useWorkbenchAiAttachmentAssociationFlow({
+ aiAttachmentAssociationRuntime,
+ conversationMessages,
+ createAiAttachmentAssociationId,
+ createInlineMessage,
+ inlineConversationAutoScrollPinned,
+ persistCurrentConversation,
+ replaceInlineMessage,
+ scrollInlineConversationToBottom,
+ sending,
+ streamOrSetInlineAssistantContent,
+ toast
+}) {
+ async function collectAiModeReceiptContext(files = []) {
+ const safeFiles = Array.isArray(files) ? files : []
+ const attachmentNames = safeFiles
+ .map((file) => String(file?.name || '').trim())
+ .filter(Boolean)
+ const ocrFiles = safeFiles.filter((file) => isLikelyAiModeOcrFile(file))
+ const ocrSourceFileNames = ocrFiles
+ .map((file) => String(file?.name || '').trim())
+ .filter(Boolean)
+
+ const baseContext = {
+ attachmentNames,
+ attachmentCount: attachmentNames.length,
+ ocrSourceFileNames,
+ ocrSummary: '',
+ ocrDocuments: []
+ }
+
+ if (!ocrFiles.length) {
+ return baseContext
+ }
+
+ try {
+ const collected = await collectReceiptFiles({
+ files: ocrFiles,
+ recognizeOcrFiles
+ })
+ return {
+ ...baseContext,
+ ocrSummary: String(collected.ocrSummary || '').trim(),
+ ocrDocuments: Array.isArray(collected.ocrDocuments) ? collected.ocrDocuments : []
+ }
+ } catch (error) {
+ console.warn('AI mode OCR request failed:', error)
+ return {
+ ...baseContext,
+ ocrError: error?.message || 'OCR识别失败,已继续使用附件名称。'
+ }
+ }
+ }
+
+ function findAiAttachmentAssociationRuntime(options = {}) {
+ const normalizedAssociationId = String(options.associationId || options.association_id || '').trim()
+ const normalizedClaimNo = String(options.claimNo || options.claim_no || '').trim()
+ if (normalizedAssociationId) {
+ const runtime = aiAttachmentAssociationRuntime.get(normalizedAssociationId)
+ if (runtime) {
+ return { associationId: normalizedAssociationId, runtime }
+ }
+ }
+
+ if (normalizedClaimNo) {
+ for (const [runtimeId, runtime] of aiAttachmentAssociationRuntime.entries()) {
+ if (String(runtime?.claimNo || '').trim() === normalizedClaimNo && runtime?.files?.length) {
+ return { associationId: runtimeId, runtime }
+ }
+ }
+ }
+
+ return { associationId: '', runtime: null }
+ }
+
+ function updateAiAttachmentAssociationActionState(message = {}, associationId = '', state = {}, options = {}) {
+ const normalizedAssociationId = String(associationId || '').trim()
+ const normalizedClaimNo = String(options.claimNo || options.claim_no || '').trim()
+ if (!message || !Array.isArray(message.suggestedActions) || (!normalizedAssociationId && !normalizedClaimNo)) {
+ return
+ }
+ message.suggestedActions = message.suggestedActions.map((action) => {
+ const actionAssociationId = String(action?.payload?.association_id || action?.payload?.associationId || '').trim()
+ const actionClaimNo = resolveAiAttachmentAssociationClaimNo(action?.payload || {})
+ const isSameAssociation = normalizedAssociationId && actionAssociationId === normalizedAssociationId
+ const isSameClaim = normalizedClaimNo && actionClaimNo === normalizedClaimNo
+ if (String(action?.action_type || '').trim() !== AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION || (!isSameAssociation && !isSameClaim)) {
+ return action
+ }
+ return {
+ ...action,
+ ...state
+ }
+ })
+ }
+
+ function buildAiAttachmentAssociationDetailActions(runtime = {}) {
+ const claimNo = String(runtime.claimNo || '').trim()
+ const claimId = String(runtime.claimId || '').trim()
+ if (!claimNo && !claimId) {
+ return []
+ }
+ return [{
+ label: '查看单据',
+ description: '打开已归集票据的报销单。',
+ icon: 'mdi mdi-open-in-new',
+ action_type: 'open_application_detail',
+ payload: {
+ claim_id: claimId,
+ claim_no: claimNo,
+ document_type: 'expense'
+ }
+ }]
+ }
+
+ async function confirmAiAttachmentAssociation(actionPayload = {}, sourceMessage = null) {
+ const requestedAssociationId = String(actionPayload.association_id || actionPayload.associationId || '').trim()
+ const payloadClaimNo = resolveAiAttachmentAssociationClaimNo(actionPayload)
+ const runtimeResult = findAiAttachmentAssociationRuntime({
+ associationId: requestedAssociationId,
+ claimNo: payloadClaimNo
+ })
+ const associationId = runtimeResult.associationId
+ const runtime = runtimeResult.runtime
+ const actionClaimNo = payloadClaimNo || String(runtime?.claimNo || '').trim()
+ if (!associationId || !runtime?.files?.length) {
+ toast('当前会话里没有可归集的附件原件,请重新上传票据后再确认。')
+ return
+ }
+
+ updateAiAttachmentAssociationActionState(sourceMessage, associationId, {
+ label: '正在归集...',
+ disabled: true
+ }, { claimNo: actionClaimNo })
+ persistCurrentConversation()
+ sending.value = true
+
+ const pendingMessage = createInlineMessage('assistant', '', {
+ pending: true,
+ stewardPlan: {
+ streamStatus: 'streaming',
+ thinkingEvents: buildAiAttachmentAssociationResultThinkingEvents('running')
+ }
+ })
+ conversationMessages.value.push(pendingMessage)
+ scrollInlineConversationToBottom()
+
+ try {
+ const syncResult = await syncExpenseClaimFilesToDraft({
+ claimId: runtime.claimId,
+ files: runtime.files,
+ fetchExpenseClaimDetail,
+ createExpenseClaimItem,
+ uploadExpenseClaimItemAttachment
+ })
+ const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationResultMessage({
+ claimNo: runtime.claimNo,
+ fileNames: runtime.fileNames,
+ uploadedCount: syncResult.uploadedCount,
+ skippedCount: syncResult.skippedCount
+ })
+ await streamOrSetInlineAssistantContent(pendingMessage.id, finalMessageText)
+ replaceInlineMessage(
+ pendingMessage.id,
+ createInlineMessage('assistant', finalMessageText, {
+ id: pendingMessage.id,
+ stewardPlan: {
+ streamStatus: 'completed',
+ thinkingEvents: buildAiAttachmentAssociationResultThinkingEvents('completed')
+ },
+ suggestedActions: buildAiAttachmentAssociationDetailActions(runtime)
+ })
+ )
+ updateAiAttachmentAssociationActionState(sourceMessage, associationId, {
+ label: '已自动关联',
+ disabled: true
+ }, { claimNo: actionClaimNo })
+ aiAttachmentAssociationRuntime.delete(associationId)
+ persistCurrentConversation()
+ } catch (error) {
+ const finalMessageText = error?.message || '自动归集失败,请稍后重试。'
+ replaceInlineMessage(
+ pendingMessage.id,
+ createInlineMessage('assistant', finalMessageText, {
+ id: pendingMessage.id,
+ stewardPlan: {
+ streamStatus: 'failed',
+ thinkingEvents: buildAiAttachmentAssociationResultThinkingEvents('failed')
+ }
+ })
+ )
+ updateAiAttachmentAssociationActionState(sourceMessage, associationId, {
+ label: '重新自动关联',
+ disabled: false
+ }, { claimNo: actionClaimNo })
+ toast(finalMessageText)
+ persistCurrentConversation()
+ } finally {
+ sending.value = false
+ scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
+ }
+ }
+
+ async function requestAiAttachmentAssociationReply(prompt, entry = {}, files = []) {
+ let shouldAutoScrollOnFinish = true
+ const pendingMessage = createInlineMessage('assistant', '', {
+ pending: true,
+ stewardPlan: {
+ streamStatus: 'streaming',
+ thinkingEvents: buildAiAttachmentAssociationThinkingEvents('running')
+ }
+ })
+ conversationMessages.value.push(pendingMessage)
+ scrollInlineConversationToBottom()
+
+ try {
+ const collected = await collectReceiptFiles({
+ files,
+ recognizeOcrFiles
+ })
+ const attachmentOcrDetails = buildInlineAttachmentOcrDetails(collected, files)
+ const claimsPayload = await fetchExpenseClaims({ page: 1, pageSize: 100 })
+ const claims = extractExpenseClaimItems(claimsPayload)
+ const match = aiAttachmentAssociationModel.resolveAiAttachmentAssociationMatch(claims, collected.ocrDocuments)
+ const associationRecord = match.best?.record || match.recommended?.record || null
+ const associationId = associationRecord?.claimId
+ ? createAiAttachmentAssociationId()
+ : ''
+ if (associationId) {
+ aiAttachmentAssociationRuntime.set(associationId, {
+ files,
+ fileNames: files.map((file) => file?.name || '').filter(Boolean),
+ claimId: String(associationRecord.claimId || '').trim(),
+ claimNo: String(associationRecord.claimNo || '').trim(),
+ ocrPayload: collected.ocrPayload,
+ ocrSummary: collected.ocrSummary,
+ ocrDocuments: collected.ocrDocuments,
+ ocrFilePreviews: collected.ocrFilePreviews
+ })
+ }
+ const finalMessageText = aiAttachmentAssociationModel.buildAiAttachmentAssociationMessage({
+ match,
+ fileNames: files.map((file) => file?.name || ''),
+ ocrDocuments: collected.ocrDocuments
+ })
+ await streamOrSetInlineAssistantContent(pendingMessage.id, finalMessageText)
+ shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
+ replaceInlineMessage(
+ pendingMessage.id,
+ createInlineMessage('assistant', finalMessageText, {
+ id: pendingMessage.id,
+ stewardPlan: {
+ streamStatus: 'completed',
+ thinkingEvents: buildAiAttachmentAssociationThinkingEvents('completed')
+ },
+ attachmentOcrDetails,
+ suggestedActions: aiAttachmentAssociationModel.buildAiAttachmentAssociationActions(match, associationId, {
+ includeOcrDetails: Boolean(attachmentOcrDetails)
+ })
+ })
+ )
+ persistCurrentConversation()
+ } catch (error) {
+ shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
+ const finalMessageText = error?.message || '票据识别或单据匹配失败,请稍后再试。'
+ replaceInlineMessage(
+ pendingMessage.id,
+ createInlineMessage('assistant', finalMessageText, {
+ id: pendingMessage.id,
+ stewardPlan: {
+ streamStatus: 'failed',
+ thinkingEvents: buildAiAttachmentAssociationThinkingEvents('failed')
+ }
+ })
+ )
+ toast(finalMessageText)
+ persistCurrentConversation()
+ } finally {
+ sending.value = false
+ scrollInlineConversationToBottom({ force: shouldAutoScrollOnFinish })
+ }
+ }
+
+ return {
+ collectAiModeReceiptContext,
+ confirmAiAttachmentAssociation,
+ requestAiAttachmentAssociationReply,
+ resolveAiAttachmentAssociationClaimNo
+ }
+}
diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiComposerFiles.js b/web/src/composables/workbenchAiMode/useWorkbenchAiComposerFiles.js
new file mode 100644
index 0000000..1e7289b
--- /dev/null
+++ b/web/src/composables/workbenchAiMode/useWorkbenchAiComposerFiles.js
@@ -0,0 +1,55 @@
+import {
+ buildFileIdentity,
+ MAX_ATTACHMENTS,
+ mergeFilesWithLimit
+} from '../../views/scripts/travelReimbursementAttachmentModel.js'
+
+export function useWorkbenchAiComposerFiles({
+ fileInputRef,
+ focusAiModeInput,
+ isInputLocked,
+ selectedFiles,
+ toast
+}) {
+ function triggerAiModeFileUpload() {
+ if (isInputLocked()) {
+ toast('请等待费用测算完成后再继续操作。')
+ return
+ }
+ fileInputRef.value?.click()
+ }
+
+ function handleAiModeFilesChange(event) {
+ const fileMergeResult = mergeFilesWithLimit(selectedFiles.value, Array.from(event.target.files || []), MAX_ATTACHMENTS)
+ selectedFiles.value = fileMergeResult.files
+ if (fileMergeResult.overflowCount > 0) {
+ toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
+ }
+ if (fileInputRef.value) {
+ fileInputRef.value.value = ''
+ }
+ focusAiModeInput()
+ }
+
+ function removeAiModeFile(fileKey) {
+ selectedFiles.value = selectedFiles.value.filter((file) => buildFileIdentity(file) !== fileKey)
+ if (!selectedFiles.value.length && fileInputRef.value) {
+ fileInputRef.value.value = ''
+ }
+ focusAiModeInput()
+ }
+
+ function clearAiModeFiles() {
+ selectedFiles.value = []
+ if (fileInputRef.value) {
+ fileInputRef.value.value = ''
+ }
+ }
+
+ return {
+ clearAiModeFiles,
+ handleAiModeFilesChange,
+ removeAiModeFile,
+ triggerAiModeFileUpload
+ }
+}
diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiDocumentQueryFlow.js b/web/src/composables/workbenchAiMode/useWorkbenchAiDocumentQueryFlow.js
new file mode 100644
index 0000000..31fc424
--- /dev/null
+++ b/web/src/composables/workbenchAiMode/useWorkbenchAiDocumentQueryFlow.js
@@ -0,0 +1,204 @@
+import { nextTick } from 'vue'
+
+import {
+ buildAiDocumentQueryConditionSummary,
+ buildAiDocumentQueryMessage,
+ filterAiDocumentQueryRecords,
+ mergeAiDocumentQueryPayloads,
+ resolveAiDocumentQueryIntent
+} from '../../utils/aiDocumentQueryModel.js'
+import {
+ extractExpenseClaimItems,
+ fetchApprovalExpenseClaims,
+ fetchExpenseClaims
+} from '../../services/reimbursements.js'
+
+const AI_DOCUMENT_QUERY_STEP_DELAY_MS = 320
+
+function waitForAiDocumentQueryStep() {
+ return new Promise((resolve) => {
+ globalThis.setTimeout(resolve, AI_DOCUMENT_QUERY_STEP_DELAY_MS)
+ })
+}
+
+function completeAiDocumentQueryEvent(events, eventId, content = '') {
+ return events.map((event) => (
+ event.eventId === eventId
+ ? {
+ ...event,
+ content: content || event.content,
+ status: 'completed'
+ }
+ : event
+ ))
+}
+
+function failAiDocumentQueryEvents(events) {
+ return events.map((event) => ({
+ ...event,
+ status: event.status === 'completed' ? 'completed' : 'failed'
+ }))
+}
+
+function resolveAiDocumentQueryFetchPendingText(intent = {}) {
+ if (intent.source === 'approval') {
+ return '等待调用待我审核单据接口。'
+ }
+ if (intent.source === 'mine') {
+ return '等待调用我名下单据接口。'
+ }
+ return '等待同时调用我名下单据和待我审核单据接口。'
+}
+
+function resolveAiDocumentQueryFetchRunningText(intent = {}) {
+ if (intent.source === 'approval') {
+ return '正在查询待我审核的单据,接口范围为待办/待审单据列表。'
+ }
+ if (intent.source === 'mine') {
+ return '正在查询我名下的单据,接口范围为当前用户本人单据列表。'
+ }
+ return '正在查询我可见的单据,接口范围包含我名下单据和待我审核单据列表。'
+}
+
+async function fetchAiDocumentQueryPayload(intent = {}) {
+ const requestParams = { page: 1, pageSize: 100 }
+ if (intent.source === 'approval') {
+ return fetchApprovalExpenseClaims(requestParams)
+ }
+ if (intent.source === 'mine') {
+ return fetchExpenseClaims(requestParams)
+ }
+ const [ownPayload, approvalPayload] = await Promise.all([
+ fetchExpenseClaims(requestParams),
+ fetchApprovalExpenseClaims(requestParams)
+ ])
+ return mergeAiDocumentQueryPayloads(
+ ownPayload,
+ {
+ items: extractExpenseClaimItems(approvalPayload),
+ querySource: 'approval'
+ }
+ )
+}
+
+export function useWorkbenchAiDocumentQueryFlow({
+ conversationMessages,
+ createInlineMessage,
+ inlineConversationAutoScrollPinned,
+ persistCurrentConversation,
+ replaceInlineMessage,
+ scrollInlineConversationToBottom
+}) {
+ async function updateAiDocumentQueryThinking(pendingMessage, thinkingEvents, streamStatus = 'streaming') {
+ const message = conversationMessages.value.find((item) => item.id === pendingMessage.id) || pendingMessage
+ message.stewardPlan = {
+ ...(message.stewardPlan || {}),
+ streamStatus,
+ thinkingEvents
+ }
+ scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
+ await nextTick()
+ }
+
+ async function handleAiDocumentQueryIntent(prompt, pendingMessage) {
+ const intent = resolveAiDocumentQueryIntent(prompt)
+ if (!intent) {
+ return false
+ }
+
+ const conditionSummary = buildAiDocumentQueryConditionSummary(intent)
+ let thinkingEvents = [
+ {
+ eventId: 'document-query-parse',
+ title: '解析自然语言筛选条件',
+ content: `正在从你的问题里提取查询来源、单据类型、时间、状态、费用类型、关键词和金额条件。当前识别:${conditionSummary}。`,
+ status: 'running'
+ },
+ {
+ eventId: 'document-query-fetch',
+ title: '查询业务单据接口',
+ content: resolveAiDocumentQueryFetchPendingText(intent),
+ status: 'pending'
+ },
+ {
+ eventId: 'document-query-filter',
+ title: '组合筛选单据',
+ content: '等待接口返回后,再按已识别条件做二次筛选。',
+ status: 'pending'
+ }
+ ]
+ await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
+ await waitForAiDocumentQueryStep()
+
+ thinkingEvents = completeAiDocumentQueryEvent(thinkingEvents, 'document-query-parse')
+ thinkingEvents = thinkingEvents.map((event) => (
+ event.eventId === 'document-query-fetch'
+ ? {
+ ...event,
+ content: resolveAiDocumentQueryFetchRunningText(intent),
+ status: 'running'
+ }
+ : event
+ ))
+ await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
+
+ try {
+ const payload = await fetchAiDocumentQueryPayload(intent)
+ const rawCount = extractExpenseClaimItems(payload).length
+ const filteredRecords = filterAiDocumentQueryRecords(payload, intent)
+ thinkingEvents = completeAiDocumentQueryEvent(
+ thinkingEvents,
+ 'document-query-fetch',
+ `接口返回 ${rawCount} 张候选单据,开始按自然语言条件筛选。`
+ )
+ thinkingEvents = thinkingEvents.map((event) => (
+ event.eventId === 'document-query-filter'
+ ? {
+ ...event,
+ content: `按“${conditionSummary}”组合筛选,当前命中 ${filteredRecords.length} 张。`,
+ status: 'running'
+ }
+ : event
+ ))
+ await updateAiDocumentQueryThinking(pendingMessage, thinkingEvents)
+ await waitForAiDocumentQueryStep()
+
+ const finalMessageText = buildAiDocumentQueryMessage(intent, payload)
+ thinkingEvents = completeAiDocumentQueryEvent(
+ thinkingEvents,
+ 'document-query-filter',
+ `筛选完成,命中 ${filteredRecords.length} 张单据,已生成卡片结果。`
+ )
+ replaceInlineMessage(
+ pendingMessage.id,
+ createInlineMessage('assistant', finalMessageText, {
+ id: pendingMessage.id,
+ stewardPlan: {
+ streamStatus: 'completed',
+ thinkingEvents
+ },
+ suggestedActions: []
+ })
+ )
+ } catch (error) {
+ const finalMessageText = error?.message || '查询单据时出现异常,请稍后再试。'
+ replaceInlineMessage(
+ pendingMessage.id,
+ createInlineMessage('assistant', finalMessageText, {
+ id: pendingMessage.id,
+ stewardPlan: {
+ streamStatus: 'failed',
+ thinkingEvents: failAiDocumentQueryEvents(thinkingEvents)
+ }
+ })
+ )
+ }
+
+ persistCurrentConversation()
+ return true
+ }
+
+ return {
+ handleAiDocumentQueryIntent
+ }
+}
diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js b/web/src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js
new file mode 100644
index 0000000..b1aca59
--- /dev/null
+++ b/web/src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js
@@ -0,0 +1,186 @@
+import { fetchExpenseClaims } from '../../services/reimbursements.js'
+import {
+ applyAiExpenseAnswer,
+ buildAiExpenseStepPrompt,
+ buildAiExpenseSummary,
+ createAiExpenseDraft,
+ isAiExpenseDraftComplete
+} from '../../utils/aiExpenseDraftModel.js'
+import {
+ buildExpenseSceneSelectionMessage,
+ SESSION_TYPE_EXPENSE
+} from '../../views/scripts/travelReimbursementConversationModel.js'
+import { buildExpenseSceneSelectionActions } from '../../utils/expenseAssistantActions.js'
+import {
+ buildRequiredApplicationActions,
+ buildRequiredApplicationMissingText,
+ buildRequiredApplicationSelectionText,
+ filterRequiredApplicationCandidates
+} from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
+
+export { SESSION_TYPE_EXPENSE }
+
+export function useWorkbenchAiExpenseFlow({
+ activateInlineConversation,
+ aiExpenseDraft,
+ assistantDraft,
+ clearAiModeFiles,
+ closeWorkbenchDatePicker,
+ conversationMessages,
+ conversationStarted,
+ createInlineMessage,
+ currentUser,
+ persistCurrentConversation,
+ pushInlineUserMessage,
+ removeWorkbenchDateTag,
+ resolveLatestInlineUserPrompt,
+ scrollInlineConversationToBottom,
+ startAiApplicationPreview
+}) {
+ function pushInlineExpenseSceneSelectionPrompt(originalMessage, selectedLabel = '') {
+ const sourceText = String(originalMessage || '我要报销').trim()
+ if (!conversationStarted.value) {
+ activateInlineConversation({
+ title: String(selectedLabel || sourceText || '报销').trim().slice(0, 18) || '报销'
+ })
+ }
+ assistantDraft.value = ''
+ removeWorkbenchDateTag()
+ closeWorkbenchDatePicker()
+ conversationMessages.value.push(createInlineMessage('user', String(selectedLabel || sourceText).trim()))
+ conversationMessages.value.push(createInlineMessage('assistant', buildExpenseSceneSelectionMessage(sourceText), {
+ suggestedActions: buildExpenseSceneSelectionActions(sourceText)
+ }))
+ persistCurrentConversation()
+ scrollInlineConversationToBottom()
+ }
+
+ function startAiApplicationPreviewFromAction(payload = {}, fallbackLabel = '') {
+ const expenseType = String(payload.expense_type || '').trim()
+ const expenseTypeLabel = String(payload.expense_type_label || fallbackLabel || '').trim()
+ return startAiApplicationPreview(
+ expenseType,
+ expenseTypeLabel,
+ payload.carry_text || resolveLatestInlineUserPrompt()
+ )
+ }
+
+ function startAiExpenseDraft(expenseType, expenseTypeLabel, requiresApplicationBeforeReimbursement) {
+ if (!conversationStarted.value) {
+ activateInlineConversation({ title: String(expenseTypeLabel || '报销').trim().slice(0, 18) || '报销' })
+ }
+ assistantDraft.value = ''
+ removeWorkbenchDateTag()
+ closeWorkbenchDatePicker()
+ clearAiModeFiles()
+ pushInlineUserMessage(`选择${expenseTypeLabel || expenseType || '报销'}`)
+
+ if (requiresApplicationBeforeReimbursement) {
+ void resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel)
+ return
+ }
+
+ const draft = createAiExpenseDraft(expenseType, expenseTypeLabel)
+ aiExpenseDraft.value = draft
+ conversationMessages.value.push(createInlineMessage('assistant', buildAiExpenseStepPrompt(draft)))
+ persistCurrentConversation()
+ scrollInlineConversationToBottom()
+ }
+
+ function advanceAiExpenseDraft(answer, files = []) {
+ const fileNames = Array.from(files || [])
+ pushInlineUserMessage(answer || (fileNames.length ? `上传 ${fileNames.length} 份附件` : ''))
+ assistantDraft.value = ''
+ clearAiModeFiles()
+
+ const next = applyAiExpenseAnswer(aiExpenseDraft.value, answer, fileNames)
+ aiExpenseDraft.value = next
+
+ if (isAiExpenseDraftComplete(next)) {
+ conversationMessages.value.push(createInlineMessage('assistant', `${buildAiExpenseSummary(next)}\n\n如果哪一项需要修改,直接告诉我;确认无误后我再帮你生成报销草稿。`))
+ aiExpenseDraft.value = null
+ } else {
+ conversationMessages.value.push(createInlineMessage('assistant', buildAiExpenseStepPrompt(next)))
+ }
+ persistCurrentConversation()
+ scrollInlineConversationToBottom()
+ }
+
+ async function resolveAiExpenseApplicationLink(expenseType, expenseTypeLabel) {
+ let claims = null
+ try {
+ claims = await fetchExpenseClaims()
+ } catch {
+ aiExpenseDraft.value = null
+ conversationMessages.value.push(createInlineMessage('assistant', '查询可关联申请单时出现异常,请稍后再试,我先暂停这次报销流程。'))
+ persistCurrentConversation()
+ scrollInlineConversationToBottom()
+ return
+ }
+
+ const candidates = filterRequiredApplicationCandidates(claims, expenseType, currentUser.value || {})
+ aiExpenseDraft.value = createAiExpenseDraft(expenseType, expenseTypeLabel)
+
+ if (!candidates.length) {
+ conversationMessages.value.push(createInlineMessage('assistant', buildRequiredApplicationMissingText(expenseType), {
+ suggestedActions: [{
+ label: '确认发起出差申请',
+ description: '生成完整申请表,并预填已识别的时间、地点和事由',
+ icon: 'mdi mdi-file-plus-outline',
+ action_type: 'ai_application_start_inline',
+ payload: {
+ expense_type: expenseType,
+ expense_type_label: expenseTypeLabel
+ }
+ }]
+ }))
+ persistCurrentConversation()
+ scrollInlineConversationToBottom()
+ return
+ }
+
+ conversationMessages.value.push(createInlineMessage('assistant', buildRequiredApplicationSelectionText(expenseType, candidates), {
+ suggestedActions: buildRequiredApplicationActions(candidates, 'select_required_application')
+ }))
+ persistCurrentConversation()
+ scrollInlineConversationToBottom()
+ }
+
+ function linkAiExpenseApplication(application = {}) {
+ const draft = aiExpenseDraft.value
+ if (!draft) {
+ return
+ }
+ const claimNo = String(application.application_claim_no || '').trim()
+ pushInlineUserMessage(`关联申请单 ${claimNo}`.trim())
+
+ const linked = {
+ ...draft,
+ applicationClaim: application,
+ values: {
+ ...draft.values,
+ reason: String(application.application_reason || '').trim(),
+ location: String(application.application_location || '').trim(),
+ time_range: String(application.application_business_time || '').trim(),
+ amount: String(application.application_amount_label || application.application_amount || '').trim()
+ },
+ stepKey: 'attachments'
+ }
+ aiExpenseDraft.value = linked
+ conversationMessages.value.push(createInlineMessage('assistant', [
+ `已关联申请单${claimNo ? ` ${claimNo}` : ''},事由、时间、地点、金额我先用申请单的内容预填了。`,
+ '',
+ '再确认一下票据:可以现在上传,或回复“稍后上传”。'
+ ].join('\n')))
+ persistCurrentConversation()
+ scrollInlineConversationToBottom()
+ }
+
+ return {
+ advanceAiExpenseDraft,
+ linkAiExpenseApplication,
+ pushInlineExpenseSceneSelectionPrompt,
+ startAiApplicationPreviewFromAction,
+ startAiExpenseDraft
+ }
+}
diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiMessageActions.js b/web/src/composables/workbenchAiMode/useWorkbenchAiMessageActions.js
new file mode 100644
index 0000000..26fb839
--- /dev/null
+++ b/web/src/composables/workbenchAiMode/useWorkbenchAiMessageActions.js
@@ -0,0 +1,33 @@
+export function useWorkbenchAiMessageActions({
+ assistantDraft,
+ focusAiModeInput,
+ persistCurrentConversation,
+ toast
+}) {
+ async function copyInlineMessage(message) {
+ try {
+ await navigator.clipboard?.writeText(message.content)
+ toast('已复制内容。')
+ } catch {
+ toast('当前浏览器暂不支持自动复制。')
+ }
+ }
+
+ function quoteInlineMessage(message) {
+ const quote = `> ${message.content}\n\n`
+ assistantDraft.value = assistantDraft.value ? assistantDraft.value + '\n' + quote : quote
+ focusAiModeInput()
+ }
+
+ function markInlineMessageFeedback(message, feedback) {
+ message.feedback = feedback
+ persistCurrentConversation()
+ toast(feedback === 'up' ? '已记录有帮助反馈。' : '已记录需要改进反馈。')
+ }
+
+ return {
+ copyInlineMessage,
+ markInlineMessageFeedback,
+ quoteInlineMessage
+ }
+}
diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiSessionCommands.js b/web/src/composables/workbenchAiMode/useWorkbenchAiSessionCommands.js
new file mode 100644
index 0000000..758cfb5
--- /dev/null
+++ b/web/src/composables/workbenchAiMode/useWorkbenchAiSessionCommands.js
@@ -0,0 +1,97 @@
+export function useWorkbenchAiSessionCommands({
+ activeConversationTitle,
+ attachmentOcrExpandedMessageIds,
+ conversationId,
+ conversationMessages,
+ conversationStarted,
+ createInlineMessage,
+ currentUser,
+ deleteAiWorkbenchConversation,
+ emit,
+ focusAiModeInput,
+ inlineConversationAutoScrollPinned,
+ normalizeRuntimeMessage,
+ refreshConversationHistory,
+ resetInlineConversationState,
+ scrollInlineConversationToBottom,
+ stewardState,
+ thinkingCollapsedMessageIds,
+ thinkingExpandedMessageIds,
+ toast
+}) {
+ function startNewInlineConversation() {
+ resetInlineConversationState()
+ emit('conversation-change', { id: '', title: '' })
+ refreshConversationHistory()
+ focusAiModeInput()
+ }
+
+ function openInlineSearchConversation(activateInlineConversation) {
+ conversationMessages.value = [
+ createInlineMessage('assistant', '你可以输入关键词搜索历史对话,也可以直接描述要继续处理的费用事项。')
+ ]
+ stewardState.value = null
+ thinkingExpandedMessageIds.value = new Set()
+ thinkingCollapsedMessageIds.value = new Set()
+ attachmentOcrExpandedMessageIds.value = new Set()
+ conversationId.value = 'ai-search'
+ activateInlineConversation({ id: 'ai-search', title: '查询对话' })
+ focusAiModeInput()
+ scrollInlineConversationToBottom()
+ }
+
+ function openInlineRecentConversation(item = {}) {
+ const title = String(item.title || '最近对话').trim()
+ conversationId.value = String(item.id || `recent-${Date.now()}`).trim()
+ activeConversationTitle.value = title
+ stewardState.value = item.stewardState || null
+ thinkingExpandedMessageIds.value = new Set()
+ thinkingCollapsedMessageIds.value = new Set()
+ attachmentOcrExpandedMessageIds.value = new Set()
+ inlineConversationAutoScrollPinned.value = true
+ conversationMessages.value = Array.isArray(item.messages) && item.messages.length
+ ? item.messages.map((message) => normalizeRuntimeMessage(message))
+ : [
+ createInlineMessage(
+ 'assistant',
+ '这条历史对话没有保存完整消息。你可以继续输入新的问题,小财管家会接着处理。'
+ )
+ ]
+ conversationStarted.value = true
+ emit('conversation-change', { id: conversationId.value, title })
+ focusAiModeInput()
+ scrollInlineConversationToBottom()
+ }
+
+ function requestDeleteCurrentConversation(deleteDialogOpen) {
+ if (!conversationMessages.value.length) {
+ return
+ }
+ deleteDialogOpen.value = true
+ }
+
+ function cancelDeleteConversation(deleteDialogOpen) {
+ deleteDialogOpen.value = false
+ }
+
+ function confirmDeleteConversation(deleteDialogOpen) {
+ const nextHistory = conversationId.value
+ ? deleteAiWorkbenchConversation(currentUser.value || {}, conversationId.value)
+ : refreshConversationHistory()
+ emit('conversation-history-change', nextHistory)
+ resetInlineConversationState()
+ deleteDialogOpen.value = false
+ emit('conversation-change', { id: '', title: '' })
+ toast('已删除当前对话。')
+ focusAiModeInput()
+ }
+
+ return {
+ cancelDeleteConversation,
+ confirmDeleteConversation,
+ openInlineRecentConversation,
+ openInlineSearchConversation,
+ requestDeleteCurrentConversation,
+ startNewInlineConversation
+ }
+}
diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiStewardFlow.js b/web/src/composables/workbenchAiMode/useWorkbenchAiStewardFlow.js
new file mode 100644
index 0000000..ce8940b
--- /dev/null
+++ b/web/src/composables/workbenchAiMode/useWorkbenchAiStewardFlow.js
@@ -0,0 +1,371 @@
+import {
+ fetchStewardPlan,
+ fetchStewardPlanStream
+} from '../../services/steward.js'
+import { fetchExpenseClaims } from '../../services/reimbursements.js'
+import {
+ buildStewardPlanMessageText,
+ buildStewardPlanRequest,
+ buildStewardSuggestedActions,
+ normalizeStewardPlan
+} from '../../views/scripts/stewardPlanModel.js'
+import {
+ buildRequiredApplicationActions,
+ buildRequiredApplicationMissingText,
+ buildRequiredApplicationSelectionText,
+ filterRequiredApplicationCandidates
+} from '../../views/scripts/travelReimbursementApplicationLinkModel.js'
+
+function shouldCheckAiRequiredApplicationGate(prompt) {
+ const compact = String(prompt || '').replace(/\s+/g, '')
+ if (!compact || !/(出差|差旅|部署|实施|支撑|支持|协助|拜访|调研|驻场|上线|验收)/.test(compact)) {
+ return false
+ }
+ if (!/\d{1,2}月\d{1,2}|昨天|前天|上周|上月/.test(compact)) {
+ return false
+ }
+ return !/(申请|报销|草稿|提交|审批|保存|发起|创建)/.test(compact)
+}
+
+function serializeRequiredApplicationCandidate(candidate = {}) {
+ return {
+ id: String(candidate.id || '').trim(),
+ claim_no: String(candidate.claim_no || '').trim(),
+ reason: String(candidate.reason || '').trim(),
+ location: String(candidate.location || '').trim(),
+ business_time: String(candidate.business_time || '').trim(),
+ status_label: String(candidate.status_label || '').trim()
+ }
+}
+
+function resolveRequiredApplicationGateContinuationFlow(normalizedPlan) {
+ if (String(normalizedPlan?.pendingFlowConfirmation?.status || '').trim() !== 'pending') {
+ return null
+ }
+ const flows = Array.isArray(normalizedPlan?.candidateFlows) ? normalizedPlan.candidateFlows : []
+ const applicationFlow = flows.find((flow) => flow.flowId === 'travel_application')
+ if (flows.length === 1 && applicationFlow && /先发起出差申请/.test(applicationFlow.label)) {
+ return applicationFlow
+ }
+ return flows.find((flow) => (
+ flow.flowId === 'travel_reimbursement' &&
+ /关联已有申请单/.test(flow.label)
+ )) || null
+}
+
+function buildAiRequiredApplicationGateAutoMessage(normalizedPlan, flow) {
+ const baseText = buildStewardPlanMessageText({
+ planStatus: normalizedPlan?.planStatus,
+ nextAction: normalizedPlan?.nextAction,
+ summary: normalizedPlan?.summary,
+ pendingFlowConfirmation: normalizedPlan?.pendingFlowConfirmation,
+ candidateFlows: normalizedPlan?.candidateFlows
+ })
+ const contextText = String(baseText || '')
+ .split(/\n\n1\. \*\*/)[0]
+ .trim()
+ .replace('### 需要先确认流程方向', '### 我已先查询申请单')
+ if (flow?.flowId === 'travel_application') {
+ return [
+ contextText || baseText,
+ '这类操作需要你手动确认。请点击下方 **确认发起出差申请**,我再在当前对话里生成完整申请表,并把已识别的信息自动预填。'
+ ].filter(Boolean).join('\n\n')
+ }
+ if (flow?.flowId === 'travel_reimbursement') {
+ return [
+ contextText || baseText,
+ '这类操作需要你手动确认。请点击下方 **确认关联已有申请单**,我再继续查询并展示可关联单据。'
+ ].filter(Boolean).join('\n\n')
+ }
+ return baseText
+}
+
+function buildAiRequiredApplicationGateSuggestedActions(flow, prompt = '') {
+ if (flow.flowId === 'travel_application') {
+ return [{
+ label: '确认发起出差申请',
+ description: '确认后生成完整申请表,并预填已识别的时间、地点和事由。',
+ icon: 'mdi mdi-file-plus-outline',
+ action_type: 'ai_application_start_inline',
+ payload: {
+ expense_type: 'travel',
+ expense_type_label: '差旅费',
+ carry_text: prompt
+ }
+ }]
+ }
+ if (flow.flowId === 'travel_reimbursement') {
+ return [{
+ label: '确认关联已有申请单',
+ description: '确认后查询你名下可关联的差旅申请单,并进入关联步骤。',
+ icon: 'mdi mdi-link-variant',
+ action_type: 'steward_confirm_flow',
+ payload: {
+ steward_confirm_flow: true,
+ flow_id: 'travel_reimbursement',
+ expense_type: 'travel',
+ expense_type_label: '差旅费',
+ carry_text: prompt
+ }
+ }]
+ }
+ return []
+}
+
+function normalizeStreamThinkingEvent(event = {}) {
+ const data = event?.data && typeof event.data === 'object' ? event.data : {}
+ const eventId = String(data.event_id || data.eventId || data.stage || `thinking-${Date.now()}`).trim()
+ return {
+ eventId,
+ stage: String(data.stage || '').trim(),
+ title: String(data.title || '小财管家正在分析').trim(),
+ content: String(data.content || '').trim(),
+ status: String(data.status || 'running').trim() || 'running'
+ }
+}
+
+export function useWorkbenchAiStewardFlow({
+ activeConversationTitle,
+ collectAiModeReceiptContext,
+ conversationId,
+ conversationMessages,
+ createInlineMessage,
+ currentUser,
+ deleteAiWorkbenchConversation,
+ emit,
+ handleAiDocumentQueryIntent,
+ inlineConversationAutoScrollPinned,
+ persistCurrentConversation,
+ replaceInlineMessage,
+ resolveInlineThinkingEvents,
+ scrollInlineConversationToBottom,
+ sending,
+ stewardState,
+ streamInlineAssistantContent,
+ updateInlineMessageContent,
+ appendInlineMessageContent,
+ toast
+}) {
+ async function attachAiRequiredApplicationGate(planRequest, prompt) {
+ if (!shouldCheckAiRequiredApplicationGate(prompt)) {
+ return planRequest
+ }
+
+ try {
+ const claims = await fetchExpenseClaims()
+ const candidates = filterRequiredApplicationCandidates(claims, 'travel', currentUser.value || {})
+ planRequest.context_json = {
+ ...(planRequest.context_json || {}),
+ required_application_gate: {
+ ...((planRequest.context_json || {}).required_application_gate || {}),
+ travel: {
+ checked: true,
+ candidate_count: candidates.length,
+ candidates: candidates.slice(0, 5).map((candidate) => serializeRequiredApplicationCandidate(candidate))
+ }
+ }
+ }
+ } catch (error) {
+ console.warn('AI mode required application lookup failed:', error)
+ planRequest.context_json = {
+ ...(planRequest.context_json || {}),
+ required_application_gate: {
+ ...((planRequest.context_json || {}).required_application_gate || {}),
+ travel: {
+ checked: false,
+ query_failed: true
+ }
+ }
+ }
+ }
+ return planRequest
+ }
+
+ function handleInlineStewardStreamEvent(messageId, event) {
+ const message = conversationMessages.value.find((item) => item.id === messageId)
+ if (!message) {
+ return
+ }
+
+ if (event?.event === 'answer_delta') {
+ const data = event?.data && typeof event.data === 'object' ? event.data : {}
+ const shouldAutoScroll = inlineConversationAutoScrollPinned.value
+ appendInlineMessageContent(message, data.delta || data.content || data.text || '')
+ message.stewardPlan = {
+ ...(message.stewardPlan || {}),
+ streamStatus: 'streaming'
+ }
+ scrollInlineConversationToBottom({ force: shouldAutoScroll })
+ return
+ }
+
+ if (event?.event !== 'thinking') {
+ return
+ }
+
+ const nextEvent = normalizeStreamThinkingEvent(event)
+ const shouldAutoScroll = inlineConversationAutoScrollPinned.value
+ const currentPlan = message.stewardPlan || {}
+ const currentEvents = Array.isArray(currentPlan.thinkingEvents) ? currentPlan.thinkingEvents : []
+ const eventIndex = currentEvents.findIndex((item) => item.eventId && item.eventId === nextEvent.eventId)
+ const nextEvents = eventIndex >= 0
+ ? currentEvents.map((item, index) => (index === eventIndex ? { ...item, ...nextEvent } : item))
+ : [...currentEvents, nextEvent]
+
+ message.stewardPlan = {
+ ...currentPlan,
+ thinkingEvents: nextEvents,
+ streamStatus: 'streaming'
+ }
+ scrollInlineConversationToBottom({ force: shouldAutoScroll })
+ }
+
+ async function fetchInlineStewardPlan(messageId, payload) {
+ try {
+ return await fetchStewardPlanStream(
+ payload,
+ {
+ onEvent: (event) => handleInlineStewardStreamEvent(messageId, event)
+ },
+ {
+ idleTimeoutMs: 90000,
+ timeoutMessage: '小财管家仍在规划任务,已停止等待。你可以稍后继续追问。'
+ }
+ )
+ } catch (error) {
+ if (String(error?.message || '').includes('流式服务')) {
+ return fetchStewardPlan(payload, {
+ timeoutMs: 75000,
+ timeoutMessage: '小财管家仍在规划任务,已停止等待。你可以稍后继续追问。'
+ })
+ }
+ throw error
+ }
+ }
+
+ async function requestInlineAssistantReply(prompt, entry = {}, files = []) {
+ let shouldAutoScrollOnFinish = true
+ const pendingMessage = createInlineMessage('assistant', '', {
+ pending: true,
+ stewardPlan: {
+ streamStatus: 'streaming',
+ thinkingEvents: [
+ {
+ eventId: 'init',
+ title: '小财管家正在接入业务流程',
+ content: '正在识别你的意图、上下文和附件信息。',
+ status: 'running'
+ }
+ ]
+ }
+ })
+ conversationMessages.value.push(pendingMessage)
+ scrollInlineConversationToBottom()
+
+ try {
+ if (await handleAiDocumentQueryIntent(prompt, pendingMessage)) {
+ shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
+ return
+ }
+
+ const receiptContext = await collectAiModeReceiptContext(files)
+ const planRequest = buildStewardPlanRequest({
+ rawText: prompt,
+ files,
+ currentUser: currentUser.value || {},
+ conversationId: conversationId.value,
+ stewardState: stewardState.value
+ })
+ planRequest.context_json = {
+ ...planRequest.context_json,
+ entry_source: 'workbench_ai_inline',
+ source: entry.source || 'workbench',
+ attachment_names: receiptContext.attachmentNames,
+ attachment_count: receiptContext.attachmentCount,
+ ocr_summary: receiptContext.ocrSummary,
+ ocr_documents: receiptContext.ocrDocuments,
+ ocr_source_file_names: receiptContext.ocrSourceFileNames,
+ ...(receiptContext.ocrError ? { ocr_error: receiptContext.ocrError } : {})
+ }
+ await attachAiRequiredApplicationGate(planRequest, prompt)
+
+ const plan = await fetchInlineStewardPlan(pendingMessage.id, planRequest)
+ const normalizedPlan = normalizeStewardPlan(plan, {
+ visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
+ initialSummaryOnly: true
+ })
+ const previousThinkingEvents = resolveInlineThinkingEvents(pendingMessage)
+ const nextThinkingEvents = normalizedPlan.thinkingEvents.length
+ ? normalizedPlan.thinkingEvents
+ : previousThinkingEvents.map((item) => ({ ...item, status: 'completed' }))
+ const previousConversationId = conversationId.value
+ const nextConversationId = String(normalizedPlan.conversationId || '').trim()
+ if (nextConversationId) {
+ conversationId.value = nextConversationId
+ emit('conversation-change', { id: conversationId.value, title: activeConversationTitle.value })
+ if (previousConversationId && previousConversationId !== nextConversationId) {
+ deleteAiWorkbenchConversation(currentUser.value || {}, previousConversationId)
+ }
+ }
+ if (normalizedPlan.stewardState) {
+ stewardState.value = normalizedPlan.stewardState
+ }
+ const requiredApplicationContinuationFlow = resolveRequiredApplicationGateContinuationFlow(normalizedPlan)
+ const finalMessageText = requiredApplicationContinuationFlow
+ ? buildAiRequiredApplicationGateAutoMessage(normalizedPlan, requiredApplicationContinuationFlow)
+ : buildStewardPlanMessageText(plan)
+ const hasServerStreamedContent = Boolean(String(pendingMessage.content || '').trim())
+ if (!hasServerStreamedContent) {
+ await streamInlineAssistantContent(pendingMessage.id, finalMessageText)
+ }
+ shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
+ replaceInlineMessage(
+ pendingMessage.id,
+ createInlineMessage('assistant', finalMessageText, {
+ id: pendingMessage.id,
+ stewardPlan: {
+ ...normalizedPlan,
+ thinkingEvents: nextThinkingEvents,
+ streamStatus: 'completed'
+ },
+ suggestedActions: requiredApplicationContinuationFlow
+ ? buildAiRequiredApplicationGateSuggestedActions(requiredApplicationContinuationFlow, prompt)
+ : buildStewardSuggestedActions(plan)
+ })
+ )
+ persistCurrentConversation()
+ } catch (error) {
+ shouldAutoScrollOnFinish = inlineConversationAutoScrollPinned.value
+ replaceInlineMessage(
+ pendingMessage.id,
+ createInlineMessage(
+ 'assistant',
+ error?.message || '小财管家暂时无法完成规划,请稍后再试。',
+ {
+ id: pendingMessage.id,
+ stewardPlan: {
+ streamStatus: 'failed',
+ thinkingEvents: resolveInlineThinkingEvents(pendingMessage).map((item) => ({
+ ...item,
+ status: 'failed'
+ }))
+ }
+ }
+ )
+ )
+ toast(error?.message || '小财管家暂时无法完成规划。')
+ persistCurrentConversation()
+ } finally {
+ sending.value = false
+ scrollInlineConversationToBottom({ force: shouldAutoScrollOnFinish })
+ }
+ }
+
+ return {
+ buildRequiredApplicationActions,
+ buildRequiredApplicationMissingText,
+ buildRequiredApplicationSelectionText,
+ filterRequiredApplicationCandidates,
+ requestInlineAssistantReply
+ }
+}
diff --git a/web/src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js b/web/src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js
new file mode 100644
index 0000000..f44b323
--- /dev/null
+++ b/web/src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js
@@ -0,0 +1,340 @@
+import {
+ buildApplicationTemplatePreview,
+ buildLocalApplicationPreview,
+ normalizeApplicationPreview
+} from '../../utils/expenseApplicationPreview.js'
+import { AI_APPLICATION_DETAIL_HREF_PREFIX } from '../../utils/aiDocumentDetailReference.js'
+import {
+ buildAiApplicationPrecheckThinkingEvents,
+ isAiApplicationPrecheckBlocking
+} from '../../utils/aiApplicationPrecheckModel.js'
+import { extractExpenseClaimItems } from '../../services/reimbursements.js'
+import {
+ AI_APPLICATION_ACTION_SAVE_DRAFT,
+ AI_APPLICATION_ACTION_SUBMIT
+} from '../../services/aiApplicationPreviewActions.js'
+
+const INLINE_APPLICATION_STATUS_LABELS = {
+ draft: '草稿',
+ submitted: '审批中',
+ pending: '待处理',
+ approved: '已审批',
+ completed: '已完成',
+ archived: '已归档',
+ returned: '已退回',
+ rejected: '已驳回',
+ pending_payment: '待付款',
+ paid: '已付款'
+}
+
+function normalizeInlineApplicationResultTableCell(value, fallback = '-') {
+ const text = String(value || '')
+ .replace(/\s*\n+\s*/g, ' ')
+ .replace(/\|/g, '|')
+ .trim()
+ return text || fallback
+}
+
+export function normalizeInlineApplicationStatusLabel(value, fallback = '') {
+ const text = String(value || '').trim()
+ if (!text) {
+ return fallback
+ }
+ return INLINE_APPLICATION_STATUS_LABELS[text.toLowerCase()] || text
+}
+
+export function buildInlineApplicationActionDetailHref(reference = '') {
+ const source = reference && typeof reference === 'object' ? reference : { reference }
+ const claimId = String(source.claimId || source.claim_id || source.id || '').trim()
+ const claimNo = String(source.claimNo || source.claim_no || source.documentNo || source.document_no || '').trim()
+ const fallback = String(source.reference || '').trim()
+ if (claimId || claimNo) {
+ const params = new URLSearchParams()
+ if (claimId) {
+ params.set('claim_id', claimId)
+ }
+ if (claimNo) {
+ params.set('claim_no', claimNo)
+ }
+ return `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(params.toString())}`
+ }
+ return fallback ? `${AI_APPLICATION_DETAIL_HREF_PREFIX}${encodeURIComponent(fallback)}` : ''
+}
+
+export function resolveInlineApplicationActionDocumentInfo(draftPayload = {}) {
+ const source = draftPayload && typeof draftPayload === 'object' ? draftPayload : {}
+ const body = String(source.body || source.markdown || '').trim()
+ const resolveBodyField = (labels = []) => {
+ for (const label of labels) {
+ const pattern = new RegExp(`${label}\\s*[::]\\s*([^\\n|]+)`, 'u')
+ const match = body.match(pattern)
+ if (match?.[1]) {
+ return String(match[1]).replace(/\*\*/g, '').trim()
+ }
+ }
+ return ''
+ }
+ const startDate = String(source.start_date || source.startDate || source.trip_start_date || source.tripStartDate || '').trim()
+ const endDate = String(source.end_date || source.endDate || source.trip_end_date || source.tripEndDate || '').trim()
+ const dateText = String(
+ source.business_time ||
+ source.businessTime ||
+ source.time ||
+ source.occurred_at ||
+ source.occurredAt ||
+ source.apply_time ||
+ source.applyTime ||
+ ''
+ ).trim()
+ const rangeText = startDate && endDate && startDate !== endDate
+ ? `${startDate} 至 ${endDate}`
+ : startDate || endDate
+ return {
+ claimNo: String(source.claim_no || source.claimNo || source.document_no || source.documentNo || '').trim(),
+ claimId: String(source.claim_id || source.claimId || source.id || '').trim(),
+ statusLabel: normalizeInlineApplicationStatusLabel(source.status_label || source.statusLabel || source.status),
+ approvalStage: String(source.approval_stage || source.approvalStage || '').trim(),
+ dateLabel: rangeText || dateText || resolveBodyField(['时间', '日期', '申请时间']) || '待补充',
+ locationLabel: String(
+ source.location ||
+ source.application_location ||
+ source.applicationLocation ||
+ source.destination ||
+ source.destination_city ||
+ source.destinationCity ||
+ ''
+ ).trim() || resolveBodyField(['地点', '目的地']) || '待补充',
+ reasonLabel: String(
+ source.reason ||
+ source.business_reason ||
+ source.businessReason ||
+ source.description ||
+ source.title ||
+ ''
+ ).trim() || resolveBodyField(['事由', '事件', '申请事由']) || '待补充',
+ amountLabel: String(
+ source.amount ||
+ source.application_amount ||
+ source.applicationAmount ||
+ source.estimated_amount ||
+ source.estimatedAmount ||
+ ''
+ ).trim() || resolveBodyField(['金额', '预计金额', '申请金额']) || '-',
+ documentTypeLabel: String(
+ source.document_type_label ||
+ source.documentTypeLabel ||
+ source.application_type_label ||
+ source.applicationTypeLabel ||
+ source.expense_type_label ||
+ source.expenseTypeLabel ||
+ ''
+ ).trim()
+ }
+}
+
+export function buildInlineApplicationResultTable(draftPayload = {}, options = {}) {
+ const info = resolveInlineApplicationActionDocumentInfo(draftPayload)
+ const reference = info.claimNo || info.claimId
+ const href = buildInlineApplicationActionDetailHref(info)
+ const actionText = href ? `[查看](${href})` : '-'
+ const statusLabel = normalizeInlineApplicationStatusLabel(info.statusLabel, options.statusLabel)
+ return [
+ '| 单据类型 | 单据编号 | 单据状态 | 当前节点 | 日期 | 地点 | 事由 | 金额 | 操作 |',
+ '| --- | --- | --- | --- | --- | --- | --- | --- | --- |',
+ `| ${normalizeInlineApplicationResultTableCell(info.documentTypeLabel || options.documentTypeLabel, '出差申请')} | ${normalizeInlineApplicationResultTableCell(reference)} | ${normalizeInlineApplicationResultTableCell(statusLabel)} | ${normalizeInlineApplicationResultTableCell(info.approvalStage || options.stageLabel)} | ${normalizeInlineApplicationResultTableCell(info.dateLabel)} | ${normalizeInlineApplicationResultTableCell(info.locationLabel)} | ${normalizeInlineApplicationResultTableCell(info.reasonLabel)} | ${normalizeInlineApplicationResultTableCell(info.amountLabel, '-')} | ${actionText} |`
+ ].join('\n')
+}
+
+export function extractInlineApplicationDraftPayload(payload = {}) {
+ const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
+ return result.draft_payload && typeof result.draft_payload === 'object'
+ ? result.draft_payload
+ : payload?.draft_payload && typeof payload.draft_payload === 'object'
+ ? payload.draft_payload
+ : null
+}
+
+export function buildInlineApplicationPreviewActionResultText(actionType, payload = {}) {
+ const draftPayload = extractInlineApplicationDraftPayload(payload) || {}
+ const claimNo = String(draftPayload.claim_no || draftPayload.claimNo || '').trim()
+ const approvalStage = String(draftPayload.approval_stage || draftPayload.approvalStage || '').trim()
+ if (actionType === AI_APPLICATION_ACTION_SUBMIT) {
+ return [
+ '### 申请单据已生成,并已进入审批流程',
+ approvalStage ? `系统已推送到 **${approvalStage}**,当前节点:${approvalStage}。` : '系统已推送到审批流程,当前节点:审批中。',
+ buildInlineApplicationResultTable(draftPayload, {
+ statusLabel: '审批中',
+ stageLabel: approvalStage || '直属领导审批',
+ documentTypeLabel: '出差申请'
+ }),
+ '需要查看完整详情时,请点击卡片“操作”行的“查看”进入单据详情。'
+ ].filter(Boolean).join('\n\n')
+ }
+ return [
+ '### 申请草稿已保存',
+ claimNo ? `系统已保存当前申请草稿,草稿单号:**${claimNo}**。` : '系统已保存当前申请草稿。',
+ buildInlineApplicationResultTable(draftPayload, {
+ statusLabel: '草稿',
+ stageLabel: '待提交',
+ documentTypeLabel: '出差申请'
+ }),
+ '后续请点击卡片“操作”行的“查看”进入详情页继续核对。'
+ ].filter(Boolean).join('\n\n')
+}
+
+export function buildInlineApplicationDetailAction(draftPayload = {}) {
+ const claimNo = String(draftPayload?.claim_no || draftPayload?.claimNo || '').trim()
+ if (!claimNo) {
+ return []
+ }
+ return [{
+ label: '查看单据详情',
+ description: '打开刚生成的申请单详情。',
+ icon: 'mdi mdi-open-in-new',
+ action_type: 'open_application_detail',
+ payload: {
+ claim_no: claimNo,
+ claim_id: String(draftPayload.claim_id || draftPayload.claimId || '').trim(),
+ document_type: 'application'
+ }
+ }]
+}
+
+export function resolveInlineApplicationPreviewActionFromText(text = '') {
+ const normalized = String(text || '').replace(/\s+/g, '').trim()
+ if (!normalized) {
+ return ''
+ }
+ if (/^(保存草稿|保存|存草稿|先保存)$/.test(normalized)) {
+ return AI_APPLICATION_ACTION_SAVE_DRAFT
+ }
+ if (/^(提交|提交申请|确认提交|提交审批|直接提交)$/.test(normalized)) {
+ return AI_APPLICATION_ACTION_SUBMIT
+ }
+ return ''
+}
+
+export function normalizeInlineApplicationTypeLabel(expenseTypeLabel, fallback = '费用申请') {
+ const label = String(expenseTypeLabel || '').trim()
+ if (!label) {
+ return fallback
+ }
+ if (label.endsWith('费用申请') || label.endsWith('申请')) {
+ return label
+ }
+ if (label.endsWith('费用')) {
+ return `${label}申请`
+ }
+ if (label.endsWith('费')) {
+ return `${label.slice(0, -1)}费用申请`
+ }
+ return `${label}申请`
+}
+
+export function buildInlineApplicationPreview(expenseTypeLabel, sourceText = '', currentUser = {}) {
+ const rawText = String(sourceText || '').trim()
+ const preview = rawText
+ ? buildLocalApplicationPreview(rawText, currentUser)
+ : buildApplicationTemplatePreview(currentUser)
+ const normalized = normalizeApplicationPreview(preview)
+ return normalizeApplicationPreview({
+ ...normalized,
+ fields: {
+ ...(normalized.fields || {}),
+ applicationType: normalizeInlineApplicationTypeLabel(
+ expenseTypeLabel,
+ normalized.fields?.applicationType || '费用申请'
+ )
+ }
+ })
+}
+
+export function resolveInlineApplicationDraftIdentity(payload = {}) {
+ const source = payload && typeof payload === 'object' ? payload : {}
+ return {
+ claimId: String(source.claim_id || source.claimId || source.id || '').trim(),
+ claimNo: String(source.claim_no || source.claimNo || source.document_no || source.documentNo || '').trim()
+ }
+}
+
+export function isSameInlineApplicationDraftClaim(claim = {}, draftPayload = {}) {
+ const draftIdentity = resolveInlineApplicationDraftIdentity(draftPayload)
+ if (!draftIdentity.claimId && !draftIdentity.claimNo) {
+ return false
+ }
+ const claimIdentity = resolveInlineApplicationDraftIdentity(claim)
+ return Boolean(
+ (draftIdentity.claimId && claimIdentity.claimId && draftIdentity.claimId === claimIdentity.claimId) ||
+ (draftIdentity.claimNo && claimIdentity.claimNo && draftIdentity.claimNo === claimIdentity.claimNo)
+ )
+}
+
+export function buildInlineApplicationSubmitPrecheckPayload(claimsPayload, draftPayload = {}) {
+ const items = extractExpenseClaimItems(claimsPayload)
+ .filter((claim) => !isSameInlineApplicationDraftClaim(claim, draftPayload))
+ return { items }
+}
+
+export function completeInlineThinkingEvents(events = []) {
+ return events.map((event) => ({
+ ...event,
+ status: event.status === 'failed' ? 'failed' : 'completed'
+ }))
+}
+
+export function buildInitialInlineApplicationSubmitThinkingEvents() {
+ return [
+ {
+ eventId: 'application-precheck-overlap',
+ title: '核查同时间段申请单',
+ content: '正在查询你名下可见申请单,检查是否存在相同或重叠日期。',
+ status: 'running'
+ },
+ {
+ eventId: 'application-precheck-budget',
+ title: '评估预算与审批影响',
+ content: '等待单据重叠核查完成后,继续评估预算占用和审批影响。',
+ status: 'pending'
+ },
+ {
+ eventId: 'application-submit',
+ title: '提交申请单据',
+ content: '等待提交前核查完成。',
+ status: 'pending'
+ }
+ ]
+}
+
+export function buildInlineApplicationSubmitThinkingEvents(precheck = {}) {
+ const blocked = isAiApplicationPrecheckBlocking(precheck)
+ return buildAiApplicationPrecheckThinkingEvents(precheck).map((event) => {
+ if (event.eventId !== 'application-precheck-form') {
+ return event
+ }
+ return {
+ eventId: 'application-submit',
+ title: blocked ? '暂停提交申请' : '提交申请单据',
+ content: blocked
+ ? '发现相同或重叠日期已有申请单,已暂停本次提交。'
+ : '提交前核查通过,正在生成申请单据并推送审批流程。',
+ status: blocked ? 'completed' : 'running'
+ }
+ })
+}
+
+export function buildFailedInlineApplicationSubmitThinkingEvents(error) {
+ return [
+ {
+ eventId: 'application-precheck-overlap',
+ title: '核查同时间段申请单',
+ content: `查询已有申请单失败:${String(error?.message || error || '未知错误')}`,
+ status: 'failed'
+ },
+ {
+ eventId: 'application-submit',
+ title: '暂停提交申请',
+ content: '因为未能完成提交前重复日期核查,系统没有提交本次申请。',
+ status: 'failed'
+ }
+ ]
+}
diff --git a/web/src/composables/workbenchAiMode/workbenchAiComposerModel.js b/web/src/composables/workbenchAiMode/workbenchAiComposerModel.js
new file mode 100644
index 0000000..18f2838
--- /dev/null
+++ b/web/src/composables/workbenchAiMode/workbenchAiComposerModel.js
@@ -0,0 +1,100 @@
+import { buildFileIdentity } from '../../views/scripts/travelReimbursementAttachmentModel.js'
+
+export const AI_COMPOSER_FILE_TYPE_META = {
+ pdf: { label: 'PDF', icon: 'mdi mdi-file-pdf-box', tone: 'pdf' },
+ image: { label: '图片', icon: 'mdi mdi-file-image-outline', tone: 'image' },
+ spreadsheet: { label: '表格', icon: 'mdi mdi-file-excel-outline', tone: 'spreadsheet' },
+ document: { label: '文档', icon: 'mdi mdi-file-document-outline', tone: 'document' },
+ archive: { label: '压缩包', icon: 'mdi mdi-folder-zip-outline', tone: 'archive' },
+ file: { label: '文件', icon: 'mdi mdi-file-outline', tone: 'file' }
+}
+
+export const AI_MODE_ACTION_ITEMS = [
+ {
+ label: '发起报销',
+ icon: 'mdi mdi-file-document-plus-outline',
+ prompt: '帮我发起一笔报销,并检查需要准备哪些票据材料。',
+ source: 'workbench',
+ sessionType: 'expense'
+ },
+ {
+ label: '查询预算',
+ icon: 'mdi mdi-chart-pie-outline',
+ prompt: '帮我查询当前预算余额和近期费用占用情况。',
+ source: 'budget',
+ sessionType: 'budget'
+ },
+ {
+ label: '解释制度',
+ icon: 'mdi mdi-book-open-page-variant-outline',
+ prompt: '帮我解释公司报销制度,并列出这次需要注意的条款。',
+ source: 'workbench',
+ sessionType: 'knowledge'
+ },
+ {
+ label: '催办审批',
+ icon: 'mdi mdi-bell-ring-outline',
+ prompt: '帮我查询待审批单据,并生成一段礼貌的催办说明。',
+ source: 'workbench',
+ sessionType: 'approval'
+ }
+]
+
+export function resolveAiComposerFileName(file) {
+ return String(file?.name || '未命名附件').trim() || '未命名附件'
+}
+
+export function resolveAiComposerFileType(file) {
+ const fileName = resolveAiComposerFileName(file).toLowerCase()
+ const mimeType = String(file?.type || '').toLowerCase()
+ const extension = fileName.includes('.') ? fileName.split('.').pop() : ''
+ if (extension === 'pdf' || mimeType.includes('pdf')) {
+ return AI_COMPOSER_FILE_TYPE_META.pdf
+ }
+ if (/^(png|jpe?g|gif|webp|bmp|svg|heic)$/.test(extension) || mimeType.startsWith('image/')) {
+ return AI_COMPOSER_FILE_TYPE_META.image
+ }
+ if (/^(xls|xlsx|csv|numbers)$/.test(extension) || mimeType.includes('spreadsheet') || mimeType.includes('excel')) {
+ return AI_COMPOSER_FILE_TYPE_META.spreadsheet
+ }
+ if (/^(doc|docx|txt|md|pages)$/.test(extension) || mimeType.includes('word') || mimeType.includes('text')) {
+ return AI_COMPOSER_FILE_TYPE_META.document
+ }
+ if (/^(zip|rar|7z|tar|gz)$/.test(extension) || mimeType.includes('zip') || mimeType.includes('compressed')) {
+ return AI_COMPOSER_FILE_TYPE_META.archive
+ }
+ return AI_COMPOSER_FILE_TYPE_META.file
+}
+
+export function buildSelectedFileCards(files = []) {
+ return files.map((file) => ({
+ key: buildFileIdentity(file),
+ name: resolveAiComposerFileName(file),
+ ...resolveAiComposerFileType(file)
+ }))
+}
+
+export function isLikelyAiModeOcrFile(file = {}) {
+ const name = String(file?.name || '').trim()
+ const type = String(file?.type || '').trim()
+ return /\.(pdf|jpe?g|png|webp|bmp)$/i.test(name) || /^(image\/|application\/pdf)/i.test(type)
+}
+
+export function isLikelyReceiptAssociationFile(file = {}) {
+ return isLikelyAiModeOcrFile(file)
+}
+
+export function shouldKeepAiAttachmentInAssistantReply(prompt = '') {
+ const compact = String(prompt || '').replace(/\s+/g, '')
+ return /(OCR|ocr|识别|票面|票据内容|发票内容|文字|读一下|看一下)/.test(compact)
+}
+
+export function shouldRunAiAttachmentAutoAssociation(entry = {}, files = [], prompt = '') {
+ return Boolean(
+ Array.isArray(files) &&
+ files.length &&
+ files.every((file) => isLikelyReceiptAssociationFile(file)) &&
+ !shouldKeepAiAttachmentInAssistantReply(prompt) &&
+ String(entry?.sessionType || '').trim() === 'steward'
+ )
+}
diff --git a/web/src/composables/workbenchAiMode/workbenchAiMessageModel.js b/web/src/composables/workbenchAiMode/workbenchAiMessageModel.js
new file mode 100644
index 0000000..68f8282
--- /dev/null
+++ b/web/src/composables/workbenchAiMode/workbenchAiMessageModel.js
@@ -0,0 +1,195 @@
+export const AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION = 'confirm_ai_attachment_association'
+export const AI_ATTACHMENT_OCR_DETAIL_ACTION = 'show_ai_attachment_ocr_details'
+
+function normalizeParagraphs(content) {
+ return String(content || '')
+ .split(/\n{2,}|\n/)
+ .map((item) => item.trim())
+ .filter(Boolean)
+}
+
+function stripInlineAssociationMarkdown(value = '') {
+ return String(value || '')
+ .replace(/\*\*/g, '')
+ .replace(/`/g, '')
+ .trim()
+}
+
+export function resolveLegacyAiAttachmentAssociationPayload(content = '') {
+ const text = String(content || '')
+ if (!/我已先识别票据,并(?:匹配到最可能的报销单|找到一张可能关联的报销单)/.test(text)) {
+ return null
+ }
+
+ const claimNo = stripInlineAssociationMarkdown(
+ text.match(/推荐关联[::]\s*([^\n]+)/u)?.[1] || ''
+ )
+ if (!claimNo) {
+ return null
+ }
+
+ return {
+ claim_no: claimNo,
+ document_type: 'expense'
+ }
+}
+
+export function hydrateInlineAttachmentAssociationSuggestedActions(actions = [], content = '') {
+ const safeActions = Array.isArray(actions) ? actions : []
+ const hasConfirmAction = safeActions.some(
+ (action) => String(action?.action_type || '').trim() === AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION
+ )
+ if (hasConfirmAction) {
+ return safeActions
+ }
+
+ const payload = resolveLegacyAiAttachmentAssociationPayload(content)
+ if (!payload) {
+ return safeActions
+ }
+
+ return [
+ {
+ label: '确认自动关联',
+ description: '将本次票据自动归集到推荐单据。',
+ icon: 'mdi mdi-link-variant',
+ action_type: AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION,
+ payload
+ },
+ ...safeActions
+ ]
+}
+
+function normalizeInlineAttachmentOcrField(field = {}) {
+ if (!field || typeof field !== 'object') {
+ return null
+ }
+ const value = String(field.value ?? field.text ?? '').trim()
+ if (!value) {
+ return null
+ }
+ return {
+ label: String(field.label || field.key || field.name || '识别字段').trim() || '识别字段',
+ value
+ }
+}
+
+function normalizeInlineAttachmentOcrDocument(document = {}, index = 0) {
+ const fields = (Array.isArray(document?.document_fields) ? document.document_fields : document?.fields || [])
+ .map((field) => normalizeInlineAttachmentOcrField(field))
+ .filter(Boolean)
+ .slice(0, 12)
+ const summary = String(document?.summary || document?.text || '').replace(/\s+/g, ' ').trim()
+ const filename = String(document?.filename || document?.name || '').trim()
+ if (!filename && !summary && !fields.length) {
+ return null
+ }
+ return {
+ filename: filename || `附件 ${index + 1}`,
+ summary,
+ fields
+ }
+}
+
+export function normalizeInlineAttachmentOcrDetails(details = null) {
+ if (!details || typeof details !== 'object') {
+ return null
+ }
+ const documents = (Array.isArray(details.documents) ? details.documents : details.ocrDocuments || [])
+ .map((document, index) => normalizeInlineAttachmentOcrDocument(document, index))
+ .filter(Boolean)
+ const fileNames = (Array.isArray(details.fileNames) ? details.fileNames : [])
+ .map((name) => String(name || '').trim())
+ .filter(Boolean)
+ if (!documents.length && !fileNames.length) {
+ return null
+ }
+ return {
+ fileNames,
+ documents
+ }
+}
+
+export function buildInlineAttachmentOcrDetails(collected = {}, files = []) {
+ return normalizeInlineAttachmentOcrDetails({
+ fileNames: files.map((file) => file?.name || '').filter(Boolean),
+ documents: collected?.ocrDocuments || []
+ })
+}
+
+export function formatMessageTime(timestamp) {
+ if (!timestamp) return ''
+ return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
+}
+
+export function createWorkbenchAiMessageRuntime() {
+ let messageSeq = 0
+
+ function nextMessageId() {
+ messageSeq += 1
+ return `${Date.now()}-${messageSeq}`
+ }
+
+ function createAiAttachmentAssociationId() {
+ messageSeq += 1
+ return `ai-attachment-${Date.now()}-${messageSeq}`
+ }
+
+ function createInlineMessage(role, content, options = {}) {
+ const normalizedContent = String(content || '').trim()
+ const suggestedActions = Array.isArray(options.suggestedActions) ? options.suggestedActions : []
+ return {
+ id: options.id || nextMessageId(),
+ role,
+ content: normalizedContent,
+ paragraphs: normalizeParagraphs(normalizedContent),
+ pending: Boolean(options.pending),
+ feedback: String(options.feedback || ''),
+ stewardPlan: options.stewardPlan || null,
+ suggestedActions: role === 'assistant'
+ ? hydrateInlineAttachmentAssociationSuggestedActions(suggestedActions, normalizedContent)
+ : suggestedActions,
+ applicationPreview: options.applicationPreview || null,
+ draftPayload: options.draftPayload || null,
+ attachmentOcrDetails: normalizeInlineAttachmentOcrDetails(options.attachmentOcrDetails || null),
+ text: options.text || normalizedContent,
+ createdAt: options.createdAt || Date.now()
+ }
+ }
+
+ function normalizeRuntimeMessage(message = {}) {
+ return createInlineMessage(message.role || 'assistant', message.content || '', {
+ id: message.id,
+ pending: false,
+ feedback: message.feedback || '',
+ stewardPlan: message.stewardPlan || null,
+ suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
+ applicationPreview: message.applicationPreview || null,
+ draftPayload: message.draftPayload || null,
+ attachmentOcrDetails: message.attachmentOcrDetails || null,
+ text: message.text || message.content || ''
+ })
+ }
+
+ function serializeRuntimeMessage(message = {}) {
+ return {
+ id: message.id,
+ role: message.role,
+ content: message.content,
+ text: message.text || message.content || '',
+ feedback: message.feedback || '',
+ stewardPlan: message.stewardPlan || null,
+ suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
+ applicationPreview: message.applicationPreview || null,
+ draftPayload: message.draftPayload || null,
+ attachmentOcrDetails: message.attachmentOcrDetails || null
+ }
+ }
+
+ return {
+ createAiAttachmentAssociationId,
+ createInlineMessage,
+ normalizeRuntimeMessage,
+ serializeRuntimeMessage
+ }
+}
diff --git a/web/src/utils/aiAttachmentAssociationModel.js b/web/src/utils/aiAttachmentAssociationModel.js
new file mode 100644
index 0000000..8bb0f71
--- /dev/null
+++ b/web/src/utils/aiAttachmentAssociationModel.js
@@ -0,0 +1,521 @@
+import { buildDraftAssociationQueryPayload } from '../views/scripts/travelReimbursementExpenseQueryModel.js'
+
+const CITY_NAMES = [
+ '北京',
+ '上海',
+ '广州',
+ '深圳',
+ '武汉',
+ '南京',
+ '杭州',
+ '成都',
+ '重庆',
+ '西安',
+ '天津',
+ '苏州',
+ '长沙',
+ '郑州',
+ '青岛',
+ '厦门',
+ '宁波',
+ '无锡',
+ '合肥',
+ '福州',
+ '昆明',
+ '大连',
+ '沈阳',
+ '济南',
+ '哈尔滨',
+ '长春',
+ '南昌',
+ '太原',
+ '贵阳',
+ '南宁',
+ '石家庄',
+ '兰州',
+ '银川',
+ '西宁',
+ '海口',
+ '拉萨'
+]
+
+function normalizeText(value) {
+ return String(value || '')
+ .trim()
+ .replace(/\s+/g, '')
+}
+
+function escapeHtml(value = '') {
+ return String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+}
+
+function unique(values = []) {
+ return Array.from(new Set(values.map((item) => String(item || '').trim()).filter(Boolean)))
+}
+
+function collectOcrText(ocrDocuments = []) {
+ return (Array.isArray(ocrDocuments) ? ocrDocuments : [])
+ .flatMap((document) => {
+ const fields = Array.isArray(document?.document_fields)
+ ? document.document_fields.flatMap((field) => [field?.label, field?.value])
+ : []
+ return [document?.filename, document?.summary, document?.text, ...fields]
+ })
+ .map((item) => String(item || '').trim())
+ .filter(Boolean)
+ .join(' ')
+}
+
+function normalizeDateToken(value) {
+ const text = String(value || '').trim()
+ if (!text) {
+ return ''
+ }
+
+ const fullDateMatch = text.match(/(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})/)
+ if (fullDateMatch) {
+ const [, year, month, day] = fullDateMatch
+ return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`
+ }
+
+ const shortDateMatch = text.match(/(\d{1,2})月(\d{1,2})/)
+ if (shortDateMatch) {
+ const [, month, day] = shortDateMatch
+ return `${month.padStart(2, '0')}-${day.padStart(2, '0')}`
+ }
+
+ return ''
+}
+
+function extractDateTokens(text) {
+ const source = String(text || '')
+ const matches = [
+ ...source.matchAll(/20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}/g),
+ ...source.matchAll(/\d{1,2}月\d{1,2}/g)
+ ]
+ return unique(matches.map((match) => normalizeDateToken(match[0])))
+}
+
+function extractCityTokens(text) {
+ const compact = normalizeText(text)
+ if (!compact) {
+ return []
+ }
+ return CITY_NAMES.filter((city) => compact.includes(city))
+}
+
+function collectFieldSignals(ocrDocuments = []) {
+ return (Array.isArray(ocrDocuments) ? ocrDocuments : [])
+ .flatMap((document) => Array.isArray(document?.document_fields) ? document.document_fields : [])
+ .filter((field) => {
+ const label = normalizeText(field?.label)
+ return /(日期|时间|发生|开票|出发|到达|起点|终点|地点|城市|路线|行程)/.test(label)
+ })
+ .map((field) => `${field?.label || ''} ${field?.value || ''}`)
+ .join(' ')
+}
+
+export function collectAiAttachmentAssociationSignals(ocrDocuments = []) {
+ const documentText = collectOcrText(ocrDocuments)
+ const fieldText = collectFieldSignals(ocrDocuments)
+ const combinedText = `${documentText} ${fieldText}`
+
+ return {
+ text: combinedText,
+ compactText: normalizeText(combinedText),
+ dates: extractDateTokens(combinedText),
+ cities: unique(extractCityTokens(combinedText))
+ }
+}
+
+function buildRecordText(record = {}) {
+ return [
+ record.claimNo,
+ record.expenseTypeLabel,
+ record.statusLabel,
+ record.reason,
+ record.location,
+ record.occurredAt,
+ record.documentDate,
+ record.summary
+ ].map((item) => String(item || '').trim()).filter(Boolean).join(' ')
+}
+
+function scoreRecord(record = {}, signals = {}) {
+ const recordText = buildRecordText(record)
+ const compactRecordText = normalizeText(recordText)
+ const recordDates = extractDateTokens(recordText)
+ const recordCities = unique([...extractCityTokens(recordText), ...extractCityTokens(record.location)])
+ const reasons = []
+ let score = 0
+
+ const dateMatched = (signals.dates || []).some((date) => {
+ if (!date) return false
+ return recordDates.some((recordDate) => recordDate === date || recordDate.endsWith(date) || date.endsWith(recordDate))
+ })
+ if (dateMatched) {
+ score += 4
+ reasons.push('票据日期与报销单日期一致')
+ }
+
+ const matchedCities = (signals.cities || []).filter((city) => compactRecordText.includes(city))
+ if (matchedCities.length) {
+ const cityScore = Math.min(4, matchedCities.length * 2)
+ score += cityScore
+ reasons.push(`地点或行程包含 ${matchedCities.join('、')}`)
+ }
+
+ if (recordCities.length >= 2 && matchedCities.length >= 2) {
+ score += 2
+ reasons.push('票据往返城市与报销事由吻合')
+ }
+
+ if (String(record.status || '').trim() === 'draft') {
+ score += 1
+ reasons.push('当前单据仍是可归集草稿')
+ }
+
+ return {
+ record,
+ score,
+ reasons
+ }
+}
+
+export function resolveAiAttachmentAssociationMatch(claims = [], ocrDocuments = []) {
+ const queryPayload = buildDraftAssociationQueryPayload(claims)
+ const records = Array.isArray(queryPayload?.records) ? queryPayload.records : []
+ const signals = collectAiAttachmentAssociationSignals(ocrDocuments)
+ const rankedRecords = records
+ .map((record) => scoreRecord(record, signals))
+ .sort((left, right) => right.score - left.score)
+
+ const recommended = rankedRecords[0] || null
+ const runnerUp = rankedRecords[1] || null
+ const highConfidence = Boolean(
+ recommended &&
+ recommended.score >= 5 &&
+ (!runnerUp || recommended.score - runnerUp.score >= 2)
+ )
+
+ return {
+ queryPayload,
+ signals,
+ rankedRecords,
+ recommended,
+ best: highConfidence ? recommended : null,
+ highConfidence
+ }
+}
+
+function formatCandidateLine(candidate, index) {
+ const record = candidate?.record || {}
+ const claimNo = String(record.claimNo || '未编号').trim()
+ const date = String(record.occurredAt || record.documentDate || '日期待补充').trim()
+ const location = String(record.location || '地点待补充').trim()
+ const reason = resolveRecordBusinessDescription(record) || '报销事项'
+ return `${index + 1}. ${claimNo},${date},${location},${reason}`
+}
+
+function wrapTrustedHtml(html = '') {
+ return [
+ '',
+ html,
+ ''
+ ].join('\n')
+}
+
+function renderAssociationField(label = '', value = '', options = {}) {
+ const text = String(value || '').trim()
+ if (!text) {
+ return ''
+ }
+ const fieldClass = options.wide ? ' ai-document-card__field--wide' : ''
+ const valueClass = options.muted ? ' ai-attachment-association__muted' : ''
+ return [
+ `
`,
+ `${escapeHtml(label)}`,
+ `${escapeHtml(text)}`,
+ '
'
+ ].join('')
+}
+
+function formatAttachmentNames(fileNames = []) {
+ const names = unique(fileNames)
+ if (!names.length) {
+ return '已接收票据附件'
+ }
+ return `${names.length} 份:${names.slice(0, 2).join('、')}${names.length > 2 ? ' 等' : ''}`
+}
+
+function formatSignalSummary(match = null) {
+ const dates = Array.isArray(match?.signals?.dates) ? match.signals.dates : []
+ const cities = Array.isArray(match?.signals?.cities) ? match.signals.cities : []
+ return [
+ dates.length ? `日期 ${dates.slice(0, 2).join('、')}` : '',
+ cities.length ? `城市 ${cities.slice(0, 4).join('、')}` : ''
+ ].filter(Boolean).join(';') || '已识别票据关键信息'
+}
+
+function isNoisyAssociationText(value = '') {
+ const text = String(value || '').replace(/\s+/g, '').trim()
+ if (!text) {
+ return true
+ }
+ if (!/[\u4e00-\u9fa5A-Za-z]/.test(text)) {
+ return true
+ }
+ if (/^[::;;,,.\-\d]+$/.test(text)) {
+ return true
+ }
+ if (/^[::;;]/.test(text) && /\d{6,}/.test(text)) {
+ return true
+ }
+ return false
+}
+
+function normalizeBusinessDescription(value = '') {
+ const text = String(value || '').replace(/\s+/g, ' ').trim()
+ return isNoisyAssociationText(text) ? '' : text
+}
+
+function resolveRecordBusinessDescription(record = {}) {
+ return (
+ normalizeBusinessDescription(record.reason) ||
+ normalizeBusinessDescription(record.summary)
+ )
+}
+
+function truncateOcrDetail(value = '', maxLength = 180) {
+ const text = String(value || '').replace(/\s+/g, ' ').trim()
+ if (!text || text.length <= maxLength) {
+ return text
+ }
+ return `${text.slice(0, maxLength - 1)}…`
+}
+
+function formatOcrDocumentDetail(document = {}) {
+ const filename = String(document?.filename || '').trim()
+ const fields = Array.isArray(document?.document_fields) ? document.document_fields : []
+ const fieldText = fields
+ .map((field) => {
+ const label = String(field?.label || '').trim()
+ const value = String(field?.value || '').trim()
+ return label && value ? `${label}:${value}` : ''
+ })
+ .filter(Boolean)
+ .slice(0, 6)
+ .join(',')
+ const fallbackText = String(document?.summary || document?.text || '').trim()
+ const detailText = truncateOcrDetail(fieldText || fallbackText)
+ return [filename, detailText].filter(Boolean).join(':')
+}
+
+function formatOcrDocumentDetails(ocrDocuments = []) {
+ return (Array.isArray(ocrDocuments) ? ocrDocuments : [])
+ .map((document) => formatOcrDocumentDetail(document))
+ .filter(Boolean)
+ .slice(0, 3)
+ .join(';')
+}
+
+function renderAssociationCard({
+ title = '',
+ status = '',
+ tone = 'is-warning',
+ className = '',
+ ariaLabel = '票据关联确认',
+ fields = [],
+ note = ''
+} = {}) {
+ const normalizedClassName = String(className || '').trim()
+ return wrapTrustedHtml([
+ `
`,
+ ``,
+ '',
+ `${escapeHtml(title)}`,
+ status ? `${escapeHtml(status)}` : '',
+ '',
+ '',
+ '
',
+ fields.join(''),
+ '
',
+ note ? `
${escapeHtml(note)}
` : '',
+ '
',
+ '',
+ ''
+ ].join(''))
+}
+
+function renderOcrRecognitionCard({ attachmentLabel = '', signalSummary = '', ocrDetailSummary = '' } = {}) {
+ return renderAssociationCard({
+ title: '票据识别结果',
+ status: '已识别',
+ tone: 'is-pending',
+ className: 'ai-ocr-recognition-card',
+ ariaLabel: '票据 OCR 识别结果',
+ fields: [
+ renderAssociationField('本次附件', attachmentLabel),
+ renderAssociationField('识别线索', signalSummary),
+ ocrDetailSummary
+ ? renderAssociationField('票面识别', ocrDetailSummary, { wide: true, muted: true })
+ : ''
+ ].filter(Boolean),
+ note: '我会基于这些票面信息继续查询可关联单据。'
+ })
+}
+
+export function buildAiAttachmentAssociationMessage({
+ match = null,
+ fileNames = [],
+ ocrDocuments = []
+} = {}) {
+ const attachmentLabel = formatAttachmentNames(fileNames)
+ const signalSummary = formatSignalSummary(match)
+ const ocrDetailSummary = formatOcrDocumentDetails(ocrDocuments)
+ const recognitionCard = renderOcrRecognitionCard({
+ attachmentLabel,
+ signalSummary,
+ ocrDetailSummary
+ })
+
+ if (!match?.rankedRecords?.length) {
+ return [
+ '我已先完成票据识别,识别结果如下。',
+ recognitionCard,
+ '我又查询了可关联单据,但当前没有查到可关联的报销草稿或待补充单据。',
+ renderAssociationCard({
+ title: '未找到可关联单据',
+ status: '未归集',
+ tone: 'is-warning',
+ fields: [
+ renderAssociationField('查询范围', '可归集草稿、待补充和退回单据', { wide: true }),
+ renderAssociationField('处理建议', '暂不归集,避免把票据放错位置', { wide: true, muted: true })
+ ],
+ note: '我先不做归集,避免把票据放错位置。'
+ })
+ ].filter(Boolean).join('\n\n')
+ }
+
+ if (match.highConfidence && match.best?.record) {
+ const record = match.best.record
+ const recordDescription = resolveRecordBusinessDescription(record)
+ const reasons = match.best.reasons.length
+ ? match.best.reasons.join(';')
+ : '票据信息与单据基础信息吻合'
+ return [
+ '我已先完成票据识别,识别结果如下。',
+ recognitionCard,
+ '我根据上述票面信息找到一张最可能关联的报销单。请确认是否自动归集:',
+ renderAssociationCard({
+ title: '可能关联单据',
+ status: '待确认',
+ tone: 'is-warning',
+ fields: [
+ renderAssociationField('推荐单据', record.claimNo),
+ recordDescription
+ ? renderAssociationField('关联事项', recordDescription, { wide: true })
+ : '',
+ renderAssociationField('匹配依据', reasons, { wide: true, muted: true })
+ ].filter(Boolean),
+ note: '确认后,我会把这些附件自动归集到该单据,并反馈处理结果。'
+ })
+ ].filter(Boolean).join('\n\n')
+ }
+
+ const candidates = match.rankedRecords.slice(0, 3).map(formatCandidateLine)
+ return [
+ '我已先完成票据识别,识别结果如下。',
+ recognitionCard,
+ '我根据上述票面信息查询到候选单据,但还不能放心自动锁定。',
+ renderAssociationCard({
+ title: '候选单据待核对',
+ status: '需确认',
+ tone: 'is-warning',
+ fields: [
+ renderAssociationField('候选单据', candidates.join(';'), { wide: true, muted: true })
+ ],
+ note: '如果这就是要归集的单据,可直接点下方“确认自动关联”;不确定时也可以先查看单据。'
+ })
+ ].filter(Boolean).join('\n\n')
+}
+
+export function buildAiAttachmentAssociationResultMessage({
+ claimNo = '',
+ uploadedCount = 0,
+ skippedCount = 0,
+ fileNames = []
+} = {}) {
+ const normalizedUploadedCount = Math.max(0, Number(uploadedCount || 0))
+ const normalizedSkippedCount = Math.max(0, Number(skippedCount || 0))
+ const done = normalizedUploadedCount > 0 && normalizedSkippedCount === 0
+ return [
+ done ? '已完成自动归集。' : '自动归集已处理完成,请留意未归集附件。',
+ renderAssociationCard({
+ title: done ? '票据已归集' : '票据归集结果',
+ status: done ? '已完成' : '部分完成',
+ tone: done ? 'is-success' : 'is-warning',
+ fields: [
+ renderAssociationField('关联单据', claimNo || '当前匹配单据'),
+ renderAssociationField('归集结果', `${normalizedUploadedCount} 份成功${normalizedSkippedCount ? `,${normalizedSkippedCount} 份未归集` : ''}`),
+ renderAssociationField('附件', formatAttachmentNames(fileNames), { wide: true })
+ ],
+ note: done
+ ? '附件已经写入该报销单,可进入详情页继续核对。'
+ : '部分附件没有找到可用明细项,请进入详情页手动核对。'
+ })
+ ].join('\n\n')
+}
+
+export function buildAiAttachmentAssociationActions(match = null, associationId = '', options = {}) {
+ const record = match?.best?.record || match?.recommended?.record
+ const actions = []
+ if (options.includeOcrDetails) {
+ actions.push({
+ label: '查看附件信息',
+ description: '展开本次上传附件的 OCR 识别明细。',
+ icon: 'mdi mdi-file-search-outline',
+ action_type: 'show_ai_attachment_ocr_details',
+ payload: {}
+ })
+ }
+
+ if (!record?.claimNo && !record?.claimId) {
+ return actions
+ }
+
+ const payload = {
+ claim_id: String(record.claimId || '').trim(),
+ claim_no: String(record.claimNo || '').trim(),
+ document_type: 'expense'
+ }
+ const normalizedAssociationId = String(associationId || '').trim()
+
+ if (payload.claim_id && normalizedAssociationId) {
+ actions.push({
+ label: '确认自动关联',
+ description: '把本次票据自动归集到匹配单据。',
+ icon: 'mdi mdi-link-variant',
+ action_type: 'confirm_ai_attachment_association',
+ payload: {
+ ...payload,
+ association_id: normalizedAssociationId
+ }
+ })
+ }
+
+ actions.push({
+ label: '查看单据',
+ description: '先打开匹配单据核对详情。',
+ icon: 'mdi mdi-open-in-new',
+ action_type: 'open_application_detail',
+ payload
+ })
+
+ return actions
+}
diff --git a/web/src/utils/aiConversationHtmlRenderer.js b/web/src/utils/aiConversationHtmlRenderer.js
index bedc15e..27b3439 100644
--- a/web/src/utils/aiConversationHtmlRenderer.js
+++ b/web/src/utils/aiConversationHtmlRenderer.js
@@ -1,3 +1,6 @@
+import { renderLegacyAttachmentAssociationHtml } from './aiConversationLegacyAttachmentRenderer.js'
+import { parseTableRow, renderTable } from './aiConversationTableRenderer.js'
+
const ALLOWED_COLON_HEADING_TITLES = new Set([
'基础信息识别结果',
'报销测算参考',
@@ -25,18 +28,6 @@ const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:'
const DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:'
const TRUSTED_HTML_BLOCK_RE = /\s*([\s\S]*?)\s*/g
const TRUSTED_HTML_PLACEHOLDER_PREFIX = 'AI_TRUSTED_HTML_BLOCK_'
-const DOCUMENT_STATUS_LABELS = {
- draft: '草稿',
- submitted: '审批中',
- pending: '待处理',
- approved: '已审批',
- completed: '已完成',
- archived: '已归档',
- returned: '已退回',
- rejected: '已驳回',
- pending_payment: '待付款',
- paid: '已付款'
-}
const TRUSTED_HTML_ALLOWED_TAGS = new Set([
'section',
'article',
@@ -349,18 +340,6 @@ function parseImageLine(line = '') {
}
}
-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)
@@ -510,174 +489,17 @@ function renderOrderedList(items = []) {
].join('')
}
-function normalizeTableHeaderCell(value = '') {
- return String(value || '').replace(/\s+/g, '').trim()
-}
-
-function findTableColumnIndex(normalizedHeader = [], labels = []) {
- return labels
- .map((label) => normalizedHeader.indexOf(label))
- .find((index) => index >= 0) ?? -1
-}
-
-function resolveTableCell(row = [], normalizedHeader = [], labels = []) {
- const columnIndex = findTableColumnIndex(normalizedHeader, labels)
- return columnIndex >= 0 ? String(row[columnIndex] || '').trim() : ''
-}
-
-function hasMeaningfulTableValue(value = '') {
- const text = String(value || '').trim()
- return Boolean(text && text !== '-')
-}
-
-function normalizeDocumentStatusLabel(status = '') {
- const text = String(status || '').trim()
- if (!text || text === '-') {
- return ''
- }
- return DOCUMENT_STATUS_LABELS[text.toLowerCase()] || text
-}
-
-function resolveDocumentRecordTone(status = '', stage = '') {
- const normalizedStatus = normalizeDocumentStatusLabel(status)
- const text = `${normalizedStatus || String(status || '')} ${String(stage || '')}`.trim()
- if (/已删除|已驳回|驳回|拒绝|失败/.test(text)) {
- return 'is-danger'
- }
- if (/已审批|审批通过|已完成|已归档|已付款|已支付|可通过/.test(text)) {
- return 'is-success'
- }
- if (/草稿|待提交|待补充|已退回|退回/.test(text)) {
- return 'is-warning'
- }
- return 'is-pending'
-}
-
-function isDocumentRecordTable(normalizedHeader = []) {
- return (
- normalizedHeader.includes('单据编号') &&
- normalizedHeader.includes('操作') &&
- normalizedHeader.some((label) => ['单据类型', '申请时间', '单据状态', '状态', '当前节点', '事由'].includes(label))
- )
-}
-
-function renderDocumentCardField(label = '', value = '', options = {}) {
- if (!hasMeaningfulTableValue(value)) {
- return ''
- }
- const valueClass = options.valueClass ? ` ${options.valueClass}` : ''
- return [
- `
`,
- `${escapeHtml(label)}`,
- `${renderInlineHtml(value)}`,
- '
'
- ].join('')
-}
-
-function renderDocumentCardAction(action = '') {
- if (!hasMeaningfulTableValue(action)) {
- return ''
- }
- const actionHtml = renderInlineHtml(action).replace(
- /class="ai-html-action-link\s+/g,
- 'class="ai-html-action-link ai-document-card__action '
- )
- return [
- '
',
- '操作',
- actionHtml,
- '
'
- ].join('')
-}
-
-function renderDocumentRecordList(header = [], bodyRows = []) {
- const normalizedHeader = header.map((cell) => normalizeTableHeaderCell(cell))
- const items = bodyRows.map((row) => {
- const documentType = resolveTableCell(row, normalizedHeader, ['单据类型'])
- const documentNo = resolveTableCell(row, normalizedHeader, ['单据编号'])
- const applyTime = resolveTableCell(row, normalizedHeader, ['申请时间', '日期', '时间'])
- const location = resolveTableCell(row, normalizedHeader, ['地点', '目的地'])
- const amount = resolveTableCell(row, normalizedHeader, ['金额', '预计金额', '报销金额'])
- const status = normalizeDocumentStatusLabel(resolveTableCell(row, normalizedHeader, ['单据状态', '状态']))
- const stage = resolveTableCell(row, normalizedHeader, ['当前节点'])
- const reason = resolveTableCell(row, normalizedHeader, ['事由'])
- const action = resolveTableCell(row, normalizedHeader, ['操作'])
- const tone = resolveDocumentRecordTone(status, stage)
- const title = documentType || reason || documentNo || '单据详情'
- const summarySecondField = amount
- ? renderDocumentCardField('金额', amount, { valueClass: 'ai-document-card__amount' })
- : renderDocumentCardField('当前节点', stage || status || '待确认')
- const summaryHtml = [
- renderDocumentCardField('日期', applyTime || '待补充'),
- summarySecondField
- ].join('')
- const detailsHtml = [
- renderDocumentCardField('地点', location || '待补充'),
- renderDocumentCardField('单据编号', documentNo, { valueClass: 'ai-document-card__number' }),
- renderDocumentCardField('事由', reason || '待补充'),
- amount ? renderDocumentCardField('当前节点', stage || status || '待确认') : '',
- renderDocumentCardAction(action),
- renderDocumentCardField('单据类型', documentType)
- ].join('')
- return [
- `
`,
- '',
- `${renderInlineHtml(title)}`,
- hasMeaningfulTableValue(status) ? `${renderInlineHtml(status)}` : '',
- '',
- '',
- summaryHtml ? `
${summaryHtml}
` : '',
- '
',
- detailsHtml,
- '
',
- '
',
- ''
- ].join('')
- }).filter(Boolean)
-
- return [
- '
'
- ].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)
- const normalizedHeader = header.map((cell) => normalizeTableHeaderCell(cell))
- if (isDocumentRecordTable(normalizedHeader)) {
- return renderDocumentRecordList(header, bodyRows)
- }
-
- return [
- '
',
- '
',
- '',
- ...header.map((cell) => `| ${renderInlineHtml(cell)} | `),
- '
',
- '',
- ...bodyRows.map((row) => [
- '',
- ...header.map((_cell, index) => `| ${renderInlineHtml(row[index] || '')} | `),
- '
'
- ].join('')),
- '',
- '
',
- '
'
- ].join('')
-}
-
function renderCodeBlock(lines = []) {
const code = lines.join('\n').replace(/\n$/, '')
return `
${escapeHtml(code)}
`
}
export function renderAiConversationHtml(content = '') {
+ const legacyAttachmentAssociationHtml = renderLegacyAttachmentAssociationHtml(content, { escapeHtml })
+ if (legacyAttachmentAssociationHtml) {
+ return legacyAttachmentAssociationHtml
+ }
+
const extracted = extractTrustedHtmlBlocks(content)
const normalized = normalizeConversationText(extracted.content)
if (!normalized) {
@@ -723,7 +545,7 @@ export function renderAiConversationHtml(content = '') {
tableLines.push(lines[index])
index += 1
}
- blocks.push(renderTable(tableLines))
+ blocks.push(renderTable(tableLines, { escapeHtml, renderInlineHtml }))
continue
}
diff --git a/web/src/utils/aiConversationLegacyAttachmentRenderer.js b/web/src/utils/aiConversationLegacyAttachmentRenderer.js
new file mode 100644
index 0000000..707d0e5
--- /dev/null
+++ b/web/src/utils/aiConversationLegacyAttachmentRenderer.js
@@ -0,0 +1,87 @@
+function stripInlineMarkdownMarkers(value = '') {
+ return String(value || '')
+ .replace(/\*\*/g, '')
+ .replace(/`/g, '')
+ .trim()
+}
+
+function resolveLegacyAttachmentAssociationField(text = '', label = '') {
+ const escapedLabel = String(label || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+ const match = String(text || '').match(new RegExp(`${escapedLabel}[::]\\s*([^\\n]+)`, 'u'))
+ return stripInlineMarkdownMarkers(match?.[1] || '')
+}
+
+function renderLegacyAttachmentAssociationField(label = '', value = '', options = {}, context = {}) {
+ const text = String(value || '').trim()
+ if (!text) {
+ return ''
+ }
+ return [
+ `
`,
+ `${context.escapeHtml(label)}`,
+ `${context.escapeHtml(text)}`,
+ '
'
+ ].join('')
+}
+
+function isNoisyLegacyAssociationText(value = '') {
+ const text = String(value || '').replace(/\s+/g, '').trim()
+ if (!text) {
+ return true
+ }
+ if (!/[\u4e00-\u9fa5A-Za-z]/.test(text)) {
+ return true
+ }
+ if (/^[::;;,,.\-\d]+$/.test(text)) {
+ return true
+ }
+ if (/^[::;;]/.test(text) && /\d{6,}/.test(text)) {
+ return true
+ }
+ return false
+}
+
+function normalizeLegacyAssociationDescription(value = '') {
+ const text = stripInlineMarkdownMarkers(value).replace(/\s+/g, ' ').trim()
+ return isNoisyLegacyAssociationText(text) ? '' : text
+}
+
+export function renderLegacyAttachmentAssociationHtml(content = '', options = {}) {
+ const context = {
+ escapeHtml: options.escapeHtml || ((item) => String(item || ''))
+ }
+ const text = String(content || '')
+ if (!/我已先识别票据,并匹配到最可能的报销单/.test(text)) {
+ return ''
+ }
+
+ const attachment = resolveLegacyAttachmentAssociationField(text, '本次附件')
+ const summary = resolveLegacyAttachmentAssociationField(text, '识别摘要')
+ const claimNo = resolveLegacyAttachmentAssociationField(text, '推荐关联')
+ const reason = normalizeLegacyAssociationDescription(resolveLegacyAttachmentAssociationField(text, '单据事项'))
+ const basis = resolveLegacyAttachmentAssociationField(text, '匹配依据')
+
+ return [
+ '
',
+ '
我已先识别票据,并找到一张可能关联的报销单。请确认是否自动归集:
',
+ '
',
+ '',
+ '',
+ '可能关联单据',
+ '待确认',
+ '',
+ '',
+ '
',
+ renderLegacyAttachmentAssociationField('推荐单据', claimNo, {}, context),
+ renderLegacyAttachmentAssociationField('本次附件', attachment, {}, context),
+ renderLegacyAttachmentAssociationField('识别摘要', summary, { wide: true, muted: true }, context),
+ renderLegacyAttachmentAssociationField('关联事项', reason, { wide: true }, context),
+ renderLegacyAttachmentAssociationField('匹配依据', basis, { wide: true, muted: true }, context),
+ '
',
+ '
如果这就是要归集的单据,可直接使用下方快捷按钮;不确定时也可以先查看单据。
',
+ '
',
+ '',
+ '',
+ '
'
+ ].join('')
+}
diff --git a/web/src/utils/aiConversationTableRenderer.js b/web/src/utils/aiConversationTableRenderer.js
new file mode 100644
index 0000000..55b8821
--- /dev/null
+++ b/web/src/utils/aiConversationTableRenderer.js
@@ -0,0 +1,192 @@
+const DOCUMENT_STATUS_LABELS = {
+ draft: '草稿',
+ submitted: '审批中',
+ pending: '待处理',
+ approved: '已审批',
+ completed: '已完成',
+ archived: '已归档',
+ returned: '已退回',
+ rejected: '已驳回',
+ pending_payment: '待付款',
+ paid: '已付款'
+}
+
+function normalizeTableHeaderCell(value = '') {
+ return String(value || '').replace(/\s+/g, '').trim()
+}
+
+function findTableColumnIndex(normalizedHeader = [], labels = []) {
+ return labels
+ .map((label) => normalizedHeader.indexOf(label))
+ .find((index) => index >= 0) ?? -1
+}
+
+function resolveTableCell(row = [], normalizedHeader = [], labels = []) {
+ const columnIndex = findTableColumnIndex(normalizedHeader, labels)
+ return columnIndex >= 0 ? String(row[columnIndex] || '').trim() : ''
+}
+
+function hasMeaningfulTableValue(value = '') {
+ const text = String(value || '').trim()
+ return Boolean(text && text !== '-')
+}
+
+function normalizeDocumentStatusLabel(status = '') {
+ const text = String(status || '').trim()
+ if (!text || text === '-') {
+ return ''
+ }
+ return DOCUMENT_STATUS_LABELS[text.toLowerCase()] || text
+}
+
+function resolveDocumentRecordTone(status = '', stage = '') {
+ const normalizedStatus = normalizeDocumentStatusLabel(status)
+ const text = `${normalizedStatus || String(status || '')} ${String(stage || '')}`.trim()
+ if (/已删除|已驳回|驳回|拒绝|失败/.test(text)) {
+ return 'is-danger'
+ }
+ if (/已审批|审批通过|已完成|已归档|已付款|已支付|可通过/.test(text)) {
+ return 'is-success'
+ }
+ if (/草稿|待提交|待补充|已退回|退回/.test(text)) {
+ return 'is-warning'
+ }
+ return 'is-pending'
+}
+
+function isDocumentRecordTable(normalizedHeader = []) {
+ return (
+ normalizedHeader.includes('单据编号') &&
+ normalizedHeader.includes('操作') &&
+ normalizedHeader.some((label) => ['单据类型', '申请时间', '单据状态', '状态', '当前节点', '事由'].includes(label))
+ )
+}
+
+function renderDocumentCardField(label = '', value = '', options = {}, context = {}) {
+ if (!hasMeaningfulTableValue(value)) {
+ return ''
+ }
+ const renderInlineHtml = context.renderInlineHtml || ((item) => String(item || ''))
+ const valueClass = options.valueClass ? ` ${options.valueClass}` : ''
+ return [
+ `
`,
+ `${context.escapeHtml(label)}`,
+ `${renderInlineHtml(value)}`,
+ '
'
+ ].join('')
+}
+
+function renderDocumentCardAction(action = '', context = {}) {
+ if (!hasMeaningfulTableValue(action)) {
+ return ''
+ }
+ const renderInlineHtml = context.renderInlineHtml || ((item) => String(item || ''))
+ const actionHtml = renderInlineHtml(action).replace(
+ /class="ai-html-action-link\s+/g,
+ 'class="ai-html-action-link ai-document-card__action '
+ )
+ return [
+ '
',
+ '操作',
+ actionHtml,
+ '
'
+ ].join('')
+}
+
+function renderDocumentRecordList(header = [], bodyRows = [], context = {}) {
+ const normalizedHeader = header.map((cell) => normalizeTableHeaderCell(cell))
+ const items = bodyRows.map((row) => {
+ const documentType = resolveTableCell(row, normalizedHeader, ['单据类型'])
+ const documentNo = resolveTableCell(row, normalizedHeader, ['单据编号'])
+ const applyTime = resolveTableCell(row, normalizedHeader, ['申请时间', '日期', '时间'])
+ const location = resolveTableCell(row, normalizedHeader, ['地点', '目的地'])
+ const amount = resolveTableCell(row, normalizedHeader, ['金额', '预计金额', '报销金额'])
+ const status = normalizeDocumentStatusLabel(resolveTableCell(row, normalizedHeader, ['单据状态', '状态']))
+ const stage = resolveTableCell(row, normalizedHeader, ['当前节点'])
+ const reason = resolveTableCell(row, normalizedHeader, ['事由'])
+ const action = resolveTableCell(row, normalizedHeader, ['操作'])
+ const tone = resolveDocumentRecordTone(status, stage)
+ const title = documentType || reason || documentNo || '单据详情'
+ const summarySecondField = amount
+ ? renderDocumentCardField('金额', amount, { valueClass: 'ai-document-card__amount' }, context)
+ : renderDocumentCardField('当前节点', stage || status || '待确认', {}, context)
+ const summaryHtml = [
+ renderDocumentCardField('日期', applyTime || '待补充', {}, context),
+ summarySecondField
+ ].join('')
+ const detailsHtml = [
+ renderDocumentCardField('地点', location || '待补充', {}, context),
+ renderDocumentCardField('单据编号', documentNo, { valueClass: 'ai-document-card__number' }, context),
+ renderDocumentCardField('事由', reason || '待补充', {}, context),
+ amount ? renderDocumentCardField('当前节点', stage || status || '待确认', {}, context) : '',
+ renderDocumentCardAction(action, context),
+ renderDocumentCardField('单据类型', documentType, {}, context)
+ ].join('')
+ return [
+ `
`,
+ '',
+ `${context.renderInlineHtml(title)}`,
+ hasMeaningfulTableValue(status) ? `${context.renderInlineHtml(status)}` : '',
+ '',
+ '',
+ summaryHtml ? `
${summaryHtml}
` : '',
+ '
',
+ detailsHtml,
+ '
',
+ '
',
+ ''
+ ].join('')
+ }).filter(Boolean)
+
+ return [
+ '
'
+ ].join('')
+}
+
+export function parseTableRow(line = '') {
+ const trimmed = String(line || '').trim()
+ if (!trimmed.startsWith('|')) {
+ return []
+ }
+ return trimmed
+ .replace(/^\|/, '')
+ .replace(/\|$/, '')
+ .split('|')
+ .map((cell) => cell.trim())
+}
+
+export function renderTable(lines = [], options = {}) {
+ const context = {
+ escapeHtml: options.escapeHtml || ((item) => String(item || '')),
+ renderInlineHtml: options.renderInlineHtml || ((item) => String(item || ''))
+ }
+ 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)
+ const normalizedHeader = header.map((cell) => normalizeTableHeaderCell(cell))
+ if (isDocumentRecordTable(normalizedHeader)) {
+ return renderDocumentRecordList(header, bodyRows, context)
+ }
+
+ return [
+ '
',
+ '
',
+ '',
+ ...header.map((cell) => `| ${context.renderInlineHtml(cell)} | `),
+ '
',
+ '',
+ ...bodyRows.map((row) => [
+ '',
+ ...header.map((_cell, index) => `| ${context.renderInlineHtml(row[index] || '')} | `),
+ '
'
+ ].join('')),
+ '',
+ '
',
+ '
'
+ ].join('')
+}
diff --git a/web/src/utils/aiDocumentDetailReference.js b/web/src/utils/aiDocumentDetailReference.js
index ede95a9..ed64c5b 100644
--- a/web/src/utils/aiDocumentDetailReference.js
+++ b/web/src/utils/aiDocumentDetailReference.js
@@ -135,7 +135,7 @@ export function buildAiDocumentDetailRequest(detailReference = {}) {
documentType: isApplication ? 'application' : 'reimbursement',
documentTypeCode: isApplication ? 'application' : 'reimbursement',
detailLookupOnly: true,
- source: 'workbench',
- returnTo: 'workbench'
+ source: 'ai-conversation',
+ returnTo: 'conversation'
}
}
diff --git a/web/src/utils/aiDocumentQueryIntent.js b/web/src/utils/aiDocumentQueryIntent.js
new file mode 100644
index 0000000..cd6ca56
--- /dev/null
+++ b/web/src/utils/aiDocumentQueryIntent.js
@@ -0,0 +1,240 @@
+import { compactText, formatDate, normalizeText, parseDate } from './aiDocumentQueryText.js'
+
+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: /软件|服务费|订阅/ }
+]
+
+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(/(?:(?
20\d{2})年?)?(?\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(/(?:(?20\d{2})年?)?(?\d{1,2})月(?\d{1,2})日?(?:至|到|~|-|—|–)(?:(?\d{1,2})月)?(?\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(/(?:(?20\d{2})年?)?(?\d{1,2})月(?\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(/近(?\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(/金额(?:在|为)?(?\d+(?:\.\d+)?)(?:元)?(?:到|至|~|-|—|–)(?\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(/(?:金额)?(?:大于|超过|高于|不少于|不低于|>=|以上)(?\d+(?:\.\d+)?)(?:元)?/)
+ || text.match(/(?\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(/(?:金额)?(?:小于|低于|少于|不超过|<=|以下)(?\d+(?:\.\d+)?)(?:元)?/)
+ || text.match(/(?\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]*(?[\u4e00-\u9fa5A-Za-z0-9_-]{2,32})/u)
+ const relatedMatch = compact.match(/(?[\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: '待我审核的单据'
+ }
+ }
+ if (/我名下|我发起|我提交|我创建|我的申请|我的报销/.test(text)) {
+ return {
+ source: 'mine',
+ sourceLabel: '我的单据'
+ }
+ }
+ return {
+ source: 'accessible',
+ 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
+ }
+}
diff --git a/web/src/utils/aiDocumentQueryModel.js b/web/src/utils/aiDocumentQueryModel.js
index 89dd801..94741ff 100644
--- a/web/src/utils/aiDocumentQueryModel.js
+++ b/web/src/utils/aiDocumentQueryModel.js
@@ -1,6 +1,9 @@
import { extractExpenseClaimItems } from '../services/reimbursements.js'
import { buildAiDocumentDetailHref } from './aiDocumentDetailReference.js'
import { isApplicationDocumentNo } from './documentClassification.js'
+import { compactText, normalizeDateText, normalizeText, parseDate } from './aiDocumentQueryText.js'
+
+export { resolveAiDocumentQueryIntent } from './aiDocumentQueryIntent.js'
const DOCUMENT_QUERY_LIMIT = 8
@@ -33,29 +36,6 @@ const TYPE_LABELS = {
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',
@@ -63,10 +43,6 @@ const MONEY_FORMATTER = new Intl.NumberFormat('zh-CN', {
maximumFractionDigits: 2
})
-function normalizeText(value) {
- return String(value ?? '').trim()
-}
-
function resolveStatusDisplayLabel(value = '') {
const text = normalizeText(value)
if (!text) {
@@ -84,252 +60,6 @@ function escapeHtml(value = '') {
.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(/(?:(?20\d{2})年?)?(?\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(/(?:(?20\d{2})年?)?(?\d{1,2})月(?\d{1,2})日?(?:至|到|~|-|—|–)(?:(?\d{1,2})月)?(?\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(/(?:(?20\d{2})年?)?(?\d{1,2})月(?\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(/近(?\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(/金额(?:在|为)?(?\d+(?:\.\d+)?)(?:元)?(?:到|至|~|-|—|–)(?\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(/(?:金额)?(?:大于|超过|高于|不少于|不低于|>=|以上)(?\d+(?:\.\d+)?)(?:元)?/)
- || text.match(/(?\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(/(?:金额)?(?:小于|低于|少于|不超过|<=|以下)(?\d+(?:\.\d+)?)(?:元)?/)
- || text.match(/(?\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]*(?[\u4e00-\u9fa5A-Za-z0-9_-]{2,32})/u)
- const relatedMatch = compact.match(/(?[\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: '待我审核的单据'
- }
- }
- if (/我名下|我发起|我提交|我创建|我的申请|我的报销/.test(text)) {
- return {
- source: 'mine',
- sourceLabel: '我的单据'
- }
- }
- return {
- source: 'accessible',
- 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)
}
diff --git a/web/src/utils/aiDocumentQueryText.js b/web/src/utils/aiDocumentQueryText.js
new file mode 100644
index 0000000..c332a58
--- /dev/null
+++ b/web/src/utils/aiDocumentQueryText.js
@@ -0,0 +1,33 @@
+export function normalizeText(value) {
+ return String(value ?? '').trim()
+}
+
+export function compactText(value) {
+ return normalizeText(value).replace(/\s+/g, '')
+}
+
+export 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('-')
+}
+
+export 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
+}
+
+export function formatDate(date) {
+ return date.toISOString().slice(0, 10)
+}
diff --git a/web/src/utils/documentCenterViewModel.js b/web/src/utils/documentCenterViewModel.js
new file mode 100644
index 0000000..3e1e950
--- /dev/null
+++ b/web/src/utils/documentCenterViewModel.js
@@ -0,0 +1,361 @@
+import { countClaimRisks, resolveArchiveRiskTone } from './archiveCenterListFilters.js'
+import { isNewDocument } from './documentCenterNewState.js'
+import { isArchivedDocumentRow } from './documentCenterRows.js'
+import { sortDocumentRowsByLatestTime } from './documentCenterSort.js'
+import {
+ extractDateText,
+ formatDocumentListTime,
+ resolveDocumentSortTime,
+ resolveDocumentStayTimeDisplay
+} from './documentCenterTime.js'
+import { normalizeRequestForUi } from './requestViewModel.js'
+
+export const DOCUMENT_TYPE_ALL = 'all'
+export const DOCUMENT_TYPE_APPLICATION = 'application'
+export const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
+export const SCENE_ALL = 'all'
+export const DOCUMENT_SCOPE_ALL = '全部'
+export const DOCUMENT_SCOPE_APPLICATION = '申请单'
+export const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'
+export const DOCUMENT_SCOPE_REVIEW = '审核单'
+export const DOCUMENT_SCOPE_ARCHIVE = '归档'
+export const scopeTabs = [
+ DOCUMENT_SCOPE_ALL,
+ DOCUMENT_SCOPE_APPLICATION,
+ DOCUMENT_SCOPE_REIMBURSEMENT,
+ DOCUMENT_SCOPE_REVIEW,
+ DOCUMENT_SCOPE_ARCHIVE
+]
+export const DOCUMENT_LOADING_MIN_VISIBLE_MS = 720
+export const DOCUMENT_CENTER_QUERY_KEYS = new Set([
+ 'dc_page',
+ 'dc_page_size',
+ 'dc_scope',
+ 'dc_status',
+ 'dc_doc_type',
+ 'dc_scene',
+ 'dc_q',
+ 'dc_start',
+ 'dc_end'
+])
+export const riskLevelTabs = ['全部', '高风险', '中风险', '低风险', '无风险']
+export const RISK_TONE_META = {
+ high: { label: '高风险', tone: 'high' },
+ medium: { label: '中风险', tone: 'medium' },
+ low: { label: '低风险', tone: 'low' },
+ none: { label: '无风险', tone: 'none' }
+}
+export const FILTER_CONFIG_BY_SCOPE = {
+ [DOCUMENT_SCOPE_ALL]: {
+ searchPlaceholder: '搜索单号、事项、费用场景...',
+ sceneFallbackLabel: '单据场景',
+ dateLabel: '单据时间',
+ statusTitle: '风险等级',
+ statusTabs: riskLevelTabs,
+ showDocumentType: true
+ },
+ [DOCUMENT_SCOPE_APPLICATION]: {
+ searchPlaceholder: '搜索申请单号、申请事项、申请场景...',
+ sceneFallbackLabel: '申请场景',
+ dateLabel: '申请时间',
+ statusTitle: '风险等级',
+ statusTabs: riskLevelTabs,
+ showDocumentType: false
+ },
+ [DOCUMENT_SCOPE_REIMBURSEMENT]: {
+ searchPlaceholder: '搜索报销单号、报销事由、费用场景...',
+ sceneFallbackLabel: '费用场景',
+ dateLabel: '报销时间',
+ statusTitle: '风险等级',
+ statusTabs: riskLevelTabs,
+ showDocumentType: false
+ },
+ [DOCUMENT_SCOPE_REVIEW]: {
+ searchPlaceholder: '搜索审核单号、事项、当前环节...',
+ sceneFallbackLabel: '审核场景',
+ dateLabel: '审核时间',
+ statusTitle: '风险等级',
+ statusTabs: riskLevelTabs,
+ showDocumentType: false
+ },
+ [DOCUMENT_SCOPE_ARCHIVE]: {
+ searchPlaceholder: '搜索归档单号、事项、费用场景...',
+ sceneFallbackLabel: '归档场景',
+ dateLabel: '归档时间',
+ statusTitle: '风险等级',
+ statusTabs: riskLevelTabs,
+ showDocumentType: false
+ }
+}
+export const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size }))
+export const pageSizeValues = pageSizeOptions.map((item) => item.value)
+export const documentTypeOptions = [
+ { value: DOCUMENT_TYPE_ALL, label: '单据类型' },
+ { value: DOCUMENT_TYPE_APPLICATION, label: '申请单' },
+ { value: DOCUMENT_TYPE_REIMBURSEMENT, label: '报销单' }
+]
+
+export function routeQueryEquals(left, right) {
+ const leftEntries = Object.entries(left || {}).map(([key, value]) => [
+ key,
+ Array.isArray(value) ? value.join(',') : String(value ?? '')
+ ])
+ const rightEntries = Object.entries(right || {}).map(([key, value]) => [
+ key,
+ Array.isArray(value) ? value.join(',') : String(value ?? '')
+ ])
+ if (leftEntries.length !== rightEntries.length) return false
+ const rightMap = new Map(rightEntries)
+ return leftEntries.every(([key, value]) => rightMap.get(key) === value)
+}
+
+export function buildDocumentRow(request, options = {}) {
+ const normalized = normalizeRequestForUi(request)
+ if (!normalized) {
+ return null
+ }
+
+ const archived = Boolean(options.archived)
+ const source = options.source || 'owned'
+ const statusGroup = resolveStatusGroup(normalized, archived)
+ const statusLabel = archived ? resolveArchivedStatusLabel(normalized) : resolveStatusLabel(normalized, statusGroup)
+ const riskMeta = buildDocumentRiskMeta(normalized, options.currentUser)
+ const documentNo = normalized.documentNo || normalized.id || normalized.claimId || '待生成'
+ const claimId = normalized.claimId || normalized.id || documentNo
+ const createdAtSource = normalized.createdAt || normalized.submittedAt || normalized.applyTime || normalized.updatedAt
+ const updatedAtSource = normalized.updatedAt || normalized.submittedAt || normalized.createdAt || normalized.applyTime
+ const createdSortTime = resolveDocumentSortTime(createdAtSource)
+ const updatedSortTime = resolveDocumentSortTime(updatedAtSource)
+ const documentTypeCode = normalized.documentTypeCode || DOCUMENT_TYPE_REIMBURSEMENT
+ const documentTypeLabel =
+ normalized.documentTypeLabel
+ || (documentTypeCode === DOCUMENT_TYPE_APPLICATION ? '申请单' : '报销单')
+ const initiatorName = String(
+ normalized.person
+ || normalized.employeeName
+ || normalized.profileName
+ || normalized.applicant
+ || request?.employee_name
+ || request?.employeeName
+ || request?.person
+ || ''
+ ).trim() || '待补充'
+
+ return {
+ ...normalized,
+ rawRequest: request,
+ documentKey: `${source}:${claimId || documentNo}`,
+ documentTypeCode,
+ documentTypeLabel,
+ claimId,
+ documentNo,
+ initiatorName,
+ node: archived ? resolveArchivedDocumentNode(normalized, documentTypeCode) : (normalized.node || normalized.workflowNode || '待提交'),
+ statusGroup,
+ statusLabel,
+ statusTone: archived ? 'archived' : resolveStatusTone(normalized, statusGroup),
+ riskTone: riskMeta.tone,
+ riskLabel: riskMeta.label,
+ riskCount: riskMeta.count,
+ riskTags: riskMeta.tags,
+ source,
+ archived,
+ createdAtDisplay: formatDocumentListTime(createdAtSource),
+ stayTimeDisplay: resolveDocumentStayTimeDisplay(normalized),
+ isNewDocument: archived
+ ? false
+ : isNewDocument({ ...normalized, source, claimId, documentNo }, options.viewedDocumentKeys || []),
+ updatedAtDisplay: formatDocumentListTime(updatedAtSource),
+ createdSortTime,
+ updatedSortTime,
+ sortTime: Math.max(createdSortTime, updatedSortTime)
+ }
+}
+
+export function buildDocumentRiskMeta(row, currentUser = null) {
+ const riskFlags = resolveDocumentRiskFlags(row)
+ const riskSummary = row?.riskSummary || row?.risk
+ // 列表风险标签按当前查看者可见性过滤,与详情页口径一致。
+ const viewerOptions = currentUser ? { request: row || {}, currentUser } : null
+ const count = countClaimRisks(riskFlags, riskSummary, viewerOptions)
+ if (!count) {
+ const meta = RISK_TONE_META.none
+ return {
+ ...meta,
+ count: 0,
+ tags: [{ ...meta }]
+ }
+ }
+
+ const tone = resolveArchiveRiskTone(riskFlags, riskSummary, viewerOptions)
+ const meta = RISK_TONE_META[tone] || RISK_TONE_META.medium
+ return {
+ ...meta,
+ count,
+ tags: [{ tone: meta.tone, label: `${meta.label} ${count}项` }]
+ }
+}
+
+export function filterDocumentRows(rows, filters = {}) {
+ const keyword = String(filters.keyword || '').trim().toLowerCase()
+ return sortDocumentRowsByLatestTime((rows || []).filter((row) => {
+ const matchesKeyword = !keyword || [
+ row.documentNo,
+ row.documentTypeLabel,
+ row.typeLabel,
+ row.initiatorName,
+ row.reason,
+ row.node,
+ row.statusLabel,
+ row.riskLabel
+ ].filter(Boolean).join('').toLowerCase().includes(keyword)
+
+ const matchesDocumentType =
+ !filters.showDocumentTypeFilter
+ || filters.activeDocumentType === DOCUMENT_TYPE_ALL
+ || row.documentTypeCode === filters.activeDocumentType
+
+ const matchesScene = filters.activeScene === SCENE_ALL || row.typeCode === filters.activeScene
+ const matchesRiskLevel = matchesRiskLevelTab(row, filters.activeStatusTab, filters.activeScopeTab)
+ const matchesDateRange = matchesAppliedDateRange(row, filters.appliedStart, filters.appliedEnd)
+
+ return matchesKeyword && matchesDocumentType && matchesScene && matchesRiskLevel && matchesDateRange
+ }))
+}
+
+export function matchesRiskLevelTab(row, tab, activeScopeTab = DOCUMENT_SCOPE_ALL) {
+ if (activeScopeTab !== DOCUMENT_SCOPE_ARCHIVE && isArchivedDocumentRow(row)) {
+ return false
+ }
+
+ if (tab === '全部') return true
+ if (tab === '高风险') return row.riskTone === 'high'
+ if (tab === '中风险') return row.riskTone === 'medium'
+ if (tab === '低风险') return row.riskTone === 'low'
+ if (tab === '无风险') return row.riskTone === 'none'
+ return true
+}
+
+export function matchesAppliedDateRange(row, start, end) {
+ if (!start || !end) {
+ return true
+ }
+
+ const date = extractDateText(row.updatedAt || row.submittedAt || row.createdAt || row.applyTime)
+ return Boolean(date) && date >= start && date <= end
+}
+
+export function mergeDocumentRows(rows) {
+ const rowMap = new Map()
+
+ rows.filter(Boolean).forEach((row) => {
+ const key = row.claimId || row.documentNo || row.documentKey
+ const current = rowMap.get(key)
+ if (!current || resolveSourcePriority(row) >= resolveSourcePriority(current)) {
+ rowMap.set(key, row)
+ }
+ })
+
+ return sortDocumentRowsByLatestTime(Array.from(rowMap.values()))
+}
+
+export function hasDocumentCenterActiveFilters(filters = {}) {
+ return Boolean(
+ String(filters.listKeyword || '').trim()
+ || filters.activeStatusTab !== '全部'
+ || (filters.showDocumentTypeFilter && filters.activeDocumentType !== DOCUMENT_TYPE_ALL)
+ || filters.activeScene !== SCENE_ALL
+ || filters.appliedStart
+ || filters.appliedEnd
+ )
+}
+
+export function buildDocumentCenterEmptyState(options = {}) {
+ const filtered = Boolean(options.hasActiveFilters)
+ const activeScopeTab = options.activeScopeTab || DOCUMENT_SCOPE_ALL
+ if (
+ activeScopeTab === DOCUMENT_SCOPE_APPLICATION
+ || options.activeDocumentType === DOCUMENT_TYPE_APPLICATION
+ ) {
+ return {
+ eyebrow: '申请单',
+ title: '当前还没有申请单数据',
+ desc: '费用申请功能接入后,差旅、会务、办公采购等前置申请会统一汇总到这里。',
+ icon: 'mdi mdi-file-sign-outline',
+ actionLabel: '',
+ actionIcon: '',
+ tone: 'theme',
+ artLabel: 'APPLY',
+ tips: ['申请、报销、审批与归档统一在此查看', '申请批准后可继续发起报销']
+ }
+ }
+
+ return {
+ eyebrow: filtered ? '筛选结果为空' : '单据中心',
+ title: filtered ? '没有符合当前条件的单据' : `“${activeScopeTab}”里暂时没有单据`,
+ desc: filtered
+ ? '可以清空当前分类下的筛选条件后再看看。'
+ : '当前视角暂无可展示单据,可以切换其他视角或发起一笔报销。',
+ icon: filtered ? 'mdi mdi-magnify-scan' : 'mdi mdi-file-document-multiple-outline',
+ actionLabel: '',
+ actionIcon: '',
+ tone: 'theme',
+ artLabel: filtered ? 'FILTER' : 'DOCS',
+ tips: ['单据中心已接入当前报销单据', '归档视角会同步已归档数据']
+ }
+}
+
+function resolveArchivedDocumentNode(normalized, documentTypeCode) {
+ if (documentTypeCode === DOCUMENT_TYPE_APPLICATION) {
+ return '申请归档'
+ }
+ if (normalized.status === 'paid' || normalized.approvalStatus === '已付款') {
+ return '已付款'
+ }
+ return normalized.node || normalized.workflowNode || '财务归档'
+}
+
+function resolveArchivedStatusLabel(normalized) {
+ if (normalized.status === 'paid' || normalized.approvalStatus === '已付款' || normalized.node === '已付款') {
+ return '已付款'
+ }
+ return '已归档'
+}
+
+function resolveStatusGroup(row, archived) {
+ if (archived) return 'completed'
+ if (row.approvalKey === 'draft') return 'draft'
+ if (row.approvalKey === 'supplement' && row.status === 'returned') return 'pending_submit'
+ if (row.approvalKey === 'supplement') return 'supplement'
+ if (row.approvalKey === 'pending_payment') return 'pending_payment'
+ if (row.approvalKey === 'in_progress') return 'in_progress'
+ if (row.approvalKey === 'completed') return 'completed'
+ return 'other'
+}
+
+function resolveStatusLabel(row, statusGroup) {
+ if (statusGroup === 'pending_submit') return '待提交'
+ if (statusGroup === 'pending_payment') return '待付款'
+ return row.approval || row.approvalStatus || '处理中'
+}
+
+function resolveStatusTone(row, statusGroup) {
+ if (statusGroup === 'pending_submit') return 'warning'
+ return row.approvalTone || 'neutral'
+}
+
+function resolveDocumentRiskFlags(row) {
+ if (Array.isArray(row?.riskFlags)) {
+ return row.riskFlags
+ }
+ if (Array.isArray(row?.risk_flags_json)) {
+ return row.risk_flags_json
+ }
+ return []
+}
+
+function resolveSourcePriority(row) {
+ if (row.archived) return 3
+ if (row.source === 'approval') return 2
+ return 1
+}
diff --git a/web/src/utils/expenseApplicationPreview.js b/web/src/utils/expenseApplicationPreview.js
index 0294b3f..5d1a5c2 100644
--- a/web/src/utils/expenseApplicationPreview.js
+++ b/web/src/utils/expenseApplicationPreview.js
@@ -1,752 +1,56 @@
import { buildApplicationFieldsFromOntology } from './expenseApplicationOntology.js'
import { evaluateLocalApplicationIntentGate } from './expenseApplicationIntentGate.js'
import {
- buildMockApplicationTransportEstimate,
formatApplicationEstimateMoney,
parseApplicationEstimateMoney,
buildSystemApplicationEstimate
} from './expenseApplicationEstimate.js'
-import { getTodayDateValue } from './workbenchComposerDate.js'
-
-export const APPLICATION_PREVIEW_FIELD_DEFINITIONS = [
- { key: 'applicationType', label: '申请类型' },
- { key: 'applicant', label: '姓名', editable: false, required: false },
- { key: 'grade', label: '职级', highlight: true, editable: false, required: false },
- { key: 'department', label: '部门', editable: false, required: false },
- { key: 'position', label: '岗位', editable: false, required: false },
- { key: 'managerName', label: '直属领导', editable: false, required: false },
- { key: 'time', label: '申请时间' },
- { key: 'location', label: '地点' },
- { key: 'reason', label: '事由' },
- { key: 'days', label: '天数' },
- { key: 'transportMode', label: '出行方式' },
- { key: 'lodgingDailyCap', label: '住宿上限/天', highlight: true, editable: false, required: false },
- { key: 'subsidyDailyCap', label: '补贴标准/天', highlight: true, editable: false, required: false },
- { key: 'transportPolicy', label: '交通费用口径', highlight: true, editable: false, required: false },
- { key: 'policyEstimate', label: '规则测算参考', highlight: true, editable: false, required: false },
- { key: 'amount', label: '系统预估费用', highlight: true, editable: false, required: false }
-]
-
-export const APPLICATION_TRANSPORT_MODE_OPTIONS = ['火车', '飞机', '轮船']
-
-const APPLICATION_POLICY_PENDING_TEXT = '填写地点和天数后自动测算'
-const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '选择火车、飞机或轮船后自动预估交通费用'
-
-export function resolveApplicationTimeLabel(applicationType = '') {
- const label = String(applicationType || '').trim()
- if (/差旅|出差/.test(label)) return '出发时间'
- if (/招待|宴请|餐饮/.test(label)) return '招待时间'
- return '申请时间'
-}
-
-function resolveApplicationFieldLabel(item, fields = {}) {
- if (item.key === 'time') {
- return resolveApplicationTimeLabel(fields.applicationType)
- }
- return item.label
-}
-
-function isTravelApplicationType(applicationType = '') {
- return /差旅|出差/.test(String(applicationType || '').trim())
-}
-
-function resolveApplicationTripDateParts(fields = {}) {
- const timeText = String(fields.time || '').trim()
- const matchedDates = timeText.match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) || []
- const startDate = normalizeDateText(matchedDates[0] || timeText)
- const explicitEndDate = normalizeDateText(matchedDates[matchedDates.length - 1] || '')
- const inferredEndDate = explicitEndDate && explicitEndDate !== startDate
- ? explicitEndDate
- : buildEndDateFromDays(startDate, fields.days)
-
- return {
- startDate,
- endDate: inferredEndDate || explicitEndDate || startDate
- }
-}
-
-function compactText(value) {
- return String(value || '').replace(/\s+/g, '')
-}
-
-function looksLikeStructuredTravelApplication(text) {
- const source = String(text || '')
- return /(?:发生时间|业务发生时间|申请时间|时间)\s*[::]/.test(source)
- && /(?:地点|业务地点|发生地点|目的地)\s*[::]/.test(source)
- && /(?:天数|出差天数|申请天数)\s*[::]?\s*(?:\d+|[一二两三四五六七八九十]{1,3})\s*天/.test(source)
-}
-
-function resolveFirstMatch(text, patterns = []) {
- for (const pattern of patterns) {
- const match = text.match(pattern)
- const value = String(match?.groups?.value || match?.[1] || '').trim()
- if (value) return value.replace(/[,。;;]$/, '')
- }
- return ''
-}
-
-function normalizeDateText(value) {
- return String(value || '').replace(/[/.]/g, '-').replace(/\s+/g, '')
-}
-
-function parseIsoDate(value) {
- const match = normalizeDateText(value).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)))
- return Number.isNaN(date.getTime()) ? null : date
-}
-
-function formatIsoDate(date) {
- return date.toISOString().slice(0, 10)
-}
-
-function buildEndDateFromDays(startText, daysText = '') {
- const days = parseApplicationDaysValue(daysText)
- const start = parseIsoDate(startText)
- if (!days || !start) return ''
- const end = new Date(start.getTime())
- end.setUTCDate(end.getUTCDate() + Math.max(days - 1, 0))
- return formatIsoDate(end)
-}
-
-function buildDateFromMonthDay(year, month, day) {
- const normalized = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
- return parseIsoDate(normalized) ? normalized : ''
-}
-
-function resolveShortMonthDayRange(text, options = {}) {
- const match = String(text || '').match(
- /(?\d{1,2})月(?\d{1,2})日?\s*(?:至|到|~|—|–|--|-)\s*(?:(?\d{1,2})月)?(?\d{1,2})日/u
- )
- if (!match?.groups) return ''
-
- const referenceYear = Number(resolvePreviewToday(options).slice(0, 4))
- const startMonth = Number(match.groups.startMonth)
- const startDay = Number(match.groups.startDay)
- const endMonth = Number(match.groups.endMonth || match.groups.startMonth)
- const endDay = Number(match.groups.endDay)
- const startDate = buildDateFromMonthDay(referenceYear, startMonth, startDay)
- const endYear = endMonth < startMonth ? referenceYear + 1 : referenceYear
- const endDate = buildDateFromMonthDay(endYear, endMonth, endDay)
- if (!startDate || !endDate) return ''
- return startDate === endDate ? startDate : `${startDate} 至 ${endDate}`
-}
-
-function resolveDaysFromDateRange(rangeText) {
- const match = String(rangeText || '').match(/(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})\s*至\s*(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/)
- if (!match) return ''
- const start = parseIsoDate(match[1])
- const end = parseIsoDate(match[2])
- if (!start || !end) return ''
- const diffDays = Math.round((end.getTime() - start.getTime()) / 86400000)
- return diffDays >= 0 ? `${diffDays + 1}天` : ''
-}
-
-export function resolveApplicationDaysFromDateRange(rangeText) {
- return resolveDaysFromDateRange(rangeText)
-}
-
-function resolveApplicationValidationIssues(fields = {}) {
- const issues = []
- const rangeDaysText = resolveDaysFromDateRange(fields.time)
- const rangeDays = parseApplicationDaysValue(rangeDaysText)
- const explicitDays = parseApplicationDaysValue(fields.days)
- if (rangeDays && explicitDays && rangeDays !== explicitDays) {
- issues.push({
- code: 'time_days_conflict',
- field: 'days',
- message: `申请时间 ${fields.time} 按自然日为 ${rangeDays} 天,但填写的是 ${explicitDays} 天。`
- })
- }
- return issues
-}
-
-function shouldTrustModelApplicationFields(preview = {}) {
- const status = String(preview?.modelReviewStatus || '').trim()
- const strategy = String(preview?.parseStrategy || preview?.parse_strategy || '').trim()
- return Boolean(preview?.modelRefined)
- || status === 'completed'
- || strategy === 'llm_primary'
-}
-
-function resolveApplicationSourceValidationIssues(sourceText = '', fields = {}, preview = {}) {
- if (shouldTrustModelApplicationFields(preview)) {
- return []
- }
-
- const issues = []
- const locationCandidates = extractApplicationLocationCandidates(sourceText)
- if (locationCandidates.length > 1) {
- issues.push({
- code: 'location_candidates_conflict',
- field: 'location',
- message: `当前输入里同时出现多个地点:${locationCandidates.join('、')}。请确认本次申请地点。`
- })
- }
-
- const transportCandidates = extractApplicationTransportCandidates(sourceText)
- if (transportCandidates.length > 1) {
- issues.push({
- code: 'transport_candidates_conflict',
- field: 'transportMode',
- message: `当前输入里同时出现多个出行方式:${transportCandidates.join('、')}。请确认本次申请使用哪一种。`
- })
- }
-
- const amountCandidates = extractApplicationAmountCandidates(sourceText)
- if (amountCandidates.length > 1) {
- issues.push({
- code: 'amount_candidates_conflict',
- field: 'amount',
- message: `当前输入里同时出现多个金额:${amountCandidates.join('、')}。请确认本次申请金额。`
- })
- }
- return issues
-}
-
-export function shouldRequireApplicationModelReview(rawText = '') {
- const text = String(rawText || '').trim()
- const compact = compactText(text)
- if (!compact) return false
-
- const hasDateRange = /(?:20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}|(?:\d{1,2}月)?\d{1,2}日?)\s*(?:至|到|~|—|–|--|-)\s*(?:20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}|\d{1,2}月?\d{1,2}日?)/u.test(text)
- const hasTravelIntent = /差旅|出差|去|到|赴|前往/.test(compact)
- const hasTransport = /高铁|动车|火车|铁路|飞机|机票|航班|轮船|船票|客轮|渡轮/.test(compact)
- const hasBusinessPurpose = /服务|支撑|支持|辅助|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(compact)
- const hasInlinePurpose = hasBusinessPurpose && !/(?:事由|申请事由|出差事由|原因|用途)\s*[::]/.test(text)
- const hasMultipleClauses = /[,,。;;\n]/.test(text) || compact.length >= 24
- const signalCount = [hasDateRange, hasTravelIntent, hasTransport, hasBusinessPurpose].filter(Boolean).length
-
- return (hasInlinePurpose && signalCount >= 2) || (hasMultipleClauses && signalCount >= 3)
-}
-
-export function resolveApplicationDateRange(rangeText, daysText = '') {
- const matchedDates = String(rangeText || '').match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) || []
- const startDate = normalizeDateText(matchedDates[0] || '')
- const explicitEndDate = normalizeDateText(matchedDates[matchedDates.length - 1] || '')
- const inferredEndDate = explicitEndDate && explicitEndDate !== startDate
- ? explicitEndDate
- : buildEndDateFromDays(startDate, daysText)
- const endDate = inferredEndDate || explicitEndDate || startDate
- const start = parseIsoDate(startDate)
- const end = parseIsoDate(endDate)
- if (!start || !end) {
- return null
- }
- const orderedStart = start.getTime() <= end.getTime() ? start : end
- const orderedEnd = start.getTime() <= end.getTime() ? end : start
- return {
- startDate: formatIsoDate(orderedStart),
- endDate: formatIsoDate(orderedEnd),
- startTime: orderedStart.getTime(),
- endTime: orderedEnd.getTime()
- }
-}
-
-export function applicationDateRangesOverlap(leftRange, rightRange) {
- if (!leftRange || !rightRange) {
- return false
- }
- return leftRange.startTime <= rightRange.endTime && rightRange.startTime <= leftRange.endTime
-}
-
-function resolvePreviewToday(options = {}) {
- const explicitToday = String(options.today || options.currentDate || '').trim()
- if (parseIsoDate(explicitToday)) return normalizeDateText(explicitToday)
- if (options.now instanceof Date && !Number.isNaN(options.now.getTime())) {
- return getTodayDateValue(options.now)
- }
- return getTodayDateValue()
-}
-
-function resolveApplicationType(text) {
- const compact = compactText(text)
- if (looksLikeStructuredTravelApplication(text)) return '差旅费用申请'
- if (/差旅|出差|高铁|动车|火车|飞机|机票|航班|酒店|住宿/.test(compact)) return '差旅费用申请'
- if (/交通|出租车|的士|网约车|打车|通勤/.test(compact)) return '交通费用申请'
- if (/住宿|酒店/.test(compact)) return '住宿费用申请'
- if (/会务|会议|发布会|展会/.test(compact)) return '会务费用申请'
- if (/采购|办公用品|文具|设备|耗材/.test(compact)) return '采购费用申请'
- if (/培训|课程|学习/.test(compact)) return '培训费用申请'
- return '费用申请'
-}
-
-function resolveApplicationAmount(text) {
- const compact = compactText(text)
- const labeled = resolveFirstMatch(text, [
- /(?:用户预估费用|预估费用|申请金额|预计金额|预计费用|预计总费用|预算|金额|费用)\s*[::]?\s*(?\d+(?:\.\d+)?\s*(?:万|千|k|K)?)/u,
- /(?\d+(?:\.\d+)?\s*(?:(?:万|千|k|K)\s*(?:元|块|人民币)?|(?:元|块|人民币)))/u
- ])
- const normalized = normalizeApplicationAmountText(labeled)
- if (normalized) return normalized
- if (/不知道预算|预算不清楚|预算待定|待测算/.test(compact)) return '待测算'
- return ''
-}
-
-function normalizeApplicationAmountText(value) {
- const text = String(value || '').replace(/[,,]/g, '').trim()
- const match = text.match(/(?\d+(?:\.\d+)?)\s*(?万|千|k|K)?/u)
- if (!match?.groups) return ''
- let amount = Number(match.groups.number)
- if (!Number.isFinite(amount) || amount <= 0) return ''
- const unit = String(match.groups.unit || '').toLowerCase()
- if (unit === '万') amount *= 10000
- if (unit === '千' || unit === 'k') amount *= 1000
- return `${Number.isInteger(amount) ? amount : Number(amount.toFixed(2))}元`
-}
-
-function extractApplicationLocationCandidates(text) {
- const candidates = []
- const labeled = resolveFirstMatch(text, [
- /(?:地点|业务地点|发生地点|目的地)\s*[::]\s*(?[^。;;\n,,]+)/u
- ])
- if (labeled) candidates.push(normalizeLocationCandidate(labeled))
-
- const compact = compactText(text)
- const patterns = [
- /(?:去|到|赴|前往)(?[\u4e00-\u9fa5]{1,24})/gu,
- /(?[\u4e00-\u9fa5]{1,12})(?:出差|驻场)/gu
- ]
- for (const pattern of patterns) {
- for (const match of compact.matchAll(pattern)) {
- candidates.push(normalizeLocationCandidate(match.groups?.value || ''))
- }
- }
- return uniqueApplicationCandidates(candidates)
- .filter((item) => !isInvalidApplicationLocationCandidate(item))
-}
-
-function normalizeLocationCandidate(value) {
- let cleaned = String(value || '').replace(/\s+/g, '')
- for (const marker of ['前往', '去', '到', '赴']) {
- if (cleaned.includes(marker)) {
- cleaned = cleaned.slice(cleaned.lastIndexOf(marker) + marker.length)
- break
- }
- }
- cleaned = cleaned
- .replace(/^(?:去|到|赴|前往)/u, '')
- .replace(/(?:出差|驻场|现场|支撑|支持|部署|上线|实施|拜访|验收|会议|采购|培训|协助|处理|办理|参加|进行).*$/u, '')
- .replace(/[::,,。;;、\s]/g, '')
- return cleaned.replace(/^(北京|上海|天津|重庆)市$/u, '$1')
-}
-
-function isInvalidApplicationLocationCandidate(value) {
- const compact = compactText(value)
- if (!compact) return true
- if (/^(?:类型|申请类型|费用类型|报销类型)$/.test(compact)) return true
- if (/^(?:费用申请|差旅费用申请|交通费用申请|住宿费用申请|会务费用申请|采购费用申请|培训费用申请|报销申请|申请)$/.test(compact)) return true
- if (/(?:类型|申请类型|费用类型|报销类型)/.test(compact)) return true
- if (/(?:交通方式|出行方式|交通工具|预算|金额|费用|票据|附件|天数|时间|事由|原因|用途)/.test(compact)) return true
- return false
-}
-
-function extractApplicationTransportCandidates(text) {
- const compact = compactText(text)
- return uniqueApplicationCandidates([
- resolveApplicationTransportMode(resolveFirstMatch(text, [
- /(?:出行方式|交通方式|交通工具|出行工具)\s*[::]\s*(?[^。;;\n,,]+)/u
- ])),
- /高铁|动车|火车|铁路/.test(compact) ? '火车' : '',
- /飞机|机票|航班/.test(compact) ? '飞机' : '',
- /轮船|船票|客轮|渡轮|邮轮/.test(compact) ? '轮船' : ''
- ])
-}
-
-function extractApplicationAmountCandidates(text) {
- const candidates = []
- const source = String(text || '')
- const labelPattern = /(?:用户预估费用|预估费用|申请金额|预计金额|预计费用|预计总费用|预算|金额|费用)\s*[::]?\s*(?\d+(?:\.\d+)?\s*(?:万|千|k|K)?\s*(?:元|块|人民币)?)/gu
- for (const match of source.matchAll(labelPattern)) {
- candidates.push(normalizeApplicationAmountText(match.groups?.value || ''))
- }
- const amountPattern = /(?\d+(?:\.\d+)?\s*(?:(?:万|千|k|K)\s*(?:元|块|人民币)?|(?:元|块|人民币)))/gu
- for (const match of source.matchAll(amountPattern)) {
- candidates.push(normalizeApplicationAmountText(match.groups?.value || ''))
- }
- return uniqueApplicationCandidates(candidates)
-}
-
-function uniqueApplicationCandidates(values) {
- return values
- .map((item) => String(item || '').trim())
- .filter(Boolean)
- .filter((item, index, list) => list.indexOf(item) === index)
-}
-
-function resolveCurrentUserGrade(currentUser = {}) {
- return String(
- currentUser.grade
- || currentUser.employeeGrade
- || currentUser.employee_grade
- || currentUser.profileGrade
- || ''
- ).trim()
-}
-
-function resolveCurrentUserDepartment(currentUser = {}) {
- return String(
- currentUser.department
- || currentUser.departmentName
- || currentUser.department_name
- || ''
- ).trim()
-}
-
-function resolveCurrentUserPosition(currentUser = {}) {
- return String(
- currentUser.position
- || currentUser.employeePosition
- || currentUser.employee_position
- || currentUser.jobTitle
- || currentUser.job_title
- || ''
- ).trim()
-}
-
-function resolveCurrentUserManagerName(currentUser = {}) {
- return String(
- currentUser.managerName
- || currentUser.manager_name
- || currentUser.directManagerName
- || currentUser.direct_manager_name
- || currentUser.leaderName
- || currentUser.leader_name
- || ''
- ).trim()
-}
-
-function parseApplicationDaysValue(value) {
- const match = String(value || '').match(/\d+/)
- const days = match ? Number(match[0]) : parseChineseNumber(value)
- return Number.isFinite(days) && days > 0 ? Math.max(1, Math.floor(days)) : 0
-}
-
-function parseChineseNumber(value) {
- const digits = {
- 一: 1,
- 二: 2,
- 两: 2,
- 三: 3,
- 四: 4,
- 五: 5,
- 六: 6,
- 七: 7,
- 八: 8,
- 九: 9
- }
- const text = String(value || '').match(/[一二两三四五六七八九十]{1,3}/)?.[0] || ''
- 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
-}
-
-function parseMoneyNumber(value) {
- const normalized = String(value ?? '').replace(/[^\d.-]/g, '')
- const amount = Number(normalized)
- return Number.isFinite(amount) ? amount : null
-}
-
-function formatPolicyMoney(value) {
- const amount = parseMoneyNumber(value)
- if (amount === null) return String(value || '').trim()
- return new Intl.NumberFormat('zh-CN', {
- minimumFractionDigits: 0,
- maximumFractionDigits: Number.isInteger(amount) ? 0 : 2
- }).format(amount)
-}
-
-function formatDailyPolicyMoney(value) {
- const display = formatPolicyMoney(value)
- return display ? `${display}元/天` : APPLICATION_POLICY_PENDING_TEXT
-}
-
-function buildTransportPolicyText(transportMode, location = '', transportEstimate = null, time = '') {
- const mode = String(transportMode || '').trim()
- const estimate = transportEstimate || buildMockApplicationTransportEstimate({ transportMode: mode, location, time })
- if (!estimate) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
- return estimate.basisText
-}
-
-function buildTransportEstimateFromPolicyResult(result = {}, fields = {}) {
- const amount = parseMoneyNumber(result?.transport_estimated_amount)
- if (!amount || amount <= 0) return null
- const amountDisplay = formatPolicyMoney(amount)
- const mode = String(result?.transport_mode || fields.transportMode || '').trim()
- const origin = String(result?.transport_origin || '').trim()
- const destination = String(result?.transport_destination || result?.matched_city || fields.location || '').trim()
- const basis = String(result?.transport_estimate_basis || '').trim()
- const ruleName = String(result?.transport_estimate_rule_name || '交通费用预估表').trim()
- const routeText = [origin, destination].filter(Boolean).join('-')
- const modeText = mode ? `${mode}往返` : '往返'
- const routeModeText = routeText ? `${routeText}${modeText}` : modeText
- const displayBasis = routeModeText && basis.startsWith(routeModeText)
- ? basis.slice(routeModeText.length).trim()
- : basis
- const basisSuffix = displayBasis ? `(${displayBasis})` : ''
- return {
- mode,
- amount,
- amountDisplay,
- routeType: '往返',
- origin,
- destination,
- queryDate: String(result?.travel_date || '').trim(),
- source: String(result?.transport_estimate_source || 'basic_rule_transport_estimate').trim(),
- confidence: String(result?.transport_estimate_confidence || 'basic_rule').trim(),
- basis,
- ruleCode: String(result?.transport_estimate_rule_code || '').trim(),
- ruleName,
- ruleVersion: String(result?.transport_estimate_rule_version || '').trim(),
- basisText: `当前尚未接通实时票务价格查询 API,无法获取当前实际票价;先按《${ruleName}》${routeModeText}${basisSuffix}暂估 ${amountDisplay}元用于申请阶段预算占用,最终报销以实际票据金额为准`
- }
-}
-
-function ensureApplicationPolicyFields(fields = {}) {
- const nextFields = { ...fields }
- if (!String(nextFields.lodgingDailyCap || '').trim()) {
- nextFields.lodgingDailyCap = APPLICATION_POLICY_PENDING_TEXT
- }
- if (!String(nextFields.subsidyDailyCap || '').trim()) {
- nextFields.subsidyDailyCap = APPLICATION_POLICY_PENDING_TEXT
- }
- if (!String(nextFields.transportPolicy || '').trim() || /实报实销/.test(String(nextFields.transportPolicy || ''))) {
- nextFields.transportPolicy = buildTransportPolicyText(nextFields.transportMode, nextFields.location, null, nextFields.time)
- }
- if (!String(nextFields.policyEstimate || '').trim()) {
- nextFields.policyEstimate = APPLICATION_POLICY_PENDING_TEXT
- }
- return nextFields
-}
-
-function resolveApplicationDays(text) {
- const value = resolveFirstMatch(text, [
- /(?:出差|申请)?(?\d+)\s*天/u,
- /(?\d+)\s*(?:个)?工作日/u
- ])
- return value ? `${value}天` : ''
-}
-
-function resolveApplicationTime(text, daysText = '', options = {}) {
- const range = text.match(
- /(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})\s*(?:至|到|~|—|–|--)\s*(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u
- )
- if (range) {
- return `${normalizeDateText(range[1])} 至 ${normalizeDateText(range[2])}`
- }
-
- const shortMonthDayRange = resolveShortMonthDayRange(text, options)
- if (shortMonthDayRange) {
- return shortMonthDayRange
- }
-
- const single = resolveFirstMatch(text, [
- /(?:发生时间|业务发生时间|申请时间|时间)\s*[::]\s*(?20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u,
- /(?20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u
- ])
- if (!single) return ''
- const normalized = normalizeDateText(single)
- const endDate = buildEndDateFromDays(normalized, daysText)
- return endDate && endDate !== normalized ? `${normalized} 至 ${endDate}` : normalized
-}
-
-function resolveApplicationTimeWithDefault(text, daysText = '', options = {}) {
- const resolvedTime = resolveApplicationTime(text, daysText, options)
- if (resolvedTime || !parseApplicationDaysValue(daysText)) {
- return resolvedTime
- }
-
- const startDate = resolvePreviewToday(options)
- const endDate = buildEndDateFromDays(startDate, daysText)
- return endDate && endDate !== startDate ? `${startDate} 至 ${endDate}` : startDate
-}
-
-function resolveApplicationLocation(text) {
- return resolveFirstMatch(text, [
- /(?:地点|业务地点|发生地点|目的地)\s*[::]\s*(?[^。;;\n]+)/u,
- /(?:去|到|前往)(?[\u4e00-\u9fa5,,、]{2,24}?)(?:出差|支撑|支持|部署|开会|培训|拜访|验收|项目|客户|。|\s|$)/u
- ])
-}
-
-function looksLikeTransportPromptText(text) {
- const compact = compactText(text)
- return /(?:还需要补充|当前还缺|缺少|请先补充|请选择|可以选择|可以选|打算怎么出行|怎么出行).{0,24}(?:出行方式|交通方式|交通工具)/u.test(compact)
- || /(?:出行方式|交通方式|交通工具).{0,24}(?:待补充|缺失|请选择|可以选择|可以选)/u.test(compact)
-}
-
-function resolveApplicationTransportMode(text) {
- const labeled = resolveFirstMatch(text, [
- /(?:出行方式|交通方式|交通工具|出行工具)\s*[::]\s*(?[^。;;\n,,]+)/u
- ])
- const labeledMode = normalizeTransportModeOption(labeled, '')
- if (labeledMode && !looksLikeTransportPromptText(labeled) && !/(?:请选择|可以选择|可以选)/u.test(String(labeled || ''))) {
- return labeledMode
- }
- const fullTextLooksLikePrompt = looksLikeTransportPromptText(text)
- const segments = String(text || '')
- .split(/[\n,,。;;]+/u)
- .map((item) => item.trim())
- .filter(Boolean)
- for (const segment of segments) {
- if (looksLikeTransportPromptText(segment)) continue
- const compactSegment = compactText(segment)
- if (
- fullTextLooksLikePrompt
- && !/(?:申请|出差|去|到|赴|前往|服务|支撑|支持|部署|上线|实施|拜访|会议)/u.test(compactSegment)
- ) {
- continue
- }
- if (/高铁|动车|火车|铁路/.test(compactSegment)) return '火车'
- if (/飞机|机票|航班/.test(compactSegment)) return '飞机'
- if (/轮船|船票|客轮|渡轮/.test(compactSegment)) return '轮船'
- }
- if (fullTextLooksLikePrompt) return ''
- const compact = compactText(text)
- if (/高铁|动车|火车|铁路/.test(compact)) return '火车'
- if (/飞机|机票|航班/.test(compact)) return '飞机'
- if (/轮船|船票|客轮|渡轮/.test(compact)) return '轮船'
- return ''
-}
-
-function stripKnownContextFromReason(value, context = {}) {
- const location = String(context.location || '').trim()
- let cleaned = String(value || '')
- .replace(/(?:请直接生成申请单核对结果|信息足够时生成申请单|但在入库或提交审批前仍需让我确认|请直接生成报销核对结果|需要创建草稿、绑定附件或提交审批前仍需让我确认)[\s\S]*$/u, '')
- .replace(/(?:发生时间|业务发生时间|申请时间|时间)\s*[::]\s*(?=[,,、。;;\s]|$)/gu, '')
- .replace(/(?:地点|业务地点|发生地点|目的地)\s*[::]\s*(?=[,,、。;;\s]|$)/gu, '')
- .replace(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}\s*(?:至|到|~|—|–|--)\s*20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/gu, '')
- .replace(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/gu, '')
- .replace(/(?:\d{1,2}月)?\d{1,2}日?\s*(?:至|到|~|—|–|--|-)\s*(?:\d{1,2}月)?\d{1,2}日?/gu, '')
- .replace(/\d{1,2}月\d{1,2}日?/gu, '')
- .replace(/(?:出差|申请)?\d+\s*天/gu, '')
- .replace(/(?:用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额|费用)\s*[::]?\s*\d+(?:\.\d+)?\s*(?:元|块|人民币)?/gu, '')
- .replace(/(?:高铁|动车|火车|铁路|飞机|机票|航班|轮船|船票|客轮|渡轮|出租车|的士|网约车|打车|自驾)/gu, '')
- .replace(/[,,、。;;]+/g, ',')
- .replace(/^\s*(去|到|前往)/u, '')
- .replace(/^[,\s]+|[,\s]+$/g, '')
- .trim()
-
- if (location) {
- const escapedLocation = location.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
- cleaned = cleaned
- .replace(new RegExp(`^${escapedLocation}(?:出差)?(?:,|,|、)?`, 'u'), '')
- .replace(new RegExp(`^(?:去|到|前往)${escapedLocation}(?:出差)?(?:,|,|、)?`, 'u'), '')
- .trim()
- }
-
- return cleaned
-}
-
-function pickBusinessReasonSegment(text) {
- const segments = String(text || '')
- .split(/[,,、。;;\n]+/u)
- .map((item) => item.trim())
- .filter((item) => item && !isSystemGeneratedReasonText(item))
- return segments.find((item) => /服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(item)) || ''
-}
-
-function isSystemGeneratedReasonText(value = '') {
- const compact = compactText(value)
- return compact.startsWith('小财管家继续执行')
- || /请直接生成申请单核对结果|信息足够时生成申请单|入库或提交审批前|请直接生成报销核对结果/.test(compact)
- || compact.startsWith('处理要求')
- || compact.startsWith('已识别信息')
- || compact.startsWith('用户已补充')
- || /^(?:类型|申请类型|费用类型|报销类型)[::]?(?:差旅费用申请|交通费用申请|住宿费用申请|费用申请|差旅费|交通费|住宿费)?$/.test(compact)
-}
-
-function resolveApplicationReason(text, context = {}) {
- const labeled = resolveFirstMatch(text, [
- /(?:事由|申请事由|出差事由|原因|用途)\s*[::]\s*(?[^,。;;\n]+)/u
- ])
- if (labeled) return stripKnownContextFromReason(labeled, context)
- const cleaned = String(text || '')
- .replace(/^\s*(我想|我要|帮我)?\s*(先)?\s*(发起|提交|申请)?\s*(一笔)?\s*(费用申请|申请)\s*/u, '')
- const withoutContext = stripKnownContextFromReason(cleaned, context)
- const businessSegment = pickBusinessReasonSegment(withoutContext)
- if (businessSegment) return stripKnownContextFromReason(businessSegment, context)
- if (isSystemGeneratedReasonText(withoutContext)) return ''
- return withoutContext
-}
-
-function isApplicationPreviewValueProvided(value) {
- const normalized = String(value || '').trim()
- return Boolean(normalized) && !['待测算', '待补充', '未知'].includes(normalized)
-}
-
-function resolveProvidedValue(value, fallback = '') {
- return isApplicationPreviewValueProvided(value) ? String(value).trim() : fallback
-}
-
-function normalizeApplicationTypeLabel(value, fallback = '') {
- const label = String(value || '').trim()
- if (!label || label === '其他费用') return fallback || '费用申请'
- if (label.endsWith('费用申请') || label.endsWith('申请')) return label
- if (label.endsWith('费用')) return `${label}申请`
- if (label.endsWith('费')) return `${label.slice(0, -1)}费用申请`
- return `${label}申请`
-}
-
-export function normalizeTransportModeOption(value, fallback = '') {
- const text = String(value || '').trim()
- if (/飞机|机票|航班/.test(text)) return '飞机'
- if (/轮船|船票|客轮|渡轮|邮轮/.test(text)) return '轮船'
- if (/火车|高铁|动车|铁路|列车/.test(text)) return '火车'
- return APPLICATION_TRANSPORT_MODE_OPTIONS.includes(text) ? text : fallback
-}
-
-function resolveModelRefinedTransportMode(ontologyFields = {}, rawText = '', currentFields = {}) {
- const currentTransportMode = isApplicationPreviewValueProvided(currentFields.transportMode)
- ? String(currentFields.transportMode).trim()
- : ''
- const explicitTransportMode = resolveApplicationTransportMode(rawText)
- if (!explicitTransportMode) {
- return currentTransportMode
- }
-
- const ontologyTransportMode = normalizeTransportModeOption(ontologyFields.transportMode, '')
- if (ontologyTransportMode && ontologyTransportMode === explicitTransportMode) {
- return ontologyTransportMode
- }
- return currentTransportMode || explicitTransportMode
-}
-
-function normalizeAmountFromOntology(fields = {}, fallback = '') {
- const numericAmount = Number(fields.amount || fields.policyTotalAmount || fields.reimbursementAmount || 0)
- if (Number.isFinite(numericAmount) && numericAmount > 0) {
- return `${numericAmount}元`
- }
-
- const display = String(fields.amountDisplay || '').trim()
- if (display && display !== '待补充') {
- const normalized = display.replace(/^¥\s*/, '').replace(/,/g, '').trim()
- return normalized.endsWith('元') ? normalized : `${normalized}元`
- }
-
- return fallback
-}
-
-function normalizeTypedOntologyAmount(value, fallback = '') {
- const amount = Number(value || 0)
- if (Number.isFinite(amount) && amount > 0) {
- return `${amount}元`
- }
- return fallback
-}
-
-function buildMissingFields(fields) {
- return APPLICATION_PREVIEW_FIELD_DEFINITIONS
- .filter((item) => item.key !== 'applicationType' && item.required !== false)
- .filter((item) => !isApplicationPreviewValueProvided(fields?.[item.key]))
- .map((item) => resolveApplicationFieldLabel(item, fields))
-}
+import {
+ APPLICATION_POLICY_PENDING_TEXT,
+ APPLICATION_PREVIEW_FIELD_DEFINITIONS,
+ APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT,
+ buildMissingFields,
+ buildTransportEstimateFromPolicyResult,
+ buildTransportPolicyText,
+ ensureApplicationPolicyFields,
+ formatDailyPolicyMoney,
+ formatPolicyMoney,
+ isApplicationPreviewValueProvided,
+ isTravelApplicationType,
+ normalizeAmountFromOntology,
+ normalizeApplicationTypeLabel,
+ normalizeTypedOntologyAmount,
+ parseApplicationDaysValue,
+ parseMoneyNumber,
+ resolveApplicationAmount,
+ resolveApplicationDays,
+ resolveApplicationFieldLabel,
+ resolveApplicationLocation,
+ resolveApplicationReason,
+ resolveApplicationSourceValidationIssues,
+ resolveApplicationTimeWithDefault,
+ resolveApplicationTransportMode,
+ resolveApplicationTripDateParts,
+ resolveApplicationType,
+ resolveApplicationValidationIssues,
+ resolveCurrentUserDepartment,
+ resolveCurrentUserGrade,
+ resolveCurrentUserManagerName,
+ resolveCurrentUserPosition,
+ resolveDaysFromDateRange,
+ resolveModelRefinedTransportMode,
+ resolveProvidedValue
+} from './expenseApplicationPreviewParsing.js'
+export {
+ APPLICATION_PREVIEW_FIELD_DEFINITIONS,
+ APPLICATION_TRANSPORT_MODE_OPTIONS,
+ applicationDateRangesOverlap,
+ normalizeTransportModeOption,
+ resolveApplicationDateRange,
+ resolveApplicationDaysFromDateRange,
+ resolveApplicationTimeLabel,
+ shouldRequireApplicationModelReview
+} from './expenseApplicationPreviewParsing.js'
export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser = {}) {
const normalized = normalizeApplicationPreview(preview)
diff --git a/web/src/utils/expenseApplicationPreviewParsing.js b/web/src/utils/expenseApplicationPreviewParsing.js
new file mode 100644
index 0000000..efb9ea1
--- /dev/null
+++ b/web/src/utils/expenseApplicationPreviewParsing.js
@@ -0,0 +1,742 @@
+import { buildMockApplicationTransportEstimate } from './expenseApplicationEstimate.js'
+import { getTodayDateValue } from './workbenchComposerDate.js'
+
+export const APPLICATION_PREVIEW_FIELD_DEFINITIONS = [
+ { key: 'applicationType', label: '申请类型' },
+ { key: 'applicant', label: '姓名', editable: false, required: false },
+ { key: 'grade', label: '职级', highlight: true, editable: false, required: false },
+ { key: 'department', label: '部门', editable: false, required: false },
+ { key: 'position', label: '岗位', editable: false, required: false },
+ { key: 'managerName', label: '直属领导', editable: false, required: false },
+ { key: 'time', label: '申请时间' },
+ { key: 'location', label: '地点' },
+ { key: 'reason', label: '事由' },
+ { key: 'days', label: '天数' },
+ { key: 'transportMode', label: '出行方式' },
+ { key: 'lodgingDailyCap', label: '住宿上限/天', highlight: true, editable: false, required: false },
+ { key: 'subsidyDailyCap', label: '补贴标准/天', highlight: true, editable: false, required: false },
+ { key: 'transportPolicy', label: '交通费用口径', highlight: true, editable: false, required: false },
+ { key: 'policyEstimate', label: '规则测算参考', highlight: true, editable: false, required: false },
+ { key: 'amount', label: '系统预估费用', highlight: true, editable: false, required: false }
+]
+
+export const APPLICATION_TRANSPORT_MODE_OPTIONS = ['火车', '飞机', '轮船']
+
+export const APPLICATION_POLICY_PENDING_TEXT = '填写地点和天数后自动测算'
+export const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '选择火车、飞机或轮船后自动预估交通费用'
+
+export function resolveApplicationTimeLabel(applicationType = '') {
+ const label = String(applicationType || '').trim()
+ if (/差旅|出差/.test(label)) return '出发时间'
+ if (/招待|宴请|餐饮/.test(label)) return '招待时间'
+ return '申请时间'
+}
+
+export function resolveApplicationFieldLabel(item, fields = {}) {
+ if (item.key === 'time') {
+ return resolveApplicationTimeLabel(fields.applicationType)
+ }
+ return item.label
+}
+
+export function isTravelApplicationType(applicationType = '') {
+ return /差旅|出差/.test(String(applicationType || '').trim())
+}
+
+export function resolveApplicationTripDateParts(fields = {}) {
+ const timeText = String(fields.time || '').trim()
+ const matchedDates = timeText.match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) || []
+ const startDate = normalizeDateText(matchedDates[0] || timeText)
+ const explicitEndDate = normalizeDateText(matchedDates[matchedDates.length - 1] || '')
+ const inferredEndDate = explicitEndDate && explicitEndDate !== startDate
+ ? explicitEndDate
+ : buildEndDateFromDays(startDate, fields.days)
+
+ return {
+ startDate,
+ endDate: inferredEndDate || explicitEndDate || startDate
+ }
+}
+
+function compactText(value) {
+ return String(value || '').replace(/\s+/g, '')
+}
+
+function looksLikeStructuredTravelApplication(text) {
+ const source = String(text || '')
+ return /(?:发生时间|业务发生时间|申请时间|时间)\s*[::]/.test(source)
+ && /(?:地点|业务地点|发生地点|目的地)\s*[::]/.test(source)
+ && /(?:天数|出差天数|申请天数)\s*[::]?\s*(?:\d+|[一二两三四五六七八九十]{1,3})\s*天/.test(source)
+}
+
+function resolveFirstMatch(text, patterns = []) {
+ for (const pattern of patterns) {
+ const match = text.match(pattern)
+ const value = String(match?.groups?.value || match?.[1] || '').trim()
+ if (value) return value.replace(/[,。;;]$/, '')
+ }
+ return ''
+}
+
+function normalizeDateText(value) {
+ return String(value || '').replace(/[/.]/g, '-').replace(/\s+/g, '')
+}
+
+function parseIsoDate(value) {
+ const match = normalizeDateText(value).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)))
+ return Number.isNaN(date.getTime()) ? null : date
+}
+
+function formatIsoDate(date) {
+ return date.toISOString().slice(0, 10)
+}
+
+function buildEndDateFromDays(startText, daysText = '') {
+ const days = parseApplicationDaysValue(daysText)
+ const start = parseIsoDate(startText)
+ if (!days || !start) return ''
+ const end = new Date(start.getTime())
+ end.setUTCDate(end.getUTCDate() + Math.max(days - 1, 0))
+ return formatIsoDate(end)
+}
+
+function buildDateFromMonthDay(year, month, day) {
+ const normalized = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
+ return parseIsoDate(normalized) ? normalized : ''
+}
+
+function resolveShortMonthDayRange(text, options = {}) {
+ const match = String(text || '').match(
+ /(?\d{1,2})月(?\d{1,2})日?\s*(?:至|到|~|—|–|--|-)\s*(?:(?\d{1,2})月)?(?\d{1,2})日/u
+ )
+ if (!match?.groups) return ''
+
+ const referenceYear = Number(resolvePreviewToday(options).slice(0, 4))
+ const startMonth = Number(match.groups.startMonth)
+ const startDay = Number(match.groups.startDay)
+ const endMonth = Number(match.groups.endMonth || match.groups.startMonth)
+ const endDay = Number(match.groups.endDay)
+ const startDate = buildDateFromMonthDay(referenceYear, startMonth, startDay)
+ const endYear = endMonth < startMonth ? referenceYear + 1 : referenceYear
+ const endDate = buildDateFromMonthDay(endYear, endMonth, endDay)
+ if (!startDate || !endDate) return ''
+ return startDate === endDate ? startDate : `${startDate} 至 ${endDate}`
+}
+
+export function resolveDaysFromDateRange(rangeText) {
+ const match = String(rangeText || '').match(/(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})\s*至\s*(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/)
+ if (!match) return ''
+ const start = parseIsoDate(match[1])
+ const end = parseIsoDate(match[2])
+ if (!start || !end) return ''
+ const diffDays = Math.round((end.getTime() - start.getTime()) / 86400000)
+ return diffDays >= 0 ? `${diffDays + 1}天` : ''
+}
+
+export function resolveApplicationDaysFromDateRange(rangeText) {
+ return resolveDaysFromDateRange(rangeText)
+}
+
+export function resolveApplicationValidationIssues(fields = {}) {
+ const issues = []
+ const rangeDaysText = resolveDaysFromDateRange(fields.time)
+ const rangeDays = parseApplicationDaysValue(rangeDaysText)
+ const explicitDays = parseApplicationDaysValue(fields.days)
+ if (rangeDays && explicitDays && rangeDays !== explicitDays) {
+ issues.push({
+ code: 'time_days_conflict',
+ field: 'days',
+ message: `申请时间 ${fields.time} 按自然日为 ${rangeDays} 天,但填写的是 ${explicitDays} 天。`
+ })
+ }
+ return issues
+}
+
+function shouldTrustModelApplicationFields(preview = {}) {
+ const status = String(preview?.modelReviewStatus || '').trim()
+ const strategy = String(preview?.parseStrategy || preview?.parse_strategy || '').trim()
+ return Boolean(preview?.modelRefined)
+ || status === 'completed'
+ || strategy === 'llm_primary'
+}
+
+export function resolveApplicationSourceValidationIssues(sourceText = '', fields = {}, preview = {}) {
+ if (shouldTrustModelApplicationFields(preview)) {
+ return []
+ }
+
+ const issues = []
+ const locationCandidates = extractApplicationLocationCandidates(sourceText)
+ if (locationCandidates.length > 1) {
+ issues.push({
+ code: 'location_candidates_conflict',
+ field: 'location',
+ message: `当前输入里同时出现多个地点:${locationCandidates.join('、')}。请确认本次申请地点。`
+ })
+ }
+
+ const transportCandidates = extractApplicationTransportCandidates(sourceText)
+ if (transportCandidates.length > 1) {
+ issues.push({
+ code: 'transport_candidates_conflict',
+ field: 'transportMode',
+ message: `当前输入里同时出现多个出行方式:${transportCandidates.join('、')}。请确认本次申请使用哪一种。`
+ })
+ }
+
+ const amountCandidates = extractApplicationAmountCandidates(sourceText)
+ if (amountCandidates.length > 1) {
+ issues.push({
+ code: 'amount_candidates_conflict',
+ field: 'amount',
+ message: `当前输入里同时出现多个金额:${amountCandidates.join('、')}。请确认本次申请金额。`
+ })
+ }
+ return issues
+}
+
+export function shouldRequireApplicationModelReview(rawText = '') {
+ const text = String(rawText || '').trim()
+ const compact = compactText(text)
+ if (!compact) return false
+
+ const hasDateRange = /(?:20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}|(?:\d{1,2}月)?\d{1,2}日?)\s*(?:至|到|~|—|–|--|-)\s*(?:20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}|\d{1,2}月?\d{1,2}日?)/u.test(text)
+ const hasTravelIntent = /差旅|出差|去|到|赴|前往/.test(compact)
+ const hasTransport = /高铁|动车|火车|铁路|飞机|机票|航班|轮船|船票|客轮|渡轮/.test(compact)
+ const hasBusinessPurpose = /服务|支撑|支持|辅助|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(compact)
+ const hasInlinePurpose = hasBusinessPurpose && !/(?:事由|申请事由|出差事由|原因|用途)\s*[::]/.test(text)
+ const hasMultipleClauses = /[,,。;;\n]/.test(text) || compact.length >= 24
+ const signalCount = [hasDateRange, hasTravelIntent, hasTransport, hasBusinessPurpose].filter(Boolean).length
+
+ return (hasInlinePurpose && signalCount >= 2) || (hasMultipleClauses && signalCount >= 3)
+}
+
+export function resolveApplicationDateRange(rangeText, daysText = '') {
+ const matchedDates = String(rangeText || '').match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) || []
+ const startDate = normalizeDateText(matchedDates[0] || '')
+ const explicitEndDate = normalizeDateText(matchedDates[matchedDates.length - 1] || '')
+ const inferredEndDate = explicitEndDate && explicitEndDate !== startDate
+ ? explicitEndDate
+ : buildEndDateFromDays(startDate, daysText)
+ const endDate = inferredEndDate || explicitEndDate || startDate
+ const start = parseIsoDate(startDate)
+ const end = parseIsoDate(endDate)
+ if (!start || !end) {
+ return null
+ }
+ const orderedStart = start.getTime() <= end.getTime() ? start : end
+ const orderedEnd = start.getTime() <= end.getTime() ? end : start
+ return {
+ startDate: formatIsoDate(orderedStart),
+ endDate: formatIsoDate(orderedEnd),
+ startTime: orderedStart.getTime(),
+ endTime: orderedEnd.getTime()
+ }
+}
+
+export function applicationDateRangesOverlap(leftRange, rightRange) {
+ if (!leftRange || !rightRange) {
+ return false
+ }
+ return leftRange.startTime <= rightRange.endTime && rightRange.startTime <= leftRange.endTime
+}
+
+function resolvePreviewToday(options = {}) {
+ const explicitToday = String(options.today || options.currentDate || '').trim()
+ if (parseIsoDate(explicitToday)) return normalizeDateText(explicitToday)
+ if (options.now instanceof Date && !Number.isNaN(options.now.getTime())) {
+ return getTodayDateValue(options.now)
+ }
+ return getTodayDateValue()
+}
+
+export function resolveApplicationType(text) {
+ const compact = compactText(text)
+ if (looksLikeStructuredTravelApplication(text)) return '差旅费用申请'
+ if (/差旅|出差|高铁|动车|火车|飞机|机票|航班|酒店|住宿/.test(compact)) return '差旅费用申请'
+ if (/交通|出租车|的士|网约车|打车|通勤/.test(compact)) return '交通费用申请'
+ if (/住宿|酒店/.test(compact)) return '住宿费用申请'
+ if (/会务|会议|发布会|展会/.test(compact)) return '会务费用申请'
+ if (/采购|办公用品|文具|设备|耗材/.test(compact)) return '采购费用申请'
+ if (/培训|课程|学习/.test(compact)) return '培训费用申请'
+ return '费用申请'
+}
+
+export function resolveApplicationAmount(text) {
+ const compact = compactText(text)
+ const labeled = resolveFirstMatch(text, [
+ /(?:用户预估费用|预估费用|申请金额|预计金额|预计费用|预计总费用|预算|金额|费用)\s*[::]?\s*(?\d+(?:\.\d+)?\s*(?:万|千|k|K)?)/u,
+ /(?\d+(?:\.\d+)?\s*(?:(?:万|千|k|K)\s*(?:元|块|人民币)?|(?:元|块|人民币)))/u
+ ])
+ const normalized = normalizeApplicationAmountText(labeled)
+ if (normalized) return normalized
+ if (/不知道预算|预算不清楚|预算待定|待测算/.test(compact)) return '待测算'
+ return ''
+}
+
+function normalizeApplicationAmountText(value) {
+ const text = String(value || '').replace(/[,,]/g, '').trim()
+ const match = text.match(/(?\d+(?:\.\d+)?)\s*(?万|千|k|K)?/u)
+ if (!match?.groups) return ''
+ let amount = Number(match.groups.number)
+ if (!Number.isFinite(amount) || amount <= 0) return ''
+ const unit = String(match.groups.unit || '').toLowerCase()
+ if (unit === '万') amount *= 10000
+ if (unit === '千' || unit === 'k') amount *= 1000
+ return `${Number.isInteger(amount) ? amount : Number(amount.toFixed(2))}元`
+}
+
+function extractApplicationLocationCandidates(text) {
+ const candidates = []
+ const labeled = resolveFirstMatch(text, [
+ /(?:地点|业务地点|发生地点|目的地)\s*[::]\s*(?[^。;;\n,,]+)/u
+ ])
+ if (labeled) candidates.push(normalizeLocationCandidate(labeled))
+
+ const compact = compactText(text)
+ const patterns = [
+ /(?:去|到|赴|前往)(?[\u4e00-\u9fa5]{1,24})/gu,
+ /(?[\u4e00-\u9fa5]{1,12})(?:出差|驻场)/gu
+ ]
+ for (const pattern of patterns) {
+ for (const match of compact.matchAll(pattern)) {
+ candidates.push(normalizeLocationCandidate(match.groups?.value || ''))
+ }
+ }
+ return uniqueApplicationCandidates(candidates)
+ .filter((item) => !isInvalidApplicationLocationCandidate(item))
+}
+
+function normalizeLocationCandidate(value) {
+ let cleaned = String(value || '').replace(/\s+/g, '')
+ for (const marker of ['前往', '去', '到', '赴']) {
+ if (cleaned.includes(marker)) {
+ cleaned = cleaned.slice(cleaned.lastIndexOf(marker) + marker.length)
+ break
+ }
+ }
+ cleaned = cleaned
+ .replace(/^(?:去|到|赴|前往)/u, '')
+ .replace(/(?:出差|驻场|现场|支撑|支持|部署|上线|实施|拜访|验收|会议|采购|培训|协助|处理|办理|参加|进行).*$/u, '')
+ .replace(/[::,,。;;、\s]/g, '')
+ return cleaned.replace(/^(北京|上海|天津|重庆)市$/u, '$1')
+}
+
+function isInvalidApplicationLocationCandidate(value) {
+ const compact = compactText(value)
+ if (!compact) return true
+ if (/^(?:类型|申请类型|费用类型|报销类型)$/.test(compact)) return true
+ if (/^(?:费用申请|差旅费用申请|交通费用申请|住宿费用申请|会务费用申请|采购费用申请|培训费用申请|报销申请|申请)$/.test(compact)) return true
+ if (/(?:类型|申请类型|费用类型|报销类型)/.test(compact)) return true
+ if (/(?:交通方式|出行方式|交通工具|预算|金额|费用|票据|附件|天数|时间|事由|原因|用途)/.test(compact)) return true
+ return false
+}
+
+function extractApplicationTransportCandidates(text) {
+ const compact = compactText(text)
+ return uniqueApplicationCandidates([
+ resolveApplicationTransportMode(resolveFirstMatch(text, [
+ /(?:出行方式|交通方式|交通工具|出行工具)\s*[::]\s*(?[^。;;\n,,]+)/u
+ ])),
+ /高铁|动车|火车|铁路/.test(compact) ? '火车' : '',
+ /飞机|机票|航班/.test(compact) ? '飞机' : '',
+ /轮船|船票|客轮|渡轮|邮轮/.test(compact) ? '轮船' : ''
+ ])
+}
+
+function extractApplicationAmountCandidates(text) {
+ const candidates = []
+ const source = String(text || '')
+ const labelPattern = /(?:用户预估费用|预估费用|申请金额|预计金额|预计费用|预计总费用|预算|金额|费用)\s*[::]?\s*(?\d+(?:\.\d+)?\s*(?:万|千|k|K)?\s*(?:元|块|人民币)?)/gu
+ for (const match of source.matchAll(labelPattern)) {
+ candidates.push(normalizeApplicationAmountText(match.groups?.value || ''))
+ }
+ const amountPattern = /(?\d+(?:\.\d+)?\s*(?:(?:万|千|k|K)\s*(?:元|块|人民币)?|(?:元|块|人民币)))/gu
+ for (const match of source.matchAll(amountPattern)) {
+ candidates.push(normalizeApplicationAmountText(match.groups?.value || ''))
+ }
+ return uniqueApplicationCandidates(candidates)
+}
+
+function uniqueApplicationCandidates(values) {
+ return values
+ .map((item) => String(item || '').trim())
+ .filter(Boolean)
+ .filter((item, index, list) => list.indexOf(item) === index)
+}
+
+export function resolveCurrentUserGrade(currentUser = {}) {
+ return String(
+ currentUser.grade
+ || currentUser.employeeGrade
+ || currentUser.employee_grade
+ || currentUser.profileGrade
+ || ''
+ ).trim()
+}
+
+export function resolveCurrentUserDepartment(currentUser = {}) {
+ return String(
+ currentUser.department
+ || currentUser.departmentName
+ || currentUser.department_name
+ || ''
+ ).trim()
+}
+
+export function resolveCurrentUserPosition(currentUser = {}) {
+ return String(
+ currentUser.position
+ || currentUser.employeePosition
+ || currentUser.employee_position
+ || currentUser.jobTitle
+ || currentUser.job_title
+ || ''
+ ).trim()
+}
+
+export function resolveCurrentUserManagerName(currentUser = {}) {
+ return String(
+ currentUser.managerName
+ || currentUser.manager_name
+ || currentUser.directManagerName
+ || currentUser.direct_manager_name
+ || currentUser.leaderName
+ || currentUser.leader_name
+ || ''
+ ).trim()
+}
+
+export function parseApplicationDaysValue(value) {
+ const match = String(value || '').match(/\d+/)
+ const days = match ? Number(match[0]) : parseChineseNumber(value)
+ return Number.isFinite(days) && days > 0 ? Math.max(1, Math.floor(days)) : 0
+}
+
+function parseChineseNumber(value) {
+ const digits = {
+ 一: 1,
+ 二: 2,
+ 两: 2,
+ 三: 3,
+ 四: 4,
+ 五: 5,
+ 六: 6,
+ 七: 7,
+ 八: 8,
+ 九: 9
+ }
+ const text = String(value || '').match(/[一二两三四五六七八九十]{1,3}/)?.[0] || ''
+ 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 parseMoneyNumber(value) {
+ const normalized = String(value ?? '').replace(/[^\d.-]/g, '')
+ const amount = Number(normalized)
+ return Number.isFinite(amount) ? amount : null
+}
+
+export function formatPolicyMoney(value) {
+ const amount = parseMoneyNumber(value)
+ if (amount === null) return String(value || '').trim()
+ return new Intl.NumberFormat('zh-CN', {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: Number.isInteger(amount) ? 0 : 2
+ }).format(amount)
+}
+
+export function formatDailyPolicyMoney(value) {
+ const display = formatPolicyMoney(value)
+ return display ? `${display}元/天` : APPLICATION_POLICY_PENDING_TEXT
+}
+
+export function buildTransportPolicyText(transportMode, location = '', transportEstimate = null, time = '') {
+ const mode = String(transportMode || '').trim()
+ const estimate = transportEstimate || buildMockApplicationTransportEstimate({ transportMode: mode, location, time })
+ if (!estimate) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
+ return estimate.basisText
+}
+
+export function buildTransportEstimateFromPolicyResult(result = {}, fields = {}) {
+ const amount = parseMoneyNumber(result?.transport_estimated_amount)
+ if (!amount || amount <= 0) return null
+ const amountDisplay = formatPolicyMoney(amount)
+ const mode = String(result?.transport_mode || fields.transportMode || '').trim()
+ const origin = String(result?.transport_origin || '').trim()
+ const destination = String(result?.transport_destination || result?.matched_city || fields.location || '').trim()
+ const basis = String(result?.transport_estimate_basis || '').trim()
+ const ruleName = String(result?.transport_estimate_rule_name || '交通费用预估表').trim()
+ const routeText = [origin, destination].filter(Boolean).join('-')
+ const modeText = mode ? `${mode}往返` : '往返'
+ const routeModeText = routeText ? `${routeText}${modeText}` : modeText
+ const displayBasis = routeModeText && basis.startsWith(routeModeText)
+ ? basis.slice(routeModeText.length).trim()
+ : basis
+ const basisSuffix = displayBasis ? `(${displayBasis})` : ''
+ return {
+ mode,
+ amount,
+ amountDisplay,
+ routeType: '往返',
+ origin,
+ destination,
+ queryDate: String(result?.travel_date || '').trim(),
+ source: String(result?.transport_estimate_source || 'basic_rule_transport_estimate').trim(),
+ confidence: String(result?.transport_estimate_confidence || 'basic_rule').trim(),
+ basis,
+ ruleCode: String(result?.transport_estimate_rule_code || '').trim(),
+ ruleName,
+ ruleVersion: String(result?.transport_estimate_rule_version || '').trim(),
+ basisText: `当前尚未接通实时票务价格查询 API,无法获取当前实际票价;先按《${ruleName}》${routeModeText}${basisSuffix}暂估 ${amountDisplay}元用于申请阶段预算占用,最终报销以实际票据金额为准`
+ }
+}
+
+export function ensureApplicationPolicyFields(fields = {}) {
+ const nextFields = { ...fields }
+ if (!String(nextFields.lodgingDailyCap || '').trim()) {
+ nextFields.lodgingDailyCap = APPLICATION_POLICY_PENDING_TEXT
+ }
+ if (!String(nextFields.subsidyDailyCap || '').trim()) {
+ nextFields.subsidyDailyCap = APPLICATION_POLICY_PENDING_TEXT
+ }
+ if (!String(nextFields.transportPolicy || '').trim() || /实报实销/.test(String(nextFields.transportPolicy || ''))) {
+ nextFields.transportPolicy = buildTransportPolicyText(nextFields.transportMode, nextFields.location, null, nextFields.time)
+ }
+ if (!String(nextFields.policyEstimate || '').trim()) {
+ nextFields.policyEstimate = APPLICATION_POLICY_PENDING_TEXT
+ }
+ return nextFields
+}
+
+export function resolveApplicationDays(text) {
+ const value = resolveFirstMatch(text, [
+ /(?:出差|申请)?(?\d+)\s*天/u,
+ /(?\d+)\s*(?:个)?工作日/u
+ ])
+ return value ? `${value}天` : ''
+}
+
+function resolveApplicationTime(text, daysText = '', options = {}) {
+ const range = text.match(
+ /(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})\s*(?:至|到|~|—|–|--)\s*(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u
+ )
+ if (range) {
+ return `${normalizeDateText(range[1])} 至 ${normalizeDateText(range[2])}`
+ }
+
+ const shortMonthDayRange = resolveShortMonthDayRange(text, options)
+ if (shortMonthDayRange) {
+ return shortMonthDayRange
+ }
+
+ const single = resolveFirstMatch(text, [
+ /(?:发生时间|业务发生时间|申请时间|时间)\s*[::]\s*(?20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u,
+ /(?20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u
+ ])
+ if (!single) return ''
+ const normalized = normalizeDateText(single)
+ const endDate = buildEndDateFromDays(normalized, daysText)
+ return endDate && endDate !== normalized ? `${normalized} 至 ${endDate}` : normalized
+}
+
+export function resolveApplicationTimeWithDefault(text, daysText = '', options = {}) {
+ const resolvedTime = resolveApplicationTime(text, daysText, options)
+ if (resolvedTime || !parseApplicationDaysValue(daysText)) {
+ return resolvedTime
+ }
+
+ const startDate = resolvePreviewToday(options)
+ const endDate = buildEndDateFromDays(startDate, daysText)
+ return endDate && endDate !== startDate ? `${startDate} 至 ${endDate}` : startDate
+}
+
+export function resolveApplicationLocation(text) {
+ return resolveFirstMatch(text, [
+ /(?:地点|业务地点|发生地点|目的地)\s*[::]\s*(?[^。;;\n]+)/u,
+ /(?:去|到|前往)(?[\u4e00-\u9fa5,,、]{2,24}?)(?:出差|支撑|支持|部署|开会|培训|拜访|验收|项目|客户|。|\s|$)/u
+ ])
+}
+
+function looksLikeTransportPromptText(text) {
+ const compact = compactText(text)
+ return /(?:还需要补充|当前还缺|缺少|请先补充|请选择|可以选择|可以选|打算怎么出行|怎么出行).{0,24}(?:出行方式|交通方式|交通工具)/u.test(compact)
+ || /(?:出行方式|交通方式|交通工具).{0,24}(?:待补充|缺失|请选择|可以选择|可以选)/u.test(compact)
+}
+
+export function resolveApplicationTransportMode(text) {
+ const labeled = resolveFirstMatch(text, [
+ /(?:出行方式|交通方式|交通工具|出行工具)\s*[::]\s*(?[^。;;\n,,]+)/u
+ ])
+ const labeledMode = normalizeTransportModeOption(labeled, '')
+ if (labeledMode && !looksLikeTransportPromptText(labeled) && !/(?:请选择|可以选择|可以选)/u.test(String(labeled || ''))) {
+ return labeledMode
+ }
+ const fullTextLooksLikePrompt = looksLikeTransportPromptText(text)
+ const segments = String(text || '')
+ .split(/[\n,,。;;]+/u)
+ .map((item) => item.trim())
+ .filter(Boolean)
+ for (const segment of segments) {
+ if (looksLikeTransportPromptText(segment)) continue
+ const compactSegment = compactText(segment)
+ if (
+ fullTextLooksLikePrompt
+ && !/(?:申请|出差|去|到|赴|前往|服务|支撑|支持|部署|上线|实施|拜访|会议)/u.test(compactSegment)
+ ) {
+ continue
+ }
+ if (/高铁|动车|火车|铁路/.test(compactSegment)) return '火车'
+ if (/飞机|机票|航班/.test(compactSegment)) return '飞机'
+ if (/轮船|船票|客轮|渡轮/.test(compactSegment)) return '轮船'
+ }
+ if (fullTextLooksLikePrompt) return ''
+ const compact = compactText(text)
+ if (/高铁|动车|火车|铁路/.test(compact)) return '火车'
+ if (/飞机|机票|航班/.test(compact)) return '飞机'
+ if (/轮船|船票|客轮|渡轮/.test(compact)) return '轮船'
+ return ''
+}
+
+function stripKnownContextFromReason(value, context = {}) {
+ const location = String(context.location || '').trim()
+ let cleaned = String(value || '')
+ .replace(/(?:请直接生成申请单核对结果|信息足够时生成申请单|但在入库或提交审批前仍需让我确认|请直接生成报销核对结果|需要创建草稿、绑定附件或提交审批前仍需让我确认)[\s\S]*$/u, '')
+ .replace(/(?:发生时间|业务发生时间|申请时间|时间)\s*[::]\s*(?=[,,、。;;\s]|$)/gu, '')
+ .replace(/(?:地点|业务地点|发生地点|目的地)\s*[::]\s*(?=[,,、。;;\s]|$)/gu, '')
+ .replace(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}\s*(?:至|到|~|—|–|--)\s*20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/gu, '')
+ .replace(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/gu, '')
+ .replace(/(?:\d{1,2}月)?\d{1,2}日?\s*(?:至|到|~|—|–|--|-)\s*(?:\d{1,2}月)?\d{1,2}日?/gu, '')
+ .replace(/\d{1,2}月\d{1,2}日?/gu, '')
+ .replace(/(?:出差|申请)?\d+\s*天/gu, '')
+ .replace(/(?:用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额|费用)\s*[::]?\s*\d+(?:\.\d+)?\s*(?:元|块|人民币)?/gu, '')
+ .replace(/(?:高铁|动车|火车|铁路|飞机|机票|航班|轮船|船票|客轮|渡轮|出租车|的士|网约车|打车|自驾)/gu, '')
+ .replace(/[,,、。;;]+/g, ',')
+ .replace(/^\s*(去|到|前往)/u, '')
+ .replace(/^[,\s]+|[,\s]+$/g, '')
+ .trim()
+
+ if (location) {
+ const escapedLocation = location.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+ cleaned = cleaned
+ .replace(new RegExp(`^${escapedLocation}(?:出差)?(?:,|,|、)?`, 'u'), '')
+ .replace(new RegExp(`^(?:去|到|前往)${escapedLocation}(?:出差)?(?:,|,|、)?`, 'u'), '')
+ .trim()
+ }
+
+ return cleaned
+}
+
+function pickBusinessReasonSegment(text) {
+ const segments = String(text || '')
+ .split(/[,,、。;;\n]+/u)
+ .map((item) => item.trim())
+ .filter((item) => item && !isSystemGeneratedReasonText(item))
+ return segments.find((item) => /服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(item)) || ''
+}
+
+function isSystemGeneratedReasonText(value = '') {
+ const compact = compactText(value)
+ return compact.startsWith('小财管家继续执行')
+ || /请直接生成申请单核对结果|信息足够时生成申请单|入库或提交审批前|请直接生成报销核对结果/.test(compact)
+ || compact.startsWith('处理要求')
+ || compact.startsWith('已识别信息')
+ || compact.startsWith('用户已补充')
+ || /^(?:类型|申请类型|费用类型|报销类型)[::]?(?:差旅费用申请|交通费用申请|住宿费用申请|费用申请|差旅费|交通费|住宿费)?$/.test(compact)
+}
+
+export function resolveApplicationReason(text, context = {}) {
+ const labeled = resolveFirstMatch(text, [
+ /(?:事由|申请事由|出差事由|原因|用途)\s*[::]\s*(?[^,。;;\n]+)/u
+ ])
+ if (labeled) return stripKnownContextFromReason(labeled, context)
+ const cleaned = String(text || '')
+ .replace(/^\s*(我想|我要|帮我)?\s*(先)?\s*(发起|提交|申请)?\s*(一笔)?\s*(费用申请|申请)\s*/u, '')
+ const withoutContext = stripKnownContextFromReason(cleaned, context)
+ const businessSegment = pickBusinessReasonSegment(withoutContext)
+ if (businessSegment) return stripKnownContextFromReason(businessSegment, context)
+ if (isSystemGeneratedReasonText(withoutContext)) return ''
+ return withoutContext
+}
+
+export function isApplicationPreviewValueProvided(value) {
+ const normalized = String(value || '').trim()
+ return Boolean(normalized) && !['待测算', '待补充', '未知'].includes(normalized)
+}
+
+export function resolveProvidedValue(value, fallback = '') {
+ return isApplicationPreviewValueProvided(value) ? String(value).trim() : fallback
+}
+
+export function normalizeApplicationTypeLabel(value, fallback = '') {
+ const label = String(value || '').trim()
+ if (!label || label === '其他费用') return fallback || '费用申请'
+ if (label.endsWith('费用申请') || label.endsWith('申请')) return label
+ if (label.endsWith('费用')) return `${label}申请`
+ if (label.endsWith('费')) return `${label.slice(0, -1)}费用申请`
+ return `${label}申请`
+}
+
+export function normalizeTransportModeOption(value, fallback = '') {
+ const text = String(value || '').trim()
+ if (/飞机|机票|航班/.test(text)) return '飞机'
+ if (/轮船|船票|客轮|渡轮|邮轮/.test(text)) return '轮船'
+ if (/火车|高铁|动车|铁路|列车/.test(text)) return '火车'
+ return APPLICATION_TRANSPORT_MODE_OPTIONS.includes(text) ? text : fallback
+}
+
+export function resolveModelRefinedTransportMode(ontologyFields = {}, rawText = '', currentFields = {}) {
+ const currentTransportMode = isApplicationPreviewValueProvided(currentFields.transportMode)
+ ? String(currentFields.transportMode).trim()
+ : ''
+ const explicitTransportMode = resolveApplicationTransportMode(rawText)
+ if (!explicitTransportMode) {
+ return currentTransportMode
+ }
+
+ const ontologyTransportMode = normalizeTransportModeOption(ontologyFields.transportMode, '')
+ if (ontologyTransportMode && ontologyTransportMode === explicitTransportMode) {
+ return ontologyTransportMode
+ }
+ return currentTransportMode || explicitTransportMode
+}
+
+export function normalizeAmountFromOntology(fields = {}, fallback = '') {
+ const numericAmount = Number(fields.amount || fields.policyTotalAmount || fields.reimbursementAmount || 0)
+ if (Number.isFinite(numericAmount) && numericAmount > 0) {
+ return `${numericAmount}元`
+ }
+
+ const display = String(fields.amountDisplay || '').trim()
+ if (display && display !== '待补充') {
+ const normalized = display.replace(/^¥\s*/, '').replace(/,/g, '').trim()
+ return normalized.endsWith('元') ? normalized : `${normalized}元`
+ }
+
+ return fallback
+}
+
+export function normalizeTypedOntologyAmount(value, fallback = '') {
+ const amount = Number(value || 0)
+ if (Number.isFinite(amount) && amount > 0) {
+ return `${amount}元`
+ }
+ return fallback
+}
+
+export function buildMissingFields(fields) {
+ return APPLICATION_PREVIEW_FIELD_DEFINITIONS
+ .filter((item) => item.key !== 'applicationType' && item.required !== false)
+ .filter((item) => !isApplicationPreviewValueProvided(fields?.[item.key]))
+ .map((item) => resolveApplicationFieldLabel(item, fields))
+}
diff --git a/web/src/utils/expenseClaimAttachmentSync.js b/web/src/utils/expenseClaimAttachmentSync.js
new file mode 100644
index 0000000..642de11
--- /dev/null
+++ b/web/src/utils/expenseClaimAttachmentSync.js
@@ -0,0 +1,124 @@
+function normalizeAttachmentMatchName(value) {
+ const fileName = String(value || '')
+ .trim()
+ .split(/[\\/]/)
+ .filter(Boolean)
+ .pop() || ''
+ return fileName
+ .toLowerCase()
+ .replace(/[^\w.\-\u4e00-\u9fff]+/g, '_')
+ .replace(/^[_\.]+|[_\.]+$/g, '')
+}
+
+function isSystemGeneratedExpenseItem(item = {}) {
+ const itemType = String(item?.itemType || item?.item_type || '').trim()
+ return Boolean(
+ item?.isSystemGenerated ||
+ item?.is_system_generated ||
+ itemType === 'travel_allowance'
+ )
+}
+
+function findCreatedAttachmentItem(items = [], usedItemIds = new Set()) {
+ return (Array.isArray(items) ? items : []).find((item) => {
+ const itemId = String(item?.id || '').trim()
+ const invoiceId = String(item?.invoiceId || item?.invoice_id || '').trim()
+ const itemType = String(item?.itemType || item?.item_type || '').trim()
+ return (
+ itemId &&
+ !usedItemIds.has(itemId) &&
+ !invoiceId &&
+ itemType !== 'travel_allowance' &&
+ !item?.isSystemGenerated &&
+ !item?.is_system_generated
+ )
+ }) || null
+}
+
+export async function syncExpenseClaimFilesToDraft({
+ claimId = '',
+ files = [],
+ fetchExpenseClaimDetail,
+ createExpenseClaimItem,
+ uploadExpenseClaimItemAttachment
+} = {}) {
+ const normalizedClaimId = String(claimId || '').trim()
+ const safeFiles = Array.isArray(files) ? files : []
+ if (!normalizedClaimId || !safeFiles.length || typeof uploadExpenseClaimItemAttachment !== 'function') {
+ return { uploadedCount: 0, skippedCount: safeFiles.length, uploadedFileNames: [], skippedFileNames: safeFiles.map((file) => file?.name || '') }
+ }
+ if (typeof fetchExpenseClaimDetail !== 'function') {
+ throw new Error('缺少单据详情查询服务,暂时无法自动归集附件。')
+ }
+
+ const claim = await fetchExpenseClaimDetail(normalizedClaimId)
+ const items = Array.isArray(claim?.items) ? claim.items : []
+ const exactMatchBuckets = new Map()
+ const normalizedMatchBuckets = new Map()
+ const placeholderQueue = []
+ const emptyAttachmentQueue = []
+ const usedItemIds = new Set()
+ const uploadedFileNames = []
+
+ for (const item of items) {
+ const itemId = String(item?.id || '').trim()
+ const invoiceId = String(item?.invoiceId || item?.invoice_id || '').trim()
+ if (!itemId) continue
+
+ if (!invoiceId && !isSystemGeneratedExpenseItem(item)) {
+ emptyAttachmentQueue.push(item)
+ continue
+ }
+ if (!invoiceId || invoiceId.includes('/')) {
+ continue
+ }
+
+ placeholderQueue.push(item)
+ const exactBucket = exactMatchBuckets.get(invoiceId) || []
+ exactBucket.push(item)
+ exactMatchBuckets.set(invoiceId, exactBucket)
+
+ const normalizedInvoiceName = normalizeAttachmentMatchName(invoiceId)
+ if (normalizedInvoiceName) {
+ const normalizedBucket = normalizedMatchBuckets.get(normalizedInvoiceName) || []
+ normalizedBucket.push(item)
+ normalizedMatchBuckets.set(normalizedInvoiceName, normalizedBucket)
+ }
+ }
+
+ for (const file of safeFiles) {
+ const exactBucket = exactMatchBuckets.get(file.name) || []
+ const nextExactMatch = exactBucket.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
+ const normalizedBucket = normalizedMatchBuckets.get(normalizeAttachmentMatchName(file.name)) || []
+ const nextNormalizedMatch = normalizedBucket.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
+ const fallbackMatch = placeholderQueue.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
+ const emptyFallbackMatch = emptyAttachmentQueue.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
+ let targetItem = nextExactMatch || nextNormalizedMatch || fallbackMatch || emptyFallbackMatch
+ let targetItemId = String(targetItem?.id || '').trim()
+
+ if (!targetItemId && typeof createExpenseClaimItem === 'function') {
+ const updatedClaim = await createExpenseClaimItem(normalizedClaimId, {})
+ targetItem = findCreatedAttachmentItem(updatedClaim?.items, usedItemIds)
+ targetItemId = String(targetItem?.id || '').trim()
+ }
+ if (!targetItemId) {
+ continue
+ }
+
+ usedItemIds.add(targetItemId)
+ await uploadExpenseClaimItemAttachment(normalizedClaimId, targetItemId, file)
+ uploadedFileNames.push(String(file?.name || '').trim())
+ }
+
+ const uploadedSet = new Set(uploadedFileNames)
+ const skippedFileNames = safeFiles
+ .map((file) => String(file?.name || '').trim())
+ .filter((name) => !uploadedSet.has(name))
+
+ return {
+ uploadedCount: uploadedFileNames.length,
+ skippedCount: Math.max(0, safeFiles.length - uploadedFileNames.length),
+ uploadedFileNames,
+ skippedFileNames
+ }
+}
diff --git a/web/src/views/AppShellRouteView.vue b/web/src/views/AppShellRouteView.vue
index 6a5385e..c7153ee 100644
--- a/web/src/views/AppShellRouteView.vue
+++ b/web/src/views/AppShellRouteView.vue
@@ -151,7 +151,7 @@
v-else-if="activeView === 'documents' && detailMode && selectedRequest"
:request="selectedRequest"
:back-label="detailBackLabel"
- @back-to-requests="closeRequestDetail"
+ @back-to-requests="handleDocumentDetailBack"
@open-assistant="openSmartEntry"
@request-updated="handleRequestUpdated"
@request-deleted="handleDetailRequestDeleted"
@@ -362,6 +362,7 @@ const {
smartEntryRevealToken,
smartEntrySessionId,
toast,
+ detailReturnTarget,
topBarView
} = useAppShell()
@@ -370,6 +371,7 @@ const ENTERPRISE_DISPLAY_NAME = '远光软件股份有限公司'
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
const isAiShellMode = computed(() => workbenchMode.value === 'ai')
const isWorkbenchAiMode = computed(() => activeView.value === 'workbench' && workbenchMode.value === 'ai')
+const DOCUMENT_DETAIL_RETURN_TARGETS = new Set(['workbench', 'conversation'])
const DETAIL_TOPBAR_FALLBACKS = {
audit: {
title: '规则中心详情',
@@ -412,6 +414,40 @@ const resolvedDetailKpis = computed(() => (
customDetailTopBarActive.value ? detailTopBarPayload.value?.kpis || [] : []
))
+function resolveDocumentDetailReturnTarget(value) {
+ const target = String(value || '').trim()
+ return DOCUMENT_DETAIL_RETURN_TARGETS.has(target) ? target : ''
+}
+
+function resolveActiveAiConversationSnapshot() {
+ const conversationId = String(aiActiveConversationId.value || '').trim()
+ if (!conversationId) {
+ return null
+ }
+ const history = aiConversationHistory.value.length
+ ? aiConversationHistory.value
+ : loadAiWorkbenchConversationHistory(currentUser.value || {})
+ return history.find((item) => String(item.id || item.conversationId || '').trim() === conversationId) || null
+}
+
+async function handleDocumentDetailBack() {
+ const shouldRestoreConversation = detailReturnTarget.value === 'conversation'
+ const activeConversation = shouldRestoreConversation ? resolveActiveAiConversationSnapshot() : null
+ const navigation = closeRequestDetail()
+ if (navigation && typeof navigation.then === 'function') {
+ await navigation
+ }
+
+ if (!shouldRestoreConversation || !activeConversation) {
+ return
+ }
+
+ workbenchMode.value = 'ai'
+ sidebarCollapsed.value = false
+ await nextTick()
+ dispatchAiSidebarCommand('open-recent', activeConversation)
+}
+
function openWorkbenchDocument(payload = {}) {
const payloadClaimId = String(payload.claimId || payload.claim_id || '').trim()
const payloadId = String(payload.id || '').trim()
@@ -436,13 +472,12 @@ function openWorkbenchDocument(payload = {}) {
|| String(item.claimNo || '').trim() === requestId
|| String(item.documentNo || '').trim() === requestId
))
- const returnTo = (
- String(payload.returnTo || '').trim() === 'workbench'
- || String(payload.source || '').trim() === 'workbench'
+ const explicitReturnTo = resolveDocumentDetailReturnTarget(payload.returnTo)
+ const fallbackToWorkbench = (
+ String(payload.source || '').trim() === 'workbench'
|| activeView.value === 'workbench'
)
- ? 'workbench'
- : ''
+ const returnTo = explicitReturnTo || (fallbackToWorkbench ? 'workbench' : '')
const payloadIdIsBusinessNo = isBusinessDocumentReference(payloadId)
const fallbackClaimId = payloadClaimId || (payloadClaimNo || payloadIdIsBusinessNo ? '' : payloadId || requestId)
const fallbackClaimNo = payloadClaimNo || (payloadIdIsBusinessNo ? payloadId : fallbackClaimId ? '' : requestId)
diff --git a/web/src/views/DocumentsCenterView.vue b/web/src/views/DocumentsCenterView.vue
index d7288ff..54eeed7 100644
--- a/web/src/views/DocumentsCenterView.vue
+++ b/web/src/views/DocumentsCenterView.vue
@@ -265,7 +265,6 @@ import {
fetchAllApprovalExpenseClaims,
fetchAllArchivedExpenseClaims
} from '../services/reimbursements.js'
-import { countClaimRisks, resolveArchiveRiskTone } from '../utils/archiveCenterListFilters.js'
import { fetchNotificationStates, patchNotificationStates } from '../services/notificationStates.js'
import {
buildDocumentViewedStatePatch,
@@ -279,88 +278,16 @@ import {
readViewedDocumentKeys,
writeDocumentScope
} from '../utils/documentCenterNewState.js'
-import { sortDocumentRowsByLatestTime } from '../utils/documentCenterSort.js'
-import { extractDateText, formatDocumentListTime, resolveDocumentSortTime, resolveDocumentStayTimeDisplay } from '../utils/documentCenterTime.js'
-import { excludeArchivedDocumentRows, filterApplicationScopeNewRows, isArchivedDocumentRow, prepareApplicationScopeRows } from '../utils/documentCenterRows.js'
-import { normalizeRequestForUi } from '../utils/requestViewModel.js'
-const DOCUMENT_TYPE_ALL = 'all'
-const DOCUMENT_TYPE_APPLICATION = 'application'
-const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
-const SCENE_ALL = 'all'
-const DOCUMENT_SCOPE_ALL = '全部'
-const DOCUMENT_SCOPE_APPLICATION = '申请单'
-const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'
-const DOCUMENT_SCOPE_REVIEW = '审核单'
-const DOCUMENT_SCOPE_ARCHIVE = '归档'
-const scopeTabs = [DOCUMENT_SCOPE_ALL, DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT, DOCUMENT_SCOPE_REVIEW, DOCUMENT_SCOPE_ARCHIVE]
-const DOCUMENT_LOADING_MIN_VISIBLE_MS = 720
-const DOCUMENT_CENTER_QUERY_KEYS = new Set([
- 'dc_page',
- 'dc_page_size',
- 'dc_scope',
- 'dc_status',
- 'dc_doc_type',
- 'dc_scene',
- 'dc_q',
- 'dc_start',
- 'dc_end'
-])
-const riskLevelTabs = ['全部', '高风险', '中风险', '低风险', '无风险']
-const RISK_TONE_META = {
- high: { label: '高风险', tone: 'high' },
- medium: { label: '中风险', tone: 'medium' },
- low: { label: '低风险', tone: 'low' },
- none: { label: '无风险', tone: 'none' }
-}
-const FILTER_CONFIG_BY_SCOPE = {
- [DOCUMENT_SCOPE_ALL]: {
- searchPlaceholder: '搜索单号、事项、费用场景...',
- sceneFallbackLabel: '单据场景',
- dateLabel: '单据时间',
- statusTitle: '风险等级',
- statusTabs: riskLevelTabs,
- showDocumentType: true
- },
- [DOCUMENT_SCOPE_APPLICATION]: {
- searchPlaceholder: '搜索申请单号、申请事项、申请场景...',
- sceneFallbackLabel: '申请场景',
- dateLabel: '申请时间',
- statusTitle: '风险等级',
- statusTabs: riskLevelTabs,
- showDocumentType: false
- },
- [DOCUMENT_SCOPE_REIMBURSEMENT]: {
- searchPlaceholder: '搜索报销单号、报销事由、费用场景...',
- sceneFallbackLabel: '费用场景',
- dateLabel: '报销时间',
- statusTitle: '风险等级',
- statusTabs: riskLevelTabs,
- showDocumentType: false
- },
- [DOCUMENT_SCOPE_REVIEW]: {
- searchPlaceholder: '搜索审核单号、事项、当前环节...',
- sceneFallbackLabel: '审核场景',
- dateLabel: '审核时间',
- statusTitle: '风险等级',
- statusTabs: riskLevelTabs,
- showDocumentType: false
- },
- [DOCUMENT_SCOPE_ARCHIVE]: {
- searchPlaceholder: '搜索归档单号、事项、费用场景...',
- sceneFallbackLabel: '归档场景',
- dateLabel: '归档时间',
- statusTitle: '风险等级',
- statusTabs: riskLevelTabs,
- showDocumentType: false
- }
-}
-const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size }))
-const pageSizeValues = pageSizeOptions.map((item) => item.value)
-const documentTypeOptions = [
- { value: DOCUMENT_TYPE_ALL, label: '单据类型' },
- { value: DOCUMENT_TYPE_APPLICATION, label: '申请单' },
- { value: DOCUMENT_TYPE_REIMBURSEMENT, label: '报销单' }
-]
+import { excludeArchivedDocumentRows, filterApplicationScopeNewRows, prepareApplicationScopeRows } from '../utils/documentCenterRows.js'
+import {
+ DOCUMENT_CENTER_QUERY_KEYS, DOCUMENT_LOADING_MIN_VISIBLE_MS, DOCUMENT_SCOPE_ALL,
+ DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_ARCHIVE, DOCUMENT_SCOPE_REIMBURSEMENT,
+ DOCUMENT_SCOPE_REVIEW, DOCUMENT_TYPE_ALL, DOCUMENT_TYPE_APPLICATION,
+ DOCUMENT_TYPE_REIMBURSEMENT, FILTER_CONFIG_BY_SCOPE, SCENE_ALL,
+ buildDocumentCenterEmptyState, buildDocumentRow, documentTypeOptions,
+ filterDocumentRows, hasDocumentCenterActiveFilters, mergeDocumentRows,
+ pageSizeOptions, pageSizeValues, routeQueryEquals, scopeTabs
+} from '../utils/documentCenterViewModel.js'
const route = useRoute()
const router = useRouter()
const props = defineProps({
@@ -440,14 +367,6 @@ function buildDocumentCenterRouteQuery() {
return nextQuery
}
-function routeQueryEquals(left, right) {
- const leftEntries = Object.entries(left || {}).map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : String(value ?? '')])
- const rightEntries = Object.entries(right || {}).map(([key, value]) => [key, Array.isArray(value) ? value.join(',') : String(value ?? '')])
- if (leftEntries.length !== rightEntries.length) return false
- const rightMap = new Map(rightEntries)
- return leftEntries.every(([key, value]) => rightMap.get(key) === value)
-}
-
const initialScopeTab = resolveInitialScopeTab()
const initialAppliedStart = readDocumentCenterQueryText('dc_start')
const initialAppliedEnd = readDocumentCenterQueryText('dc_end')
@@ -494,7 +413,11 @@ const dateRangeLabel = computed(() => {
const ownedRows = computed(() =>
excludeArchivedDocumentRows(
props.filteredRequests
- .map((item) => buildDocumentRow(item, { source: 'owned' }))
+ .map((item) => buildDocumentRow(item, {
+ source: 'owned',
+ currentUser: currentUser.value,
+ viewedDocumentKeys: viewedDocumentKeys.value
+ }))
.filter(Boolean)
)
)
@@ -570,33 +493,16 @@ const statusFilterLabel = computed(() =>
statusFilterOptions.value.find((item) => item.value === activeStatusTab.value)?.label || '全部风险'
)
-const filteredRows = computed(() => {
- const keyword = listKeyword.value.trim().toLowerCase()
-
- return sortDocumentRowsByLatestTime(activeScopeRows.value.filter((row) => {
- const matchesKeyword = !keyword || [
- row.documentNo,
- row.documentTypeLabel,
- row.typeLabel,
- row.initiatorName,
- row.reason,
- row.node,
- row.statusLabel,
- row.riskLabel
- ].filter(Boolean).join('').toLowerCase().includes(keyword)
-
- const matchesDocumentType =
- !showDocumentTypeFilter.value
- || activeDocumentType.value === DOCUMENT_TYPE_ALL
- || row.documentTypeCode === activeDocumentType.value
-
- const matchesScene = activeScene.value === SCENE_ALL || row.typeCode === activeScene.value
- const matchesRiskLevel = matchesRiskLevelTab(row, activeStatusTab.value)
- const matchesDateRange = matchesAppliedDateRange(row)
-
- return matchesKeyword && matchesDocumentType && matchesScene && matchesRiskLevel && matchesDateRange
- }))
-})
+const filteredRows = computed(() => filterDocumentRows(activeScopeRows.value, {
+ keyword: listKeyword.value,
+ showDocumentTypeFilter: showDocumentTypeFilter.value,
+ activeDocumentType: activeDocumentType.value,
+ activeScene: activeScene.value,
+ activeStatusTab: activeStatusTab.value,
+ activeScopeTab: activeScopeTab.value,
+ appliedStart: appliedStart.value,
+ appliedEnd: appliedEnd.value
+}))
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value)))
const pageSummary = computed(() => `共 ${filteredRows.value.length} 条,目前第 ${currentPage.value} / ${totalPages.value} 页`)
@@ -636,229 +542,22 @@ const documentSummary = computed(() => {
}
})
-const emptyState = computed(() => {
- const filtered = hasActiveFilters()
- if (
- activeScopeTab.value === DOCUMENT_SCOPE_APPLICATION
- || activeDocumentType.value === DOCUMENT_TYPE_APPLICATION
- ) {
- return {
- eyebrow: '申请单',
- title: '当前还没有申请单数据',
- desc: '费用申请功能接入后,差旅、会务、办公采购等前置申请会统一汇总到这里。',
- icon: 'mdi mdi-file-sign-outline',
- actionLabel: '',
- actionIcon: '',
- tone: 'theme',
- artLabel: 'APPLY',
- tips: ['申请、报销、审批与归档统一在此查看', '申请批准后可继续发起报销']
- }
- }
-
- return {
- eyebrow: filtered ? '筛选结果为空' : '单据中心',
- title: filtered ? '没有符合当前条件的单据' : `“${activeScopeTab.value}”里暂时没有单据`,
- desc: filtered
- ? '可以清空当前分类下的筛选条件后再看看。'
- : '当前视角暂无可展示单据,可以切换其他视角或发起一笔报销。',
- icon: filtered ? 'mdi mdi-magnify-scan' : 'mdi mdi-file-document-multiple-outline',
- actionLabel: '',
- actionIcon: '',
- tone: 'theme',
- artLabel: filtered ? 'FILTER' : 'DOCS',
- tips: ['单据中心已接入当前报销单据', '归档视角会同步已归档数据']
- }
-})
-
-function resolveArchivedDocumentNode(normalized, documentTypeCode) {
- if (documentTypeCode === DOCUMENT_TYPE_APPLICATION) {
- return '申请归档'
- }
- if (normalized.status === 'paid' || normalized.approvalStatus === '已付款') {
- return '已付款'
- }
- return normalized.node || normalized.workflowNode || '财务归档'
-}
-
-function resolveArchivedStatusLabel(normalized) {
- if (normalized.status === 'paid' || normalized.approvalStatus === '已付款' || normalized.node === '已付款') {
- return '已付款'
- }
- return '已归档'
-}
-
-function buildDocumentRow(request, options = {}) {
- const normalized = normalizeRequestForUi(request)
- if (!normalized) {
- return null
- }
-
- const archived = Boolean(options.archived)
- const statusGroup = resolveStatusGroup(normalized, archived)
- const statusLabel = archived ? resolveArchivedStatusLabel(normalized) : resolveStatusLabel(normalized, statusGroup)
- const riskMeta = buildDocumentRiskMeta(normalized)
- const documentNo = normalized.documentNo || normalized.id || normalized.claimId || '待生成'
- const claimId = normalized.claimId || normalized.id || documentNo
- const createdAtSource = normalized.createdAt || normalized.submittedAt || normalized.applyTime || normalized.updatedAt
- const updatedAtSource = normalized.updatedAt || normalized.submittedAt || normalized.createdAt || normalized.applyTime
- const createdSortTime = resolveDocumentSortTime(createdAtSource)
- const updatedSortTime = resolveDocumentSortTime(updatedAtSource)
- const documentTypeCode = normalized.documentTypeCode || DOCUMENT_TYPE_REIMBURSEMENT
- const documentTypeLabel =
- normalized.documentTypeLabel
- || (documentTypeCode === DOCUMENT_TYPE_APPLICATION ? '申请单' : '报销单')
- const initiatorName = String(
- normalized.person
- || normalized.employeeName
- || normalized.profileName
- || normalized.applicant
- || request?.employee_name
- || request?.employeeName
- || request?.person
- || ''
- ).trim() || '待补充'
-
- return {
- ...normalized,
- rawRequest: request,
- documentKey: `${options.source || 'owned'}:${claimId || documentNo}`,
- documentTypeCode,
- documentTypeLabel,
- claimId,
- documentNo,
- initiatorName,
- node: archived ? resolveArchivedDocumentNode(normalized, documentTypeCode) : (normalized.node || normalized.workflowNode || '待提交'),
- statusGroup,
- statusLabel,
- statusTone: archived ? 'archived' : resolveStatusTone(normalized, statusGroup),
- riskTone: riskMeta.tone,
- riskLabel: riskMeta.label,
- riskCount: riskMeta.count,
- riskTags: riskMeta.tags,
- source: options.source || 'owned',
- archived,
- createdAtDisplay: formatDocumentListTime(createdAtSource),
- stayTimeDisplay: resolveDocumentStayTimeDisplay(normalized),
- isNewDocument: archived
- ? false
- : isNewDocument({ ...normalized, source: options.source || 'owned', claimId, documentNo }, viewedDocumentKeys.value),
- updatedAtDisplay: formatDocumentListTime(updatedAtSource),
- createdSortTime,
- updatedSortTime,
- sortTime: Math.max(createdSortTime, updatedSortTime)
- }
-}
-
-function resolveStatusGroup(row, archived) {
- if (archived) return 'completed'
- if (row.approvalKey === 'draft') return 'draft'
- if (row.approvalKey === 'supplement' && row.status === 'returned') return 'pending_submit'
- if (row.approvalKey === 'supplement') return 'supplement'
- if (row.approvalKey === 'pending_payment') return 'pending_payment'
- if (row.approvalKey === 'in_progress') return 'in_progress'
- if (row.approvalKey === 'completed') return 'completed'
- return 'other'
-}
-
-function resolveStatusLabel(row, statusGroup) {
- if (statusGroup === 'pending_submit') return '待提交'
- if (statusGroup === 'pending_payment') return '待付款'
- return row.approval || row.approvalStatus || '处理中'
-}
-
-function resolveStatusTone(row, statusGroup) {
- if (statusGroup === 'pending_submit') return 'warning'
- return row.approvalTone || 'neutral'
-}
-
-function resolveDocumentRiskFlags(row) {
- if (Array.isArray(row?.riskFlags)) {
- return row.riskFlags
- }
- if (Array.isArray(row?.risk_flags_json)) {
- return row.risk_flags_json
- }
- return []
-}
-
-function buildDocumentRiskMeta(row) {
- const riskFlags = resolveDocumentRiskFlags(row)
- const riskSummary = row?.riskSummary || row?.risk
- // 列表风险标签按当前查看者可见性过滤,与详情页口径一致:
- // 申请人看不到的预算治理等风险不计入列表展示的风险等级。
- const viewerOptions = currentUser.value
- ? { request: row || {}, currentUser: currentUser.value }
- : null
- const count = countClaimRisks(riskFlags, riskSummary, viewerOptions)
- if (!count) {
- const meta = RISK_TONE_META.none
- return {
- ...meta,
- count: 0,
- tags: [{ ...meta }]
- }
- }
-
- const tone = resolveArchiveRiskTone(riskFlags, riskSummary, viewerOptions)
- const meta = RISK_TONE_META[tone] || RISK_TONE_META.medium
- return {
- ...meta,
- count,
- tags: [{ tone: meta.tone, label: `${meta.label} ${count}项` }]
- }
-}
-
-function matchesRiskLevelTab(row, tab) {
- if (activeScopeTab.value !== DOCUMENT_SCOPE_ARCHIVE && isArchivedDocumentRow(row)) {
- return false
- }
-
- if (tab === '全部') return true
- if (tab === '高风险') return row.riskTone === 'high'
- if (tab === '中风险') return row.riskTone === 'medium'
- if (tab === '低风险') return row.riskTone === 'low'
- if (tab === '无风险') return row.riskTone === 'none'
- return true
-}
-
-function matchesAppliedDateRange(row) {
- if (!appliedStart.value || !appliedEnd.value) {
- return true
- }
-
- const date = extractDateText(row.updatedAt || row.submittedAt || row.createdAt || row.applyTime)
- return Boolean(date) && date >= appliedStart.value && date <= appliedEnd.value
-}
-
-function mergeDocumentRows(rows) {
- const rowMap = new Map()
-
- rows.filter(Boolean).forEach((row) => {
- const key = row.claimId || row.documentNo || row.documentKey
- const current = rowMap.get(key)
- if (!current || resolveSourcePriority(row) >= resolveSourcePriority(current)) {
- rowMap.set(key, row)
- }
- })
-
- return sortDocumentRowsByLatestTime(Array.from(rowMap.values()))
-}
-
-function resolveSourcePriority(row) {
- if (row.archived) return 3
- if (row.source === 'approval') return 2
- return 1
-}
+const emptyState = computed(() => buildDocumentCenterEmptyState({
+ hasActiveFilters: hasActiveFilters(),
+ activeScopeTab: activeScopeTab.value,
+ activeDocumentType: activeDocumentType.value
+}))
function hasActiveFilters() {
- return Boolean(
- listKeyword.value.trim()
- || activeStatusTab.value !== '全部'
- || (showDocumentTypeFilter.value && activeDocumentType.value !== DOCUMENT_TYPE_ALL)
- || activeScene.value !== SCENE_ALL
- || appliedStart.value
- || appliedEnd.value
- )
+ return hasDocumentCenterActiveFilters({
+ listKeyword: listKeyword.value,
+ activeStatusTab: activeStatusTab.value,
+ showDocumentTypeFilter: showDocumentTypeFilter.value,
+ activeDocumentType: activeDocumentType.value,
+ activeScene: activeScene.value,
+ appliedStart: appliedStart.value,
+ appliedEnd: appliedEnd.value
+ })
}
function toggleFilter(key) {
@@ -993,7 +692,11 @@ async function loadSupportingRows() {
approvalRows.value = excludeArchivedDocumentRows(
extractExpenseClaimItems(approvalResult.value)
.map((item) => mapExpenseClaimToRequest(item))
- .map((item) => buildDocumentRow(item, { source: 'approval' }))
+ .map((item) => buildDocumentRow(item, {
+ source: 'approval',
+ currentUser: currentUser.value,
+ viewedDocumentKeys: viewedDocumentKeys.value
+ }))
.filter(Boolean)
)
} else {
@@ -1003,7 +706,12 @@ async function loadSupportingRows() {
if (archiveResult.status === 'fulfilled') {
archiveRows.value = extractExpenseClaimItems(archiveResult.value)
.map((item) => mapExpenseClaimToRequest(item))
- .map((item) => buildDocumentRow(item, { source: 'archive', archived: true }))
+ .map((item) => buildDocumentRow(item, {
+ source: 'archive',
+ archived: true,
+ currentUser: currentUser.value,
+ viewedDocumentKeys: viewedDocumentKeys.value
+ }))
.filter(Boolean)
} else {
archiveRows.value = []
diff --git a/web/src/views/TravelRequestDetailView.vue b/web/src/views/TravelRequestDetailView.vue
index 74edccb..675fa8c 100644
--- a/web/src/views/TravelRequestDetailView.vue
+++ b/web/src/views/TravelRequestDetailView.vue
@@ -2,112 +2,18 @@