import { computed, ref } from 'vue' import TableEmptyState from '../../components/shared/TableEmptyState.vue' import { mapExpenseClaimToRequest } from '../../composables/useRequests.js' import { useSystemState } from '../../composables/useSystemState.js' import { fetchExpenseClaims } from '../../services/reimbursements.js' 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) { if (Array.isArray(riskFlags)) { const severities = riskFlags .map((item) => String(item?.severity || '').trim().toLowerCase()) .filter(Boolean) if (severities.includes('high')) { return 'high' } if (severities.includes('medium')) { return 'medium' } if (severities.includes('low')) { return 'low' } } if (String(riskSummary || '').trim() && String(riskSummary || '').trim() !== '无') { return 'medium' } return 'low' } function resolveRiskItems(request) { const riskFlags = Array.isArray(request?.riskFlags) ? request.riskFlags : [] const items = riskFlags .map((item) => { const tone = resolveRiskTone([item], '') const text = String(item?.message || item?.label || item?.reason || '').trim() if (!text) { return null } return { text, level: tone === 'high' ? '高' : tone === 'medium' ? '中' : '低', tone, icon: tone === 'high' ? 'mdi mdi-alert-circle' : tone === 'medium' ? 'mdi mdi-alert' : 'mdi mdi-shield-check' } }) .filter(Boolean) if (items.length) { return items } const summary = String(request?.riskSummary || '').trim() if (summary && summary !== '无') { return summary.split(';').filter(Boolean).map((text) => ({ text, level: '中', tone: 'medium', icon: 'mdi mdi-alert' })) } return [ { text: 'AI预审已通过,当前未发现额外风险。', level: '低', tone: 'low', icon: 'mdi mdi-shield-check' } ] } function resolveAttachmentMeta(name) { const normalized = String(name || '').trim() const lowerName = normalized.toLowerCase() if (lowerName.endsWith('.pdf')) { return { icon: 'mdi mdi-file-pdf-box', iconClass: 'pdf' } } if (/\.(png|jpg|jpeg|webp|bmp)$/i.test(lowerName)) { return { icon: 'mdi mdi-image', iconClass: 'img' } } return { icon: 'mdi mdi-file-document-outline', iconClass: 'file' } } function buildAttachments(expenseItems) { const seen = new Set() const attachments = [] for (const item of Array.isArray(expenseItems) ? expenseItems : []) { for (const fileName of Array.isArray(item?.attachments) ? item.attachments : []) { const normalized = String(fileName || '').trim() if (!normalized || seen.has(normalized)) { continue } seen.add(normalized) attachments.push({ name: normalized, size: '已识别', ...resolveAttachmentMeta(normalized) }) } } if (attachments.length) { return attachments } return [ { name: '当前无附件', size: '待补充', icon: 'mdi mdi-file-document-outline', iconClass: 'miss', missing: true } ] } 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 buildHeroSummaryItems(request) { return [ { label: '单号', value: request.id || '-', icon: 'mdi mdi-pound-box-outline' }, { label: '报销类型', value: request.typeLabel || '-', icon: 'mdi mdi-briefcase-outline' }, { label: '业务地点', value: request.sceneTarget || '待补充', icon: 'mdi mdi-map-marker-outline' }, { label: '发生时间', value: request.occurredDisplay || '待补充', icon: 'mdi mdi-calendar-range' }, { label: '票据关联', value: request.attachmentSummary || '无', icon: 'mdi mdi-paperclip' }, { label: '事由', value: request.title || '待补充', icon: 'mdi mdi-text-box-outline' } ] } function buildFlowItems(request) { return Array.isArray(request?.progressSteps) ? request.progressSteps.map((item) => ({ label: item.label, desc: item.current ? '当前处理节点' : item.done ? '已完成' : '待处理', time: item.time, icon: item.current ? 'mdi mdi-circle-slice-8' : item.done ? 'mdi mdi-check' : 'mdi mdi-circle-outline', current: item.current, pending: !item.done && !item.current })) : [] } function canCurrentUserProcessRequest(request, currentUser) { const node = String(request?.workflowNode || '').trim() const roleCodes = Array.isArray(currentUser?.roleCodes) ? currentUser.roleCodes.filter(Boolean) : [] const currentName = String(currentUser?.name || '').trim() const applicantName = String(request?.person || request?.employeeName || '').trim() if (currentName && applicantName && currentName === applicantName) { return false } if (currentUser?.isAdmin || roleCodes.includes('finance')) { return node.includes('财务') } return ( node.includes('直属领导') || node.includes('领导审批') || node.includes('部门负责人') || node.includes('负责人审批') ) } function buildApprovalRow(request) { const riskTone = resolveRiskTone(request.riskFlags, request.riskSummary) const riskItems = resolveRiskItems(request) const expenseItems = Array.isArray(request.expenseItems) ? request.expenseItems : [] 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', heroSummaryItems: buildHeroSummaryItems(request), summaryItems: buildHeroSummaryItems(request).slice(2), progressSteps: Array.isArray(request.progressSteps) ? request.progressSteps : [], expenseItems, attachments: buildAttachments(expenseItems), riskItems, flowItems: buildFlowItems(request) } } export default { name: 'ApprovalCenterView', components: { TableEmptyState }, setup() { const { currentUser } = useSystemState() const activeTab = ref('全部待审') const selectedClaimId = ref('') const expandedExpenseId = ref(null) const listKeyword = ref('') const rows = ref([]) const loading = ref(false) const error = ref('') const selectedRow = computed({ get() { return rows.value.find((row) => row.claimId === selectedClaimId.value) || null }, set(value) { selectedClaimId.value = value?.claimId || '' expandedExpenseId.value = null } }) 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) => { return ( 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 showTable = computed(() => !loading.value && !error.value && visibleRows.value.length > 0) 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: ['分页与表格只在有数据时展示', '空态页面会保留当前页签上下文说明'] } }) const approvalSteps = computed(() => selectedRow.value?.progressSteps || []) const summaryItems = computed(() => selectedRow.value?.summaryItems || []) const heroSummaryItems = computed(() => selectedRow.value?.heroSummaryItems || []) const expenseItems = computed(() => selectedRow.value?.expenseItems || []) const expenseTotal = computed(() => selectedRow.value?.amount || formatCurrency(0)) const uploadedExpenseCount = computed( () => expenseItems.value.filter((item) => Array.isArray(item?.attachments) && item.attachments.length).length ) const attachments = computed(() => selectedRow.value?.attachments || []) const riskItems = computed(() => selectedRow.value?.riskItems || []) const flowItems = computed(() => selectedRow.value?.flowItems || []) const currentProgressRingMotion = { initial: { scale: 1, opacity: 0.34 }, enter: { scale: [1, 1.42, 1.78], opacity: [0.34, 0.16, 0], transition: { duration: 3.2, repeat: Infinity, repeatType: 'loop', repeatDelay: 0.85, ease: 'easeOut', times: [0, 0.5, 1] } } } function showExpenseRisk(item) { return ['medium', 'high'].includes(String(item?.riskTone || '').trim()) } function toggleExpenseAttachments(id) { expandedExpenseId.value = expandedExpenseId.value === id ? null : id } function handleEmptyAction() { if (!rows.value.length) { void reload() return } activeTab.value = '全部待审' } async function reload() { loading.value = true error.value = '' try { const payload = await fetchExpenseClaims() const mappedRows = Array.isArray(payload) ? payload .map((item) => mapExpenseClaimToRequest(item)) .filter((item) => item.approvalKey === 'in_progress') .filter((item) => canCurrentUserProcessRequest(item, currentUser.value)) .map((item) => buildApprovalRow(item)) : [] rows.value = mappedRows 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, selectedRow, expandedExpenseId, listKeyword, tabs, filters, rows, visibleRows, showTable, showEmpty, approvalEmptyState, approvalSteps, summaryItems, heroSummaryItems, currentProgressRingMotion, expenseItems, expenseTotal, uploadedExpenseCount, showExpenseRisk, toggleExpenseAttachments, attachments, riskItems, flowItems, handleEmptyAction, loading, error, reload } } }