Files
x-server-mobile/frontend/src/pages/approval/detail.tsx
T
root 5c582b092e fix: 修复审批弹框按钮不显示的问题
- 使用 actions 属性自定义弹框按钮
- 添加按钮点击事件的日志

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 00:39:03 +08:00

575 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}