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:
root
2026-02-25 23:54:38 +08:00
parent 27ad5728c9
commit 04953b22dc
17 changed files with 2733 additions and 28 deletions
+424
View File
@@ -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>
)
}