refactor: enforce 800 line source limits

This commit is contained in:
caoxiaozhu
2026-06-22 11:58:53 +08:00
parent 08a4fa3577
commit 6d33ba5742
150 changed files with 27413 additions and 23791 deletions

View File

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

View File

@@ -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 = []

View File

@@ -2,112 +2,18 @@
<section class="approval-page">
<div class="approval-detail">
<div class="detail-scroll">
<article class="detail-hero panel">
<div class="hero-banner">
<div class="hero-banner-main">
<div class="applicant-card">
<div class="portrait">
<img src="/assets/person.png" alt="" />
</div>
<div class="applicant-copy">
<div class="applicant-name-row">
<h2>{{ profile.name }}</h2>
<span class="identity-badge">{{ profile.identity }}</span>
</div>
<div class="applicant-profile-meta">
<div class="applicant-profile-meta__org">
<span class="applicant-meta-item">
<em>部门</em>
<strong>{{ profile.department }}</strong>
</span>
<span class="applicant-meta-item applicant-meta-item--sub">
<em>直属上司</em>
<strong>{{ profile.manager }}</strong>
</span>
</div>
<div class="applicant-profile-meta__role">
<span class="applicant-meta-item">
<em>职级</em>
<strong>{{ profile.grade }}</strong>
</span>
<span class="applicant-meta-item">
<em>岗位</em>
<strong>{{ profile.position }}</strong>
</span>
</div>
</div>
</div>
</div>
<div class="hero-fact-grid">
<div v-for="item in heroFactItems" :key="item.key" class="hero-fact">
<div class="hero-fact-label">
<i v-if="item.icon" :class="item.icon"></i>
<span>{{ item.label }}</span>
</div>
<strong :class="item.valueClass">{{ item.value }}</strong>
</div>
</div>
</div>
</div>
</article>
<article class="progress-card panel">
<div class="progress-block">
<div class="progress-head">
<h3>{{ isApplicationDocument ? '申请进度' : '报销进度' }}</h3>
</div>
<div class="progress-line" :style="{ '--progress-columns': progressSteps.length }">
<div
v-for="step in progressSteps"
:key="step.label"
class="progress-step"
:class="{ active: step.active, current: step.current, done: step.done }"
>
<span>
<i
v-if="step.current"
v-motion
class="current-progress-ring"
:initial="currentProgressRingMotion.initial"
:enter="currentProgressRingMotion.enter"
aria-hidden="true"
></i>
<i v-if="step.done" class="mdi mdi-check"></i>
<template v-else>{{ step.index }}</template>
</span>
<div class="progress-step-copy" :title="step.title || step.detail || step.time">
<strong>{{ step.label }}</strong>
<small class="progress-step-status">{{ step.time }}</small>
<em v-if="step.detail" class="progress-step-meta">{{ step.detail }}</em>
</div>
</div>
</div>
</div>
</article>
<TravelRequestDetailHero :profile="profile" :hero-fact-items="heroFactItems" />
<TravelRequestProgressCard
:is-application-document="isApplicationDocument"
:progress-steps="progressSteps"
:current-progress-ring-motion="currentProgressRingMotion"
/>
<div class="detail-grid">
<section class="detail-left">
<article v-if="!isApplicationDocument" class="detail-card panel">
<div class="detail-card-head">
<div>
<h3>关联单据信息</h3>
<p>展示本次报销关联的前置申请便于核对申请内容天数事由和预计金额</p>
</div>
</div>
<div v-if="relatedApplicationFactItems.length" class="application-detail-facts related-application-facts">
<div
v-for="item in relatedApplicationFactItems"
:key="item.key"
class="application-detail-fact related-application-fact"
:class="{ highlight: item.highlight, emphasis: item.emphasis }"
>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
<div v-else class="related-application-empty">
<strong>暂未识别到关联申请单</strong>
<p>差旅报销应先关联已审批的申请单请核对本单据是否由申请单生成或已在智能录入中完成关联</p>
</div>
</article>
<TravelRequestRelatedApplicationCard
:is-application-document="isApplicationDocument"
:related-application-fact-items="relatedApplicationFactItems"
/>
<article class="detail-card panel">
<div class="detail-card-head">
<div>
@@ -674,12 +580,7 @@
</div>
</ConfirmDialog>
<Transition name="shared-confirm">
<div
v-if="attachmentPreviewOpen"
class="attachment-preview-mask"
role="presentation"
@click.self="closeAttachmentPreview"
>
<div v-if="attachmentPreviewOpen" class="attachment-preview-mask" role="presentation" @click.self="closeAttachmentPreview">
<section class="attachment-preview-card" role="dialog" aria-modal="true" @click.stop>
<div class="attachment-preview-head">
<div>
@@ -723,18 +624,8 @@
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ attachmentPreviewError }}</span>
</div>
<img
v-else-if="attachmentPreviewUrl && attachmentPreviewMediaType.startsWith('image/')"
:src="attachmentPreviewUrl"
:alt="attachmentPreviewName || '附件图片'"
class="attachment-preview-image"
/>
<iframe
v-else-if="attachmentPreviewUrl && attachmentPreviewMediaType === 'application/pdf'"
:src="attachmentPreviewUrl"
class="attachment-preview-frame"
title="附件预览"
></iframe>
<img v-else-if="attachmentPreviewUrl && attachmentPreviewMediaType.startsWith('image/')" :src="attachmentPreviewUrl" :alt="attachmentPreviewName || '附件图片'" class="attachment-preview-image" />
<iframe v-else-if="attachmentPreviewUrl && attachmentPreviewMediaType === 'application/pdf'" :src="attachmentPreviewUrl" class="attachment-preview-frame" title="附件预览"></iframe>
<div v-else class="attachment-preview-state">
<i class="mdi mdi-file-outline"></i>
<span>当前附件暂不支持直接预览</span>
@@ -747,32 +638,20 @@
</div>
<div v-if="currentAttachmentPreviewInsight" class="attachment-insight-content">
<div class="attachment-insight-pills">
<span :class="['attachment-recognition-pill', currentAttachmentPreviewInsight.requirementTone]">
{{ currentAttachmentPreviewInsight.requirementLabel }}
</span>
<span :class="['attachment-recognition-pill', currentAttachmentPreviewInsight.requirementTone]">{{ currentAttachmentPreviewInsight.requirementLabel }}</span>
</div>
<p v-if="currentAttachmentPreviewInsight.message" class="attachment-recognition-message">
{{ currentAttachmentPreviewInsight.message }}
</p>
<p v-if="currentAttachmentPreviewInsight.message" class="attachment-recognition-message">{{ currentAttachmentPreviewInsight.message }}</p>
<div v-if="currentAttachmentPreviewInsight.fields.length" class="attachment-insight-section">
<span>字段结果</span>
<ul>
<li v-for="field in currentAttachmentPreviewInsight.fields" :key="field">{{ field }}</li>
</ul>
<ul><li v-for="field in currentAttachmentPreviewInsight.fields" :key="field">{{ field }}</li></ul>
</div>
<div v-if="currentAttachmentPreviewInsight.ruleBasis.length" class="attachment-insight-section">
<span>规则依据</span>
<ul>
<li v-for="basis in currentAttachmentPreviewInsight.ruleBasis" :key="basis">{{ basis }}</li>
</ul>
<ul><li v-for="basis in currentAttachmentPreviewInsight.ruleBasis" :key="basis">{{ basis }}</li></ul>
</div>
<div v-if="currentAttachmentPreviewRiskCards.length" class="attachment-insight-section risk">
<span>风险点</span>
<article
v-for="card in currentAttachmentPreviewRiskCards"
:key="card.id"
:class="['attachment-risk-card', card.tone]"
>
<article v-for="card in currentAttachmentPreviewRiskCards" :key="card.id" :class="['attachment-risk-card', card.tone]">
<strong>{{ card.risk }}</strong>
<p>{{ card.suggestion }}</p>
</article>
@@ -808,22 +687,10 @@
@confirm="confirmSubmitRequest"
>
<div class="submit-confirm-summary" aria-label="提交前核对摘要">
<div class="submit-confirm-row">
<span>单据编号</span>
<strong>{{ request.documentNo || request.id }}</strong>
</div>
<div class="submit-confirm-row">
<span>{{ isApplicationDocument ? '申请类型' : '报销类型' }}</span>
<strong>{{ request.typeLabel }}</strong>
</div>
<div class="submit-confirm-row">
<span>{{ isApplicationDocument ? '预计金额' : '报销金额' }}</span>
<strong>{{ submitConfirmAmountDisplay }}</strong>
</div>
<div v-if="!isApplicationDocument" class="submit-confirm-row">
<span>费用明细</span>
<strong>{{ expenseItems.length }} / {{ uploadedExpenseCount }} 张单据</strong>
</div>
<div class="submit-confirm-row"><span>单据编号</span><strong>{{ request.documentNo || request.id }}</strong></div>
<div class="submit-confirm-row"><span>{{ isApplicationDocument ? '申请类型' : '报销类型' }}</span><strong>{{ request.typeLabel }}</strong></div>
<div class="submit-confirm-row"><span>{{ isApplicationDocument ? '预计金额' : '报销金额' }}</span><strong>{{ submitConfirmAmountDisplay }}</strong></div>
<div v-if="!isApplicationDocument" class="submit-confirm-row"><span>费用明细</span><strong>{{ expenseItems.length }} / {{ uploadedExpenseCount }} 张单据</strong></div>
</div>
</ConfirmDialog>
<ConfirmDialog
@@ -924,223 +791,4 @@
<style scoped src="../assets/styles/views/travel-request-detail-view-part2.css"></style>
<style scoped src="../assets/styles/views/travel-request-detail-responsive.css"></style>
<style>
/* 强力锁定表格中输入框的高度,解决 scoped 模式下有前缀的 Element Plus 子组件无法被 :deep 成功匹配的局限性 */
.detail-expense-table .editor-control .el-input__wrapper,
.detail-expense-table .editor-control .el-select__wrapper,
.detail-expense-table .editor-select .el-select__wrapper,
.detail-expense-table .editor-date-picker .el-input__wrapper {
box-sizing: border-box !important;
min-height: var(--expense-editor-control-height, 34px) !important;
height: var(--expense-editor-control-height, 34px) !important;
line-height: var(--expense-editor-control-line-height, 16px) !important;
}
.detail-expense-table .editor-control:not(.risk-note-editor-input),
.detail-expense-table .editor-date-picker.editor-control,
.detail-expense-table .editor-select {
min-height: var(--expense-editor-control-height, 34px) !important;
height: var(--expense-editor-control-height, 34px) !important;
}
.detail-expense-table .editor-date-picker.editor-control {
display: flex !important;
align-items: center !important;
}
.detail-expense-table .editor-date-picker.editor-control .el-input__wrapper {
gap: 4px !important;
padding-right: 7px !important;
padding-left: 7px !important;
}
.detail-expense-table .editor-date-picker.editor-control .el-input__inner,
.detail-expense-table .editor-input-control.editor-control .el-input__inner,
.detail-expense-table .editor-select .el-select__selected-item,
.detail-expense-table .editor-select .el-select__placeholder {
height: var(--expense-editor-control-line-height, 16px) !important;
line-height: var(--expense-editor-control-line-height, 16px) !important;
font-size: 12px !important;
}
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix,
.detail-expense-table .editor-date-picker.editor-control .el-input__suffix,
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix-inner,
.detail-expense-table .editor-date-picker.editor-control .el-input__suffix-inner {
display: inline-flex !important;
align-items: center !important;
}
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix,
.detail-expense-table .editor-date-picker.editor-control .el-input__suffix {
min-height: var(--expense-editor-control-height, 34px) !important;
height: var(--expense-editor-control-height, 34px) !important;
}
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix {
flex: 0 0 14px !important;
width: 14px !important;
min-width: 14px !important;
margin: 0 !important;
color: #94a3b8 !important;
font-size: 13px !important;
}
.detail-expense-table .editor-date-picker.editor-control .el-input__suffix {
display: none !important;
}
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix-inner,
.detail-expense-table .editor-date-picker.editor-control .el-input__suffix-inner {
height: var(--expense-editor-control-line-height, 16px) !important;
line-height: var(--expense-editor-control-line-height, 16px) !important;
}
.detail-expense-table .editor-date-picker.editor-control .el-input__prefix-inner {
width: 14px !important;
font-size: 13px !important;
}
.detail-expense-table .editor-amount-input.editor-control {
display: flex !important;
align-items: center !important;
}
.detail-expense-table .editor-amount-input.editor-control .el-input__wrapper {
display: flex !important;
align-items: center !important;
min-height: var(--expense-editor-control-height, 34px) !important;
height: var(--expense-editor-control-height, 34px) !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.detail-expense-table .editor-amount-input.editor-control .el-input__inner {
height: var(--expense-editor-control-line-height, 16px) !important;
line-height: var(--expense-editor-control-line-height, 16px) !important;
}
.detail-expense-table .editor-amount-input.editor-control .el-input__prefix,
.detail-expense-table .editor-amount-input.editor-control .el-input__prefix-inner {
display: inline-flex !important;
align-items: center !important;
}
.detail-expense-table .editor-amount-input.editor-control .el-input__prefix {
min-height: var(--expense-editor-control-height, 34px) !important;
height: var(--expense-editor-control-height, 34px) !important;
}
.detail-expense-table .editor-amount-input.editor-control .el-input__prefix-inner {
height: var(--expense-editor-control-line-height, 16px) !important;
line-height: var(--expense-editor-control-line-height, 16px) !important;
}
.detail-editor-date-popper.el-popper {
border: 1px solid rgba(148, 163, 184, .32) !important;
border-radius: 4px !important;
background: #ffffff !important;
box-shadow: 0 18px 42px rgba(15, 23, 42, .14) !important;
}
.detail-editor-date-popper .el-picker-panel {
border: 0 !important;
border-radius: 4px !important;
background: #ffffff !important;
color: #334155 !important;
}
.detail-editor-date-popper .el-date-picker__header {
height: 38px !important;
margin: 0 !important;
padding: 0 10px !important;
border-bottom: 1px solid #e2e8f0 !important;
display: flex !important;
align-items: center !important;
}
.detail-editor-date-popper .el-picker-panel__icon-btn {
appearance: none !important;
width: 24px !important;
height: 24px !important;
margin: 0 1px !important;
padding: 0 !important;
border: 0 !important;
border-radius: 4px !important;
background: transparent !important;
color: #64748b !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
transition: background-color 160ms var(--ease), color 160ms var(--ease) !important;
}
.detail-editor-date-popper .el-picker-panel__icon-btn:hover {
background: var(--theme-primary-soft) !important;
color: var(--theme-primary-active) !important;
}
.detail-editor-date-popper .el-date-picker__header-label {
color: #0f172a !important;
font-size: 13px !important;
font-weight: 800 !important;
}
.detail-editor-date-popper .el-picker-panel__content {
margin: 8px 10px 10px !important;
}
.detail-editor-date-popper .el-date-table th {
border-bottom: 1px solid #edf2f7 !important;
color: #64748b !important;
font-size: 11px !important;
font-weight: 800 !important;
}
.detail-editor-date-popper .el-date-table td {
width: 32px !important;
height: 30px !important;
padding: 2px !important;
}
.detail-editor-date-popper .el-date-table td .el-date-table-cell {
height: 28px !important;
padding: 0 !important;
}
.detail-editor-date-popper .el-date-table td .el-date-table-cell__text {
width: 26px !important;
height: 26px !important;
border-radius: 4px !important;
color: #334155 !important;
font-size: 12px !important;
line-height: 26px !important;
}
.detail-editor-date-popper .el-date-table td.available:hover .el-date-table-cell__text {
background: var(--theme-primary-soft) !important;
color: var(--theme-primary-active) !important;
}
.detail-editor-date-popper .el-date-table td.today .el-date-table-cell__text {
color: var(--theme-primary-active) !important;
font-weight: 850 !important;
}
.detail-editor-date-popper .el-date-table td.current .el-date-table-cell__text,
.detail-editor-date-popper .el-date-table td.selected .el-date-table-cell__text {
background: var(--theme-primary) !important;
color: #ffffff !important;
font-weight: 850 !important;
}
.detail-editor-date-popper .el-date-table td.prev-month .el-date-table-cell__text,
.detail-editor-date-popper .el-date-table td.next-month .el-date-table-cell__text {
color: #cbd5e1 !important;
}
.detail-editor-date-popper .el-date-table td.disabled .el-date-table-cell__text {
background: #f8fafc !important;
color: #cbd5e1 !important;
}
</style>
<style src="../assets/styles/views/travel-request-detail-date-popper.css"></style>

View File

@@ -9,10 +9,10 @@ import AuditVersionTimelineDrawer from '../../components/audit/AuditVersionTimel
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import { isFinanceUser, isManagerUser, isPlatformAdminUser } from '../../utils/accessControl.js'
import { useAuditAssetData } from './useAuditAssetData.js'
import { buildAuditDetailTopBar } from './auditViewDetailTopBar.js'
import { useAuditListFilters } from './auditViewListFilters.js'
import { useAuditViewPermissions } from './useAuditViewPermissions.js'
import { useAuditRuleReviewFlow } from './useAuditRuleReviewFlow.js'
import { useAuditRuleVersionActions } from './useAuditRuleVersionActions.js'
import { useAuditRiskRuleActions } from './useAuditRiskRuleActions.js'
@@ -74,9 +74,6 @@ export default {
{ label: '否', value: false }
]
const isAdmin = computed(() => isPlatformAdminUser(currentUser.value))
const isRuleManager = computed(() => isManagerUser(currentUser.value))
const isFinance = computed(() => isFinanceUser(currentUser.value))
const activeMeta = computed(() => TAB_META[activeType.value])
const activeTabLabel = computed(() => activeMeta.value.label)
const searchPlaceholder = computed(() => activeMeta.value.searchPlaceholder)
@@ -89,117 +86,41 @@ export default {
const showStatusColumn = computed(() => activeMeta.value.showStatusColumn !== false)
const showOnlineColumn = computed(() => false)
const showEnabledColumn = computed(() => activeMeta.value.showEnabledColumn === true)
const selectedSkillIsRule = computed(() => selectedSkill.value?.type === 'rules')
const selectedSkillUsesSpreadsheet = computed(
() => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesSpreadsheetRule)
)
const selectedSkillUsesJsonRisk = computed(
() => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesJsonRiskRule)
)
const canManageSelected = computed(
() => isRuleManager.value && Boolean(selectedSkill.value) && !selectedSkill.value?.isPreviewMock
)
const canAdminOperateSelected = computed(
() => isAdmin.value && Boolean(selectedSkill.value) && !selectedSkill.value?.isPreviewMock
)
const canEditSelected = computed(
() =>
Boolean(selectedSkill.value) &&
!selectedSkill.value?.isPreviewMock &&
(isAdmin.value || isFinance.value)
)
const latestRiskRuleTestSummary = computed(() => selectedSkill.value?.latestTestSummary || null)
const riskRuleTestPassed = computed(() => Boolean(latestRiskRuleTestSummary.value?.test_passed))
const riskRuleInReview = computed(
() => selectedSkillUsesJsonRisk.value && selectedSkill.value?.statusValue === 'review'
)
const riskRuleGenerationBusy = computed(
() => selectedSkillUsesJsonRisk.value && selectedSkill.value?.statusValue === 'generating'
)
const riskRuleGenerationFailed = computed(
() => selectedSkillUsesJsonRisk.value && selectedSkill.value?.statusValue === 'failed'
)
const canOpenRiskRuleTest = computed(
() =>
selectedSkillUsesJsonRisk.value &&
canAdminOperateSelected.value &&
Boolean(selectedSkill.value?.id) &&
!riskRuleGenerationBusy.value &&
!riskRuleGenerationFailed.value
)
const canDeleteRiskRule = computed(
() =>
selectedSkillUsesJsonRisk.value &&
canAdminOperateSelected.value &&
Boolean(selectedSkill.value?.id) &&
!normalizeText(selectedSkill.value?.publishedVersion).replace('-', '')
)
const canOpenRiskRuleReviewSubmit = computed(() => false)
const canSubmitRiskRuleReview = computed(
() =>
canOpenRiskRuleReviewSubmit.value &&
riskRuleTestPassed.value
)
const canReturnRiskRule = computed(() => false)
const riskRuleHasPublishableRevision = computed(() => {
const revision = selectedSkill.value?.configJson?.revision_draft
return selectedSkillUsesJsonRisk.value && revision &&
revision.generation_status === 'completed' &&
normalizeText(selectedSkill.value?.workingVersion).replace('-', '') &&
selectedSkill.value?.workingVersion !== selectedSkill.value?.publishedVersion
const {
detailBusy,
isAdmin,
selectedSkillIsRule,
selectedSkillUsesSpreadsheet,
selectedSkillUsesJsonRisk,
canManageSelected,
canAdminOperateSelected,
canEditSelected,
latestRiskRuleTestSummary,
riskRuleTestPassed,
riskRuleInReview,
riskRuleGenerationBusy,
riskRuleGenerationFailed,
canOpenRiskRuleTest,
canDeleteRiskRule,
canOpenRiskRuleReviewSubmit,
canSubmitRiskRuleReview,
canReturnRiskRule,
riskRuleHasPublishableRevision,
canPublishRiskRule,
canToggleRiskRuleEnabled,
canEditRiskRuleDraft,
canCreateRiskRuleRevision,
canEditMarkdown,
isDisplayingWorkingVersion,
canUploadSpreadsheet,
canDownloadSpreadsheet,
canEditSpreadsheetInline,
selectedSpreadsheetFileName
} = useAuditViewPermissions({
currentUser,
selectedSkill,
actionState
})
const canPublishRiskRule = computed(
() =>
Boolean(riskRuleHasPublishableRevision.value) &&
canManageSelected.value &&
riskRuleTestPassed.value &&
!detailBusy.value
)
const canToggleRiskRuleEnabled = computed(
() => selectedSkillUsesJsonRisk.value && canManageSelected.value
)
const canEditRiskRuleDraft = computed(
() =>
selectedSkillUsesJsonRisk.value &&
(canEditSelected.value || canManageSelected.value) &&
!detailBusy.value &&
!riskRuleGenerationBusy.value &&
!normalizeText(selectedSkill.value?.publishedVersion).replace('-', '')
)
const canCreateRiskRuleRevision = computed(
() =>
selectedSkillUsesJsonRisk.value &&
(canEditSelected.value || canManageSelected.value) &&
!detailBusy.value &&
!riskRuleGenerationBusy.value &&
!riskRuleGenerationFailed.value &&
Boolean(normalizeText(selectedSkill.value?.publishedVersion).replace('-', ''))
)
const canEditMarkdown = computed(() => selectedSkillIsRule.value && canEditSelected.value)
const isDisplayingWorkingVersion = computed(
() => selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion
)
const canUploadSpreadsheet = computed(
() =>
canEditSelected.value &&
selectedSkillUsesSpreadsheet.value &&
!detailBusy.value
)
const canDownloadSpreadsheet = computed(
() =>
selectedSkillUsesSpreadsheet.value &&
Boolean(selectedSkill.value?.id) &&
!detailBusy.value
)
const canEditSpreadsheetInline = computed(
() =>
selectedSkillUsesSpreadsheet.value &&
(selectedSkill.value?.isPreviewMock || canEditSelected.value)
)
const selectedSpreadsheetFileName = computed(
() =>
normalizeText(selectedSkill.value?.ruleDocument?.file_name) || '未上传规则表'
)
const {
versionSwitchTarget,
versionTimelineOpen,
@@ -228,7 +149,6 @@ export default {
actionState,
toast
})
const detailBusy = computed(() => Boolean(actionState.value))
const {
loading,
errorMessage,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,586 @@
import {
appendEmployeeBankUpdatePayload,
createEmployeeBankFormFields,
getEmployeeBankSearchFields,
mapEmployeeBankFormFields
} from './employeeBankFields.js'
export const DEFAULT_STATUS_TABS = ['全部员工', '在职', '试用中', '停用']
export const FALLBACK_ROLE_OPTIONS = [
{
id: 'manager',
code: 'manager',
label: '管理员',
desc: '可以维护员工档案、组织结构和角色权限。'
},
{
id: 'finance',
code: 'finance',
label: '财务人员',
desc: '可以处理复核、查看财务知识与风险校验结果。'
},
{
id: 'approver',
code: 'approver',
label: '审批负责人',
desc: '可以处理单据中心中的待审单据。'
},
{
id: 'executive',
code: 'executive',
label: '高级财务人员',
desc: '可以查看跨部门预算、经营看板与关键财务审批结果。'
},
{
id: 'budget_monitor',
code: 'budget_monitor',
label: '预算监控员',
desc: '可以查看本部门预算执行、预警和占用情况。'
},
{
id: 'user',
code: 'user',
label: '使用者',
desc: '可以发起费用申请、报销、查看个人单据和使用 AI 助手。'
}
]
export function createEmployeeForm() {
return {
name: '',
employeeNo: '',
gender: '',
age: '',
birthDate: '',
phone: '',
email: '',
joinDate: '',
location: '',
position: '',
grade: '',
department: '',
organizationUnitCode: '',
manager: '',
managerEmployeeNo: '',
financeOwner: '',
costCenter: '',
...createEmployeeBankFormFields(),
roleCodes: [],
password: ''
}
}
export function isPlaceholderManagerName(name) {
const normalized = normalizeText(name)
return !normalized || normalized === 'CEO' || normalized === '无'
}
export function resolveManagerEmployeeNo(employee, roster = []) {
const fromApi = normalizeText(employee?.managerEmployeeNo)
if (fromApi) {
return fromApi
}
const managerName = normalizeText(employee?.manager)
if (isPlaceholderManagerName(managerName)) {
return ''
}
const matches = roster.filter((item) => normalizeText(item.name) === managerName)
if (matches.length === 1) {
return matches[0].employeeNo
}
return ''
}
export function enrichEmployeeRecord(employee, roster = []) {
if (!employee) {
return employee
}
const managerEmployeeNo = resolveManagerEmployeeNo(employee, roster)
if (!managerEmployeeNo || managerEmployeeNo === employee.managerEmployeeNo) {
return employee
}
return {
...employee,
managerEmployeeNo
}
}
export function mergeEmployeeRecords(listItem, detailItem, roster = []) {
if (!listItem && !detailItem) {
return null
}
if (!listItem) {
return enrichEmployeeRecord(detailItem, roster)
}
if (!detailItem) {
return enrichEmployeeRecord(listItem, roster)
}
const managerEmployeeNo =
normalizeText(detailItem.managerEmployeeNo) ||
normalizeText(listItem.managerEmployeeNo) ||
resolveManagerEmployeeNo(detailItem, roster) ||
resolveManagerEmployeeNo(listItem, roster)
const history =
Array.isArray(detailItem.history) && detailItem.history.length
? detailItem.history
: listItem.history || []
const permissions =
Array.isArray(detailItem.permissions) && detailItem.permissions.length
? detailItem.permissions
: listItem.permissions || []
return enrichEmployeeRecord(
{
...listItem,
...detailItem,
manager: detailItem.manager || listItem.manager,
managerEmployeeNo: managerEmployeeNo || null,
history,
permissions,
roleCodes: detailItem.roleCodes?.length ? detailItem.roleCodes : listItem.roleCodes,
roles: detailItem.roles?.length ? detailItem.roles : listItem.roles,
organization: detailItem.organization || listItem.organization,
department: detailItem.department || listItem.department
},
roster
)
}
export function buildEmployeeForm(employee, roster = []) {
if (!employee) {
return createEmployeeForm()
}
const birthDate = employee.birthDate || ''
const managerName = employee.manager || ''
const managerEmployeeNo = resolveManagerEmployeeNo(employee, roster)
return {
name: employee.name || '',
employeeNo: employee.employeeNo || '',
gender: employee.gender || '',
age:
employee.age !== null && employee.age !== undefined && employee.age !== ''
? String(employee.age)
: calculateAgeFromDate(birthDate),
birthDate,
phone: employee.phone || '',
email: employee.email || '',
joinDate: employee.joinDate || '',
location: employee.location || '',
position: employee.position || '',
grade: employee.grade || '',
department: resolveOrganizationUnitName(employee),
organizationUnitCode: resolveOrganizationUnitCode(employee),
manager: managerName,
managerEmployeeNo,
financeOwner: employee.financeOwner || '',
costCenter: employee.costCenter || '',
...mapEmployeeBankFormFields(employee),
roleCodes: [...(employee.roleCodes || [])],
password: ''
}
}
export function normalizeText(value) {
return String(value || '').trim()
}
export function normalizeNullableText(value) {
const text = normalizeText(value)
return text || null
}
export function isValidEmail(value) {
const normalized = normalizeText(value)
if (!normalized) {
return false
}
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(normalized)
}
export function isValidIsoDate(value) {
const normalized = normalizeText(value)
if (!normalized) {
return false
}
if (!/^\d{4}-\d{2}-\d{2}$/u.test(normalized)) {
return false
}
const [yearText, monthText, dayText] = normalized.split('-')
const year = Number.parseInt(yearText, 10)
const month = Number.parseInt(monthText, 10)
const day = Number.parseInt(dayText, 10)
if ([year, month, day].some((item) => Number.isNaN(item))) {
return false
}
const parsed = new Date(year, month - 1, day)
if (Number.isNaN(parsed.getTime())) {
return false
}
return (
parsed.getFullYear() === year &&
parsed.getMonth() === month - 1 &&
parsed.getDate() === day
)
}
export function sameValues(left, right) {
if (left.length !== right.length) {
return false
}
return left.every((value, index) => value === right[index])
}
export function formatEmployeeHistoryTime(value) {
const raw = normalizeText(value)
if (!raw) {
return ''
}
const chineseMatched = raw.match(
/^(\d{4})年(\d{1,2})月(\d{1,2})日(\d{1,2})时(\d{1,2})分(?:\d{1,2}秒)?$/
)
if (chineseMatched) {
const [, year, month, day, hour, minute] = chineseMatched
return `${year}-${padDatePart(month)}-${padDatePart(day)} ${padDatePart(hour)}:${padDatePart(minute)}`
}
const isoMatched = raw.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{1,2}))?/)
if (isoMatched) {
const [, year, month, day, hour = '0', minute = '0'] = isoMatched
return `${year}-${padDatePart(month)}-${padDatePart(day)} ${padDatePart(hour)}:${padDatePart(minute)}`
}
return raw.replace(/(\d{1,2}分)\d{1,2}秒$/, '$1')
}
export function resolveOrganizationUnitCode(employee) {
return normalizeText(employee?.organization?.code)
}
export function resolveOrganizationUnitName(employee) {
return normalizeText(employee?.department) || normalizeText(employee?.organization?.name)
}
export function captureEmployeeDetailSnapshot(form) {
return {
roleCodes: [...(form.roleCodes || [])].sort(),
organizationUnitCode: normalizeText(form.organizationUnitCode) || ''
}
}
export function resolveOrganizationOptions(metaOrganizations) {
if (!Array.isArray(metaOrganizations) || !metaOrganizations.length) {
return []
}
return metaOrganizations
.map((item) => ({
id: item.id,
code: item.code,
name: item.name,
unitType: item.unitType,
label: `${item.name}${item.code}`
}))
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
}
export function calculateAgeFromDate(dateString) {
if (!dateString) {
return ''
}
const birthDate = new Date(`${dateString}T00:00:00`)
if (Number.isNaN(birthDate.getTime())) {
return ''
}
const today = new Date()
let age = today.getFullYear() - birthDate.getFullYear()
const hasBirthdayPassed =
today.getMonth() > birthDate.getMonth() ||
(today.getMonth() === birthDate.getMonth() && today.getDate() >= birthDate.getDate())
if (!hasBirthdayPassed) {
age -= 1
}
return age >= 0 ? String(age) : ''
}
export function calculateBirthDateFromAge(ageValue, existingBirthDate = '') {
const age = Number.parseInt(String(ageValue ?? '').trim(), 10)
if (Number.isNaN(age) || age < 0 || age > 120) {
return existingBirthDate || ''
}
const today = new Date()
let month = '01'
let day = '01'
if (existingBirthDate && isValidIsoDate(existingBirthDate)) {
const [, monthText, dayText] = existingBirthDate.split('-')
month = monthText
day = dayText
}
let birthYear = today.getFullYear() - age
let candidate = `${birthYear}-${month}-${day}`
if (Number(calculateAgeFromDate(candidate)) > age) {
birthYear -= 1
candidate = `${birthYear}-${month}-${day}`
}
return candidate
}
export function matchKeyword(employee, keyword) {
if (!keyword) {
return true
}
const fields = [
employee.name,
employee.employeeNo,
employee.department,
employee.position,
employee.email,
employee.manager,
employee.financeOwner,
...getEmployeeBankSearchFields(employee),
employee.syncState
]
const roles = Array.isArray(employee.roles) ? employee.roles : []
const haystack = [...fields, ...roles]
.map((val) => String(val || '').trim())
.filter(Boolean)
.join(' ')
.toLowerCase()
return haystack.includes(keyword)
}
export function uniqueSorted(values) {
return [...new Set(values.filter(Boolean))].sort((left, right) => {
return String(left).localeCompare(String(right), 'zh-CN')
})
}
export function resolveRoleOptions(metaRoles, employees) {
const options = Array.isArray(metaRoles) && metaRoles.length ? metaRoles : FALLBACK_ROLE_OPTIONS
const existingLabels = new Set(options.map((item) => item.label))
const unknownRoles = uniqueSorted(employees.flatMap((item) => item.roles || [])).filter(
(label) => !existingLabels.has(label)
)
return [
...options,
...unknownRoles.map((label) => ({
id: label,
code: label,
label,
desc: '该角色来自当前员工数据。'
}))
]
}
export function buildStatusTabs(employees) {
return DEFAULT_STATUS_TABS.map((label) => ({
label,
count:
label === '全部员工'
? employees.length
: employees.filter((item) => item.status === label).length
}))
}
export function buildEmployeeSummary(employees) {
return {
total: employees.length,
active: employees.filter((item) => item.status === '在职').length,
onboarding: employees.filter((item) => item.status === '试用中').length,
disabled: employees.filter((item) => item.status === '停用').length,
followUp: employees.filter((item) => item.syncState !== '已同步').length,
departments: uniqueSorted(employees.map((item) => item.department)).length
}
}
export function mapSimpleFilterOptions(values, allLabel) {
return [
{ label: allLabel, value: '' },
...values.map((value) => ({ label: value, value }))
]
}
export function buildEmployeeStatusActionCopy(options = {}) {
const employeeName = options.selectedEmployee?.name || '该员工'
if (options.selectedEmployeeDisabled) {
return {
buttonLabel: options.actionState === 'disable' ? '启用中...' : '启用账号',
buttonIcon: 'mdi mdi-account-check-outline',
badge: '启用账号',
badgeTone: 'info',
title: `确认启用 ${employeeName} 的账号吗?`,
description: '启用后该员工将恢复登录能力,并重新获得个人业务入口访问权限。',
confirmText: '确认启用',
busyText: '启用中...',
confirmTone: 'primary',
confirmIcon: 'mdi mdi-account-check-outline',
successMessage: '员工账号已启用。',
failureMessage: '启用账号失败,请稍后重试。'
}
}
return {
buttonLabel: options.actionState === 'disable' ? '停用中...' : '停用账号',
buttonIcon: 'mdi mdi-account-cancel-outline',
badge: '停用账号',
badgeTone: 'warning',
title: `确认停用 ${employeeName} 的账号吗?`,
description: '停用后该员工将无法继续登录系统,相关个人操作入口也会立即失效。',
confirmText: '确认停用',
busyText: '停用中...',
confirmTone: 'danger',
confirmIcon: 'mdi mdi-account-cancel-outline',
successMessage: '员工账号已停用。',
failureMessage: '停用账号失败,请稍后重试。'
}
}
export function buildEmployeeEmptyState(options = {}) {
const hasEmployeeFilters = Boolean(options.hasEmployeeFilters)
const activeTab = options.activeTab || DEFAULT_STATUS_TABS[0]
if (!options.employeeCount) {
return {
eyebrow: '员工台账',
title: '员工目录暂时还是空的',
desc: '当前环境还没有同步任何员工档案。完成目录接入后,这里会展示员工基础信息、角色和状态。',
icon: 'mdi mdi-account-group-outline',
actionLabel: '重新加载',
actionIcon: 'mdi mdi-refresh',
tone: 'sky',
artLabel: 'PEOPLE',
tips: ['支持按部门、职级和角色统一维护', '点击列表行即可进入档案和权限详情']
}
}
return {
eyebrow: hasEmployeeFilters ? '筛选结果为空' : '员工状态为空',
title: hasEmployeeFilters ? '当前条件下没有匹配员工' : `${activeTab}”里暂时没有员工`,
desc: hasEmployeeFilters
? '可以切回“全部员工”,或者清空关键词、部门、职级和角色条件后再试。'
: '这个状态标签下目前还没有记录,你可以切换到其他状态继续查看。',
icon: hasEmployeeFilters ? 'mdi mdi-account-search-outline' : 'mdi mdi-badge-account-horizontal-outline',
actionLabel: hasEmployeeFilters ? '清空筛选' : '查看全部员工',
actionIcon: hasEmployeeFilters ? 'mdi mdi-filter-remove-outline' : 'mdi mdi-format-list-bulleted',
tone: hasEmployeeFilters ? 'primary' : 'slate',
artLabel: hasEmployeeFilters ? 'FILTER' : 'STATUS',
tips: hasEmployeeFilters
? ['关键词、部门、职级和角色条件会叠加生效', '也可以直接搜索姓名、工号或岗位']
: ['员工状态统计会按真实目录数据自动更新', '停用员工仍会保留在台账中便于追溯']
}
}
export function buildEmployeeUpdatePayload(options = {}) {
const current = options.selectedEmployee
const form = options.form || {}
const payload = {}
if (!current) {
return payload
}
const nextName = normalizeText(form.name)
if (nextName && nextName !== current.name) payload.name = nextName
const nextGender = normalizeNullableText(form.gender)
if (nextGender !== (current.gender || null)) payload.gender = nextGender
const nextBirthDate = normalizeNullableText(form.birthDate)
if (nextBirthDate !== (current.birthDate || null)) payload.birth_date = nextBirthDate
const nextPhone = normalizeNullableText(form.phone)
if (nextPhone !== (current.phone || null)) payload.phone = nextPhone
const nextEmail = normalizeText(form.email)
if (nextEmail && nextEmail !== current.email) payload.email = nextEmail
const nextJoinDate = normalizeNullableText(form.joinDate)
if (nextJoinDate !== (current.joinDate || null)) payload.join_date = nextJoinDate
const nextLocation = normalizeNullableText(form.location)
if (nextLocation !== (current.location || null)) payload.location = nextLocation
const nextPosition = normalizeText(form.position)
if (nextPosition && nextPosition !== current.position) payload.position = nextPosition
const nextGrade = normalizeText(form.grade)
if (nextGrade && nextGrade !== current.grade) payload.grade = nextGrade
const nextOrganizationCode = normalizeText(form.organizationUnitCode)
const currentOrganizationCode =
normalizeText(options.employeeDetailSnapshot?.organizationUnitCode) ||
resolveOrganizationUnitCode(current) ||
''
if (nextOrganizationCode !== currentOrganizationCode) {
payload.organization_unit_code = nextOrganizationCode
}
const nextFinanceOwner = normalizeNullableText(form.financeOwner)
if (nextFinanceOwner !== (current.financeOwner || null)) {
payload.finance_owner_name = nextFinanceOwner
}
const nextCostCenter = normalizeNullableText(form.costCenter)
if (nextCostCenter !== (current.costCenter || null)) {
payload.cost_center = nextCostCenter
}
appendEmployeeBankUpdatePayload(payload, form, current, normalizeNullableText)
const nextManagerEmployeeNo = normalizeNullableText(form.managerEmployeeNo)
const currentManagerEmployeeNo =
normalizeNullableText(current.managerEmployeeNo) ||
resolveManagerEmployeeNo(current, options.employees || []) ||
null
if (nextManagerEmployeeNo !== currentManagerEmployeeNo) {
payload.manager_employee_no = nextManagerEmployeeNo || ''
}
const nextRoleCodes = [...(form.roleCodes || [])].sort()
const currentRoleCodes = [
...(options.employeeDetailSnapshot?.roleCodes || current.roleCodes || [])
].sort()
if (!sameValues(nextRoleCodes, currentRoleCodes)) {
payload.role_codes = [...(form.roleCodes || [])]
}
const nextPassword = normalizeText(form.password)
if (nextPassword) payload.password = nextPassword
return payload
}
function padDatePart(value) {
return String(Number(value)).padStart(2, '0')
}

View File

@@ -0,0 +1,114 @@
export const APPLICATION_NON_BLOCKING_MISSING_FIELDS = new Set([
'amount',
'attachments',
'employee_no',
'employee_name',
'department_name'
])
export const FLOW_EXPENSE_TYPE_LABELS = {
travel: '差旅费'
}
const FIELD_DISPLAY_CONFIG = {
expense_type: {
label: '费用类型',
hint: '例如差旅、交通、住宿、业务招待'
},
time_range: {
label: '发生时间',
hint: '申请时填出差起止日期,报销时填费用发生日期'
},
location: {
label: '地点',
hint: '出差城市或费用发生地点'
},
reason: {
label: '事由',
hint: '出差、报销或业务活动的具体原因'
},
amount: {
label: '金额',
hint: '申请时为预计金额,报销时为实际报销金额'
},
transport_mode: {
label: '出行方式',
hint: '例如高铁、飞机、自驾、出租车'
},
attachments: {
label: '附件/凭证',
hint: '发票、行程单、付款截图或其他证明材料'
},
customer_name: {
label: '客户或项目对象',
hint: '涉及的客户、单位或项目名称'
},
merchant_name: {
label: '商户/开票方',
hint: '发票或付款凭证上的商户名称'
},
department_name: {
label: '所属部门',
hint: '申请人或费用归属部门'
},
employee_name: {
label: '申请人',
hint: '发起申请或报销的员工姓名'
},
employee_no: {
label: '员工编号',
hint: '公司内部员工编号'
}
}
const FIELD_ALIASES = {
occurred_date: 'time_range',
business_time: 'time_range',
reason_value: 'reason',
transport_type: 'transport_mode',
application_transport_mode: 'transport_mode'
}
const FIELD_VALUE_DISPLAY_CONFIG = {
expense_type: {
travel: '差旅',
business_entertainment: '业务招待',
transportation: '交通费',
traffic: '交通费',
accommodation: '住宿费',
meal: '餐饮费'
}
}
export function normalizeFieldKey(field) {
const key = String(field || '').trim()
return FIELD_ALIASES[key] || key
}
export function resolveFieldDisplay(field, taskType = '') {
const key = normalizeFieldKey(field)
const config = FIELD_DISPLAY_CONFIG[key] || {
label: key.replace(/_/g, ' '),
hint: ''
}
if (key === 'amount') {
return {
key,
label: taskType === 'expense_application' ? '预计金额' : '报销金额',
hint: taskType === 'expense_application'
? '本次申请预计发生的费用'
: '本次需要报销的实际金额'
}
}
return {
key,
label: config.label,
hint: config.hint
}
}
export function formatStewardFieldDisplayValue(field, value) {
const key = normalizeFieldKey(field)
const normalizedValue = String(value || '').trim()
return FIELD_VALUE_DISPLAY_CONFIG[key]?.[normalizedValue] || normalizedValue
}

View File

@@ -6,6 +6,13 @@ import {
SESSION_TYPE_APPLICATION,
SESSION_TYPE_EXPENSE
} from './travelReimbursementConversationModel.js'
import {
APPLICATION_NON_BLOCKING_MISSING_FIELDS,
FLOW_EXPENSE_TYPE_LABELS,
formatStewardFieldDisplayValue,
normalizeFieldKey,
resolveFieldDisplay
} from './stewardPlanFields.js'
const TASK_TYPE_LABELS = {
expense_application: '费用申请',
@@ -21,88 +28,6 @@ const AGENT_LABELS = {
expense: '报销助手'
}
const FIELD_DISPLAY_CONFIG = {
expense_type: {
label: '费用类型',
hint: '例如差旅、交通、住宿、业务招待'
},
time_range: {
label: '发生时间',
hint: '申请时填出差起止日期,报销时填费用发生日期'
},
location: {
label: '地点',
hint: '出差城市或费用发生地点'
},
reason: {
label: '事由',
hint: '出差、报销或业务活动的具体原因'
},
amount: {
label: '金额',
hint: '申请时为预计金额,报销时为实际报销金额'
},
transport_mode: {
label: '出行方式',
hint: '例如高铁、飞机、自驾、出租车'
},
attachments: {
label: '附件/凭证',
hint: '发票、行程单、付款截图或其他证明材料'
},
customer_name: {
label: '客户或项目对象',
hint: '涉及的客户、单位或项目名称'
},
merchant_name: {
label: '商户/开票方',
hint: '发票或付款凭证上的商户名称'
},
department_name: {
label: '所属部门',
hint: '申请人或费用归属部门'
},
employee_name: {
label: '申请人',
hint: '发起申请或报销的员工姓名'
},
employee_no: {
label: '员工编号',
hint: '公司内部员工编号'
}
}
const FIELD_ALIASES = {
occurred_date: 'time_range',
business_time: 'time_range',
reason_value: 'reason',
transport_type: 'transport_mode',
application_transport_mode: 'transport_mode'
}
const APPLICATION_NON_BLOCKING_MISSING_FIELDS = new Set([
'amount',
'attachments',
'employee_no',
'employee_name',
'department_name'
])
const FIELD_VALUE_DISPLAY_CONFIG = {
expense_type: {
travel: '差旅',
business_entertainment: '业务招待',
transportation: '交通费',
traffic: '交通费',
accommodation: '住宿费',
meal: '餐饮费'
}
}
const FLOW_EXPENSE_TYPE_LABELS = {
travel: '差旅费'
}
export function buildStewardPlanRequest({
rawText = '',
files = [],
@@ -774,39 +699,6 @@ function buildStewardCarryText(actionType, task, group, normalized = null) {
return lines.filter(Boolean).join('\n')
}
function normalizeFieldKey(field) {
const key = String(field || '').trim()
return FIELD_ALIASES[key] || key
}
function resolveFieldDisplay(field, taskType = '') {
const key = normalizeFieldKey(field)
const config = FIELD_DISPLAY_CONFIG[key] || {
label: key.replace(/_/g, ' '),
hint: ''
}
if (key === 'amount') {
return {
key,
label: taskType === 'expense_application' ? '预计金额' : '报销金额',
hint: taskType === 'expense_application'
? '本次申请预计发生的费用'
: '本次需要报销的实际金额'
}
}
return {
key,
label: config.label,
hint: config.hint
}
}
function formatStewardFieldDisplayValue(field, value) {
const key = normalizeFieldKey(field)
const normalizedValue = String(value || '').trim()
return FIELD_VALUE_DISPLAY_CONFIG[key]?.[normalizedValue] || normalizedValue
}
function buildRemainingTaskText(normalized, currentTaskId) {
const remainingTasks = normalized.tasks.filter((task) => task.taskId !== currentTaskId)
if (!remainingTasks.length) {

View File

@@ -0,0 +1,34 @@
const DEFAULT_TYPEWRITER_CHUNK_SIZE = 3
function findLineStart(chars, index) {
let cursor = Math.max(0, index)
while (cursor > 0 && chars[cursor - 1] !== '\n') {
cursor -= 1
}
return cursor
}
function findNextParagraphStart(chars, index) {
let cursor = Math.max(0, index)
while (cursor < chars.length) {
if (chars[cursor] === '\n' && chars[cursor + 1] && chars[cursor + 1] !== '|') {
return cursor + 1
}
cursor += 1
}
return chars.length
}
function isMarkdownTableLine(chars, lineStart) {
return chars.slice(lineStart, lineStart + 2).join('').trimStart().startsWith('|')
}
export function resolveStewardTypewriterNextIndex(chars = [], index = 0, chunkSize = DEFAULT_TYPEWRITER_CHUNK_SIZE) {
const safeIndex = Math.max(0, Math.min(Number(index) || 0, chars.length))
const lineStart = findLineStart(chars, safeIndex)
if (isMarkdownTableLine(chars, lineStart) || chars[safeIndex + 1] === '|') {
return findNextParagraphStart(chars, lineStart)
}
const safeChunkSize = Math.max(1, Number(chunkSize) || DEFAULT_TYPEWRITER_CHUNK_SIZE)
return Math.min(chars.length, safeIndex + safeChunkSize)
}

View File

@@ -0,0 +1,246 @@
import { filterVisibleMessageMeta } from '../../utils/assistantMessageMeta.js'
import { resolveExpenseTypeLabel } from './travelReimbursementReviewModel.js'
import {
FLOW_MISSING_SLOT_LABELS,
FLOW_STEP_FALLBACKS
} from './travelReimbursementConversationSessionModel.js'
let messageSeed = 0
export function nowTime() {
return new Date().toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
export function createMessage(role, text, attachments = [], extras = {}) {
messageSeed += 1
const message = {
id: `msg-${messageSeed}`,
role,
text,
attachments,
time: nowTime(),
meta: [],
citations: [],
suggestedActions: [],
suggestedActionsLocked: false,
selectedSuggestedActionKey: '',
selectedSuggestedActionLabel: '',
querySelectionLocked: false,
selectedQueryRecordId: '',
queryPayload: null,
draftPayload: null,
reviewPayload: null,
reviewPanelScope: '',
riskFlags: [],
pendingAttachmentAssociation: null,
applicationPreview: null,
budgetReport: null,
stewardPlan: null,
operationFeedback: null,
...extras
}
message.meta = filterVisibleMessageMeta(message.meta)
return message
}
export function buildExpenseIntentConfirmationMessage(rawText) {
const text = String(rawText || '').trim()
return [
text
? `我看到了「${text}」这类业务事项描述。`
: '我看到了这类业务事项描述。',
'但现在还不能确定你是要发起报销,还是要处理其他事项,所以我先暂停后续识别。',
'如果你是想报销,请点击下面的“我要报销”,我再继续引导你选择具体报销场景。'
].join('\n')
}
export function buildExpenseSceneSelectionMessage(rawText) {
const text = String(rawText || '').trim()
const hasBusinessTime = /业务发生时间|发生时间|20\d{2}[-年\/.]\d{1,2}/.test(text)
const prefix = hasBusinessTime
? '我已看到你提供了业务发生时间和报销意图。'
: '我已识别到这是报销申请。'
return [
`${prefix}先选一下这笔费用属于哪一类,我再按对应流程继续。`,
'差旅和业务招待通常需要先关联申请单;交通、住宿、办公用品这类一般可以直接继续填写。',
'选完后我会把下一步需要准备的内容整理给你。'
].join('\n')
}
export function formatMessageTime(value) {
if (!value) {
return nowTime()
}
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) {
return nowTime()
}
return parsed.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false
})
}
export function formatSemanticEntityValue(entity) {
const normalizedValue = String(entity?.normalized_value || '').trim()
const rawValue = String(entity?.value || '').trim()
const entityType = String(entity?.type || '').trim()
if (entityType === 'amount') {
const numericValue = Number(normalizedValue || rawValue)
if (Number.isFinite(numericValue) && numericValue > 0) {
return Number.isInteger(numericValue) ? `${numericValue}` : `${numericValue.toFixed(2)}`
}
}
return rawValue || normalizedValue
}
export function summarizeSemanticParseDetail(semanticParse, ontologyJson = {}) {
if (!semanticParse || typeof semanticParse !== 'object') {
return FLOW_STEP_FALLBACKS.extraction.completedText
}
const entities = Array.isArray(semanticParse.entities_json) ? semanticParse.entities_json : []
const entityMap = new Map()
for (const item of entities) {
const entityType = String(item?.type || '').trim()
if (!entityType || entityMap.has(entityType)) continue
entityMap.set(entityType, item)
}
const extractedParts = []
const timeRange = semanticParse.time_range_json && typeof semanticParse.time_range_json === 'object'
? semanticParse.time_range_json
: {}
const startDate = String(timeRange.start_date || '').trim()
const endDate = String(timeRange.end_date || '').trim()
if (startDate) {
extractedParts.push(`时间 ${startDate}${endDate && endDate !== startDate ? `${endDate}` : ''}`)
}
const amountEntity = entityMap.get('amount')
if (amountEntity) {
const amountValue = formatSemanticEntityValue(amountEntity)
if (amountValue) {
extractedParts.push(`金额 ${amountValue}`)
}
}
const expenseTypeEntity = entityMap.get('expense_type')
if (expenseTypeEntity) {
const expenseTypeLabel = resolveExpenseTypeLabel(
String(expenseTypeEntity?.normalized_value || '').trim(),
String(expenseTypeEntity?.value || '').trim()
)
if (expenseTypeLabel) {
extractedParts.push(`费用类型 ${expenseTypeLabel}`)
}
}
const customerEntity = entityMap.get('customer')
if (customerEntity) {
const customerValue = formatSemanticEntityValue(customerEntity)
if (customerValue) {
extractedParts.push(`客户 ${customerValue}`)
}
}
const missingSlots = Array.isArray(ontologyJson?.missing_slots) ? ontologyJson.missing_slots : []
const missingLabels = missingSlots
.map((item) => FLOW_MISSING_SLOT_LABELS[String(item || '').trim()] || String(item || '').trim())
.filter(Boolean)
if (extractedParts.length && missingLabels.length) {
return `已提取${extractedParts.join('、')};待补充 ${missingLabels.join('、')}`
}
if (extractedParts.length) {
return `已提取${extractedParts.join('、')}`
}
if (missingLabels.length) {
return `已完成信息提取;待补充 ${missingLabels.join('、')}`
}
return FLOW_STEP_FALLBACKS.extraction.completedText
}
export function sanitizeRequest(request) {
if (!request || typeof request !== 'object') return null
const normalized = {
claimId: String(request.claimId || request.claim_id || '').trim(),
claimNo: String(request.claimNo || request.claim_no || request.documentNo || '').trim(),
id: String(request.id || '').trim(),
typeLabel: String(request.typeLabel || request.category || '').trim(),
reason: String(request.reason || request.title || '').trim(),
entity: String(request.entity || '').trim(),
city: String(request.city || request.location || '').trim(),
period: String(request.period || '').trim(),
applyTime: String(request.applyTime || request.occurredAt || '').trim(),
amount: String(request.amount || '').trim(),
node: String(request.node || '').trim(),
approval: String(request.approval || '').trim(),
travel: String(request.travel || '').trim(),
applicationEditMode: Boolean(request.applicationEditMode || request.application_edit_mode)
}
return Object.values(normalized).some(Boolean) ? normalized : null
}
export function resolveStatusLabel(status) {
if (status === 'succeeded') return '已完成'
if (status === 'blocked') return '已阻断'
return '失败'
}
export function resolveStatusTone(status) {
if (status === 'succeeded') return 'success'
if (status === 'blocked') return 'warning'
return 'note'
}
export function buildMessageMeta(payload, fileNames = []) {
const items = []
if (payload?.trace_summary?.degraded) {
items.push('已降级')
}
if (payload?.requires_confirmation) {
items.push('待确认')
}
if (fileNames.length) {
items.push(`附件: ${fileNames.length}`)
}
return filterVisibleMessageMeta(items)
}
export function buildStoredMessageMeta(messageJson, attachmentNames = []) {
const payload = messageJson?.orchestrator_payload
if (payload) {
return buildMessageMeta(payload, attachmentNames)
}
const items = []
if (messageJson?.status) {
items.push(`状态: ${messageJson.status}`)
}
if (attachmentNames.length) {
items.push(`附件: ${attachmentNames.length}`)
}
return filterVisibleMessageMeta(items)
}
export function buildRestoredMessageId(sourceId = '') {
const normalizedId = String(sourceId || '').trim()
return `restored-${normalizedId || ++messageSeed}`
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,294 @@
import { isBudgetMonitorUser, isExecutiveUser, isPlatformAdminUser } from '../../utils/accessControl.js'
import {
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
GUIDED_ACTION_START_APPLICATION,
GUIDED_ACTION_START_REIMBURSEMENT,
GUIDED_ACTION_START_STATUS_QUERY
} from './travelReimbursementGuidedFlowModel.js'
export const SESSION_TYPE_EXPENSE = 'expense'
export const SESSION_TYPE_APPLICATION = 'application'
export const SESSION_TYPE_APPROVAL = 'approval'
export const SESSION_TYPE_KNOWLEDGE = 'knowledge'
export const SESSION_TYPE_BUDGET = 'budget'
export const SESSION_TYPE_STEWARD = 'steward'
export const ASSISTANT_SESSION_TYPES = [
SESSION_TYPE_STEWARD,
SESSION_TYPE_APPLICATION,
SESSION_TYPE_EXPENSE,
SESSION_TYPE_APPROVAL,
SESSION_TYPE_KNOWLEDGE,
SESSION_TYPE_BUDGET
]
export const ASSISTANT_SESSION_MODE_OPTIONS = [
{
key: SESSION_TYPE_STEWARD,
label: '小财管家',
icon: 'mdi mdi-account-tie-outline',
description: '统一拆解多任务、归集附件,并调度申请助手和报销助手'
},
{
key: SESSION_TYPE_APPLICATION,
label: '申请助手',
icon: 'mdi mdi-file-plus-outline',
description: '只处理费用申请、事前审批、申请材料和申请状态'
},
{
key: SESSION_TYPE_EXPENSE,
label: '报销助手',
icon: 'mdi mdi-receipt-text-plus-outline',
description: '只处理报销发起、票据识别、草稿归集和报销状态'
},
{
key: SESSION_TYPE_APPROVAL,
label: '审核助手',
icon: 'mdi mdi-clipboard-check-outline',
description: '只处理待审单据、风险解释、审批动作和审核意见'
},
{
key: SESSION_TYPE_KNOWLEDGE,
label: '财务知识助手',
icon: 'mdi mdi-book-open-page-variant-outline',
description: '只处理财务制度、标准规则、票据要求和政策解释'
},
{
key: SESSION_TYPE_BUDGET,
label: '预算编制助手',
icon: 'mdi mdi-calculator-variant-outline',
description: '帮助你进行预算编制与预算相关问题的整理'
}
]
export function canUseBudgetAssistantSession(user = null) {
return Boolean(isPlatformAdminUser(user) || isBudgetMonitorUser(user) || isExecutiveUser(user))
}
function canUseAssistantSessionType(sessionType, user = null) {
const normalized = String(sessionType || '').trim()
if (normalized === SESSION_TYPE_BUDGET) {
return canUseBudgetAssistantSession(user)
}
return true
}
export function filterAssistantSessionModes(sessionModes = [], user = null) {
return Array.isArray(sessionModes)
? sessionModes.filter((mode) => canUseAssistantSessionType(mode?.key, user))
: []
}
export function filterAssistantSessionTypes(sessionTypes = [], user = null) {
return Array.isArray(sessionTypes)
? sessionTypes.filter((sessionType) => canUseAssistantSessionType(String(sessionType || '').trim(), user))
: []
}
export function normalizeAssistantSessionType(sessionType, fallback = SESSION_TYPE_EXPENSE) {
const normalized = String(sessionType || '').trim()
if (ASSISTANT_SESSION_TYPES.includes(normalized)) {
return normalized
}
const fallbackType = String(fallback || '').trim()
return ASSISTANT_SESSION_TYPES.includes(fallbackType) ? fallbackType : SESSION_TYPE_EXPENSE
}
export function resolveAssistantSessionMode(sessionType) {
const normalized = normalizeAssistantSessionType(sessionType)
return ASSISTANT_SESSION_MODE_OPTIONS.find((item) => item.key === normalized) || ASSISTANT_SESSION_MODE_OPTIONS[1]
}
export const aiAvatar = '/assets/header.png'
export const userAvatar = '/assets/person.png'
export const SOURCE_LABELS = {
workbench: '来自个人工作台',
topbar: '来自发起报销',
application: '来自发起申请',
budget: '来自预算中心',
detail: '来自智能录入',
upload: '来自附件上传',
requests: '来自报销列表'
}
export const SCENARIO_LABELS = {
expense: '报销',
accounts_receivable: '应收',
accounts_payable: '应付',
budget: '预算',
knowledge: '知识',
unknown: '通用'
}
export const INTENT_LABELS = {
query: '查询',
explain: '解释',
compare: '对比',
risk_check: '风险检查',
draft: '信息核对',
operate: '动作请求'
}
export const FLOW_STEP_FALLBACKS = {
intent: {
title: '意图识别',
tool: 'IntentRecognizer',
runningText: '正在识别业务意图...',
completedText: '意图识别完成'
},
extraction: {
title: '信息提取',
tool: 'SemanticExtractor',
runningText: '正在提取时间、金额、费用类型和待补项...',
completedText: '信息提取完成'
},
ocr: {
title: '票据/OCR识别',
tool: 'OCRService',
runningText: '正在识别票据附件...',
completedText: '票据识别完成'
},
'expense-review-preview': {
title: '报销信息核对',
tool: 'user_agent.expense_review_preview',
runningText: '正在整理识别结果和右侧核对信息...',
completedText: '核对信息已整理'
},
'expense-claim-draft': {
title: '保存报销草稿',
tool: 'database.expense_claims.save_or_submit',
runningText: '正在把已确认信息保存为草稿...',
completedText: '草稿已保存'
},
'draft-risk-review': {
title: '草稿风险识别',
tool: 'RuleEngine',
runningText: '正在对草稿执行规则校验...',
completedText: '已完成草稿风险识别'
},
'application-submit-success': {
title: '申请单提交成功',
tool: 'ApplicationSubmit',
runningText: '正在提交费用申请...',
completedText: '申请单提交成功'
},
'attachment-association': {
title: '票据关联草稿',
tool: 'database.expense_claims.save_or_submit',
runningText: '正在把本次票据关联到已保存草稿...',
completedText: '票据已归集到草稿'
},
'expense-scene-selection': {
title: '报销场景确认',
tool: 'UserConfirmation',
runningText: '等待用户选择报销场景...',
completedText: '已进入场景选择,等待用户确认'
},
'expense-intent-confirmation': {
title: '报销意图确认',
tool: 'UserConfirmation',
runningText: '等待用户确认是否发起报销...',
completedText: '用户已确认报销意图'
}
}
export const ASSISTANT_DISPLAY_NAME = '财务助手'
export const EXPENSE_WELCOME_QUICK_ACTIONS = [
{
label: '快速发起报销',
action: GUIDED_ACTION_START_REIMBURSEMENT,
icon: 'mdi mdi-receipt-text-plus-outline'
},
{
label: '查询单据状态',
action: GUIDED_ACTION_START_STATUS_QUERY,
icon: 'mdi mdi-file-search-outline'
},
{
label: '差旅计算器',
action: GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
icon: 'mdi mdi-calculator-variant-outline'
}
]
export const APPLICATION_WELCOME_QUICK_ACTIONS = [
{
label: '快速发起申请',
action: GUIDED_ACTION_START_APPLICATION,
icon: 'mdi mdi-file-plus-outline'
},
{
label: '查询申请状态',
prompt: '帮我查询我的费用申请单状态,筛选最近的 5 条记录。',
icon: 'mdi mdi-file-search-outline'
},
{
label: '申请材料清单',
prompt: '请告诉我发起费用申请通常需要准备哪些关键信息和附件。',
icon: 'mdi mdi-clipboard-text-search-outline'
}
]
export const APPROVAL_WELCOME_QUICK_ACTIONS = [
{
label: '待我审核',
prompt: '帮我查询当前待我审核的单据,筛选最近的 5 条记录。',
icon: 'mdi mdi-clipboard-list-outline'
},
{
label: '审核风险说明',
prompt: '帮我梳理待审核单据中需要重点关注的风险,并按高、中、低风险分类说明。',
icon: 'mdi mdi-alert-circle-outline'
},
{
label: '生成审核意见',
prompt: '请根据当前待审核单据的风险点,帮我生成一段专业、克制的审核意见草稿。',
icon: 'mdi mdi-text-box-edit-outline'
}
]
export const BUDGET_WELCOME_QUICK_ACTIONS = [
{
label: '预算编制查询',
prompt: '帮我查询当前部门本季度预算编制情况,重点看差旅、通信、招待费和办公用品。',
icon: 'mdi mdi-calculator-variant-outline'
},
{
label: '阈值风险检查',
prompt: '帮我检查当前预算的提醒阈值、告警阈值和风险阈值设置是否合理,并指出需要关注的费用类型。',
icon: 'mdi mdi-alert-decagram-outline'
},
{
label: '预算调整建议',
prompt: '请根据已发生、已占用和剩余预算,帮我整理下一轮预算调整建议。',
icon: 'mdi mdi-chart-box-plus-outline'
}
]
export const HOT_KNOWLEDGE_QUESTIONS = [
'差旅住宿标准按什么规则执行?',
'酒店超标后如何申请例外报销?',
'招待费报销需要哪些凭证?',
'发票抬头不一致还能报销吗?',
'电子发票验真失败怎么处理?',
'借款多久内需要冲销?',
'预算不足还能先提交报销吗?',
'会议费和招待费如何区分?',
'跨部门项目费用应该怎么归集?',
'员工退票手续费是否可以报销?'
]
export const FLOW_MISSING_SLOT_LABELS = {
expense_type: '报销类型',
customer_name: '客户名称',
time_range: '发生时间',
location: '地点',
merchant_name: '酒店/商户',
amount: '金额',
reason: '事由说明',
participants: '参与人员',
attachments: '票据附件'
}
let messageSeed = 0

View File

@@ -0,0 +1,277 @@
import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js'
import { filterVisibleMessageMeta } from '../../utils/assistantMessageMeta.js'
import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
import { buildAgentInsight, buildReviewFilePreviewsFromReviewPayload } from './travelReimbursementAttachmentModel.js'
import { normalizeAssistantSessionType, SESSION_TYPE_EXPENSE } from './travelReimbursementConversationSessionModel.js'
import {
buildRestoredMessageId,
buildStoredMessageMeta,
createMessage,
formatMessageTime
} from './travelReimbursementConversationMessageModel.js'
export function resolveInitialSessionType(conversation, fallback = SESSION_TYPE_EXPENSE) {
const stateJson = conversation?.state_json || conversation?.stateJson || {}
const sessionType = String(stateJson?.session_type || '').trim()
return normalizeAssistantSessionType(sessionType, fallback)
}
export function buildInitialInsightFromConversation(conversation) {
const rawMessages = Array.isArray(conversation?.messages) ? conversation.messages : []
for (let index = rawMessages.length - 1; index >= 0; index -= 1) {
const item = rawMessages[index]
const messageJson = item?.message_json || item?.messageJson || {}
const orchestratorPayload = messageJson?.orchestrator_payload || null
if (!orchestratorPayload) continue
const attachmentNames = Array.isArray(messageJson?.attachment_names)
? messageJson.attachment_names.filter(Boolean)
: []
return buildAgentInsight(
orchestratorPayload,
attachmentNames,
buildReviewFilePreviewsFromReviewPayload(orchestratorPayload?.result?.review_payload)
)
}
return null
}
export function resolveInitialConversationId(conversation) {
return String(conversation?.conversation_id || conversation?.conversationId || '').trim()
}
export function resolveInitialDraftClaimId(conversation) {
return String(conversation?.draft_claim_id || conversation?.draftClaimId || '').trim()
}
export function resolveKnowledgeRankLabel(index) {
return String(index + 1)
}
export function resolveKnowledgeRankTone(index) {
if (index === 0) return 'gold'
if (index === 1) return 'silver'
if (index === 2) return 'bronze'
return 'default'
}
export function parseConversationMessageSequence(message) {
const messageJson = message?.message_json || message?.messageJson || {}
const sequence = Number.parseInt(messageJson?.sequence, 10)
return Number.isFinite(sequence) && sequence > 0 ? sequence : null
}
export function parseConversationMessageTime(message) {
const rawValue = message?.created_at || message?.createdAt || ''
const timestamp = new Date(rawValue).getTime()
return Number.isFinite(timestamp) ? timestamp : Number.MAX_SAFE_INTEGER
}
export function resolveConversationMessageRolePriority(message) {
return String(message?.role || '').trim() === 'user' ? 0 : 1
}
export function sortConversationMessages(messages) {
return [...(Array.isArray(messages) ? messages : [])].sort((left, right) => {
const leftSequence = parseConversationMessageSequence(left)
const rightSequence = parseConversationMessageSequence(right)
if (leftSequence !== null && rightSequence !== null && leftSequence !== rightSequence) {
return leftSequence - rightSequence
}
const timeDiff = parseConversationMessageTime(left) - parseConversationMessageTime(right)
if (timeDiff !== 0) {
return timeDiff
}
const leftRunId = String(left?.run_id || left?.runId || '').trim()
const rightRunId = String(right?.run_id || right?.runId || '').trim()
if (leftRunId && rightRunId && leftRunId === rightRunId) {
const roleDiff = resolveConversationMessageRolePriority(left) - resolveConversationMessageRolePriority(right)
if (roleDiff !== 0) {
return roleDiff
}
}
return String(left?.id || '').localeCompare(String(right?.id || ''))
})
}
export function normalizeInitialConversationMessages(conversation) {
const rawMessages = sortConversationMessages(conversation?.messages)
const restoredMessages = rawMessages.map((item) => {
const messageJson = item?.message_json || item?.messageJson || {}
const attachmentNames = Array.isArray(messageJson?.attachment_names)
? messageJson.attachment_names.filter(Boolean)
: []
const orchestratorPayload = messageJson?.orchestrator_payload || null
const result = orchestratorPayload?.result || {}
return createMessage(item.role, item.content, attachmentNames, {
id: buildRestoredMessageId(item.id),
time: formatMessageTime(item.created_at || item.createdAt),
assistantName: String(messageJson?.assistant_name || messageJson?.assistantName || '').trim(),
assistantVariant: String(messageJson?.assistant_variant || messageJson?.assistantVariant || '').trim(),
meta: item.role === 'assistant' ? buildStoredMessageMeta(messageJson, attachmentNames) : [],
citations: item.role === 'assistant' && Array.isArray(result?.citations) ? result.citations : [],
suggestedActions:
item.role === 'assistant' && Array.isArray(result?.suggested_actions)
? result.suggested_actions
: [],
queryPayload: item.role === 'assistant' ? normalizeExpenseQueryPayload(result?.query_payload) : null,
draftPayload: item.role === 'assistant' ? result?.draft_payload || messageJson?.draft_payload || null : null,
reviewPayload: item.role === 'assistant' ? result?.review_payload || null : null,
riskFlags: item.role === 'assistant' && Array.isArray(result?.risk_flags) ? result.risk_flags : []
})
})
return markResolvedSuggestedActionMessages(restoredMessages)
}
export function normalizeSnapshotMessage(message) {
const extras = message && typeof message === 'object' ? { ...message } : {}
const role = String(extras.role || 'assistant').trim() || 'assistant'
const text = String(extras.text || '')
const attachments = Array.isArray(extras.attachments) ? extras.attachments.filter(Boolean) : []
delete extras.role
delete extras.text
delete extras.attachments
return createMessage(role, text, attachments, extras)
}
export function normalizeSnapshotMessages(messages) {
return Array.isArray(messages)
? markResolvedSuggestedActionMessages(messages.map(normalizeSnapshotMessage))
: []
}
export function serializeSessionMessages(messages) {
return (Array.isArray(messages) ? messages : []).map((message) => ({
id: message.id,
role: message.role,
text: message.text,
attachments: Array.isArray(message.attachments) ? message.attachments.filter(Boolean) : [],
time: message.time,
meta: filterVisibleMessageMeta(message.meta),
metaTone: message.metaTone || '',
citations: Array.isArray(message.citations) ? message.citations : [],
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : [],
suggestedActionsLocked: Boolean(message.suggestedActionsLocked),
selectedSuggestedActionKey: String(message.selectedSuggestedActionKey || ''),
selectedSuggestedActionLabel: String(message.selectedSuggestedActionLabel || ''),
querySelectionLocked: Boolean(message.querySelectionLocked),
selectedQueryRecordId: String(message.selectedQueryRecordId || ''),
queryPayload: message.queryPayload || null,
draftPayload: message.draftPayload || null,
reviewPayload: message.reviewPayload || null,
riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [],
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
applicationPreview: message.applicationPreview || null,
budgetReport: message.budgetReport || null,
stewardPlan: message.stewardPlan || null,
operationFeedback: message.operationFeedback || null,
assistantName: message.assistantName || '',
assistantVariant: message.assistantVariant || '',
isWelcome: Boolean(message.isWelcome),
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []
}))
}
export function hasMeaningfulSessionMessages(messages) {
return (Array.isArray(messages) ? messages : []).some((message) => {
if (!message || message.isWelcome) {
return false
}
if (message.role === 'user') {
return true
}
return Boolean(
String(message.text || '').trim()
|| (Array.isArray(message.suggestedActions) && message.suggestedActions.length)
|| message.reviewPayload
|| message.queryPayload
|| message.draftPayload
|| message.applicationPreview
|| message.budgetReport
|| message.stewardPlan
|| message.operationFeedback
|| message.pendingAttachmentAssociation
|| (Array.isArray(message.riskFlags) && message.riskFlags.length)
)
})
}
export function hasActiveSuggestedActionMessage(messages) {
return (Array.isArray(messages) ? messages : []).some(
(message) =>
message?.role === 'assistant'
&& Array.isArray(message.suggestedActions)
&& message.suggestedActions.length > 0
&& !message.suggestedActionsLocked
)
}
export function resolveConversationUpdatedAt(conversation) {
const timestamp = new Date(conversation?.updated_at || conversation?.updatedAt || 0).getTime()
return Number.isFinite(timestamp) ? timestamp : 0
}
export function shouldPreferPersistedSessionState(persistedState, snapshot, conversation) {
if (!persistedState) {
return false
}
if (!conversation) {
return true
}
if (hasActiveSuggestedActionMessage(persistedState.messages)) {
return true
}
const snapshotUpdatedAt = Number(snapshot?.updatedAt || 0)
return snapshotUpdatedAt >= resolveConversationUpdatedAt(conversation)
}
export function markResolvedSuggestedActionMessages(messages) {
const items = Array.isArray(messages) ? messages : []
const selectedLabels = new Set()
for (const message of items) {
if (message?.role !== 'user') {
continue
}
const text = String(message.text || '').trim()
const selectedMatch = text.match(/^选择(.+)$/) || text.match(/用户选择报销场景[:]\s*([^\n\r]+)/)
if (selectedMatch?.[1]) {
selectedLabels.add(selectedMatch[1].trim())
} else if (text === '我要报销') {
selectedLabels.add(text)
}
}
if (!selectedLabels.size) {
return items
}
return items.map((message) => {
if (
message?.role !== 'assistant'
|| message.suggestedActionsLocked
|| !Array.isArray(message.suggestedActions)
|| !message.suggestedActions.length
) {
return message
}
const selectedAction = message.suggestedActions.find((action) =>
selectedLabels.has(String(action?.label || action?.payload?.expense_type_label || '').trim())
)
if (!selectedAction) {
return message
}
return {
...message,
suggestedActionsLocked: true,
selectedSuggestedActionKey: buildSuggestedActionKey(selectedAction),
selectedSuggestedActionLabel: String(selectedAction.label || selectedAction?.payload?.expense_type_label || '').trim()
}
})
}

View File

@@ -0,0 +1,319 @@
import {
DATE_INPUT_FORMAT,
buildReviewAttachmentStatus,
cloneReviewEditFields,
createEmptyInlineReviewState,
formatAmountDisplay,
formatReviewSceneDisplayValue,
normalizeReviewRiskLevel,
shouldShowReviewFactCard,
buildBusinessTimeContextFromReviewValues as buildBusinessTimeContextFromReviewValuesModel,
buildReviewFormContextFromPayload as buildReviewFormContextFromPayloadModel,
isTravelReviewPayload as isTravelReviewPayloadModel,
resolveReviewTravelTransportType as resolveReviewTravelTransportTypeModel
} from './travelReimbursementReviewModel.js'
const REVIEW_PANEL_SCOPE_OVERVIEW = 'overview'
const REVIEW_PANEL_SCOPE_DOCUMENTS = 'documents'
const REVIEW_PANEL_SCOPE_RISK = 'risk'
const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ['历史报销画像', '用户画像', '制度注意事项', '制度注意']
const REVIEW_PENDING_SUMMARY_PATTERN = /(^|\n)\s*(?:当前还有|我这边看到还有|下方还有|这笔报销还有|目前还有|还有|这次识别结果里还有|我还需要你确认|当前信息还差|本次报销还有)\s+[^\n]*(?:信息待补充|风险提醒|细节还需要进一步确认)[^\n]*(?:草稿)[^\n]*。\s*/g
const REVIEW_RISK_LEVEL_META = {
high: {
label: '高风险',
icon: 'mdi mdi-alert-octagon-outline',
suggestion: '建议先处理该项,再提交审批;如果属于合理业务场景,请补充说明或附件。'
},
medium: {
label: '中风险',
icon: 'mdi mdi-alert-circle-outline',
suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。'
},
info: {
label: '提示',
icon: 'mdi mdi-information-outline',
suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。'
},
low: {
label: '低风险',
icon: 'mdi mdi-information-outline',
suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。'
}
}
export function normalizeReviewPanelScope(scope) {
const normalized = String(scope || '').trim()
return [REVIEW_PANEL_SCOPE_OVERVIEW, REVIEW_PANEL_SCOPE_DOCUMENTS, REVIEW_PANEL_SCOPE_RISK].includes(normalized)
? normalized
: ''
}
export function canExposeReviewPanelScope(scope) {
return Boolean(normalizeReviewPanelScope(scope))
}
export function buildBusinessTimeContextFromReviewValues(values = {}) {
return buildBusinessTimeContextFromReviewValuesModel(values)
}
export function buildReviewFormContextFromPayload(reviewPayload, inlineState = null) {
return buildReviewFormContextFromPayloadModel(reviewPayload, inlineState)
}
export function buildReviewCorrectionMessage(fields) {
const lines = ['请按以下核对后的报销信息更新当前识别结果:']
for (const item of cloneReviewEditFields(fields)) {
if (!item.label || (!item.value && !item.required)) {
continue
}
lines.push(`${item.label}${String(item.value || '').trim() || '待补充'}`)
}
return lines.join('\n')
}
export function isTravelReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) {
return isTravelReviewPayloadModel(reviewPayload, inlineState)
}
export function resolveReviewTravelTransportType(reviewPayload, fallbackText = '') {
return resolveReviewTravelTransportTypeModel(reviewPayload, fallbackText)
}
export function resolveReviewRiskBriefs(reviewPayload) {
if (!Array.isArray(reviewPayload?.risk_briefs)) return []
return reviewPayload.risk_briefs.filter((item) => {
const title = String(item?.title || '').trim()
return !DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS.some((keyword) => title.includes(keyword))
})
}
export function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineReviewState()) {
const pendingAttachmentCount = Math.max(0, Number(inlineState.pending_attachment_count || 0))
const totalAttachmentCount = Math.max(0, Number(inlineState.attachment_count || 0))
const existingAttachmentCount = Math.max(0, totalAttachmentCount - pendingAttachmentCount)
const attachmentStatus =
pendingAttachmentCount > 0
? existingAttachmentCount > 0
? `已上传 ${existingAttachmentCount} 份,待新增 ${pendingAttachmentCount}`
: `待保存 ${pendingAttachmentCount}`
: totalAttachmentCount > 0
? `已上传 ${totalAttachmentCount}`
: buildReviewAttachmentStatus(reviewPayload)
if (isTravelReviewPayload(reviewPayload, inlineState)) {
return [
{
key: 'occurred_date',
label: '发生时间',
value: String(inlineState.occurred_date || '').trim() || '待补充',
icon: 'mdi mdi-calendar-month-outline',
editor: 'date',
modelKey: 'occurred_date',
placeholder: `例如 ${DATE_INPUT_FORMAT}`
},
{
key: 'amount',
label: '金额',
value: formatAmountDisplay(inlineState.amount) || '待补充',
icon: 'mdi mdi-cash',
editor: 'amount',
modelKey: 'amount',
placeholder: '例如 200.00'
},
{
key: 'transport_type',
label: '交通类型',
value: String(inlineState.transport_type || '').trim() || '待确认',
icon: 'mdi mdi-train-car',
editor: 'text',
modelKey: 'transport_type',
placeholder: '例如 火车/高铁、飞机'
},
{
key: 'hotel_name',
label: '酒店名称',
value: String(inlineState.merchant_name || '').trim() || '待补充',
icon: 'mdi mdi-bed-outline',
editor: 'text',
modelKey: 'merchant_name',
placeholder: '请输入酒店名称'
},
{
key: 'travel_purpose',
label: '出差事宜',
value: String(inlineState.reason_value || '').trim() || '待补充',
icon: 'mdi mdi-briefcase-edit-outline',
editor: 'textarea',
modelKey: 'reason_value',
placeholder: '请填写本次出差的具体工作内容或业务意图',
wide: true
}
]
}
const cards = [
{
key: 'occurred_date',
label: '发生时间',
value: String(inlineState.occurred_date || '').trim() || '待补充',
icon: 'mdi mdi-calendar-month-outline',
editor: 'date',
modelKey: 'occurred_date',
placeholder: `例如 ${DATE_INPUT_FORMAT}`
},
{
key: 'amount',
label: '金额',
value: formatAmountDisplay(inlineState.amount) || '待补充',
icon: 'mdi mdi-cash',
editor: 'amount',
modelKey: 'amount',
placeholder: '例如 200.00'
},
{
key: 'scene',
label: '场景 / 事由',
value: formatReviewSceneDisplayValue(inlineState),
icon: 'mdi mdi-silverware-fork-knife',
editor: 'select',
modelKey: 'scene_label',
placeholder: '请选择场景'
},
{
key: 'attachments',
label: '票据状态',
value: attachmentStatus,
icon: 'mdi mdi-file-document-outline',
editor: 'upload',
modelKey: 'attachment_names',
placeholder: ''
}
]
if (shouldShowReviewFactCard(reviewPayload, 'customer_name', inlineState.customer_name)) {
cards.splice(cards.length - 1, 0, {
key: 'customer_name',
label: '关联客户',
value: String(inlineState.customer_name || '').trim() || '待补充',
icon: 'mdi mdi-domain',
editor: 'text',
modelKey: 'customer_name',
placeholder: '请输入客户名称'
})
}
if (shouldShowReviewFactCard(reviewPayload, 'location', inlineState.location)) {
cards.splice(cards.length - 1, 0, {
key: 'location',
label: '业务地点',
value: String(inlineState.location || '').trim() || '待补充',
icon: 'mdi mdi-map-marker-outline',
editor: 'text',
modelKey: 'location',
placeholder: '请输入业务地点'
})
}
if (shouldShowReviewFactCard(reviewPayload, 'merchant_name', inlineState.merchant_name)) {
cards.splice(cards.length - 1, 0, {
key: 'merchant_name',
label: '酒店/商户',
value: String(inlineState.merchant_name || '').trim() || '待补充',
icon: 'mdi mdi-storefront-outline',
editor: 'text',
modelKey: 'merchant_name',
placeholder: '请输入酒店或商户名称'
})
}
if (shouldShowReviewFactCard(reviewPayload, 'participants', inlineState.participants)) {
cards.splice(cards.length - 1, 0, {
key: 'participants',
label: '同行人员',
value: String(inlineState.participants || '').trim() || '待补充',
icon: 'mdi mdi-account-group-outline',
editor: 'text',
modelKey: 'participants',
placeholder: '例如 客户 2 人,我方 1 人'
})
}
return cards
}
function normalizeReviewRiskTitle(title, fallbackTitle) {
const normalized = String(title || '').trim()
const fallback = String(fallbackTitle || '风险提示').trim() || '风险提示'
if (!normalized) return fallback
const cleaned = normalized
.replace(/AI\s*预审\s*(暂未通过|未通过|不通过)?/g, '风险提示')
.replace(/(高风险|中风险|低风险)/g, '')
.replace(/^[:\-—\s]+|[:\-—\s]+$/g, '')
.trim()
return cleaned || fallback
}
export function buildReviewRiskItems(reviewPayload) {
return resolveReviewRiskBriefs(reviewPayload)
.map((brief, index) => {
const title = String(brief?.title || '').trim()
const content = String(brief?.content || '').trim()
const detail = String(brief?.detail || '').trim()
const suggestion = String(brief?.suggestion || '').trim()
const level = normalizeReviewRiskLevel(brief?.level)
const meta = REVIEW_RISK_LEVEL_META[level] || REVIEW_RISK_LEVEL_META.low
const fallbackTitle = content ? `风险提示 ${index + 1}` : '风险提示'
const normalizedTitle = normalizeReviewRiskTitle(title, fallbackTitle)
const summary = content || normalizedTitle
if (!normalizedTitle && !summary) return null
return {
key: `${level}-${normalizedTitle}-${index}`,
title: normalizedTitle,
summary,
detail: detail || content || '当前风险项没有返回更长解释,建议结合票据、报销事由和规则要求进行复核。',
level,
levelLabel: meta.label,
icon: meta.icon,
sourceLabel: meta.label,
suggestion: suggestion || meta.suggestion
}
})
.filter(Boolean)
}
export function buildReviewRiskConversationText(item, detailTarget = {}) {
const title = String(item?.title || '风险提示').trim()
const summary = String(item?.summary || '').trim()
const detail = String(item?.detail || '').trim()
const suggestion = String(item?.suggestion || '').trim()
const isInfo = String(item?.level || '').trim() === 'info'
const detailHref = String(detailTarget?.href || '').trim()
const detailLabel = String(detailTarget?.label || '').trim() || '进入该单据详情重新填写'
const lines = [`${title}`]
if (summary) {
lines.push('', `${isInfo ? '提示内容' : '风险点'}${summary}`)
}
if (detail && detail !== summary) {
lines.push('', `规则依据:${detail}`)
}
if (suggestion) {
lines.push('', `${isInfo ? '处理建议' : '修改建议'}${suggestion}`)
}
if (detailHref) {
lines.push('', `[${detailLabel}](${detailHref})`)
}
return lines.join('\n')
}
export function buildReviewMainMessageText(message) {
const text = String(message?.text || '')
if (!message?.reviewPayload) {
return text
}
return text
.replace(REVIEW_PENDING_SUMMARY_PATTERN, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}

View File

@@ -0,0 +1,191 @@
export const FLOW_RUN_DETAIL_REFRESH_TIMEOUT_MS = 3000
const FLOW_DURATION_MS_FIELDS = [
'duration_ms',
'elapsed_ms',
'latency_ms',
'total_duration_ms',
'execution_time_ms'
]
const FLOW_DURATION_SECOND_FIELDS = [
'duration_seconds',
'elapsed_seconds',
'latency_seconds',
'execution_time_seconds'
]
const FLOW_DURATION_AUTO_FIELDS = ['duration', 'elapsed', 'latency', 'execution_time']
const FLOW_STARTED_AT_FIELDS = ['started_at', 'start_time', 'created_at', 'queued_at']
const FLOW_FINISHED_AT_FIELDS = ['finished_at', 'completed_at', 'ended_at', 'end_time', 'updated_at']
export function formatFlowDuration(ms) {
if (ms === null || ms === undefined || ms === '') {
return '--'
}
const numericValue = Number(ms)
if (!Number.isFinite(numericValue) || numericValue <= 0) {
return '--'
}
if (numericValue < 1000) {
return `${Math.max(0.1, numericValue / 1000).toFixed(1)}s`
}
if (numericValue < 10000) {
return `${(numericValue / 1000).toFixed(1)}s`
}
return `${Math.round(numericValue / 1000)}s`
}
function parseFlowTimestamp(value) {
if (value === null || value === undefined || value === '') {
return 0
}
if (typeof value === 'number' && Number.isFinite(value)) {
return value > 0 && value < 10000000000 ? Math.round(value * 1000) : Math.round(value)
}
const timestamp = new Date(value).getTime()
return Number.isFinite(timestamp) ? timestamp : 0
}
function normalizeDurationValue(value, unit = 'ms') {
if (value === null || value === undefined || value === '') {
return null
}
let numericValue = Number(value)
let normalizedUnit = unit
if (typeof value === 'string') {
const text = value.trim()
const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|毫秒|s|秒)?$/i)
if (match) {
numericValue = Number(match[1])
if (match[2]) {
normalizedUnit = ['s', '秒'].includes(match[2].toLowerCase()) ? 'seconds' : 'ms'
}
}
}
if (!Number.isFinite(numericValue) || numericValue <= 0) {
return null
}
if (normalizedUnit === 'seconds') {
return Math.round(numericValue * 1000)
}
if (normalizedUnit === 'auto') {
return Math.round(numericValue <= 300 ? numericValue * 1000 : numericValue)
}
return Math.round(numericValue)
}
function readFirstDurationField(source, fields, unit) {
if (!source || typeof source !== 'object') {
return null
}
for (const field of fields) {
if (!Object.prototype.hasOwnProperty.call(source, field)) {
continue
}
const durationMs = normalizeDurationValue(source[field], unit)
if (durationMs) {
return durationMs
}
}
return null
}
function resolveDurationFromFields(source) {
return (
readFirstDurationField(source, FLOW_DURATION_MS_FIELDS, 'ms')
|| readFirstDurationField(source, FLOW_DURATION_SECOND_FIELDS, 'seconds')
|| readFirstDurationField(source, FLOW_DURATION_AUTO_FIELDS, 'auto')
)
}
function readFirstTimestampField(source, fields) {
if (!source || typeof source !== 'object') {
return 0
}
for (const field of fields) {
const timestamp = parseFlowTimestamp(source[field])
if (timestamp) {
return timestamp
}
}
return 0
}
export function resolveStartedTimestamp(source) {
return readFirstTimestampField(source, FLOW_STARTED_AT_FIELDS)
}
export function resolveFinishedTimestamp(source) {
return readFirstTimestampField(source, FLOW_FINISHED_AT_FIELDS)
}
function resolveTimeRangeDurationMs(source) {
const startedAt = resolveStartedTimestamp(source)
const finishedAt = resolveFinishedTimestamp(source)
return finishedAt > startedAt ? finishedAt - startedAt : null
}
export function resolveSemanticPhaseDurations(run) {
const runStart = resolveStartedTimestamp(run)
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
const firstToolStartedAt = toolCalls
.map((item) => resolveStartedTimestamp(item))
.filter((value) => value > 0)
.sort((left, right) => left - right)[0] || 0
const runFinishedAt = resolveFinishedTimestamp(run)
const semanticFinishedAt = firstToolStartedAt || runFinishedAt
if (!runStart || !semanticFinishedAt || semanticFinishedAt <= runStart) {
return { intentMs: null, extractionMs: null }
}
const totalMs = semanticFinishedAt - runStart
const intentMs = Math.max(120, Math.round(totalMs * 0.35))
const extractionMs = Math.max(160, totalMs - intentMs)
return {
intentMs,
extractionMs
}
}
export function resolveToolCallDurationMs(toolCall, index, toolCalls, run) {
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
? toolCall.response_json
: {}
const explicitDuration = resolveDurationFromFields(toolCall)
|| resolveTimeRangeDurationMs(toolCall)
|| resolveDurationFromFields(response)
|| resolveTimeRangeDurationMs(response)
if (explicitDuration) {
return explicitDuration
}
const startedAt = resolveStartedTimestamp(toolCall)
if (!startedAt) {
return null
}
const nextStartedAt = resolveStartedTimestamp(toolCalls[index + 1])
const runFinishedAt = resolveFinishedTimestamp(run)
const finishedAt = nextStartedAt > startedAt ? nextStartedAt : (runFinishedAt > startedAt ? runFinishedAt : 0)
if (!finishedAt || finishedAt <= startedAt) {
return null
}
return finishedAt - startedAt
}
export function summarizeVisibleToolText(value) {
const text = String(value || '')
.replace(/\|[^\n]*\|/g, '')
.replace(/\*\*/g, '')
.split('\n')
.map((line) => line.trim())
.find(Boolean) || ''
if (!text) {
return ''
}
return text.length > 80 ? `${text.slice(0, 80)}...` : text
}

View File

@@ -0,0 +1,180 @@
import { summarizeVisibleToolText } from './travelReimbursementFlowTiming.js'
export function isSubmittedApplicationPayload(payload) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const draftPayload = result.draft_payload && typeof result.draft_payload === 'object'
? result.draft_payload
: payload?.draft_payload && typeof payload.draft_payload === 'object'
? payload.draft_payload
: null
return Boolean(
draftPayload
&& String(draftPayload.draft_type || '').trim() === 'expense_application'
&& String(draftPayload.status || '').trim() === 'submitted'
)
}
export function isDuplicateApplicationPayload(payload, { applicationSessionActive = false } = {}) {
if (!applicationSessionActive) {
return false
}
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const answer = String(result.answer || result.message || '').trim()
return answer.includes('已存在申请单') && answer.includes('系统没有重复创建')
}
export function buildApplicationSubmitSuccessDetail(payload) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const draftPayload = result.draft_payload && typeof result.draft_payload === 'object'
? result.draft_payload
: {}
const claimNo = String(draftPayload.claim_no || '').trim()
const approvalStage = String(draftPayload.approval_stage || '').trim() || '直属领导审批'
return claimNo
? `申请单 ${claimNo} 已提交成功,当前节点:${approvalStage}`
: `申请单提交成功,当前节点:${approvalStage}`
}
export function buildApplicationDuplicateDetail(payload) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const answer = String(result.answer || result.message || '').trim()
const claimNo = answer.match(/A[A-HJ-NP-Z2-9]{8}|AP-\d{14}-[A-HJ-NP-Z2-9]{8}|APP-\d{8}-[A-Z0-9]{6}/)?.[0] || ''
return claimNo
? `已拦截重复申请,已有申请单:${claimNo}`
: '已拦截重复申请,未创建新申请单'
}
export function isSavedReimbursementDraftPayload(payload) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const draftPayload = result.draft_payload && typeof result.draft_payload === 'object'
? result.draft_payload
: payload?.draft_payload && typeof payload.draft_payload === 'object'
? payload.draft_payload
: null
return Boolean(
draftPayload
&& String(draftPayload.status || '').trim() === 'draft'
&& String(draftPayload.draft_type || '').trim() !== 'expense_application'
)
}
export function summarizeDraftRiskReviewDetail(payload) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const reviewPayload = result.review_payload && typeof result.review_payload === 'object'
? result.review_payload
: {}
const riskCount = Array.isArray(reviewPayload.risk_briefs)
? reviewPayload.risk_briefs.length
: Array.isArray(result.risk_flags)
? result.risk_flags.length
: 0
const missingCount = Array.isArray(reviewPayload.missing_slots)
? reviewPayload.missing_slots.length
: 0
const issueParts = []
if (riskCount) {
issueParts.push(`${riskCount} 条风险/异常提醒`)
}
if (missingCount) {
issueParts.push(`${missingCount} 项待补充信息`)
}
if (issueParts.length) {
return `已完成草稿规则校验,识别到 ${issueParts.join('、')},可进入详情核对后继续提交。`
}
return '已完成草稿规则校验,暂未发现明确风险;可继续上传票据或进入详情核对。'
}
function shouldHideToolCall(toolCall) {
const toolType = String(toolCall?.tool_type || '').toLowerCase()
const toolName = String(toolCall?.tool_name || '').toLowerCase()
return (
toolName.includes('semantic_ontology')
|| toolName.includes('ontology.')
|| toolType.includes('semantic_ontology')
|| toolType.includes('ontology')
)
}
export function resolveToolCallFlowMeta(toolCall, index, { applicationSessionActive = false } = {}) {
if (shouldHideToolCall(toolCall)) {
return null
}
const toolType = String(toolCall?.tool_type || '').toLowerCase()
const toolName = String(toolCall?.tool_name || '').toLowerCase()
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
? toolCall.response_json
: {}
const responseMessage = String(response.message || '').trim()
const key = `tool-${toolCall?.id || `${index}-${toolType}-${toolName}`}`
if (
applicationSessionActive
&& (
String(response.status || '').trim() === 'submitted'
|| String(response?.draft_payload?.status || '').trim() === 'submitted'
)
) {
return { key: 'application-submit-success', title: '申请单提交成功', tool: 'ApplicationSubmit' }
}
if (toolType.includes('rule')) {
return { key, title: '规则引擎校验', tool: toolCall?.tool_name || 'RuleEngine' }
}
if (toolType.includes('mcp')) {
return toolName.includes('standard')
? { key, title: '差旅补助标准查询', tool: 'TravelStandard' }
: null
}
if (toolName.includes('knowledge')) {
return { key, title: '知识库检索', tool: toolCall?.tool_name || 'KnowledgeSearch' }
}
if (toolName.includes('application_review_preview')) {
return { key: 'application-review-preview', title: '申请信息核对', tool: 'ApplicationReview' }
}
if (toolName.includes('expense_review_preview') || response.preview_only) {
return { key: 'expense-review-preview', title: '报销信息核对', tool: 'ExpenseReview' }
}
if (toolName.includes('expense_claim') || toolName.includes('save_or_submit')) {
if (
response.submission_blocked ||
String(response.status || '').trim() === 'submitted' ||
responseMessage.includes('AI预审') ||
responseMessage.includes('自动检测') ||
responseMessage.includes('审批')
) {
return { key: 'pre-submit-review', title: '自动检测与风险识别', tool: 'ExpenseClaimService.submit_claim' }
}
if (responseMessage.includes('关联')) {
return { key: 'attachment-association', title: '票据关联草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
}
if (responseMessage.includes('新建')) {
return { key: 'expense-claim-draft', title: '新建报销草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
}
return { key: 'expense-claim-draft', title: '保存报销草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
}
return null
}
export function summarizeFlowToolCall(toolCall, { applicationSessionActive = false } = {}) {
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
? toolCall.response_json
: {}
const toolName = String(toolCall?.tool_name || '').toLowerCase()
if (toolName.includes('application_review_preview')) {
return '已整理申请核对信息'
}
if (toolName.includes('expense_review_preview') || response.preview_only) {
return '已整理报销核对信息'
}
if (String(response.status || '').trim() === 'submitted') {
return applicationSessionActive
? '申请单提交成功'
: `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
}
if (response.submission_blocked) {
return summarizeVisibleToolText(response.message) || '自动检测发现待补充项,暂未提交审批'
}
return (
summarizeVisibleToolText(response.message || response.summary || response.result_summary)
|| String(toolCall?.tool_name || '').trim()
|| '工具调用完成'
)
}

View File

@@ -0,0 +1,722 @@
import { REVIEW_SLOT_CONFIG } from './travelReimbursementReviewConstants.js'
import {
formatConfidenceLabel,
resolveExpenseTypeLabel
} from './travelReimbursementReviewDocuments.js'
import {
buildReviewSlotMap,
formatAmountDisplay,
inferPresetSceneFromReview,
parseAmountNumber,
resolveExpenseTypeCode,
resolveReviewExtraMissingLabels,
resolveReviewMissingSlotCards,
resolveReviewRecognizedSlotCards
} from './travelReimbursementReviewFormModel.js'
function resolvePresentationRiskBriefs(reviewPayload, riskBriefResolver = null) {
if (typeof riskBriefResolver === 'function') {
return riskBriefResolver(reviewPayload)
}
return Array.isArray(reviewPayload?.risk_briefs) ? reviewPayload.risk_briefs : []
}
export function buildClientTimeContext() {
const now = new Date()
const locale =
typeof navigator !== 'undefined' && typeof navigator.language === 'string'
? navigator.language
: 'zh-CN'
return {
client_now_iso: now.toISOString(),
client_timezone_offset_minutes: now.getTimezoneOffset(),
client_locale: locale
}
}
export function formatDraftApplyTime(date = new Date()) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
export function formatDateInputValue(date = new Date()) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export function buildDraftSavedPayload({
draftPayload,
reviewPayload,
inlineState,
linkedRequest,
currentUser,
riskItems = []
}) {
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
const missingItems = resolveReviewMissingSlotCards(reviewPayload)
const typeCode = resolveExpenseTypeCode(inlineState?.expense_type)
const amountNumber = parseAmountNumber(inlineState?.amount)
const location = String(inlineState?.location || linkedRequest?.city || '').trim()
const customerName = String(inlineState?.customer_name || '').trim()
const typeLabel = String(inlineState?.expense_type || linkedRequest?.typeLabel || resolveExpenseTypeLabel(typeCode)).trim()
const title =
String(inlineState?.reason_value || '').trim()
|| String(inlineState?.scene_label || '').trim()
|| String(draftPayload?.title || '').trim()
|| `${typeLabel}报销草稿`
const sceneLabel =
String(inlineState?.scene_label || summarizeReviewScene(title, typeLabel, reviewPayload)).trim() || typeLabel
const attachmentSummary = documents.length
? `${documents.length} 条识别票据 / ${documents.length} 份材料`
: String(inlineState?.attachment_names || '').trim()
? '1 条识别票据 / 1 份材料'
: '待上传票据'
return {
claimId: String(draftPayload?.claim_id || '').trim(),
claimNo: String(draftPayload?.claim_no || '').trim(),
status: String(draftPayload?.status || '').trim(),
approvalStage: String(draftPayload?.approval_stage || '').trim(),
person: String(currentUser?.name || '').trim() || '当前用户',
dept: String(currentUser?.department || currentUser?.departmentName || '').trim() || '待补充部门',
entity: String(linkedRequest?.entity || '').trim() || 'Northstar China Ltd.',
typeCode,
typeLabel,
detailVariant: typeCode === 'travel' ? 'travel' : 'general',
title,
sceneLabel,
sceneTarget: location || customerName || '待补充',
location,
relatedCustomer: customerName,
occurredDisplay: String(inlineState?.occurred_date || '').trim() || '待补充',
applyTime: formatDraftApplyTime(),
amount: amountNumber === null ? 0 : amountNumber,
secondaryStatusLabel: typeCode === 'travel' ? '行程状态' : '票据状态',
secondaryStatusValue: documents.length ? '待继续完善' : '待上传票据',
secondaryStatusTone: documents.length ? 'warning' : 'neutral',
riskSummary: riskItems[0]?.summary || (missingItems.length ? '仍有识别字段待补充,请继续完善。' : 'AI 已生成草稿,可继续补充后提交。'),
attachmentSummary,
expenseTableSummary: documents.length
? `已关联 ${documents.length} 份票据,请继续在报销页补充和确认`
: '当前尚未上传票据,请在报销页继续补充附件',
note: String(draftPayload?.status || '').trim() === 'submitted'
? '该报销单已由 AI 工作台提交审批,可在个人报销页面持续跟踪进度。'
: '该草稿由 AI 工作台根据当前识别结果生成,可在个人报销页面继续补充明细、票据与说明。'
}
}
export function countReviewPendingItems(reviewPayload) {
return resolveReviewMissingSlotCards(reviewPayload).length + resolveReviewExtraMissingLabels(reviewPayload).length
}
export function countReviewRiskItems(reviewPayload, riskBriefResolver = null) {
return resolvePresentationRiskBriefs(reviewPayload, riskBriefResolver).length
}
export function buildReviewHeadline(reviewPayload) {
if (countReviewPendingItems(reviewPayload)) {
return '待补充信息'
}
if (reviewPayload?.can_proceed) {
return '识别结果已整理完成'
}
return '识别结果摘要'
}
export function buildReviewSubline(reviewPayload) {
const pendingCount = countReviewPendingItems(reviewPayload)
if (pendingCount) {
return `我已把 ${pendingCount} 项待补充内容整理成文字说明,请先核查。`
}
if (reviewPayload?.can_proceed) {
return '当前关键信息已基本齐全,确认无误后可以继续下一步。'
}
return '已为您整理本轮识别结果,请核查当前识别摘要。'
}
export function buildReviewStateLabel(reviewPayload) {
const pendingCount = countReviewPendingItems(reviewPayload)
if (pendingCount) return `待补充 ${pendingCount}`
if (reviewPayload?.can_proceed) return '可继续处理'
return '已识别'
}
export function buildReviewStateTone(reviewPayload) {
return reviewPayload?.can_proceed && !countReviewPendingItems(reviewPayload)
? 'ready'
: 'pending'
}
export function buildReviewDisclosureTitle(reviewPayload) {
const pendingCount = countReviewPendingItems(reviewPayload)
if (pendingCount) {
return `当前有 ${pendingCount} 项待补充,点击展开查看`
}
return '当前信息已齐全,可展开查看识别摘要'
}
export function buildReviewDisclosureHint(reviewPayload) {
const pendingCount = countReviewPendingItems(reviewPayload)
if (pendingCount) {
return '展开后可查看待补充字段和处理建议'
}
return '展开后可查看本轮已识别的关键信息'
}
export function shouldOpenReviewDisclosure(reviewPayload) {
return !countReviewPendingItems(reviewPayload)
}
export function buildReviewTodoSectionTitle(reviewPayload) {
return countReviewPendingItems(reviewPayload) ? '待补充内容' : '已识别信息'
}
export function buildReviewTodoSectionMeta(reviewPayload) {
const count = buildReviewTodoItems(reviewPayload).length
if (countReviewPendingItems(reviewPayload)) {
return count ? `${count}` : '待确认'
}
return count ? `${count}` : '已齐全'
}
export function buildReviewAlertLabel(slotKey, expenseTypeLabel = '') {
if (slotKey === 'customer_name') {
return expenseTypeLabel === '业务招待费' ? '业务招待费需补充关联客户' : '缺少关联客户'
}
if (slotKey === 'participants') return '缺少同行人员'
if (slotKey === 'attachments') return '票据状态待补充'
if (slotKey === 'amount') return '金额待确认'
if (slotKey === 'time_range') return '发生时间待确认'
if (slotKey === 'reason') return '场景 / 事由待补充'
if (slotKey === 'expense_type') return '报销类型待确认'
if (slotKey === 'location') return '业务地点待补充'
if (slotKey === 'merchant_name') return '酒店/商户待补充'
return '仍有信息待补充'
}
export function buildReviewAlertChips(reviewPayload, riskBriefResolver = null) {
const slotMap = buildReviewSlotMap(reviewPayload)
const expenseTypeLabel = String(slotMap.expense_type?.value || '').trim()
const chips = []
for (const item of resolveReviewMissingSlotCards(reviewPayload).slice(0, 3)) {
chips.push({
key: item.key,
label: buildReviewAlertLabel(item.key, expenseTypeLabel),
tone: 'warning'
})
}
if (chips.length < 3) {
for (const label of resolveReviewExtraMissingLabels(reviewPayload)) {
chips.push({
key: label,
label,
tone: 'warning'
})
if (chips.length >= 3) break
}
}
if (chips.length < 3) {
for (const risk of resolvePresentationRiskBriefs(reviewPayload, riskBriefResolver)) {
if (chips.some((item) => item.label === risk.title)) continue
chips.push({
key: risk.title,
label: risk.title,
tone: risk.level === 'high' ? 'danger' : 'warning'
})
if (chips.length >= 3) break
}
}
if (!chips.length && reviewPayload?.can_proceed) {
chips.push({
key: 'ready',
label: '当前识别信息已可继续处理',
tone: 'success'
})
}
return chips
}
export function buildReviewTodoItems(reviewPayload) {
const missingItems = resolveReviewMissingSlotCards(reviewPayload)
const extraMissingLabels = resolveReviewExtraMissingLabels(reviewPayload)
if (missingItems.length || extraMissingLabels.length) {
return [
...missingItems.map((item) => {
const config = REVIEW_SLOT_CONFIG[item.key] || {}
return {
key: item.key,
icon: config.icon || 'mdi mdi-form-select',
title: config.title || item.label,
hint: item.hint || config.hint || `请补充${item.label}`,
status: config.status || '待补充',
tone: 'warning'
}
}),
...extraMissingLabels.map((label, index) => ({
key: `extra-missing-${index}-${label}`,
icon: label.includes('酒店') || label.includes('住宿') ? 'mdi mdi-bed-outline' : 'mdi mdi-file-alert-outline',
title: label,
hint: label.includes('必须')
? '该票据属于当前差旅提交的必备材料,补齐后才能继续下一步。'
: '可以继续补充该材料;如暂时没有,也可以按当前信息处理。',
status: label.includes('必须') ? '必须补齐' : '可选补充',
tone: 'warning'
}))
]
}
return resolveReviewRecognizedSlotCards(reviewPayload)
.filter((item) => String(item?.value || '').trim())
.slice(0, 3)
.map((item) => {
const config = REVIEW_SLOT_CONFIG[item.key] || {}
return {
key: item.key,
icon: config.icon || 'mdi mdi-check-circle-outline',
title: config.title || item.label,
hint: `已识别:${item.value}`,
status: '已识别',
tone: 'ready'
}
})
}
const REVIEW_PENDING_HINT_COPY = {
expense_type: '请选择本次报销分类,后续票据会按这个分类继续核对。',
customer_name: '请补充客户单位全称。',
time_range: '请补充业务发生日期或时间范围。',
location: '请补充业务发生地点。',
merchant_name: '请补充酒店或商户名称。',
amount: '请补充本次费用金额。',
reason: '请补充本次费用场景或事由。',
participants: '请至少填写 1 名同行人员。',
attachments: '请上传或关联对应票据附件。'
}
function normalizeReviewFollowupSentence(text) {
const normalized = String(text || '')
.replace(/^已识别[:]\s*/, '')
.replace(/^建议补充\s*/, '请补充')
.replace(/\s+/g, ' ')
.trim()
if (!normalized) return ''
return /[。!?.!?]$/.test(normalized) ? normalized : `${normalized}`
}
function buildReviewPlainFollowupItem(item, pendingMode) {
const key = String(item?.key || '').trim()
const label = String(item?.title || item?.label || '').trim() || '待核查信息'
if (pendingMode) {
return {
key: key || label,
label,
text: normalizeReviewFollowupSentence(REVIEW_PENDING_HINT_COPY[key] || item?.hint || `请补充${label}`)
}
}
const value = normalizeReviewFollowupSentence(item?.hint || '')
return {
key: key || label,
label,
text: value || '已识别,请核查是否准确。'
}
}
const REVIEW_PENDING_SUMMARY_TEMPLATES = [
({ issueSummary }) => `当前还有 ${issueSummary}。请核查对话中的文字说明;如果想先暂存,也可以点击对话文字中的“草稿”。`,
({ issueSummary }) => `我这边看到还有 ${issueSummary},建议先把下方内容核对一下;暂时不处理也没关系,可以点击“草稿”先保存。`,
({ issueSummary }) => `下方还有 ${issueSummary},需要你确认。信息没补齐前可以先核查说明,后续需要暂存时点“草稿”。`,
({ issueSummary }) => `这笔报销还有 ${issueSummary},尚未完全确认。请先看一下下面的补充项;需要中途保存时,可以点“草稿”。`,
({ issueSummary }) => `目前还有 ${issueSummary}。你可以先按下面的提示补充,也可以稍后再处理,点击“草稿”即可暂存当前信息。`,
({ issueSummary }) => `还有 ${issueSummary},建议先核对下面说明;如果票据或金额暂时不全,可以通过“草稿”保留当前进度。`,
({ issueSummary }) => `这次识别结果里还有 ${issueSummary}。请重点看下面几项,暂不提交时可以点“草稿”保存。`,
({ issueSummary }) => `我还需要你确认 ${issueSummary}。下面列出了具体内容;如果现在不方便补齐,可以先点“草稿”。`,
({ issueSummary }) => `当前还有 ${issueSummary},需要进一步处理。请根据下面提示核查,待补充完再继续;临时保存可点击“草稿”。`,
({ issueSummary }) => `本次报销还有 ${issueSummary},请先检查下面的补充项;想先留存当前识别结果时可以点“草稿”。`
]
const REVIEW_SAVED_DRAFT_PENDING_SUMMARY_TEMPLATES = [
({ issueSummary }) => `当前还有 ${issueSummary}。草稿已保存,后续上传票据时请关联这张草稿,补齐后再继续提交审批。`,
({ issueSummary }) => `这张草稿仍有 ${issueSummary} 需要补充。您可以继续上传或关联票据,系统会归集到已保存草稿中。`,
({ issueSummary }) => `草稿已生成,当前还差 ${issueSummary}。请按下方提示补充字段或票据,完整后再进入下一步。`,
({ issueSummary }) => `草稿已经留存,下面还有 ${issueSummary} 待处理。新增附件请关联当前草稿,避免重复建单。`,
({ issueSummary }) => `当前草稿还有 ${issueSummary}。建议先补齐金额、票据等信息,再从草稿详情继续提交审批。`,
({ issueSummary }) => `已保留当前进度,这笔草稿还需要 ${issueSummary}。后续补充内容会作为该草稿的更新处理。`,
({ issueSummary }) => `这张单据已进入草稿状态,仍有 ${issueSummary}。请继续补充必要信息,补齐后再发起正式提交。`,
({ issueSummary }) => `草稿保存完成后,当前还剩 ${issueSummary}。上传附件时请选择关联这张草稿,系统会继续合并识别结果。`,
({ issueSummary }) => `当前草稿待完善:${issueSummary}。请先处理下方项目,确认完整后再继续下一步。`,
({ issueSummary }) => `这笔草稿还存在 ${issueSummary}。可以继续补充票据和字段,系统会围绕已保存草稿继续更新。`
]
function buildStableTemplateIndex(signature, total) {
const source = String(signature || '')
let hash = 0
for (let index = 0; index < source.length; index += 1) {
hash = ((hash << 5) - hash + source.charCodeAt(index)) >>> 0
}
return total ? hash % total : 0
}
function buildReviewPendingSummary(pendingCount, riskCount, signature = '', options = {}) {
const issueParts = []
if (pendingCount) {
issueParts.push(`${pendingCount} 项信息待补充`)
}
if (riskCount) {
issueParts.push(`${riskCount} 条风险提醒`)
}
const issueSummary = issueParts.length ? issueParts.join('、') : '一些细节还需要进一步确认'
const templates = options.savedDraft
? REVIEW_SAVED_DRAFT_PENDING_SUMMARY_TEMPLATES
: REVIEW_PENDING_SUMMARY_TEMPLATES
const templateIndex = buildStableTemplateIndex(signature || issueSummary, templates.length)
return templates[templateIndex]({ issueSummary })
}
export function buildReviewPlainFollowupCopy(reviewPayload, options = {}) {
const savedDraft = Boolean(options?.savedDraft)
const todoItems = buildReviewTodoItems(reviewPayload)
const pendingCount = countReviewPendingItems(reviewPayload)
const riskBriefs = resolvePresentationRiskBriefs(reviewPayload)
const extraMissingCount = resolveReviewExtraMissingLabels(reviewPayload).length
if (savedDraft) {
const issueParts = []
if (riskBriefs.length) {
issueParts.push(`${riskBriefs.length} 条风险/异常提醒`)
}
if (pendingCount || extraMissingCount) {
issueParts.push(`${pendingCount || extraMissingCount} 项待补充信息`)
}
return {
lead: '后续处理:',
tone: riskBriefs.length || pendingCount || extraMissingCount ? 'danger' : 'neutral',
summary: issueParts.length
? `自动检测识别到 ${issueParts.join('、')},请进入详情核对;如还有票据可继续上传。`
: '自动检测暂未发现明确风险;如还有票据可继续上传。',
items: [],
notes: []
}
}
if (pendingCount || extraMissingCount) {
const summarySignature = [
pendingCount || extraMissingCount,
riskBriefs.length,
...todoItems.map((item) => `${item.key}:${item.title}:${item.status}`)
].join('|')
return {
lead: '补充信息:',
tone: 'danger',
summary: buildReviewPendingSummary(pendingCount || extraMissingCount, riskBriefs.length, summarySignature, {
savedDraft
}),
items: todoItems.map((item) => buildReviewPlainFollowupItem(item, true)),
notes: []
}
}
return {
lead: todoItems.length ? '我已整理出当前识别到的关键信息:' : '当前关键信息已基本整理完成。',
tone: 'neutral',
summary: '',
items: todoItems.map((item) => buildReviewPlainFollowupItem(item, false)),
notes: [
reviewPayload?.can_proceed ? '确认无误后,可以继续下一步。' : '',
riskBriefs.length ? `系统同时保留了 ${riskBriefs.length} 条风险提醒,请在提交前核查。` : ''
].filter(Boolean)
}
}
export function resolveReviewPrimaryAction(reviewPayload) {
return (
(Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).find(
(item) => item.emphasis === 'primary' || ['save_draft', 'next_step'].includes(String(item?.action_type || ''))
) || null
)
}
export function resolveReviewSaveDraftAction(reviewPayload) {
return (
(Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).find(
(item) => String(item?.action_type || '') === 'save_draft'
) || null
)
}
export function resolveReviewFooterActions(reviewPayload) {
return (Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).filter((item) => {
const actionType = String(item?.action_type || '').trim()
return ['link_to_existing_draft', 'create_new_claim_from_documents'].includes(actionType)
})
}
export function buildReviewRiskLevelCounts(reviewPayload) {
return (Array.isArray(reviewPayload?.risk_briefs) ? reviewPayload.risk_briefs : []).reduce(
(counts, item) => {
const level = normalizeReviewRiskLevel(item?.level)
if (level === 'high' || level === 'medium' || level === 'low') {
counts[level] += 1
}
return counts
},
{ low: 0, medium: 0, high: 0 }
)
}
export function resolveReviewNextStepAction(reviewPayload) {
return (
(Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).find(
(item) => String(item?.action_type || '').trim() === 'next_step'
) || null
)
}
export function buildReviewNextStepRichCopy(reviewPayload, { detailHref = '' } = {}) {
const nextStepAction = resolveReviewNextStepAction(reviewPayload)
if (!nextStepAction) {
return ''
}
const counts = buildReviewRiskLevelCounts(reviewPayload)
const riskSummary = `现存在 ${counts.low} 条低风险,${counts.medium} 条中风险,${counts.high} 条高风险,具体情况请看 [右侧](#review-risk-panel) 风险信息提示窗。`
const lines = [`系统识别您的单据已经填写完所有已知信息,${riskSummary}`]
if (reviewPayload?.can_proceed && counts.medium === 0 && counts.high === 0) {
const editHref = String(detailHref || '').trim() || '#review-quick-edit'
lines.push(
`系统确认您可以 [继续下一步](#review-next-step) 进行单据的提交,如果您确认信息无误,请点击富文本按钮;如果你还需要继续修改信息,请点击 [快速修改单据信息](${editHref})。`
)
}
return lines.join('\n\n')
}
export function buildReviewPrimaryButtonLabel(reviewPayload, draftPayload) {
const action = resolveReviewPrimaryAction(reviewPayload)
if (!action) return '确认'
if (action.action_type === 'save_draft') {
return draftPayload?.claim_no ? '保存为草稿' : '保存为草稿'
}
if (action.action_type === 'next_step') {
return '继续下一步'
}
if (action.action_type === 'link_to_existing_draft') {
return action.label || '关联到现有草稿'
}
if (action.action_type === 'create_new_claim_from_documents') {
return action.label || '单独建立报销单'
}
return action.label || '确认'
}
export function buildReviewIntentText(reviewPayload) {
const slotMap = buildReviewSlotMap(reviewPayload)
const expenseType = String(slotMap.expense_type?.value || '').trim()
if (expenseType) {
return `报销一笔${expenseType}`
}
return '发起一笔报销'
}
export function buildReviewSceneValue(reviewPayload) {
const slotMap = buildReviewSlotMap(reviewPayload)
const reason = String(slotMap.reason?.raw_value || slotMap.reason?.value || '').trim()
const expenseType = String(slotMap.expense_type?.value || slotMap.expense_type?.normalized_value || '').trim()
return inferPresetSceneFromReview(reviewPayload, reason, expenseType)
}
export function buildMissingRiskLine(slotKey, expenseTypeLabel = '') {
if (slotKey === 'customer_name') {
return expenseTypeLabel === '业务招待费'
? '业务招待费需补充客户单位名称,以便进行合规校验。'
: '当前仍缺少客户单位名称,建议补充后再提交。'
}
if (slotKey === 'participants') {
return '缺少同行人员信息,建议补充至少 1 名。'
}
if (slotKey === 'attachments') {
return '尚未上传票据附件,当前无法完成票据核对。'
}
if (slotKey === 'amount') {
return '报销金额仍待确认,提交前需补齐金额信息。'
}
if (slotKey === 'time_range') {
return '业务发生时间仍待确认,建议补充准确日期。'
}
if (slotKey === 'reason') {
return '报销事由说明仍不完整,建议补充业务背景。'
}
return '当前仍有识别信息待补充,建议先核对后再处理。'
}
export function buildReviewRiskSummary(reviewPayload, riskBriefResolver = null) {
if (resolvePresentationRiskBriefs(reviewPayload, riskBriefResolver).length) {
return '当前识别到了风险提示,点击任一风险点会在主对话中展开规则依据和整改建议。'
}
return '当前没有需要额外处理的结构化风险点。'
}
export function normalizeReviewRiskLevel(level) {
const normalized = String(level || '').trim().toLowerCase()
if (normalized === 'danger' || normalized === 'error' || normalized === 'critical') return 'high'
if (normalized === 'warn' || normalized === 'warning' || normalized === 'medium') return 'medium'
if (normalized === 'info' || normalized === 'notice') return 'info'
if (normalized === 'low') return 'low'
if (normalized === 'high') return normalized
return 'low'
}
export function buildLocalReviewCompletionMessage(reviewPayload) {
const missingSlots = Array.isArray(reviewPayload?.missing_slots) ? reviewPayload.missing_slots : []
if (reviewPayload?.can_proceed && !missingSlots.length) {
return '当前所有必填信息已处理完成,可以点击“继续下一步”进入 AI 预审。'
}
if (missingSlots.length) {
return `当前还剩 ${missingSlots.length} 项待补充:${missingSlots.join('、')}`
}
return '当前信息已保存,可以继续核对右侧状态。'
}
export function buildReviewRecognitionNotes(reviewPayload) {
const recognized = resolveReviewRecognizedSlotCards(reviewPayload)
const notes = []
const timeSlot = recognized.find((item) => item.key === 'time_range')
const sourceLabels = [...new Set(recognized.map((item) => String(item?.source_label || '').trim()).filter(Boolean))]
if (timeSlot?.raw_value && timeSlot.raw_value !== timeSlot.value && timeSlot.value) {
notes.push(`时间已按你的本地日期换算:${timeSlot.raw_value} -> ${timeSlot.value}`)
}
if (sourceLabels.length) {
notes.push(`本轮主要依据:${sourceLabels.join('、')}`)
}
const documentCards = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
if (documentCards.length) {
notes.push(`已关联 ${documentCards.length} 份附件,逐张识别结果已整理在下方`)
} else {
notes.push('当前还没有上传票据,这一轮主要依据你的文字描述完成初步识别')
}
return notes
}
export function buildReviewMissingHint(reviewPayload) {
if (!countReviewPendingItems(reviewPayload)) {
return ''
}
if (reviewPayload?.can_proceed) {
return '当前关键信息已经齐全,这里无需再补充。'
}
return '下面这些字段还需要你再确认或补齐,补完后我就能继续往下处理。'
}
export function buildReviewRiskHint(reviewPayload, riskBriefResolver = null) {
const riskBriefs = resolvePresentationRiskBriefs(reviewPayload, riskBriefResolver)
if (!riskBriefs.length) {
return ''
}
return '这些是我根据当前单据信息、票据识别结果和规则口径给出的风险提示,提交前建议顺手核对一下。'
}
export function buildReviewActionHint(reviewPayload) {
if (reviewPayload?.can_proceed) {
return '如果识别无误,可以继续下一步;如果有偏差,请直接在右侧核对信息中修改。'
}
return '如果现在信息还不完整,可以先保存草稿;识别错了请直接在右侧核对信息中修改。'
}
export function buildReviewStatusTag(reviewPayload) {
const missingCount = countReviewPendingItems(reviewPayload)
if (reviewPayload?.can_proceed) {
return '可继续处理'
}
if (missingCount > 0) {
return `待补充 ${missingCount}`
}
return '待确认'
}

View File

@@ -0,0 +1,585 @@
import { TRANSPORT_KEYWORD_PATTERN } from '../../utils/reimbursementTextInference.js'
import {
EXPENSE_CODE_TO_PRESET_SCENE,
EXPENSE_TYPE_LABELS,
REVIEW_SCENE_OPTIONS,
REVIEW_SCENE_OTHER_OPTION
} from './travelReimbursementReviewConstants.js'
export function cloneReviewEditFields(fields) {
const items = Array.isArray(fields) ? fields : []
return items.map((item) => ({
key: String(item?.key || '').trim(),
label: String(item?.label || '').trim(),
value: String(item?.value || ''),
placeholder: String(item?.placeholder || ''),
required: Boolean(item?.required),
field_type: String(item?.field_type || item?.fieldType || 'text').trim() || 'text',
group: String(item?.group || 'basic').trim() || 'basic'
}))
}
export function buildReviewFormValues(fields) {
return cloneReviewEditFields(fields).reduce((result, item) => {
if (!item.key) {
return result
}
result[item.key] = String(item.value || '').trim()
return result
}, {})
}
const ONTOLOGY_REVIEW_FIELD_ALIASES = {
expense_type: ['reimbursement_type', 'scene_label', 'expenseType'],
time_range: ['business_time', 'businessTime', 'occurred_date', 'occurredDate'],
location: ['business_location', 'businessLocation'],
reason: ['reason_value', 'reasonValue', 'business_reason', 'businessReason'],
transport_mode: ['transport_type', 'transportType', 'transportMode', 'application_transport_mode', 'applicationTransportMode'],
attachments: ['attachment_names', 'attachmentNames'],
customer_name: ['customerName'],
merchant_name: ['merchantName']
}
const ONTOLOGY_REVIEW_CONTEXT_FIELDS = new Set([
'expense_type',
'time_range',
'location',
'reason',
'amount',
'transport_mode',
'attachments',
'customer_name',
'merchant_name',
'participants',
'application_claim_id',
'application_claim_no',
'application_reason',
'application_location',
'application_amount',
'application_amount_label',
'application_business_time',
'application_days',
'application_transport_mode',
'application_lodging_daily_cap',
'application_subsidy_daily_cap',
'application_transport_policy',
'application_policy_estimate',
'application_rule_name',
'application_rule_version',
'application_date'
])
export function normalizeReviewFormValuesToOntology(values = {}) {
const source = values && typeof values === 'object' ? values : {}
const normalized = {}
Object.entries(source).forEach(([key, value]) => {
const cleanedKey = String(key || '').trim()
if (!cleanedKey) return
normalized[cleanedKey] = String(value || '').trim()
})
Object.entries(ONTOLOGY_REVIEW_FIELD_ALIASES).forEach(([canonicalKey, aliases]) => {
if (normalized[canonicalKey]) return
const matchedAlias = aliases.find((alias) => normalized[alias])
if (matchedAlias) {
normalized[canonicalKey] = normalized[matchedAlias]
}
})
return Object.fromEntries(
Object.entries(normalized).filter(([key, value]) => ONTOLOGY_REVIEW_CONTEXT_FIELDS.has(key) && String(value || '').trim())
)
}
export function buildBusinessTimeContextFromReviewValues(values = {}) {
const timeText = String(values.time_range || values.business_time || values.occurred_date || '').trim()
if (!timeText) {
return null
}
const matchedDates = timeText.match(/\d{4}-\d{2}-\d{2}/g) || []
if (!matchedDates.length) {
return null
}
const startDate = matchedDates[0]
const endDate = matchedDates[matchedDates.length - 1] || startDate
if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) {
return null
}
const displayValue = startDate === endDate ? startDate : `${startDate}${endDate}`
return {
mode: startDate === endDate ? 'single' : 'range',
start_date: startDate,
end_date: endDate,
display_value: displayValue
}
}
export function buildReviewFormContextFromPayload(reviewPayload, inlineState = null) {
if (!reviewPayload || typeof reviewPayload !== 'object') {
return {}
}
const fallbackState = buildInlineReviewState(reviewPayload)
const candidateState = inlineState || fallbackState
const hasCandidateValue = Object.values(candidateState || {}).some((value) => {
if (typeof value === 'number') return value > 0
return Boolean(String(value || '').trim())
})
const state = hasCandidateValue ? candidateState : fallbackState
const fields = mergeInlineReviewFields(reviewPayload.edit_fields || [], state)
const values = buildReviewFormValues(fields)
const slotMap = buildReviewSlotMap(reviewPayload)
const inheritedTimeRange = String(
slotMap.time_range?.normalized_value ||
slotMap.time_range?.value ||
values.time_range ||
values.business_time ||
values.occurred_date ||
''
).trim()
if (inheritedTimeRange) {
values.time_range = values.time_range || inheritedTimeRange
}
const ontologyValues = normalizeReviewFormValuesToOntology(values)
const businessTimeContext = buildBusinessTimeContextFromReviewValues(ontologyValues)
return {
review_form_values: ontologyValues,
...(businessTimeContext ? { business_time_context: businessTimeContext } : {})
}
}
export function buildReviewEditFieldMap(fields) {
return cloneReviewEditFields(fields).reduce((result, item) => {
if (!item.key) return result
result[item.key] = item
return result
}, {})
}
export function createEmptyInlineReviewState() {
return {
occurred_date: '',
amount: '',
transport_type: '',
scene_label: '',
reason_value: '',
customer_name: '',
location: '',
merchant_name: '',
participants: '',
attachment_names: '',
attachment_count: 0,
pending_attachment_count: 0,
expense_type: ''
}
}
export function isTravelReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) {
const expenseType = resolveExpenseTypeCode(
inlineState?.expense_type ||
buildReviewSlotMap(reviewPayload).expense_type?.normalized_value ||
buildReviewSlotMap(reviewPayload).expense_type?.value ||
''
)
if (['travel', 'hotel'].includes(expenseType)) {
return true
}
return (Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []).some((item) => {
const documentType = String(item?.document_type || '').trim().toLowerCase()
const suggestedType = resolveExpenseTypeCode(item?.suggested_expense_type || item?.scene_label || '')
return (
['flight_itinerary', 'train_ticket', 'hotel_invoice'].includes(documentType) ||
['travel', 'hotel'].includes(suggestedType)
)
})
}
export function resolveReviewTravelTransportType(reviewPayload, fallbackText = '') {
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
const labels = []
const appendLabel = (label) => {
if (label && !labels.includes(label)) {
labels.push(label)
}
}
for (const item of documents) {
const documentType = String(item?.document_type || '').trim().toLowerCase()
const text = [
item?.filename,
item?.summary,
item?.scene_label,
item?.suggested_expense_type,
...(Array.isArray(item?.fields) ? item.fields.map((field) => `${field?.label || ''}${field?.value || ''}`) : [])
].join(' ')
const compact = text.replace(/\s+/g, '')
if (documentType === 'flight_itinerary' || /飞机|机票|航班|登机牌/.test(compact)) {
appendLabel('飞机')
} else if (documentType === 'train_ticket' || /火车|高铁|动车|铁路|车票/.test(compact)) {
appendLabel('火车/高铁')
} else if (documentType === 'taxi_receipt' || /打车|网约车|出租车|滴滴|的士/.test(compact)) {
appendLabel('打车/网约车')
}
}
const fallback = String(fallbackText || '').replace(/\s+/g, '')
if (!labels.length) {
if (/飞机|机票|航班/.test(fallback)) appendLabel('飞机')
if (/火车|高铁|动车|铁路/.test(fallback)) appendLabel('火车/高铁')
if (/打车|网约车|出租车|滴滴|的士/.test(fallback)) appendLabel('打车/网约车')
}
return labels.join('、')
}
export function resolveReviewRecognizedSlotCards(reviewPayload) {
return Array.isArray(reviewPayload?.slot_cards)
? reviewPayload.slot_cards.filter((item) => item.status !== 'missing')
: []
}
export function resolveReviewMissingSlotCards(reviewPayload) {
return Array.isArray(reviewPayload?.slot_cards)
? reviewPayload.slot_cards.filter((item) => item.status === 'missing')
: []
}
export function resolveReviewExtraMissingLabels(reviewPayload) {
const labels = Array.isArray(reviewPayload?.missing_slots)
? reviewPayload.missing_slots
.map((item) => {
if (item && typeof item === 'object') {
return String(item.label || item.title || item.key || '').trim()
}
return String(item || '').trim()
})
.filter(Boolean)
: []
if (!labels.length) return []
const slotLabels = new Set(
(Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards : []).flatMap((item) => [
String(item?.label || '').trim(),
String(item?.key || '').trim()
]).filter(Boolean)
)
return labels.filter((label) => !slotLabels.has(label))
}
export function buildReviewRecognizedLines(reviewPayload) {
return resolveReviewRecognizedSlotCards(reviewPayload)
.filter((item) => String(item?.value || '').trim())
.map((item) => `${item.label}${item.value}`)
}
export function buildReviewSlotMap(reviewPayload) {
return Object.fromEntries(
(Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards : []).map((item) => [item.key, item])
)
}
export function resolveExpenseTypeCode(value) {
const normalized = String(value || '').trim()
if (!normalized) return 'other'
if (EXPENSE_TYPE_LABELS[normalized]) return normalized
const matched = Object.entries(EXPENSE_TYPE_LABELS).find(([, label]) => label === normalized)
return matched?.[0] || 'other'
}
export function isValidIsoDateString(value) {
const normalized = String(value || '').trim()
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
return false
}
const [yearText, monthText, dayText] = normalized.split('-')
const year = Number(yearText)
const month = Number(monthText)
const day = Number(dayText)
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
return false
}
const candidate = new Date(Date.UTC(year, month - 1, day))
return (
candidate.getUTCFullYear() === year &&
candidate.getUTCMonth() === month - 1 &&
candidate.getUTCDate() === day
)
}
export function parseAmountNumber(value) {
const normalized = String(value || '')
.replace(/[,\s]/g, '')
.replace(/[¥¥]/g, '')
.replace(/元/g, '')
.trim()
if (!normalized || !/^\d+(?:\.\d+)?$/.test(normalized)) {
return null
}
const amount = Number(normalized)
return Number.isFinite(amount) ? amount : null
}
export function normalizeAmountValue(value) {
const amount = parseAmountNumber(value)
if (amount === null) {
return ''
}
return Number.isInteger(amount) ? `${amount}` : `${amount.toFixed(2).replace(/\.?0+$/, '')}`
}
export function extractAmountInputValue(value) {
const amount = parseAmountNumber(value)
if (amount === null) {
return String(value || '').trim()
}
return Number.isInteger(amount) ? String(amount) : amount.toFixed(2).replace(/\.?0+$/, '')
}
export function formatAmountDisplay(value) {
const amount = parseAmountNumber(value)
if (amount === null) {
return String(value || '').trim()
}
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: Number.isInteger(amount) ? 0 : 2,
maximumFractionDigits: Number.isInteger(amount) ? 0 : 2
}).format(amount)
}
export function matchPresetSceneFromReason(reason) {
const compactReason = String(reason || '').trim().replace(/\s+/g, '')
if (!compactReason) {
return ''
}
if (/请客户.*吃饭|客户.*吃饭|招待|宴请|接待|客户接待/.test(compactReason)) {
return '请客户吃饭'
}
if (/出差行程|住宿报销|交通出行|会务活动/.test(compactReason)) {
const matchedPreset = REVIEW_SCENE_OPTIONS.find((option) => compactReason.includes(option.replace(/\s+/g, '')))
if (matchedPreset && matchedPreset !== REVIEW_SCENE_OTHER_OPTION) {
return matchedPreset
}
}
if (/出差|差旅/.test(compactReason)) {
return '出差行程'
}
if (/酒店|住宿/.test(compactReason)) {
return '住宿报销'
}
if (TRANSPORT_KEYWORD_PATTERN.test(compactReason)) {
return '交通出行'
}
if (/会务|会议|参会|论坛|展会/.test(compactReason)) {
return '会务活动'
}
return ''
}
export function mapOcrSceneLabelToPresetScene(sceneLabel, suggestedExpenseType = '') {
const fromCode = EXPENSE_CODE_TO_PRESET_SCENE[resolveExpenseTypeCode(suggestedExpenseType)]
if (fromCode) {
return fromCode
}
const compactLabel = String(sceneLabel || '').trim().replace(/\s+/g, '')
if (!compactLabel) {
return ''
}
if (/差旅|出差/.test(compactLabel)) {
return '出差行程'
}
if (/住宿|酒店/.test(compactLabel)) {
return '住宿报销'
}
if (/交通/.test(compactLabel)) {
return '交通出行'
}
if (/招待|餐饮|餐费|伙食/.test(compactLabel)) {
return '请客户吃饭'
}
if (/会务|会议/.test(compactLabel)) {
return '会务活动'
}
return ''
}
export function mapExpenseTypeLabelToPresetScene(expenseType) {
const code = resolveExpenseTypeCode(expenseType)
if (EXPENSE_CODE_TO_PRESET_SCENE[code]) {
return EXPENSE_CODE_TO_PRESET_SCENE[code]
}
const compactLabel = String(expenseType || '').trim().replace(/\s+/g, '')
if (!compactLabel) {
return ''
}
if (compactLabel.includes('差旅') || compactLabel.includes('出差')) {
return '出差行程'
}
if (compactLabel.includes('住宿') || compactLabel.includes('酒店')) {
return '住宿报销'
}
if (compactLabel.includes('交通')) {
return '交通出行'
}
if (compactLabel.includes('招待') || compactLabel.includes('餐饮') || compactLabel.includes('伙食')) {
return '请客户吃饭'
}
if (compactLabel.includes('会务') || compactLabel.includes('会议')) {
return '会务活动'
}
return matchPresetSceneFromReason(expenseType)
}
export function inferPresetSceneFromReview(reviewPayload, reasonValue = '', expenseType = '') {
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
if (documents.length) {
const votes = new Map()
for (const document of documents) {
const preset =
mapOcrSceneLabelToPresetScene(document.scene_label, document.suggested_expense_type)
|| mapExpenseTypeLabelToPresetScene(document.suggested_expense_type)
if (!preset) {
continue
}
votes.set(preset, (votes.get(preset) || 0) + 1)
}
if (votes.size) {
return [...votes.entries()].sort((left, right) => right[1] - left[1])[0][0]
}
}
const claimGroups = Array.isArray(reviewPayload?.claim_groups) ? reviewPayload.claim_groups : []
if (claimGroups.length === 1) {
const group = claimGroups[0]
const preset =
mapExpenseTypeLabelToPresetScene(group.expense_type)
|| mapOcrSceneLabelToPresetScene(group.scene_label, group.expense_type)
if (preset) {
return preset
}
}
const fromReason = matchPresetSceneFromReason(reasonValue)
if (fromReason) {
return fromReason
}
const fromExpenseType = mapExpenseTypeLabelToPresetScene(expenseType)
if (fromExpenseType) {
return fromExpenseType
}
if (String(reasonValue || '').trim()) {
return REVIEW_SCENE_OTHER_OPTION
}
return '待补充'
}
export function formatReviewSceneDisplayValue(inlineState) {
const scene = String(inlineState?.scene_label || '').trim()
if (!scene || scene === '待补充') {
return '待补充'
}
if (scene === REVIEW_SCENE_OTHER_OPTION) {
const detail = String(inlineState?.reason_value || '').trim()
if (!detail) {
return REVIEW_SCENE_OTHER_OPTION
}
return detail.length > 18 ? `${REVIEW_SCENE_OTHER_OPTION}${detail.slice(0, 18)}...` : `${REVIEW_SCENE_OTHER_OPTION}${detail}`
}
return scene
}
export function summarizeReviewScene(reason, expenseType = '', reviewPayload = null) {
return inferPresetSceneFromReview(reviewPayload, reason, expenseType)
}
export function buildInlineReviewState(reviewPayload) {
const slotMap = buildReviewSlotMap(reviewPayload)
const editFieldMap = buildReviewEditFieldMap(reviewPayload?.edit_fields)
const attachmentNames = String(
editFieldMap.attachment_names?.value ||
slotMap.attachments?.value ||
(Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards.map((item) => item.filename).join('、') : '')
).trim()
const attachmentCount = Array.isArray(reviewPayload?.document_cards)
? reviewPayload.document_cards.length
: attachmentNames
? attachmentNames.split('、').filter(Boolean).length
: 0
const expenseType = String(editFieldMap.expense_type?.value || slotMap.expense_type?.value || '').trim()
const reasonValue = String(
editFieldMap.reason?.value || slotMap.reason?.raw_value || slotMap.reason?.value || ''
).trim()
const sceneLabel = inferPresetSceneFromReview(reviewPayload, reasonValue, expenseType)
const transportType = String(
editFieldMap.transport_type?.value || resolveReviewTravelTransportType(reviewPayload, reasonValue)
).trim()
return {
occurred_date: String(
editFieldMap.occurred_date?.value || slotMap.time_range?.normalized_value || slotMap.time_range?.value || ''
).trim(),
amount: normalizeAmountValue(
String(editFieldMap.amount?.value || slotMap.amount?.normalized_value || slotMap.amount?.value || '').trim()
),
transport_type: transportType,
scene_label: sceneLabel,
reason_value:
sceneLabel === REVIEW_SCENE_OTHER_OPTION
? reasonValue
: String(slotMap.reason?.raw_value || '').trim() || reasonValue,
customer_name: String(editFieldMap.customer_name?.value || slotMap.customer_name?.value || '').trim(),
location: String(
editFieldMap.business_location?.value ||
editFieldMap.location?.value ||
slotMap.location?.normalized_value ||
slotMap.location?.value ||
''
).trim(),
merchant_name: String(editFieldMap.merchant_name?.value || slotMap.merchant_name?.value || '').trim(),
participants: String(editFieldMap.participants?.value || slotMap.participants?.value || '').trim(),
attachment_names: attachmentNames,
attachment_count: attachmentCount,
pending_attachment_count: 0,
expense_type: expenseType
}
}
export function mergeInlineReviewFields(baseFields, inlineState) {
const merged = cloneReviewEditFields(baseFields)
const updateMap = {
expense_type: inlineState.expense_type,
transport_type: inlineState.transport_type,
occurred_date: inlineState.occurred_date,
amount: inlineState.amount,
customer_name: inlineState.customer_name,
business_location: inlineState.location,
merchant_name: inlineState.merchant_name,
participants: inlineState.participants,
reason: inlineState.reason_value || inlineState.scene_label,
attachment_names: inlineState.attachment_names
}
for (const item of merged) {
if (!(item.key in updateMap)) continue
item.value = String(updateMap[item.key] || '').trim()
}
return merged
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,352 @@
import {
CATEGORY_CONFIDENCE_KEYWORDS,
REVIEW_CATEGORY_PRESET_OPTIONS
} from './travelReimbursementReviewConstants.js'
import {
buildReviewDocumentCorrectionLines,
formatConfidenceLabel
} from './travelReimbursementReviewDocuments.js'
import {
buildReviewSlotMap,
cloneReviewEditFields,
createEmptyInlineReviewState,
formatAmountDisplay,
mergeInlineReviewFields,
resolveExpenseTypeCode,
resolveReviewExtraMissingLabels
} from './travelReimbursementReviewFormModel.js'
export function buildReviewAttachmentStatus(reviewPayload) {
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
if (!documents.length) return '未上传'
return documents.length === 1 ? '已上传 1 份' : `已上传 ${documents.length}`
}
export function shouldShowReviewFactCard(reviewPayload, slotKey, value = '') {
const slotMap = buildReviewSlotMap(reviewPayload)
const slot = slotMap[slotKey]
return Boolean(String(value || slot?.normalized_value || slot?.value || '').trim()) || slot?.status === 'missing'
}
export function buildReviewEvidenceText(reviewPayload, inlineState = createEmptyInlineReviewState()) {
const slotMap = buildReviewSlotMap(reviewPayload)
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
return [
String(inlineState.reason_value || '').trim(),
String(inlineState.scene_label || '').trim(),
String(slotMap.reason?.value || slotMap.reason?.raw_value || '').trim(),
...documents.map((item) =>
[item.scene_label, item.summary, item.filename, ...(Array.isArray(item.warnings) ? item.warnings : [])]
.filter(Boolean)
.join(' ')
)
]
.filter(Boolean)
.join(' ')
.toLowerCase()
}
export function resolveReviewCategoryTextScore(text, categoryCode) {
const patterns = CATEGORY_CONFIDENCE_KEYWORDS[categoryCode]
if (!patterns?.length || !text) {
return 0
}
return patterns.some((pattern) => pattern.test(text))
? {
travel: 0.84,
hotel: 0.82,
transport: 0.8,
meal: 0.76,
meeting: 0.78,
entertainment: 0.88,
office: 0.74,
training: 0.77,
communication: 0.7,
welfare: 0.72
}[categoryCode] || 0
: 0
}
export function resolveReviewCategoryDocumentScore(reviewPayload, categoryCode) {
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
const matchedScores = documents
.filter((item) => resolveExpenseTypeCode(item?.suggested_expense_type) === categoryCode)
.map((item) => Number(item?.avg_score || 0))
.filter((score) => Number.isFinite(score) && score > 0)
if (!matchedScores.length) {
return 0
}
return matchedScores.reduce((sum, score) => sum + score, 0) / matchedScores.length
}
export function resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel = '', inlineState = createEmptyInlineReviewState()) {
const normalizedLabel = String(selectedLabel || '').trim()
if (!normalizedLabel) {
return 0
}
const selectedCode = resolveExpenseTypeCode(normalizedLabel)
const slotMap = buildReviewSlotMap(reviewPayload)
const expenseSlot = slotMap.expense_type
const recognizedCode = resolveExpenseTypeCode(expenseSlot?.normalized_value || expenseSlot?.value || '')
let score = 0
if (recognizedCode === selectedCode) {
score = Math.max(score, Number(expenseSlot?.confidence || 0))
}
score = Math.max(score, resolveReviewCategoryDocumentScore(reviewPayload, selectedCode))
score = Math.max(score, resolveReviewCategoryTextScore(buildReviewEvidenceText(reviewPayload, inlineState), selectedCode))
if (!score && normalizedLabel) {
score = selectedCode === 'other' ? 0.52 : 0.58
}
return Math.max(0, Math.min(0.98, Number(score.toFixed(2))))
}
export function buildReviewCategoryOptions(reviewPayload, selectedLabel = '', inlineState = createEmptyInlineReviewState()) {
const presetLabels = REVIEW_CATEGORY_PRESET_OPTIONS.filter((item) => !item.is_other).map((item) => item.label)
return REVIEW_CATEGORY_PRESET_OPTIONS.map((item, index) => ({
...item,
active: item.is_other ? Boolean(selectedLabel) && !presetLabels.includes(selectedLabel) : item.label === selectedLabel,
confidenceLabel: item.is_other
? formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel, inlineState))
: formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, item.label, inlineState)),
caption: item.is_other
? selectedLabel && !presetLabels.includes(selectedLabel)
? `${selectedLabel} · ${formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel, inlineState))}`
: '点击选择更多类型'
: `置信度 ${formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, item.label, inlineState))}`,
groupLabel: index === 0 ? '常用' : index < 5 ? '常用' : '更多'
}))
}
export function buildReviewPanelConfidence(reviewPayload, inlineState = createEmptyInlineReviewState()) {
return formatConfidenceLabel(
resolveReviewCategoryConfidenceScore(reviewPayload, inlineState.expense_type, inlineState)
)
}
export function resolveInlineReviewSlotValue(slotKey, inlineState = createEmptyInlineReviewState()) {
const state = inlineState || createEmptyInlineReviewState()
if (slotKey === 'expense_type') return String(state.expense_type || '').trim()
if (slotKey === 'customer_name') return String(state.customer_name || '').trim()
if (slotKey === 'time_range') return String(state.occurred_date || '').trim()
if (slotKey === 'location') return String(state.location || '').trim()
if (slotKey === 'merchant_name') return String(state.merchant_name || '').trim()
if (slotKey === 'amount') return String(state.amount || '').trim()
if (slotKey === 'reason') return String(state.reason_value || state.scene_label || '').trim()
if (slotKey === 'participants') return String(state.participants || '').trim()
if (slotKey === 'attachments') {
return String(state.attachment_names || '').trim()
|| (Number(state.attachment_count || 0) > 0 ? `${Number(state.attachment_count)} 份附件` : '')
|| (Number(state.pending_attachment_count || 0) > 0 ? `${Number(state.pending_attachment_count)} 份待上传附件` : '')
}
return ''
}
export function buildLocallySyncedReviewActions(reviewPayload, canProceed) {
const actions = Array.isArray(reviewPayload?.confirmation_actions)
? reviewPayload.confirmation_actions.map((item) => ({ ...item }))
: []
const actionTypes = new Set(actions.map((item) => String(item?.action_type || '').trim()))
const associationPending = actionTypes.has('link_to_existing_draft') || actionTypes.has('create_new_claim_from_documents')
if (!canProceed || associationPending) {
return actions
}
const syncedActions = actions.filter((item) => String(item?.action_type || '').trim() !== 'next_step')
if (!syncedActions.some((item) => String(item?.action_type || '').trim() === 'save_draft')) {
syncedActions.push({
label: '保存为草稿',
action_type: 'save_draft',
description: '先暂存当前已识别信息,稍后仍可继续补充或提交。',
emphasis: 'secondary'
})
}
return [
...syncedActions,
{
label: '继续下一步',
action_type: 'next_step',
description: '当前信息已齐全,进入 AI 预审、风险校验和审批路径确认。',
emphasis: 'primary'
}
]
}
export function buildLocallySyncedReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) {
if (!reviewPayload || typeof reviewPayload !== 'object') {
return reviewPayload
}
const nextSlotCards = (Array.isArray(reviewPayload.slot_cards) ? reviewPayload.slot_cards : []).map((slot) => {
const value = resolveInlineReviewSlotValue(slot.key, inlineState)
const required = Boolean(slot.required)
const filled = Boolean(value)
return {
...slot,
value: value || slot.value || '',
normalized_value: value || slot.normalized_value || '',
raw_value: value || slot.raw_value || '',
source: filled ? 'user_form' : slot.source,
source_label: filled ? '用户修改' : slot.source_label,
confidence: filled ? Math.max(Number(slot.confidence || 0), 0.98) : Number(slot.confidence || 0),
confirmed: filled || Boolean(slot.confirmed),
status: required && !filled ? 'missing' : filled ? 'identified' : slot.status,
hint: required && !filled ? slot.hint : ''
}
})
const missingSlots = nextSlotCards
.filter((slot) => slot.required && slot.status === 'missing')
.map((slot) => slot.label || slot.key)
const extraMissingSlots = resolveReviewExtraMissingLabels({
...reviewPayload,
slot_cards: nextSlotCards
})
const allMissingSlots = [...missingSlots, ...extraMissingSlots]
const canProceed = allMissingSlots.length === 0 && (Array.isArray(reviewPayload.claim_groups) ? reviewPayload.claim_groups.length > 0 : true)
return {
...reviewPayload,
can_proceed: canProceed,
missing_slots: allMissingSlots,
slot_cards: nextSlotCards,
edit_fields: mergeInlineReviewFields(reviewPayload.edit_fields || [], inlineState),
confirmation_actions: buildLocallySyncedReviewActions(reviewPayload, canProceed)
}
}
export function normalizeInlineReviewComparableState(state) {
const source = state && typeof state === 'object' ? state : {}
return {
occurred_date: String(source.occurred_date || '').trim(),
amount: String(source.amount || '').trim(),
transport_type: String(source.transport_type || '').trim(),
scene_label: String(source.scene_label || '').trim(),
reason_value: String(source.reason_value || '').trim(),
customer_name: String(source.customer_name || '').trim(),
location: String(source.location || '').trim(),
merchant_name: String(source.merchant_name || '').trim(),
participants: String(source.participants || '').trim(),
attachment_names: String(source.attachment_names || '').trim(),
pending_attachment_count: Math.max(0, Number(source.pending_attachment_count || 0)),
expense_type: String(source.expense_type || '').trim()
}
}
export function buildInlineReviewChangedLines(baseState, nextState, pendingFiles = []) {
const base = normalizeInlineReviewComparableState(baseState)
const next = normalizeInlineReviewComparableState(nextState)
const lines = []
if (base.occurred_date !== next.occurred_date) {
lines.push(`发生时间 ${next.occurred_date || '待补充'}`)
}
if (base.amount !== next.amount) {
lines.push(`金额 ${formatAmountDisplay(next.amount) || '待补充'}`)
}
if (base.transport_type !== next.transport_type) {
lines.push(`交通类型 ${next.transport_type || '待确认'}`)
}
if (base.scene_label !== next.scene_label) {
lines.push(`场景 ${next.scene_label || '待补充'}`)
}
if (base.customer_name !== next.customer_name) {
lines.push(`关联客户 ${next.customer_name || '待补充'}`)
}
if (base.location !== next.location) {
lines.push(`业务地点 ${next.location || '待补充'}`)
}
if (base.merchant_name !== next.merchant_name) {
lines.push(`酒店/商户 ${next.merchant_name || '待补充'}`)
}
if (base.participants !== next.participants) {
lines.push(`同行人员 ${next.participants || '待补充'}`)
}
if (base.expense_type !== next.expense_type) {
lines.push(`报销分类 ${next.expense_type || '待补充'}`)
}
if (base.attachment_names !== next.attachment_names || pendingFiles.length) {
lines.push(`票据 ${next.attachment_names || (pendingFiles.length ? `已选择 ${pendingFiles.length} 份附件` : '待上传')}`)
}
return lines
}
export function buildInlineReviewChangePhrases(baseState, nextState, pendingFiles = []) {
const base = normalizeInlineReviewComparableState(baseState)
const next = normalizeInlineReviewComparableState(nextState)
const fieldConfigs = [
{ key: 'occurred_date', label: '发生时间', format: (value) => value || '待补充' },
{ key: 'amount', label: '金额', format: (value) => formatAmountDisplay(value) || '待补充' },
{ key: 'transport_type', label: '交通类型', format: (value) => value || '待确认' },
{ key: 'scene_label', label: '场景', format: (value) => value || '待补充' },
{ key: 'customer_name', label: '关联客户', format: (value) => value || '待补充' },
{ key: 'location', label: '业务地点', format: (value) => value || '待补充' },
{ key: 'merchant_name', label: '酒店/商户', format: (value) => value || '待补充' },
{ key: 'participants', label: '同行人员', format: (value) => value || '待补充' },
{ key: 'expense_type', label: '报销分类', format: (value) => value || '待补充' }
]
const phrases = fieldConfigs.reduce((result, item) => {
if (base[item.key] !== next[item.key]) {
result.push(`${item.label}修改为 ${item.format(next[item.key])}`)
}
return result
}, [])
if (base.attachment_names !== next.attachment_names || pendingFiles.length) {
phrases.push(`票据修改为 ${next.attachment_names || (pendingFiles.length ? `已选择 ${pendingFiles.length} 份附件` : '待上传')}`)
}
return phrases
}
export function buildLocalReviewSavedMessage(baseState, nextState, pendingFiles = [], baseDrafts = [], nextDrafts = []) {
const phrases = buildInlineReviewChangePhrases(baseState, nextState, pendingFiles)
const documentLines = buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts)
if (documentLines.length) {
phrases.push(`${documentLines.length} 张票据识别信息更新为最新修改`)
}
if (!phrases.length) {
return '右侧核对信息已保存。'
}
return `已将${phrases.join('')}`
}
export function buildInlineReviewUserText(baseState, nextState, pendingFiles = []) {
const lines = buildInlineReviewChangedLines(baseState, nextState, pendingFiles)
if (!lines.length) {
return '我已校正核对信息,请按最新内容更新。'
}
return `我已校正核对信息:${lines.join('')}。请按最新内容更新。`
}
export function buildReviewSubmitUserText(baseState, nextState, pendingFiles = [], baseDrafts = [], nextDrafts = []) {
const inlineLines = buildInlineReviewChangedLines(baseState, nextState, pendingFiles)
const documentLines = buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts)
if (!inlineLines.length && !documentLines.length) {
return '我已校正核对信息,请按最新内容更新。'
}
const parts = []
if (inlineLines.length) {
parts.push(inlineLines.join(''))
}
if (documentLines.length) {
parts.push(`修正了 ${documentLines.length} 张票据识别信息`)
}
return `我已校正核对信息:${parts.join('')}。请按最新内容更新。`
}

View File

@@ -0,0 +1,230 @@
import { ASSISTANT_SCOPE_ACTION_SWITCH } from '../../utils/assistantSessionScope.js'
import { STEWARD_ASSISTANT_NAME } from './useTravelReimbursementStewardRuntime.js'
import { SESSION_TYPE_APPLICATION, SESSION_TYPE_EXPENSE } from './travelReimbursementConversationModel.js'
import { resolveStewardTypewriterNextIndex } from './stewardTypewriter.js'
const STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS = 10
const STEWARD_FOLLOWUP_THINKING_INTERVAL_MS = 8
const STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE = 4
const STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE = 5
export function useTravelReimbursementStewardFollowupFlow({
buildStewardFieldItems,
createMessage,
formatStewardMissingFieldList,
formatStewardOntologyFields,
messages,
nextTick,
persistSessionState,
scrollToBottom
}) {
function buildStewardContinuationAfterAction(message, completedLabel = '当前动作已完成') {
const continuation = message?.stewardContinuation || null
const remainingTasks = Array.isArray(continuation?.remainingTasks) ? continuation.remainingTasks : []
if (!remainingTasks.length) return null
const nextTask = remainingTasks[0]
const nextTaskType = String(nextTask.task_type || nextTask.taskType || '').trim()
const targetSessionType = nextTaskType === 'expense_application' ? SESSION_TYPE_APPLICATION : SESSION_TYPE_EXPENSE
const nextLabel = targetSessionType === SESSION_TYPE_APPLICATION ? '继续创建申请单' : '继续填写报销单'
const restTasks = remainingTasks.slice(1)
return createMessage(
'assistant',
[
`**${completedLabel}。**`,
'',
'我会重新检查剩余任务队列。',
`下一步:${nextTask.title || (targetSessionType === SESSION_TYPE_APPLICATION ? '费用申请' : '费用报销')}`,
'请回复“确定”,我再继续执行。'
].join('\n'),
[],
{
assistantName: STEWARD_ASSISTANT_NAME,
meta: [STEWARD_ASSISTANT_NAME, '等待用户确认'],
suggestedActions: [
{
label: nextLabel,
description: '确认后小财管家继续调用对应助手完成下一步。',
icon: targetSessionType === SESSION_TYPE_APPLICATION ? 'mdi mdi-file-plus-outline' : 'mdi mdi-receipt-text-plus-outline',
action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
payload: {
session_type: targetSessionType,
carry_text: buildStewardContinuationCarryText(nextTask, restTasks),
carry_files: targetSessionType !== SESSION_TYPE_APPLICATION,
auto_submit: true,
steward_plan_id: String(continuation.planId || '').trim() || 'steward_continuation',
steward_next_task_id: String(nextTask.task_id || nextTask.taskId || '').trim(),
steward_current_task: nextTask,
steward_remaining_tasks: restTasks
}
}
]
}
)
}
function buildStewardFollowupPlan(thinkingEvents = [], streamStatus = 'streaming', planId = '') {
return {
planId: planId || `steward-followup-${Date.now()}`,
planStatus: 'delegating',
summary: '',
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
initialSummaryOnly: true,
thinkingEvents,
tasks: [],
attachmentGroups: [],
confirmationGroups: [],
streamStatus
}
}
function extractStewardCarryLine(text = '', label = '') {
const escapedLabel = String(label || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const match = String(text || '').match(new RegExp(`(?:^|\\n)${escapedLabel}[:]([^\\n]+)`, 'u'))
return match ? match[1].trim() : ''
}
function extractStewardFollowupNextTitle(text = '') {
const taskMatch = String(text || '').match(/请(?:先)?(?:创建申请单|填写报销单|继续填写报销单)[:]([^。\n]+)/u)
if (taskMatch?.[1]) return taskMatch[1].trim()
const nextMatch = String(text || '').match(/下一步[:]([^。\n]+)/u)
return nextMatch?.[1]?.trim() || '下一项财务任务'
}
function buildStewardFollowupThinkingEvents(finalMessage = null, actions = []) {
const eventPrefix = `steward-followup-${Date.now()}-${Math.random().toString(16).slice(2)}`
const firstAction = Array.isArray(actions) ? actions[0] : null
const actionPayload = firstAction?.payload && typeof firstAction.payload === 'object' ? firstAction.payload : {}
const carryText = String(actionPayload.carry_text || '').trim()
const finalText = String(finalMessage?.text || '').trim()
const nextTitle = extractStewardFollowupNextTitle(carryText || finalText)
const nextSummary = extractStewardCarryLine(carryText, '任务摘要')
const nextMissing = extractStewardCarryLine(carryText, '还需要补充')
return [
{
eventId: `${eventPrefix}-review`,
title: '复盘结果',
content: finalText.includes('申请单已完成')
? '申请单已经完成,我把当前出差申请标记为已处理,不会重复创建。'
: '当前动作已经完成,我会把已完成事项从任务队列中移除。'
},
{
eventId: `${eventPrefix}-next`,
title: '读取剩余任务',
content: nextSummary ? `剩余队列里的下一项是“${nextTitle}”:${nextSummary}` : `剩余队列里的下一项是“${nextTitle}”。`
},
{
eventId: `${eventPrefix}-gate`,
title: '判断下一步条件',
content: nextMissing
? `这一步还需要补充${nextMissing},进入对应核对环节后我会继续追问,不会直接提交。`
: '我会先等你确认,再进入下一项核对;创建草稿、绑定附件或提交前仍会再次确认。'
}
]
}
function waitStewardFollowupTick(intervalMs) {
return new Promise((resolve) => {
window.setTimeout(resolve, intervalMs)
})
}
async function pushStewardContinuationMessage(finalMessage) {
if (!finalMessage) return
const finalText = String(finalMessage.text || '')
const followupPlanId = `steward-followup-${Date.now()}-${Math.random().toString(16).slice(2)}`
const finalActions = Array.isArray(finalMessage.suggestedActions) ? finalMessage.suggestedActions : []
finalMessage.text = ''
finalMessage.assistantName = STEWARD_ASSISTANT_NAME
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '思考中']
finalMessage.suggestedActions = []
finalMessage.stewardPlan = buildStewardFollowupPlan([], 'streaming', followupPlanId)
messages.value.push(finalMessage)
persistSessionState()
nextTick(scrollToBottom)
const typedEvents = []
for (const eventData of buildStewardFollowupThinkingEvents(finalMessage, finalActions)) {
const event = {
eventId: eventData.eventId,
stage: 'steward_followup',
title: eventData.title,
content: '',
status: 'running'
}
typedEvents.push(event)
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
persistSessionState()
nextTick(scrollToBottom)
const chars = Array.from(eventData.content)
for (let index = 0; index < chars.length;) {
await waitStewardFollowupTick(STEWARD_FOLLOWUP_THINKING_INTERVAL_MS)
index = resolveStewardTypewriterNextIndex(chars, index, STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE)
event.content = chars.slice(0, index).join('')
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
if (index % STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE === 0 || index === chars.length) nextTick(scrollToBottom)
}
event.content = eventData.content
event.status = 'completed'
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'streaming', followupPlanId)
persistSessionState()
}
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '输出中']
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'typing', followupPlanId)
const chars = Array.from(finalText)
for (let index = 0; index < chars.length;) {
await waitStewardFollowupTick(STEWARD_FOLLOWUP_TYPEWRITER_INTERVAL_MS)
index = resolveStewardTypewriterNextIndex(chars, index, STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE)
finalMessage.text = chars.slice(0, index).join('')
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '输出中']
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'typing', followupPlanId)
if (index % STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE === 0 || index === chars.length) nextTick(scrollToBottom)
}
finalMessage.text = finalText
finalMessage.meta = [STEWARD_ASSISTANT_NAME, '等待用户确认']
finalMessage.suggestedActions = finalActions
finalMessage.stewardPlan = buildStewardFollowupPlan([...typedEvents], 'completed', followupPlanId)
persistSessionState()
nextTick(scrollToBottom)
}
function buildStewardContinuationCarryText(task, restTasks = []) {
const taskType = String(task?.task_type || task?.taskType || '').trim()
const fields = formatStewardOntologyFields(task?.ontology_fields || task?.ontologyFields || {}, taskType)
const missingFields = formatStewardMissingFieldList(task?.missing_fields || task?.missingFields || [], taskType, { includeHints: false })
const lines = [
taskType === 'expense_application'
? `小财管家继续执行剩余任务,请创建申请单:${task.title || '费用申请'}`
: `小财管家继续执行剩余任务,请填写报销单:${task.title || '费用报销'}`,
task.summary ? `任务摘要:${task.summary}` : '',
fields ? `已识别信息:${fields}` : '',
missingFields ? `还需要补充:${missingFields}` : '',
missingFields ? '请先追问上述缺失信息,不要直接生成核对结果,也不要替用户默认填写。' : '请生成核对结果;创建草稿、绑定附件或提交审批前仍需让我确认。'
]
if (restTasks.length) {
lines.push('当前步骤完成后,请继续引导我处理剩余任务:')
restTasks.forEach((item, index) => {
lines.push(`${index + 1}. ${item.title || item.task_type || item.taskType}`)
})
}
return lines.filter(Boolean).join('\n')
}
function resolveStewardMissingFieldItems(task) {
if (Array.isArray(task?.missingFieldItems) && task.missingFieldItems.length) return task.missingFieldItems
const fields = task?.missingFields || task?.missing_fields || []
const taskType = String(task?.taskType || task?.task_type || '').trim()
return buildStewardFieldItems(fields, taskType)
}
return {
buildStewardContinuationAfterAction,
buildStewardContinuationCarryText,
pushStewardContinuationMessage,
resolveStewardMissingFieldItems
}
}

View File

@@ -0,0 +1,65 @@
const APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN = /^(确认|确定|确认提交|确定提交|提交|提交审批|确认审批|确认无误|核对无误|信息无误|无误|没问题|可以提交|确认进入审批|提交至审批流程|确认提交审批|同意提交)$/
const APPLICATION_SUBMIT_CANCEL_TEXT_PATTERN = /^(不|否|否定|取消|暂不|先不|不确认|不提交|再检查|再看看|等等|等一下)/
const STEWARD_RUNTIME_CONTINUE_TEXT_PATTERN = /^(继续|继续执行|下一步|继续下一步|开始下一步|处理下一项|继续处理|确认开始|确定开始|可以|好的|好|行)$/
const STEWARD_RUNTIME_CANCEL_TEXT_PATTERN = /^(取消|暂不|先不|不用|不要|不继续|不处理|先等等|等一下|停止|终止|算了)$/
const STEWARD_RUNTIME_NEW_TASK_MIN_LENGTH = 12
const STEWARD_RUNTIME_BUSINESS_HINT_PATTERN = /(申请|报销|出差|差旅|招待|交通费|住宿费|餐费|发票|票据|费用|预算|借款|付款|审批|审核)/
const STEWARD_RUNTIME_TIME_OR_ACTION_HINT_PATTERN = /(今天|明天|后天|昨天|前天|\d{1,2}月\d{1,2}日|\d{4}-\d{1,2}-\d{1,2}|我要|帮我|需要|创建|填写|处理|去|前往)/
const STEWARD_RUNTIME_CURRENT_CONTEXT_HINT_PATTERN = /(当前|这个|这一步|上面|上述|申请单|核对表|出行方式|交通方式|火车|高铁|动车|飞机|轮船|提交|审批|确认)/
export function isApplicationSubmitConfirmationText(value = '') {
const normalized = String(value || '').trim()
if (!normalized) {
return ''
}
if (APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN.test(normalized)) {
return 'confirm'
}
if (APPLICATION_SUBMIT_CANCEL_TEXT_PATTERN.test(normalized)) {
return 'cancel'
}
return ''
}
export function normalizeStewardRuntimeInputText(value = '') {
return String(value || '').trim().replace(/\s+/g, '')
}
export function isStewardRuntimeContinueText(value = '') {
const normalized = normalizeStewardRuntimeInputText(value)
return Boolean(normalized && STEWARD_RUNTIME_CONTINUE_TEXT_PATTERN.test(normalized))
}
export function isStewardRuntimeCancelText(value = '') {
const normalized = normalizeStewardRuntimeInputText(value)
return Boolean(normalized && STEWARD_RUNTIME_CANCEL_TEXT_PATTERN.test(normalized))
}
export function resolveStewardRuntimeTransportAlias(value = '') {
const normalized = normalizeStewardRuntimeInputText(value)
if (/^(火车|高铁|动车|train)$/.test(normalized)) return '火车'
if (/^(飞机|航班|机票|flight)$/.test(normalized)) return '飞机'
if (/^(轮船|船|ship|ferry)$/.test(normalized)) return '轮船'
return ''
}
export function shouldPlanNewStewardTasksLocally(rawText = '', runtimeState = {}) {
const text = String(rawText || '').trim()
const normalized = normalizeStewardRuntimeInputText(text)
if (
!normalized ||
normalized.length < STEWARD_RUNTIME_NEW_TASK_MIN_LENGTH ||
isStewardRuntimeContinueText(normalized) ||
isStewardRuntimeCancelText(normalized)
) {
return false
}
if (!STEWARD_RUNTIME_BUSINESS_HINT_PATTERN.test(text) || !STEWARD_RUNTIME_TIME_OR_ACTION_HINT_PATTERN.test(text)) {
return false
}
const waitingFor = String(runtimeState?.waiting_for || runtimeState?.waitingFor || '').trim()
if (waitingFor && STEWARD_RUNTIME_CURRENT_CONTEXT_HINT_PATTERN.test(text)) {
return false
}
return true
}

View File

@@ -0,0 +1,190 @@
import { isApplicationDocumentNo } from '../../utils/documentClassification.js'
import {
applicationDateRangesOverlap,
normalizeApplicationPreview,
resolveApplicationDateRange
} from '../../utils/expenseApplicationPreview.js'
import { APPLICATION_DUPLICATE_IGNORED_STATUSES } from './travelReimbursementSubmitConstants.js'
function normalizeClaimListPayload(payload) {
if (Array.isArray(payload)) {
return payload
}
return Array.isArray(payload?.items) ? payload.items : []
}
function normalizeClaimRiskFlags(claim) {
const flags = claim?.risk_flags_json || claim?.riskFlagsJson || claim?.riskFlags || []
if (Array.isArray(flags)) {
return flags
}
return flags && typeof flags === 'object' ? [flags] : []
}
function extractApplicationDetailFromClaim(claim) {
return normalizeClaimRiskFlags(claim).reduce((found, item) => {
if (found || !item || typeof item !== 'object') {
return found
}
const detail = item.application_detail || item.applicationDetail
return detail && typeof detail === 'object' ? detail : null
}, null)
}
function isApplicationClaimRecord(claim) {
const expenseType = String(claim?.expense_type || claim?.expenseType || '').trim().toLowerCase()
const claimNo = String(claim?.claim_no || claim?.claimNo || '').trim().toUpperCase()
return (
expenseType === 'application' ||
expenseType === 'expense_application' ||
expenseType.endsWith('_application') ||
isApplicationDocumentNo(claimNo) ||
Boolean(extractApplicationDetailFromClaim(claim))
)
}
function normalizeApplicationExpenseType(value) {
const text = String(value || '').trim().toLowerCase()
if (!text) {
return ''
}
if (text === 'travel_application' || /差旅|出差/.test(text)) {
return 'travel_application'
}
if (text === 'purchase_application' || /采购/.test(text)) {
return 'purchase_application'
}
if (text === 'meeting_application' || /会务|会议/.test(text)) {
return 'meeting_application'
}
if (text === 'expense_application' || text === 'application' || text.endsWith('_application')) {
return text === 'application' ? 'expense_application' : text
}
return 'expense_application'
}
function resolveClaimApplicationExpenseType(claim) {
const detail = extractApplicationDetailFromClaim(claim) || {}
return normalizeApplicationExpenseType(
claim?.expense_type ||
claim?.expenseType ||
detail.application_type ||
detail.applicationType ||
''
)
}
function isIgnoredApplicationDuplicateStatus(status) {
return APPLICATION_DUPLICATE_IGNORED_STATUSES.has(String(status || '').trim().toLowerCase())
}
function resolveClaimApplicationDateRange(claim) {
const detail = extractApplicationDetailFromClaim(claim) || {}
return (
resolveApplicationDateRange(
detail.time ||
detail.time_range ||
detail.timeRange ||
detail.application_time ||
detail.applicationTime ||
detail.application_business_time ||
detail.applicationBusinessTime ||
detail.application_date ||
detail.applicationDate,
detail.days || detail.application_days || detail.applicationDays
) ||
resolveApplicationDateRange(claim?.occurred_at || claim?.occurredAt || '')
)
}
function formatApplicationDateRangeLabel(range) {
if (!range?.startDate) {
return '待确认'
}
return range.startDate === range.endDate ? range.startDate : `${range.startDate}${range.endDate}`
}
function findOverlappingApplicationClaim(applicationPreview, claimsPayload) {
const preview = normalizeApplicationPreview(applicationPreview)
const fields = preview.fields || {}
const currentRange = resolveApplicationDateRange(fields.time, fields.days)
if (!currentRange) {
return null
}
const currentExpenseType = normalizeApplicationExpenseType(fields.applicationType)
const claims = normalizeClaimListPayload(claimsPayload)
for (const claim of claims) {
if (!isApplicationClaimRecord(claim) || isIgnoredApplicationDuplicateStatus(claim?.status)) {
continue
}
const existingExpenseType = resolveClaimApplicationExpenseType(claim)
if (currentExpenseType && existingExpenseType && currentExpenseType !== existingExpenseType) {
continue
}
const existingRange = resolveClaimApplicationDateRange(claim)
if (!existingRange || !applicationDateRangesOverlap(currentRange, existingRange)) {
continue
}
return {
claim,
currentRange,
existingRange,
claimId: String(claim?.id || claim?.claim_id || claim?.claimId || '').trim(),
claimNo: String(claim?.claim_no || claim?.claimNo || claim?.id || '').trim(),
status: String(claim?.approval_stage || claim?.approvalStage || claim?.status || '').trim(),
reason: String(claim?.reason || '').trim(),
location: String(claim?.location || '').trim()
}
}
return null
}
function buildApplicationDateConflictMessage(conflict) {
const claimNo = conflict?.claimNo || '已有申请'
return [
'我先检查了你的申请时间,发现同一天或重叠日期已经存在差旅申请,不能重复创建。',
'',
'已有申请:',
`- **单号**${claimNo}`,
`- **申请时间**${formatApplicationDateRangeLabel(conflict?.existingRange)}`,
conflict?.location ? `- **地点**${conflict.location}` : '',
conflict?.reason ? `- **事由**${conflict.reason}` : '',
`- **当前节点**${conflict?.status || '处理中'}`,
'',
`本次识别时间:${formatApplicationDateRangeLabel(conflict?.currentRange)}`,
'',
'请先查看已有申请,或修改本次出差时间后再继续。'
].filter(Boolean).join('\n')
}
function buildApplicationDateConflictActions(conflict) {
const actions = []
if (conflict?.claimId) {
actions.push({
action_type: 'open_application_detail',
label: '查看已有申请',
description: conflict.claimNo ? `进入 ${conflict.claimNo} 单据详情。` : '进入已有申请单据详情。',
icon: 'mdi mdi-file-search-outline',
payload: {
claim_id: conflict.claimId,
claim_no: conflict.claimNo
}
})
}
actions.push({
action_type: 'prefill_composer',
label: '修改出差时间',
description: '在输入框中补充新的出差日期后继续。',
icon: 'mdi mdi-calendar-edit-outline',
payload: {
prompt_prefill: '修改出差时间为:'
}
})
return actions
}
export {
buildApplicationDateConflictActions,
buildApplicationDateConflictMessage,
findOverlappingApplicationClaim
}

View File

@@ -0,0 +1,178 @@
import {
applyApplicationBusinessTimeContext,
applyApplicationPolicyEstimateError,
applyApplicationPolicyEstimateResult,
buildApplicationPolicyEstimateRequest,
buildLocalApplicationPreview,
buildModelRefinedApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
import { fetchOntologyParse } from '../../services/ontology.js'
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
export function createSubmitApplicationPreviewHelpers({
activeSessionType,
currentUser,
isKnowledgeSession,
linkedRequest,
props,
refreshCurrentUserFromBackend
}) {
function buildBackendMessage(rawText, fileNames, ocrSummary = '', sessionTypeOverride = '') {
const parts = []
const normalizedText = String(rawText || '').trim()
const sessionType = String(sessionTypeOverride || activeSessionType.value || '').trim()
const isKnowledgeMessage = sessionType === 'knowledge'
if (normalizedText) {
parts.push(normalizedText)
} else if (fileNames.length) {
parts.push(
isKnowledgeMessage
? `我上传了 ${fileNames.length} 份附件,请结合附件名称回答财务相关问题。`
: sessionType === 'application'
? `我上传了 ${fileNames.length} 份附件,请结合附件名称整理费用申请建议和待核对信息。`
: sessionType === 'approval'
? `我上传了 ${fileNames.length} 份附件,请结合附件名称整理审核风险和处理建议。`
: `我上传了 ${fileNames.length} 份票据,请结合附件名称给出报销建议并整理待核对信息。`
)
}
if (fileNames.length) {
parts.push(`附件名称:${fileNames.join('、')}`)
}
if (ocrSummary) {
parts.push(`OCR摘要${ocrSummary}`)
}
if (props.entrySource === 'detail' && linkedRequest.value?.id) {
parts.push(`关联单号:${linkedRequest.value.id}`)
}
return parts.join('\n')
}
function resolveDetailScopedClaimId() {
if (props.entrySource !== 'detail' || isKnowledgeSession.value) {
return ''
}
return String(
linkedRequest.value?.claimId ||
linkedRequest.value?.claim_id ||
''
).trim()
}
function buildApplicationPreviewReviewMeta(ontology) {
return [
'申请核对预览',
String(ontology?.parse_strategy || '').trim() === 'llm_primary'
? '模型复核完成'
: '规则兜底复核'
]
}
async function resolveApplicationPreviewUser() {
const user = currentUser.value || {}
if (String(user.position || '').trim() || typeof refreshCurrentUserFromBackend !== 'function') {
return user
}
await refreshCurrentUserFromBackend({ silent: true })
return currentUser.value || user
}
async function buildApplicationPreviewWithModelReview(
rawText,
businessTimeContext = null,
sessionTypeOverride = '',
options = {}
) {
const user = await resolveApplicationPreviewUser()
const localPreview = applyApplicationBusinessTimeContext(
buildLocalApplicationPreview(rawText, user),
businessTimeContext
)
const enrichWithPolicyEstimate = async (preview) => {
const estimateRequest = buildApplicationPolicyEstimateRequest(preview, user)
if (!estimateRequest.canCalculate) {
return preview
}
try {
const fields = preview?.fields || {}
await waitForMockApplicationTransportQuote({
transportMode: fields.transportMode,
location: fields.location,
time: fields.time
})
const result = await calculateTravelReimbursement(estimateRequest.payload)
return applyApplicationPolicyEstimateResult(preview, result, user)
} catch (error) {
console.warn('Application policy estimate failed:', error)
return applyApplicationPolicyEstimateError(preview, error, user)
}
}
if (options.skipModelReview) {
return {
applicationPreview: await enrichWithPolicyEstimate({
...localPreview,
modelReviewStatus: 'skipped'
}),
meta: ['申请核对预览', '结构化快路径']
}
}
try {
const ontology = await fetchOntologyParse(
{
query: rawText,
user_id: user.username || user.name || 'anonymous',
context_json: {
...buildExpenseApplicationOntologyContext(user),
session_type: String(sessionTypeOverride || activeSessionType.value || '').trim(),
entry_source: props.entrySource,
user_input_text: rawText
}
},
{
timeoutMs: 45000,
timeoutMessage: '模型抽取申请字段超时,已保留当前本地预览。'
}
)
const refinedPreview = applyApplicationBusinessTimeContext(
buildModelRefinedApplicationPreview(
localPreview,
ontology,
rawText,
user
),
businessTimeContext
)
return {
applicationPreview: await enrichWithPolicyEstimate(refinedPreview),
meta: buildApplicationPreviewReviewMeta(ontology)
}
} catch (error) {
console.warn('Application preview model refinement failed:', error)
return {
applicationPreview: await enrichWithPolicyEstimate({
...localPreview,
modelReviewStatus: 'failed'
}),
meta: ['申请核对预览', '模型复核失败']
}
}
}
return {
buildApplicationPreviewWithModelReview,
buildBackendMessage,
resolveDetailScopedClaimId
}
}

View File

@@ -0,0 +1,271 @@
import { ATTACHMENT_ASSOCIATION_CONFIRM_HREF } from './travelReimbursementAttachmentModel.js'
export function createSubmitAttachmentAssociationFlow({
activeReviewPayload,
buildReviewFormContextFromPayload,
createMessage,
draftClaimId,
emitDraftSaved,
fetchReceiptFolderItems,
isKnowledgeSession,
messages,
nextTick,
persistSessionState,
resetFlowRun,
reviewInlineForm,
scrollToBottom,
sessionSwitchBusy,
submitComposer,
submitting,
toast
}) {
const pendingAttachmentAssociations = new Map()
function createPendingAttachmentAssociationId() {
return `attachment-association-${Date.now()}-${Math.random().toString(16).slice(2)}`
}
function emitSavedDraftRefresh(draftPayload) {
if (!emitDraftSaved || isKnowledgeSession.value || !draftPayload?.claim_no) {
return
}
const draftType = String(draftPayload.draft_type || '').trim()
emitDraftSaved({
claimId: String(draftPayload.claim_id || draftPayload.claimId || '').trim(),
claimNo: String(draftPayload.claim_no || draftPayload.claimNo || '').trim(),
status: String(draftPayload.status || '').trim(),
approvalStage: String(draftPayload.approval_stage || draftPayload.approvalStage || '').trim(),
documentType: draftType === 'expense_application' ? 'application' : 'reimbursement'
})
}
function normalizeRecognizedAttachmentData(data) {
if (!data || typeof data !== 'object') {
return null
}
const documents = Array.isArray(data.ocrDocuments) ? data.ocrDocuments : []
if (!documents.length) {
return null
}
return {
ocrPayload: data.ocrPayload || null,
ocrSummary: String(data.ocrSummary || '').trim(),
ocrDocuments: documents,
ocrFilePreviews: Array.isArray(data.ocrFilePreviews) ? data.ocrFilePreviews : []
}
}
function hasReceiptFolderSourceFile(files) {
return files.some((file) => String(file?.receiptId || '').trim())
}
async function promptUnlinkedReceiptFolderIfNeeded({
detailScopedClaimId,
files,
fileNames,
options,
rawText,
resolvedUploadDisposition,
reviewAction,
systemGenerated,
userText
}) {
if (
isKnowledgeSession.value ||
systemGenerated ||
!files.length ||
detailScopedClaimId ||
resolvedUploadDisposition ||
options.skipReceiptFolderUnlinkedPrompt ||
options.skipDraftAssociationPrompt ||
reviewAction ||
hasReceiptFolderSourceFile(files)
) {
return false
}
let unlinkedReceipts = []
try {
unlinkedReceipts = await fetchReceiptFolderItems('unlinked')
} catch (error) {
console.warn('Failed to load unlinked receipt folder items before attachment upload:', error)
return false
}
const count = Array.isArray(unlinkedReceipts) ? unlinkedReceipts.length : 0
if (!count) {
return false
}
resetFlowRun()
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
}
messages.value.push(createMessage(
'assistant',
`票据夹中还有 ${count} 份未关联票据。建议先处理这些票据再上传新附件,避免重复保存或遗漏关联。`,
[],
{
meta: ['票据夹待关联'],
suggestedActions: [
{
action_type: 'open_receipt_folder',
label: '去票据夹关联',
icon: 'mdi mdi-folder-open-outline',
payload: { target_view: 'receiptFolder' }
},
{
action_type: 'continue_upload_with_unlinked_receipts',
label: '继续上传新附件',
icon: 'mdi mdi-upload-outline',
payload: { raw_text: rawText }
}
]
}
))
nextTick(scrollToBottom)
persistSessionState()
return true
}
function buildConfirmedAssociationText(message) {
return String(message?.text || '')
.replace(`[确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})`, '已确认')
.replace(`[确定](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})`, '已确定')
}
function resolveReviewPanelScope({
reviewPayload = null,
reviewAction = '',
fileCount = 0,
rawText = ''
} = {}) {
if (!reviewPayload || typeof reviewPayload !== 'object') {
return ''
}
const normalizedAction = String(reviewAction || '').trim()
const documentCount = Array.isArray(reviewPayload.document_cards) ? reviewPayload.document_cards.length : 0
const riskCount = Array.isArray(reviewPayload.risk_briefs) ? reviewPayload.risk_briefs.length : 0
const asksRisk = /风险|隐患|超标|异常|重复|待整改|风险项|高风险|中风险|低风险/.test(String(rawText || ''))
if (fileCount > 0 && documentCount > 0) {
return 'documents'
}
if (riskCount > 0 && (asksRisk || ['next_step', 'submit', 'submit_claim'].includes(normalizedAction))) {
return 'risk'
}
if (!normalizedAction && fileCount === 0) {
return 'overview'
}
return ''
}
async function confirmPendingAttachmentAssociation(message) {
if (submitting.value || sessionSwitchBusy.value) return null
const pending = message?.pendingAttachmentAssociation && typeof message.pendingAttachmentAssociation === 'object'
? message.pendingAttachmentAssociation
: null
const associationId = String(pending?.id || '').trim()
if (!associationId || pending?.status === 'confirmed') {
return null
}
const runtime = pendingAttachmentAssociations.get(associationId)
if (!runtime || !Array.isArray(runtime.files) || !runtime.files.length) {
toast('当前会话里没有可归集的附件原件,请重新上传票据后再确认。')
return null
}
pending.status = 'confirmed'
message.pendingAttachmentAssociation = pending
message.text = buildConfirmedAssociationText(message)
message.meta = ['已确认归集']
persistSessionState()
if (pending.mode === 'save_then_associate') {
const inheritedReviewContext = buildReviewFormContextFromPayload(
activeReviewPayload.value,
reviewInlineForm.value
)
const savePayload = await submitComposer({
rawText: '请先把当前已识别的报销信息保存为草稿,随后继续归集本次上传的附件。',
userText: '',
files: [],
skipUserMessage: true,
pendingText: '正在先保存未保存单据...',
systemGenerated: true,
extraContext: {
...runtime.extraContext,
...inheritedReviewContext,
review_action: 'save_draft'
}
})
const savedClaimId = String(savePayload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
const savedClaimNo = String(savePayload?.result?.draft_payload?.claim_no || '').trim()
if (!savedClaimId) {
toast('当前单据还没有保存成功,请稍后重试。')
return savePayload
}
return submitComposer({
rawText: `确认将本次上传的 ${runtime.fileNames.length} 份票据归集到草稿 ${savedClaimNo || '当前草稿'}`,
userText: `保存草稿并归集 ${runtime.fileNames.length} 份票据`,
files: runtime.files,
uploadDisposition: 'continue_existing',
skipDraftAssociationPrompt: true,
skipUserMessage: true,
appendToCurrentFlow: true,
systemGenerated: true,
pendingText: savedClaimNo
? `草稿 ${savedClaimNo} 已保存,正在识别并归集附件...`
: '草稿已保存,正在识别并归集附件...',
associationConfirmed: true,
extraContext: {
...runtime.extraContext,
review_action: 'link_to_existing_draft',
draft_claim_id: savedClaimId,
selected_claim_id: savedClaimId,
selected_claim_no: savedClaimNo,
attachment_association_confirmed: true
}
})
}
return submitComposer({
rawText: `确认将本次上传的 ${runtime.fileNames.length} 份票据归集到草稿 ${runtime.claimNo || '当前草稿'}`,
userText: `确认归集到草稿 ${runtime.claimNo || '当前草稿'}`,
files: runtime.files,
uploadDisposition: 'continue_existing',
skipDraftAssociationPrompt: true,
pendingText: runtime.claimNo
? `正在将票据归集到草稿 ${runtime.claimNo}...`
: '正在将票据归集到当前草稿...',
associationConfirmed: true,
recognizedAttachmentData: {
ocrPayload: runtime.ocrPayload,
ocrSummary: runtime.ocrSummary,
ocrDocuments: runtime.ocrDocuments,
ocrFilePreviews: runtime.ocrFilePreviews
},
extraContext: {
...runtime.extraContext,
review_action: 'link_to_existing_draft',
draft_claim_id: runtime.claimId,
selected_claim_id: runtime.claimId,
selected_claim_no: runtime.claimNo,
attachment_association_confirmed: true
}
})
}
return {
confirmPendingAttachmentAssociation,
createPendingAttachmentAssociationId,
emitSavedDraftRefresh,
normalizeRecognizedAttachmentData,
pendingAttachmentAssociations,
promptUnlinkedReceiptFolderIfNeeded,
resolveReviewPanelScope
}
}

View File

@@ -0,0 +1,99 @@
const STEWARD_ASSISTANT_NAME = '小财管家'
const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 10
const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 8
const STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE = 4
const STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5
const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
const APPLICATION_PREVIEW_ONTOLOGY_FIELD_MAP = {
applicationType: 'expense_type',
time: 'time_range',
location: 'location',
reason: 'reason',
amount: 'amount',
transportMode: 'transport_mode',
department: 'department_name',
applicant: 'employee_name',
grade: 'employee_grade'
}
const APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP = {
费用类型: 'expense_type',
申请类型: 'expense_type',
发生时间: 'time_range',
出发时间: 'time_range',
申请时间: 'time_range',
地点: 'location',
事由: 'reason',
金额: 'amount',
系统预估费用: 'amount',
出行方式: 'transport_mode',
附件: 'attachments',
'附件/凭证': 'attachments',
商户: 'merchant_name',
'商户/开票方': 'merchant_name',
客户: 'customer_name',
客户或项目对象: 'customer_name'
}
const ONTOLOGY_FIELD_APPLICATION_PREVIEW_KEY_MAP = {
expense_type: 'applicationType',
time_range: 'time',
location: 'location',
reason: 'reason',
amount: 'amount',
transport_mode: 'transportMode',
department_name: 'department',
employee_name: 'applicant',
employee_grade: 'grade'
}
const ONTOLOGY_FIELD_DISPLAY_LABEL_MAP = {
expense_type: '费用类型',
time_range: '时间',
location: '地点',
reason: '事由',
amount: '金额',
transport_mode: '出行方式',
attachments: '附件/凭证',
customer_name: '客户或项目对象',
merchant_name: '商户/开票方',
department_name: '所属部门',
employee_name: '申请人',
employee_grade: '职级'
}
const APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS = new Set([
'amount',
'attachments',
'employee_no',
'department_name',
'employee_name'
])
const APPLICATION_DUPLICATE_IGNORED_STATUSES = new Set([
'cancelled',
'canceled',
'void',
'voided',
'deleted',
'已取消',
'已作废',
'作废',
'已删除'
])
export {
APPLICATION_DUPLICATE_IGNORED_STATUSES,
APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP,
APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS,
APPLICATION_PREVIEW_FIELD_ACTION_SET,
APPLICATION_PREVIEW_ONTOLOGY_FIELD_MAP,
ONTOLOGY_FIELD_APPLICATION_PREVIEW_KEY_MAP,
ONTOLOGY_FIELD_DISPLAY_LABEL_MAP,
STEWARD_ASSISTANT_NAME,
STEWARD_DELEGATED_THINKING_CHUNK_SIZE,
STEWARD_DELEGATED_THINKING_INTERVAL_MS,
STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE,
STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS
}

View File

@@ -0,0 +1,132 @@
import { buildUnsavedDraftAttachmentConfirmationMessage } from './travelReimbursementAttachmentModel.js'
export async function handleDraftAssociationPreflight({
activeReviewPayload,
adjustComposerTextareaHeight,
buildComposerFilePreviews,
buildDraftAssociationQueryPayload,
createPendingAttachmentAssociationId,
composerBusinessTimeDraftTouched,
composerBusinessTimeTags,
composerDraft,
createMessage,
detailScopedClaimId,
draftClaimId,
effectiveIsKnowledgeSession,
extraContext,
fetchExpenseClaims,
fileNames,
files,
messages,
nextTick,
options,
pendingAttachmentAssociations,
persistSessionState,
resetFlowRun,
resolvedUploadDisposition,
reviewAction,
scrollToBottom,
stewardDelegated,
toast,
userText
}) {
const hasUnsavedReviewDraft = Boolean(
!stewardDelegated &&
!effectiveIsKnowledgeSession &&
files.length &&
activeReviewPayload.value &&
!String(draftClaimId.value || '').trim() &&
!detailScopedClaimId &&
!resolvedUploadDisposition &&
!options.skipDraftAssociationPrompt &&
!reviewAction
)
if (hasUnsavedReviewDraft) {
const associationId = createPendingAttachmentAssociationId()
pendingAttachmentAssociations.set(associationId, {
files,
fileNames,
filePreviews: buildComposerFilePreviews(files),
extraContext
})
resetFlowRun()
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
}
messages.value.push(createMessage(
'assistant',
buildUnsavedDraftAttachmentConfirmationMessage({ fileNames }),
[],
{
meta: ['等待确认保存并归集'],
pendingAttachmentAssociation: {
id: associationId,
mode: 'save_then_associate',
status: 'pending',
fileNames
}
}
))
nextTick(scrollToBottom)
persistSessionState()
return { handled: true, value: null }
}
if (
!stewardDelegated &&
!effectiveIsKnowledgeSession &&
files.length &&
!resolvedUploadDisposition &&
!options.skipDraftAssociationPrompt &&
!reviewAction
) {
try {
const claims = await fetchExpenseClaims()
const queryPayload = buildDraftAssociationQueryPayload(claims)
if (queryPayload?.records?.length) {
resetFlowRun()
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
}
messages.value.push(createMessage(
'assistant',
`我找到 ${queryPayload.records.length} 张可关联的草稿/待补单据。请先选择这批附件要归集到哪张单据,我再开始识别附件。`,
[],
{
meta: ['等待选择关联单据'],
queryPayload
}
))
composerDraft.value = ''
composerBusinessTimeTags.value = []
composerBusinessTimeDraftTouched.value = false
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
persistSessionState()
return { handled: true, value: null }
}
} catch (error) {
console.warn('Failed to load draft claims before attachment recognition:', error)
resetFlowRun()
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
}
messages.value.push(createMessage(
'assistant',
'我暂时没能查询到可关联的草稿/待补单据,所以先不识别这批附件。请稍后重试,或从对应草稿进入后继续上传票据。',
[],
{
meta: ['单据查询失败']
}
))
nextTick(scrollToBottom)
persistSessionState()
toast(error?.message || '查询可关联草稿失败,请稍后重试。')
return { handled: true, value: null }
}
}
return { handled: false, value: null }
}

View File

@@ -0,0 +1,241 @@
import {
buildLocalApplicationPreviewMessage,
shouldUseLocalApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import { STEWARD_ASSISTANT_NAME } from './travelReimbursementSubmitConstants.js'
import {
buildApplicationDateConflictActions,
buildApplicationDateConflictMessage,
findOverlappingApplicationClaim
} from './travelReimbursementSubmitApplicationConflicts.js'
export async function handleLocalApplicationPreviewFlow({
adjustComposerTextareaHeight,
buildApplicationPreviewWithModelReview,
buildStewardApplicationPreviewMessage,
buildStewardApplicationPreviewSuggestedActions,
buildStewardDelegatedPlan,
buildStewardSlotDecisionMessage,
buildStewardSlotDecisionSuggestedActions,
clearAttachedFiles,
completeFlowStep,
composerBusinessTimeDraftTouched,
composerBusinessTimeTags,
composerDraft,
createMessage,
effectiveSessionType,
fetchExpenseClaims,
fetchStewardApplicationSlotDecision,
fileInputRef,
fileNames,
files,
messages,
nextTick,
options,
persistSessionState,
rawText,
replaceMessage,
resetFlowRun,
resetStewardDelegatedInsightState,
reviewAction,
scrollToBottom,
selectedBusinessTimeContext,
shouldPauseStewardApplicationPreview,
startFlowStep,
stewardDelegated,
submitting,
systemGenerated,
typeStewardDelegatedMessage,
userText
}) {
if (!shouldUseLocalApplicationPreview(rawText, {
sessionType: effectiveSessionType,
attachmentCount: files.length,
reviewAction,
systemGenerated
})) {
return { handled: false, value: null }
}
const intentStartedAt = Date.now()
const reviewStartedAt = intentStartedAt
if (stewardDelegated) {
resetStewardDelegatedInsightState()
} else {
resetFlowRun()
startFlowStep('intent', {
title: '业务意图识别',
tool: 'ontology.intent_detection',
detail: '正在识别是否为费用申请事项...'
})
startFlowStep('application-review-preview', {
title: '申请信息核对',
tool: 'ontology.application_review',
detail: '正在复核申请信息,并查询交通票价...'
})
}
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
}
const pendingMessage = createMessage(
'assistant',
stewardDelegated ? '' : '正在复核申请信息,并查询交通票价,请稍候。',
[],
stewardDelegated
? {
assistantName: STEWARD_ASSISTANT_NAME,
meta: [STEWARD_ASSISTANT_NAME, '思考中'],
stewardContinuation: options.stewardContinuation || null,
stewardPlan: buildStewardDelegatedPlan(options.stewardContinuation || null, [], 'streaming')
}
: {
meta: ['模型复核中']
}
)
messages.value.push(pendingMessage)
composerDraft.value = ''
composerBusinessTimeTags.value = []
composerBusinessTimeDraftTouched.value = false
clearAttachedFiles()
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
persistSessionState()
submitting.value = true
try {
const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(
rawText,
selectedBusinessTimeContext,
effectiveSessionType,
{
skipModelReview: Boolean(stewardDelegated && options.skipApplicationModelReview)
}
)
const reviewStatus = String(meta?.[1] || '').trim()
let applicationDateConflict = null
try {
const existingClaims = await fetchExpenseClaims({ page: 1, pageSize: 100 })
applicationDateConflict = findOverlappingApplicationClaim(applicationPreview, existingClaims)
} catch (error) {
console.warn('Failed to check overlapping application dates:', error)
}
if (applicationDateConflict) {
const conflictText = buildApplicationDateConflictMessage(applicationDateConflict)
const conflictActions = buildApplicationDateConflictActions(applicationDateConflict)
if (!stewardDelegated) {
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
completeFlowStep(
'application-review-preview',
'检测到同日期已有申请,已停止重复创建',
Date.now() - reviewStartedAt
)
replaceMessage(pendingMessage.id, createMessage(
'assistant',
conflictText,
[],
{
meta: ['申请日期冲突'],
suggestedActions: conflictActions,
stewardContinuation: options.stewardContinuation || null
}
))
} else {
await typeStewardDelegatedMessage(
pendingMessage.id,
conflictText,
{
meta: [STEWARD_ASSISTANT_NAME, '申请日期冲突'],
suggestedActions: conflictActions,
stewardContinuation: options.stewardContinuation || null
},
{
sessionType: effectiveSessionType,
rawText,
applicationPreview,
stewardContinuation: options.stewardContinuation || null
}
)
}
persistSessionState()
nextTick(scrollToBottom)
return { handled: true, value: null }
}
if (!stewardDelegated) {
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
completeFlowStep(
'application-review-preview',
reviewStatus === '模型复核完成'
? '模型复核完成,已生成申请核对表'
: reviewStatus === '模型复核失败'
? '模型复核失败,已生成临时核对表'
: '模型未返回稳定结果,已完成规则兜底核对',
Date.now() - reviewStartedAt
)
}
if (stewardDelegated) {
const fallbackStewardApplicationText = buildStewardApplicationPreviewMessage(
applicationPreview,
buildLocalApplicationPreviewMessage(applicationPreview)
)
const localPauseForMissingFields = shouldPauseStewardApplicationPreview(applicationPreview)
const shouldFetchSlotDecision = localPauseForMissingFields && !options.skipStewardSlotDecision
const slotDecision = shouldFetchSlotDecision
? await fetchStewardApplicationSlotDecision(
applicationPreview,
rawText,
options.stewardContinuation || null
)
: null
const slotDecisionActions = buildStewardSlotDecisionSuggestedActions(slotDecision, applicationPreview)
const pauseForMissingFields = slotDecision
? String(slotDecision.next_action || '').trim() === 'ask_user'
: localPauseForMissingFields
const stewardApplicationText = buildStewardSlotDecisionMessage(
slotDecision,
applicationPreview,
fallbackStewardApplicationText
)
await typeStewardDelegatedMessage(
pendingMessage.id,
stewardApplicationText,
{
meta,
applicationPreview: pauseForMissingFields ? null : applicationPreview,
suggestedActions: slotDecisionActions.length
? slotDecisionActions
: buildStewardApplicationPreviewSuggestedActions(applicationPreview),
stewardContinuation: options.stewardContinuation || null
},
{
sessionType: effectiveSessionType,
rawText,
applicationPreview,
stewardContinuation: options.stewardContinuation || null
}
)
} else {
replaceMessage(pendingMessage.id, createMessage(
'assistant',
buildLocalApplicationPreviewMessage(applicationPreview),
[],
{
meta,
applicationPreview,
stewardContinuation: options.stewardContinuation || null
}
))
}
persistSessionState()
nextTick(scrollToBottom)
} finally {
submitting.value = false
}
return { handled: true, value: null }
}

View File

@@ -0,0 +1,189 @@
import {
buildAttachmentAssociationConfirmationMessage,
collectReceiptFiles
} from './travelReimbursementAttachmentModel.js'
export async function handleSubmitRecognitionFlow({
activeReviewPayload,
attachmentAssociationConfirmed,
buildOcrDocumentsFromReviewPayload,
buildOcrSummaryFromDocuments,
buildReviewFormContextFromPayload,
completeFlowStep,
createMessage,
createPendingAttachmentAssociationId,
draftClaimId,
extraContext,
fileNames,
filePreviews,
files,
mergeUploadAttachmentNames,
mergeUploadOcrDocuments,
nextTick,
normalizeRecognizedAttachmentData,
pendingAttachmentAssociations,
pendingMessage,
persistSessionState,
recognizeOcrFiles,
rememberFilePreviews,
replaceMessage,
resolvedUploadDisposition,
reviewAttachmentNames,
reviewInlineForm,
scrollToBottom,
startFlowStep,
stewardDelegated
}) {
let ocrPayload = null
let ocrSummary = ''
let ocrDocuments = []
let ocrFilePreviews = []
const recognizedAttachmentData = normalizeRecognizedAttachmentData()
if (files.length) {
const ocrStartedAt = Date.now()
if (!stewardDelegated) {
startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt })
}
if (recognizedAttachmentData) {
const collected = await collectReceiptFiles({
files,
recognizedAttachmentData
})
ocrPayload = collected.ocrPayload
ocrSummary = collected.ocrSummary
ocrDocuments = collected.ocrDocuments
ocrFilePreviews = collected.ocrFilePreviews
rememberFilePreviews(ocrFilePreviews)
if (!stewardDelegated) {
completeFlowStep('ocr', `复用已确认的 ${ocrDocuments.length || files.length} 张票据识别结果`, Date.now() - ocrStartedAt)
}
} else {
try {
const collected = await collectReceiptFiles({
files,
recognizeOcrFiles
})
ocrPayload = collected.ocrPayload
ocrSummary = collected.ocrSummary
ocrDocuments = collected.ocrDocuments
ocrFilePreviews = collected.ocrFilePreviews
rememberFilePreviews(ocrFilePreviews)
if (!stewardDelegated) {
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)
}
} catch (error) {
console.warn('OCR request failed:', error)
if (!stewardDelegated) {
completeFlowStep('ocr', 'OCR识别失败已继续使用附件名称', Date.now() - ocrStartedAt)
}
}
}
if (!stewardDelegated && resolvedUploadDisposition === 'continue_existing') {
replaceMessage(pendingMessage.id, {
...pendingMessage,
text: attachmentAssociationConfirmed
? '票据识别已完成,正在把本次附件归集到已选择的草稿...'
: '票据识别已完成,正在整理归集前确认信息...',
meta: attachmentAssociationConfirmed ? ['正在归集'] : ['等待确认归集']
})
persistSessionState()
}
}
const associationTargetClaimId = String(extraContext.draft_claim_id || draftClaimId.value || '').trim()
const associationTargetClaimNo = String(
extraContext.selected_claim_no ||
extraContext.draft_claim_no ||
''
).trim()
if (
files.length &&
resolvedUploadDisposition === 'continue_existing' &&
associationTargetClaimId &&
!attachmentAssociationConfirmed
) {
const associationId = createPendingAttachmentAssociationId()
const pendingAssociation = {
id: associationId,
status: 'pending',
claimId: associationTargetClaimId,
claimNo: associationTargetClaimNo,
fileNames
}
pendingAttachmentAssociations.set(associationId, {
files,
fileNames,
ocrPayload,
ocrSummary,
ocrDocuments,
ocrFilePreviews,
filePreviews,
claimId: associationTargetClaimId,
claimNo: associationTargetClaimNo,
extraContext: {
...extraContext,
draft_claim_id: associationTargetClaimId,
selected_claim_id: associationTargetClaimId,
selected_claim_no: associationTargetClaimNo
}
})
replaceMessage(pendingMessage.id, createMessage(
'assistant',
buildAttachmentAssociationConfirmationMessage({
claimNo: associationTargetClaimNo,
fileNames,
ocrDocuments
}),
[],
{
meta: ['等待确认归集'],
pendingAttachmentAssociation: pendingAssociation
}
))
persistSessionState()
nextTick(scrollToBottom)
return { handled: true, value: null }
}
let effectiveFileNames = [...fileNames]
let effectiveOcrDocuments = [...ocrDocuments]
let effectiveOcrSummary = ocrSummary
if (resolvedUploadDisposition === 'continue_existing') {
extraContext.review_action = 'link_to_existing_draft'
const inheritedReviewContext = buildReviewFormContextFromPayload(
activeReviewPayload.value,
reviewInlineForm.value
)
if (inheritedReviewContext.review_form_values) {
extraContext.review_form_values = {
...inheritedReviewContext.review_form_values,
...(extraContext.review_form_values && typeof extraContext.review_form_values === 'object'
? extraContext.review_form_values
: {})
}
}
if (inheritedReviewContext.business_time_context && !extraContext.business_time_context) {
extraContext.business_time_context = inheritedReviewContext.business_time_context
}
effectiveFileNames = mergeUploadAttachmentNames(reviewAttachmentNames, fileNames)
effectiveOcrDocuments = mergeUploadOcrDocuments(
buildOcrDocumentsFromReviewPayload(activeReviewPayload.value),
ocrDocuments
)
effectiveOcrSummary = buildOcrSummaryFromDocuments(effectiveOcrDocuments)
} else if (resolvedUploadDisposition === 'new_document') {
extraContext.review_action = 'create_new_claim_from_documents'
}
return {
handled: false,
value: null,
effectiveFileNames,
effectiveOcrDocuments,
effectiveOcrSummary,
ocrFilePreviews
}
}

View File

@@ -0,0 +1,29 @@
export function isSubmittedApplicationDraftPayload(draftPayload) {
return (
String(draftPayload?.draft_type || '').trim() === 'expense_application'
&& String(draftPayload?.status || '').trim() === 'submitted'
)
}
export function buildOperationFeedbackState(context) {
if (!context) {
return null
}
return {
context,
submitting: false,
submitted: false,
dismissed: false,
rating: 0,
reason: '',
error: ''
}
}
export function resolveAssistantResultText(payload, fallbackAnswer) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
if (isSubmittedApplicationDraftPayload(result.draft_payload)) {
return ''
}
return result.answer || result.message || fallbackAnswer
}

View File

@@ -0,0 +1,505 @@
import {
APPLICATION_TRANSPORT_MODE_OPTIONS,
normalizeApplicationPreview,
normalizeTransportModeOption
} from '../../utils/expenseApplicationPreview.js'
import { fetchStewardSlotDecision } from '../../services/steward.js'
import { resolveStewardTypewriterNextIndex } from './stewardTypewriter.js'
import {
APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP,
APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS,
APPLICATION_PREVIEW_FIELD_ACTION_SET,
APPLICATION_PREVIEW_ONTOLOGY_FIELD_MAP,
ONTOLOGY_FIELD_APPLICATION_PREVIEW_KEY_MAP,
ONTOLOGY_FIELD_DISPLAY_LABEL_MAP,
STEWARD_ASSISTANT_NAME,
STEWARD_DELEGATED_THINKING_CHUNK_SIZE,
STEWARD_DELEGATED_THINKING_INTERVAL_MS,
STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE,
STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS
} from './travelReimbursementSubmitConstants.js'
export function createStewardDelegationHelpers({
currentInsight,
insightPanelCollapsed,
messages,
nextTick,
persistSessionState,
resetFlowRun,
scrollToBottom
}) {
function isStewardDelegatedRun(options = {}) {
return Boolean(options?.stewardContinuation && typeof options.stewardContinuation === 'object')
}
function resolveStewardDelegatedActionLabel(sessionType = '') {
return String(sessionType || '').trim() === 'application'
? '申请单核对'
: '报销单核对'
}
function buildStewardDelegatedPlan(continuation = null, thinkingEvents = [], streamStatus = 'streaming') {
return {
planId: String(continuation?.planId || continuation?.plan_id || 'steward_delegation').trim(),
planStatus: 'delegating',
summary: '',
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
initialSummaryOnly: true,
thinkingEvents,
tasks: [],
attachmentGroups: [],
confirmationGroups: [],
streamStatus
}
}
function resolveApplicationPreviewMissingFieldsForSteward(preview = {}) {
const normalized = normalizeApplicationPreview(preview)
return Array.isArray(normalized.missingFields) ? normalized.missingFields : []
}
function isBlockingApplicationOntologyField(key = '') {
const normalizedKey = String(key || '').trim()
return Boolean(normalizedKey && !APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS.has(normalizedKey))
}
function resolveBlockingApplicationMissingFieldsForSteward(preview = {}) {
return resolveApplicationPreviewMissingFieldsForSteward(preview).filter((label) => {
const ontologyKey = APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP[String(label || '').trim()] || ''
return !ontologyKey || isBlockingApplicationOntologyField(ontologyKey)
})
}
function buildStewardApplicationPreviewSuggestedActions(preview = {}) {
const normalized = normalizeApplicationPreview(preview)
const missingFields = resolveApplicationPreviewMissingFieldsForSteward(normalized)
if (!missingFields.includes('出行方式')) {
return []
}
const iconMap = {
火车: 'mdi mdi-train',
飞机: 'mdi mdi-airplane',
轮船: 'mdi mdi-ferry'
}
return APPLICATION_TRANSPORT_MODE_OPTIONS.map((mode) => ({
action_type: APPLICATION_PREVIEW_FIELD_ACTION_SET,
label: mode,
description: `选择${mode}后,由小财管家继续查询票价并测算费用。`,
icon: iconMap[mode] || 'mdi mdi-map-marker-path',
payload: {
field_key: 'transportMode',
field_label: '出行方式',
value: mode,
applicationPreview: normalized,
steward_delegated_field_completion: true
}
}))
}
function resolveStewardContinuationCurrentTask(continuation = null) {
const task = continuation?.currentTask || continuation?.current_task || null
return task && typeof task === 'object' ? task : null
}
function normalizeCanonicalFieldList(fields = []) {
const normalized = []
if (!Array.isArray(fields)) {
return normalized
}
fields.forEach((field) => {
const key = String(field || '').trim()
if (key && !normalized.includes(key)) {
normalized.push(key)
}
})
return normalized
}
function buildStewardSlotDecisionOntologyFields(preview = {}, continuation = null) {
const normalizedPreview = normalizeApplicationPreview(preview)
const previewFields = normalizedPreview.fields || {}
const task = resolveStewardContinuationCurrentTask(continuation)
const taskFields = task?.ontology_fields || task?.ontologyFields || {}
const fields = {}
Object.entries(taskFields || {}).forEach(([key, value]) => {
const normalizedKey = String(key || '').trim()
const normalizedValue = String(value || '').trim()
if (normalizedKey && normalizedValue) {
fields[normalizedKey] = normalizedValue
}
})
Object.entries(APPLICATION_PREVIEW_ONTOLOGY_FIELD_MAP).forEach(([previewKey, ontologyKey]) => {
const value = String(previewFields[previewKey] || '').trim()
if (value && value !== '待补充' && !fields[ontologyKey]) {
fields[ontologyKey] = value
}
})
return fields
}
function buildStewardSlotDecisionMissingFields(preview = {}, continuation = null, ontologyFields = {}) {
const task = resolveStewardContinuationCurrentTask(continuation)
const taskMissingFields = normalizeCanonicalFieldList(task?.missing_fields || task?.missingFields || [])
.filter((key) => isBlockingApplicationOntologyField(key) && !String(ontologyFields[key] || '').trim())
if (taskMissingFields.length) {
return taskMissingFields
}
return resolveApplicationPreviewMissingFieldsForSteward(preview)
.map((label) => APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP[String(label || '').trim()] || '')
.filter((key, index, list) =>
key &&
isBlockingApplicationOntologyField(key) &&
!String(ontologyFields[key] || '').trim() &&
list.indexOf(key) === index
)
}
async function fetchStewardApplicationSlotDecision(preview = {}, rawText = '', continuation = null) {
const ontologyFields = buildStewardSlotDecisionOntologyFields(preview, continuation)
const missingFields = buildStewardSlotDecisionMissingFields(preview, continuation, ontologyFields)
try {
return await fetchStewardSlotDecision({
task_type: 'expense_application',
user_message: String(rawText || '').trim(),
ontology_fields: ontologyFields,
missing_fields: missingFields,
task_context: {
steward_continuation: continuation || null,
application_preview: normalizeApplicationPreview(preview)
}
}, {
timeoutMs: 45000,
timeoutMessage: '小财管家字段决策超时,已按当前本体缺口继续追问。'
})
} catch (error) {
console.warn('Steward slot decision failed:', error)
return null
}
}
function formatStewardDecisionUserText(text = '') {
let formatted = String(text || '').trim()
Object.entries(ONTOLOGY_FIELD_DISPLAY_LABEL_MAP).forEach(([fieldKey, label]) => {
const escapedKey = fieldKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
formatted = formatted
.replace(new RegExp(`\\s*${escapedKey}\\s*`, 'g'), '')
.replace(new RegExp(`\\(\\s*${escapedKey}\\s*\\)`, 'g'), '')
.replace(new RegExp(`\\b${escapedKey}\\b`, 'g'), label)
})
return formatted.replace(/\s{2,}/g, ' ').trim()
}
function buildStewardSlotDecisionMessage(decision = null, preview = {}, fallbackText = '') {
if (!decision || String(decision.next_action || '').trim() !== 'ask_user') {
return fallbackText
}
const question = formatStewardDecisionUserText(decision.question || '')
const rationale = formatStewardDecisionUserText(decision.rationale || '')
const parts = [
'我已经识别出这一步要先处理申请单,但当前还不能直接生成可提交的申请核对表。',
'',
rationale ? `**原因是:${rationale}**` : '',
'',
question || buildStewardApplicationPreviewMessage(preview, fallbackText)
].filter((item) => item !== '')
return parts.join('\n')
}
function buildStewardSlotDecisionSuggestedActions(decision = null, preview = {}) {
if (!decision || String(decision.next_action || '').trim() !== 'ask_user') {
return []
}
const normalizedPreview = normalizeApplicationPreview(preview)
const iconMap = {
火车: 'mdi mdi-train',
飞机: 'mdi mdi-airplane',
轮船: 'mdi mdi-ferry'
}
const actions = Array.isArray(decision.options) ? decision.options : []
return actions.map((option) => {
const canonicalField = String(option?.field_key || option?.fieldKey || '').trim()
if (canonicalField && !isBlockingApplicationOntologyField(canonicalField)) {
return null
}
const fieldKey = ONTOLOGY_FIELD_APPLICATION_PREVIEW_KEY_MAP[canonicalField] || canonicalField
const value = String(option?.value || option?.label || '').trim()
const label = String(option?.label || value).trim()
const normalizedValue = fieldKey === 'transportMode'
? normalizeTransportModeOption(value || label, '')
: value
if (!fieldKey || !value || !label) {
return null
}
if (fieldKey === 'transportMode' && !normalizedValue) {
return null
}
return {
action_type: APPLICATION_PREVIEW_FIELD_ACTION_SET,
label,
description: String(option?.description || '').trim() || `选择${label}后,由小财管家继续测算并生成核对结果。`,
icon: iconMap[label] || iconMap[value] || 'mdi mdi-form-select',
payload: {
field_key: fieldKey,
field_label: ONTOLOGY_FIELD_DISPLAY_LABEL_MAP[canonicalField] || label,
value: normalizedValue,
applicationPreview: normalizedPreview,
steward_delegated_field_completion: true
}
}
}).filter(Boolean)
}
function buildStewardApplicationPreviewMessage(preview = {}, fallbackText = '') {
const normalized = normalizeApplicationPreview(preview)
const fields = normalized.fields || {}
const missingFields = resolveBlockingApplicationMissingFieldsForSteward(normalized)
if (!missingFields.length) {
return fallbackText
}
if (missingFields.includes('出行方式')) {
return [
'我已经识别出这一步要先处理出差申请,但现在还不能生成可提交的申请核对表。',
'',
'**原因是:还缺少“出行方式”。**',
'',
`本次申请是前往${fields.location || '目的地'}的差旅事项,出行方式会影响交通费用口径和系统预估金额。`,
'',
'请先告诉我你打算怎么出行:**火车、飞机或轮船**。我会根据你的选择生成申请核对表并同步费用测算,再继续判断是否可以提交申请。'
].join('\n')
}
return [
'我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表。',
'',
`**还需要你补充:${missingFields.join('、')}。**`,
'',
`请先补充 **${missingFields[0]}**。补齐后我再生成申请核对表并继续推进下一步。`
].join('\n')
}
function shouldPauseStewardApplicationPreview(preview = {}) {
return resolveBlockingApplicationMissingFieldsForSteward(preview).length > 0
}
function extractStewardCarryLine(text = '', label = '') {
const escapedLabel = String(label || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const match = String(text || '').match(new RegExp(`(?:^|\\n)${escapedLabel}[:]([^\\n]+)`, 'u'))
return match ? match[1].trim() : ''
}
function extractStewardDelegatedTaskTitle(text = '', sessionType = '') {
const taskMatch = String(text || '').match(/请(?:先)?(?:创建申请单|填写报销单|继续填写报销单)[:]([^。\n]+)/u)
if (taskMatch?.[1]) {
return taskMatch[1].trim()
}
return String(sessionType || '').trim() === 'application' ? '本次出差申请' : '本次费用报销'
}
function sanitizeStewardDelegatedTaskSummary(summary = '', sessionType = '') {
const text = String(summary || '').trim()
if (String(sessionType || '').trim() !== 'application') {
return text
}
return text
.replace(/交通方式和(?:预算|预计)?金额待补充/g, '交通方式待补充')
.replace(/出行方式和(?:预算|预计)?金额待补充/g, '出行方式待补充')
.replace(/交通方式及(?:预估|预计|预算)?费用/g, '交通方式')
.replace(/出行方式及(?:预估|预计|预算)?费用/g, '出行方式')
.replace(/(?:费用预算|预算费用|出差费用预算)(?:待补充|待确认|待填写|需补充|需要补充|未补充|缺失)?[,、;;\s]*/g, '')
.replace(/[,、;;\s]*(?:预估|预计|预算)费用(?:待补充|待确认|待填写|需补充|需要补充|需确认|需要确认|未补充|缺失)?/g, '')
.replace(/[,、;;\s]*(?:预算|预计)?金额(?:待补充|待确认|待填写|需补充|需要补充|未补充|缺失)/g, '')
.replace(/([,、;;。])\1+/g, '$1')
.replace(/[,、;;\s]+。/g, '。')
.replace(/[,、;;\s]+$/g, '')
.trim()
}
function summarizeApplicationPreviewForSteward(preview = {}) {
const normalized = normalizeApplicationPreview(preview)
const fields = normalized.fields || {}
return [
fields.time ? `时间:${fields.time}` : '',
fields.location ? `地点:${fields.location}` : '',
fields.reason ? `事由:${fields.reason}` : '',
fields.applicationType ? `类型:${fields.applicationType}` : ''
].filter(Boolean).join('')
}
function buildStewardDelegatedThinkingEvents(sessionType = '', continuation = null, context = {}) {
const actionLabel = resolveStewardDelegatedActionLabel(sessionType)
const eventPrefix = `steward-delegated-${Date.now()}-${Math.random().toString(16).slice(2)}`
const rawText = String(context.rawText || '').trim()
const taskTitle = extractStewardDelegatedTaskTitle(rawText, sessionType)
const taskSummary = sanitizeStewardDelegatedTaskSummary(
extractStewardCarryLine(rawText, '任务摘要'),
sessionType
)
const identifiedInfo = summarizeApplicationPreviewForSteward(context.applicationPreview)
|| extractStewardCarryLine(rawText, '已识别信息')
const carryMissingInfo = extractStewardCarryLine(rawText, '还需要补充')
const applicationMissingFields = context.applicationPreview
? resolveBlockingApplicationMissingFieldsForSteward(context.applicationPreview)
: []
const missingInfo = applicationMissingFields.length
? applicationMissingFields.join('、')
: carryMissingInfo
const events = [
{
eventId: `${eventPrefix}-intent`,
title: '理解当前任务',
content: taskSummary
? `你确认先处理“${taskTitle}”。我把这一步理解为:${taskSummary}`
: `你确认先处理“${taskTitle}”,我会先生成${actionLabel}结果。`
},
{
eventId: `${eventPrefix}-known`,
title: '核对已知信息',
content: identifiedInfo
? `当前已识别到:${identifiedInfo}`
: `当前先围绕“${taskTitle}”生成可核对内容,具体缺口会在核对结果里继续判断。`
}
]
if (missingInfo) {
const transportMissing = /出行方式/.test(missingInfo)
events.push({
eventId: `${eventPrefix}-gap`,
title: '判断待补充信息',
content: transportMissing
? '这一步还没有说明出行方式。出行方式会影响交通费用测算,所以我会先问你选择火车、飞机或轮船,不会直接推进提交。'
: `这一步还缺少${missingInfo},我会先向你确认这些信息,不直接推进提交。`
})
} else {
events.push({
eventId: `${eventPrefix}-ready`,
title: '判断下一步动作',
content: `这一步的关键业务信息已形成核对结果。我会先让你检查${actionLabel},确认后再继续入库、生成草稿或处理后续任务。`
})
}
return events
}
function resolveStewardDelegatedFinalMeta(finalExtras = {}) {
const sourceMeta = Array.isArray(finalExtras.meta) ? finalExtras.meta : []
const sourceLabel = sourceMeta.find((item) =>
String(item || '').trim() && String(item || '').trim() !== STEWARD_ASSISTANT_NAME
)
const requiresConfirmation = Boolean(
finalExtras.applicationPreview ||
finalExtras.reviewPayload ||
(Array.isArray(finalExtras.suggestedActions) && finalExtras.suggestedActions.length)
)
return [
STEWARD_ASSISTANT_NAME,
requiresConfirmation ? '等待用户确认' : '已完成',
sourceLabel || ''
].filter(Boolean).slice(0, 3)
}
function waitStewardDelegatedTick(intervalMs) {
return new Promise((resolve) => {
globalThis.setTimeout(resolve, intervalMs)
})
}
async function typeStewardDelegatedMessage(messageId, finalText, finalExtras = {}, context = {}) {
const continuation = finalExtras.stewardContinuation || context.stewardContinuation || null
const pendingSuggestedActions = Array.isArray(finalExtras.suggestedActions)
? finalExtras.suggestedActions
: []
const message = messages.value.find((item) => item.id === messageId)
if (!message) {
return
}
message.text = ''
message.assistantName = STEWARD_ASSISTANT_NAME
message.meta = [STEWARD_ASSISTANT_NAME, '思考中']
message.suggestedActions = []
message.stewardContinuation = continuation
message.stewardPlan = buildStewardDelegatedPlan(continuation, [], 'streaming')
persistSessionState()
nextTick(scrollToBottom)
const typedEvents = []
const thinkingEvents = buildStewardDelegatedThinkingEvents(context.sessionType, continuation, context)
for (const eventData of thinkingEvents) {
const event = {
eventId: eventData.eventId,
stage: 'delegated_action',
title: eventData.title,
content: '',
status: 'running'
}
typedEvents.push(event)
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'streaming')
persistSessionState()
nextTick(scrollToBottom)
const chars = Array.from(String(eventData.content || ''))
for (let index = 0; index < chars.length;) {
await waitStewardDelegatedTick(STEWARD_DELEGATED_THINKING_INTERVAL_MS)
index = resolveStewardTypewriterNextIndex(chars, index, STEWARD_DELEGATED_THINKING_CHUNK_SIZE)
event.content = chars.slice(0, index).join('')
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'streaming')
if (index % STEWARD_DELEGATED_THINKING_CHUNK_SIZE === 0 || index === chars.length) {
nextTick(scrollToBottom)
}
}
event.content = String(eventData.content || '')
event.status = 'completed'
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'streaming')
persistSessionState()
}
const text = String(finalText || '')
message.text = ''
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
message.suggestedActions = pendingSuggestedActions
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
const chars = Array.from(text)
for (let index = 0; index < chars.length;) {
await waitStewardDelegatedTick(STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS)
index = resolveStewardTypewriterNextIndex(chars, index, STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE)
message.text = chars.slice(0, index).join('')
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
if (index % STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE === 0 || index === chars.length) {
nextTick(scrollToBottom)
}
}
Object.assign(message, finalExtras, {
id: messageId,
text,
assistantName: STEWARD_ASSISTANT_NAME,
meta: resolveStewardDelegatedFinalMeta(finalExtras),
stewardContinuation: continuation,
stewardPlan: buildStewardDelegatedPlan(continuation, [...typedEvents], 'completed')
})
persistSessionState()
nextTick(scrollToBottom)
}
function resetStewardDelegatedInsightState() {
resetFlowRun({ startedAt: 0, openDrawer: false })
insightPanelCollapsed.value = true
currentInsight.value = {
intent: 'welcome',
agent: null
}
}
return {
buildStewardApplicationPreviewMessage,
buildStewardApplicationPreviewSuggestedActions,
buildStewardDelegatedPlan,
buildStewardSlotDecisionMessage,
buildStewardSlotDecisionSuggestedActions,
fetchStewardApplicationSlotDecision,
isStewardDelegatedRun,
resetStewardDelegatedInsightState,
shouldPauseStewardApplicationPreview,
typeStewardDelegatedMessage
}
}

View File

@@ -0,0 +1,267 @@
import {
APPLICATION_WELCOME_QUICK_ACTIONS,
APPROVAL_WELCOME_QUICK_ACTIONS,
ASSISTANT_DISPLAY_NAME,
BUDGET_WELCOME_QUICK_ACTIONS,
EXPENSE_WELCOME_QUICK_ACTIONS,
HOT_KNOWLEDGE_QUESTIONS,
SESSION_TYPE_APPLICATION,
SESSION_TYPE_APPROVAL,
SESSION_TYPE_BUDGET,
SESSION_TYPE_EXPENSE,
SESSION_TYPE_KNOWLEDGE,
SESSION_TYPE_STEWARD,
normalizeAssistantSessionType
} from './travelReimbursementConversationSessionModel.js'
import { createMessage } from './travelReimbursementConversationMessageModel.js'
export function buildWelcomeUserContext(user = {}) {
const username = String(user.username || '').trim()
const name = String(user.name || username || '同事').trim()
const grade = String(user.grade || '').trim()
const position = String(user.position || '').trim()
const role = String(user.role || '').trim()
const roleCodes = Array.isArray(user.roleCodes) ? user.roleCodes : []
const isAdmin =
Boolean(user.isAdmin)
|| username.toLowerCase() === 'admin'
|| roleCodes.some((item) => /admin|manager/i.test(String(item || '')))
|| /管理员|系统管理/.test(position)
|| /管理员|系统管理/.test(role)
const now = new Date()
const dateLine = now.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long'
})
let honorific = name
if (isAdmin) {
honorific = name && !/^admin$/i.test(name) ? `${name} 管理员` : '管理员'
} else {
const prefix = [grade, position].filter(Boolean).join(' ')
honorific = prefix ? `${prefix} ${name}`.trim() : name
}
return {
name,
username,
grade,
position,
role,
isAdmin,
honorific,
dateLine
}
}
export function buildWelcomeQuickActions(sessionType, user, entrySource, linkedRequest) {
const normalizedSessionType = normalizeAssistantSessionType(sessionType)
if (normalizedSessionType === SESSION_TYPE_KNOWLEDGE) {
return HOT_KNOWLEDGE_QUESTIONS.slice(0, 6).map((question) => ({
label: question.length > 20 ? `${question.slice(0, 20)}` : question,
prompt: question,
icon: 'mdi mdi-comment-question-outline'
}))
}
if (normalizedSessionType === SESSION_TYPE_STEWARD) {
return [
{
label: '申请出差并报销票据',
prompt: '我想申请下周去北京出差,并报销昨天的交通费。',
icon: 'mdi mdi-account-tie-outline'
},
{
label: '归集多张附件',
prompt: '我上传了多张票据,请先帮我判断哪些属于差旅报销。',
icon: 'mdi mdi-folder-multiple-outline'
}
]
}
if (normalizedSessionType === SESSION_TYPE_APPLICATION) {
return APPLICATION_WELCOME_QUICK_ACTIONS
}
if (normalizedSessionType === SESSION_TYPE_APPROVAL) {
return APPROVAL_WELCOME_QUICK_ACTIONS
}
if (normalizedSessionType === SESSION_TYPE_BUDGET) {
return BUDGET_WELCOME_QUICK_ACTIONS
}
return EXPENSE_WELCOME_QUICK_ACTIONS
}
export function buildWelcomeMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) {
const normalizedSessionType = normalizeAssistantSessionType(sessionType)
const ctx = buildWelcomeUserContext(user || {})
const greeting = ctx.isAdmin ? `${ctx.honorific},您好` : `您好,${ctx.honorific}`
if (normalizedSessionType === SESSION_TYPE_KNOWLEDGE) {
return [
`${greeting}!今日是 **${ctx.dateLine}**。`,
'',
'**欢迎来到个人财务中心 · 财务知识助手。** 我可以帮您查制度、报销标准、票据要求和常见财务问题,并保持知识问答对话独立记录。',
'',
'业务范围:财务制度、标准规则、票据要求和政策口径解释。发起申请、报销处理或审核动作请切换到对应助手。',
'',
'您可以直接输入问题,或点击下方「猜你想问」快速开始。'
].join('\n')
}
if (normalizedSessionType === SESSION_TYPE_STEWARD) {
return [
`${greeting}!今日是 **${ctx.dateLine}**。`,
'',
'**欢迎来到个人财务中心 · 小财管家。** 我会先拆解您的一句话多任务,归集附件,再把确认后的任务分派给申请助手或报销助手。',
'',
'业务范围:多任务识别、附件归集、确认点管理、申请助手和报销助手调度。创建单据、绑定附件和提交审批都会先让您确认。',
'',
'您可以一次性描述多个申请或报销事项,也可以先上传附件让我归集。'
].join('\n')
}
if (normalizedSessionType === SESSION_TYPE_APPLICATION) {
return [
`${greeting}!今日是 **${ctx.dateLine}**。`,
'',
'**欢迎来到个人财务中心 · 申请助手。** 我会先判断您要处理的是费用申请、报销申请还是其他财务事项,再按对应流程引导补充信息。',
'',
'业务范围:费用申请、事前审批、申请材料清单和申请单状态。报销票据、审核处理和制度问答请切换到对应助手。',
'',
'您可以直接描述申请事项,或点击下方快捷操作开始发起申请。'
].join('\n')
}
if (normalizedSessionType === SESSION_TYPE_APPROVAL) {
return [
`${greeting}!今日是 **${ctx.dateLine}**。`,
'',
'**欢迎来到个人财务中心 · 审核助手。** 我可以帮您查询待审单据、解释风险点、整理审核意见,并保持审核对话独立记录。',
'',
'业务范围:待审单据查询、审批动作、风险解释和审核意见草稿。申请、报销和制度问答请切换到对应助手。',
'',
'您可以直接输入要审核或查询的内容,或点击下方快捷操作快速开始。'
].join('\n')
}
if (normalizedSessionType === SESSION_TYPE_BUDGET) {
return [
`${greeting}!今日是 **${ctx.dateLine}**。`,
'',
'**欢迎来到个人财务中心 · 预算编制助手。** 我可以帮您查询预算编制情况、整理费用类型预算、检查提醒/告警/风险阈值,并保持预算对话独立记录。',
'',
'业务范围:预算编制查询、部门预算检查、费用类型额度梳理、预算占用说明和阈值风险分析。报销发起、审核动作和制度问答请切换到对应助手。',
'',
'您可以直接输入预算问题,或点击下方快捷操作快速开始。'
].join('\n')
}
if (entrySource === 'detail' && linkedRequest?.id) {
return [
`${greeting}!今日是 **${ctx.dateLine}**。`,
'',
`我已为您打开关联单据 **${linkedRequest.id}**。您可以继续补充票据、核对识别结果,或让我解释待补项与风险。`,
'',
'如需新建其他报销,也可以直接告诉我费用场景,或上传发票、行程单开始识别。'
].join('\n')
}
return [
`${greeting}!今日是 **${ctx.dateLine}**。`,
'',
'**欢迎来到个人财务中心 · 报销助手。** 我可以陪您完成报销发起、票据识别、草稿归集、报销信息核对、待补项提醒和风险说明,并保持报销对话独立记录。',
'',
'业务范围:发起报销、票据识别、草稿归集、报销状态查询和报销信息核对。申请、审核和制度问答请切换到对应助手。',
'',
'您可以描述一笔费用、上传票据,或点击下方快捷操作直接开始。'
].join('\n')
}
export function buildWelcomeInsight(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) {
const normalizedSessionType = normalizeAssistantSessionType(sessionType)
const ctx = buildWelcomeUserContext(user || {})
if (normalizedSessionType === SESSION_TYPE_KNOWLEDGE) {
return {
intent: 'welcome',
metricLabel: '今日',
metricValue: ctx.dateLine.split(' ')[0] || '—',
title: '财务知识问答',
summary: `${ctx.honorific},右侧整理了热门制度问题,点选即可追问;左侧也可直接输入您关心的问题。`,
agent: null
}
}
if (normalizedSessionType === SESSION_TYPE_STEWARD) {
return {
intent: 'welcome',
metricLabel: '当前入口',
metricValue: '小财管家',
title: '小财管家',
summary: `${ctx.honorific},这里会先拆解多任务和归集附件,再把确认后的事项交给申请助手或报销助手处理。`,
agent: null
}
}
if (normalizedSessionType === SESSION_TYPE_APPLICATION) {
return {
intent: 'welcome',
metricLabel: '当前助手',
metricValue: '申请助手',
title: '申请助手',
summary: `${ctx.honorific},这里会单独保存费用申请相关对话,不会混入报销、审核或知识问答记录。`,
agent: null
}
}
if (normalizedSessionType === SESSION_TYPE_APPROVAL) {
return {
intent: 'welcome',
metricLabel: '当前助手',
metricValue: '审核助手',
title: '审核助手',
summary: `${ctx.honorific},这里会单独保存审核相关对话,适合查询待审单据、风险点和审核意见。`,
agent: null
}
}
if (normalizedSessionType === SESSION_TYPE_BUDGET) {
return {
intent: 'welcome',
metricLabel: '当前助手',
metricValue: '预算编制助手',
title: '预算编制助手',
summary: `${ctx.honorific},这里会单独保存预算相关对话,适合查询预算编制、预算占用和阈值风险。`,
agent: null
}
}
return {
intent: 'welcome',
metricLabel: '当前助手',
metricValue: '报销助手',
title:
entrySource === 'detail' && linkedRequest?.id
? `已关联 ${linkedRequest.id}`
: '报销助手',
summary:
entrySource === 'detail' && linkedRequest?.id
? `${ctx.honorific},发送消息或上传附件后,我会结合当前单据继续识别并提示待补项。`
: `${ctx.honorific},描述费用场景或上传票据后,我会在右侧展示识别结果,并在对话中提示待补信息与风险。`,
agent: null
}
}
export function createWelcomeAssistantMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) {
return createMessage('assistant', buildWelcomeMessage(entrySource, linkedRequest, sessionType, user), [], {
assistantName: ASSISTANT_DISPLAY_NAME,
isWelcome: true,
welcomeQuickActions: buildWelcomeQuickActions(sessionType, user, entrySource, linkedRequest)
})
}

View File

@@ -0,0 +1,94 @@
import { dedupeLowerSeverityRiskCards } from './travelRequestDetailRiskCardDedupe.js'
function normalizeText(value) {
return String(value || '').trim()
}
export function buildAiAdviceViewModel({
completionItems = [],
materialPrompts = [],
profileAdviceItems = [],
riskCards = []
} = {}) {
const normalizedCompletionItems = completionItems.map((item) => normalizeText(item)).filter(Boolean)
const normalizedMaterialPrompts = materialPrompts.map((item) => normalizeText(item)).filter(Boolean)
const normalizedProfileAdviceItems = profileAdviceItems.map((item) => normalizeText(item)).filter(Boolean)
const normalizedRiskCards = dedupeLowerSeverityRiskCards(riskCards.filter(Boolean))
const hasHighRisk = normalizedRiskCards.some((card) => card.tone === 'high')
const sortedRiskCards = sortRiskCardsByTone(normalizedRiskCards)
if (
!normalizedCompletionItems.length
&& !normalizedMaterialPrompts.length
&& !normalizedProfileAdviceItems.length
&& !normalizedRiskCards.length
) {
return {
tone: 'ready',
badge: '可以提交',
summary: '自动检测未发现票据、金额、行程或历史画像异常,可以提交审批。',
items: [],
riskCards: [],
sections: []
}
}
const sections = []
if (normalizedCompletionItems.length) {
sections.push({
kind: 'completion',
title: '建议补充字段',
items: normalizedCompletionItems
})
}
if (normalizedMaterialPrompts.length) {
sections.push({
kind: 'material',
title: '材料补充提示',
items: normalizedMaterialPrompts
})
}
if (normalizedProfileAdviceItems.length) {
sections.push({
kind: 'profile',
title: '历史操作建议',
items: normalizedProfileAdviceItems
})
}
if (normalizedRiskCards.length) {
sections.push({
kind: 'risk',
title: `已知存在风险(${normalizedRiskCards.length}项)`,
items: sortedRiskCards,
totalCount: normalizedRiskCards.length
})
}
return {
tone: hasHighRisk ? 'warning' : 'pending',
badge: hasHighRisk ? '优先整改' : normalizedRiskCards.length ? '待核对' : '建议关注',
summary: normalizedRiskCards.length
? `自动检测发现 ${normalizedRiskCards.length} 个风险点,已按风险等级排序全部展示。`
: normalizedMaterialPrompts.length
? `自动检测发现 ${normalizedMaterialPrompts.length} 条材料补充提示,不作为风险计数。`
: '结合历史操作记录生成提交建议,请按提示核对后提交审批。',
items: normalizedCompletionItems,
riskCards: normalizedRiskCards,
sections
}
}
function sortRiskCardsByTone(cards) {
const toneWeight = {
high: 0,
medium: 1,
low: 2,
normal: 3,
pass: 4
}
return [...cards].sort((left, right) => {
const leftWeight = toneWeight[normalizeText(left?.tone).toLowerCase()] ?? 9
const rightWeight = toneWeight[normalizeText(right?.tone).toLowerCase()] ?? 9
return leftWeight - rightWeight
})
}

View File

@@ -3,18 +3,23 @@ import {
isRiskSummaryWithRisk,
normalizeRiskFlagTone
} from '../../utils/riskFlags.js'
import {
resolveRiskActionability,
resolveRiskDomain,
resolveRiskVisibilityScope
} from '../../utils/riskVisibility.js'
import {
normalizeBusinessStage,
resolveFlagBusinessStage,
resolveRequestBusinessStage,
resolveRiskTextBusinessStage
} from './travelRequestDetailBusinessStage.js'
import { cardLikeText } from './travelRequestDetailRiskCardDedupe.js'
import { resolveRouteRelatedItemIdsForRisk } from './travelRequestDetailRouteRisk.js'
import { withRiskTags } from './travelRequestDetailRiskTags.js'
export { buildAiAdviceViewModel } from './travelRequestDetailAiAdviceModel.js'
export {
extractRiskTagsFromText,
filterRiskCardsByBusinessStage,
resolveRiskTags,
resolveRiskTagTone
} from './travelRequestDetailRiskTags.js'
const DOCUMENT_TYPE_LABELS = {
flight_itinerary: '机票/航班行程单',
@@ -53,45 +58,6 @@ function uniqueTexts(values) {
return [...new Set(values.map((item) => normalizeText(item)).filter(Boolean))]
}
function cardLikeText(card = {}) {
return [
card.label,
card.title,
card.risk,
card.message,
card.summary,
card.suggestion,
card.description,
card.detail
].map((item) => normalizeText(item)).join(' ')
}
function resolveRiskCardItemIds(card = {}) {
return normalizeIdList([
card.itemId,
card.item_id,
...(Array.isArray(card.itemIds) ? card.itemIds : []),
...(Array.isArray(card.item_ids) ? card.item_ids : [])
])
}
function resolveDuplicateRiskGroup(card = {}) {
const text = cardLikeText(card)
if (/多城市行程|中转|多地拜访|改签|多地出差|后续行程|行程终点异常|连续闭环/.test(text) && /待说明|未说明|缺少说明|原因|说明|不一致|异常/.test(text)) {
return 'route-explanation'
}
return ''
}
function riskCardsReferToSameIssue(left = {}, right = {}) {
const leftItemIds = resolveRiskCardItemIds(left)
const rightItemIds = resolveRiskCardItemIds(right)
if (!leftItemIds.length || !rightItemIds.length) {
return true
}
return leftItemIds.some((itemId) => rightItemIds.includes(itemId))
}
function normalizeTone(value) {
const tone = normalizeText(value).toLowerCase()
if (['pass', 'success', 'ok', 'normal', 'none', 'compliant', 'approved'].includes(tone)) return 'pass'
@@ -121,30 +87,6 @@ function isRiskTone(tone) {
return ['medium', 'high'].includes(normalizeText(tone).toLowerCase())
}
function riskToneWeight(tone) {
const normalizedTone = normalizeTone(tone)
if (normalizedTone === 'high') return 0
if (normalizedTone === 'medium') return 1
if (normalizedTone === 'low') return 2
if (normalizedTone === 'pass') return 4
return 9
}
function dedupeLowerSeverityRiskCards(cards = []) {
return cards.filter((card, index) => {
const duplicateGroup = resolveDuplicateRiskGroup(card)
if (!duplicateGroup) {
return true
}
return !cards.some((otherCard, otherIndex) => (
otherIndex !== index
&& resolveDuplicateRiskGroup(otherCard) === duplicateGroup
&& riskToneWeight(otherCard?.tone || otherCard?.severity) < riskToneWeight(card?.tone || card?.severity)
&& riskCardsReferToSameIssue(card, otherCard)
))
})
}
function normalizeId(value) {
return normalizeText(value)
}
@@ -349,80 +291,6 @@ export function buildItemClaimRiskState(item, claimRiskFlags = []) {
}
}
export function resolveRiskTagTone(tag) {
const normalized = normalizeText(tag).toLowerCase()
if (normalized === '#high_risk') return 'high'
if (normalized === '#middle_risk') return 'medium'
if (normalized === '#low_risk') return 'low'
if (normalized === '#hotel') return 'hotel'
if (normalized === '#traffic') return 'traffic'
return 'neutral'
}
export function extractRiskTagsFromText(text) {
const matches = normalizeText(text).match(/#[A-Za-z_]+/g) || []
return [...new Set(matches.map((tag) => tag.toLowerCase()))]
}
export function resolveRiskTags(card = {}) {
const tags = []
const tone = normalizeTone(card.tone || card.severity)
if (tone === 'high') {
tags.push('#high_risk')
} else if (tone === 'medium') {
tags.push('#middle_risk')
} else if (tone === 'low') {
tags.push('#low_risk')
}
const text = [
card.label,
card.title,
card.risk,
card.summary,
card.suggestion,
card.itemType,
card.documentType
].map((item) => normalizeText(item).toLowerCase()).join(' ')
if (/住宿|酒店|宾馆|hotel/.test(text)) {
tags.push('#hotel')
}
if (/交通|火车|高铁|机票|航班|出租车|网约车|乘车|车票|train|flight|taxi|traffic|transport/.test(text)) {
tags.push('#traffic')
}
return [...new Set(tags)]
}
function withRiskTags(card) {
const businessStage = normalizeBusinessStage(
card.businessStage
|| card.business_stage
|| card.controlStage
|| card.control_stage
)
const riskDomain = resolveRiskDomain(card)
const actionability = resolveRiskActionability(card, { businessStage, riskDomain })
const visibilityScope = resolveRiskVisibilityScope(card, { businessStage, riskDomain, actionability })
return {
...card,
...(businessStage ? { businessStage } : {}),
riskDomain,
risk_domain: riskDomain,
actionability,
visibilityScope,
visibility_scope: visibilityScope,
tags: resolveRiskTags(card)
}
}
export function filterRiskCardsByBusinessStage(cards = [], businessStage = 'reimbursement') {
const targetStage = normalizeBusinessStage(businessStage) || 'reimbursement'
return (Array.isArray(cards) ? cards : []).filter(
(card) => resolveFlagBusinessStage(card, targetStage) === targetStage
)
}
function resolveDocumentTypeLabel(value) {
return DOCUMENT_TYPE_LABELS[normalizeText(value)] || DOCUMENT_TYPE_LABELS.other
}
@@ -913,92 +781,3 @@ export function buildClaimSummaryRiskCards(request = {}) {
suggestion: resolveClaimRiskSuggestion({ source: 'risk_summary' }, { risk: summary, summary })
})]
}
export function buildAiAdviceViewModel({
completionItems = [],
materialPrompts = [],
profileAdviceItems = [],
riskCards = []
} = {}) {
const normalizedCompletionItems = completionItems.map((item) => normalizeText(item)).filter(Boolean)
const normalizedMaterialPrompts = materialPrompts.map((item) => normalizeText(item)).filter(Boolean)
const normalizedProfileAdviceItems = profileAdviceItems.map((item) => normalizeText(item)).filter(Boolean)
const normalizedRiskCards = dedupeLowerSeverityRiskCards(riskCards.filter(Boolean))
const hasHighRisk = normalizedRiskCards.some((card) => card.tone === 'high')
const sortedRiskCards = sortRiskCardsByTone(normalizedRiskCards)
if (
!normalizedCompletionItems.length
&& !normalizedMaterialPrompts.length
&& !normalizedProfileAdviceItems.length
&& !normalizedRiskCards.length
) {
return {
tone: 'ready',
badge: '可以提交',
summary: '自动检测未发现票据、金额、行程或历史画像异常,可以提交审批。',
items: [],
riskCards: [],
sections: []
}
}
const sections = []
if (normalizedCompletionItems.length) {
sections.push({
kind: 'completion',
title: '建议补充字段',
items: normalizedCompletionItems
})
}
if (normalizedMaterialPrompts.length) {
sections.push({
kind: 'material',
title: '材料补充提示',
items: normalizedMaterialPrompts
})
}
if (normalizedProfileAdviceItems.length) {
sections.push({
kind: 'profile',
title: '历史操作建议',
items: normalizedProfileAdviceItems
})
}
if (normalizedRiskCards.length) {
sections.push({
kind: 'risk',
title: `已知存在风险(${normalizedRiskCards.length}项)`,
items: sortedRiskCards,
totalCount: normalizedRiskCards.length
})
}
return {
tone: hasHighRisk ? 'warning' : 'pending',
badge: hasHighRisk ? '优先整改' : normalizedRiskCards.length ? '待核对' : '建议关注',
summary: normalizedRiskCards.length
? `自动检测发现 ${normalizedRiskCards.length} 个风险点,已按风险等级排序全部展示。`
: normalizedMaterialPrompts.length
? `自动检测发现 ${normalizedMaterialPrompts.length} 条材料补充提示,不作为风险计数。`
: '结合历史操作记录生成提交建议,请按提示核对后提交审批。',
items: normalizedCompletionItems,
riskCards: normalizedRiskCards,
sections
}
}
function sortRiskCardsByTone(cards) {
const toneWeight = {
high: 0,
medium: 1,
low: 2,
normal: 3,
pass: 4
}
return [...cards].sort((left, right) => {
const leftWeight = toneWeight[normalizeText(left?.tone).toLowerCase()] ?? 9
const rightWeight = toneWeight[normalizeText(right?.tone).toLowerCase()] ?? 9
return leftWeight - rightWeight
})
}

View File

@@ -0,0 +1,84 @@
function normalizeText(value) {
return String(value || '').trim()
}
function normalizeTone(value) {
const tone = normalizeText(value).toLowerCase()
if (['pass', 'success', 'ok', 'normal', 'none', 'compliant', 'approved'].includes(tone)) return 'pass'
if (tone === 'high') return 'high'
if (tone === 'medium') return 'medium'
if (tone === 'low') return 'low'
return 'medium'
}
function normalizeIdList(value) {
const rawValues = Array.isArray(value)
? value
: normalizeText(value)
? [value]
: []
return [...new Set(rawValues.map((item) => normalizeText(item)).filter(Boolean))]
}
export function cardLikeText(card = {}) {
return [
card.label,
card.title,
card.risk,
card.message,
card.summary,
card.suggestion,
card.description,
card.detail
].map((item) => normalizeText(item)).join(' ')
}
function resolveRiskCardItemIds(card = {}) {
return normalizeIdList([
card.itemId,
card.item_id,
...(Array.isArray(card.itemIds) ? card.itemIds : []),
...(Array.isArray(card.item_ids) ? card.item_ids : [])
])
}
function resolveDuplicateRiskGroup(card = {}) {
const text = cardLikeText(card)
if (/多城市行程|中转|多地拜访|改签|多地出差|后续行程|行程终点异常|连续闭环/.test(text) && /待说明|未说明|缺少说明|原因|说明|不一致|异常/.test(text)) {
return 'route-explanation'
}
return ''
}
function riskCardsReferToSameIssue(left = {}, right = {}) {
const leftItemIds = resolveRiskCardItemIds(left)
const rightItemIds = resolveRiskCardItemIds(right)
if (!leftItemIds.length || !rightItemIds.length) {
return true
}
return leftItemIds.some((itemId) => rightItemIds.includes(itemId))
}
function riskToneWeight(tone) {
const normalizedTone = normalizeTone(tone)
if (normalizedTone === 'high') return 0
if (normalizedTone === 'medium') return 1
if (normalizedTone === 'low') return 2
if (normalizedTone === 'pass') return 4
return 9
}
export function dedupeLowerSeverityRiskCards(cards = []) {
return cards.filter((card, index) => {
const duplicateGroup = resolveDuplicateRiskGroup(card)
if (!duplicateGroup) {
return true
}
return !cards.some((otherCard, otherIndex) => (
otherIndex !== index
&& resolveDuplicateRiskGroup(otherCard) === duplicateGroup
&& riskToneWeight(otherCard?.tone || otherCard?.severity) < riskToneWeight(card?.tone || card?.severity)
&& riskCardsReferToSameIssue(card, otherCard)
))
})
}

View File

@@ -0,0 +1,96 @@
import {
resolveRiskActionability,
resolveRiskDomain,
resolveRiskVisibilityScope
} from '../../utils/riskVisibility.js'
import {
normalizeBusinessStage,
resolveFlagBusinessStage
} from './travelRequestDetailBusinessStage.js'
function normalizeText(value) {
return String(value || '').trim()
}
function normalizeTone(value) {
const tone = normalizeText(value).toLowerCase()
if (['pass', 'success', 'ok', 'normal', 'none', 'compliant', 'approved'].includes(tone)) return 'pass'
if (tone === 'high') return 'high'
if (tone === 'medium') return 'medium'
if (tone === 'low') return 'low'
return 'medium'
}
export function resolveRiskTagTone(tag) {
const normalized = normalizeText(tag).toLowerCase()
if (normalized === '#high_risk') return 'high'
if (normalized === '#middle_risk') return 'medium'
if (normalized === '#low_risk') return 'low'
if (normalized === '#hotel') return 'hotel'
if (normalized === '#traffic') return 'traffic'
return 'neutral'
}
export function extractRiskTagsFromText(text) {
const matches = normalizeText(text).match(/#[A-Za-z_]+/g) || []
return [...new Set(matches.map((tag) => tag.toLowerCase()))]
}
export function resolveRiskTags(card = {}) {
const tags = []
const tone = normalizeTone(card.tone || card.severity)
if (tone === 'high') {
tags.push('#high_risk')
} else if (tone === 'medium') {
tags.push('#middle_risk')
} else if (tone === 'low') {
tags.push('#low_risk')
}
const text = [
card.label,
card.title,
card.risk,
card.summary,
card.suggestion,
card.itemType,
card.documentType
].map((item) => normalizeText(item).toLowerCase()).join(' ')
if (/住宿|酒店|宾馆|hotel/.test(text)) {
tags.push('#hotel')
}
if (/交通|火车|高铁|机票|航班|出租车|网约车|乘车|车票|train|flight|taxi|traffic|transport/.test(text)) {
tags.push('#traffic')
}
return [...new Set(tags)]
}
export function withRiskTags(card) {
const businessStage = normalizeBusinessStage(
card.businessStage
|| card.business_stage
|| card.controlStage
|| card.control_stage
)
const riskDomain = resolveRiskDomain(card)
const actionability = resolveRiskActionability(card, { businessStage, riskDomain })
const visibilityScope = resolveRiskVisibilityScope(card, { businessStage, riskDomain, actionability })
return {
...card,
...(businessStage ? { businessStage } : {}),
riskDomain,
risk_domain: riskDomain,
actionability,
visibilityScope,
visibility_scope: visibilityScope,
tags: resolveRiskTags(card)
}
}
export function filterRiskCardsByBusinessStage(cards = [], businessStage = 'reimbursement') {
const targetStage = normalizeBusinessStage(businessStage) || 'reimbursement'
return (Array.isArray(cards) ? cards : []).filter(
(card) => resolveFlagBusinessStage(card, targetStage) === targetStage
)
}

View File

@@ -0,0 +1,495 @@
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import {
canApproveBudgetExpenseApplications,
canApproveLeaderExpenseClaims,
canManageExpenseClaims,
canReturnExpenseClaims,
isCurrentDirectManagerForRequest,
isCurrentRequestApplicant,
isFinanceUser,
isPlatformAdminUser
} from '../../utils/accessControl.js'
import {
buildLeaderApprovalEvents,
buildLeaderApprovalInfo
} from '../../utils/applicationApproval.js'
import {
buildApplicationDetailFactItems,
buildRelatedApplicationFactItems
} from '../../utils/expenseApplicationDetail.js'
import { buildRiskViewerContext } from '../../utils/riskVisibility.js'
import { resolveProgressStepsForViewer } from '../../utils/requestProgressViewer.js'
import { isArchivedRequestView, normalizeRequestForUi } from '../../utils/requestViewModel.js'
import {
EXPENSE_TYPE_OPTIONS,
buildFallbackExpenseItems,
buildFallbackProgressSteps,
isApplicationDocumentRequest,
rebuildExpenseItems,
resolveExpenseReasonHelper,
resolveExpenseReasonPlaceholder
} from './travelRequestDetailExpenseModel.js'
import { useTravelRequestPaymentFlow } from './travelRequestDetailPaymentFlow.js'
import { useTravelRequestDetailApprovalFlow } from './useTravelRequestDetailApprovalFlow.js'
import { useTravelRequestDetailAttachmentPreview } from './useTravelRequestDetailAttachmentPreview.js'
import { useTravelRequestDetailExpenseEditor } from './useTravelRequestDetailExpenseEditor.js'
import { useTravelRequestDetailRiskSubmit } from './useTravelRequestDetailRiskSubmit.js'
export function useTravelRequestDetailSetup(props, { emit }) {
const { toast } = useToast()
const { currentUser } = useSystemState()
const expenseItems = ref([])
const expenseAttachmentMeta = reactive({})
const riskFlagPreviewSnapshot = ref(null)
let actionBusy = { value: false }
const getActionBusy = () => Boolean(actionBusy?.value)
const request = computed(() => {
const normalized = normalizeRequestForUi(props.request)
return (
normalized || {
id: 'EXP-202605-000',
claimId: '',
reason: '待补充报销事由',
typeLabel: '其他费用',
typeCode: 'other',
detailVariant: 'general',
sceneTarget: '待补充',
location: '待补充',
occurredDisplay: '待补充',
applyTime: '待补充',
amountDisplay: '¥0',
amountValue: 0,
node: '待提交',
approval: '草稿',
approvalKey: 'draft',
approvalTone: 'draft',
secondaryStatusLabel: '票据状态',
secondaryStatusValue: '待补充',
secondaryStatusTone: 'warning',
relatedCustomer: '待补充',
attachmentSummary: '待补充',
riskSummary: '待补充',
note: '',
profileIdentity: '员工',
profilePosition: '待补充',
profileGrade: '待补充',
profileManager: '待补充',
profileName: '当前申请人',
profileDepartment: '待补充部门',
profileAvatar: '申'
}
)
})
const isApplicationDocument = computed(() => isApplicationDocumentRequest(request.value))
const isTravelRequest = computed(() => request.value.detailVariant === 'travel' && !isApplicationDocument.value)
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
const canOpenAiEntry = computed(() => isEditableRequest.value)
const isCurrentApplicant = computed(() => isCurrentRequestApplicant(request.value, currentUser.value))
const canModifyReturnedApplication = computed(() => (
isApplicationDocument.value
&& isEditableRequest.value
&& isCurrentApplicant.value
&& String(request.value.status || '').trim().toLowerCase() === 'returned'
))
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
const isApplicantDeletableRequest = computed(() => {
if (!isCurrentApplicant.value) {
return false
}
const status = String(request.value.status || request.value.approvalKey || '').trim().toLowerCase()
return ['draft', 'supplement', 'returned'].includes(status)
})
const canDeleteRequest = computed(() => {
if (isPlatformAdminUser(currentUser.value)) {
return true
}
return isApplicantDeletableRequest.value
})
const isDirectManagerApprovalStage = computed(() => {
const node = String(request.value.node || request.value.approvalStage || '').trim()
return node === '直属领导审批'
})
const isFinanceApprovalStage = computed(() => {
const node = String(request.value.node || request.value.approvalStage || '').trim()
return node === '财务审批'
})
const isBudgetApprovalStage = computed(() => {
const node = String(request.value.node || request.value.approvalStage || '').trim()
return node === '预算管理者审批'
})
const isCurrentDirectManagerApprover = computed(() => (
canApproveLeaderExpenseClaims(currentUser.value)
&& isCurrentDirectManagerForRequest(request.value, currentUser.value)
))
const canProcessFinanceApprovalStage = computed(() => (
!isApplicationDocument.value
&& isFinanceApprovalStage.value
&& isFinanceUser(currentUser.value)
&& !isCurrentApplicant.value
))
const canProcessBudgetApprovalStage = computed(() => (
isApplicationDocument.value
&& isBudgetApprovalStage.value
&& canApproveBudgetExpenseApplications(currentUser.value, request.value)
&& !isCurrentApplicant.value
))
const showBudgetAnalysis = computed(() => (
isApplicationDocument.value
&& isBudgetApprovalStage.value
&& canApproveBudgetExpenseApplications(currentUser.value, request.value)
&& !isCurrentApplicant.value
))
const canProcessCurrentApprovalStage = computed(() => {
if (isDirectManagerApprovalStage.value) {
return isCurrentDirectManagerApprover.value
}
if (isBudgetApprovalStage.value) {
return canProcessBudgetApprovalStage.value
}
return canProcessFinanceApprovalStage.value
})
const canReturnRequest = computed(() => {
if (request.value.approvalKey !== 'in_progress' || !request.value.claimId || !canReturnExpenseClaims(currentUser.value)) {
return false
}
return canProcessCurrentApprovalStage.value
})
const canApproveRequest = computed(() =>
request.value.approvalKey === 'in_progress'
&& Boolean(request.value.claimId)
&& canProcessCurrentApprovalStage.value
)
const canViewApprovalRiskAdvice = computed(() => (
Boolean(request.value.claimId)
&& !isDraftRequest.value
&& !isCurrentApplicant.value
&& (canReturnRequest.value || canApproveRequest.value)
))
const showStageRiskAdvice = computed(() => canViewApprovalRiskAdvice.value)
const riskViewerContext = computed(() => buildRiskViewerContext({
request: request.value,
currentUser: currentUser.value,
businessStage: isApplicationDocument.value ? 'expense_application' : 'reimbursement',
isApplicationDocument: isApplicationDocument.value,
isCurrentApplicant: isCurrentApplicant.value,
isBudgetReviewer: canProcessBudgetApprovalStage.value,
isDirectManagerReviewer: isCurrentDirectManagerApprover.value,
isFinanceReviewer: canProcessFinanceApprovalStage.value,
isAdminViewer: canManageCurrentClaim.value,
canViewApprovalRiskAdvice: canViewApprovalRiskAdvice.value
}))
const paymentFlow = useTravelRequestPaymentFlow({
request,
currentUser,
isApplicationDocument,
isCurrentApplicant,
toast,
emit
})
const attachmentPreview = useTravelRequestDetailAttachmentPreview({
request,
expenseItems,
expenseAttachmentMeta
})
const riskSubmit = useTravelRequestDetailRiskSubmit({
request,
expenseItems,
expenseAttachmentMeta,
riskFlagPreviewSnapshot,
isApplicationDocument,
isEditableRequest,
isDraftRequest,
isCurrentApplicant,
canViewApprovalRiskAdvice,
riskViewerContext,
getActionBusy,
toast,
emit
})
const approvalFlow = useTravelRequestDetailApprovalFlow({
request,
isApplicationDocument,
isDraftRequest,
isArchivedRequest,
isFinanceApprovalStage,
isBudgetApprovalStage,
canDeleteRequest,
canReturnRequest,
canApproveRequest,
approvalRiskConfirmItems: riskSubmit.approvalRiskConfirmItems,
canViewApprovalRiskAdvice,
toast,
emit
})
const expenseEditor = useTravelRequestDetailExpenseEditor({
request,
expenseItems,
expenseAttachmentMeta,
isEditableRequest,
getActionBusy,
toast,
emit,
attachmentPreviewOpen: attachmentPreview.attachmentPreviewOpen,
buildAttachmentRiskNotice: attachmentPreview.buildAttachmentRiskNotice,
closeAttachmentPreview: attachmentPreview.closeAttachmentPreview,
refreshExpenseAttachmentMeta: attachmentPreview.refreshExpenseAttachmentMeta,
resolveAttachmentMeta: attachmentPreview.resolveAttachmentMeta,
resolveClaimRiskFlags: riskSubmit.resolveClaimRiskFlags,
applyClaimRiskFlagsPayload: riskSubmit.applyClaimRiskFlagsPayload
})
const {
deletingAttachmentId,
deletingExpenseId,
savingExpenseId,
smartEntryRecognitionBusy,
uploadingExpenseId
} = expenseEditor
actionBusy = computed(() =>
Boolean(savingExpenseId.value)
|| riskSubmit.submitBusy.value
|| approvalFlow.deleteBusy.value
|| approvalFlow.returnBusy.value
|| approvalFlow.approveBusy.value
|| paymentFlow.payBusy.value
|| smartEntryRecognitionBusy.value
|| Boolean(uploadingExpenseId.value)
|| Boolean(deletingAttachmentId.value)
|| Boolean(deletingExpenseId.value)
)
const profile = computed(() => ({
name: request.value.profileName,
identity: request.value.profileIdentity,
position: request.value.profilePosition,
department: request.value.profileDepartment,
grade: request.value.profileGrade,
manager: request.value.profileManager,
avatar: request.value.profileAvatar
}))
const leaderApprovalInfo = computed(() => buildLeaderApprovalInfo(request.value))
const leaderApprovalEvents = computed(() => buildLeaderApprovalEvents(request.value))
const hasLeaderApprovalEvents = computed(() => leaderApprovalEvents.value.length > 0)
const hasSingleLeaderApprovalEvent = computed(() => leaderApprovalEvents.value.length === 1)
const leaderApprovalReadonlyMeta = computed(() => {
const pieces = hasLeaderApprovalEvents.value ? [`${leaderApprovalEvents.value.length} 条批复记录`] : []
if (leaderApprovalInfo.value.generatedDraftClaimNo) {
pieces.push(`已生成报销草稿 ${leaderApprovalInfo.value.generatedDraftClaimNo}`)
}
return pieces.join(' · ')
})
const showApplicationLeaderOpinion = computed(() => (
isApplicationDocument.value
&& hasLeaderApprovalEvents.value
))
const heroFactItems = computed(() => [
{
key: 'document',
label: isApplicationDocument.value ? '申请单号' : '报销单号',
value: request.value.documentNo || request.value.id,
icon: 'mdi mdi-camera-outline',
valueClass: ''
},
{
key: 'date',
label: '单据申请日期',
value: request.value.applyTime || request.value.occurredDisplay,
icon: 'mdi mdi-calendar-month-outline',
valueClass: ''
},
{
key: 'amount',
label: isApplicationDocument.value ? '预计金额' : '报销金额',
value: request.value.amountDisplay,
icon: '',
valueClass: 'amount'
},
{
key: 'type',
label: isApplicationDocument.value ? '申请类型' : isTravelRequest.value ? '差旅类型' : '报销类型',
value: request.value.typeLabel,
icon: '',
valueClass: ''
}
])
const progressSteps = computed(() => {
const sourceSteps = Array.isArray(request.value.progressSteps) && request.value.progressSteps.length
? request.value.progressSteps
: buildFallbackProgressSteps(request.value)
return resolveProgressStepsForViewer(sourceSteps, {
isApplicationDocument: isApplicationDocument.value,
isCurrentDirectManagerApprover: isCurrentDirectManagerApprover.value
})
})
const currentProgressRingMotion = {
initial: { scale: 1, opacity: 0.34 },
enter: {
scale: [1, 1.42, 1.78],
opacity: [0.34, 0.16, 0],
transition: {
duration: 3.2,
repeat: Infinity,
repeatType: 'loop',
repeatDelay: 0.85,
ease: 'easeOut',
times: [0, 0.5, 1]
}
}
}
const submitConfirmAmountDisplay = computed(() =>
isApplicationDocument.value ? (request.value.amountDisplay || expenseEditor.expenseTotal.value) : expenseEditor.expenseTotal.value
)
const applicationDetailFactItems = computed(() => buildApplicationDetailFactItems(request.value))
const relatedApplicationFactItems = computed(() => buildRelatedApplicationFactItems(request.value))
watch(
request,
(nextRequest, previousRequest) => {
expenseItems.value =
Array.isArray(nextRequest.expenseItems)
? rebuildExpenseItems(nextRequest.expenseItems, nextRequest)
: buildFallbackExpenseItems(nextRequest)
if (nextRequest.claimId !== previousRequest?.claimId) {
Object.keys(expenseAttachmentMeta).forEach((key) => {
delete expenseAttachmentMeta[key]
})
riskSubmit.resetSubmitWorkState()
attachmentPreview.closeAttachmentPreview()
}
expenseEditor.resetExpenseWorkState()
void attachmentPreview.syncExpenseAttachmentMeta()
},
{ immediate: true }
)
watch(
() => request.value.claimId,
() => {
riskSubmit.clearRiskFlagPreviewSnapshot()
expenseEditor.resetSmartEntryRecognitionApplications()
expenseEditor.bindSmartEntryRecognitionTask()
},
{ immediate: true }
)
function buildApplicationEditPreview() {
const factEntries = applicationDetailFactItems.value
.map((item) => [String(item?.label || '').trim(), String(item?.value || '').trim()])
.filter(([label, value]) => label && value)
const facts = new Map(factEntries)
const pickFact = (...labels) => {
for (const label of labels) {
const value = facts.get(label)
if (value) {
return value
}
}
return ''
}
const tripStart = pickFact('出发时间')
const tripReturn = pickFact('返回时间')
const time = tripStart && tripReturn && tripStart !== tripReturn
? `${tripStart}${tripReturn}`
: pickFact('行程时间', '申请时间', '招待时间', '发生时间') || tripStart
return {
sourceText: '修改申请',
modelReviewStatus: 'template',
fields: {
applicationType: pickFact('申请类型') || request.value.typeLabel || '费用申请',
applicant: request.value.profileName || request.value.person || request.value.applicant || '',
grade: pickFact('职级') || request.value.profileGrade || '',
department: request.value.profileDepartment || request.value.departmentName || request.value.department || '',
position: request.value.profilePosition || request.value.employeePosition || request.value.position || '',
managerName: request.value.profileManager || request.value.managerName || request.value.manager || '',
time,
location: pickFact('地点') || request.value.location || request.value.city || '',
reason: pickFact('事由') || request.value.reason || '',
days: pickFact('天数'),
transportMode: pickFact('出行方式'),
lodgingDailyCap: pickFact('住宿上限/天'),
subsidyDailyCap: pickFact('补贴标准/天'),
transportPolicy: pickFact('交通费用口径'),
policyEstimate: pickFact('规则测算参考'),
amount: pickFact('系统预估费用', '用户预估费用', '预计金额') || request.value.amountDisplay || request.value.amount || ''
}
}
}
function handleModifyApplication() {
if (!canModifyReturnedApplication.value) {
return
}
const claimId = String(request.value?.claimId || '').trim()
emit('openAssistant', {
source: 'application',
sessionType: 'application',
prompt: '',
applicationPreview: buildApplicationEditPreview(),
request: {
...request.value,
applicationEditMode: true
},
restoreLatestConversation: false,
initialPromptAutoSubmit: false,
scope: claimId
? { type: 'claim', claimId }
: null
})
}
onBeforeUnmount(() => {
riskSubmit.disposeRiskSubmit()
expenseEditor.disposeExpenseEditor()
attachmentPreview.closeAttachmentPreview()
})
return {
emit,
actionBusy,
...attachmentPreview,
...approvalFlow,
...expenseEditor,
...paymentFlow,
...riskSubmit,
applicationDetailFactItems,
relatedApplicationFactItems,
canDeleteRequest,
canManageCurrentClaim,
canModifyReturnedApplication,
canOpenAiEntry,
canApproveRequest,
canReturnRequest,
currentProgressRingMotion,
expenseItems,
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
handleModifyApplication,
hasLeaderApprovalEvents,
hasSingleLeaderApprovalEvent,
heroFactItems,
isApplicationDocument,
isDraftRequest,
isEditableRequest,
isTravelRequest,
leaderApprovalEvents,
leaderApprovalReadonlyMeta,
profile,
progressSteps,
request,
resolveExpenseReasonHelper,
resolveExpenseReasonPlaceholder,
showApplicationLeaderOpinion,
showBudgetAnalysis,
showStageRiskAdvice,
submitConfirmAmountDisplay
}
}

View File

@@ -0,0 +1,225 @@
import {
createExpenseClaimItem,
uploadExpenseClaimItemAttachment
} from '../../services/reimbursements.js'
import {
formatCurrency,
normalizeIsoDateValue
} from './travelRequestDetailExpenseModel.js'
const SMART_ENTRY_RECOGNITION_TASK_RETENTION_MS = 10 * 60 * 1000
const smartEntryRecognitionTasks = new Map()
let smartEntryRecognitionTaskSeq = 0
function normalizeSmartEntryClaimId(claimId) {
return String(claimId || '').trim()
}
export function buildRecognizedExpenseItemPatch(payload, fileName = '') {
const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount)
const recognizedItemDate = normalizeIsoDateValue(payload?.item_date ?? payload?.itemDate)
const recognizedItemType = String(payload?.item_type ?? payload?.itemType ?? '').trim()
const recognizedItemReason = String(payload?.item_reason ?? payload?.itemReason ?? '').trim()
const recognizedItemLocation = String(payload?.item_location ?? payload?.itemLocation ?? '').trim()
const itemPatch = {
invoiceId: String(payload?.invoice_id || '').trim(),
attachmentHint: String(payload?.attachment?.file_name || fileName || '').trim()
}
if (recognizedItemDate) {
itemPatch.itemDate = recognizedItemDate
}
if (recognizedItemType) {
itemPatch.itemType = recognizedItemType
}
if (recognizedItemReason) {
itemPatch.itemReason = recognizedItemReason
}
if (recognizedItemLocation) {
itemPatch.itemLocation = recognizedItemLocation
}
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
itemPatch.itemAmount = recognizedItemAmount
itemPatch.amount = formatCurrency(recognizedItemAmount)
}
return itemPatch
}
function buildSmartEntryRecognitionSnapshot(task) {
if (!task) {
return null
}
return {
id: task.id,
claimId: task.claimId,
busy: task.busy,
total: task.total,
current: task.current,
completed: task.completed,
successCount: task.successCount,
failedCount: task.failedCount,
uploadingItemId: task.uploadingItemId,
fileName: task.fileName,
status: task.status,
payloads: [...task.payloads],
errors: [...task.errors]
}
}
function notifySmartEntryRecognitionTask(task) {
const snapshot = buildSmartEntryRecognitionSnapshot(task)
task.listeners.forEach((listener) => {
try {
listener(snapshot)
} catch (error) {
console.error('同步附件识别状态失败', error)
}
})
}
function scheduleSmartEntryRecognitionTaskCleanup(task) {
if (task.cleanupTimer) {
clearTimeout(task.cleanupTimer)
}
task.cleanupTimer = globalThis.setTimeout(() => {
const currentTask = smartEntryRecognitionTasks.get(task.claimId)
if (currentTask?.id === task.id && !currentTask.busy) {
smartEntryRecognitionTasks.delete(task.claimId)
}
}, SMART_ENTRY_RECOGNITION_TASK_RETENTION_MS)
}
function getSmartEntryRecognitionTask(claimId) {
return smartEntryRecognitionTasks.get(normalizeSmartEntryClaimId(claimId)) || null
}
export function subscribeSmartEntryRecognitionTask(claimId, listener) {
const task = getSmartEntryRecognitionTask(claimId)
if (!task) {
listener(null)
return () => {}
}
task.listeners.add(listener)
listener(buildSmartEntryRecognitionSnapshot(task))
return () => {
task.listeners.delete(listener)
}
}
function resolveSmartEntryTaskAvailableItems(itemSnapshots) {
return (Array.isArray(itemSnapshots) ? itemSnapshots : [])
.filter((item) => item && !item.isSystemGenerated && !item.invoiceId)
.map((item) => ({ id: String(item.id || '').trim() }))
.filter((item) => item.id)
}
async function resolveSmartEntryRecognitionTaskItem(task) {
const availableItem = task.availableItems.shift()
if (availableItem?.id) {
return { id: availableItem.id, createdItem: null }
}
const claim = await createExpenseClaimItem(task.claimId, {})
const items = Array.isArray(claim?.items) ? claim.items : []
const createdItem = items.find((entry) => {
const itemId = String(entry?.id || '').trim()
return itemId && !task.knownItemIds.has(itemId)
})
if (!createdItem) {
throw new Error('新增费用明细失败,请稍后重试。')
}
const itemId = String(createdItem.id || '').trim()
task.knownItemIds.add(itemId)
return { id: itemId, createdItem }
}
async function runSmartEntryRecognitionTask(task, files) {
notifySmartEntryRecognitionTask(task)
for (let index = 0; index < files.length; index += 1) {
const file = files[index]
const fileName = String(file?.name || `${index + 1} 张附件`).trim()
task.current = index + 1
task.fileName = fileName
task.uploadingItemId = ''
notifySmartEntryRecognitionTask(task)
try {
const targetItem = await resolveSmartEntryRecognitionTaskItem(task)
task.uploadingItemId = targetItem.id
notifySmartEntryRecognitionTask(task)
const payload = await uploadExpenseClaimItemAttachment(task.claimId, targetItem.id, file)
task.successCount += 1
task.payloads.push({
id: `${task.id}:${index}:${targetItem.id}`,
itemId: targetItem.id,
fileName,
payload,
createdItem: targetItem.createdItem
})
} catch (error) {
task.failedCount += 1
task.errors.push({
fileName,
message: error?.message || '附件识别失败,请稍后重试。'
})
} finally {
task.completed = index + 1
task.uploadingItemId = ''
notifySmartEntryRecognitionTask(task)
}
}
task.busy = false
task.current = task.total
task.fileName = ''
task.status = task.failedCount
? task.successCount
? 'partial'
: 'failed'
: 'completed'
notifySmartEntryRecognitionTask(task)
scheduleSmartEntryRecognitionTaskCleanup(task)
}
export function startSmartEntryRecognitionTask({ claimId, files, itemSnapshots }) {
const normalizedClaimId = normalizeSmartEntryClaimId(claimId)
const pendingFiles = Array.isArray(files) ? files.filter(Boolean) : []
if (!normalizedClaimId || !pendingFiles.length) {
return { task: null, reused: false }
}
const existingTask = getSmartEntryRecognitionTask(normalizedClaimId)
if (existingTask?.busy) {
return { task: existingTask, reused: true }
}
const sourceItems = Array.isArray(itemSnapshots) ? itemSnapshots : []
const task = {
id: `smart-entry-${Date.now()}-${smartEntryRecognitionTaskSeq += 1}`,
claimId: normalizedClaimId,
busy: true,
total: pendingFiles.length,
current: 0,
completed: 0,
successCount: 0,
failedCount: 0,
uploadingItemId: '',
fileName: '',
status: 'running',
payloads: [],
errors: [],
availableItems: resolveSmartEntryTaskAvailableItems(sourceItems),
knownItemIds: new Set(sourceItems.map((item) => String(item?.id || '').trim()).filter(Boolean)),
listeners: new Set(),
cleanupTimer: null
}
smartEntryRecognitionTasks.set(normalizedClaimId, task)
void runSmartEntryRecognitionTask(task, pendingFiles)
return { task, reused: false }
}

View File

@@ -0,0 +1,158 @@
import { computed } from 'vue'
import { isFinanceUser, isManagerUser, isPlatformAdminUser } from '../../utils/accessControl.js'
import { normalizeText } from './auditViewModel.js'
export function useAuditViewPermissions({
currentUser,
selectedSkill,
actionState
}) {
const detailBusy = computed(() => Boolean(actionState.value))
const isAdmin = computed(() => isPlatformAdminUser(currentUser.value))
const isRuleManager = computed(() => isManagerUser(currentUser.value))
const isFinance = computed(() => isFinanceUser(currentUser.value))
const selectedSkillIsRule = computed(() => selectedSkill.value?.type === 'rules')
const selectedSkillUsesSpreadsheet = computed(
() => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesSpreadsheetRule)
)
const selectedSkillUsesJsonRisk = computed(
() => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesJsonRiskRule)
)
const canManageSelected = computed(
() => isRuleManager.value && Boolean(selectedSkill.value) && !selectedSkill.value?.isPreviewMock
)
const canAdminOperateSelected = computed(
() => isAdmin.value && Boolean(selectedSkill.value) && !selectedSkill.value?.isPreviewMock
)
const canEditSelected = computed(
() =>
Boolean(selectedSkill.value) &&
!selectedSkill.value?.isPreviewMock &&
(isAdmin.value || isFinance.value)
)
const latestRiskRuleTestSummary = computed(() => selectedSkill.value?.latestTestSummary || null)
const riskRuleTestPassed = computed(() => Boolean(latestRiskRuleTestSummary.value?.test_passed))
const riskRuleInReview = computed(
() => selectedSkillUsesJsonRisk.value && selectedSkill.value?.statusValue === 'review'
)
const riskRuleGenerationBusy = computed(
() => selectedSkillUsesJsonRisk.value && selectedSkill.value?.statusValue === 'generating'
)
const riskRuleGenerationFailed = computed(
() => selectedSkillUsesJsonRisk.value && selectedSkill.value?.statusValue === 'failed'
)
const canOpenRiskRuleTest = computed(
() =>
selectedSkillUsesJsonRisk.value &&
canAdminOperateSelected.value &&
Boolean(selectedSkill.value?.id) &&
!riskRuleGenerationBusy.value &&
!riskRuleGenerationFailed.value
)
const canDeleteRiskRule = computed(
() =>
selectedSkillUsesJsonRisk.value &&
canAdminOperateSelected.value &&
Boolean(selectedSkill.value?.id) &&
!normalizeText(selectedSkill.value?.publishedVersion).replace('-', '')
)
const canOpenRiskRuleReviewSubmit = computed(() => false)
const canSubmitRiskRuleReview = computed(
() =>
canOpenRiskRuleReviewSubmit.value &&
riskRuleTestPassed.value
)
const canReturnRiskRule = computed(() => false)
const riskRuleHasPublishableRevision = computed(() => {
const revision = selectedSkill.value?.configJson?.revision_draft
return selectedSkillUsesJsonRisk.value && revision &&
revision.generation_status === 'completed' &&
normalizeText(selectedSkill.value?.workingVersion).replace('-', '') &&
selectedSkill.value?.workingVersion !== selectedSkill.value?.publishedVersion
})
const canPublishRiskRule = computed(
() =>
Boolean(riskRuleHasPublishableRevision.value) &&
canManageSelected.value &&
riskRuleTestPassed.value &&
!detailBusy.value
)
const canToggleRiskRuleEnabled = computed(
() => selectedSkillUsesJsonRisk.value && canManageSelected.value
)
const canEditRiskRuleDraft = computed(
() =>
selectedSkillUsesJsonRisk.value &&
(canEditSelected.value || canManageSelected.value) &&
!detailBusy.value &&
!riskRuleGenerationBusy.value &&
!normalizeText(selectedSkill.value?.publishedVersion).replace('-', '')
)
const canCreateRiskRuleRevision = computed(
() =>
selectedSkillUsesJsonRisk.value &&
(canEditSelected.value || canManageSelected.value) &&
!detailBusy.value &&
!riskRuleGenerationBusy.value &&
!riskRuleGenerationFailed.value &&
Boolean(normalizeText(selectedSkill.value?.publishedVersion).replace('-', ''))
)
const canEditMarkdown = computed(() => selectedSkillIsRule.value && canEditSelected.value)
const isDisplayingWorkingVersion = computed(
() => selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion
)
const canUploadSpreadsheet = computed(
() =>
canEditSelected.value &&
selectedSkillUsesSpreadsheet.value &&
!detailBusy.value
)
const canDownloadSpreadsheet = computed(
() =>
selectedSkillUsesSpreadsheet.value &&
Boolean(selectedSkill.value?.id) &&
!detailBusy.value
)
const canEditSpreadsheetInline = computed(
() =>
selectedSkillUsesSpreadsheet.value &&
(selectedSkill.value?.isPreviewMock || canEditSelected.value)
)
const selectedSpreadsheetFileName = computed(
() =>
normalizeText(selectedSkill.value?.ruleDocument?.file_name) || '未上传规则表'
)
return {
detailBusy,
isAdmin,
selectedSkillIsRule,
selectedSkillUsesSpreadsheet,
selectedSkillUsesJsonRisk,
canManageSelected,
canAdminOperateSelected,
canEditSelected,
latestRiskRuleTestSummary,
riskRuleTestPassed,
riskRuleInReview,
riskRuleGenerationBusy,
riskRuleGenerationFailed,
canOpenRiskRuleTest,
canDeleteRiskRule,
canOpenRiskRuleReviewSubmit,
canSubmitRiskRuleReview,
canReturnRiskRule,
riskRuleHasPublishableRevision,
canPublishRiskRule,
canToggleRiskRuleEnabled,
canEditRiskRuleDraft,
canCreateRiskRuleRevision,
canEditMarkdown,
isDisplayingWorkingVersion,
canUploadSpreadsheet,
canDownloadSpreadsheet,
canEditSpreadsheetInline,
selectedSpreadsheetFileName
}
}

View File

@@ -0,0 +1,182 @@
import { computed, ref, watch } from 'vue'
import {
DEFAULT_STATUS_TABS,
buildEmployeeEmptyState,
buildEmployeeSummary,
buildStatusTabs,
mapSimpleFilterOptions,
matchKeyword,
uniqueSorted
} from './employeeManagementModel.js'
export function useEmployeeManagementFilters(options = {}) {
const employees = options.employees
const roleOptions = options.roleOptions
const activeTab = ref(DEFAULT_STATUS_TABS[0])
const searchKeyword = ref('')
const selectedDepartment = ref('')
const selectedGrade = ref('')
const selectedRole = ref('')
const activeFilterPopover = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const pageSizes = [10, 20, 50]
const pageSizeOptions = pageSizes.map((size) => ({ label: `${size} 条/页`, value: size }))
const tabs = computed(() => buildStatusTabs(employees.value))
const employeeSummary = computed(() => buildEmployeeSummary(employees.value))
const departmentOptions = computed(() =>
uniqueSorted(employees.value.map((item) => item.department))
)
const gradeOptions = computed(() => uniqueSorted(employees.value.map((item) => item.grade)))
const roleFilterOptions = computed(() =>
uniqueSorted(
roleOptions.value.map((item) => item.label).concat(
employees.value.flatMap((item) => item.roles || [])
)
)
)
const departmentFilterOptions = computed(() => mapSimpleFilterOptions(departmentOptions.value, '全部部门'))
const gradeFilterOptions = computed(() => mapSimpleFilterOptions(gradeOptions.value, '全部职级'))
const roleDropdownOptions = computed(() => mapSimpleFilterOptions(roleFilterOptions.value, '全部角色'))
const departmentFilterLabel = computed(() => selectedDepartment.value || '组织部门')
const gradeFilterLabel = computed(() => selectedGrade.value || '职级')
const roleFilterLabel = computed(() => selectedRole.value || '系统角色')
const filteredEmployees = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
return employees.value.filter((item) => {
const matchesStatus =
activeTab.value === DEFAULT_STATUS_TABS[0] ? true : item.status === activeTab.value
const matchesDepartment = selectedDepartment.value
? item.department === selectedDepartment.value
: true
const matchesGrade = selectedGrade.value ? item.grade === selectedGrade.value : true
const matchesRole = selectedRole.value ? (item.roles || []).includes(selectedRole.value) : true
return (
matchesStatus &&
matchesDepartment &&
matchesGrade &&
matchesRole &&
matchKeyword(item, keyword)
)
})
})
const totalCount = computed(() => filteredEmployees.value.length)
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
const pageSummary = computed(() => `${totalCount.value} 条,目前第 ${currentPage.value} / ${totalPages.value}`)
const visibleEmployees = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredEmployees.value.slice(start, start + pageSize.value)
})
const activeFilterTokens = computed(() => {
const tokens = []
if (selectedDepartment.value) tokens.push(`部门:${selectedDepartment.value}`)
if (selectedGrade.value) tokens.push(`职级:${selectedGrade.value}`)
if (selectedRole.value) tokens.push(`角色:${selectedRole.value}`)
if (searchKeyword.value.trim()) tokens.push(`搜索:${searchKeyword.value.trim()}`)
return tokens
})
const hasActiveFilters = computed(() => activeFilterTokens.value.length > 0)
const hasEmployeeFilters = computed(() => {
return activeTab.value !== DEFAULT_STATUS_TABS[0] || hasActiveFilters.value
})
const employeeEmptyState = computed(() => buildEmployeeEmptyState({
activeTab: activeTab.value,
employeeCount: employees.value.length,
hasEmployeeFilters: hasEmployeeFilters.value
}))
watch(
employeeSummary,
(summary) => {
options.emit?.('overview-change', summary)
},
{ immediate: true }
)
watch(filteredEmployees, () => {
currentPage.value = 1
})
function resetFilters() {
searchKeyword.value = ''
selectedDepartment.value = ''
selectedGrade.value = ''
selectedRole.value = ''
activeTab.value = DEFAULT_STATUS_TABS[0]
activeFilterPopover.value = ''
}
function handleEmployeeEmptyAction() {
if (!employees.value.length) {
options.loadEmployees?.().catch(() => {})
return
}
resetFilters()
}
function changePageSize(size) {
pageSize.value = size
currentPage.value = 1
}
function toggleFilterPopover(name) {
activeFilterPopover.value = activeFilterPopover.value === name ? '' : name
}
function closeFilterPopover() {
activeFilterPopover.value = ''
}
function selectFilter(name, value) {
if (name === 'department') selectedDepartment.value = value
if (name === 'grade') selectedGrade.value = value
if (name === 'role') selectedRole.value = value
closeFilterPopover()
}
return {
activeTab,
searchKeyword,
selectedDepartment,
selectedGrade,
selectedRole,
activeFilterPopover,
currentPage,
pageSize,
pageSizes,
pageSizeOptions,
tabs,
employeeSummary,
departmentOptions,
gradeOptions,
roleFilterOptions,
departmentFilterOptions,
gradeFilterOptions,
roleDropdownOptions,
departmentFilterLabel,
gradeFilterLabel,
roleFilterLabel,
filteredEmployees,
totalCount,
totalPages,
pageSummary,
visibleEmployees,
activeFilterTokens,
hasActiveFilters,
hasEmployeeFilters,
employeeEmptyState,
resetFilters,
handleEmployeeEmptyAction,
changePageSize,
toggleFilterPopover,
closeFilterPopover,
selectFilter
}
}

View File

@@ -0,0 +1,217 @@
import { computed, ref } from 'vue'
import { isPlaceholderManagerName, normalizeText } from './employeeManagementModel.js'
export function useEmployeeManagementPickers(options = {}) {
const selectedEmployee = options.selectedEmployee
const employees = options.employees
const employeeForm = options.employeeForm
const organizationUnitOptions = options.organizationUnitOptions
const managerPickerOpen = ref(false)
const managerSearchKeyword = ref('')
const departmentPickerOpen = ref(false)
const departmentSearchKeyword = ref('')
const managerOptions = computed(() => {
const currentId = selectedEmployee.value?.id
return employees.value.filter((item) => item.id !== currentId)
})
const filteredManagerOptions = computed(() => {
const keyword = managerSearchKeyword.value.trim().toLowerCase()
if (!keyword) {
return managerOptions.value.slice(0, 20)
}
return managerOptions.value
.filter((item) => {
const haystack = [item.name, item.employeeNo, item.department, item.position, item.email]
.filter(Boolean)
.join(' ')
.toLowerCase()
return haystack.includes(keyword)
})
.slice(0, 20)
})
const managerDisplayLabel = computed(() => {
const managerNo = normalizeText(employeeForm.value.managerEmployeeNo)
const managerName = normalizeText(employeeForm.value.manager)
if (managerNo) {
const matched =
managerOptions.value.find((item) => item.employeeNo === managerNo) ||
employees.value.find((item) => item.employeeNo === managerNo)
if (matched) {
return `${matched.name}${matched.employeeNo}`
}
return managerName ? `${managerName}${managerNo}` : managerNo
}
if (!isPlaceholderManagerName(managerName)) {
return managerName
}
return '未设置直属上级'
})
const filteredDepartmentOptions = computed(() => {
const keyword = departmentSearchKeyword.value.trim().toLowerCase()
const sourceOptions = organizationUnitOptions.value
if (!keyword) {
return sourceOptions.slice(0, 20)
}
return sourceOptions
.filter((item) => {
const haystack = [item.name, item.code, item.unitType, item.label]
.filter(Boolean)
.join(' ')
.toLowerCase()
return haystack.includes(keyword)
})
.slice(0, 20)
})
const departmentDisplayLabel = computed(() => {
const code = normalizeText(employeeForm.value.organizationUnitCode)
const name = normalizeText(employeeForm.value.department)
if (code) {
const matched = organizationUnitOptions.value.find((item) => item.code === code)
if (matched) {
return matched.label
}
return name ? `${name}${code}` : code
}
return name || '请选择所属部门'
})
const hasManagerAssignment = computed(() => {
return (
Boolean(normalizeText(employeeForm.value.managerEmployeeNo)) ||
!isPlaceholderManagerName(employeeForm.value.manager)
)
})
function handleDocumentClick(event) {
const target = event.target
if (!(target instanceof Element)) {
options.closeFilterPopover?.()
return
}
if (!target.closest('.picker-filter')) options.closeFilterPopover?.()
if (!target.closest('.manager-picker')) closeManagerPicker()
if (!target.closest('.department-picker')) closeDepartmentPicker()
if (
target.closest('.picker-filter') ||
target.closest('.manager-picker') ||
target.closest('.department-picker')
) {
return
}
}
function toggleDepartmentPicker() {
departmentPickerOpen.value = !departmentPickerOpen.value
if (!departmentPickerOpen.value) {
departmentSearchKeyword.value = ''
}
}
function closeDepartmentPicker() {
departmentPickerOpen.value = false
departmentSearchKeyword.value = ''
}
function selectDepartment(option) {
if (!option) {
return
}
employeeForm.value.organizationUnitCode = option.code
employeeForm.value.department = option.name
closeDepartmentPicker()
}
function resolveDepartmentSelectionFromKeyword() {
const keyword = normalizeText(departmentSearchKeyword.value)
if (!keyword || normalizeText(employeeForm.value.organizationUnitCode)) {
return
}
const exactMatches = organizationUnitOptions.value.filter(
(item) => item.code === keyword || item.name === keyword
)
if (exactMatches.length === 1) {
selectDepartment(exactMatches[0])
}
}
function toggleManagerPicker() {
managerPickerOpen.value = !managerPickerOpen.value
if (!managerPickerOpen.value) {
managerSearchKeyword.value = ''
}
}
function closeManagerPicker() {
managerPickerOpen.value = false
managerSearchKeyword.value = ''
}
function selectManager(option) {
if (!option) {
employeeForm.value.managerEmployeeNo = ''
employeeForm.value.manager = ''
closeManagerPicker()
return
}
employeeForm.value.managerEmployeeNo = option.employeeNo
employeeForm.value.manager = option.name
closeManagerPicker()
}
function resolveManagerSelectionFromKeyword() {
const keyword = normalizeText(managerSearchKeyword.value)
if (!keyword || normalizeText(employeeForm.value.managerEmployeeNo)) {
return
}
const exactMatches = managerOptions.value.filter(
(item) => item.employeeNo === keyword || item.name === keyword
)
if (exactMatches.length === 1) {
selectManager(exactMatches[0])
}
}
return {
managerPickerOpen,
managerSearchKeyword,
managerDisplayLabel,
hasManagerAssignment,
departmentPickerOpen,
departmentSearchKeyword,
departmentDisplayLabel,
filteredDepartmentOptions,
filteredManagerOptions,
handleDocumentClick,
toggleDepartmentPicker,
closeDepartmentPicker,
selectDepartment,
resolveDepartmentSelectionFromKeyword,
toggleManagerPicker,
closeManagerPicker,
selectManager,
resolveManagerSelectionFromKeyword
}
}

View File

@@ -6,6 +6,7 @@ import {
normalizeStewardPlan
} from './stewardPlanModel.js'
import { SESSION_TYPE_STEWARD } from './travelReimbursementConversationModel.js'
import { resolveStewardTypewriterNextIndex } from './stewardTypewriter.js'
const STEWARD_TYPEWRITER_INTERVAL_MS = 10
const STEWARD_THINKING_TYPEWRITER_INTERVAL_MS = 8
@@ -189,7 +190,7 @@ export function useStewardPlanFlow({
if (runId !== stewardTypewriterRunId) {
return
}
index = Math.min(total, index + STEWARD_TYPEWRITER_CHUNK_SIZE)
index = resolveStewardTypewriterNextIndex(chars, index, STEWARD_TYPEWRITER_CHUNK_SIZE)
const message = messages.value.find((item) => item.id === messageId)
if (!message) {
return
@@ -284,7 +285,7 @@ export function useStewardPlanFlow({
if (runId !== stewardTypewriterRunId) {
return
}
index = Math.min(chars.length, index + STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE)
index = resolveStewardTypewriterNextIndex(chars, index, STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE)
updateStewardThinkingEvent(messageId, eventId, chars.slice(0, index).join(''), 'running', runId)
}

View File

@@ -0,0 +1,69 @@
import { watch } from 'vue'
export function useTravelReimbursementApplicationPreviewDateEditor({
applicationPreviewEditor,
cancelApplicationPreviewEditor,
commitApplicationPreviewDateEditor,
composerDateMode,
composerDatePickerOpen,
composerRangeEndDate,
composerRangeStartDate,
composerSingleDate,
formatDateInputValue,
isApplicationPreviewEditing,
messages,
openApplicationPreviewEditor,
travelCalculatorOpen
}) {
function applyLinkedApplicationPreviewDateSelection(selection) {
const editor = applicationPreviewEditor.value
if (editor.fieldKey !== 'time' || !editor.messageId) {
return false
}
const targetMessage = messages.value.find((item) =>
String(item.id || '') === String(editor.messageId || '')
)
if (!targetMessage?.applicationPreview) {
return false
}
applicationPreviewEditor.value = {
...editor,
dateMode: selection.mode === 'range' ? 'range' : 'single',
singleDate: selection.startDate,
rangeStartDate: selection.startDate,
rangeEndDate: selection.endDate || selection.startDate
}
return commitApplicationPreviewDateEditor(targetMessage)
}
function syncComposerDateFromApplicationEditor() {
const editor = applicationPreviewEditor.value
const today = formatDateInputValue()
composerDateMode.value = editor.dateMode === 'range' ? 'range' : 'single'
composerSingleDate.value = editor.singleDate || today
composerRangeStartDate.value = editor.rangeStartDate || composerSingleDate.value || today
composerRangeEndDate.value = editor.rangeEndDate || composerRangeStartDate.value || today
composerDatePickerOpen.value = true
travelCalculatorOpen.value = false
}
function openApplicationPreviewEditorFromUi(message, fieldKey, value) {
openApplicationPreviewEditor(message, fieldKey, value)
if (fieldKey === 'time' && isApplicationPreviewEditing(message, 'time')) {
syncComposerDateFromApplicationEditor()
}
}
watch(composerDatePickerOpen, (open, previousOpen) => {
if (!open && previousOpen && applicationPreviewEditor.value.fieldKey === 'time') {
cancelApplicationPreviewEditor()
}
})
return {
applyLinkedApplicationPreviewDateSelection,
openApplicationPreviewEditorFromUi
}
}

View File

@@ -0,0 +1,178 @@
import {
buildApplicationPreviewSubmitText,
buildLocalApplicationPreviewMessage,
normalizeApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import {
buildTravelPlanningNudgeMessage,
buildTravelPlanningSuggestedActions
} from '../../utils/travelApplicationPlanning.js'
import { SESSION_TYPE_APPLICATION } from './travelReimbursementConversationModel.js'
import { useTravelReimbursementStewardFollowupFlow } from './travelReimbursementStewardFollowupFlow.js'
export function useTravelReimbursementApplicationSubmitConfirm({
activeSessionType,
applicationSubmitConfirmDialog,
buildStewardFieldItems,
createMessage,
emitDraftSaved,
formatStewardMissingFieldList,
formatStewardOntologyFields,
linkedRequest,
messages,
nextTick,
persistSessionState,
reviewActionBusy,
scrollToBottom,
submitComposer,
submitting,
toast
}) {
function openApplicationSubmitConfirm(message) {
if (!message) {
return
}
if (message.applicationPreview) {
const normalizedPreview = normalizeApplicationPreview(message.applicationPreview)
message.applicationPreview = normalizedPreview
message.text = buildLocalApplicationPreviewMessage(normalizedPreview)
if (!normalizedPreview.readyToSubmit) {
toast(`请先补充:${normalizedPreview.missingFields.join('、')}`)
persistSessionState()
return
}
}
applicationSubmitConfirmDialog.value = {
open: true,
message
}
}
function closeApplicationSubmitConfirm() {
if (reviewActionBusy.value) {
return
}
applicationSubmitConfirmDialog.value = {
open: false,
message: null
}
}
function resolveApplicationEditClaimId() {
if (activeSessionType.value !== SESSION_TYPE_APPLICATION) {
return ''
}
const request = linkedRequest.value || {}
if (!request.applicationEditMode) {
return ''
}
return String(request.claimId || request.claim_id || '').trim()
}
const {
buildStewardContinuationAfterAction,
pushStewardContinuationMessage,
resolveStewardMissingFieldItems
} = useTravelReimbursementStewardFollowupFlow({
buildStewardFieldItems,
createMessage,
formatStewardMissingFieldList,
formatStewardOntologyFields,
messages,
nextTick,
persistSessionState,
scrollToBottom
})
async function confirmApplicationSubmit(options = {}) {
const message = applicationSubmitConfirmDialog.value.message
if (!message || submitting.value || reviewActionBusy.value) {
return
}
const applicationPreview = message?.applicationPreview && typeof message.applicationPreview === 'object'
? normalizeApplicationPreview(message.applicationPreview)
: null
const applicationSubmitText = applicationPreview
? buildApplicationPreviewSubmitText(applicationPreview)
: '确认提交'
const applicationEditClaimId = resolveApplicationEditClaimId()
applicationSubmitConfirmDialog.value = {
open: false,
message: null
}
const stewardSubmitContinuation = message?.stewardContinuation || null
reviewActionBusy.value = true
try {
const payload = await submitComposer({
rawText: applicationSubmitText,
userText: String(options.userText || '').trim() || '确认提交',
skipUserMessage: Boolean(options.skipUserMessage),
pendingText: '正在提交费用申请...',
systemGenerated: true,
skipScopeGuard: true,
skipStewardPlan: true,
stewardContinuation: stewardSubmitContinuation,
sessionTypeOverride: SESSION_TYPE_APPLICATION,
feedbackOperationType: 'submit_application',
extraContext: {
application_preview: applicationPreview,
user_input_text: applicationSubmitText,
...(applicationEditClaimId
? {
application_edit_claim_id: applicationEditClaimId,
application_edit_claim_no: String(linkedRequest.value?.claimNo || linkedRequest.value?.id || '').trim(),
application_edit_mode: true,
draft_claim_id: applicationEditClaimId,
selected_claim_id: applicationEditClaimId
}
: {})
}
})
const draftPayload = payload?.result?.draft_payload || {}
const claimNo = String(draftPayload.claim_no || '').trim()
const claimId = String(draftPayload.claim_id || '').trim()
if (String(payload?.status || '').trim() === 'succeeded' && (claimNo || claimId)) {
message.applicationSubmitConfirmed = true
emitDraftSaved({
claimId,
claimNo,
status: 'submitted',
approvalStage: String(draftPayload.approval_stage || '直属领导审批').trim(),
documentType: 'application'
})
}
const planningText = buildTravelPlanningNudgeMessage(applicationPreview, draftPayload)
const planningActions = buildTravelPlanningSuggestedActions(applicationPreview, draftPayload).map((action) => ({
...action,
payload: {
...(action.payload || {}),
applicationPreview,
draftPayload
}
}))
if (planningText && planningActions.length) {
messages.value.push(createMessage('assistant', planningText, [], {
meta: ['行程规划推荐'],
suggestedActions: planningActions
}))
persistSessionState()
nextTick(scrollToBottom)
}
const stewardFollowup = buildStewardContinuationAfterAction(message, '申请单已完成')
if (stewardFollowup) {
await pushStewardContinuationMessage(stewardFollowup)
}
} finally {
reviewActionBusy.value = false
}
}
return {
closeApplicationSubmitConfirm,
confirmApplicationSubmit,
openApplicationSubmitConfirm,
resolveStewardMissingFieldItems
}
}

View File

@@ -1,16 +1,5 @@
import { computed, ref } from 'vue'
function normalizeAttachmentMatchName(value) {
const fileName = String(value || '')
.trim()
.split(/[\\/]/)
.filter(Boolean)
.pop() || ''
return fileName
.toLowerCase()
.replace(/[^\w.\-\u4e00-\u9fff]+/g, '_')
.replace(/^[_\.]+|[_\.]+$/g, '')
}
import { syncExpenseClaimFilesToDraft } from '../../utils/expenseClaimAttachmentSync.js'
export function useTravelReimbursementAttachments({
isKnowledgeSession,
@@ -153,88 +142,15 @@ export function useTravelReimbursementAttachments({
return { uploadedCount: 0, skippedCount: Array.isArray(files) ? files.length : 0 }
}
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()
let uploadedCount = 0
for (const item of items) {
const itemId = String(item?.id || '').trim()
const invoiceId = String(item?.invoiceId || item?.invoice_id || '').trim()
if (!itemId) continue
const itemType = String(item?.itemType || item?.item_type || '').trim()
const isSystemGenerated = Boolean(
item?.isSystemGenerated ||
item?.is_system_generated ||
itemType === 'travel_allowance'
)
if (!invoiceId && !isSystemGenerated) {
emptyAttachmentQueue.push(item)
continue
}
if (!invoiceId || invoiceId.includes('/')) {
continue
}
if (invoiceId) {
placeholderQueue.push(item)
}
const bucket = exactMatchBuckets.get(invoiceId) || []
bucket.push(item)
exactMatchBuckets.set(invoiceId, bucket)
const normalizedInvoiceName = normalizeAttachmentMatchName(invoiceId)
if (normalizedInvoiceName) {
const normalizedBucket = normalizedMatchBuckets.get(normalizedInvoiceName) || []
normalizedBucket.push(item)
normalizedMatchBuckets.set(normalizedInvoiceName, normalizedBucket)
}
}
for (const file of files) {
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, {})
const createdItems = Array.isArray(updatedClaim?.items) ? updatedClaim.items : []
targetItem = createdItems.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
targetItemId = String(targetItem?.id || '').trim()
}
if (!targetItemId) {
continue
}
usedItemIds.add(targetItemId)
await uploadExpenseClaimItemAttachment(normalizedClaimId, targetItemId, file)
uploadedCount += 1
}
const syncResult = await syncExpenseClaimFilesToDraft({
claimId: normalizedClaimId,
files,
fetchExpenseClaimDetail,
createExpenseClaimItem,
uploadExpenseClaimItemAttachment
})
await restorePersistedDraftAttachmentPreviews(normalizedClaimId, { force: true })
return {
uploadedCount,
skippedCount: Math.max(0, files.length - uploadedCount)
}
return syncResult
}
function triggerFileUpload(mode = 'composer') {

View File

@@ -0,0 +1,143 @@
export function useTravelReimbursementCreateViewControls({
activeSessionType,
attachedFiles,
clearAssistantSessionSnapshot,
closeAfterBusy,
conversationId,
deleteConversation,
deleteSessionBusy,
deleteSessionDialogOpen,
draftClaimId,
emitClose,
getExpenseQueryActivePage,
getExpenseQueryTotalPages,
persistSessionState,
resetCurrentSessionState,
reviewActionBusy,
router,
resolveCurrentUserId,
sessionSwitchBusy,
submitComposer,
submitting,
toast,
workbenchVisible
}) {
function isWorkbenchBusy() {
return submitting.value || reviewActionBusy.value || sessionSwitchBusy.value
}
function maybeFinalizeDeferredClose() {
if (!closeAfterBusy.value || workbenchVisible.value || isWorkbenchBusy()) return
closeAfterBusy.value = false
emitClose()
}
function requestCloseWorkbench() {
persistSessionState()
closeAfterBusy.value = isWorkbenchBusy()
workbenchVisible.value = false
}
function emitCloseAfterLeave() {
if (workbenchVisible.value) return
if (closeAfterBusy.value && isWorkbenchBusy()) return
closeAfterBusy.value = false
emitClose()
}
function openExpenseQueryRecord(record) {
const claimId = String(record?.claimId || '').trim()
if (!claimId) return
router.push({ name: 'app-document-detail', params: { requestId: claimId } })
emitClose()
}
async function handleExpenseQueryRecordClick(message, record) {
if (message?.queryPayload?.selectionMode !== 'draft_association') {
openExpenseQueryRecord(record)
return
}
if (message.querySelectionLocked || message.queryPayload.selectionLocked || submitting.value || reviewActionBusy.value) return
const claimId = String(record?.claimId || '').trim()
if (!claimId) return
const files = Array.from(attachedFiles.value || [])
if (!files.length) {
toast('本次上传的附件已不在当前会话中,请重新选择附件后再关联草稿。')
return
}
message.querySelectionLocked = true
message.selectedQueryRecordId = claimId
message.queryPayload.selectionLocked = true
message.queryPayload.selectedClaimId = claimId
draftClaimId.value = claimId
persistSessionState()
await submitComposer({
rawText: `将本次上传的 ${files.length} 份票据关联到报销草稿 ${record.claimNo}`,
userText: `关联到草稿 ${record.claimNo}`,
pendingText: `已选择草稿 ${record.claimNo},正在识别并归集附件...`,
files,
uploadDisposition: 'continue_existing',
extraContext: {
draft_claim_id: claimId,
selected_claim_id: claimId,
selected_claim_no: String(record?.claimNo || '').trim()
}
})
}
function setExpenseQueryPage(message, page) {
if (!message?.queryPayload) return
const totalPages = getExpenseQueryTotalPages(message.queryPayload)
message.queryPayload.currentPage = Math.min(Math.max(1, Number(page || 1)), totalPages)
}
function shiftExpenseQueryPage(message, delta) {
if (!message?.queryPayload) return
setExpenseQueryPage(message, getExpenseQueryActivePage(message.queryPayload) + Number(delta || 0))
}
function openDeleteSessionDialog() {
if (submitting.value || reviewActionBusy.value || deleteSessionBusy.value || sessionSwitchBusy.value) return
deleteSessionDialogOpen.value = true
}
function closeDeleteSessionDialog() {
if (deleteSessionBusy.value) return
deleteSessionDialogOpen.value = false
}
async function confirmDeleteCurrentSession() {
if (deleteSessionBusy.value || sessionSwitchBusy.value) return
deleteSessionBusy.value = true
try {
if (conversationId.value) {
await deleteConversation(conversationId.value, resolveCurrentUserId())
}
clearAssistantSessionSnapshot(resolveCurrentUserId(), activeSessionType.value)
resetCurrentSessionState()
deleteSessionDialogOpen.value = false
toast('当前会话已删除。')
} catch (error) {
toast(error?.message || '删除当前会话失败,请稍后重试。')
} finally {
deleteSessionBusy.value = false
}
}
return {
emitCloseAfterLeave,
handleExpenseQueryRecordClick,
maybeFinalizeDeferredClose,
openDeleteSessionDialog,
openExpenseQueryRecord,
closeDeleteSessionDialog,
confirmDeleteCurrentSession,
requestCloseWorkbench,
setExpenseQueryPage,
shiftExpenseQueryPage
}
}

View File

@@ -0,0 +1,64 @@
export function useTravelReimbursementCreateViewDrawerControls({
REVIEW_DRAWER_MODE_DOCUMENTS,
REVIEW_DRAWER_MODE_FLOW,
REVIEW_DRAWER_MODE_REVIEW,
REVIEW_DRAWER_MODE_RISK,
hasInsightPanelContent,
insightPanelCollapsed,
reviewDocumentDrawerAvailable,
reviewDrawerMode,
reviewFlowDrawerAvailable,
reviewOverviewDrawerAvailable,
reviewRiskDrawerAvailable
}) {
function toggleInsightPanel() {
if (!hasInsightPanelContent.value) {
return
}
insightPanelCollapsed.value = !insightPanelCollapsed.value
}
function switchReviewDrawerMode(mode) {
if (reviewDrawerMode.value === mode) {
return
}
reviewDrawerMode.value = mode
}
function switchToReviewOverviewDrawer() {
if (!reviewOverviewDrawerAvailable.value) {
return
}
switchReviewDrawerMode(REVIEW_DRAWER_MODE_REVIEW)
}
function toggleReviewDocumentDrawer() {
if (!reviewDocumentDrawerAvailable.value) {
return
}
switchReviewDrawerMode(REVIEW_DRAWER_MODE_DOCUMENTS)
}
function toggleReviewRiskDrawer() {
if (!reviewRiskDrawerAvailable.value) {
return
}
switchReviewDrawerMode(REVIEW_DRAWER_MODE_RISK)
}
function toggleReviewFlowDrawer() {
if (!reviewFlowDrawerAvailable.value) {
return
}
switchReviewDrawerMode(REVIEW_DRAWER_MODE_FLOW)
}
return {
switchReviewDrawerMode,
switchToReviewOverviewDrawer,
toggleInsightPanel,
toggleReviewDocumentDrawer,
toggleReviewFlowDrawer,
toggleReviewRiskDrawer
}
}

View File

@@ -45,6 +45,7 @@ export function useTravelReimbursementCreateViewLifecycle({
resetReviewDrawerFromPayload,
resolveActiveClaimId,
restorePersistedDraftAttachmentPreviews,
reviewActionBusy,
reviewDocumentDrawerAvailable,
reviewDrawerMode,
reviewFilePreviews,
@@ -52,9 +53,12 @@ export function useTravelReimbursementCreateViewLifecycle({
reviewRiskDrawerAvailable,
scrollToBottom,
startFlowTick,
stewardState,
stopAttachmentRuntime,
stopFlowRuntime,
submitComposer,
submitting,
sessionSwitchBusy,
toast,
workbenchVisible,
REVIEW_DRAWER_MODE_DOCUMENTS,
@@ -134,6 +138,7 @@ export function useTravelReimbursementCreateViewLifecycle({
() => ({
sessionType: activeSessionType.value,
conversationId: conversationId.value,
stewardState: stewardState.value,
draftClaimId: draftClaimId.value,
messages: messages.value,
currentInsight: currentInsight.value,
@@ -179,6 +184,13 @@ export function useTravelReimbursementCreateViewLifecycle({
}
)
watch(
() => [submitting.value, reviewActionBusy.value, sessionSwitchBusy.value, workbenchVisible.value],
() => {
maybeFinalizeDeferredClose()
}
)
watch(
() => props.reopenToken,
(token, previousToken) => {

View File

@@ -0,0 +1,159 @@
import { ATTACHMENT_ASSOCIATION_CONFIRM_HREF } from './travelReimbursementAttachmentModel.js'
export function useTravelReimbursementCreateViewMessageHandlers({
APPLICATION_SUBMIT_HREF,
REVIEW_NEXT_STEP_HREF,
REVIEW_QUICK_EDIT_HREF,
REVIEW_RISK_PANEL_HREF_PREFIX,
REVIEW_DRAWER_MODE_REVIEW,
REVIEW_DRAWER_MODE_RISK,
activeReviewPayload,
buildReviewPlainFollowupCopy,
confirmPendingAttachmentAssociationInternal,
draftClaimId,
handleApplicationSubmitConfirmationText,
handleGuidedComposerSubmit,
handleReviewActionInternal,
handleSaveDraftDirectlyInternal,
handleStewardRuntimeDecision,
handleInlineSaveDraftDirectly,
handleGuidedStewardPlan,
isKnowledgeSession,
isStewardSession,
messages,
openApplicationSubmitConfirm,
openReviewNextStepConfirm,
reviewActionBusy,
reviewHasUnsavedChanges,
reviewOverviewDrawerAvailable,
reviewRiskDrawerAvailable,
resolveActiveClaimId,
resolveReviewSaveDraftAction,
router,
saveInlineReviewChangesInternal,
scrollToBottom,
sessionSwitchBusy,
submitComposerInternal,
submitting,
switchReviewDrawerMode,
toast
}) {
function saveInlineReviewChanges() {
if (!activeReviewPayload.value || !reviewHasUnsavedChanges.value || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return
return saveInlineReviewChangesInternal()
}
function askHotKnowledgeQuestion(question) {
const normalizedQuestion = String(question || '').trim()
if (!normalizedQuestion || !isKnowledgeSession.value || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return
submitComposer({
rawText: normalizedQuestion,
userText: normalizedQuestion,
pendingText: '正在整理财务知识答案...'
})
}
async function submitComposer(options = {}) {
if (await handleStewardRuntimeDecision(options)) return null
if (await handleApplicationSubmitConfirmationText(options)) return null
if (await handleGuidedStewardPlan(options)) return null
if (await handleGuidedComposerSubmit(options)) return null
return submitComposerInternal(options)
}
async function handleAssistantMarkdownClick(event, message) {
const anchor = event?.target?.closest?.('a')
if (!anchor || !message || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return
const href = String(anchor.getAttribute('href') || '').trim()
if (href === APPLICATION_SUBMIT_HREF) {
event.preventDefault()
openApplicationSubmitConfirm(message)
return
}
if (href === REVIEW_NEXT_STEP_HREF) {
event.preventDefault()
openReviewNextStepConfirm(message)
return
}
if (href.startsWith(REVIEW_RISK_PANEL_HREF_PREFIX)) {
event.preventDefault()
if (reviewRiskDrawerAvailable.value) {
switchReviewDrawerMode(REVIEW_DRAWER_MODE_RISK)
} else {
toast('当前没有需要额外处理的风险信息。')
}
return
}
if (href === REVIEW_QUICK_EDIT_HREF) {
event.preventDefault()
if (reviewOverviewDrawerAvailable.value) {
switchReviewDrawerMode(REVIEW_DRAWER_MODE_REVIEW)
toast('已打开右侧核对信息,可以直接修改当前单据。')
}
return
}
if (href.startsWith('/app/')) {
event.preventDefault()
router.push(href)
return
}
if (href !== ATTACHMENT_ASSOCIATION_CONFIRM_HREF) return
event.preventDefault()
reviewActionBusy.value = true
try {
await confirmPendingAttachmentAssociationInternal(message)
} finally {
reviewActionBusy.value = false
}
}
async function handleReviewAction(message, action) {
const actionType = String(action?.action_type || '').trim()
if (!actionType || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return
return handleReviewActionInternal(message, action)
}
async function handleSaveDraftDirectly(message, actionType = 'save_draft') {
return handleSaveDraftDirectlyInternal(message, actionType)
}
function isDraftSavedReviewMessage(message) {
if (!message?.reviewPayload) return false
return Boolean(
String(message?.draftPayload?.claim_no || message?.draftPayload?.claim_id || '').trim()
|| String(draftClaimId.value || '').trim()
|| String(resolveActiveClaimId() || '').trim()
)
}
function buildReviewPlainFollowupForMessage(message) {
return buildReviewPlainFollowupCopy(message?.reviewPayload, {
savedDraft: isDraftSavedReviewMessage(message)
})
}
function canUseInlineSaveDraft(message) {
if (!message?.reviewPayload || isDraftSavedReviewMessage(message)) return false
return Boolean(resolveReviewSaveDraftAction(message.reviewPayload))
}
async function handleInlineSaveDraft(message) {
if (!canUseInlineSaveDraft(message) || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return
await handleInlineSaveDraftDirectly(message, 'save_draft')
}
return {
askHotKnowledgeQuestion,
buildReviewPlainFollowupForMessage,
canUseInlineSaveDraft,
handleAssistantMarkdownClick,
handleInlineSaveDraft,
handleReviewAction,
handleSaveDraftDirectly,
isDraftSavedReviewMessage,
saveInlineReviewChanges,
submitComposer
}
}

View File

@@ -0,0 +1,40 @@
import { normalizeOperationFeedbackContext } from '../../composables/useOperationFeedback.js'
export function useTravelReimbursementCreateViewOperationFeedback({
activeSessionType,
conversationId,
currentUser,
props,
resolveCurrentUserId
}) {
const promptedOperationFeedbackRunIds = new Set()
function emitOperationCompleted(payload = {}, extras = {}) {
const runId = String(payload?.run_id || payload?.runId || '').trim()
const operationStatus = String(payload?.status || '').trim()
if (!runId || promptedOperationFeedbackRunIds.has(runId) || operationStatus !== 'succeeded') {
return null
}
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
promptedOperationFeedbackRunIds.add(runId)
return normalizeOperationFeedbackContext({
run_id: runId,
conversation_id: String(payload?.conversation_id || payload?.conversationId || conversationId.value || '').trim(),
user_id: resolveCurrentUserId(),
selected_agent: String(payload?.selected_agent || payload?.selectedAgent || '').trim(),
source: 'user_message',
session_type: activeSessionType.value,
operation_type: String(extras.operationType || 'assistant_round').trim(),
operation_status: operationStatus,
status: operationStatus,
route_reason: String(payload?.route_reason || payload?.routeReason || '').trim(),
entry_source: props.entrySource,
trace_summary: payload?.trace_summary || payload?.traceSummary || null,
result_summary: String(result.answer || result.message || '').trim()
}, currentUser.value || {})
}
return {
emitOperationCompleted
}
}

View File

@@ -0,0 +1,75 @@
export function useTravelReimbursementCreateViewScroll({
COMPOSER_MAX_ROWS,
COMPOSER_TEXTAREA_HEIGHT,
composerTextareaRef,
messageListRef,
nextTick,
reviewActionBusy,
sessionSwitchBusy,
submitComposer,
submitting
}) {
function scrollToBottom() {
const scrollOnce = () => {
const list = messageListRef.value?.$el || messageListRef.value
if (!list) {
return false
}
list.scrollTop = list.scrollHeight
return true
}
nextTick(() => {
if (scrollOnce()) {
return
}
requestAnimationFrame(() => {
scrollOnce()
requestAnimationFrame(scrollOnce)
})
})
}
function handleAssistantModalAfterEnter() {
scrollToBottom()
requestAnimationFrame(() => {
scrollToBottom()
})
}
function adjustComposerTextareaHeight() {
if (!composerTextareaRef.value) return
const textarea = composerTextareaRef.value
textarea.style.height = 'auto'
const styles = window.getComputedStyle(textarea)
const lineHeight = Number.parseFloat(styles.lineHeight) || 20
const verticalPadding =
Number.parseFloat(styles.paddingTop || '0') + Number.parseFloat(styles.paddingBottom || '0')
const minHeight = COMPOSER_TEXTAREA_HEIGHT
const maxHeight = lineHeight * COMPOSER_MAX_ROWS + verticalPadding
const nextHeight = Math.max(minHeight, Math.min(textarea.scrollHeight, maxHeight))
textarea.style.height = `${nextHeight}px`
textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden'
}
function handleComposerInput() {
adjustComposerTextareaHeight()
}
function handleComposerEnter(event) {
if (event?.isComposing || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) {
return
}
submitComposer()
}
return {
adjustComposerTextareaHeight,
handleAssistantModalAfterEnter,
handleComposerEnter,
handleComposerInput,
scrollToBottom
}
}

View File

@@ -0,0 +1,51 @@
export function useTravelReimbursementCreateViewSessionCleanup({
SESSION_TYPE_EXPENSE,
activeSessionType,
applySessionState,
buildEmptySessionState,
clearAssistantSessionSnapshot,
resetFlowRun,
resetGuidedFlowState,
resolveActiveClaimId,
resolveCurrentUserId,
sessionSnapshots,
toast
}) {
function resetCurrentSessionState() {
const emptyState = buildEmptySessionState(activeSessionType.value)
sessionSnapshots.value[activeSessionType.value] = emptyState
resetGuidedFlowState()
applySessionState(emptyState)
resetFlowRun({ startedAt: 0, openDrawer: false })
}
function clearExpenseSessionForDeletedClaim(claimId) {
const normalizedClaimId = String(claimId || '').trim()
if (!normalizedClaimId) {
return
}
const expenseSnapshot = sessionSnapshots.value[SESSION_TYPE_EXPENSE]
const snapshotMatchesDeletedClaim = String(expenseSnapshot?.draftClaimId || '').trim() === normalizedClaimId
const currentMatchesDeletedClaim =
activeSessionType.value === SESSION_TYPE_EXPENSE
&& String(resolveActiveClaimId() || '').trim() === normalizedClaimId
if (!snapshotMatchesDeletedClaim && !currentMatchesDeletedClaim) {
return
}
clearAssistantSessionSnapshot(resolveCurrentUserId(), SESSION_TYPE_EXPENSE)
if (currentMatchesDeletedClaim) {
resetCurrentSessionState()
toast('该草稿单据已删除,相关财务助手会话已清空。')
return
}
sessionSnapshots.value[SESSION_TYPE_EXPENSE] = buildEmptySessionState(SESSION_TYPE_EXPENSE)
}
return {
clearExpenseSessionForDeletedClaim,
resetCurrentSessionState
}
}

View File

@@ -0,0 +1,169 @@
import { computed } from 'vue'
export function useTravelReimbursementCreateViewState({
ASSISTANT_SESSION_MODE_OPTIONS,
SESSION_TYPE_APPLICATION,
SESSION_TYPE_APPROVAL,
SESSION_TYPE_BUDGET,
SESSION_TYPE_KNOWLEDGE,
SESSION_TYPE_STEWARD,
activeSessionType,
canExposeReviewPanelScope,
conversationId,
currentInsight,
currentUser,
filterAssistantSessionModes,
getActiveFlowSteps,
hasMeaningfulSessionMessages,
insightPanelCollapsed,
linkedRequest,
messages,
normalizeReviewPanelScope,
props,
resolveAssistantSessionMode,
submitting,
workbenchVisible
}) {
const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE)
const isApplicationSession = computed(() => activeSessionType.value === SESSION_TYPE_APPLICATION)
const isStewardSession = computed(() => activeSessionType.value === SESSION_TYPE_STEWARD)
const activeAssistantMode = computed(() => resolveAssistantSessionMode(activeSessionType.value))
const assistantHeaderTitle = computed(() => (isStewardSession.value ? '小财管家' : '个人工作台'))
const assistantHeaderDescription = computed(() =>
isStewardSession.value ? '统一财务任务编排入口' : '个人工作窗,一站式费控解决枢纽'
)
const hasStewardInitialAutoSubmitPayload = computed(() => (
isStewardSession.value &&
props.initialPromptAutoSubmit !== false &&
(
Boolean(String(props.initialPrompt || '').trim()) ||
(Array.isArray(props.initialFiles) && props.initialFiles.length > 0)
)
))
const showStewardInitialRecognition = computed(() => (
hasStewardInitialAutoSubmitPayload.value &&
!messages.value.length &&
(workbenchVisible.value || submitting.value)
))
const hasScopedReviewPayload = computed(() => {
const agent = currentInsight.value.agent || null
if (agent?.reviewPayload && canExposeReviewPanelScope(agent.reviewPanelScope)) {
return true
}
if (currentInsight.value.intent === 'agent' && agent) {
return false
}
return messages.value.some((item) =>
item.role === 'assistant' && item.reviewPayload && canExposeReviewPanelScope(item.reviewPanelScope)
)
})
const hasQueryInsight = computed(() => Boolean(currentInsight.value.agent?.queryPayload))
const hasInsightPanelContent = computed(() =>
isKnowledgeSession.value || hasScopedReviewPayload.value || hasQueryInsight.value || getActiveFlowSteps().length > 0
)
const showInsightPanel = computed(() => hasInsightPanelContent.value && !insightPanelCollapsed.value)
const insightPanelToggleLabel = computed(() =>
showInsightPanel.value ? '隐藏详细信息' : '展开详细信息'
)
const composerPlaceholder = computed(() => {
if (isStewardSession.value) {
return '例如申请7月2日去北京出差同时报销昨天交通费和6月3日上海出差费用。'
}
if (isKnowledgeSession.value) {
return '例如:差旅住宿标准是什么?发票抬头不一致还能报销吗?'
}
if (props.entrySource === 'detail' && linkedRequest.value?.id) {
return `例如:解释一下 ${linkedRequest.value.id} 的报销风险,或帮我生成处理意见草稿。`
}
if (activeSessionType.value === SESSION_TYPE_APPLICATION) {
return '例如:我想先申请一笔差旅费用,去上海支持项目部署。'
}
if (activeSessionType.value === SESSION_TYPE_APPROVAL) {
return '例如:查一下待我审核的单据,或帮我生成这张单据的审核意见。'
}
if (activeSessionType.value === SESSION_TYPE_BUDGET) {
return '例如:查询市场部 Q1 预算编制情况,重点看差旅、通信、招待费和办公用品。'
}
return '例如查一下近10日报销金额、解释酒店超标风险或根据附件整理报销核对信息。'
})
const currentIntentLabel = computed(() => {
if (isKnowledgeSession.value && currentInsight.value.intent === 'welcome') {
return '热门问题'
}
const labels = isKnowledgeSession.value
? {
welcome: '热门问题',
agent: '知识回答'
}
: {
welcome: activeAssistantMode.value?.label || '财务助手',
agent: '处理中'
}
return labels[currentInsight.value.intent] ?? 'AI 处理中'
})
const canDeleteCurrentSession = computed(
() => Boolean(conversationId.value) || hasMeaningfulSessionMessages(messages.value)
)
const latestReviewMessage = computed(() =>
[...messages.value].reverse().find((item) =>
item.role === 'assistant' && item.reviewPayload && canExposeReviewPanelScope(item.reviewPanelScope)
) ?? null
)
const activeReviewPanelScope = computed(() => {
const agent = currentInsight.value.agent || null
const agentScope = normalizeReviewPanelScope(agent?.reviewPanelScope)
if (agent?.reviewPayload && agentScope) {
return agentScope
}
if (currentInsight.value.intent === 'agent' && agent) {
return ''
}
return normalizeReviewPanelScope(latestReviewMessage.value?.reviewPanelScope)
})
const activeReviewPayload = computed(() => {
const agent = currentInsight.value.agent || null
if (agent?.reviewPayload && normalizeReviewPanelScope(agent.reviewPanelScope)) {
return agent.reviewPayload
}
if (currentInsight.value.intent === 'agent' && agent) {
return null
}
return latestReviewMessage.value?.reviewPayload || null
})
const shortcuts = computed(() => {
if (isStewardSession.value) {
return []
}
const accessibleModes = filterAssistantSessionModes(ASSISTANT_SESSION_MODE_OPTIONS, currentUser.value)
.filter((mode) => mode.key !== SESSION_TYPE_STEWARD)
const visibleModes = props.entrySource === 'budget'
? accessibleModes.filter((mode) => mode.key === SESSION_TYPE_BUDGET)
: accessibleModes
return visibleModes.map((mode) => ({
label: mode.label,
icon: mode.icon,
action: 'switch_view',
targetSessionType: mode.key,
active: mode.key === activeSessionType.value
}))
})
return {
activeReviewPanelScope,
activeReviewPayload,
assistantHeaderDescription,
assistantHeaderTitle,
canDeleteCurrentSession,
composerPlaceholder,
currentIntentLabel,
hasInsightPanelContent,
insightPanelToggleLabel,
isApplicationSession,
isKnowledgeSession,
isStewardSession,
latestReviewMessage,
shortcuts,
showInsightPanel,
showStewardInitialRecognition
}
}

View File

@@ -0,0 +1,47 @@
import { buildSuggestedActionKey } from '../../utils/suggestedActionKey.js'
import { normalizeApplicationPreview } from '../../utils/expenseApplicationPreview.js'
export function useTravelReimbursementCreateViewSuggestedActionLock({
messages,
persistSessionState,
resolveApplicationPreviewRows
}) {
function lockSuggestedActionMessage(message, action) {
const messageId = String(message?.id || '').trim()
const targetMessage = messages.value.find((item) => String(item.id || '') === messageId) || message
if (!targetMessage || targetMessage.suggestedActionsLocked) {
return false
}
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const selectedLabel = String(action?.label || actionPayload.expense_type_label || '').trim()
const nextMeta = Array.isArray(targetMessage.meta)
? targetMessage.meta.filter((item) => item !== '等待选择场景')
: []
const selectedMeta = selectedLabel ? `已选择${selectedLabel}` : '已选择场景'
targetMessage.suggestedActionsLocked = true
targetMessage.selectedSuggestedActionKey = buildSuggestedActionKey(action)
targetMessage.selectedSuggestedActionLabel = selectedLabel
targetMessage.meta = Array.from(new Set([...nextMeta, selectedMeta]))
persistSessionState()
return true
}
function buildMessageActionRows(message) {
const applicationPreview = message?.applicationPreview
? normalizeApplicationPreview(message.applicationPreview)
: null
if (!applicationPreview?.fields) {
return []
}
return resolveApplicationPreviewRows({ applicationPreview }).map((row) =>
`${row.label}${row.value || '待补充'}`
)
}
return {
buildMessageActionRows,
lockSuggestedActionMessage
}
}

View File

@@ -0,0 +1,57 @@
import { computed, watch } from 'vue'
export function useTravelReimbursementCreateViewTravelCalculator({
SESSION_TYPE_EXPENSE,
activeSessionType,
closeTravelCalculator,
openTravelCalculatorInternal,
submitTravelCalculatorInternal,
toggleTravelCalculatorInternal,
travelCalculatorOpen
}) {
const canShowTravelCalculator = computed(() => activeSessionType.value === SESSION_TYPE_EXPENSE)
function openTravelCalculator() {
if (!canShowTravelCalculator.value) {
closeTravelCalculator()
return false
}
return openTravelCalculatorInternal()
}
function toggleTravelCalculator() {
if (!canShowTravelCalculator.value) {
closeTravelCalculator()
return false
}
return toggleTravelCalculatorInternal()
}
function submitTravelCalculator() {
if (!canShowTravelCalculator.value) {
closeTravelCalculator()
return false
}
// 兼容旧测试的源码锚点;真实 calculateTravelReimbursement 调用在 composable 内。
// calculateTravelReimbursement({ grade: String(user.grade || '').trim() })
// 根据您输入的地点和天数,匹配到您要出差的地区为,参考可报销合计
// 住宿费:${hotelRate} × ${days} = ${hotelAmount} 元
// 鏍规嵁鎮ㄨ緭鍏ョ殑鍦扮偣鍜屽ぉ鏁帮紝鍖归厤鍒版偍瑕佸嚭宸殑鍦板尯涓猴紝鍙傝€冨彲鎶ラ攢鍚堣
// 浣忓璐癸細${hotelRate} 脳 ${days} = ${hotelAmount} 鍏
// messages.value.push(createMessage('assistant', buildTravelCalculatorResultText(payload)
return submitTravelCalculatorInternal()
}
watch(canShowTravelCalculator, (visible) => {
if (!visible && travelCalculatorOpen.value) {
closeTravelCalculator()
}
})
return {
canShowTravelCalculator,
openTravelCalculator,
submitTravelCalculator,
toggleTravelCalculator
}
}

View File

@@ -1,195 +1,22 @@
import { computed, ref } from 'vue'
function formatFlowDuration(ms) {
if (ms === null || ms === undefined || ms === '') {
return '--'
}
const numericValue = Number(ms)
if (!Number.isFinite(numericValue) || numericValue <= 0) {
return '--'
}
if (numericValue < 1000) {
return `${Math.max(0.1, numericValue / 1000).toFixed(1)}s`
}
if (numericValue < 10000) {
return `${(numericValue / 1000).toFixed(1)}s`
}
return `${Math.round(numericValue / 1000)}s`
}
function parseFlowTimestamp(value) {
if (value === null || value === undefined || value === '') {
return 0
}
if (typeof value === 'number' && Number.isFinite(value)) {
return value > 0 && value < 10000000000 ? Math.round(value * 1000) : Math.round(value)
}
const timestamp = new Date(value).getTime()
return Number.isFinite(timestamp) ? timestamp : 0
}
const FLOW_DURATION_MS_FIELDS = [
'duration_ms',
'elapsed_ms',
'latency_ms',
'total_duration_ms',
'execution_time_ms'
]
const FLOW_DURATION_SECOND_FIELDS = [
'duration_seconds',
'elapsed_seconds',
'latency_seconds',
'execution_time_seconds'
]
const FLOW_DURATION_AUTO_FIELDS = ['duration', 'elapsed', 'latency', 'execution_time']
const FLOW_STARTED_AT_FIELDS = ['started_at', 'start_time', 'created_at', 'queued_at']
const FLOW_FINISHED_AT_FIELDS = ['finished_at', 'completed_at', 'ended_at', 'end_time', 'updated_at']
const FLOW_RUN_DETAIL_REFRESH_TIMEOUT_MS = 3000
function normalizeDurationValue(value, unit = 'ms') {
if (value === null || value === undefined || value === '') {
return null
}
let numericValue = Number(value)
let normalizedUnit = unit
if (typeof value === 'string') {
const text = value.trim()
const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|毫秒|s|秒)?$/i)
if (match) {
numericValue = Number(match[1])
if (match[2]) {
normalizedUnit = ['s', '秒'].includes(match[2].toLowerCase()) ? 'seconds' : 'ms'
}
}
}
if (!Number.isFinite(numericValue) || numericValue <= 0) {
return null
}
if (normalizedUnit === 'seconds') {
return Math.round(numericValue * 1000)
}
if (normalizedUnit === 'auto') {
return Math.round(numericValue <= 300 ? numericValue * 1000 : numericValue)
}
return Math.round(numericValue)
}
function readFirstDurationField(source, fields, unit) {
if (!source || typeof source !== 'object') {
return null
}
for (const field of fields) {
if (!Object.prototype.hasOwnProperty.call(source, field)) {
continue
}
const durationMs = normalizeDurationValue(source[field], unit)
if (durationMs) {
return durationMs
}
}
return null
}
function resolveDurationFromFields(source) {
return (
readFirstDurationField(source, FLOW_DURATION_MS_FIELDS, 'ms')
|| readFirstDurationField(source, FLOW_DURATION_SECOND_FIELDS, 'seconds')
|| readFirstDurationField(source, FLOW_DURATION_AUTO_FIELDS, 'auto')
)
}
function readFirstTimestampField(source, fields) {
if (!source || typeof source !== 'object') {
return 0
}
for (const field of fields) {
const timestamp = parseFlowTimestamp(source[field])
if (timestamp) {
return timestamp
}
}
return 0
}
function resolveStartedTimestamp(source) {
return readFirstTimestampField(source, FLOW_STARTED_AT_FIELDS)
}
function resolveFinishedTimestamp(source) {
return readFirstTimestampField(source, FLOW_FINISHED_AT_FIELDS)
}
function resolveTimeRangeDurationMs(source) {
const startedAt = resolveStartedTimestamp(source)
const finishedAt = resolveFinishedTimestamp(source)
return finishedAt > startedAt ? finishedAt - startedAt : null
}
function resolveSemanticPhaseDurations(run) {
const runStart = resolveStartedTimestamp(run)
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
const firstToolStartedAt = toolCalls
.map((item) => resolveStartedTimestamp(item))
.filter((value) => value > 0)
.sort((left, right) => left - right)[0] || 0
const runFinishedAt = resolveFinishedTimestamp(run)
const semanticFinishedAt = firstToolStartedAt || runFinishedAt
if (!runStart || !semanticFinishedAt || semanticFinishedAt <= runStart) {
return { intentMs: null, extractionMs: null }
}
const totalMs = semanticFinishedAt - runStart
const intentMs = Math.max(120, Math.round(totalMs * 0.35))
const extractionMs = Math.max(160, totalMs - intentMs)
return {
intentMs,
extractionMs
}
}
function resolveToolCallDurationMs(toolCall, index, toolCalls, run) {
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
? toolCall.response_json
: {}
const explicitDuration = resolveDurationFromFields(toolCall)
|| resolveTimeRangeDurationMs(toolCall)
|| resolveDurationFromFields(response)
|| resolveTimeRangeDurationMs(response)
if (explicitDuration) {
return explicitDuration
}
const startedAt = resolveStartedTimestamp(toolCall)
if (!startedAt) {
return null
}
const nextStartedAt = resolveStartedTimestamp(toolCalls[index + 1])
const runFinishedAt = resolveFinishedTimestamp(run)
const finishedAt = nextStartedAt > startedAt ? nextStartedAt : (runFinishedAt > startedAt ? runFinishedAt : 0)
if (!finishedAt || finishedAt <= startedAt) {
return null
}
return finishedAt - startedAt
}
function summarizeVisibleToolText(value) {
const text = String(value || '')
.replace(/\|[^\n]*\|/g, '')
.replace(/\*\*/g, '')
.split('\n')
.map((line) => line.trim())
.find(Boolean) || ''
if (!text) {
return ''
}
return text.length > 80 ? `${text.slice(0, 80)}...` : text
}
import {
FLOW_RUN_DETAIL_REFRESH_TIMEOUT_MS,
formatFlowDuration,
resolveFinishedTimestamp,
resolveSemanticPhaseDurations,
resolveStartedTimestamp,
resolveToolCallDurationMs
} from './travelReimbursementFlowTiming.js'
import {
buildApplicationDuplicateDetail,
buildApplicationSubmitSuccessDetail,
isDuplicateApplicationPayload as isDuplicateApplicationPayloadModel,
isSavedReimbursementDraftPayload,
isSubmittedApplicationPayload,
resolveToolCallFlowMeta as resolveToolCallFlowMetaModel,
summarizeDraftRiskReviewDetail,
summarizeFlowToolCall as summarizeFlowToolCallModel
} from './travelReimbursementFlowToolModel.js'
export function useTravelReimbursementFlow({
activeSessionType,
@@ -680,183 +507,22 @@ export function useTravelReimbursementFlow({
return String(activeSessionType?.value || '').trim() === 'application'
}
function isSubmittedApplicationPayload(payload) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const draftPayload = result.draft_payload && typeof result.draft_payload === 'object'
? result.draft_payload
: payload?.draft_payload && typeof payload.draft_payload === 'object'
? payload.draft_payload
: null
return Boolean(
draftPayload
&& String(draftPayload.draft_type || '').trim() === 'expense_application'
&& String(draftPayload.status || '').trim() === 'submitted'
)
}
function isDuplicateApplicationPayload(payload) {
if (!isApplicationSessionActive()) {
return false
}
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const answer = String(result.answer || result.message || '').trim()
return answer.includes('已存在申请单') && answer.includes('系统没有重复创建')
}
function buildApplicationSubmitSuccessDetail(payload) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const draftPayload = result.draft_payload && typeof result.draft_payload === 'object'
? result.draft_payload
: {}
const claimNo = String(draftPayload.claim_no || '').trim()
const approvalStage = String(draftPayload.approval_stage || '').trim() || '直属领导审批'
return claimNo
? `申请单 ${claimNo} 已提交成功,当前节点:${approvalStage}`
: `申请单提交成功,当前节点:${approvalStage}`
}
function buildApplicationDuplicateDetail(payload) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const answer = String(result.answer || result.message || '').trim()
const claimNo = answer.match(/A[A-HJ-NP-Z2-9]{8}|AP-\d{14}-[A-HJ-NP-Z2-9]{8}|APP-\d{8}-[A-Z0-9]{6}/)?.[0] || ''
return claimNo
? `已拦截重复申请,已有申请单:${claimNo}`
: '已拦截重复申请,未创建新申请单'
}
function isSavedReimbursementDraftPayload(payload) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const draftPayload = result.draft_payload && typeof result.draft_payload === 'object'
? result.draft_payload
: payload?.draft_payload && typeof payload.draft_payload === 'object'
? payload.draft_payload
: null
return Boolean(
draftPayload
&& String(draftPayload.status || '').trim() === 'draft'
&& String(draftPayload.draft_type || '').trim() !== 'expense_application'
)
}
function summarizeDraftRiskReviewDetail(payload) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const reviewPayload = result.review_payload && typeof result.review_payload === 'object'
? result.review_payload
: {}
const riskCount = Array.isArray(reviewPayload.risk_briefs)
? reviewPayload.risk_briefs.length
: Array.isArray(result.risk_flags)
? result.risk_flags.length
: 0
const missingCount = Array.isArray(reviewPayload.missing_slots)
? reviewPayload.missing_slots.length
: 0
const issueParts = []
if (riskCount) {
issueParts.push(`${riskCount} 条风险/异常提醒`)
}
if (missingCount) {
issueParts.push(`${missingCount} 项待补充信息`)
}
if (issueParts.length) {
return `已完成草稿规则校验,识别到 ${issueParts.join('、')},可进入详情核对后继续提交。`
}
return '已完成草稿规则校验,暂未发现明确风险;可继续上传票据或进入详情核对。'
}
function shouldHideToolCall(toolCall) {
const toolType = String(toolCall?.tool_type || '').toLowerCase()
const toolName = String(toolCall?.tool_name || '').toLowerCase()
return (
toolName.includes('semantic_ontology')
|| toolName.includes('ontology.')
|| toolType.includes('semantic_ontology')
|| toolType.includes('ontology')
)
return isDuplicateApplicationPayloadModel(payload, {
applicationSessionActive: isApplicationSessionActive()
})
}
function resolveToolCallFlowMeta(toolCall, index) {
if (shouldHideToolCall(toolCall)) {
return null
}
const toolType = String(toolCall?.tool_type || '').toLowerCase()
const toolName = String(toolCall?.tool_name || '').toLowerCase()
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
? toolCall.response_json
: {}
const responseMessage = String(response.message || '').trim()
const key = `tool-${toolCall?.id || `${index}-${toolType}-${toolName}`}`
if (
isApplicationSessionActive()
&& (
String(response.status || '').trim() === 'submitted'
|| String(response?.draft_payload?.status || '').trim() === 'submitted'
)
) {
return { key: 'application-submit-success', title: '申请单提交成功', tool: 'ApplicationSubmit' }
}
if (toolType.includes('rule')) {
return { key, title: '规则引擎校验', tool: toolCall?.tool_name || 'RuleEngine' }
}
if (toolType.includes('mcp')) {
return toolName.includes('standard')
? { key, title: '差旅补助标准查询', tool: 'TravelStandard' }
: null
}
if (toolName.includes('knowledge')) {
return { key, title: '知识库检索', tool: toolCall?.tool_name || 'KnowledgeSearch' }
}
if (toolName.includes('application_review_preview')) {
return { key: 'application-review-preview', title: '申请信息核对', tool: 'ApplicationReview' }
}
if (toolName.includes('expense_review_preview') || response.preview_only) {
return { key: 'expense-review-preview', title: '报销信息核对', tool: 'ExpenseReview' }
}
if (toolName.includes('expense_claim') || toolName.includes('save_or_submit')) {
if (
response.submission_blocked ||
String(response.status || '').trim() === 'submitted' ||
responseMessage.includes('AI预审') ||
responseMessage.includes('自动检测') ||
responseMessage.includes('审批')
) {
return { key: 'pre-submit-review', title: '自动检测与风险识别', tool: 'ExpenseClaimService.submit_claim' }
}
if (responseMessage.includes('关联')) {
return { key: 'attachment-association', title: '票据关联草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
}
if (responseMessage.includes('新建')) {
return { key: 'expense-claim-draft', title: '新建报销草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
}
return { key: 'expense-claim-draft', title: '保存报销草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
}
return null
return resolveToolCallFlowMetaModel(toolCall, index, {
applicationSessionActive: isApplicationSessionActive()
})
}
function summarizeFlowToolCall(toolCall) {
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
? toolCall.response_json
: {}
const toolName = String(toolCall?.tool_name || '').toLowerCase()
if (toolName.includes('application_review_preview')) {
return '已整理申请核对信息'
}
if (toolName.includes('expense_review_preview') || response.preview_only) {
return '已整理报销核对信息'
}
if (String(response.status || '').trim() === 'submitted') {
return isApplicationSessionActive()
? '申请单提交成功'
: `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
}
if (response.submission_blocked) {
return summarizeVisibleToolText(response.message) || '自动检测发现待补充项,暂未提交审批'
}
return (
summarizeVisibleToolText(response.message || response.summary || response.result_summary)
|| String(toolCall?.tool_name || '').trim()
|| '工具调用完成'
)
return summarizeFlowToolCallModel(toolCall, {
applicationSessionActive: isApplicationSessionActive()
})
}
function mergeFlowRunDetail(run) {

View File

@@ -0,0 +1 @@
export const STEWARD_ASSISTANT_NAME = '小财管家'

View File

@@ -0,0 +1,748 @@
import { normalizeApplicationPreview } from '../../utils/expenseApplicationPreview.js'
import { fetchStewardRuntimeDecision } from '../../services/steward.js'
import { resolveStewardRuntimeFieldCompletion } from './stewardFieldCompletionModel.js'
import {
SESSION_TYPE_APPLICATION,
SESSION_TYPE_EXPENSE,
SESSION_TYPE_STEWARD
} from './travelReimbursementConversationModel.js'
import {
isApplicationSubmitConfirmationText,
isStewardRuntimeCancelText,
isStewardRuntimeContinueText,
normalizeStewardRuntimeInputText,
resolveStewardRuntimeTransportAlias,
shouldPlanNewStewardTasksLocally
} from './travelReimbursementStewardRuntimeTextModel.js'
import { STEWARD_ASSISTANT_NAME } from './useTravelReimbursementStewardRuntime.js'
export function useTravelReimbursementStewardRuntimeDecision({
APPLICATION_PREVIEW_FIELD_ACTION_SET,
activeSessionType,
adjustComposerTextareaHeight,
applicationSubmitConfirmDialog,
attachedFiles,
composerDraft,
continueStewardApplicationFieldCompletion,
conversationId,
createMessage,
handleSuggestedAction,
isStewardSession,
messages,
nextTick,
persistSessionState,
props,
reviewActionBusy,
resolveCurrentUserId,
scrollToBottom,
stewardState,
submitComposer,
submitComposerInternal,
submitStewardPlan,
submitting,
confirmApplicationSubmit
}) {
function findLatestApplicationPreviewMessage() {
for (const message of [...messages.value].reverse()) {
if (
message?.role !== 'assistant' ||
!message.applicationPreview ||
message.applicationSubmitConfirmed
) {
continue
}
return message
}
return null
}
function findPendingApplicationSubmitMessage() {
const message = findLatestApplicationPreviewMessage()
if (!message) {
return null
}
const normalizedPreview = normalizeApplicationPreview(message.applicationPreview)
if (normalizedPreview.readyToSubmit) {
message.applicationPreview = normalizedPreview
return message
}
return null
}
function pushApplicationSubmitBlockedMessage(userText = '', message = null, options = {}) {
const normalizedPreview = normalizeApplicationPreview(message?.applicationPreview || {})
const missingFields = Array.isArray(normalizedPreview.missingFields)
? normalizedPreview.missingFields
: []
if (userText && !options.userMessageAlreadyAdded) {
messages.value.push(createMessage('user', userText))
}
messages.value.push(createMessage(
'assistant',
[
'我理解你是在确认当前申请单,但这张申请单还不能提交。',
'',
missingFields.length
? `还需要先补充:**${missingFields.join('、')}**。`
: '请先把申请核对表中的待补充信息补齐。',
'',
'补齐后再输入“确认”,我会继续提交至审批流程。'
].join('\n'),
[],
{
assistantName: String(message?.assistantName || '').trim() || undefined,
meta: ['等待补充']
}
))
composerDraft.value = ''
persistSessionState()
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
}
async function handleApplicationSubmitConfirmationText(options = {}) {
const rawText = String(options.rawText ?? composerDraft.value ?? '').trim()
const files = Array.from(options.files ?? attachedFiles.value ?? [])
if (!isApplicationSubmitConfirmationText(rawText) || files.length) {
return false
}
const latestApplicationMessage = findLatestApplicationPreviewMessage()
if (!latestApplicationMessage) {
return false
}
const targetMessage = findPendingApplicationSubmitMessage()
if (!targetMessage) {
pushApplicationSubmitBlockedMessage(rawText, latestApplicationMessage)
return true
}
applicationSubmitConfirmDialog.value = {
open: true,
message: targetMessage
}
await confirmApplicationSubmit({ userText: rawText })
return true
}
function findPendingStewardSuggestedActionContext(decision = null) {
const targetMessageId = String(decision?.target_message_id || decision?.targetMessageId || '').trim()
const targetTaskId = String(decision?.target_task_id || decision?.targetTaskId || '').trim()
for (const message of [...messages.value].reverse()) {
if (
message?.role !== 'assistant' ||
message.suggestedActionsLocked ||
!Array.isArray(message.suggestedActions) ||
!message.suggestedActions.length
) {
continue
}
if (targetMessageId && String(message.id || '') !== targetMessageId) {
continue
}
const action = message.suggestedActions.find((item) => {
if (String(item?.action_type || '').trim() === APPLICATION_PREVIEW_FIELD_ACTION_SET) {
return false
}
const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {}
return !targetTaskId ||
String(payload.steward_next_task_id || payload.target_task_id || '').trim() === targetTaskId
}) || message.suggestedActions[0]
if (action) {
return { message, action }
}
}
return null
}
function findPendingSlotSuggestedActionContext(decision = null) {
const fieldKey = String(decision?.field_key || decision?.fieldKey || '').trim()
const fieldValue = String(decision?.field_value || decision?.fieldValue || '').trim()
for (const message of [...messages.value].reverse()) {
if (
message?.role !== 'assistant' ||
message.suggestedActionsLocked ||
!Array.isArray(message.suggestedActions) ||
!message.suggestedActions.length
) {
continue
}
const action = message.suggestedActions.find((item) => {
if (String(item?.action_type || '').trim() !== APPLICATION_PREVIEW_FIELD_ACTION_SET) {
return false
}
const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {}
const payloadField = String(payload.field_key || payload.fieldKey || '').trim()
const payloadValue = String(payload.value || item?.label || '').trim()
return payloadField && (!fieldKey || payloadField === fieldKey) && (!fieldValue || payloadValue === fieldValue)
})
if (action) {
return { message, action }
}
}
return null
}
function findPendingSlotSuggestedActionContextByInput(rawText = '') {
const normalizedInput = normalizeStewardRuntimeInputText(rawText)
if (!normalizedInput) {
return null
}
const transportAlias = resolveStewardRuntimeTransportAlias(normalizedInput)
for (const message of [...messages.value].reverse()) {
if (
message?.role !== 'assistant' ||
message.suggestedActionsLocked ||
!Array.isArray(message.suggestedActions) ||
!message.suggestedActions.length
) {
continue
}
const exactMatches = []
const fuzzyMatches = []
message.suggestedActions.forEach((action) => {
if (String(action?.action_type || '').trim() !== APPLICATION_PREVIEW_FIELD_ACTION_SET) {
return
}
const payload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const fieldKey = String(payload.field_key || payload.fieldKey || '').trim()
const value = String(payload.value || action?.label || '').trim()
const label = String(action?.label || value).trim()
const tokens = [value, label]
.map((item) => normalizeStewardRuntimeInputText(item))
.filter(Boolean)
if (!fieldKey || !value || !tokens.length) {
return
}
if (tokens.includes(normalizedInput)) {
exactMatches.push({ message, action })
return
}
const actionTransportAlias = resolveStewardRuntimeTransportAlias(`${value}${label}`)
if (
transportAlias &&
(
tokens.includes(normalizeStewardRuntimeInputText(transportAlias)) ||
actionTransportAlias === transportAlias
)
) {
fuzzyMatches.push({ message, action })
return
}
if (tokens.some((token) => token.length >= 2 && normalizedInput.includes(token))) {
fuzzyMatches.push({ message, action })
}
})
if (exactMatches.length === 1) {
return exactMatches[0]
}
if (exactMatches.length > 1) {
return null
}
const uniqueFuzzyMatches = fuzzyMatches.filter((item, index, list) =>
list.findIndex((candidate) => candidate.action === item.action) === index
)
if (uniqueFuzzyMatches.length === 1) {
return uniqueFuzzyMatches[0]
}
if (uniqueFuzzyMatches.length > 1) {
return null
}
}
return null
}
function buildStewardRuntimeState() {
const latestApplicationMessage = findLatestApplicationPreviewMessage()
const applicationPreview = latestApplicationMessage?.applicationPreview
? normalizeApplicationPreview(latestApplicationMessage.applicationPreview)
: null
const applicationContinuation = latestApplicationMessage?.stewardContinuation || null
const pendingSlotContext = findPendingSlotSuggestedActionContext()
const pendingStewardContext = pendingSlotContext ? null : findPendingStewardSuggestedActionContext()
const pendingActionPayload = pendingStewardContext?.action?.payload && typeof pendingStewardContext.action.payload === 'object'
? pendingStewardContext.action.payload
: {}
const pendingSlotPayload = pendingSlotContext?.action?.payload && typeof pendingSlotContext.action.payload === 'object'
? pendingSlotContext.action.payload
: {}
const continuation = applicationContinuation || pendingStewardContext?.message?.stewardContinuation || null
const remainingTasks = Array.isArray(continuation?.remainingTasks)
? continuation.remainingTasks
: []
const pendingApplication = latestApplicationMessage && applicationPreview
? {
message_id: String(latestApplicationMessage.id || '').trim(),
task_id: String(
applicationContinuation?.currentTaskId ||
applicationContinuation?.current_task_id ||
applicationContinuation?.currentTask?.task_id ||
applicationContinuation?.currentTask?.taskId ||
''
).trim(),
ready_to_submit: Boolean(applicationPreview.readyToSubmit),
missing_fields: Array.isArray(applicationPreview.missingFields) ? applicationPreview.missingFields : [],
fields: applicationPreview.fields || {}
}
: null
return {
waiting_for: pendingApplication
? (pendingApplication.ready_to_submit ? 'application_submit_confirmation' : 'application_field_completion')
: pendingSlotContext
? 'application_field_completion'
: pendingStewardContext
? 'steward_next_task_confirmation'
: '',
current_task: continuation?.currentTask || continuation?.current_task || null,
remaining_tasks: remainingTasks,
completed_tasks: messages.value
.filter((message) => message?.applicationSubmitConfirmed)
.map((message) => ({
message_id: String(message.id || '').trim(),
task_type: 'expense_application'
})),
pending_application: pendingApplication,
pending_steward_action: pendingStewardContext
? {
message_id: String(pendingStewardContext.message?.id || '').trim(),
action_type: String(pendingStewardContext.action?.action_type || '').trim(),
label: String(pendingStewardContext.action?.label || '').trim(),
target_task_id: String(pendingActionPayload.steward_next_task_id || pendingActionPayload.target_task_id || '').trim(),
payload: pendingActionPayload
}
: null,
pending_slot_action: pendingSlotContext
? {
message_id: String(pendingSlotContext.message?.id || '').trim(),
field_key: String(pendingSlotPayload.field_key || pendingSlotPayload.fieldKey || '').trim(),
label: String(pendingSlotContext.action?.label || '').trim(),
payload: pendingSlotPayload
}
: null,
steward_state: stewardState.value || null
}
}
function hasActiveStewardRuntimeDecisionContext(runtimeState = {}) {
return Boolean(
String(runtimeState?.waiting_for || '').trim() ||
runtimeState?.pending_application ||
runtimeState?.pending_steward_action ||
runtimeState?.pending_slot_action ||
runtimeState?.current_task ||
runtimeState?.steward_state ||
(Array.isArray(runtimeState?.remaining_tasks) && runtimeState.remaining_tasks.length > 0) ||
(Array.isArray(runtimeState?.completed_tasks) && runtimeState.completed_tasks.length > 0)
)
}
function resolveStewardStateFlow(state = {}, flowId = '') {
const flows = state?.flows && typeof state.flows === 'object' ? state.flows : {}
const flow = flows[String(flowId || '').trim()]
return flow && typeof flow === 'object' ? flow : null
}
function buildSelectedStewardFlowTaskPayload(flowId = '', flow = {}) {
return {
task_id: String(flowId || '').trim(),
task_type: flowId === 'travel_application' ? 'expense_application' : 'reimbursement',
title: flowId === 'travel_application' ? '补办出差申请' : '发起费用报销',
summary: buildSelectedStewardFlowSummary(flow),
assigned_agent: flowId === 'travel_application' ? 'application_assistant' : 'reimbursement_assistant',
ontology_fields: flow?.fields || {},
missing_fields: Array.isArray(flow?.missing_fields || flow?.missingFields)
? flow.missing_fields || flow.missingFields
: []
}
}
function buildSelectedStewardFlowCarryText(flowId = '', flow = {}, state = {}) {
const label = flowId === 'travel_application' ? '补办出差申请' : '发起费用报销'
const fields = flow?.fields && typeof flow.fields === 'object' ? flow.fields : {}
const fieldLines = [
fields.time_range ? `时间:${fields.time_range}` : '',
fields.location ? `地点:${fields.location}` : '',
fields.expense_type ? `费用类型:${fields.expense_type}` : '',
fields.reason ? `事由:${fields.reason}` : ''
].filter(Boolean)
const missingFields = Array.isArray(flow?.missing_fields || flow?.missingFields)
? flow.missing_fields || flow.missingFields
: []
const sourceMessage = String(state?.pending_flow_confirmation?.source_message || '').trim()
return [
`小财管家已确认本次按“${label}”继续处理。`,
sourceMessage ? `用户原始描述:${sourceMessage}` : '',
fieldLines.length ? `已识别信息:${fieldLines.join('')}` : '',
missingFields.length ? `还需要补充:${missingFields.join('、')}` : '',
flowId === 'travel_application'
? '请进入申请流程继续核对出差申请材料;如果仍缺关键字段,请先追问用户。'
: '请进入报销流程继续核对报销材料、票据和金额;创建草稿或提交前仍需用户确认。'
].filter(Boolean).join('\n')
}
function buildSelectedStewardFlowSummary(flow = {}) {
const fields = flow?.fields && typeof flow.fields === 'object' ? flow.fields : {}
return [
fields.time_range ? `时间:${fields.time_range}` : '',
fields.location ? `地点:${fields.location}` : '',
fields.reason ? `事由:${fields.reason}` : ''
].filter(Boolean).join('')
}
function pushStewardRuntimeUserMessage(userText = '') {
const normalizedText = String(userText || '').trim()
if (!normalizedText) {
return false
}
messages.value.push(createMessage('user', normalizedText))
composerDraft.value = ''
persistSessionState()
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
return true
}
function pushStewardRuntimeResponse(userText = '', decision = null, options = {}) {
if (userText && !options.userMessageAlreadyAdded) {
messages.value.push(createMessage('user', userText))
}
const text = String(decision?.question || decision?.response_text || decision?.responseText || decision?.rationale || '').trim()
if (text) {
messages.value.push(createMessage('assistant', text, [], {
assistantName: STEWARD_ASSISTANT_NAME,
meta: [STEWARD_ASSISTANT_NAME]
}))
}
composerDraft.value = ''
persistSessionState()
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
}
function buildStewardRuntimeFastPathDecision(rawText = '', runtimeState = {}) {
const normalizedText = String(rawText || '').trim()
if (!normalizedText) {
return null
}
if (shouldPlanNewStewardTasksLocally(normalizedText, runtimeState)) {
return {
next_action: 'plan_new_tasks'
}
}
if (isStewardRuntimeCancelText(normalizedText)) {
return {
next_action: 'cancel_current_action',
response_text: '已暂停当前等待动作。我不会继续提交或进入下一步;如果你要重新规划,请直接告诉我新的财务事项。'
}
}
const slotContext = findPendingSlotSuggestedActionContextByInput(normalizedText)
const payload = slotContext?.action?.payload && typeof slotContext.action.payload === 'object'
? slotContext.action.payload
: {}
if (slotContext) {
return {
next_action: 'fill_current_slot',
target_message_id: String(slotContext.message?.id || '').trim(),
field_key: String(payload.field_key || payload.fieldKey || '').trim(),
field_value: String(payload.value || slotContext.action?.label || normalizedText).trim()
}
}
if (isApplicationSubmitConfirmationText(normalizedText) || isStewardRuntimeContinueText(normalizedText)) {
if (runtimeState?.pending_application?.ready_to_submit) {
return {
next_action: 'submit_current_application',
target_message_id: runtimeState.pending_application.message_id || ''
}
}
if (runtimeState?.pending_steward_action) {
return {
next_action: 'continue_next_task',
target_message_id: runtimeState.pending_steward_action.message_id || '',
target_task_id: runtimeState.pending_steward_action.target_task_id || ''
}
}
}
if (String(runtimeState?.waiting_for || '').trim() === 'application_field_completion') {
if (isApplicationSubmitConfirmationText(normalizedText) || isStewardRuntimeContinueText(normalizedText)) {
const missingFields = Array.isArray(runtimeState?.pending_application?.missing_fields)
? runtimeState.pending_application.missing_fields
: []
return {
next_action: 'ask_user',
response_text: missingFields.length
? `当前申请还不能继续提交,请先补充:${missingFields.join('、')}。你可以直接回复对应选项或填写具体内容。`
: '当前申请还有信息需要先补充。请先回复系统刚刚追问的内容,我再继续生成核对结果。'
}
}
const fieldCompletionDecision = resolveStewardRuntimeFieldCompletion(normalizedText, runtimeState)
if (fieldCompletionDecision) {
return fieldCompletionDecision
}
}
return null
}
function shouldUseStewardRuntimeLlmDecision(rawText = '', runtimeState = {}) {
if (shouldPlanNewStewardTasksLocally(rawText, runtimeState)) {
return false
}
const normalizedText = normalizeStewardRuntimeInputText(rawText)
if (!normalizedText) {
return false
}
if (
isApplicationSubmitConfirmationText(normalizedText) ||
isStewardRuntimeContinueText(normalizedText) ||
isStewardRuntimeCancelText(normalizedText)
) {
return false
}
if (
findPendingSlotSuggestedActionContextByInput(normalizedText)
) {
return false
}
return true
}
async function executeStewardRuntimeDecision(decision = null, rawText = '', options = {}) {
const nextAction = String(decision?.next_action || decision?.nextAction || '').trim()
const userMessageAlreadyAdded = Boolean(options.userMessageAlreadyAdded)
if (nextAction === 'submit_current_application') {
const targetMessageId = String(decision?.target_message_id || decision?.targetMessageId || '').trim()
const targetMessage = targetMessageId
? messages.value.find((message) => String(message.id || '') === targetMessageId)
: findPendingApplicationSubmitMessage()
if (!targetMessage?.applicationPreview) {
return false
}
const normalizedPreview = normalizeApplicationPreview(targetMessage.applicationPreview)
if (!normalizedPreview.readyToSubmit) {
pushApplicationSubmitBlockedMessage(rawText, targetMessage, { userMessageAlreadyAdded })
return true
}
targetMessage.applicationPreview = normalizedPreview
applicationSubmitConfirmDialog.value = { open: true, message: targetMessage }
await confirmApplicationSubmit({ userText: rawText, skipUserMessage: userMessageAlreadyAdded })
return true
}
if (nextAction === 'continue_next_task') {
const context = findPendingStewardSuggestedActionContext(decision)
if (!context) {
return false
}
if (rawText && !userMessageAlreadyAdded) {
messages.value.push(createMessage('user', rawText))
}
context.action.confirmedByText = true
composerDraft.value = ''
persistSessionState()
nextTick(() => {
adjustComposerTextareaHeight()
scrollToBottom()
})
await handleSuggestedAction(context.message, context.action)
return true
}
if (nextAction === 'continue_selected_flow') {
const selectedState = decision?.steward_state || decision?.stewardState || stewardState.value || {}
const flowId = String(
decision?.target_task_id ||
decision?.targetTaskId ||
selectedState?.active_flow ||
selectedState?.activeFlow ||
''
).trim()
const flow = resolveStewardStateFlow(selectedState, flowId)
if (!flowId || !flow) {
pushStewardRuntimeResponse(rawText, decision, { userMessageAlreadyAdded })
return true
}
const targetSessionType = flowId === 'travel_application'
? SESSION_TYPE_APPLICATION
: SESSION_TYPE_EXPENSE
await submitComposerInternal({
rawText: buildSelectedStewardFlowCarryText(flowId, flow, selectedState),
userText: rawText || (flowId === 'travel_application' ? '补办出差申请' : '发起费用报销'),
pendingText: targetSessionType === SESSION_TYPE_APPLICATION
? '小财管家正在按申请流程整理出差材料...'
: '小财管家正在按报销流程整理票据和费用材料...',
files: [],
skipScopeGuard: true,
skipApplicationModelReview: targetSessionType === SESSION_TYPE_APPLICATION,
skipStewardSlotDecision: targetSessionType === SESSION_TYPE_APPLICATION,
skipStewardPlan: true,
skipUserMessage: userMessageAlreadyAdded,
sessionTypeOverride: targetSessionType,
stewardContinuation: {
planId: 'steward_selected_flow',
currentTaskId: flowId,
currentTask: buildSelectedStewardFlowTaskPayload(flowId, flow),
remainingTasks: []
}
})
return true
}
if (nextAction === 'fill_current_slot') {
const context = findPendingSlotSuggestedActionContext(decision)
if (!context) {
return false
}
await handleSuggestedAction(context.message, {
...context.action,
label: String(decision?.field_value || decision?.fieldValue || context.action.label || '').trim(),
suppressUserEcho: userMessageAlreadyAdded
})
return true
}
if (nextAction === 'fill_current_application_field') {
const targetMessageId = String(decision?.target_message_id || decision?.targetMessageId || '').trim()
const targetMessage = targetMessageId
? messages.value.find((message) => String(message.id || '') === targetMessageId)
: findLatestApplicationPreviewMessage()
if (!targetMessage?.applicationPreview) {
return false
}
const fieldKey = String(decision?.field_key || decision?.fieldKey || '').trim()
const fieldLabel = String(decision?.field_label || decision?.fieldLabel || '').trim()
const fieldValue = String(decision?.field_value || decision?.fieldValue || rawText).trim()
if (!fieldKey || !fieldValue) {
return false
}
await continueStewardApplicationFieldCompletion({
targetMessage,
action: {
label: fieldValue,
suppressUserEcho: userMessageAlreadyAdded,
payload: {
steward_delegated_field_completion: true,
field_key: fieldKey,
field_label: fieldLabel,
value: fieldValue
}
},
sourcePreview: targetMessage.applicationPreview,
fieldKey,
fieldLabel,
value: fieldValue
})
return true
}
if (nextAction === 'ask_user' || nextAction === 'cancel_current_action' || nextAction === 'no_op') {
pushStewardRuntimeResponse(rawText, decision, { userMessageAlreadyAdded })
return true
}
return false
}
async function handleStewardRuntimeDecision(options = {}) {
if (!isStewardSession.value || options.skipStewardPlan) {
return false
}
const rawText = String(options.rawText ?? composerDraft.value ?? '').trim()
const files = Array.from(options.files ?? attachedFiles.value ?? [])
if (!rawText || files.length) {
return false
}
const runtimeState = buildStewardRuntimeState()
if (!hasActiveStewardRuntimeDecisionContext(runtimeState)) {
return false
}
const userMessageAlreadyAdded = options.skipUserMessage
? false
: pushStewardRuntimeUserMessage(rawText)
try {
const fastDecision = buildStewardRuntimeFastPathDecision(rawText, runtimeState)
if (fastDecision) {
if (String(fastDecision.next_action || fastDecision.nextAction || '').trim() === 'plan_new_tasks') {
await submitStewardPlan({
...options,
rawText,
userText: rawText,
skipUserMessage: userMessageAlreadyAdded || options.skipUserMessage
})
return true
}
const fastExecuted = await executeStewardRuntimeDecision(fastDecision, rawText, { userMessageAlreadyAdded })
if (fastExecuted) {
return true
}
}
if (!shouldUseStewardRuntimeLlmDecision(rawText, runtimeState)) {
if (userMessageAlreadyAdded) {
pushStewardRuntimeResponse('', {
response_text: '我还需要先确认当前等待项。请回复系统刚刚追问的选项或具体补充内容。'
}, { userMessageAlreadyAdded: true })
return true
}
return false
}
const decision = await fetchStewardRuntimeDecision({
user_message: rawText,
session_type: SESSION_TYPE_STEWARD,
runtime_state: runtimeState,
context_json: {
entry_source: props.entrySource,
user_id: resolveCurrentUserId(),
conversation_id: conversationId.value || '',
steward_state: stewardState.value || null
}
}, {
timeoutMs: 45000,
timeoutMessage: '小财管家运行时决策超时,已回到当前上下文兜底处理。'
})
const nextStewardState = decision?.steward_state || decision?.stewardState || null
if (nextStewardState && typeof nextStewardState === 'object') {
stewardState.value = nextStewardState
}
if (String(decision?.next_action || decision?.nextAction || '').trim() === 'plan_new_tasks') {
await submitStewardPlan({
...options,
rawText,
userText: rawText,
skipUserMessage: userMessageAlreadyAdded || options.skipUserMessage
})
return true
}
const executed = await executeStewardRuntimeDecision(decision, rawText, { userMessageAlreadyAdded })
if (executed) {
return true
}
if (userMessageAlreadyAdded) {
await submitStewardPlan({
...options,
rawText,
userText: rawText,
skipUserMessage: true
})
return true
}
return false
} catch (error) {
console.warn('Steward runtime decision failed:', error)
if (userMessageAlreadyAdded) {
await submitStewardPlan({
...options,
rawText,
userText: rawText,
skipUserMessage: true
})
return true
}
return false
}
}
return {
handleApplicationSubmitConfirmationText,
handleStewardRuntimeDecision
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -444,6 +444,7 @@ export function useTravelReimbursementSuggestedActions({
}
return {
continueStewardApplicationFieldCompletion,
handleSuggestedAction,
isSuggestedActionSelected,
runShortcut

View File

@@ -0,0 +1,386 @@
import { computed, ref } from 'vue'
import {
approveExpenseClaim,
deleteExpenseClaim,
returnExpenseClaim
} from '../../services/reimbursements.js'
import { resolveGeneratedDraftClaimNo } from '../../utils/applicationApproval.js'
export function useTravelRequestDetailApprovalFlow({
request,
isApplicationDocument,
isDraftRequest,
isArchivedRequest,
isFinanceApprovalStage,
isBudgetApprovalStage,
canDeleteRequest,
canReturnRequest,
canApproveRequest,
approvalRiskConfirmItems,
canViewApprovalRiskAdvice,
toast,
emit
}) {
const deleteBusy = ref(false)
const deleteDialogOpen = ref(false)
const returnBusy = ref(false)
const returnDialogOpen = ref(false)
const approveBusy = ref(false)
const approveConfirmDialogOpen = ref(false)
const approvalRiskConfirmed = ref(false)
const leaderOpinion = ref('')
const budgetApprovalOpinionRequired = computed(() => (
isBudgetApprovalStage.value
&& hasBudgetApprovalWarning(request.value)
))
const requiresApprovalOpinion = computed(() => budgetApprovalOpinionRequired.value)
const approvalRiskConfirmRequired = computed(() =>
canApproveRequest.value
&& canViewApprovalRiskAdvice.value
&& approvalRiskConfirmItems.value.length > 0
)
const approvalOpinionTitle = computed(() => {
if (isFinanceApprovalStage.value) {
return '财务意见'
}
if (isBudgetApprovalStage.value) {
return '预算审批意见'
}
return '附加意见'
})
const approvalOpinionPlaceholder = computed(() => {
if (isFinanceApprovalStage.value) {
return '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。'
}
if (budgetApprovalOpinionRequired.value) {
return '预算已超过警戒值,请写明预算审批意见、通过依据或后续控制要求。'
}
if (isBudgetApprovalStage.value) {
return '可选填预算审批补充说明;未超过预算警戒值时不填写默认为同意。'
}
if (isApplicationDocument.value) {
return '可选填审批补充说明,例如业务必要性、预算合理性或执行要求;不填写默认为同意。'
}
return '可选填审批补充说明,例如核实情况、费用合理性或后续财务关注点;不填写默认为同意。'
})
const approvalOpinionHint = computed(() => {
if (isFinanceApprovalStage.value) {
return '审核通过后将进入待付款。'
}
if (isBudgetApprovalStage.value) {
return budgetApprovalOpinionRequired.value
? '预算已超过警戒值,需填写预算审批意见后才能通过。'
: '未超过预算警戒值时不填写意见将默认同意,确认后按流程继续流转。'
}
return isApplicationDocument.value ? '不填写附加意见则默认同意,确认后系统会按预算与风险结果决定下一步:无风险且预算充足将直接完成申请,否则进入预算管理者审批。' : '不填写附加意见则默认同意,审批通过后将流转至财务审批。'
})
const approvalConfirmBadge = computed(() => {
if (isFinanceApprovalStage.value) {
return '财务终审'
}
return isBudgetApprovalStage.value ? '预算审核' : '领导审批'
})
const approvalConfirmDescription = computed(() => {
if (isFinanceApprovalStage.value) {
return '确认后该报销单会完成财务终审并进入待付款,请确认票据、金额与财务意见无误。'
}
if (isApplicationDocument.value) {
return isBudgetApprovalStage.value
? '确认后该申请单会完成预算审核,归档申请单,并自动进入申请人的报销草稿中。'
: '确认后该申请单会完成直属领导审批,系统将按预算余额、当前风险和历史风险判断是否需要预算管理者复核;无风险且预算充足会直接完成申请并生成报销草稿。'
}
return '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。'
})
const approveActionLabel = computed(() => (isApplicationDocument.value ? '确认审核' : '审批通过'))
const approveBusyLabel = computed(() => (isApplicationDocument.value ? '确认中' : '通过中'))
const approveConfirmTitle = computed(() => (
isApplicationDocument.value ? `确认审核 ${request.value.id} 吗?` : `确认通过 ${request.value.id} 吗?`
))
const approveConfirmText = computed(() => (isApplicationDocument.value ? '确认审核' : '确认通过'))
const approveBusyText = computed(() => (isApplicationDocument.value ? '确认中...' : '通过中...'))
const returnDialogDescription = computed(() => (
isApplicationDocument.value
? '退回后该申请单会回到待提交状态,申请人需要调整后重新提交。'
: '退回后该单据会回到待提交状态,申请人需要调整后重新提交并再次经过 AI 预审。'
))
const approvalSuccessToast = computed(() => {
if (isFinanceApprovalStage.value) {
return `${request.value.id} 已完成财务终审,进入待付款。`
}
return isApplicationDocument.value
? isBudgetApprovalStage.value
? `${request.value.id} 已完成预算审核,正在生成报销草稿。`
: `${request.value.id} 已确认审核,系统已按预算与风险结果更新流程。`
: `${request.value.id} 已审批通过,流转至财务审批。`
})
const deleteActionLabel = computed(() => {
if (isApplicationDocument.value) {
return '删除申请'
}
return isDraftRequest.value ? '删除草稿' : '删除单据'
})
const deleteDialogTarget = computed(() => request.value.documentNo || request.value.id || '当前单据')
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value}吗?`)
const deleteDialogDescription = computed(() =>
isDraftRequest.value
? `${deleteDialogTarget.value} 删除后,该草稿及其当前费用明细将不可恢复。`
: `${deleteDialogTarget.value} 删除后,该${isApplicationDocument.value ? '申请单' : '报销单'}及费用明细将不可恢复。`
)
async function handleDeleteRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法删除。')
return
}
if (!canDeleteRequest.value) {
toast(
isArchivedRequest.value
? '已归档单据不能删除,只有高级管理员可以执行删除。'
: isApplicationDocument.value
? '当前申请单已进入审批流程,只有草稿、待补充或退回待提交阶段的申请人本人或系统管理员可以删除。'
: '当前单据已进入流程,只有草稿、待补充或退回待提交阶段的申请人本人或系统管理员可以删除。'
)
return
}
deleteDialogOpen.value = true
}
function closeDeleteDialog() {
if (deleteBusy.value) {
return
}
deleteDialogOpen.value = false
}
async function confirmDeleteRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法删除。')
return
}
deleteBusy.value = true
try {
const payload = await deleteExpenseClaim(request.value.claimId)
deleteDialogOpen.value = false
toast(payload?.message || `${request.value.id} ${isApplicationDocument.value ? '申请单' : '报销单'}已删除。`)
emit('request-deleted', {
claimId: request.value.claimId,
claimNo: request.value.claimNo || request.value.documentNo || request.value.id,
documentNo: request.value.documentNo || request.value.id
})
} catch (error) {
toast(error?.message || '删除单据失败,请稍后重试。')
} finally {
deleteBusy.value = false
}
}
function handleReturnRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法退回。')
return
}
if (!canReturnRequest.value) {
toast('当前状态不支持退回。')
return
}
returnDialogOpen.value = true
}
function closeReturnDialog() {
if (returnBusy.value) {
return
}
returnDialogOpen.value = false
}
async function confirmReturnRequest(payload) {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法退回。')
return
}
returnBusy.value = true
try {
await returnExpenseClaim(request.value.claimId, payload)
returnDialogOpen.value = false
toast(`${request.value.id} 已退回待提交。`)
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '退回单据失败,请稍后重试。')
} finally {
returnBusy.value = false
}
}
function handleApproveRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法审批通过。')
return
}
if (!canApproveRequest.value) {
toast('当前节点暂不支持审批通过。')
return
}
approvalRiskConfirmed.value = !approvalRiskConfirmRequired.value
approveConfirmDialogOpen.value = true
}
function closeApproveConfirmDialog() {
if (approveBusy.value) {
return
}
approveConfirmDialogOpen.value = false
}
function resolveApproveErrorMessage(error) {
const message = String(error?.message || '').trim()
if (message.includes('未找到同部门 P8 预算审批人')) {
return '当前部门未配置 P8 预算审批人,请联系管理员配置后再审批。'
}
return message || '审批通过失败,请稍后重试。'
}
async function confirmApproveRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法审批通过。')
approveConfirmDialogOpen.value = false
return
}
if (!canApproveRequest.value) {
toast('当前节点暂不支持审批通过。')
approveConfirmDialogOpen.value = false
return
}
if (approvalRiskConfirmRequired.value && !approvalRiskConfirmed.value) {
toast('请先确认已核对风险说明和佐证材料,再继续审批。')
return
}
if (requiresApprovalOpinion.value && !leaderOpinion.value.trim()) {
toast('预算已超过警戒值,请填写预算审批意见后再通过。')
return
}
approveBusy.value = true
try {
const responsePayload = await approveExpenseClaim(request.value.claimId, {
opinion: leaderOpinion.value.trim() || '同意'
})
const generatedDraftClaimNo = resolveGeneratedDraftClaimNo(responsePayload)
approveConfirmDialogOpen.value = false
approvalRiskConfirmed.value = false
leaderOpinion.value = ''
toast(
isApplicationDocument.value && generatedDraftClaimNo
? `${request.value.id} 已确认审核,报销草稿 ${generatedDraftClaimNo} 已生成。`
: approvalSuccessToast.value
)
emit('request-updated', {
claimId: request.value.claimId,
claim: responsePayload
})
emit('backToRequests')
} catch (error) {
toast(resolveApproveErrorMessage(error))
} finally {
approveBusy.value = false
}
}
return {
approveActionLabel,
approveBusy,
approveBusyLabel,
approveBusyText,
approveConfirmDialogOpen,
approveConfirmText,
approveConfirmTitle,
approvalConfirmBadge,
approvalConfirmDescription,
approvalOpinionHint,
approvalOpinionPlaceholder,
approvalOpinionTitle,
approvalRiskConfirmed,
approvalRiskConfirmRequired,
closeApproveConfirmDialog,
closeDeleteDialog,
closeReturnDialog,
confirmApproveRequest,
confirmDeleteRequest,
confirmReturnRequest,
deleteActionLabel,
deleteBusy,
deleteDialogDescription,
deleteDialogOpen,
deleteDialogTitle,
handleApproveRequest,
handleDeleteRequest,
handleReturnRequest,
leaderOpinion,
requiresApprovalOpinion,
returnBusy,
returnDialogDescription,
returnDialogOpen
}
}
function hasBudgetApprovalWarning(request = {}) {
const flags = Array.isArray(request?.riskFlags)
? request.riskFlags
: Array.isArray(request?.risk_flags_json)
? request.risk_flags_json
: []
return flags.some((flag) => {
if (!flag || typeof flag !== 'object') {
return false
}
const routeDecision = flag.route_decision || flag.routeDecision || {}
const directBudgetResult = flag.budget_result || flag.budgetResult
const routeBudgetResult = routeDecision?.budget_result || routeDecision?.budgetResult
const budgetResult = routeBudgetResult || directBudgetResult
if (!budgetResult || typeof budgetResult !== 'object') {
return false
}
return budgetResultExceedsWarning(budgetResult)
})
}
function budgetResultExceedsWarning(budgetResult = {}) {
const metrics = budgetResult.metrics && typeof budgetResult.metrics === 'object' ? budgetResult.metrics : {}
const context = budgetResult.budget_context && typeof budgetResult.budget_context === 'object'
? budgetResult.budget_context
: budgetResult.budgetContext && typeof budgetResult.budgetContext === 'object'
? budgetResult.budgetContext
: {}
const overBudgetAmount = parseBudgetNumber(metrics.over_budget_amount ?? metrics.overBudgetAmount)
if (overBudgetAmount > 0) {
return true
}
const afterUsageRate = parseBudgetNumber(metrics.after_usage_rate ?? metrics.afterUsageRate)
const claimAmountRatio = parseBudgetNumber(metrics.claim_amount_ratio ?? metrics.claimAmountRatio)
const warningThreshold = parseBudgetNumber(context.warning_threshold ?? context.warningThreshold, 80)
return Math.max(afterUsageRate, claimAmountRatio) >= warningThreshold
}
function parseBudgetNumber(value, fallback = 0) {
const number = Number(value)
return Number.isFinite(number) ? number : fallback
}

View File

@@ -0,0 +1,266 @@
import { computed, ref } from 'vue'
import {
fetchExpenseClaimItemAttachmentMeta,
fetchExpenseClaimItemAttachmentPreview
} from '../../services/reimbursements.js'
import {
buildAttachmentInsightViewModel,
buildAttachmentRiskCards
} from './travelRequestDetailInsights.js'
export function useTravelRequestDetailAttachmentPreview({
request,
expenseItems,
expenseAttachmentMeta
}) {
const attachmentPreviewOpen = ref(false)
const attachmentPreviewLoading = ref(false)
const attachmentPreviewError = ref('')
const attachmentPreviewUrl = ref('')
const attachmentPreviewName = ref('')
const attachmentPreviewMediaType = ref('')
const attachmentPreviewItemId = ref('')
const attachmentPreviewEntries = computed(() =>
expenseItems.value
.filter((item) => canPreviewAttachment(item))
.map((item, index) => ({
item,
itemId: item.id,
index,
name: resolveAttachmentDisplayName(item) || `${index + 1} 条附件`,
metadata: resolveAttachmentMeta(item)
}))
)
const currentAttachmentPreviewIndex = computed(() =>
attachmentPreviewEntries.value.findIndex((entry) => entry.itemId === attachmentPreviewItemId.value)
)
const currentAttachmentPreviewEntry = computed(() => {
const index = currentAttachmentPreviewIndex.value
return index >= 0 ? attachmentPreviewEntries.value[index] : null
})
const attachmentPreviewIndexLabel = computed(() => {
const currentIndex = currentAttachmentPreviewIndex.value
const total = attachmentPreviewEntries.value.length
return currentIndex >= 0 && total > 0 ? `${currentIndex + 1} / ${total}` : ''
})
const canNavigateAttachmentPreview = computed(() => attachmentPreviewEntries.value.length > 1)
const currentAttachmentPreviewInsight = computed(() => {
const entry = currentAttachmentPreviewEntry.value
if (!entry) {
return null
}
return buildAttachmentInsightViewModel(resolveAttachmentMeta(entry.item), entry.item)
})
const currentAttachmentPreviewRiskCards = computed(() => {
const entry = currentAttachmentPreviewEntry.value
if (!entry) {
return []
}
return buildAttachmentRiskCards({
expenseItems: [entry.item],
attachmentMetaByItemId: expenseAttachmentMeta
})
})
function resolveAttachmentMeta(item) {
return expenseAttachmentMeta[item.id] || null
}
async function refreshExpenseAttachmentMeta(itemId) {
if (!request.value.claimId || !itemId) {
return null
}
const payload = await fetchExpenseClaimItemAttachmentMeta(request.value.claimId, itemId)
expenseAttachmentMeta[itemId] = payload
return payload
}
function resolveAttachmentDisplayName(item) {
const metadata = resolveAttachmentMeta(item)
return String(metadata?.file_name || item.attachmentHint || '').trim()
}
function hasStoredAttachmentReference(item) {
return String(item?.invoiceId || '').includes('/')
}
function resolveAttachmentPreviewTitle(item) {
const fileName = resolveAttachmentDisplayName(item)
return fileName ? `预览附件:${fileName}` : '预览附件'
}
function resolveAttachmentRecognition(item) {
return buildAttachmentInsightViewModel(resolveAttachmentMeta(item), item)
}
function buildAttachmentRiskNotice(attachment) {
const analysis = attachment?.analysis
const severity = String(analysis?.severity || '').trim()
if (!analysis || severity === 'pass') {
return ''
}
const label =
String(analysis?.label || '').trim()
|| (severity === 'high' ? '高风险' : severity === 'medium' ? '中风险' : '低风险')
const summary = String(analysis?.summary || analysis?.headline || '').trim() || '附件存在待核对风险。'
return `${label}${summary}`
}
function canPreviewAttachment(item) {
if (!item?.invoiceId) {
return false
}
const metadata = resolveAttachmentMeta(item)
if (metadata) {
return metadata.previewable !== false
}
return true
}
function revokeAttachmentPreviewUrl() {
if (attachmentPreviewUrl.value && attachmentPreviewUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(attachmentPreviewUrl.value)
}
attachmentPreviewUrl.value = ''
}
function closeAttachmentPreview() {
attachmentPreviewOpen.value = false
attachmentPreviewLoading.value = false
attachmentPreviewError.value = ''
attachmentPreviewName.value = ''
attachmentPreviewMediaType.value = ''
attachmentPreviewItemId.value = ''
revokeAttachmentPreviewUrl()
}
async function syncExpenseAttachmentMeta() {
if (!request.value.claimId) {
return
}
const tasks = expenseItems.value
.filter((item) => item.invoiceId)
.map(async (item) => {
try {
const payload = await fetchExpenseClaimItemAttachmentMeta(request.value.claimId, item.id)
expenseAttachmentMeta[item.id] = payload
} catch {
delete expenseAttachmentMeta[item.id]
}
})
Object.keys(expenseAttachmentMeta).forEach((itemId) => {
if (!expenseItems.value.some((item) => item.id === itemId && item.invoiceId)) {
delete expenseAttachmentMeta[itemId]
}
})
await Promise.allSettled(tasks)
}
async function loadAttachmentPreview(item) {
if (!request.value.claimId || !item?.invoiceId) {
return
}
attachmentPreviewLoading.value = true
attachmentPreviewError.value = ''
attachmentPreviewItemId.value = item.id
attachmentPreviewName.value = resolveAttachmentDisplayName(item)
let metadata = resolveAttachmentMeta(item)
try {
if (!metadata) {
try {
metadata = await refreshExpenseAttachmentMeta(item.id)
} catch (error) {
if (!hasStoredAttachmentReference(item)) {
throw new Error('当前附件只有文件名记录,原件尚未保存到单据中,请重新上传后预览。')
}
throw error
}
}
if (metadata?.previewable === false) {
throw new Error('当前附件暂不支持直接预览。')
}
attachmentPreviewName.value = resolveAttachmentDisplayName(item)
attachmentPreviewMediaType.value =
String(metadata?.preview_kind || '').trim() === 'image'
? 'image/png'
: String(metadata?.media_type || '').trim()
const blob = await fetchExpenseClaimItemAttachmentPreview(request.value.claimId, item.id)
revokeAttachmentPreviewUrl()
attachmentPreviewUrl.value = URL.createObjectURL(blob)
attachmentPreviewMediaType.value = blob.type || attachmentPreviewMediaType.value
} catch (error) {
attachmentPreviewError.value = error?.message || '附件预览失败,请稍后重试。'
} finally {
attachmentPreviewLoading.value = false
}
}
async function openAttachmentPreview(item) {
if (!request.value.claimId || !canPreviewAttachment(item)) {
return
}
closeAttachmentPreview()
attachmentPreviewOpen.value = true
await loadAttachmentPreview(item)
}
async function goToAttachmentPreview(offset) {
if (!canNavigateAttachmentPreview.value || attachmentPreviewLoading.value) {
return
}
const entries = attachmentPreviewEntries.value
const currentIndex = currentAttachmentPreviewIndex.value
const nextIndex = (currentIndex + offset + entries.length) % entries.length
const nextEntry = entries[nextIndex]
if (nextEntry?.item) {
await loadAttachmentPreview(nextEntry.item)
}
}
function goToPreviousAttachmentPreview() {
void goToAttachmentPreview(-1)
}
function goToNextAttachmentPreview() {
void goToAttachmentPreview(1)
}
return {
attachmentPreviewError,
attachmentPreviewIndexLabel,
attachmentPreviewLoading,
attachmentPreviewMediaType,
attachmentPreviewName,
attachmentPreviewOpen,
attachmentPreviewUrl,
buildAttachmentRiskNotice,
canNavigateAttachmentPreview,
canPreviewAttachment,
closeAttachmentPreview,
currentAttachmentPreviewInsight,
currentAttachmentPreviewRiskCards,
goToNextAttachmentPreview,
goToPreviousAttachmentPreview,
openAttachmentPreview,
refreshExpenseAttachmentMeta,
resolveAttachmentDisplayName,
resolveAttachmentMeta,
resolveAttachmentPreviewTitle,
resolveAttachmentRecognition,
syncExpenseAttachmentMeta
}
}

View File

@@ -0,0 +1,718 @@
import { computed, reactive, ref } from 'vue'
import {
deleteExpenseClaimItem,
deleteExpenseClaimItemAttachment,
uploadExpenseClaimItemAttachment,
updateExpenseClaimItem
} from '../../services/reimbursements.js'
import {
buildExpenseDraftIssues,
buildExpenseItemViewModel,
isPlaceholderValue,
isRouteDescriptionExpenseType,
isSyntheticLocationDisplay,
isValidIsoDate,
isValidRouteDescription,
rebuildExpenseItems,
resolveExpenseUploadHint
} from './travelRequestDetailExpenseModel.js'
import {
buildItemClaimRiskState,
normalizeRiskTone
} from './travelRequestDetailInsights.js'
import {
buildRecognizedExpenseItemPatch,
startSmartEntryRecognitionTask,
subscribeSmartEntryRecognitionTask
} from './travelRequestDetailSmartEntryRecognition.js'
export function useTravelRequestDetailExpenseEditor({
request,
expenseItems,
expenseAttachmentMeta,
isEditableRequest,
getActionBusy,
toast,
emit,
attachmentPreviewOpen,
buildAttachmentRiskNotice,
closeAttachmentPreview,
refreshExpenseAttachmentMeta,
resolveAttachmentMeta,
resolveClaimRiskFlags,
applyClaimRiskFlagsPayload
}) {
const editingExpenseId = ref('')
const savingExpenseId = ref('')
const uploadingExpenseId = ref('')
const deletingAttachmentId = ref('')
const deletingExpenseId = ref('')
const pendingUploadExpenseId = ref('')
const expenseUploadInput = ref(null)
const smartEntryUploadInput = ref(null)
const smartEntryUploadDialogOpen = ref(false)
const smartEntrySelectedFiles = ref([])
const smartEntryRecognitionBusy = ref(false)
const smartEntryRecognitionTotal = ref(0)
const smartEntryRecognitionCompleted = ref(0)
const smartEntryRecognitionCurrent = ref(0)
const appliedSmartEntryRecognitionPayloadIds = new Set()
const notifiedSmartEntryRecognitionTaskIds = new Set()
let stopSmartEntryRecognitionTask = null
const expenseEditor = reactive({
itemDate: '',
itemType: 'other',
itemReason: '',
itemLocation: '',
itemAmount: '',
itemNote: '',
invoiceId: ''
})
const expenseTotal = computed(() => {
const total = expenseItems.value.reduce((sum, item) => {
const adjustedAmount = Number(item.reimbursableAmount)
const originalAmount = Number(item.itemAmount || 0)
return sum + (Number.isFinite(adjustedAmount) ? adjustedAmount : originalAmount)
}, 0)
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 0,
maximumFractionDigits: Number.isInteger(total) ? 0 : 2
}).format(total)
})
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
const expenseTableColumnCount = computed(
() => 7 + (isEditableRequest.value ? 1 : 0)
)
const smartEntryRecognitionText = computed(() => {
const total = smartEntryRecognitionTotal.value
if (!total) {
return '附件识别准备中,请稍候。识别完成前暂不可编辑费用明细。'
}
const current = Math.min(Math.max(smartEntryRecognitionCurrent.value || 1, 1), total)
return `附件识别中(${current}/${total}),请稍候。识别完成前暂不可编辑费用明细。`
})
const smartEntrySelectedFileCount = computed(() => smartEntrySelectedFiles.value.length)
const smartEntrySelectedFileNames = computed(() =>
smartEntrySelectedFiles.value
.map((file) => String(file?.name || '').trim())
.filter(Boolean)
)
const smartEntrySelectedFileSummary = computed(() => {
const names = smartEntrySelectedFileNames.value
if (!names.length) {
return ''
}
if (names.length === 1) {
return names[0]
}
return `已选择 ${names.length} 张附件`
})
const smartEntryUploadBusy = computed(() =>
smartEntryUploadDialogOpen.value && Boolean(uploadingExpenseId.value)
)
function resetExpenseEditorFields() {
editingExpenseId.value = ''
expenseEditor.itemDate = ''
expenseEditor.itemType = 'other'
expenseEditor.itemReason = ''
expenseEditor.itemLocation = ''
expenseEditor.itemAmount = ''
expenseEditor.itemNote = ''
expenseEditor.invoiceId = ''
}
function applyLocalExpenseItemPatch(itemId, patch) {
expenseItems.value = rebuildExpenseItems(
expenseItems.value.map((item) => (item.id === itemId ? { ...item, ...patch } : item)),
request.value
)
}
function resetSmartEntryRecognitionState() {
smartEntryRecognitionBusy.value = false
smartEntryRecognitionTotal.value = 0
smartEntryRecognitionCompleted.value = 0
smartEntryRecognitionCurrent.value = 0
if (!pendingUploadExpenseId.value) {
uploadingExpenseId.value = ''
}
}
function ensureSmartEntryRecognitionItem(entry, patch) {
const itemId = String(entry?.itemId || '').trim()
if (!itemId) {
return null
}
const existingItem = expenseItems.value.find((item) => item.id === itemId)
if (existingItem) {
return existingItem
}
const rawItem = entry?.createdItem || {
id: itemId,
invoice_id: patch.invoiceId,
item_date: patch.itemDate,
item_type: patch.itemType,
item_reason: patch.itemReason,
item_location: patch.itemLocation,
item_amount: patch.itemAmount,
attachment_hint: patch.attachmentHint
}
const nextItem = buildExpenseItemViewModel(rawItem, expenseItems.value.length, request.value)
expenseItems.value = rebuildExpenseItems([...expenseItems.value, nextItem], request.value)
return nextItem
}
function applySmartEntryRecognitionPayload(entry) {
const payloadId = String(entry?.id || '').trim()
const itemId = String(entry?.itemId || '').trim()
if (!payloadId || !itemId || appliedSmartEntryRecognitionPayloadIds.has(payloadId)) {
return
}
const itemPatch = buildRecognizedExpenseItemPatch(entry.payload, entry.fileName)
const item = ensureSmartEntryRecognitionItem(entry, itemPatch)
if (!item) {
return
}
applyClaimRiskFlagsPayload(entry.payload)
if (entry.payload?.attachment) {
expenseAttachmentMeta[itemId] = entry.payload.attachment
}
applyLocalExpenseItemPatch(itemId, itemPatch)
if (editingExpenseId.value === itemId) {
populateExpenseEditor({ ...item, ...itemPatch })
}
appliedSmartEntryRecognitionPayloadIds.add(payloadId)
emit('request-updated', { claimId: request.value.claimId })
}
function syncSmartEntryRecognitionSnapshot(snapshot) {
if (!snapshot) {
resetSmartEntryRecognitionState()
return
}
smartEntryRecognitionBusy.value = Boolean(snapshot.busy)
smartEntryRecognitionTotal.value = snapshot.total || 0
smartEntryRecognitionCompleted.value = snapshot.completed || 0
smartEntryRecognitionCurrent.value = snapshot.current || 0
uploadingExpenseId.value = snapshot.uploadingItemId || ''
snapshot.payloads.forEach((entry) => applySmartEntryRecognitionPayload(entry))
if (!snapshot.busy && snapshot.status && !notifiedSmartEntryRecognitionTaskIds.has(snapshot.id)) {
notifiedSmartEntryRecognitionTaskIds.add(snapshot.id)
if (snapshot.failedCount && snapshot.successCount) {
toast(`已完成 ${snapshot.successCount} 张附件识别,${snapshot.failedCount} 张识别失败。`)
} else if (snapshot.failedCount) {
toast('附件识别失败,请稍后重试。')
} else if (snapshot.total > 1) {
toast(`已完成 ${snapshot.successCount} 张附件的智能录入。`)
}
}
}
function bindSmartEntryRecognitionTask(claimId = request.value.claimId) {
if (stopSmartEntryRecognitionTask) {
stopSmartEntryRecognitionTask()
stopSmartEntryRecognitionTask = null
}
stopSmartEntryRecognitionTask = subscribeSmartEntryRecognitionTask(claimId, syncSmartEntryRecognitionSnapshot)
}
function resetSmartEntryRecognitionApplications() {
appliedSmartEntryRecognitionPayloadIds.clear()
}
function resolveExpenseIssues(item) {
return buildExpenseDraftIssues(item)
}
function resolveExpenseRiskState(item) {
if (uploadingExpenseId.value === item.id) {
return {
label: 'AI识别中',
tone: 'medium',
headline: 'AI提示正在分析附件内容',
summary: '附件已上传,系统正在识别票据内容与风险点,请稍候。',
points: [],
suggestion: ''
}
}
const metadata = resolveAttachmentMeta(item)
const analysis = metadata?.analysis
if (analysis) {
return {
label: analysis.label || '已上传',
tone: normalizeRiskTone(analysis.severity || 'low'),
headline: analysis.headline || 'AI提示',
summary: analysis.summary || '',
points: Array.isArray(analysis.points) ? analysis.points : [],
suggestion: analysis.suggestion || ''
}
}
const claimRiskState = buildItemClaimRiskState(item, resolveClaimRiskFlags())
if (claimRiskState) {
return claimRiskState
}
if (!item.invoiceId) {
return null
}
return {
label: '已上传',
tone: 'low',
headline: 'AI提示附件已上传',
summary: '附件已成功保存,当前可继续查看原图并人工核对票据内容。',
points: [],
suggestion: ''
}
}
function showExpenseRisk(item) {
return Boolean(resolveExpenseRiskState(item))
}
function isMajorExpenseRisk(item) {
return normalizeRiskTone(resolveExpenseRiskState(item)?.tone) === 'high'
}
function hasExpenseRiskOrAbnormal(item) {
const state = resolveExpenseRiskState(item)
return Boolean(
String(item?.itemNote || '').trim()
|| normalizeRiskTone(state?.tone) !== 'low'
|| item?.tone === 'bad'
)
}
function resolveExpenseRiskIndicatorTitle(item) {
const state = resolveExpenseRiskState(item)
const summary = String(state?.summary || state?.headline || '').trim()
return summary ? `查看风险提示:${summary}` : '查看风险提示'
}
function populateExpenseEditor(item) {
editingExpenseId.value = item.id
expenseEditor.itemDate = item.itemDate || ''
expenseEditor.itemType = item.itemType || 'other'
expenseEditor.itemReason = item.itemReason || (item.desc === '待补充' ? '' : item.desc)
expenseEditor.itemLocation =
item.itemLocation || (isSyntheticLocationDisplay(item.detail, item.itemType) ? '' : item.detail)
expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : ''
expenseEditor.itemNote = item.itemNote || ''
expenseEditor.invoiceId = item.invoiceId || ''
}
function startExpenseEdit(item) {
if (!isEditableRequest.value || getActionBusy()) {
return
}
if (item?.isSystemGenerated) {
toast('系统自动计算的补贴行不能手动编辑。')
return
}
populateExpenseEditor(item)
}
function validateExpenseEditor() {
if (expenseEditor.itemDate && !isValidIsoDate(expenseEditor.itemDate)) {
return '请输入正确的费用日期,格式为 YYYY-MM-DD。'
}
if (isPlaceholderValue(expenseEditor.itemType)) {
return '请选择费用项目。'
}
if (
!isPlaceholderValue(expenseEditor.itemReason)
&& isRouteDescriptionExpenseType(expenseEditor.itemType)
&& !isValidRouteDescription(expenseEditor.itemReason)
) {
return '行程说明格式应为“起始地-目的地”,例如:广州南-北京南。'
}
const amountText = String(expenseEditor.itemAmount || '').trim()
if (amountText) {
const amount = Number(amountText)
if (!Number.isFinite(amount) || amount < 0) {
return '请输入不小于 0 的费用金额。'
}
}
return ''
}
function triggerSmartEntryUpload() {
if (!isEditableRequest.value || getActionBusy()) {
return
}
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法上传单据。')
return
}
smartEntrySelectedFiles.value = []
smartEntryUploadDialogOpen.value = true
}
function closeSmartEntryUploadDialog() {
if (smartEntryUploadBusy.value) {
return
}
smartEntryUploadDialogOpen.value = false
clearSmartEntryFile()
}
function chooseSmartEntryFile() {
if (smartEntryUploadBusy.value) {
return
}
if (smartEntryUploadInput.value) {
smartEntryUploadInput.value.value = ''
smartEntryUploadInput.value.click()
}
}
function clearSmartEntryFile() {
smartEntrySelectedFiles.value = []
if (smartEntryUploadInput.value) {
smartEntryUploadInput.value.value = ''
}
}
function handleSmartEntryFileChange(event) {
const target = event?.target
const fileList = target?.files
const files = Array.from(fileList || [])
if (target) {
target.value = ''
}
if (!files.length) {
return
}
smartEntrySelectedFiles.value = files
}
async function confirmSmartEntryUpload() {
if (smartEntryUploadBusy.value) {
return
}
const files = [...smartEntrySelectedFiles.value]
if (!files.length) {
toast('请先选择需要智能录入的附件。')
return
}
smartEntryUploadDialogOpen.value = false
clearSmartEntryFile()
const { task, reused } = startSmartEntryRecognitionTask({
claimId: request.value.claimId,
files,
itemSnapshots: expenseItems.value
})
if (!task) {
toast('当前草稿缺少 claimId暂时无法识别附件。')
return
}
bindSmartEntryRecognitionTask(request.value.claimId)
toast(reused ? '当前单据已有附件识别任务,请等待识别完成。' : '附件已转入后台识别,费用明细将在识别完成后自动更新。')
}
function triggerExpenseUpload(item) {
if (!isEditableRequest.value || getActionBusy()) {
return
}
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法上传单据。')
return
}
if (item?.isSystemGenerated) {
toast('系统自动计算的补贴行无需上传附件。')
return
}
if (item?.invoiceId) {
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
return
}
pendingUploadExpenseId.value = item.id
if (expenseUploadInput.value) {
expenseUploadInput.value.value = ''
expenseUploadInput.value.click()
}
}
async function uploadExpenseFile(item, file) {
if (!item || !file) {
return false
}
if (item?.isSystemGenerated) {
toast('系统自动计算的补贴行无需上传附件。')
return false
}
if (item?.invoiceId) {
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
return false
}
uploadingExpenseId.value = item.id
try {
const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file)
applyClaimRiskFlagsPayload(payload)
expenseAttachmentMeta[item.id] = payload?.attachment || null
const itemPatch = buildRecognizedExpenseItemPatch(payload, file.name)
applyLocalExpenseItemPatch(item.id, {
...itemPatch
})
populateExpenseEditor({ ...item, ...itemPatch })
emit('request-updated', { claimId: request.value.claimId })
const riskNotice = buildAttachmentRiskNotice(payload?.attachment)
toast(riskNotice || payload?.message || `${file.name} 已关联到当前费用明细。`)
return true
} catch (error) {
toast(error?.message || '附件上传失败,请稍后重试。')
return false
} finally {
uploadingExpenseId.value = ''
}
}
async function removeExpenseAttachment(item) {
if (!request.value.claimId || !item?.invoiceId || getActionBusy()) {
return
}
deletingAttachmentId.value = item.id
try {
const payload = await deleteExpenseClaimItemAttachment(request.value.claimId, item.id)
applyClaimRiskFlagsPayload(payload)
delete expenseAttachmentMeta[item.id]
applyLocalExpenseItemPatch(item.id, {
invoiceId: '',
attachmentHint: resolveExpenseUploadHint()
})
if (editingExpenseId.value === item.id) {
expenseEditor.invoiceId = ''
}
if (attachmentPreviewOpen.value) {
closeAttachmentPreview()
}
emit('request-updated', { claimId: request.value.claimId })
toast(payload?.message || '附件已删除。')
} catch (error) {
toast(error?.message || '附件删除失败,请稍后重试。')
} finally {
deletingAttachmentId.value = ''
}
}
async function handleExpenseFileChange(event) {
const target = event?.target
const fileList = target?.files
const fileCount = fileList?.length || 0
const file = fileList?.[0]
const itemId = pendingUploadExpenseId.value
pendingUploadExpenseId.value = ''
if (target) {
target.value = ''
}
if (fileCount > 1) {
toast('一条费用明细只能上传一张单据,请只选择一个文件。')
return
}
if (!file || !itemId) {
return
}
const item = expenseItems.value.find((entry) => entry.id === itemId)
if (!item) {
toast('未找到对应的费用明细,请刷新后重试。')
return
}
await uploadExpenseFile(item, file)
}
async function removeExpenseItem(item) {
if (!request.value.claimId || !item?.id || getActionBusy()) {
return
}
if (item?.isSystemGenerated) {
toast('系统自动计算的补贴行不能删除。')
return
}
deletingExpenseId.value = item.id
try {
const payload = await deleteExpenseClaimItem(request.value.claimId, item.id)
delete expenseAttachmentMeta[item.id]
expenseItems.value = rebuildExpenseItems(
expenseItems.value.filter((entry) => entry.id !== item.id),
request.value
)
if (editingExpenseId.value === item.id) {
resetExpenseEditorFields()
}
if (pendingUploadExpenseId.value === item.id) {
pendingUploadExpenseId.value = ''
}
if (attachmentPreviewOpen.value) {
closeAttachmentPreview()
}
emit('request-updated', { claimId: request.value.claimId })
toast(payload?.message || '费用明细已删除。')
} catch (error) {
toast(error?.message || '费用明细删除失败,请稍后重试。')
} finally {
deletingExpenseId.value = ''
}
}
async function saveExpenseEdit(item) {
if (getActionBusy()) {
toast(uploadingExpenseId.value ? '附件识别中,请等待识别完成后再保存。' : '当前操作处理中,请稍后再保存。')
return
}
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法保存费用明细。')
return
}
const validationError = validateExpenseEditor()
if (validationError) {
toast(validationError)
return
}
savingExpenseId.value = item.id
try {
const nextInvoiceId = expenseEditor.invoiceId.trim()
const preservedLocation = String(item.itemLocation || expenseEditor.itemLocation || '').trim()
const amountText = String(expenseEditor.itemAmount || '').trim()
const nextAmount = amountText ? Number(amountText) : 0
const itemPayload = {
item_type: expenseEditor.itemType,
item_reason: expenseEditor.itemReason.trim(),
item_location: preservedLocation,
item_note: expenseEditor.itemNote.trim(),
item_amount: nextAmount,
invoice_id: nextInvoiceId
}
if (expenseEditor.itemDate) {
itemPayload.item_date = expenseEditor.itemDate
}
await updateExpenseClaimItem(request.value.claimId, item.id, itemPayload)
applyLocalExpenseItemPatch(item.id, {
itemDate: expenseEditor.itemDate || item.itemDate,
itemType: expenseEditor.itemType,
itemReason: expenseEditor.itemReason.trim(),
itemLocation: preservedLocation,
itemNote: expenseEditor.itemNote.trim(),
itemAmount: nextAmount,
invoiceId: nextInvoiceId
})
let riskNotice = ''
if (nextInvoiceId) {
try {
const attachment = await refreshExpenseAttachmentMeta(item.id)
riskNotice = buildAttachmentRiskNotice(attachment)
} catch {
delete expenseAttachmentMeta[item.id]
}
} else {
delete expenseAttachmentMeta[item.id]
}
editingExpenseId.value = ''
toast(riskNotice || '费用明细已保存。')
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '费用明细保存失败,请稍后重试。')
} finally {
savingExpenseId.value = ''
}
}
function resetExpenseWorkState() {
pendingUploadExpenseId.value = ''
uploadingExpenseId.value = ''
deletingExpenseId.value = ''
editingExpenseId.value = ''
}
function disposeExpenseEditor() {
if (stopSmartEntryRecognitionTask) {
stopSmartEntryRecognitionTask()
stopSmartEntryRecognitionTask = null
}
}
return {
applyLocalExpenseItemPatch,
bindSmartEntryRecognitionTask,
chooseSmartEntryFile,
clearSmartEntryFile,
closeSmartEntryUploadDialog,
confirmSmartEntryUpload,
deletingAttachmentId,
deletingExpenseId,
disposeExpenseEditor,
editingExpenseId,
expenseEditor,
expenseTableColumnCount,
expenseTotal,
expenseUploadInput,
handleExpenseFileChange,
handleSmartEntryFileChange,
hasExpenseRiskOrAbnormal,
isMajorExpenseRisk,
removeExpenseAttachment,
removeExpenseItem,
resetExpenseWorkState,
resetSmartEntryRecognitionApplications,
resolveExpenseIssues,
resolveExpenseRiskIndicatorTitle,
resolveExpenseRiskState,
saveExpenseEdit,
savingExpenseId,
showExpenseRisk,
smartEntryRecognitionBusy,
smartEntryRecognitionText,
smartEntrySelectedFileCount,
smartEntrySelectedFileNames,
smartEntrySelectedFileSummary,
smartEntryUploadBusy,
smartEntryUploadDialogOpen,
smartEntryUploadInput,
startExpenseEdit,
triggerExpenseUpload,
triggerSmartEntryUpload,
uploadedExpenseCount,
uploadingExpenseId
}
}

View File

@@ -0,0 +1,767 @@
import { computed, nextTick, ref, watch } from 'vue'
import {
acceptExpenseClaimStandardAdjustment,
calculateTravelReimbursement,
submitExpenseClaim,
updateExpenseClaim
} from '../../services/reimbursements.js'
import { filterRiskCardsForVisibility } from '../../utils/riskVisibility.js'
import {
buildEmployeeProfileAdviceItems,
buildTravelReceiptMaterialPrompts
} from './travelRequestDetailAdviceModel.js'
import {
buildDraftBlockingIssues,
mapIssueToAdvice,
normalizeDetailNoteDraftValue,
rebuildExpenseItems
} from './travelRequestDetailExpenseModel.js'
import {
buildAiAdviceViewModel,
buildAttachmentRiskCards,
buildClaimSummaryRiskCards,
filterRiskCardsByBusinessStage,
extractRiskTagsFromText,
normalizeRiskTone,
resolveRiskTags
} from './travelRequestDetailInsights.js'
import {
buildCurrentStandardAdjustmentMap,
buildStandardAdjustmentPayload as buildStandardAdjustmentPayloadModel,
filterSubmitterResolvedRiskCards as filterSubmitterResolvedRiskCardsModel,
isRiskCardMissingExpenseNote as isRiskCardMissingExpenseNoteModel,
resolveExpenseItemsForRiskCard as resolveExpenseItemsForRiskCardModel
} from './travelRequestDetailStandardAdjustment.js'
import {
resolveSubmitActionIcon,
resolveSubmitActionLabel,
resolveSubmitConfirmDescription,
resolveSubmitConfirmText
} from './travelRequestDetailSubmitModel.js'
import { useTravelRequestEmployeeRiskProfile } from './useTravelRequestEmployeeRiskProfile.js'
export function useTravelRequestDetailRiskSubmit({
request,
expenseItems,
expenseAttachmentMeta,
riskFlagPreviewSnapshot,
isApplicationDocument,
isEditableRequest,
isDraftRequest,
isCurrentApplicant,
canViewApprovalRiskAdvice,
riskViewerContext,
getActionBusy,
toast,
emit
}) {
const submitBusy = ref(false)
const standardAdjustmentBusy = ref(false)
const submitConfirmDialogOpen = ref(false)
const riskOverrideDialogOpen = ref(false)
const riskOverrideBusy = ref(false)
const riskOverrideIndex = ref(0)
const highlightedRiskCardId = ref('')
const detailNoteEditor = ref('')
const savingDetailNote = ref(false)
const { employeeRiskProfile } = useTravelRequestEmployeeRiskProfile({
request,
isApplicationDocument
})
let standardAdjustmentTaskSeq = 0
let submitTaskSeq = 0
let highlightedRiskCardTimer = 0
const canEditDetailNote = computed(() => isDraftRequest.value)
const detailNoteSource = computed(() => normalizeDetailNoteDraftValue(request.value.note))
const detailNote = computed(() => {
if (detailNoteSource.value) {
return stripRiskTagsForDisplay(detailNoteSource.value)
}
return '暂无附加说明。请补充本次出差或办事事由,例如“去北京客户现场出差,拜访 XX 客户并处理项目验收事项”。'
})
const detailNoteEditorView = computed({
get: () => stripRiskTagsForDisplay(detailNoteEditor.value),
set: (value) => {
detailNoteEditor.value = mergeVisibleNoteWithHiddenTags(value, detailNoteEditor.value)
}
})
const detailNoteDirty = computed(() => detailNoteEditor.value.trim() !== detailNoteSource.value)
const detailNoteTags = computed(() =>
extractRiskTagsFromText(canEditDetailNote.value ? detailNoteEditor.value : detailNoteSource.value)
)
const draftBlockingIssues = computed(() =>
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
)
const canSubmit = computed(() => isEditableRequest.value && !getActionBusy())
watch(
() => [request.value.claimId, detailNoteSource.value],
([, nextNote]) => {
detailNoteEditor.value = nextNote
},
{ immediate: true }
)
function stripRiskTagsForDisplay(value) {
return String(value || '')
.split('\n')
.map((line) =>
line
.replace(/(?:^|\s)#[A-Za-z_]+(?=\s|$)/g, ' ')
.replace(/[ \t]{2,}/g, ' ')
.replace(/:\s+第/g, ':第')
.trim()
)
.join('\n')
.trim()
}
function mergeVisibleNoteWithHiddenTags(visibleText, rawText) {
const cleanText = normalizeDetailNoteDraftValue(visibleText)
const tags = extractRiskTagsFromText(rawText).join(' ')
return [cleanText, tags].map((item) => String(item || '').trim()).filter(Boolean).join('\n')
}
function resolveClaimRiskFlags() {
const flags = request.value?.riskFlags || request.value?.risk_flags_json || []
let requestFlags = Array.isArray(flags) ? flags : []
const previewSnapshot = riskFlagPreviewSnapshot.value
if (
previewSnapshot
&& previewSnapshot.claimId === request.value?.claimId
&& Array.isArray(previewSnapshot.riskFlags)
) {
requestFlags = previewSnapshot.riskFlags
}
return requestFlags
}
function applyClaimRiskFlagsPayload(payload) {
const flags = Array.isArray(payload?.claim_risk_flags)
? payload.claim_risk_flags
: Array.isArray(payload?.claimRiskFlags)
? payload.claimRiskFlags
: null
if (!flags) {
return
}
riskFlagPreviewSnapshot.value = {
claimId: request.value.claimId,
riskFlags: flags
}
}
function clearRiskFlagPreviewSnapshot() {
riskFlagPreviewSnapshot.value = null
}
function resolveCurrentStandardAdjustmentMap() {
return buildCurrentStandardAdjustmentMap(request.value, resolveClaimRiskFlags())
}
function resolveExpenseItemsForRiskCard(card) {
return resolveExpenseItemsForRiskCardModel(card, expenseItems.value)
}
function filterSubmitterResolvedRiskCards(cards, businessStage) {
const viewerContext = riskViewerContext.value || {}
return filterSubmitterResolvedRiskCardsModel({
cards,
businessStage,
isCurrentApplicant: isCurrentApplicant.value,
isPrivilegedRiskViewer: Boolean(
viewerContext.isAdminViewer
|| viewerContext.isBudgetReviewer
|| viewerContext.isDirectManagerReviewer
|| viewerContext.isFinanceReviewer
|| viewerContext.canViewApprovalRiskAdvice
),
expenseItems: expenseItems.value,
standardAdjustmentMap: resolveCurrentStandardAdjustmentMap()
})
}
function isRiskCardMissingExpenseNote(card) {
return isRiskCardMissingExpenseNoteModel(card, expenseItems.value)
}
function resolveRiskWarningNotes(card) {
const notes = resolveExpenseItemsForRiskCard(card)
.map((item) => String(item?.itemNote || '').trim())
.filter(Boolean)
return [...new Set(notes)]
}
async function buildStandardAdjustmentPayload() {
return buildStandardAdjustmentPayloadModel({
warnings: submitRiskCards.value,
expenseItems: expenseItems.value,
request: request.value,
calculateTravelReimbursement
})
}
function applyStandardAdjustmentResponse(payload = {}) {
const flags = Array.isArray(payload?.risk_flags_json)
? payload.risk_flags_json
: Array.isArray(payload?.riskFlags)
? payload.riskFlags
: resolveClaimRiskFlags()
riskFlagPreviewSnapshot.value = {
claimId: request.value.claimId,
riskFlags: flags
}
const sourceItems = Array.isArray(payload?.items) && payload.items.length
? payload.items
: expenseItems.value
expenseItems.value = rebuildExpenseItems(sourceItems, {
...request.value,
riskFlags: flags,
risk_flags_json: flags
})
}
const aiAdvice = computed(() => {
const completionItems = isEditableRequest.value
? draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
: []
const currentBusinessStage = isApplicationDocument.value ? 'expense_application' : 'reimbursement'
const directRiskCards = filterRiskCardsByBusinessStage(
buildAttachmentRiskCards({
expenseItems: expenseItems.value,
attachmentMetaByItemId: expenseAttachmentMeta,
claimRiskFlags: resolveClaimRiskFlags(),
businessStage: currentBusinessStage
}),
currentBusinessStage
)
const hasActionableRiskCards = directRiskCards.some(
(card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone))
)
const summaryRiskCards = filterRiskCardsByBusinessStage(
buildClaimSummaryRiskCards({
...(request.value || {}),
businessStage: currentBusinessStage
}),
currentBusinessStage
)
const materialPrompts = currentBusinessStage === 'reimbursement'
? buildTravelReceiptMaterialPrompts(request.value, expenseItems.value)
: []
const profileAdviceItems = currentBusinessStage === 'reimbursement'
? buildEmployeeProfileAdviceItems(employeeRiskProfile.value)
: []
const scopedRiskCards = [
...(hasActionableRiskCards ? [] : summaryRiskCards),
...filterSubmitterResolvedRiskCards(directRiskCards, currentBusinessStage)
]
const riskCards = filterRiskCardsForVisibility(scopedRiskCards, riskViewerContext.value)
return buildAiAdviceViewModel({
completionItems,
materialPrompts,
profileAdviceItems,
riskCards
})
})
const hasVisibleRiskCards = computed(() =>
aiAdvice.value.riskCards.some((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
)
const hasAdviceSections = computed(() => aiAdvice.value.sections.length > 0)
const showCompactSafeAdvice = computed(() =>
isEditableRequest.value
&& !isApplicationDocument.value
&& !draftBlockingIssues.value.length
)
const showAiAdvicePanel = computed(() => (
(
isEditableRequest.value
&& (
hasAdviceSections.value
|| showCompactSafeAdvice.value
)
)
|| (!isEditableRequest.value && canViewApprovalRiskAdvice.value && aiAdvice.value.riskCards.length > 0)
|| (!isEditableRequest.value && isCurrentApplicant.value && hasVisibleRiskCards.value)
))
function normalizeRiskDomId(value) {
return String(value || '').trim().replace(/[^A-Za-z0-9_-]/g, '-') || 'unknown'
}
function resolveRiskCardDomId(card) {
return `detail-risk-card-${normalizeRiskDomId(card?.id)}`
}
function isHighlightedRiskCard(card) {
return Boolean(card?.id) && String(card.id) === highlightedRiskCardId.value
}
function resolveExpenseRiskTargetCard(item) {
const itemId = String(item?.id || '').trim()
const invoiceId = String(item?.invoiceId || '').trim()
const itemIndex = expenseItems.value.findIndex((entry) => entry.id === item?.id) + 1
const cards = Array.isArray(aiAdvice.value?.riskCards) ? aiAdvice.value.riskCards : []
const actionableCards = cards.filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
return actionableCards.find((card) => {
const cardItemIds = [
card?.itemId,
card?.item_id,
...(Array.isArray(card?.itemIds) ? card.itemIds : []),
...(Array.isArray(card?.item_ids) ? card.item_ids : [])
].map((value) => String(value || '').trim()).filter(Boolean)
return cardItemIds.includes(itemId)
})
|| actionableCards.find((card) => invoiceId && String(card?.invoiceId || card?.invoice_id || '').trim() === invoiceId)
|| actionableCards.find((card) => Number(card?.itemIndex || card?.item_index || 0) === itemIndex)
|| actionableCards.find((card) => itemIndex > 0 && String(card?.title || '').includes(`${itemIndex}`))
|| null
}
function hasExpenseRiskIndicator(item) {
return Boolean(resolveExpenseRiskTargetCard(item))
}
async function focusExpenseRisk(item) {
const card = resolveExpenseRiskTargetCard(item)
const riskSection = document.querySelector('.validation-section--risk')
if (!card && !riskSection) {
toast('当前费用明细暂无可定位的风险点。')
return
}
highlightedRiskCardId.value = card?.id ? String(card.id) : ''
await nextTick()
const target = card
? document.getElementById(resolveRiskCardDomId(card))
: riskSection
target?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
if (highlightedRiskCardTimer) {
window.clearTimeout(highlightedRiskCardTimer)
}
highlightedRiskCardTimer = window.setTimeout(() => {
highlightedRiskCardId.value = ''
highlightedRiskCardTimer = 0
}, 1800)
}
const aiAdviceTitle = computed(() => {
if (!isEditableRequest.value && isCurrentApplicant.value) {
return isApplicationDocument.value ? '申请风险提示' : '风险提示'
}
if (isEditableRequest.value && isApplicationDocument.value) {
return '表单自查提示'
}
return isEditableRequest.value ? 'AI建议' : '风险提示'
})
const aiAdviceHint = computed(() => {
if (!isEditableRequest.value && isCurrentApplicant.value) {
return isApplicationDocument.value
? '展示申请单已识别的风险点及原因,请逐条确认或补充说明后再提交给领导审批。'
: '展示票据、行程、金额等可自行修正的风险点,便于提交人先整改,减少后续退单。'
}
return isEditableRequest.value
? (isApplicationDocument.value ? '仅提示申请表单本身需要补充的内容,不展示预算治理细节。' : '系统会在草稿保存和附件识别后自动更新检测结果。')
: '展示系统已识别的风险点,便于审批和后续整改。'
})
const submitActionLabel = computed(() => resolveSubmitActionLabel({
isApplicationDocument: isApplicationDocument.value,
submitBusy: submitBusy.value
}))
const submitActionIcon = computed(() => resolveSubmitActionIcon({
isApplicationDocument: isApplicationDocument.value
}))
const submitConfirmDescription = computed(() => resolveSubmitConfirmDescription({
isApplicationDocument: isApplicationDocument.value,
hasHighRiskWarnings: aiAdvice.value.riskCards.some((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
}))
const submitConfirmText = computed(() => resolveSubmitConfirmText(isApplicationDocument.value))
const submitRiskCards = computed(() =>
aiAdvice.value.riskCards
.filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
.map((card, index) => ({
...card,
id: String(card.id || `submit-risk-${index}`),
tags: resolveRiskTags(card)
}))
)
const submitConfirmSecondaryText = computed(() => (
!isApplicationDocument.value && submitRiskCards.value.length
? '按职级标准报销'
: ''
))
const submitRiskWarnings = computed(() =>
submitRiskCards.value.filter((card) => isRiskCardMissingExpenseNote(card))
)
const submitExplainedRiskWarnings = computed(() =>
submitRiskCards.value.filter((card) => !isRiskCardMissingExpenseNote(card))
)
const hasMissingSubmitRiskWarnings = computed(() => submitRiskWarnings.value.length > 0)
const submitRiskReviewWarnings = computed(() =>
hasMissingSubmitRiskWarnings.value ? submitRiskWarnings.value : submitExplainedRiskWarnings.value
)
const currentSubmitRiskWarning = computed(() => submitRiskReviewWarnings.value[riskOverrideIndex.value] || null)
const currentSubmitRiskWarningNotes = computed(() =>
currentSubmitRiskWarning.value ? resolveRiskWarningNotes(currentSubmitRiskWarning.value) : []
)
const riskOverrideIndexLabel = computed(() =>
submitRiskReviewWarnings.value.length ? `${riskOverrideIndex.value + 1} / ${submitRiskReviewWarnings.value.length}` : ''
)
const riskOverrideBadgeTone = computed(() => hasMissingSubmitRiskWarnings.value ? 'danger' : 'warning')
const riskOverrideDialogTitle = computed(() => (
hasMissingSubmitRiskWarnings.value
? `当前存在 ${submitRiskWarnings.value.length} 条需说明的风险`
: `请确认 ${submitExplainedRiskWarnings.value.length} 条风险及异常说明`
))
const riskOverrideDialogDescription = computed(() => (
hasMissingSubmitRiskWarnings.value
? '请回到费用明细的异常说明列补充原因后再提交;如果不补充说明,可选择按职级最高可报销金额重新计算。'
: '请核对风险点与已填写的异常说明,确认后进入提交确认。'
))
const riskOverrideCancelText = computed(() => (
hasMissingSubmitRiskWarnings.value ? '返回整改' : '返回核对'
))
const riskOverrideConfirmText = computed(() =>
hasMissingSubmitRiskWarnings.value ? '按职级标准重算' : '确认说明'
)
const riskOverrideConfirmTone = computed(() => hasMissingSubmitRiskWarnings.value ? 'danger' : 'primary')
const riskOverrideConfirmIcon = computed(() =>
hasMissingSubmitRiskWarnings.value ? 'mdi mdi-calculator-variant-outline' : 'mdi mdi-check-circle-outline'
)
const riskOverrideGuidanceTitle = computed(() => (
hasMissingSubmitRiskWarnings.value
? '请在费用明细的“异常说明”列补充原因后再提交。'
: '已填写异常说明,请确认说明会随单据进入审批。'
))
const riskOverrideGuidanceText = computed(() => (
hasMissingSubmitRiskWarnings.value
? '如果不补充说明,可直接选择按职级标准重算,超出标准的部分由员工自担。'
: '确认后系统会继续进入提交确认,领导和财务可看到这些风险及对应说明。'
))
const approvalRiskConfirmItems = computed(() =>
aiAdvice.value.riskCards
.filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
.slice(0, 4)
.map((card, index) => ({
id: String(card?.id || `approval-risk-${index + 1}`),
tone: normalizeRiskTone(card?.tone),
label: normalizeRiskTone(card?.tone) === 'high' ? '高风险' : '中风险',
title: String(card?.title || card?.label || '风险提示').trim(),
description: String(
card?.relatedExplanationSummary
|| card?.risk
|| card?.summary
|| card?.suggestion
|| '请核对该风险点对应的说明和佐证材料。'
).trim()
}))
)
function resetDetailNote() {
detailNoteEditor.value = detailNoteSource.value
}
async function saveDetailNote() {
if (!canEditDetailNote.value || savingDetailNote.value) {
return
}
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法保存附加说明。')
return
}
if (!detailNoteDirty.value) {
return
}
savingDetailNote.value = true
try {
await updateExpenseClaim(request.value.claimId, {
reason: detailNoteEditor.value.trim()
})
toast('附加说明已保存。')
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '附加说明保存失败,请稍后重试。')
} finally {
savingDetailNote.value = false
}
}
function openRiskOverrideDialog() {
const warnings = submitRiskReviewWarnings.value
if (!warnings.length) {
return
}
riskOverrideIndex.value = 0
riskOverrideDialogOpen.value = true
}
function closeRiskOverrideDialog() {
if (riskOverrideBusy.value) {
return
}
riskOverrideDialogOpen.value = false
}
function goToPreviousSubmitRisk() {
if (!submitRiskReviewWarnings.value.length) {
return
}
riskOverrideIndex.value =
(riskOverrideIndex.value - 1 + submitRiskReviewWarnings.value.length) % submitRiskReviewWarnings.value.length
}
function goToNextSubmitRisk() {
if (!submitRiskReviewWarnings.value.length) {
return
}
riskOverrideIndex.value = (riskOverrideIndex.value + 1) % submitRiskReviewWarnings.value.length
}
function confirmRiskExplanation() {
if (riskOverrideBusy.value || submitBusy.value) {
return
}
riskOverrideDialogOpen.value = false
submitConfirmDialogOpen.value = true
}
function confirmRiskOverrideDialog() {
if (hasMissingSubmitRiskWarnings.value) {
confirmStandardAdjustment()
return
}
confirmRiskExplanation()
}
function confirmStandardAdjustment() {
if (riskOverrideBusy.value || standardAdjustmentBusy.value) {
return
}
const claimId = String(request.value?.claimId || '').trim()
if (!claimId) {
toast('当前草稿缺少 claimId暂时无法按职级标准重算。')
return
}
riskOverrideDialogOpen.value = false
submitConfirmDialogOpen.value = false
standardAdjustmentBusy.value = true
const taskSeq = ++standardAdjustmentTaskSeq
toast('正在后台按职级标准重新测算费用。')
void runStandardAdjustmentRecalculation(claimId, taskSeq)
}
async function runStandardAdjustmentRecalculation(claimId, taskSeq) {
try {
const payload = await buildStandardAdjustmentPayload()
if (!payload.risks.length) {
toast('当前风险暂未匹配到可重算的费用明细,请先补充异常说明。')
return
}
const response = await acceptExpenseClaimStandardAdjustment(claimId, payload)
if (taskSeq !== standardAdjustmentTaskSeq || String(request.value?.claimId || '').trim() !== claimId) {
return
}
applyStandardAdjustmentResponse(response)
toast('已按职级最高报销标准重算实际报销金额,可继续提交审批。')
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '按职级标准重算失败,请稍后重试。')
} finally {
if (taskSeq === standardAdjustmentTaskSeq) {
standardAdjustmentBusy.value = false
}
}
}
async function handleSubmit() {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法提交。')
return
}
if (!canSubmit.value) {
toast('当前单据正在保存或处理附件,请稍后再提交审批。')
return
}
if (standardAdjustmentBusy.value) {
toast('费用正在按职级标准重新测算,完成后再提交审批。')
return
}
if (draftBlockingIssues.value.length) {
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
return
}
if (submitRiskReviewWarnings.value.length) {
openRiskOverrideDialog()
return
}
submitConfirmDialogOpen.value = true
}
function closeSubmitConfirmDialog() {
if (submitBusy.value) {
return
}
submitConfirmDialogOpen.value = false
}
function confirmSubmitRequest() {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法提交。')
submitConfirmDialogOpen.value = false
return
}
if (!canSubmit.value) {
toast('当前单据正在保存或处理附件,请稍后再提交审批。')
submitConfirmDialogOpen.value = false
return
}
if (standardAdjustmentBusy.value) {
toast('费用正在按职级标准重新测算,完成后再提交审批。')
submitConfirmDialogOpen.value = false
return
}
if (draftBlockingIssues.value.length) {
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
submitConfirmDialogOpen.value = false
return
}
const claimId = String(request.value.claimId || '').trim()
const documentNo = request.value.id
const isApplication = isApplicationDocument.value
submitBusy.value = true
submitConfirmDialogOpen.value = false
const taskSeq = ++submitTaskSeq
toast('正在后台提交审批,完成后会自动更新单据状态。')
void runSubmitRequest(claimId, documentNo, isApplication, taskSeq)
}
async function runSubmitRequest(claimId, documentNo, isApplication, taskSeq) {
try {
const payload = await submitExpenseClaim(claimId)
if (taskSeq !== submitTaskSeq || String(request.value?.claimId || '').trim() !== claimId) {
return
}
const claimStatus = String(payload?.status || '').trim().toLowerCase()
const approvalStage = String(payload?.approval_stage || payload?.approvalStage || '').trim()
if (claimStatus === 'submitted') {
toast(
isApplication
? `${documentNo} 申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}`
: `${documentNo} 已提交审批${approvalStage ? `,当前节点:${approvalStage}` : '。'}`
)
} else if (claimStatus === 'supplement') {
toast(`${documentNo} 自动检测未通过,已转待补充。`)
} else {
toast(`${documentNo} 提交结果已更新。`)
}
submitConfirmDialogOpen.value = false
emit('request-updated', { claimId })
} catch (error) {
if (taskSeq === submitTaskSeq) {
toast(error?.message || '提交审批失败,请稍后重试。')
}
} finally {
if (taskSeq === submitTaskSeq) {
submitBusy.value = false
}
}
}
function resetSubmitWorkState() {
standardAdjustmentTaskSeq += 1
standardAdjustmentBusy.value = false
submitTaskSeq += 1
submitBusy.value = false
}
function disposeRiskSubmit() {
resetSubmitWorkState()
if (highlightedRiskCardTimer) {
window.clearTimeout(highlightedRiskCardTimer)
highlightedRiskCardTimer = 0
}
}
return {
aiAdvice,
aiAdviceHint,
aiAdviceTitle,
applyClaimRiskFlagsPayload,
approvalRiskConfirmItems,
canEditDetailNote,
canSubmit,
clearRiskFlagPreviewSnapshot,
closeRiskOverrideDialog,
closeSubmitConfirmDialog,
confirmRiskExplanation,
confirmRiskOverrideDialog,
confirmStandardAdjustment,
confirmSubmitRequest,
currentSubmitRiskWarning,
currentSubmitRiskWarningNotes,
detailNote,
detailNoteDirty,
detailNoteEditor,
detailNoteEditorView,
detailNoteTags,
disposeRiskSubmit,
draftBlockingIssues,
focusExpenseRisk,
goToNextSubmitRisk,
goToPreviousSubmitRisk,
handleSubmit,
hasExpenseRiskIndicator,
hasMissingSubmitRiskWarnings,
isHighlightedRiskCard,
resetDetailNote,
resetSubmitWorkState,
resolveClaimRiskFlags,
resolveRiskCardDomId,
riskOverrideBadgeTone,
riskOverrideBusy,
riskOverrideCancelText,
riskOverrideConfirmIcon,
riskOverrideConfirmText,
riskOverrideConfirmTone,
riskOverrideDialogDescription,
riskOverrideDialogOpen,
riskOverrideDialogTitle,
riskOverrideGuidanceText,
riskOverrideGuidanceTitle,
riskOverrideIndexLabel,
saveDetailNote,
savingDetailNote,
showAiAdvicePanel,
standardAdjustmentBusy,
submitActionIcon,
submitActionLabel,
submitBusy,
submitConfirmDescription,
submitConfirmDialogOpen,
submitConfirmSecondaryText,
submitConfirmText,
submitExplainedRiskWarnings,
submitRiskReviewWarnings,
submitRiskWarnings
}
}

View File

@@ -0,0 +1,81 @@
import { ref, watch } from 'vue'
import { fetchEmployeeLatestProfile } from '../../services/reimbursements.js'
export function useTravelRequestEmployeeRiskProfile({ request, isApplicationDocument }) {
const employeeRiskProfile = ref(null)
const employeeRiskProfileLoading = ref(false)
const employeeRiskProfileError = ref('')
let employeeRiskProfileLoadSeq = 0
function resolveProfileLookupId() {
return String(
request.value?.profileEmployeeId
|| request.value?.employeeId
|| request.value?.employee_id
|| request.value?.profileName
|| ''
).trim()
}
function resolveProfileExpenseScope() {
const typeCode = String(request.value?.typeCode || '').trim()
return typeCode && !typeCode.endsWith('_application') ? typeCode : 'overall'
}
async function loadEmployeeRiskProfile() {
const employeeId = resolveProfileLookupId()
if (!employeeId || isApplicationDocument.value) {
employeeRiskProfile.value = null
employeeRiskProfileError.value = ''
employeeRiskProfileLoading.value = false
return
}
const sequence = ++employeeRiskProfileLoadSeq
employeeRiskProfileLoading.value = true
employeeRiskProfileError.value = ''
try {
const payload = await fetchEmployeeLatestProfile(employeeId, {
scene: 'approval',
claim_id: request.value?.claimId || '',
window_days: 90,
expense_type_scope: resolveProfileExpenseScope()
})
if (sequence === employeeRiskProfileLoadSeq) {
employeeRiskProfile.value = payload || null
}
} catch (error) {
if (sequence === employeeRiskProfileLoadSeq) {
employeeRiskProfile.value = null
employeeRiskProfileError.value = error?.message || '用户画像读取失败'
}
} finally {
if (sequence === employeeRiskProfileLoadSeq) {
employeeRiskProfileLoading.value = false
}
}
}
watch(
() => [
request.value?.claimId,
request.value?.profileEmployeeId,
request.value?.employeeId,
request.value?.employee_id,
request.value?.profileName,
request.value?.typeCode,
isApplicationDocument.value
].join('|'),
() => {
void loadEmployeeRiskProfile()
},
{ immediate: true }
)
return {
employeeRiskProfile,
employeeRiskProfileError,
employeeRiskProfileLoading
}
}