feat: 新增归档中心页面并完善知识库与报销查询能力
新增前端归档中心视图及相关工具函数,扩充知识库文档分类和 提取器支持多种格式,增强编排器报销查询的多维度检索,优 化本体规则和用户代理审核消息,前端完善报销创建和审批详 情交互细节,补充单元测试覆盖。
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user