feat: 增强风险规则生成引擎与预算中心页面

后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块,
优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强
报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图
组件,重构审计页面和风险规则测试对话框交互,完善文档中心
和报销创建页面细节,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-26 09:15:14 +08:00
parent d0e946cf47
commit 0e861d8fa6
150 changed files with 14953 additions and 4099 deletions

View File

@@ -1,8 +1,7 @@
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useApprovalInbox } from './useApprovalInbox.js'
import { useNavigation, navItems } from './useNavigation.js'
import { useNavigation, navItems } from './useNavigation.js'
import { useRequests } from './useRequests.js'
import { useSystemState } from './useSystemState.js'
import { useToast } from './useToast.js'
@@ -23,14 +22,15 @@ export function useAppShell() {
const smartEntryOpen = ref(false)
const smartEntryContext = ref({
prompt: '',
source: 'requests',
source: 'documents',
request: null,
files: [],
conversation: null,
scope: null
})
const smartEntrySessionId = ref(0)
const smartEntryInvalidatedDraftClaimId = ref('')
const smartEntrySessionId = ref(0)
const smartEntryRevealToken = ref(0)
const smartEntryInvalidatedDraftClaimId = ref('')
const selectedRequestSnapshot = ref(null)
const { activeView, currentView, setView } = useNavigation()
@@ -49,7 +49,6 @@ export function useAppShell() {
} = useRequests()
const { currentUser } = useSystemState()
const { toast } = useToast()
const { refreshApprovalInbox } = useApprovalInbox()
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
@@ -83,21 +82,14 @@ export function useAppShell() {
return null
})
const detailMode = computed(() => ['app-request-detail', 'app-document-detail'].includes(route.name))
const logDetailMode = computed(() => route.name === 'app-log-detail')
const detailAlerts = computed(() => (detailMode.value ? buildDetailAlerts(selectedRequest.value) : []))
const requestsListActive = computed(() => activeView.value === 'requests' && !detailMode.value)
const documentsListActive = computed(() => activeView.value === 'documents' && !detailMode.value)
const workbenchActive = computed(() => activeView.value === 'workbench')
watch(requestsListActive, (isActive, wasActive) => {
if (isActive && !wasActive) {
void reloadRequests()
}
})
watch(documentsListActive, (isActive, wasActive) => {
const detailMode = computed(() => route.name === 'app-document-detail')
const logDetailMode = computed(() => route.name === 'app-log-detail')
const detailAlerts = computed(() => (detailMode.value ? buildDetailAlerts(selectedRequest.value) : []))
const documentsListActive = computed(() => activeView.value === 'documents' && !detailMode.value)
const workbenchActive = computed(() => activeView.value === 'workbench')
watch(documentsListActive, (isActive, wasActive) => {
if (isActive && !wasActive) {
void reloadRequests()
}
@@ -178,6 +170,10 @@ export function useAppShell() {
}
function openFinancialAssistantCreate(source) {
if (smartEntryOpen.value) {
smartEntryRevealToken.value += 1
return
}
smartEntryOpen.value = true
smartEntryContext.value = {
prompt: '',
@@ -237,6 +233,7 @@ export function useAppShell() {
return (
documentType === 'application'
|| documentType === 'expense_application'
|| normalizedClaimNo.startsWith('AP-')
|| normalizedClaimNo.startsWith('APP-')
)
}
@@ -266,8 +263,12 @@ export function useAppShell() {
}
}
async function openSmartEntry(payload = {}) {
const conversation = await resolveSmartEntryConversation(payload)
async function openSmartEntry(payload = {}) {
if (smartEntryOpen.value) {
smartEntryRevealToken.value += 1
return
}
const conversation = await resolveSmartEntryConversation(payload)
const scope = resolveSmartEntryClaimScope(payload)
smartEntryOpen.value = true
@@ -294,13 +295,12 @@ export function useAppShell() {
await reloadRequests()
if (status === 'submitted') {
smartEntryOpen.value = false
void refreshApprovalInbox()
toast(
isApplicationDocument
? `${claimNo || '该'}申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}`
: `${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`
)
router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })
router.push({ name: 'app-documents' })
return
}
toast(
@@ -310,23 +310,21 @@ export function useAppShell() {
)
}
function openRequestDetail(request) {
selectedRequestSnapshot.value = request || null
const routeName = activeView.value === 'documents' ? 'app-document-detail' : 'app-request-detail'
router.push({
name: routeName,
params: { requestId: request.claimId || request.id }
})
}
function closeRequestDetail() {
router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })
}
async function handleRequestUpdated() {
await reloadRequests()
void refreshApprovalInbox()
}
function openRequestDetail(request) {
selectedRequestSnapshot.value = request || null
router.push({
name: 'app-document-detail',
params: { requestId: request.claimId || request.id }
})
}
function closeRequestDetail() {
router.push({ name: 'app-documents' })
}
async function handleRequestUpdated() {
await reloadRequests()
}
async function handleRequestDeleted(payload = {}) {
const deletedClaimId = String(payload.claimId || payload.claim_id || '').trim()
@@ -335,11 +333,10 @@ export function useAppShell() {
smartEntryInvalidatedDraftClaimId.value = deletedClaimId
}
await reloadRequests()
void refreshApprovalInbox()
selectedRequestSnapshot.value = null
router.push({ name: activeView.value === 'documents' ? 'app-documents' : 'app-requests' })
}
await reloadRequests()
selectedRequestSnapshot.value = null
router.push({ name: 'app-documents' })
}
return {
activeRange,
@@ -374,9 +371,10 @@ export function useAppShell() {
selectedRequest,
setView,
smartEntryContext,
smartEntryInvalidatedDraftClaimId,
smartEntryOpen,
smartEntrySessionId,
smartEntryInvalidatedDraftClaimId,
smartEntryOpen,
smartEntryRevealToken,
smartEntrySessionId,
detailAlerts,
toast,
topBarView

View File

@@ -0,0 +1,176 @@
import { computed, ref } from 'vue'
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims, fetchExpenseClaims } from '../services/reimbursements.js'
import {
DOCUMENT_VIEWED_KEYS_CHANGE_EVENT,
countNewDocuments,
readViewedDocumentKeys,
resolveDocumentNewKey
} from '../utils/documentCenterNewState.js'
import { mapExpenseClaimToRequest } from './useRequests.js'
const SOURCE_PRIORITY = {
owned: 1,
approval: 2,
archive: 3
}
const documentRows = ref([])
const viewedDocumentKeys = ref(readViewedDocumentKeys())
const loading = ref(false)
let refreshTimer = null
let viewedKeysListenerAttached = false
function normalizeClaimText(...values) {
for (const value of values) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
return ''
}
function buildDocumentInboxRow(claim, source) {
const request = mapExpenseClaimToRequest(claim)
const claimId = normalizeClaimText(request.claimId, request.id, claim?.id, claim?.claim_id)
const documentNo = normalizeClaimText(request.documentNo, request.claimNo, request.id, claim?.claim_no)
const documentKey = normalizeClaimText(claimId, documentNo)
return documentKey
? {
source,
claimId: claimId || documentKey,
documentNo,
documentKey: `${source}:${documentKey}`
}
: null
}
function sourcePriority(row) {
return SOURCE_PRIORITY[row?.source] || 0
}
function mergeNonArchivedRows(rows) {
const rowMap = new Map()
rows.filter(Boolean).forEach((row) => {
const key = normalizeClaimText(row.claimId, row.documentNo, row.documentKey)
if (!key) {
return
}
const current = rowMap.get(key)
if (!current || sourcePriority(row) >= sourcePriority(current)) {
rowMap.set(key, row)
}
})
return Array.from(rowMap.values())
}
function uniqueRowsByNewKey(rows) {
const seenKeys = new Set()
return rows.filter((row) => {
const key = resolveDocumentNewKey(row)
if (!key || seenKeys.has(key)) {
return false
}
seenKeys.add(key)
return true
})
}
function mapClaimsToRows(claims, source) {
return Array.isArray(claims)
? claims.map((claim) => buildDocumentInboxRow(claim, source)).filter(Boolean)
: []
}
export function buildDocumentInboxRows({ ownedClaims = [], approvalClaims = [], archivedClaims = [] } = {}) {
const ownedRows = mapClaimsToRows(ownedClaims, 'owned')
const approvalRows = mapClaimsToRows(approvalClaims, 'approval')
const archiveRows = mapClaimsToRows(archivedClaims, 'archive')
return uniqueRowsByNewKey([
...mergeNonArchivedRows([...ownedRows, ...approvalRows]),
...archiveRows
])
}
function refreshViewedDocumentKeys() {
viewedDocumentKeys.value = readViewedDocumentKeys()
}
function attachViewedKeysListener() {
if (typeof window === 'undefined' || viewedKeysListenerAttached) {
return
}
window.addEventListener(DOCUMENT_VIEWED_KEYS_CHANGE_EVENT, refreshViewedDocumentKeys)
viewedKeysListenerAttached = true
}
async function readClaimList(fetcher) {
const result = await fetcher()
return Array.isArray(result) ? result : []
}
export function useDocumentCenterInbox() {
attachViewedKeysListener()
const unreadCount = computed(() => countNewDocuments(documentRows.value, viewedDocumentKeys.value))
const hasUnread = computed(() => unreadCount.value > 0)
async function refreshDocumentInbox() {
loading.value = true
try {
const [ownedResult, approvalResult, archiveResult] = await Promise.allSettled([
readClaimList(fetchExpenseClaims),
readClaimList(fetchApprovalExpenseClaims),
readClaimList(fetchArchivedExpenseClaims)
])
documentRows.value = buildDocumentInboxRows({
ownedClaims: ownedResult.status === 'fulfilled' ? ownedResult.value : [],
approvalClaims: approvalResult.status === 'fulfilled' ? approvalResult.value : [],
archivedClaims: archiveResult.status === 'fulfilled' ? archiveResult.value : []
})
refreshViewedDocumentKeys()
} finally {
loading.value = false
}
}
function startDocumentInboxPolling(intervalMs = 45000) {
stopDocumentInboxPolling()
if (typeof window === 'undefined') {
return
}
refreshTimer = window.setInterval(() => {
void refreshDocumentInbox()
}, intervalMs)
}
function stopDocumentInboxPolling() {
if (refreshTimer && typeof window !== 'undefined') {
window.clearInterval(refreshTimer)
refreshTimer = null
}
}
return {
hasUnread,
loading,
refreshDocumentInbox,
startDocumentInboxPolling,
stopDocumentInboxPolling,
unreadCount
}
}

View File

@@ -3,7 +3,7 @@ import { useRoute, useRouter } from 'vue-router'
import { icons } from '../data/icons.js'
export const appViews = ['overview', 'workbench', 'documents', 'requests', 'approval', 'archive', 'policies', 'audit', 'employees', 'logs', 'settings']
export const appViews = ['overview', 'workbench', 'documents', 'budget', 'policies', 'audit', 'employees', 'logs', 'settings']
export const navItems = [
{
@@ -31,28 +31,12 @@ export const navItems = [
desc: '统一查看申请、报销、审批与归档。'
},
{
id: 'requests',
label: '报销中心',
navHint: '查看和管理报销单据',
icon: icons.list,
title: '报销中心',
desc: '集中查看草稿、审批进度、票据状态与风险提示。'
},
{
id: 'approval',
label: '审批中心',
navHint: '处理审批任务',
icon: icons.approval,
title: '审批中心',
desc: '按优先级处理待审批事项,控制时效与风险。'
},
{
id: 'archive',
label: '归档中心',
navHint: '查阅公司已归档财务数据',
icon: icons.archive,
title: '归档中心',
desc: '集中保存公司已归档入账的报销单据,形成完整财务归档库。'
id: 'budget',
label: '预算中心',
navHint: '管理预算额度、预算占用与超预算预警',
icon: icons.budget,
title: '预算中心',
desc: '配置部门、项目及费用类型预算,跟踪申请占用、报销核销与超预算预警。'
},
{
id: 'policies',
@@ -100,9 +84,7 @@ const viewRouteNames = {
overview: 'app-overview',
workbench: 'app-workbench',
documents: 'app-documents',
requests: 'app-requests',
approval: 'app-approval',
archive: 'app-archive',
budget: 'app-budget',
policies: 'app-policies',
audit: 'app-audit',
logs: 'app-logs',
@@ -114,7 +96,7 @@ const routeNameViews = Object.fromEntries(
Object.entries(viewRouteNames).map(([view, routeName]) => [routeName, view])
)
routeNameViews['app-request-detail'] = 'requests'
routeNameViews['app-request-detail'] = 'documents'
routeNameViews['app-document-detail'] = 'documents'
routeNameViews['app-log-detail'] = 'logs'

View File

@@ -148,6 +148,7 @@ function resolveDocumentTypeMeta(claim, typeCode) {
const isApplication =
explicitType === DOCUMENT_TYPE_APPLICATION
|| explicitType === 'expense_application'
|| claimNo.startsWith('AP-')
|| claimNo.startsWith('APP-')
|| normalizedType === 'application'
|| normalizedType.endsWith('_application')