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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+1460
-1
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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('登录成功')
|
||||
// 跳转到首页
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user