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,249 @@
|
||||
.detail {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.statusCard {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.statusHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.statusInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.infoItem {
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #666;
|
||||
width: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #333;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.approverItem {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.approverItem:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.approverName {
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.approverType {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.formData {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.formRow {
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.formLabel {
|
||||
color: #666;
|
||||
width: 100px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.formValue {
|
||||
color: #333;
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.flowNodes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.flowNode {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.flowNodeDot {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.flowNode.pending .flowNodeDot {
|
||||
background: #e8e8e8;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.flowNode.done .flowNodeDot {
|
||||
background: #52c41a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.flowNode.current .flowNodeDot {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 4px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
|
||||
.flowNodeContent {
|
||||
flex: 1;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.flowNodeName {
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.flowNodeType {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.timelineItem {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.timelineDot {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.timelineDotIcon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.timelineContent {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.timelineTitle {
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.timelineName {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.timelineTime {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.timelineComment {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
background: #f5f5f5;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 20px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.footer button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.commentBox {
|
||||
padding: 8px 0;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
.list {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.item {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.businessType {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.flow {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.applicant {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty p {
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { List, Card, Tag, Tabs, PullToRefresh, InfiniteScroll } from 'antd-mobile'
|
||||
import {
|
||||
ClockCircleOutline,
|
||||
CheckCircleOutline,
|
||||
CloseCircleOutline,
|
||||
ExclamationCircleOutline,
|
||||
} from 'antd-mobile-icons'
|
||||
import { approvalApi } from '../../services/api'
|
||||
import styles from './list.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: '已撤销' },
|
||||
}
|
||||
|
||||
interface ApprovalItem {
|
||||
id: number
|
||||
title: string
|
||||
business_type: string
|
||||
status: string
|
||||
created_at: string
|
||||
applicant?: {
|
||||
name: string
|
||||
}
|
||||
flow?: {
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
// 业务类型映射
|
||||
const businessTypeConfig: Record<string, string> = {
|
||||
business_trip: '出差申请',
|
||||
expense: '费用报销',
|
||||
leave: '请假申请',
|
||||
purchase: '采购申请',
|
||||
}
|
||||
|
||||
export default function ApprovalList() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'my' | 'pending' | 'approved'>('my')
|
||||
const [list, setList] = useState<ApprovalItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
// 加载数据
|
||||
const loadData = async (pageNum: number = 1, isRefresh: boolean = false) => {
|
||||
if (loading) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await approvalApi.getList({
|
||||
type: activeTab,
|
||||
page: pageNum,
|
||||
page_size: 20,
|
||||
})
|
||||
|
||||
const newList = result.list || []
|
||||
|
||||
if (isRefresh || pageNum === 1) {
|
||||
setList(newList)
|
||||
} else {
|
||||
setList([...list, ...newList])
|
||||
}
|
||||
|
||||
setHasMore(newList.length >= 20)
|
||||
setPage(pageNum)
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load approvals:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换标签
|
||||
const handleTabChange = (key: string) => {
|
||||
setActiveTab(key as 'my' | 'pending' | 'approved')
|
||||
setList([])
|
||||
setHasMore(true)
|
||||
setPage(1)
|
||||
loadData(1, true)
|
||||
}
|
||||
|
||||
// 刷新
|
||||
const handleRefresh = async () => {
|
||||
await loadData(1, true)
|
||||
}
|
||||
|
||||
// 加载更多
|
||||
const handleLoadMore = async () => {
|
||||
await loadData(page + 1, false)
|
||||
}
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadData(1, true)
|
||||
}, [])
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetail = (id: number) => {
|
||||
navigate(`/approval/${id}`)
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
return `${date.getMonth() + 1}/${date.getDate()}`
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (days === 0) {
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||
if (hours === 0) {
|
||||
const minutes = Math.floor(diff / (1000 * 60))
|
||||
return minutes < 1 ? '刚刚' : `${minutes}分钟前`
|
||||
}
|
||||
return `${hours}小时前`
|
||||
}
|
||||
if (days === 1) return '昨天'
|
||||
if (days < 7) return `${days}天前`
|
||||
return formatDate(dateStr)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.list}>
|
||||
<Tabs activeKey={activeTab} onChange={handleTabChange}>
|
||||
<Tabs.Tab title="我发起的" key="my" />
|
||||
<Tabs.Tab title="待我审批" key="pending" />
|
||||
<Tabs.Tab title="我已审批" key="approved" />
|
||||
</Tabs>
|
||||
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<List>
|
||||
{list.map((item) => {
|
||||
const config = statusConfig[item.status] || statusConfig.draft
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
key={item.id}
|
||||
onClick={() => handleViewDetail(item.id)}
|
||||
className={styles.item}
|
||||
>
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.title}>{item.title}</span>
|
||||
<Tag color={config.color} className={styles.tag}>
|
||||
{config.icon}
|
||||
{config.text}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.info}>
|
||||
<span className={styles.businessType}>
|
||||
{businessTypeConfig[item.business_type] || item.business_type}
|
||||
</span>
|
||||
<span className={styles.flow}>{item.flow?.name}</span>
|
||||
</div>
|
||||
<div className={styles.time}>{formatTime(item.created_at)}</div>
|
||||
{item.applicant && activeTab !== 'my' && (
|
||||
<div className={styles.applicant}>申请人: {item.applicant.name}</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</List.Item>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</PullToRefresh>
|
||||
|
||||
{list.length === 0 && !loading && (
|
||||
<div className={styles.empty}>
|
||||
<ExclamationCircleOutline fontSize={48} color="#cccccc" />
|
||||
<p>暂无审批记录</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InfiniteScroll loadMore={handleLoadMore} hasMore={hasMore} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -94,9 +94,25 @@ export default function BusinessTripApply() {
|
||||
|
||||
console.log('📍 Page init:', { fromSelection, locationState: location.state, initRef: initRef.current })
|
||||
|
||||
// 1. 从选择页面返回:恢复所有人员数据
|
||||
// 1. 从选择页面返回:恢复所有数据(草稿 + 人员)
|
||||
if (fromSelection) {
|
||||
console.log('🔄 Returning from selection page')
|
||||
// 恢复草稿数据
|
||||
const draftData = sessionStorage.getItem('businessTripDraft')
|
||||
if (draftData) {
|
||||
const draft = JSON.parse(draftData)
|
||||
setReason(draft.reason || '')
|
||||
// 将日期字符串转换回 Date 对象
|
||||
const restoredItineraries = (draft.itineraries || [{ startTime: null, endTime: null, departure: '', destination: '', transport: '', tripType: '' as 'oneway' | 'roundtrip' | '', remark: '' }]).map((item: any) => ({
|
||||
...item,
|
||||
startTime: item.startTime ? new Date(item.startTime) : null,
|
||||
endTime: item.endTime ? new Date(item.endTime) : null,
|
||||
}))
|
||||
setItineraries(restoredItineraries)
|
||||
console.log('✅ Restored draft')
|
||||
}
|
||||
|
||||
// 恢复人员数据
|
||||
const approversData = sessionStorage.getItem('selectedApprovers')
|
||||
if (approversData) {
|
||||
setApprovers(JSON.parse(approversData))
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function Dashboard() {
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>所有应用</h3>
|
||||
<div className={styles.actionGrid}>
|
||||
<div className={styles.actionCard} onClick={() => navigate('/business-trip')}>
|
||||
<div className={styles.actionCard} onClick={() => navigate('/approval')}>
|
||||
<div className={styles.actionIcon} style={{ backgroundColor: '#1677ff15' }}>
|
||||
<span style={{ fontSize: 32 }}>✍️</span>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Button, Input, Toast } from 'antd-mobile'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import styles from './login-simple.module.css'
|
||||
|
||||
export default function SimpleLogin() {
|
||||
const navigate = useNavigate()
|
||||
const { login } = useUserStore()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -28,9 +30,9 @@ export default function SimpleLogin() {
|
||||
const result = await response.json()
|
||||
|
||||
if (result.code === 0) {
|
||||
// 登录成功,保存 token
|
||||
localStorage.setItem('token', result.data.token)
|
||||
localStorage.setItem('user', JSON.stringify(result.data.user))
|
||||
// 登录成功,保存到 store
|
||||
login(result.data.user, result.data.token)
|
||||
localStorage.setItem('i18nextLng', 'zh')
|
||||
Toast.show('登录成功')
|
||||
// 跳转到首页
|
||||
navigate('/dashboard')
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Toast } from 'antd-mobile'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import styles from './login.module.css'
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate()
|
||||
const { login } = useUserStore()
|
||||
|
||||
useEffect(() => {
|
||||
// 检查 URL 中是否有 auth_code 参数(飞书授权回调)
|
||||
@@ -54,9 +56,8 @@ export default function Login() {
|
||||
const result = await response.json()
|
||||
|
||||
if (result.code === 0) {
|
||||
// 登录成功,保存 token
|
||||
localStorage.setItem('token', result.data.token)
|
||||
localStorage.setItem('user', JSON.stringify(result.data.user))
|
||||
// 登录成功,保存到 store
|
||||
login(result.data.user, result.data.token)
|
||||
|
||||
Toast.show('登录成功')
|
||||
// 跳转到首页
|
||||
|
||||
Reference in New Issue
Block a user