Files
X-Financial/web/src/views/scripts/ApprovalCenterView.js

443 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}
}