5c582b092e
- 使用 actions 属性自定义弹框按钮 - 添加按钮点击事件的日志 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
575 lines
19 KiB
TypeScript
575 lines
19 KiB
TypeScript
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: '备注',
|
||
}
|
||
|
||
interface ApprovalRecord {
|
||
id: number
|
||
node_id: number
|
||
node_type: string
|
||
action: string
|
||
approver_id: number
|
||
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') => {
|
||
console.log('🔘 打开审批弹框:', { action, id })
|
||
setActionType(action)
|
||
setComment('')
|
||
setCommentVisible(true)
|
||
}
|
||
|
||
// 提交审批
|
||
const handleConfirmApprove = async () => {
|
||
console.log('📝 提交审批:', { actionType, id, comment })
|
||
|
||
if (document.activeElement instanceof HTMLElement) {
|
||
document.activeElement.blur()
|
||
}
|
||
|
||
try {
|
||
if (actionType === 'approve') {
|
||
console.log('✅ 调用同意API:', { id: Number(id), comment })
|
||
await approvalApi.approve(Number(id), comment)
|
||
Toast.show('已同意')
|
||
} else if (actionType === 'reject') {
|
||
console.log('❌ 调用拒绝API:', { id: Number(id), comment })
|
||
await approvalApi.reject(Number(id), comment)
|
||
Toast.show('已拒绝')
|
||
} else if (actionType === 'cancel') {
|
||
console.log('🔄 调用撤销API:', { id: Number(id) })
|
||
await approvalApi.cancel(Number(id))
|
||
Toast.show('已撤销')
|
||
}
|
||
setCommentVisible(false)
|
||
loadDetail()
|
||
} catch (error: any) {
|
||
console.error('❌ 审批操作失败:', error)
|
||
Toast.show(error.message || '操作失败')
|
||
}
|
||
}
|
||
|
||
// 检查是否是当前审批人
|
||
const isCurrentApprover = () => {
|
||
console.log('🔍 isCurrentApprover 检查:', {
|
||
hasDetail: !!detail,
|
||
hasUser: !!user,
|
||
userId: user?.id,
|
||
userIdType: typeof user?.id,
|
||
status: detail?.status,
|
||
currentNodes: detail?.current_nodes,
|
||
nodesCount: detail?.flow?.nodes?.length,
|
||
})
|
||
|
||
if (!detail || !user) {
|
||
console.log('❌ 缺少 detail 或 user')
|
||
return false
|
||
}
|
||
if (detail.status !== 'pending') {
|
||
console.log('❌ 状态不是 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,
|
||
approversType: typeof n.approvers,
|
||
approversFirstType: n.approvers?.[0] !== undefined ? typeof n.approvers[0] : 'undefined'
|
||
})),
|
||
})
|
||
|
||
for (const nodeId of currentNodeIds) {
|
||
const node = nodes.find((n) => n.id === nodeId)
|
||
console.log(`🔍 检查节点 ${nodeId}:`, {
|
||
node,
|
||
approvers: node?.approvers,
|
||
userId: user.id,
|
||
userIdType: typeof user.id,
|
||
isInApprovers: node?.approvers?.includes(user.id),
|
||
// 尝试类型转换后的检查
|
||
isInApproversAsString: node?.approvers?.includes(String(user.id)),
|
||
isInApproversAsNumber: node?.approvers?.includes(Number(user.id)),
|
||
})
|
||
|
||
if (node && node.approvers) {
|
||
// 尝试多种方式匹配
|
||
const matches = node.approvers.some(a => a === user.id || a === String(user.id) || Number(a) === user.id)
|
||
console.log(`🔍 节点 ${nodeId} 匹配结果:`, { matches })
|
||
|
||
if (matches) {
|
||
// 检查是否已审批 - 使用 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
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log('❌ 不是当前审批人')
|
||
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()
|
||
|
||
// 显示审批按钮状态
|
||
console.log('🎯 操作按钮状态:', {
|
||
canCancel,
|
||
canApprove,
|
||
isOwner,
|
||
applicantName: detail.applicant?.name,
|
||
userName: user?.name,
|
||
})
|
||
|
||
// 格式化时间
|
||
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 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)
|
||
}
|
||
|
||
// 获取当前审批人
|
||
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,
|
||
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) => {
|
||
const typeLabel = item.nodeType === 'notify' ? '抄送人' : '审批人'
|
||
const typeClass = item.nodeType === 'notify' ? styles.typeNotify : ''
|
||
return (
|
||
<div key={index} className={styles.approverItem}>
|
||
<span className={`${styles.approverType} ${typeClass}`}>{typeLabel}</span>
|
||
<span className={styles.approverName}>{getNodeApproversNames(item.nodeId, item.nodeType)}</span>
|
||
</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>
|
||
)
|
||
})}
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{/* 审批流程 */}
|
||
{detail.flow?.nodes && detail.flow.nodes.length > 0 && (
|
||
<Card title="审批流程" className={styles.section}>
|
||
<div className={styles.timeline}>
|
||
{detail.flow.nodes.map((node, index) => {
|
||
const isCurrent = detail.current_nodes?.includes(node.id)
|
||
const records = detail.records.filter(r => r.node_id === node.id && r.action !== 'submit')
|
||
const hasApproved = records.length > 0
|
||
|
||
// 判断节点状态
|
||
let nodeStatus: 'pending' | 'waiting' | 'done' = 'waiting'
|
||
if (hasApproved) {
|
||
nodeStatus = 'done'
|
||
} else if (isCurrent) {
|
||
nodeStatus = 'pending'
|
||
}
|
||
|
||
// 获取节点类型标签
|
||
const typeLabel = node.type === 'notify' ? '抄送人' : '审批人'
|
||
const typeClass = node.type === 'notify' ? styles.typeNotify : styles.typeApprover
|
||
|
||
// 获取显示名称和审批时间
|
||
let displayName = node.name || '待定'
|
||
let approvalTime = ''
|
||
|
||
if (hasApproved && records[0]) {
|
||
displayName = records[0].approver?.name || displayName
|
||
approvalTime = formatTime(records[0].created_at)
|
||
}
|
||
|
||
return (
|
||
<div key={node.id} className={styles.timelineItem}>
|
||
<div className={`${styles.timelineDot} ${styles[nodeStatus]} ${isCurrent ? styles.current : ''}`}>
|
||
{nodeStatus === 'done' ? '✓' : nodeStatus === 'pending' ? '⏱' : index + 1}
|
||
</div>
|
||
<div className={styles.timelineContent}>
|
||
<div className={styles.timelineTitle}>
|
||
<span className={`${styles.timelineType} ${typeClass}`}>{typeLabel}</span>
|
||
<span className={styles.timelineName}>{displayName}</span>
|
||
</div>
|
||
{approvalTime && (
|
||
<div className={styles.timelineTime}>{approvalTime}</div>
|
||
)}
|
||
{hasApproved && records[0]?.comment && (
|
||
<div className={styles.timelineComment}>备注: {records[0].comment}</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>
|
||
}
|
||
closeOnMaskClick={false}
|
||
closeOnAction={false}
|
||
onConfirm={handleConfirmApprove}
|
||
onCancel={() => {
|
||
console.log('弹框取消')
|
||
setCommentVisible(false)
|
||
}}
|
||
actions={[
|
||
[
|
||
{
|
||
key: 'cancel',
|
||
text: '取消',
|
||
onClick: () => {
|
||
console.log('点击取消按钮')
|
||
setCommentVisible(false)
|
||
}
|
||
},
|
||
{
|
||
key: 'confirm',
|
||
text: '确认',
|
||
primary: true,
|
||
onClick: () => {
|
||
console.log('点击确认按钮')
|
||
handleConfirmApprove()
|
||
}
|
||
}
|
||
]
|
||
]}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|