refactor: consolidate finance workflow modules

This commit is contained in:
caoxiaozhu
2026-06-23 11:21:18 +08:00
parent 1f40ce3df3
commit 73966b3a7b
52 changed files with 3468 additions and 2865 deletions

View File

@@ -34,6 +34,7 @@
@new-chat="openAiSidebarNewChat"
@open-recent="openAiSidebarRecent"
@rename-conversation="handleAiConversationRename"
@prefetch-view="prefetchAppView"
@logout="handleLogout"
/>
<SidebarRail
@@ -49,6 +50,7 @@
@logout="handleLogout"
@toggle-collapse="toggleSidebarCollapsed"
@navigate="handleNavigateWithMobileClose"
@prefetch-view="prefetchAppView"
/>
</Transition>
</div>
@@ -243,24 +245,18 @@ import AiSidebarRail from '../components/layout/AiSidebarRail.vue'
import SidebarRail from '../components/layout/SidebarRail.vue'
import TopBar from '../components/layout/TopBar.vue'
import FilterBar from '../components/layout/FilterBar.vue'
import AuditView from './AuditView.vue'
import BudgetCenterView from './BudgetCenterView.vue'
import DigitalEmployeesView from './DigitalEmployeesView.vue'
import DocumentsCenterView from './DocumentsCenterView.vue'
import EmployeeManagementView from './EmployeeManagementView.vue'
import OverviewView from './OverviewView.vue'
import PersonalWorkbenchView from './PersonalWorkbenchView.vue'
import PoliciesView from './PoliciesView.vue'
import ReceiptFolderView from './ReceiptFolderView.vue'
import SettingsView from './SettingsView.vue'
import TravelReimbursementCreateView from './TravelReimbursementCreateView.vue'
import TravelRequestDetailView from './TravelRequestDetailView.vue'
import { useAppShell } from '../composables/useAppShell.js'
import { useSystemState } from '../composables/useSystemState.js'
import { filterNavItemsByAccess, isPlatformAdminUser } from '../utils/accessControl.js'
import { isBusinessDocumentReference } from '../utils/aiDocumentDetailReference.js'
import { loadAiWorkbenchConversationHistory, saveAiWorkbenchConversation } from '../utils/aiWorkbenchConversationStore.js'
import {
defineAsyncModalView,
defineAsyncRouteView,
preloadAppView,
scheduleRelatedAppViewPreload
} from './scripts/appShellAsyncViews.js'
const employeeSummary = ref(null)
const knowledgeSummary = ref(null)
@@ -300,6 +296,18 @@ const aiSidebarCommandSeq = ref(0)
const aiSidebarCommand = ref({ seq: 0, type: '', payload: null })
const aiActiveConversationId = ref('')
const aiConversationHistory = ref([])
const AuditView = defineAsyncRouteView('audit')
const BudgetCenterView = defineAsyncRouteView('budget')
const DigitalEmployeesView = defineAsyncRouteView('digitalEmployees')
const DocumentsCenterView = defineAsyncRouteView('documents')
const EmployeeManagementView = defineAsyncRouteView('employees')
const OverviewView = defineAsyncRouteView('overview')
const PersonalWorkbenchView = defineAsyncRouteView('workbench')
const PoliciesView = defineAsyncRouteView('policies')
const ReceiptFolderView = defineAsyncRouteView('receiptFolder')
const SettingsView = defineAsyncRouteView('settings')
const TravelReimbursementCreateView = defineAsyncModalView('travelCreate')
const TravelRequestDetailView = defineAsyncRouteView('travelDetail')
function toggleSidebarCollapsed() {
sidebarCollapsed.value = !sidebarCollapsed.value
@@ -310,6 +318,10 @@ function handleNavigateWithMobileClose(viewId) {
mobileSidebarOpen.value = false
}
function prefetchAppView(viewId) {
void preloadAppView(viewId).catch(() => {})
}
function toggleWorkbenchMode() {
const nextMode = workbenchMode.value === 'ai' ? 'traditional' : 'ai'
if (nextMode === 'ai') {
@@ -580,4 +592,12 @@ watch(
},
{ immediate: true }
)
watch(
() => activeView.value,
(view) => {
scheduleRelatedAppViewPreload(view)
},
{ immediate: true }
)
</script>

View File

@@ -0,0 +1,86 @@
import { defineAsyncComponent } from 'vue'
import AppModalLoadingState from '../../components/shared/AppModalLoadingState.vue'
import AppViewLoadingState from '../../components/shared/AppViewLoadingState.vue'
const appViewLoaders = {
audit: () => import('../AuditView.vue'),
budget: () => import('../BudgetCenterView.vue'),
digitalEmployees: () => import('../DigitalEmployeesView.vue'),
documents: () => import('../DocumentsCenterView.vue'),
employees: () => import('../EmployeeManagementView.vue'),
overview: () => import('../OverviewView.vue'),
policies: () => import('../PoliciesView.vue'),
receiptFolder: () => import('../ReceiptFolderView.vue'),
settings: () => import('../SettingsView.vue'),
travelCreate: () => import('../TravelReimbursementCreateView.vue'),
travelDetail: () => import('../TravelRequestDetailView.vue'),
workbench: () => import('../PersonalWorkbenchView.vue')
}
const appViewPreloadCache = new Map()
const relatedPreloadViews = {
audit: ['documents', 'policies'],
budget: ['documents', 'overview'],
digitalEmployees: ['overview', 'settings'],
documents: ['travelDetail', 'workbench'],
employees: ['settings', 'overview'],
overview: ['workbench', 'documents'],
policies: ['audit', 'documents'],
receiptFolder: ['documents', 'workbench'],
settings: ['digitalEmployees', 'employees'],
workbench: ['documents', 'receiptFolder']
}
export function preloadAppView(viewId) {
const normalizedViewId = String(viewId || '').trim()
const loader = appViewLoaders[normalizedViewId]
if (!loader) {
return Promise.resolve(null)
}
if (!appViewPreloadCache.has(normalizedViewId)) {
appViewPreloadCache.set(
normalizedViewId,
loader().catch((error) => {
appViewPreloadCache.delete(normalizedViewId)
throw error
})
)
}
return appViewPreloadCache.get(normalizedViewId)
}
export function defineAsyncRouteView(viewId, options = {}) {
return defineAsyncComponent({
loader: () => preloadAppView(viewId),
loadingComponent: options.loadingComponent || AppViewLoadingState,
delay: options.delay ?? 160,
timeout: options.timeout ?? 30000,
suspensible: false
})
}
export function defineAsyncModalView(viewId) {
return defineAsyncRouteView(viewId, {
loadingComponent: AppModalLoadingState,
delay: 80
})
}
export function scheduleRelatedAppViewPreload(viewId) {
const views = relatedPreloadViews[String(viewId || '').trim()] || []
if (!views.length || typeof window === 'undefined') {
return
}
const runPreload = () => {
for (const view of views.slice(0, 2)) {
void preloadAppView(view).catch(() => {})
}
}
if (typeof window.requestIdleCallback === 'function') {
window.requestIdleCallback(runPreload, { timeout: 1600 })
return
}
window.setTimeout(runPreload, 360)
}

View File

@@ -1,319 +1,14 @@
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()
}
export {
buildBusinessTimeContextFromReviewValues,
buildReviewCorrectionMessage,
buildReviewFactCards,
buildReviewFormContextFromPayload,
buildReviewMainMessageText,
buildReviewRiskConversationText,
buildReviewRiskItems,
canExposeReviewPanelScope,
isTravelReviewPayload,
normalizeReviewPanelScope,
resolveReviewRiskBriefs,
resolveReviewTravelTransportType
} from './travelReimbursementReviewPanelModel.js'