Files
x-server-mobile/frontend/src/pages/approval/detail.tsx
T

489 lines
16 KiB
TypeScript
Raw Normal View History

2026-02-25 23:54:38 +08:00
import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { NavBar, Card, Tag, Button, Dialog, Toast, TextArea } from 'antd-mobile'
import {
LeftOutline,
ClockCircleOutline,
CheckCircleOutline,
CloseCircleOutline,
ExclamationCircleOutline,
} from 'antd-mobile-icons'
import { approvalApi } from '../../services/api'
import { useUserStore } from '@/stores/user'
import dayjs from 'dayjs'
import styles from './detail.module.css'
// 状态配置
const statusConfig: Record<string, { color: string; icon: React.ReactNode; text: string }> = {
draft: { color: 'default', icon: <ClockCircleOutline />, text: '草稿' },
pending: { color: 'warning', icon: <ExclamationCircleOutline />, text: '审批中' },
approved: { color: 'success', icon: <CheckCircleOutline />, text: '已通过' },
rejected: { color: 'danger', icon: <CloseCircleOutline />, text: '已拒绝' },
cancelled: { color: 'default', icon: <CloseCircleOutline />, text: '已撤销' },
}
// 操作配置
const actionConfig: Record<string, { color: string; icon: string; text: string }> = {
submit: { color: '#999', icon: '📝', text: '提交申请' },
approve: { color: '#52c41a', icon: '✅', text: '已同意' },
reject: { color: '#ff4d4f', icon: '❌', text: '已拒绝' },
cancel: { color: '#999', icon: '🔄', text: '已撤销' },
}
// 业务类型映射
const businessTypeConfig: Record<string, string> = {
business_trip: '出差申请',
expense: '费用报销',
leave: '请假申请',
purchase: '采购申请',
}
// 表单字段映射(英文key -> 中文label)
const formFieldLabelMap: Record<string, string> = {
reason: '出差事由',
itineraries: '行程安排',
destination: '目的地',
start_date: '开始日期',
end_date: '结束日期',
estimated_cost: '预计费用',
approvers: '审批人',
cc_users: '抄送人',
attachment: '附件',
remark: '备注',
}
2026-02-25 23:54:38 +08:00
interface ApprovalRecord {
id: number
node_id: number
2026-02-25 23:54:38 +08:00
node_type: string
action: string
approver_id: number
2026-02-25 23:54:38 +08:00
comment: string | null
created_at: string
approver: {
name: string
avatar?: string
}
}
interface ApprovalNode {
id: number
type: string
name: string
approvers: number[]
}
interface ApprovalDetail {
id: number
title: string
business_type: string
business_id: number
status: string
created_at: string
current_nodes: number[]
applicant: {
name: string
avatar?: string
}
flow: {
name: string
nodes?: ApprovalNode[]
}
form_data: Record<string, any>
records: ApprovalRecord[]
}
export default function ApprovalDetail() {
const { t } = useTranslation()
const navigate = useNavigate()
const { id } = useParams()
const { user } = useUserStore()
const [detail, setDetail] = useState<ApprovalDetail | null>(null)
const [loading, setLoading] = useState(true)
const [commentVisible, setCommentVisible] = useState(false)
const [comment, setComment] = useState('')
const [actionType, setActionType] = useState<'approve' | 'reject' | 'cancel'>('approve')
// 加载详情
const loadDetail = async () => {
if (!id) return
setLoading(true)
try {
const data = await approvalApi.getDetail(Number(id))
setDetail(data)
} catch (error: any) {
Toast.show(error.message || '加载失败')
navigate(-1)
} finally {
setLoading(false)
}
}
useEffect(() => {
loadDetail()
}, [id])
// 撤销申请
const handleCancel = () => {
Dialog.confirm({
content: '确定要撤销此申请吗?',
onConfirm: async () => {
try {
await approvalApi.cancel(Number(id))
Toast.show('已撤销')
loadDetail()
} catch (error: any) {
Toast.show(error.message || '操作失败')
}
},
})
}
// 打开审批意见框
const handleApprove = (action: 'approve' | 'reject') => {
setActionType(action)
setComment('')
setCommentVisible(true)
}
// 提交审批
const handleConfirmApprove = async () => {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
try {
if (actionType === 'approve') {
await approvalApi.approve(Number(id), comment)
Toast.show('已同意')
} else if (actionType === 'reject') {
await approvalApi.reject(Number(id), comment)
Toast.show('已拒绝')
} else if (actionType === 'cancel') {
await approvalApi.cancel(Number(id))
Toast.show('已撤销')
}
setCommentVisible(false)
loadDetail()
} catch (error: any) {
Toast.show(error.message || '操作失败')
}
}
// 检查是否是当前审批人
const isCurrentApprover = () => {
console.log('🔍 isCurrentApprover 检查:', {
hasDetail: !!detail,
hasUser: !!user,
userId: user?.id,
status: detail?.status,
currentNodes: detail?.current_nodes,
nodesCount: detail?.flow?.nodes?.length,
})
2026-02-25 23:54:38 +08:00
if (!detail || !user) return false
if (detail.status !== 'pending') return false
// 检查当前用户是否在 current_nodes 对应的节点审批人列表中
const currentNodeIds = detail.current_nodes || []
const nodes = detail.flow?.nodes || []
console.log('🔍 节点信息:', {
currentNodeIds,
nodes: nodes.map(n => ({ id: n.id, name: n.name, approvers: n.approvers })),
})
2026-02-25 23:54:38 +08:00
for (const nodeId of currentNodeIds) {
const node = nodes.find((n) => n.id === nodeId)
console.log(`🔍 检查节点 ${nodeId}:`, {
node,
approvers: node?.approvers,
userId: user.id,
isInApprovers: node?.approvers?.includes(user.id),
})
2026-02-25 23:54:38 +08:00
if (node && node.approvers && node.approvers.includes(user.id)) {
// 检查是否已审批 - 使用 approver_id 而不是 approver.id
const hasApproved = detail.records.some(
(r) => r.node_id === nodeId && r.approver_id === user.id
)
console.log(`🔍 节点 ${nodeId} 审批状态:`, {
hasApproved,
records: detail.records.filter(r => r.node_id === nodeId),
})
if (!hasApproved) {
console.log('✅ 是当前审批人!')
return true
}
2026-02-25 23:54:38 +08:00
}
}
console.log('❌ 不是当前审批人')
2026-02-25 23:54:38 +08:00
return false
}
if (loading || !detail) {
return <div className={styles.loading}>加载中...</div>
}
const config = statusConfig[detail.status] || statusConfig.draft
const isOwner = detail.applicant?.name === user?.name
const canCancel = detail.status === 'pending' && isOwner
const canApprove = isCurrentApprover()
// 格式化时间
const formatTime = (timeStr: string) => {
return dayjs(timeStr).format('YYYY-MM-DD HH:mm')
}
// 获取节点类型名称
const getNodeTypeName = (nodeType: string) => {
const typeMap: Record<string, string> = {
approver: '审批人',
and_approve: '会签',
or_approve: '或签',
notify: '抄送人',
single_approve: '审批人',
2026-02-25 23:54:38 +08:00
}
return typeMap[nodeType] || nodeType
}
// 获取节点的审批人姓名
const getNodeApproversNames = (nodeId: number, nodeType: string): string => {
// 从审批记录中获取已审批的审批人姓名
const approverRecords = detail.records.filter(r => r.node_id === nodeId && r.action !== 'submit')
if (approverRecords.length > 0) {
return approverRecords.map(r => r.approver?.name).join('、')
}
// 未审批的节点,显示节点名称(人名)
const nodes = detail.flow?.nodes || []
const node = nodes.find((n) => n.id === nodeId)
return node?.name || '待审批'
}
// 获取节点显示名称(只显示人名)
const getNodeDisplayName = (node: any): string => {
return getNodeApproversNames(node.id, node.type)
}
2026-02-25 23:54:38 +08:00
// 获取当前审批人
const getCurrentApprovers = () => {
const currentNodeIds = detail.current_nodes || []
const nodes = detail.flow?.nodes || []
const approvers: any[] = []
for (const nodeId of currentNodeIds) {
const node = nodes.find((n) => n.id === nodeId)
if (node) {
approvers.push({
nodeId: node.id,
nodeType: node.type,
2026-02-25 23:54:38 +08:00
type: getNodeTypeName(node.type),
approvers: node.approvers,
})
}
}
return approvers
}
const currentApprovers = getCurrentApprovers()
return (
<div className={styles.detail}>
<NavBar onBack={() => navigate(-1)}>审批详情</NavBar>
<div className={styles.content}>
{/* 状态卡片 */}
<Card className={styles.statusCard}>
<div className={styles.statusHeader}>
<span className={styles.title}>{detail.title}</span>
<Tag color={config.color}>
{config.icon}
{config.text}
</Tag>
</div>
<div className={styles.statusInfo}>
<div className={styles.infoItem}>
<span className={styles.label}>业务类型:</span>
<span className={styles.value}>
{businessTypeConfig[detail.business_type] || detail.business_type}
</span>
</div>
<div className={styles.infoItem}>
<span className={styles.label}>申请人:</span>
<span className={styles.value}>{detail.applicant?.name}</span>
</div>
<div className={styles.infoItem}>
<span className={styles.label}>申请时间:</span>
<span className={styles.value}>{formatTime(detail.created_at)}</span>
</div>
<div className={styles.infoItem}>
<span className={styles.label}>审批流程:</span>
<span className={styles.value}>{detail.flow?.name}</span>
</div>
</div>
</Card>
{/* 当前审批节点 */}
{detail.status === 'pending' && currentApprovers.length > 0 && (
<Card title="当前审批" className={styles.section}>
{currentApprovers.map((item, index) => (
<div key={index} className={styles.approverItem}>
<div className={styles.approverName}>{getNodeApproversNames(item.nodeId, item.nodeType)}</div>
2026-02-25 23:54:38 +08:00
</div>
))}
</Card>
)}
{/* 表单数据 */}
{Object.keys(detail.form_data).length > 0 && (
<Card title="申请内容" className={styles.section}>
<div className={styles.formData}>
{Object.entries(detail.form_data)
.filter(([key]) => !['approvers', 'cc_users'].includes(key))
.map(([key, value]) => {
// 获取字段标签,优先使用映射表,没有则使用原始key
const label = formFieldLabelMap[key] || key
return (
<div key={key} className={styles.formRow}>
<span className={styles.formLabel}>{label}:</span>
<span className={styles.formValue}>
{Array.isArray(value) ? value.join(', ') : String(value)}
</span>
</div>
)
})}
2026-02-25 23:54:38 +08:00
</div>
</Card>
)}
{/* 审批流程 */}
{detail.flow?.nodes && detail.flow.nodes.length > 0 && (
<Card title="审批流程" className={styles.section}>
<div className={styles.flowNodes}>
{detail.flow.nodes.map((node, index) => {
const isCurrent = detail.current_nodes?.includes(node.id)
const record = detail.records.find((r) => r.node_id === node.id)
let status: 'pending' | 'done' = 'pending'
if (record) {
status = 'done'
} else if (!isCurrent && index > 0) {
// 检查前面的节点是否完成
const prevNode = detail.flow.nodes[index - 1]
const prevRecord = detail.records.find((r) => r.node_id === prevNode.id)
if (prevRecord) status = 'done'
}
return (
<div
key={node.id}
className={`${styles.flowNode} ${styles[status]} ${isCurrent ? styles.current : ''}`}
>
<div className={styles.flowNodeDot}>
{status === 'done' ? '✓' : index + 1}
</div>
<div className={styles.flowNodeContent}>
<div className={styles.flowNodeName}>{getNodeDisplayName(node)}</div>
2026-02-25 23:54:38 +08:00
</div>
</div>
)
})}
</div>
</Card>
)}
{/* 审批记录 */}
<Card title="审批记录" className={styles.section}>
<div className={styles.timeline}>
{detail.records && detail.records.length > 0 ? (
detail.records.map((record) => {
const recordConfig = actionConfig[record.action] || actionConfig.submit
return (
<div key={record.id} className={styles.timelineItem}>
<div className={styles.timelineDot} style={{ background: recordConfig.color }}>
<span className={styles.timelineDotIcon}>{recordConfig.icon}</span>
</div>
<div className={styles.timelineContent}>
<div className={styles.timelineTitle}>
{recordConfig.text}
<span className={styles.timelineName}>
{record.approver?.name}
</span>
</div>
<div className={styles.timelineTime}>
{formatTime(record.created_at)}
</div>
{record.comment && (
<div className={styles.timelineComment}>备注: {record.comment}</div>
)}
</div>
</div>
)
})
) : (
<div className={styles.empty}>暂无审批记录</div>
)}
</div>
</Card>
</div>
{/* 底部操作栏 */}
{(canCancel || canApprove) && (
<div className={styles.footer}>
{canCancel && (
<Button block color="warning" onClick={handleCancel}>
撤销申请
</Button>
)}
{canApprove && (
<>
<Button
block
color="danger"
fill="outline"
onClick={() => handleApprove('reject')}
>
拒绝
</Button>
<Button
block
color="primary"
onClick={() => handleApprove('approve')}
>
同意
</Button>
</>
)}
</div>
)}
{/* 审批意见弹框 */}
<Dialog
visible={commentVisible}
title={actionType === 'approve' ? '同意申请' : actionType === 'reject' ? '拒绝申请' : '撤销申请'}
content={
<div className={styles.commentBox}>
<TextArea
placeholder="请输入审批意见(可选)"
value={comment}
onChange={setComment}
rows={3}
maxLength={200}
/>
</div>
}
onConfirm={handleConfirmApprove}
onClose={() => setCommentVisible(false)}
onCancel={() => setCommentVisible(false)}
confirmText="确认"
cancelText="取消"
/>
</div>
)
}