feat: 新增归档中心页面并完善知识库与报销查询能力

新增前端归档中心视图及相关工具函数,扩充知识库文档分类和
提取器支持多种格式,增强编排器报销查询的多维度检索,优
化本体规则和用户代理审核消息,前端完善报销创建和审批详
情交互细节,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-22 16:00:19 +08:00
parent 1f15699013
commit 88ff04bef8
120 changed files with 6236 additions and 643 deletions

View File

@@ -6,92 +6,29 @@ import { useNavigation, navItems } from './useNavigation.js'
import { useRequests } from './useRequests.js'
import { useSystemState } from './useSystemState.js'
import { useToast } from './useToast.js'
import { fetchLatestConversation } from '../services/orchestrator.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
import { buildWorkbenchSummary } from '../utils/workbenchSummary.js'
const SESSION_TYPE_EXPENSE = 'expense'
function isPlaceholderValue(value) {
const text = String(value || '').trim()
if (!text) {
return true
}
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
}
function hasMissingAttachment(request) {
const expenseItems = Array.isArray(request?.expenseItems) ? request.expenseItems : []
if (expenseItems.length) {
return expenseItems.some((item) => !String(item?.invoiceId || item?.invoice_id || '').trim())
}
const attachmentSummary = String(request?.attachmentSummary || '').trim()
const secondaryStatusValue = String(request?.secondaryStatusValue || '').trim()
return /待|缺|未/.test(attachmentSummary) || /待|缺|未/.test(secondaryStatusValue)
}
function hasPendingInfo(request) {
if (!request) {
return false
}
if (request.approvalKey === 'draft' || request.approvalKey === 'supplement') {
return true
}
if (!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) {
return true
}
return [
request.profileDepartment,
request.profilePosition,
request.profileGrade,
request.profileManager,
request.reason,
request.occurredDisplay
].some(isPlaceholderValue)
}
function resolveDetailAlertTone(request) {
if (request?.approvalKey === 'completed') return 'success'
if (request?.approvalKey === 'rejected') return 'danger'
return 'warning'
}
function buildDetailAlerts(request) {
if (!request) {
return []
}
const alerts = []
const nodeLabel = String(request.node || request.approval || '').trim()
if (nodeLabel) {
alerts.push({ label: nodeLabel, tone: resolveDetailAlertTone(request) })
}
if (hasMissingAttachment(request)) {
alerts.push({ label: '缺少票据', tone: 'warning' })
}
if (hasPendingInfo(request)) {
alerts.push({ label: '待补信息', tone: 'warning' })
}
return alerts.filter((item, index, list) => list.findIndex((entry) => entry.label === item.label) === index).slice(0, 3)
}
export function useAppShell() {
import { fetchLatestConversation } from '../services/orchestrator.js'
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js'
import { buildDetailAlerts } from '../utils/detailAlerts.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
import { buildWorkbenchSummary } from '../utils/workbenchSummary.js'
const SESSION_TYPE_EXPENSE = 'expense'
export function useAppShell() {
const route = useRoute()
const router = useRouter()
const smartEntryOpen = ref(false)
const smartEntryContext = ref({ prompt: '', source: 'requests', request: null, files: [], conversation: null })
const smartEntryContext = ref({
prompt: '',
source: 'requests',
request: null,
files: [],
conversation: null,
scope: null
})
const smartEntrySessionId = ref(0)
const smartEntryInvalidatedDraftClaimId = ref('')
const { activeView, currentView, setView } = useNavigation()
const {
@@ -208,25 +145,56 @@ export function useAppShell() {
setView(view)
}
function openTravelCreate() {
smartEntryOpen.value = true
smartEntryContext.value = { prompt: '', source: 'topbar', request: null, files: [], conversation: null }
smartEntrySessionId.value += 1
}
function openTravelCreate() {
smartEntryOpen.value = true
smartEntryContext.value = {
prompt: '',
source: 'topbar',
request: null,
files: [],
conversation: null,
scope: null
}
smartEntrySessionId.value += 1
}
function resolveCurrentUserId() {
const user = currentUser.value || {}
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
}
async function resolveSmartEntryConversation(payload = {}) {
if (payload.conversation) {
return payload.conversation
}
if (!payload.restoreLatestConversation) {
return null
}
function resolveCurrentUserId() {
const user = currentUser.value || {}
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
}
function resolveSmartEntryClaimScope(payload = {}) {
const request = payload.request && typeof payload.request === 'object' ? payload.request : null
const payloadScope = payload.scope && typeof payload.scope === 'object' ? payload.scope : null
const claimId = String(
payloadScope?.claimId ||
payloadScope?.claim_id ||
request?.claimId ||
request?.claim_id ||
''
).trim()
if (!claimId) {
return null
}
return { type: 'claim', claimId }
}
function isDetailClaimScopedPayload(payload = {}) {
return String(payload.source || '').trim() === 'detail' && Boolean(resolveSmartEntryClaimScope(payload))
}
async function resolveSmartEntryConversation(payload = {}) {
if (payload.conversation) {
return payload.conversation
}
if (isDetailClaimScopedPayload(payload)) {
return null
}
if (!payload.restoreLatestConversation) {
return null
}
try {
const latestPayload = await fetchLatestConversation(resolveCurrentUserId(), SESSION_TYPE_EXPENSE, {
@@ -240,17 +208,19 @@ export function useAppShell() {
}
}
async function openSmartEntry(payload = {}) {
const conversation = await resolveSmartEntryConversation(payload)
smartEntryOpen.value = true
smartEntryContext.value = {
prompt: payload.prompt ?? '',
source: payload.source ?? 'workbench',
request: payload.request ?? selectedRequest.value,
files: Array.isArray(payload.files) ? payload.files : [],
conversation
}
async function openSmartEntry(payload = {}) {
const conversation = await resolveSmartEntryConversation(payload)
const scope = resolveSmartEntryClaimScope(payload)
smartEntryOpen.value = true
smartEntryContext.value = {
prompt: payload.prompt ?? '',
source: payload.source ?? 'workbench',
request: payload.request ?? selectedRequest.value,
files: Array.isArray(payload.files) ? payload.files : [],
conversation,
scope
}
smartEntrySessionId.value += 1
}
@@ -262,15 +232,15 @@ export function useAppShell() {
const claimNo = String(payload.claimNo || payload.claim_no || '').trim()
const status = String(payload.status || payload.claimStatus || '').trim()
const approvalStage = String(payload.approvalStage || payload.approval_stage || '').trim()
smartEntryOpen.value = false
await reloadRequests()
void refreshApprovalInbox()
if (status === 'submitted') {
smartEntryOpen.value = false
void refreshApprovalInbox()
toast(`${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`)
} else {
toast(`${claimNo || '该'}单据已保存到草稿,请到报销页面查看。`)
router.push({ name: 'app-requests' })
return
}
router.push({ name: 'app-requests' })
toast(`${claimNo || '该'}单据已保存为草稿,可继续上传票据或补充信息。`)
}
function openRequestDetail(request) {
@@ -289,7 +259,13 @@ export function useAppShell() {
void refreshApprovalInbox()
}
async function handleRequestDeleted() {
async function handleRequestDeleted(payload = {}) {
const deletedClaimId = String(payload.claimId || payload.claim_id || '').trim()
if (deletedClaimId) {
clearAssistantSessionSnapshotForDraftClaim(resolveCurrentUserId(), deletedClaimId, SESSION_TYPE_EXPENSE)
smartEntryInvalidatedDraftClaimId.value = deletedClaimId
}
await reloadRequests()
void refreshApprovalInbox()
router.push({ name: 'app-requests' })
@@ -327,6 +303,7 @@ export function useAppShell() {
selectedRequest,
setView,
smartEntryContext,
smartEntryInvalidatedDraftClaimId,
smartEntryOpen,
smartEntrySessionId,
detailAlerts,

View File

@@ -3,7 +3,7 @@ import { useRoute, useRouter } from 'vue-router'
import { icons } from '../data/icons.js'
export const appViews = ['overview', 'workbench', 'requests', 'approval', 'policies', 'audit', 'logs', 'employees', 'settings']
export const appViews = ['overview', 'workbench', 'requests', 'approval', 'archive', 'policies', 'audit', 'logs', 'employees', 'settings']
export const navItems = [
{
@@ -24,10 +24,10 @@ export const navItems = [
},
{
id: 'requests',
label: '个人报销',
navHint: '查看和管理个人报销',
label: '报销中心',
navHint: '查看和管理报销单据',
icon: icons.list,
title: '个人报销',
title: '报销中心',
desc: '集中查看草稿、审批进度、票据状态与风险提示。'
},
{
@@ -38,6 +38,14 @@ export const navItems = [
title: '审批中心',
desc: '按优先级处理待审批事项,控制时效与风险。'
},
{
id: 'archive',
label: '归档中心',
navHint: '查阅公司已归档财务数据',
icon: icons.archive,
title: '归档中心',
desc: '集中保存公司已归档入账的报销单据,形成完整财务归档库。'
},
{
id: 'policies',
label: '制度知识',
@@ -85,6 +93,7 @@ const viewRouteNames = {
workbench: 'app-workbench',
requests: 'app-requests',
approval: 'app-approval',
archive: 'app-archive',
policies: 'app-policies',
audit: 'app-audit',
logs: 'app-logs',

View File

@@ -1,11 +1,14 @@
import { computed, reactive, ref } from 'vue'
import { fetchExpenseClaims } from '../services/reimbursements.js'
import { filterActionableRiskFlags } from '../utils/riskFlags.js'
const EXPENSE_TYPE_LABELS = {
travel: '差旅费',
train_ticket: '火车票',
flight_ticket: '机票',
ship_ticket: '轮船票',
ferry_ticket: '轮船票',
hotel_ticket: '住宿票',
ride_ticket: '乘车',
travel_allowance: '出差补贴',
@@ -31,6 +34,8 @@ const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
const REIMBURSEMENT_PROGRESS_LABELS = [
'创建单据',
@@ -135,6 +140,17 @@ function resolveLocationDisplay(location, typeCode) {
return isLocationRequiredExpenseType(typeCode) ? '待补充' : '非必填'
}
function resolveExpenseDescriptionDetail(itemType, itemLocation) {
const normalizedType = normalizeExpenseType(itemType)
if (ROUTE_DESCRIPTION_EXPENSE_TYPES.has(normalizedType)) {
return '起始地-目的地'
}
if (HOTEL_DESCRIPTION_EXPENSE_TYPES.has(normalizedType)) {
return '目的地酒店'
}
return resolveLocationDisplay(itemLocation, normalizedType)
}
function resolveExpenseItemViewId(item, index, claim) {
return String(item?.id || `${claim?.id || 'claim'}-item-${index}`)
}
@@ -273,7 +289,7 @@ function buildRiskSummary(riskFlags) {
return '无'
}
const items = riskFlags.map((item) => stringifyRiskFlag(item)).filter(Boolean)
const items = filterActionableRiskFlags(riskFlags).map((item) => stringifyRiskFlag(item)).filter(Boolean)
return items.length ? items.join('') : '无'
}
@@ -602,7 +618,7 @@ function buildExpenseItems(claim, riskSummary) {
name: itemTypeLabel,
category: itemTypeLabel,
desc: itemReason || '待补充',
detail: resolveLocationDisplay(itemLocation, itemType),
detail: resolveExpenseDescriptionDetail(itemType, itemLocation),
amount: itemAmountDisplay,
status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充',
tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad',
@@ -654,6 +670,7 @@ export function mapExpenseClaimToRequest(claim) {
applyTime: formatDateTime(applyDateTime) || '待补充',
submittedAt: applyDateTime || '',
createdAt: claim?.created_at || '',
updatedAt: claim?.updated_at || '',
amount: parseNumber(claim?.amount),
riskFlags: Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : [],
invoiceCount,