feat: 完善审批功能
- 新增通用审批列表页面(我发起的/待我审批/我已审批) - 新增通用审批详情页面(支持审批通过/拒绝操作) - 修复审批列表过滤逻辑("我已审批"只显示已完成订单) - 修复登录页面未设置 Zustand store 的问题 - 修复 User 类型定义(id 改为 number 类型) - 修复草稿恢复时的日期转换问题 - 创建测试数据脚本(test_data.php) - 安装 webman 官方依赖包(database, redis, pagination 等) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
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: '采购申请',
|
||||
}
|
||||
|
||||
interface ApprovalRecord {
|
||||
id: number
|
||||
node_type: string
|
||||
action: string
|
||||
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 = () => {
|
||||
if (!detail || !user) return false
|
||||
if (detail.status !== 'pending') return false
|
||||
|
||||
// 检查当前用户是否在 current_nodes 对应的节点审批人列表中
|
||||
const currentNodeIds = detail.current_nodes || []
|
||||
const nodes = detail.flow?.nodes || []
|
||||
|
||||
for (const nodeId of currentNodeIds) {
|
||||
const node = nodes.find((n) => n.id === nodeId)
|
||||
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
|
||||
)
|
||||
if (!hasApproved) return true
|
||||
}
|
||||
}
|
||||
|
||||
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: '单人审批',
|
||||
}
|
||||
return typeMap[nodeType] || nodeType
|
||||
}
|
||||
|
||||
// 获取当前审批人
|
||||
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({
|
||||
name: node.name,
|
||||
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}>{item.name}</div>
|
||||
<div className={styles.approverType}>{item.type}</div>
|
||||
</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]) => (
|
||||
<div key={key} className={styles.formRow}>
|
||||
<span className={styles.formLabel}>{key}:</span>
|
||||
<span className={styles.formValue}>
|
||||
{Array.isArray(value) ? value.join(', ') : String(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</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}>{node.name}</div>
|
||||
<div className={styles.flowNodeType}>
|
||||
{getNodeTypeName(node.type)}
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user