feat: 完善报销单审批流程及退回原因追踪

新增直属领导审批通过接口和审批待办列表查询,报销单退回
支持原因码分类和审批环节标记,优化票据附件去重和路径
回退查找,前端新增退回原因对话框、审批收件箱和工作台
图标组件,补充工具函数和单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-20 21:00:47 +08:00
parent f8b25a7ccc
commit 002bf4f756
62 changed files with 5331 additions and 2101 deletions

View File

@@ -1,13 +1,12 @@
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import { mapExpenseClaimToRequest } from '../../composables/useRequests.js'
import { useApprovalInbox } from '../../composables/useApprovalInbox.js'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import { deleteExpenseClaim, fetchExpenseClaims, returnExpenseClaim } from '../../services/reimbursements.js'
import { canManageExpenseClaims } from '../../utils/accessControl.js'
import { fetchApprovalExpenseClaims } from '../../services/reimbursements.js'
import { listPendingApprovalRequests } from '../../utils/approvalInbox.js'
import TravelRequestDetailView from '../TravelRequestDetailView.vue'
const DEFAULT_SLA_HOURS = 24
const tabs = ['全部待审', '高风险', '即将超时', '已处理']
@@ -61,94 +60,6 @@ function resolveRiskTone(riskFlags, riskSummary) {
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) {
@@ -173,55 +84,8 @@ function resolveSlaMeta(submittedAt) {
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 currentName = String(currentUser?.name || '').trim()
const applicantName = String(request?.person || request?.employeeName || '').trim()
if (currentName && applicantName && currentName === applicantName) {
return false
}
if (canManageExpenseClaims(currentUser)) {
return true
}
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'
@@ -240,37 +104,35 @@ function buildApprovalRow(request) {
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)
spotlight: riskTone === 'high' || statusTone === 'urgent'
}
}
export default {
name: 'ApprovalCenterView',
components: {
ConfirmDialog,
TravelRequestDetailView,
TableLoadingState,
TableEmptyState
},
setup() {
const { currentUser } = useSystemState()
const { toast } = useToast()
const { markClaimViewed, syncPendingClaimIds } = useApprovalInbox()
const activeTab = ref('全部待审')
const selectedClaimId = ref('')
const expandedExpenseId = ref(null)
const listKeyword = ref('')
const rows = ref([])
const loading = ref(false)
const error = ref('')
const actionBusy = ref(false)
const returnDialogOpen = ref(false)
const deleteDialogOpen = ref(false)
watch(
() => selectedClaimId.value,
(claimId) => {
if (claimId) {
markClaimViewed(claimId)
}
}
)
const selectedRow = computed({
get() {
@@ -278,14 +140,12 @@ export default {
},
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 === '即将超时') {
@@ -294,25 +154,20 @@ export default {
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)
)
})
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 showTable = computed(() => !loading.value && !error.value && visibleRows.value.length > 0)
const showEmpty = computed(() => !loading.value && !error.value && visibleRows.value.length === 0)
const canManageClaims = computed(() => canManageExpenseClaims(currentUser.value))
const approvalEmptyState = computed(() => {
if (!rows.value.length) {
return {
@@ -343,45 +198,6 @@ export default {
}
})
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()
@@ -391,74 +207,18 @@ export default {
activeTab.value = '全部待审'
}
function handleReturnSelected() {
if (!selectedRow.value?.claimId || !canManageClaims.value || actionBusy.value) {
return
}
returnDialogOpen.value = true
function closeSelectedDetail() {
selectedClaimId.value = ''
}
function handleDeleteSelected() {
if (!selectedRow.value?.claimId || !canManageClaims.value || actionBusy.value) {
return
}
deleteDialogOpen.value = true
async function handleDetailUpdated() {
selectedClaimId.value = ''
await reload()
}
function closeReturnDialog() {
if (!actionBusy.value) {
returnDialogOpen.value = false
}
}
function closeDeleteDialog() {
if (!actionBusy.value) {
deleteDialogOpen.value = false
}
}
async function confirmReturnSelected() {
const row = selectedRow.value
if (!row?.claimId || actionBusy.value) {
return
}
actionBusy.value = true
try {
await returnExpenseClaim(row.claimId, {
reason: '审批中心退回,请申请人调整后重新提交。'
})
toast(`${row.id} 已退回待提交。`)
returnDialogOpen.value = false
selectedClaimId.value = ''
await reload()
} catch (nextError) {
toast(nextError?.message || '退回单据失败,请稍后重试。')
} finally {
actionBusy.value = false
}
}
async function confirmDeleteSelected() {
const row = selectedRow.value
if (!row?.claimId || actionBusy.value) {
return
}
actionBusy.value = true
try {
const payload = await deleteExpenseClaim(row.claimId)
toast(payload?.message || `${row.id} 报销单已删除。`)
deleteDialogOpen.value = false
selectedClaimId.value = ''
await reload()
} catch (nextError) {
toast(nextError?.message || '删除单据失败,请稍后重试。')
} finally {
actionBusy.value = false
}
async function handleDetailDeleted() {
selectedClaimId.value = ''
await reload()
}
async function reload() {
@@ -466,15 +226,11 @@ export default {
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))
: []
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 = ''
}
@@ -491,42 +247,21 @@ export default {
return {
activeTab,
selectedRow,
expandedExpenseId,
listKeyword,
tabs,
filters,
rows,
visibleRows,
showTable,
showEmpty,
actionBusy,
approvalEmptyState,
approvalSteps,
canManageClaims,
closeDeleteDialog,
closeReturnDialog,
confirmDeleteSelected,
confirmReturnSelected,
deleteDialogOpen,
summaryItems,
heroSummaryItems,
currentProgressRingMotion,
expenseItems,
expenseTotal,
uploadedExpenseCount,
showExpenseRisk,
toggleExpenseAttachments,
attachments,
riskItems,
flowItems,
handleEmptyAction,
handleDeleteSelected,
handleReturnSelected,
loading,
closeSelectedDetail,
error,
returnDialogOpen,
reload
filters,
handleDetailDeleted,
handleDetailUpdated,
handleEmptyAction,
listKeyword,
loading,
reload,
rows,
selectedRow,
showEmpty,
tabs,
visibleRows
}
}
}