Files
X-Financial/web/src/views/scripts/ApprovalCenterView.js
caoxiaozhu 88ff04bef8 feat: 新增归档中心页面并完善知识库与报销查询能力
新增前端归档中心视图及相关工具函数,扩充知识库文档分类和
提取器支持多种格式,增强编排器报销查询的多维度检索,优
化本体规则和用户代理审核消息,前端完善报销创建和审批详
情交互细节,补充单元测试覆盖。
2026-05-22 16:00:19 +08:00

272 lines
8.3 KiB
JavaScript

import { computed, ref, watch } from 'vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import { useApprovalInbox } from '../../composables/useApprovalInbox.js'
import { useSystemState } from '../../composables/useSystemState.js'
import { fetchApprovalExpenseClaims } from '../../services/reimbursements.js'
import { listPendingApprovalRequests } from '../../utils/approvalInbox.js'
import {
filterActionableRiskFlags,
isRiskSummaryWithRisk,
normalizeRiskFlagTone
} from '../../utils/riskFlags.js'
import TravelRequestDetailView from '../TravelRequestDetailView.vue'
const DEFAULT_SLA_HOURS = 24
const tabs = ['全部待审', '高风险', '即将超时', '已处理']
const filters = ['法人主体', '费用类型', '风险等级', '金额区间', '所属部门']
const RISK_LABELS = {
low: '低风险',
medium: '中风险',
high: '高风险'
}
function toDate(value) {
if (!value) {
return null
}
const nextDate = new Date(value)
return Number.isNaN(nextDate.getTime()) ? null : nextDate
}
function formatCurrency(value) {
const amount = Number(value)
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 0,
maximumFractionDigits: Number.isInteger(amount) ? 0 : 2
}).format(Number.isFinite(amount) ? amount : 0)
}
function resolveRiskTone(riskFlags, riskSummary) {
const actionableFlags = filterActionableRiskFlags(riskFlags)
if (actionableFlags.length) {
const severities = actionableFlags.map((item) => normalizeRiskFlagTone(item)).filter(Boolean)
if (severities.includes('high')) {
return 'high'
}
if (severities.includes('medium')) {
return 'medium'
}
if (severities.includes('low')) {
return 'low'
}
}
if (isRiskSummaryWithRisk(riskSummary)) {
return 'medium'
}
return 'low'
}
function resolveSlaMeta(submittedAt) {
const startAt = toDate(submittedAt)
if (!startAt) {
return { label: '待处理', tone: 'safe', urgent: false }
}
const deadline = new Date(startAt.getTime() + DEFAULT_SLA_HOURS * 60 * 60 * 1000)
const diffMs = deadline.getTime() - Date.now()
if (diffMs <= 0) {
return { label: '已超时', tone: 'danger', urgent: true }
}
const diffHours = diffMs / (60 * 60 * 1000)
const diffMinutes = Math.max(1, Math.ceil(diffMs / (60 * 1000)))
const label = diffHours >= 1 ? `${diffHours.toFixed(diffHours >= 10 ? 0 : 1)}h` : `${diffMinutes}m`
if (diffHours <= 2) {
return { label, tone: 'danger', urgent: true }
}
if (diffHours <= 8) {
return { label, tone: 'warning', urgent: false }
}
return { label, tone: 'safe', urgent: false }
}
function buildApprovalRow(request) {
const riskTone = resolveRiskTone(request.riskFlags, request.riskSummary)
const slaMeta = resolveSlaMeta(request.submittedAt || request.createdAt)
const statusTone = slaMeta.urgent ? 'urgent' : 'pending'
return {
...request,
applicant: request.person,
avatar: String(request.person || '?').trim().slice(0, 1) || '?',
department: request.dept,
type: request.typeLabel,
amount: formatCurrency(request.amount),
time: request.applyTime,
risk: RISK_LABELS[riskTone] || RISK_LABELS.low,
riskTone,
sla: slaMeta.label,
slaTone: slaMeta.tone,
node: request.workflowNode || '审批中',
status: statusTone === 'urgent' ? '即将超时' : '待审批',
statusTone,
spotlight: riskTone === 'high' || statusTone === 'urgent'
}
}
export default {
name: 'ApprovalCenterView',
components: {
TravelRequestDetailView,
TableLoadingState,
TableEmptyState
},
setup() {
const { currentUser } = useSystemState()
const { markClaimViewed, syncPendingClaimIds } = useApprovalInbox()
const activeTab = ref('全部待审')
const selectedClaimId = ref('')
const listKeyword = ref('')
const rows = ref([])
const loading = ref(false)
const error = ref('')
watch(
() => selectedClaimId.value,
(claimId) => {
if (claimId) {
markClaimViewed(claimId)
}
}
)
const selectedRow = computed({
get() {
return rows.value.find((row) => row.claimId === selectedClaimId.value) || null
},
set(value) {
selectedClaimId.value = value?.claimId || ''
}
})
const visibleRows = computed(() => {
let filteredRows = rows.value
if (activeTab.value === '高风险') {
filteredRows = filteredRows.filter((row) => row.riskTone === 'high')
} else if (activeTab.value === '即将超时') {
filteredRows = filteredRows.filter((row) => row.statusTone === 'urgent')
} else if (activeTab.value === '已处理') {
filteredRows = []
}
if (listKeyword.value.trim()) {
const keyword = listKeyword.value.trim().toLowerCase()
filteredRows = filteredRows.filter((row) => (
String(row.id || '').toLowerCase().includes(keyword)
|| String(row.applicant || '').toLowerCase().includes(keyword)
|| String(row.department || '').toLowerCase().includes(keyword)
|| String(row.type || '').toLowerCase().includes(keyword)
|| String(row.amount || '').toLowerCase().includes(keyword)
))
}
return filteredRows
})
const showEmpty = computed(() => !loading.value && !error.value && visibleRows.value.length === 0)
const approvalEmptyState = computed(() => {
if (!rows.value.length) {
return {
eyebrow: '审批中心',
title: '当前没有待审批单据',
desc: '进入直属领导或财务审批节点的报销单会自动汇总到这里,后续可继续处理或跟踪。',
icon: 'mdi mdi-clipboard-check-outline',
actionLabel: null,
actionIcon: null,
tone: 'slate',
artLabel: 'QUEUE',
tips: ['当前仅展示你有权限处理的单据', '高风险和即将超时单据会优先高亮']
}
}
return {
eyebrow: '状态列表为空',
title: `${activeTab.value}”里暂时没有单据`,
desc: activeTab.value === '已处理'
? '当前视图还没有已处理审批数据,可以先回到全部待审继续处理。'
: '可以切换到其他状态查看,或返回全部待审列表继续处理。',
icon: activeTab.value === '已处理' ? 'mdi mdi-archive-clock-outline' : 'mdi mdi-view-list-outline',
actionLabel: '查看全部待审',
actionIcon: 'mdi mdi-format-list-bulleted',
tone: activeTab.value === '已处理' ? 'amber' : 'sky',
artLabel: activeTab.value === '已处理' ? 'DONE' : 'FILTER',
tips: ['分页与表格只在有数据时展示', '空态页面会保留当前页签上下文说明']
}
})
function handleEmptyAction() {
if (!rows.value.length) {
void reload()
return
}
activeTab.value = '全部待审'
}
function closeSelectedDetail() {
selectedClaimId.value = ''
}
async function handleDetailUpdated() {
selectedClaimId.value = ''
await reload()
}
async function handleDetailDeleted() {
selectedClaimId.value = ''
await reload()
}
async function reload() {
loading.value = true
error.value = ''
try {
const payload = await fetchApprovalExpenseClaims()
const pendingRequests = listPendingApprovalRequests(payload, currentUser.value)
const mappedRows = pendingRequests.map((item) => buildApprovalRow(item))
rows.value = mappedRows
syncPendingClaimIds(mappedRows.map((item) => item.claimId))
if (!mappedRows.some((item) => item.claimId === selectedClaimId.value)) {
selectedClaimId.value = ''
}
} catch (nextError) {
rows.value = []
selectedClaimId.value = ''
error.value = nextError instanceof Error ? nextError.message : '审批中心加载失败。'
} finally {
loading.value = false
}
}
void reload()
return {
activeTab,
approvalEmptyState,
closeSelectedDetail,
error,
filters,
handleDetailDeleted,
handleDetailUpdated,
handleEmptyAction,
listKeyword,
loading,
reload,
rows,
selectedRow,
showEmpty,
tabs,
visibleRows
}
}
}