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
@@ -50,7 +50,7 @@ class ApprovalController extends BaseController
{
$id = (int) $this->getRouteParam($request, 'id');
$approval = ApprovalOrder::with(['flow', 'applicant', 'records'])
$approval = ApprovalOrder::with(['flow.nodes', 'applicant', 'records.approver'])
->findOrFail($id);
return $this->success($approval);
+4 -2
View File
@@ -190,13 +190,15 @@ class ApprovalService
$query->whereIn('id', $pendingOrderIds);
break;
case 'approved':
// 获取用户已审批的订单
// 获取用户已审批的订单(只显示已完成的:approved、rejected、cancelled)
$approvedOrderIds = ApprovalRecord::where('approver_id', $userId)
->where('action', '!=', ApprovalRecord::ACTION_SUBMIT)
->whereIn('action', [ApprovalRecord::ACTION_APPROVE, ApprovalRecord::ACTION_REJECT])
->pluck('order_id')
->unique()
->toArray();
$query->whereIn('id', $approvedOrderIds);
$query->whereIn('id', $approvedOrderIds)
->whereIn('status', [ApprovalOrder::STATUS_APPROVED, ApprovalOrder::STATUS_REJECTED, ApprovalOrder::STATUS_CANCELLED]);
break;
}
+2 -1
View File
@@ -161,7 +161,8 @@ class BusinessTripService
*/
public function getList(int $userId, string $type = 'my', int $page = 1, int $pageSize = 20): array
{
$query = BusinessTrip::with(['user', 'itineraries', 'approvalOrder']);
// 先不加 approvalOrder 关系,避免 PHP 8.5 兼容性问题
$query = BusinessTrip::with(['user', 'itineraries']);
switch ($type) {
case 'my':
+6 -3
View File
@@ -7,11 +7,14 @@
"workerman/webman": "^2.0",
"workerman/webman-framework": "^2.0",
"illuminate/database": "^10.0",
"illuminate/pagination": "^10.0",
"illuminate/pagination": "^10.49",
"illuminate/redis": "^10.0",
"illuminate/events": "^10.0",
"illuminate/events": "^10.49",
"firebase/php-jwt": "^6.10",
"vlucas/phpdotenv": "^5.6"
"vlucas/phpdotenv": "^5.6",
"webman/database": "^2.1",
"symfony/var-dumper": "^8.0",
"webman/redis": "^2.1"
},
"require-dev": {
"mockery/mockery": "^1.5",
+1460 -1
View File
File diff suppressed because it is too large Load Diff
+263
View File
@@ -0,0 +1,263 @@
<?php
require_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/support/bootstrap.php';
use support\Db;
$userId1 = 1; // 李佳博
$userId2 = 2; // 闫继光
$userId3 = 3; // 孟启
// 获取出差审批流程
$flow = Db::table('approval_flow')->where('business_type', 'business_trip')->where('status', 1)->first();
if (!$flow) {
echo "Error: No approval flow found\n";
exit;
}
echo "Creating test data...\n";
// 1. 创建用户2提交的申请(让用户1审批)
$trip1 = Db::table('business_trip')->insertGetId([
'user_id' => $userId2,
'reason' => '北京技术交流',
'start_date' => '2026-03-15',
'end_date' => '2026-03-17',
'destination' => '北京',
'estimated_cost' => 5000,
'status' => 'pending',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
Db::table('business_trip_itinerary')->insert([
'business_trip_id' => $trip1,
'start_time' => '2026-03-15 08:00:00',
'end_time' => '2026-03-17 18:00:00',
'departure' => '郑州',
'destination' => '北京',
'transport' => '高铁',
'trip_type' => 'roundtrip',
'remark' => '参加技术交流会议',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
$order1 = Db::table('approval_order')->insertGetId([
'business_type' => 'business_trip',
'business_id' => $trip1,
'title' => '出差申请 - 闫继光',
'applicant_id' => $userId2,
'status' => 'pending',
'current_nodes' => json_encode([1]),
'flow_id' => $flow->id,
'form_data' => json_encode(['reason' => '北京技术交流', 'approvers' => [$userId1]]),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
Db::table('business_trip')->where('id', $trip1)->update(['approval_order_id' => $order1]);
Db::table('approval_record')->insert([
'order_id' => $order1,
'node_id' => 1,
'node_type' => 'single_approve',
'approver_id' => $userId2,
'action' => 'submit',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
echo "Created trip for user 2 (闫继光), order_id: $order1\n";
// 2. 创建用户3提交的申请(让用户1审批)
$trip2 = Db::table('business_trip')->insertGetId([
'user_id' => $userId3,
'reason' => '上海客户拜访',
'start_date' => '2026-03-20',
'end_date' => '2026-03-21',
'destination' => '上海',
'estimated_cost' => 3000,
'status' => 'pending',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
Db::table('business_trip_itinerary')->insert([
'business_trip_id' => $trip2,
'start_time' => '2026-03-20 08:00:00',
'end_time' => '2026-03-21 18:00:00',
'departure' => '郑州',
'destination' => '上海',
'transport' => '高铁',
'trip_type' => 'roundtrip',
'remark' => '拜访重要客户',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
$order2 = Db::table('approval_order')->insertGetId([
'business_type' => 'business_trip',
'business_id' => $trip2,
'title' => '出差申请 - 孟启',
'applicant_id' => $userId3,
'status' => 'pending',
'current_nodes' => json_encode([1]),
'flow_id' => $flow->id,
'form_data' => json_encode(['reason' => '上海客户拜访', 'approvers' => [$userId1]]),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
Db::table('business_trip')->where('id', $trip2)->update(['approval_order_id' => $order2]);
Db::table('approval_record')->insert([
'order_id' => $order2,
'node_id' => 1,
'node_type' => 'single_approve',
'approver_id' => $userId3,
'action' => 'submit',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
echo "Created trip for user 3 (孟启), order_id: $order2\n";
// 3. 创建一个已通过的申请(用户1审批的)
$trip3 = Db::table('business_trip')->insertGetId([
'user_id' => $userId2,
'reason' => '广州展会',
'start_date' => '2026-03-10',
'end_date' => '2026-03-12',
'destination' => '广州',
'estimated_cost' => 8000,
'status' => 'approved',
'created_at' => date('Y-m-d H:i:s', strtotime('-2 days')),
'updated_at' => date('Y-m-d H:i:s', strtotime('-1 day')),
]);
Db::table('business_trip_itinerary')->insert([
'business_trip_id' => $trip3,
'start_time' => '2026-03-10 08:00:00',
'end_time' => '2026-03-12 18:00:00',
'departure' => '郑州',
'destination' => '广州',
'transport' => '飞机',
'trip_type' => 'roundtrip',
'remark' => '参加行业展会',
'created_at' => date('Y-m-d H:i:s', strtotime('-2 days')),
'updated_at' => date('Y-m-d H:i:s', strtotime('-2 days')),
]);
$order3 = Db::table('approval_order')->insertGetId([
'business_type' => 'business_trip',
'business_id' => $trip3,
'title' => '出差申请 - 闫继光',
'applicant_id' => $userId2,
'status' => 'approved',
'current_nodes' => json_encode([]),
'flow_id' => $flow->id,
'form_data' => json_encode(['reason' => '广州展会', 'approvers' => [$userId1]]),
'created_at' => date('Y-m-d H:i:s', strtotime('-2 days')),
'updated_at' => date('Y-m-d H:i:s', strtotime('-1 day')),
]);
Db::table('business_trip')->where('id', $trip3)->update(['approval_order_id' => $order3]);
Db::table('approval_record')->insert([
'order_id' => $order3,
'node_id' => 1,
'node_type' => 'single_approve',
'approver_id' => $userId2,
'action' => 'submit',
'created_at' => date('Y-m-d H:i:s', strtotime('-2 days')),
'updated_at' => date('Y-m-d H:i:s', strtotime('-2 days')),
]);
Db::table('approval_record')->insert([
'order_id' => $order3,
'node_id' => 1,
'node_type' => 'single_approve',
'approver_id' => $userId1,
'action' => 'approve',
'comment' => '同意',
'created_at' => date('Y-m-d H:i:s', strtotime('-1 day')),
'updated_at' => date('Y-m-d H:i:s', strtotime('-1 day')),
]);
echo "Created approved trip, order_id: $order3\n";
// 4. 创建一个已拒绝的申请
$trip4 = Db::table('business_trip')->insertGetId([
'user_id' => $userId3,
'reason' => '深圳培训',
'start_date' => '2026-03-05',
'end_date' => '2026-03-06',
'destination' => '深圳',
'estimated_cost' => 2000,
'status' => 'rejected',
'created_at' => date('Y-m-d H:i:s', strtotime('-3 days')),
'updated_at' => date('Y-m-d H:i:s', strtotime('-2 days')),
]);
Db::table('business_trip_itinerary')->insert([
'business_trip_id' => $trip4,
'start_time' => '2026-03-05 08:00:00',
'end_time' => '2026-03-06 18:00:00',
'departure' => '郑州',
'destination' => '深圳',
'transport' => '高铁',
'trip_type' => 'roundtrip',
'remark' => '参加产品培训',
'created_at' => date('Y-m-d H:i:s', strtotime('-3 days')),
'updated_at' => date('Y-m-d H:i:s', strtotime('-3 days')),
]);
$order4 = Db::table('approval_order')->insertGetId([
'business_type' => 'business_trip',
'business_id' => $trip4,
'title' => '出差申请 - 孟启',
'applicant_id' => $userId3,
'status' => 'rejected',
'current_nodes' => json_encode([]),
'flow_id' => $flow->id,
'form_data' => json_encode(['reason' => '深圳培训', 'approvers' => [$userId1]]),
'created_at' => date('Y-m-d H:i:s', strtotime('-3 days')),
'updated_at' => date('Y-m-d H:i:s', strtotime('-2 days')),
]);
Db::table('business_trip')->where('id', $trip4)->update(['approval_order_id' => $order4]);
Db::table('approval_record')->insert([
'order_id' => $order4,
'node_id' => 1,
'node_type' => 'single_approve',
'approver_id' => $userId3,
'action' => 'submit',
'created_at' => date('Y-m-d H:i:s', strtotime('-3 days')),
'updated_at' => date('Y-m-d H:i:s', strtotime('-3 days')),
]);
Db::table('approval_record')->insert([
'order_id' => $order4,
'node_id' => 1,
'node_type' => 'single_approve',
'approver_id' => $userId1,
'action' => 'reject',
'comment' => '预算不足,暂不批准',
'created_at' => date('Y-m-d H:i:s', strtotime('-2 days')),
'updated_at' => date('Y-m-d H:i:s', strtotime('-2 days')),
]);
echo "Created rejected trip, order_id: $order4\n";
// 5. 更新现有订单4的审批节点,让用户1成为审批人
Db::table('approval_order')->where('id', 4)->update([
'form_data' => json_encode(['reason' => '11', 'approvers' => [$userId1]]),
]);
echo "Updated order 4 to make user 1 the approver\n";
echo "\n=== Test data created successfully ===\n";
@@ -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;
}
+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>
)
}
@@ -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;
}
+195
View File
@@ -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>
)
}
+17 -1
View File
@@ -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))
+1 -1
View File
@@ -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>
+5 -3
View File
@@ -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')
+4 -3
View File
@@ -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('登录成功')
// 跳转到首页
+4
View File
@@ -6,6 +6,8 @@ import BusinessTripApply from '@/pages/business-trip/apply'
import BusinessTripDetail from '@/pages/business-trip/detail'
import SelectApprover from '@/pages/business-trip/select-approver'
import SelectCc from '@/pages/business-trip/select-cc'
import ApprovalList from '@/pages/approval/list'
import ApprovalDetail from '@/pages/approval/detail'
import Login from '@/pages/login'
import SimpleLogin from '@/pages/login-simple'
@@ -21,6 +23,8 @@ const router = createBrowserRouter([
{ path: 'business-trip/apply/select-approver', element: <SelectApprover /> },
{ path: 'business-trip/apply/select-cc', element: <SelectCc /> },
{ path: 'business-trip/:id', element: <BusinessTripDetail /> },
{ path: 'approval', element: <ApprovalList /> },
{ path: 'approval/:id', element: <ApprovalDetail /> },
],
},
{ path: '/login', element: <Login /> },
+2 -2
View File
@@ -29,12 +29,12 @@ export const useUserStore = create<UserState>()(
setCurrentCompany: (currentCompany) => set({ currentCompany }),
login: (user, token) => {
localStorage.setItem('access_token', token)
localStorage.setItem('token', token)
set({ user, token, isLoggedIn: true })
},
logout: () => {
localStorage.removeItem('access_token')
localStorage.removeItem('token')
set({ user: null, token: null, currentCompany: null, isLoggedIn: false })
},
}),
+18 -10
View File
@@ -22,13 +22,16 @@ export type ApprovalNodeType = (typeof ApprovalNodeType)[keyof typeof ApprovalNo
// 用户信息
export interface User {
id: string
companyId: string
id: number
company_id: number
sn?: string
name: string
email?: string
phone?: string
avatar?: string
status: number
user_type?: number
position?: string | null
}
// 公司信息
@@ -54,9 +57,11 @@ export interface PaginationResponse<T> {
}
export interface ApprovalNode {
id: string
type: ApprovalNodeType
approvers: User[]
id: number
flow_id: number
type: string
name: string
approvers: number[]
order: number
conditions?: any
}
@@ -93,11 +98,14 @@ export interface Attachment {
}
export interface ApprovalTimeline {
id: string
nodeId: string
nodeType: ApprovalNodeType
approver: User
id: number
order_id: number
node_id: number
node_type: string
approver_id: number
action: 'approve' | 'reject' | 'cancel' | 'submit'
comment?: string
createdAt: string
created_at: string
updated_at: string
approver?: User
}