feat: 增强风险规则生成引擎与预算中心页面
后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块, 优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强 报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图 组件,重构审计页面和风险规则测试对话框交互,完善文档中心 和报销创建页面细节,补充单元测试覆盖。
This commit is contained in:
@@ -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
|
||||
|
||||
176
web/src/composables/useDocumentCenterInbox.js
Normal file
176
web/src/composables/useDocumentCenterInbox.js
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user