From 582f454a5a98a59a3be911390dfde08a9ca3d608 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 25 Feb 2026 18:09:04 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E7=9B=AE=E5=BD=95=E7=BB=93=E6=9E=84=E5=92=8C=E5=91=BD?= =?UTF-8?q?=E5=90=8D=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将后端目录名从大写改为小写 (Controller->controller 等) - 将命名空间从 App\ 改为 app\ (符合 webman 官方规范) - 移除不必要的 bootstrap 目录 - 完善出差审批业务功能: - 添加删除草稿功能 - 添加审批操作功能(同意/拒绝) - 完善审批详情页,显示审批人和审批记录 - 前端优化: - 调整列表页行高 - 隐藏底部导航栏 - 仪表盘只显示3个快捷入口 Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 27 ++ backend/app/Bootstrap/Database.php | 15 - backend/app/Middleware/AuthMiddleware.php | 42 --- .../ApprovalController.php | 4 +- .../AuthController.php | 10 +- .../BaseController.php | 2 +- .../BusinessTripController.php | 47 ++- .../UserController.php | 4 +- backend/app/middleware/AuthMiddleware.php | 35 ++- backend/app/{Model => model}/ApprovalFlow.php | 2 +- backend/app/{Model => model}/ApprovalNode.php | 2 +- .../app/{Model => model}/ApprovalOrder.php | 2 +- .../app/{Model => model}/ApprovalRecord.php | 2 +- backend/app/{Model => model}/BusinessTrip.php | 2 +- .../BusinessTripItinerary.php | 2 +- backend/app/{Model => model}/Company.php | 2 +- .../app/{Model => model}/CompanyFeishuApp.php | 2 +- backend/app/{Model => model}/User.php | 2 +- .../{Service => service}/ApprovalService.php | 14 +- .../BusinessTripService.php | 185 +++++++++++- .../{Service => service}/FeishuService.php | 10 +- backend/app/{Util => util}/Db.php | 2 +- backend/app/{Util => util}/JwtUtil.php | 2 +- backend/app/{Util => util}/Lang.php | 2 +- backend/composer.json | 2 +- backend/config/bootstrap.php | 5 - backend/config/route.php | 65 +++-- backend/scripts/import_old_data.php | 2 +- frontend/dev-tools.html | 74 +++++ frontend/set-token.html | 101 +++++++ .../src/components/layout/MobileLayout.tsx | 2 +- frontend/src/components/layout/TabBar.tsx | 3 +- frontend/src/index.css | 52 ++++ frontend/src/main.tsx | 1 + .../src/pages/business-trip/apply.module.css | 7 +- frontend/src/pages/business-trip/apply.tsx | 179 ++++++++++-- .../src/pages/business-trip/detail.module.css | 113 ++++++- frontend/src/pages/business-trip/detail.tsx | 275 ++++++++++++++---- .../src/pages/business-trip/list.module.css | 10 +- .../pages/business-trip/select-approver.tsx | 16 +- .../src/pages/business-trip/select-cc.tsx | 6 +- frontend/src/pages/dashboard/index.tsx | 132 +++------ frontend/src/pages/login-simple/index.tsx | 92 ++++++ .../login-simple/login-simple.module.css | 57 ++++ frontend/src/router/index.tsx | 6 +- frontend/src/services/api.ts | 5 + set-token.js | 33 +++ 47 files changed, 1297 insertions(+), 360 deletions(-) delete mode 100644 backend/app/Bootstrap/Database.php delete mode 100644 backend/app/Middleware/AuthMiddleware.php rename backend/app/{Controller => controller}/ApprovalController.php (97%) rename backend/app/{Controller => controller}/AuthController.php (95%) rename backend/app/{Controller => controller}/BaseController.php (98%) rename backend/app/{Controller => controller}/BusinessTripController.php (77%) rename backend/app/{Controller => controller}/UserController.php (97%) rename backend/app/{Model => model}/ApprovalFlow.php (97%) rename backend/app/{Model => model}/ApprovalNode.php (98%) rename backend/app/{Model => model}/ApprovalOrder.php (98%) rename backend/app/{Model => model}/ApprovalRecord.php (98%) rename backend/app/{Model => model}/BusinessTrip.php (99%) rename backend/app/{Model => model}/BusinessTripItinerary.php (98%) rename backend/app/{Model => model}/Company.php (98%) rename backend/app/{Model => model}/CompanyFeishuApp.php (97%) rename backend/app/{Model => model}/User.php (99%) rename backend/app/{Service => service}/ApprovalService.php (98%) rename backend/app/{Service => service}/BusinessTripService.php (62%) rename backend/app/{Service => service}/FeishuService.php (98%) rename backend/app/{Util => util}/Db.php (98%) rename backend/app/{Util => util}/JwtUtil.php (97%) rename backend/app/{Util => util}/Lang.php (99%) delete mode 100644 backend/config/bootstrap.php create mode 100644 frontend/dev-tools.html create mode 100644 frontend/set-token.html create mode 100644 frontend/src/pages/login-simple/index.tsx create mode 100644 frontend/src/pages/login-simple/login-simple.module.css create mode 100644 set-token.js diff --git a/CLAUDE.md b/CLAUDE.md index 35cb2b9..5cd9069 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -251,6 +251,33 @@ const handleSubmit = async () => { - `frontend/src/pages/business-trip/select-cc.tsx` - `frontend/src/pages/business-trip/apply.tsx` +#### 问题3:导航时草稿保存不及时 +**现象:** 用户输入内容后快速点击选择人员,返回时输入的内容丢失 + +**原因:** React 的 useEffect 是异步执行的,用户快速导航时,useEffect 可能还没来得及保存到 sessionStorage + +**解决方案:** 在导航处理函数中立即保存草稿 + +```tsx +const handleSelectApprovers = () => { + // 导航前立即保存草稿 + const draftData = { reason, itineraries } + sessionStorage.setItem('businessTripDraft', JSON.stringify(draftData)) + navigate('/business-trip/apply/select-approver', { state: { selected: approvers, type: 'approver' } }) +} + +const handleSelectCc = () => { + // 导航前立即保存草稿 + const draftData = { reason, itineraries } + sessionStorage.setItem('businessTripDraft', JSON.stringify(draftData)) + navigate('/business-trip/apply/select-cc', { state: { selected: ccUsers, type: 'cc' } }) +} +``` + +**关键点:** +- ✅ 导航前同步保存草稿数据,不依赖 useEffect +- ✅ 保留 useEffect 自动保存作为备份机制(防止用户不导航就离开页面) + ### Ant Design Mobile DatePicker 点击无反应 **问题:** DatePicker 组件使用 children render prop 方式时,点击无法弹出选择框 diff --git a/backend/app/Bootstrap/Database.php b/backend/app/Bootstrap/Database.php deleted file mode 100644 index 268aaed..0000000 --- a/backend/app/Bootstrap/Database.php +++ /dev/null @@ -1,15 +0,0 @@ -header('Authorization'); - $token = str_replace('Bearer ', '', $token); - - // 如果没有 token,返回 401 - if (!$token) { - return json([ - 'code' => 401, - 'message' => '未登录', - 'data' => null, - ]); - } - - // 验证 token - $payload = JwtUtil::decode($token); - if (!$payload) { - return json([ - 'code' => 401, - 'message' => '登录已过期', - 'data' => null, - ]); - } - - // 将用户ID注入到请求中 - $request->userId = (int) ($payload['user_id'] ?? 0); - $request->companyId = (int) ($payload['company_id'] ?? 0); - - return $next($request); - } -} diff --git a/backend/app/Controller/ApprovalController.php b/backend/app/controller/ApprovalController.php similarity index 97% rename from backend/app/Controller/ApprovalController.php rename to backend/app/controller/ApprovalController.php index e55858e..ea88c45 100644 --- a/backend/app/Controller/ApprovalController.php +++ b/backend/app/controller/ApprovalController.php @@ -1,8 +1,8 @@ getUserId($request); + $id = (int) $this->getRouteParam($request, 'id'); + if (!$id) { + return $this->error('参数错误'); + } + + try { + $this->service->deleteDraft($userId, $id); + return $this->success(null, '删除成功'); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 审批操作 + */ + public function approve(Request $request): Response + { + $userId = $this->getUserId($request); + $id = (int) $this->getRouteParam($request, 'id'); + if (!$id) { + return $this->error('参数错误'); + } + + $data = $request->post(); + $data = $data ?? []; + $action = $data['action'] ?? 'approve'; // approve 或 reject + $comment = $data['comment'] ?? ''; + + try { + $result = $this->service->approve($userId, $id, $action, $comment); + return $this->success($result, $action === 'approve' ? '已同意' : '已拒绝'); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + /** * 取消出差申请 */ diff --git a/backend/app/Controller/UserController.php b/backend/app/controller/UserController.php similarity index 97% rename from backend/app/Controller/UserController.php rename to backend/app/controller/UserController.php index 8de999c..1893380 100644 --- a/backend/app/Controller/UserController.php +++ b/backend/app/controller/UserController.php @@ -1,8 +1,8 @@ header('Authorization'); - $token = str_replace('Bearer ', '', $token); - if (!$token || !JwtUtil::verify($token)) { + // 移除 "Bearer " 前缀(如果存在) + if ($token && str_starts_with($token, 'Bearer ')) { + $token = substr($token, 7); + } + + // 如果没有 token,返回 401 + if (!$token) { return json([ 'code' => 401, - 'message' => 'Unauthorized', + 'message' => '未登录', 'data' => null, - ], 401); + ]); } - // 解析 Token 并设置用户信息到请求 + // 验证 token $payload = JwtUtil::decode($token); - if ($payload) { - $request->userId = $payload['user_id'] ?? null; - $request->companyId = $payload['company_id'] ?? null; + if (!$payload) { + return json([ + 'code' => 401, + 'message' => '登录已过期', + 'data' => null, + ]); } - return $handler($request); + // 将用户ID注入到请求中 + $request->userId = (int) ($payload['user_id'] ?? 0); + $request->companyId = (int) ($payload['company_id'] ?? 0); + + return $next($request); } } diff --git a/backend/app/Model/ApprovalFlow.php b/backend/app/model/ApprovalFlow.php similarity index 97% rename from backend/app/Model/ApprovalFlow.php rename to backend/app/model/ApprovalFlow.php index 9150784..ad79961 100644 --- a/backend/app/Model/ApprovalFlow.php +++ b/backend/app/model/ApprovalFlow.php @@ -1,6 +1,6 @@ where('user_id', $userId); break; case 'pending': - // 获取待审批的申请 + // 待我审批:需要查询当前用户是审批人且还未审批的申请 $pendingOrderIds = ApprovalOrder::where('status', ApprovalOrder::STATUS_PENDING) ->where('business_type', 'business_trip') - ->pluck('business_id') + ->pluck('id') ->toArray(); - $query->whereIn('id', $pendingOrderIds); + + if (empty($pendingOrderIds)) { + $query->whereRaw('1 = 0'); // 返回空结果 + } else { + // 获取这些审批单中当前用户待审批的,排除已审批的 + $approvedOrderIds = ApprovalRecord::whereIn('order_id', $pendingOrderIds) + ->where('approver_id', $userId) + ->whereIn('action', ['approve', 'reject']) + ->pluck('order_id') + ->toArray(); + + $pendingOrderIds = array_diff($pendingOrderIds, $approvedOrderIds); + + $query->whereIn('approval_order_id', $pendingOrderIds); + } + break; + case 'approved': + // 我已审批:当前用户已经审批过的申请 + $approvedOrderIds = ApprovalRecord::where('approver_id', $userId) + ->whereIn('action', ['approve', 'reject']) + ->pluck('order_id') + ->toArray(); + + if (empty($approvedOrderIds)) { + $query->whereRaw('1 = 0'); // 返回空结果 + } else { + $query->whereIn('approval_order_id', $approvedOrderIds); + } break; } @@ -194,10 +222,32 @@ class BusinessTripService /** * 获取出差申请详情 */ - public function getDetail(int $id): BusinessTrip + public function getDetail(int $id): array { - return BusinessTrip::with(['user', 'itineraries']) + $businessTrip = BusinessTrip::with(['user', 'itineraries']) ->findOrFail($id); + + $result = $businessTrip->toArray(); + + // 获取审批单信息 + if ($businessTrip->approval_order_id) { + $approvalOrder = ApprovalOrder::with(['records.approver']) + ->find($businessTrip->approval_order_id); + + if ($approvalOrder) { + $result['approval_order'] = $approvalOrder->toArray(); + + // 获取审批人和抄送人信息 + $formData = $approvalOrder->form_data ?? []; + $result['approvers'] = $formData['approvers'] ?? []; + $result['cc_users'] = $formData['cc_users'] ?? []; + + // 获取审批记录 + $result['approval_records'] = $approvalOrder->records->toArray(); + } + } + + return $result; } /** @@ -300,4 +350,113 @@ class BusinessTripService return $businessTrip->fresh(); } + + /** + * 删除草稿 + */ + public function deleteDraft(int $userId, int $id): bool + { + $businessTrip = BusinessTrip::findOrFail($id); + + if ($businessTrip->user_id !== $userId) { + throw new \Exception('无权操作此申请'); + } + + if ($businessTrip->status !== BusinessTrip::STATUS_DRAFT) { + throw new \Exception('只能删除草稿'); + } + + return $this->db->transaction(function () use ($businessTrip) { + // 删除行程明细 + BusinessTripItinerary::where('business_trip_id', $businessTrip->id)->delete(); + + // 删除申请 + return $businessTrip->delete(); + }); + } + + /** + * 审批操作 + */ + public function approve(int $userId, int $businessTripId, string $action, string $comment = ''): BusinessTrip + { + return $this->db->transaction(function () use ($userId, $businessTripId, $action, $comment) { + $businessTrip = BusinessTrip::with(['itineraries', 'approvalOrder'])->findOrFail($businessTripId); + + if (!$businessTrip->approval_order_id) { + throw new \Exception('未找到审批单'); + } + + $approvalOrder = $businessTrip->approvalOrder; + + if ($approvalOrder->status !== ApprovalOrder::STATUS_PENDING) { + throw new \Exception('当前状态无法审批'); + } + + // 检查用户是否是当前审批人 + $currentNodes = $approvalOrder->current_nodes ?? []; + $isApprover = false; + + foreach ($currentNodes as $nodeId) { + $node = ApprovalNode::where('order_id', $approvalOrder->id) + ->where('id', $nodeId) + ->first(); + + if (!$node) { + continue; + } + + // 检查是否是单人审批节点 + if ($node->node_type === 'single_approve') { + $approvers = $node->approvers ?? []; + if (in_array($userId, $approvers)) { + $isApprover = true; + break; + } + } + } + + if (!$isApprover) { + throw new \Exception('您不是当前审批人'); + } + + // 检查是否已经审批过 + $existingRecord = ApprovalRecord::where('order_id', $approvalOrder->id) + ->where('approver_id', $userId) + ->whereIn('action', ['approve', 'reject']) + ->first(); + + if ($existingRecord) { + throw new \Exception('您已经审批过了'); + } + + // 记录审批操作 + ApprovalRecord::create([ + 'order_id' => $approvalOrder->id, + 'node_id' => $currentNodes[0] ?? 1, + 'node_type' => 'single_approve', + 'approver_id' => $userId, + 'action' => $action, + 'comment' => $comment, + ]); + + // 更新审批单状态 + if ($action === 'reject') { + // 拒绝:直接结束流程 + $approvalOrder->status = ApprovalOrder::STATUS_REJECTED; + $approvalOrder->current_nodes = []; + $businessTrip->status = BusinessTrip::STATUS_REJECTED; + } else { + // 同意:检查是否还有后续节点(简化版:直接通过) + $approvalOrder->status = ApprovalOrder::STATUS_APPROVED; + $approvalOrder->current_nodes = []; + $businessTrip->status = BusinessTrip::STATUS_APPROVED; + } + + $approvalOrder->save(); + $businessTrip->save(); + + return $businessTrip->fresh(['itineraries', 'approvalOrder']); + }); + } } diff --git a/backend/app/Service/FeishuService.php b/backend/app/service/FeishuService.php similarity index 98% rename from backend/app/Service/FeishuService.php rename to backend/app/service/FeishuService.php index 3b3d92a..ea6b371 100644 --- a/backend/app/Service/FeishuService.php +++ b/backend/app/service/FeishuService.php @@ -1,11 +1,11 @@ middleware([\app\middleware\AuthMiddleware::class]); + +// 审批路由(需要认证) +Route::group('/api/v1/approval', function () { + Route::get('/list', [app\controller\ApprovalController::class, 'list']); + Route::get('/detail/{id}', [app\controller\ApprovalController::class, 'detail']); + Route::post('/approve/{id}', [app\controller\ApprovalController::class, 'approve']); + Route::post('/reject/{id}', [app\controller\ApprovalController::class, 'reject']); + Route::post('/cancel/{id}', [app\controller\ApprovalController::class, 'cancel']); +})->middleware([\app\middleware\AuthMiddleware::class]); + +// 出差审批路由(需要认证) +Route::group('/api/v1/business-trip', function () { + Route::get('/list', [app\controller\BusinessTripController::class, 'list']); + Route::get('/detail/{id}', [app\controller\BusinessTripController::class, 'detail']); + Route::post('/create', [app\controller\BusinessTripController::class, 'create']); + Route::post('/update/{id}', [app\controller\BusinessTripController::class, 'update']); + Route::post('/submit/{id}', [app\controller\BusinessTripController::class, 'submit']); + Route::post('/cancel/{id}', [app\controller\BusinessTripController::class, 'cancel']); + Route::delete('/{id}', [app\controller\BusinessTripController::class, 'deleteDraft']); + Route::post('/approve/{id}', [app\controller\BusinessTripController::class, 'approve']); +})->middleware([\app\middleware\AuthMiddleware::class]); + // 默认路由 Route::fallback(function () { return json(['code' => 404, 'message' => 'Not Found']); diff --git a/backend/scripts/import_old_data.php b/backend/scripts/import_old_data.php index 4cd3b43..87bc841 100644 --- a/backend/scripts/import_old_data.php +++ b/backend/scripts/import_old_data.php @@ -11,7 +11,7 @@ require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../app/Util/Db.php'; -use App\Util\Db; +use app\util\Db; class DataImporter { diff --git a/frontend/dev-tools.html b/frontend/dev-tools.html new file mode 100644 index 0000000..c150370 --- /dev/null +++ b/frontend/dev-tools.html @@ -0,0 +1,74 @@ + + + + + Token 设置工具 + + + +

🔧 开发调试工具

+ +
+

步骤 1:复制 Token

+
+ + +
+ +
+

步骤 2:设置 Token

+
    +
  1. 按 F12 打开开发者工具
  2. +
  3. 切换到 Console 标签
  4. +
  5. 粘贴并执行:
    localStorage.setItem('token', 刚才复制的token)
  6. +
+
+ +
+

步骤 3:验证

+ +
+
+ + + + diff --git a/frontend/set-token.html b/frontend/set-token.html new file mode 100644 index 0000000..1c9b334 --- /dev/null +++ b/frontend/set-token.html @@ -0,0 +1,101 @@ + + + + + + 设置 Token + + + +
+

🎯 设置登录 Token

+
+ 点击下方按钮设置登录凭证,然后关闭此页面并刷新主页面。 +
+ +
+
+ + + + diff --git a/frontend/src/components/layout/MobileLayout.tsx b/frontend/src/components/layout/MobileLayout.tsx index 0dbcd01..9d6de77 100644 --- a/frontend/src/components/layout/MobileLayout.tsx +++ b/frontend/src/components/layout/MobileLayout.tsx @@ -10,7 +10,7 @@ export default function MobileLayout() { const mainRef = useRef(null) const showHeader = location.pathname !== '/login' - const showTabBar = !location.pathname.includes('/business-trip/') + const showTabBar = false // 暂时隐藏底部 Tab // 路由变化时滚动到顶部 useEffect(() => { diff --git a/frontend/src/components/layout/TabBar.tsx b/frontend/src/components/layout/TabBar.tsx index e00a810..b64f82c 100644 --- a/frontend/src/components/layout/TabBar.tsx +++ b/frontend/src/components/layout/TabBar.tsx @@ -1,5 +1,5 @@ import { useNavigate, useLocation } from 'react-router-dom' -import { AppOutline, AppstoreOutline, UserOutline } from 'antd-mobile-icons' +import { AppOutline, AppstoreOutline } from 'antd-mobile-icons' import { useTranslation } from 'react-i18next' import styles from './TabBar.module.css' @@ -13,7 +13,6 @@ interface TabItem { const tabs: TabItem[] = [ { key: 'home', path: '/dashboard', icon: , labelKey: 'tab.home' }, { key: 'apps', path: '/apps', icon: , labelKey: 'tab.apps' }, - { key: 'profile', path: '/profile', icon: , labelKey: 'tab.profile' }, ] export default function TabBar() { diff --git a/frontend/src/index.css b/frontend/src/index.css index 736250f..3b66a8f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -68,3 +68,55 @@ select { padding-bottom: env(safe-area-inset-bottom); } } + +/* Dialog 组件层级修复 - 确保在所有固定定位元素之上 */ +/* Dialog 容器 - 最高优先级 */ +.adm-dialog { + position: fixed !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; + z-index: 99999 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; +} + +/* Dialog 遮罩 - 确保在容器内但有正确的层级 */ +.adm-dialog .adm-dialog-mask { + position: absolute !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; + z-index: 1 !important; + background-color: rgba(0, 0, 0, 0.45) !important; +} + +/* Dialog 内容容器 - 在遮罩之上 */ +.adm-dialog .adm-dialog-content { + position: relative !important; + z-index: 2 !important; +} + +/* Dialog 内部元素 - 确保在内容容器之上 */ +.adm-dialog .adm-dialog-body, +.adm-dialog .adm-dialog-header, +.adm-dialog .adm-dialog-footer, +.adm-dialog .adm-button { + position: relative !important; + z-index: 3 !important; +} + +/* 处理 antd-mobile 5.x 的实际 DOM 结构 */ +/* antd-mobile 5.x 的 Dialog 可能直接渲染到 body 下 */ +body > .adm-dialog { + z-index: 99999 !important; +} + +/* 确保 mask 不会覆盖 dialog 内容 */ +.adm-dialog-mask ~ .adm-dialog-content { + position: relative !important; + z-index: 10 !important; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 8f63160..7d84477 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -8,6 +8,7 @@ import 'antd-mobile/es/global' import 'antd-mobile/es/components/popup/popup.css' import 'antd-mobile/es/components/mask/mask.css' import 'antd-mobile/es/components/date-picker/date-picker.css' +import 'antd-mobile/es/components/dialog/dialog.css' import router from './router' import { initRem } from './utils/rem' import './i18n' diff --git a/frontend/src/pages/business-trip/apply.module.css b/frontend/src/pages/business-trip/apply.module.css index e9dc709..8b80fd5 100644 --- a/frontend/src/pages/business-trip/apply.module.css +++ b/frontend/src/pages/business-trip/apply.module.css @@ -155,10 +155,13 @@ z-index: 10000 !important; } -:global(.adm-mask) { +/* 只影响 Popup 的 mask,不影响 Dialog */ +:global(.adm-popup .adm-mask) { z-index: 9999 !important; } +/* Dialog 样式在 index.css 中统一设置 */ + /* Selector 组件样式覆盖 */ .field :global(.adm-selector) { flex: 1; @@ -407,7 +410,7 @@ box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06); display: flex; gap: 12px; - z-index: 9999; + z-index: 100; } .saveBtn, diff --git a/frontend/src/pages/business-trip/apply.tsx b/frontend/src/pages/business-trip/apply.tsx index 170f2b7..eb4ec2f 100644 --- a/frontend/src/pages/business-trip/apply.tsx +++ b/frontend/src/pages/business-trip/apply.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useEffect } from 'react' +import { useState, useMemo, useEffect, useRef } from 'react' import { useNavigate, useLocation } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { DatePicker, Input, Toast, Dialog, TextArea, Selector } from 'antd-mobile' @@ -65,31 +65,118 @@ export default function BusinessTripApply() { const [approvers, setApprovers] = useState([]) const [ccUsers, setCcUsers] = useState([]) + // 手动保存草稿的函数 + const saveDraft = () => { + const draftData = { + reason, + itineraries, + } + sessionStorage.setItem('businessTripDraft', JSON.stringify(draftData)) + console.log('💾 Manually saved draft:', { reason, itineraries }) + } + // DatePicker 可见性状态 const [datePickerVisible, setDatePickerVisible] = useState<{ index: number field: 'startTime' | 'endTime' } | null>(null) - // 处理从选择页面返回的数据 + // 使用 ref 来跟踪是否已经恢复过草稿 + const hasRestoredRef = useRef(false) + + // 处理从选择页面返回的数据和草稿恢复 useEffect(() => { - // 检查 sessionStorage 中是否有选择的数据 - const approversData = sessionStorage.getItem('selectedApprovers') - const ccData = sessionStorage.getItem('selectedCc') + const fromSelection = location.state?.fromSelection - // 从 sessionStorage 恢复状态(组件重新挂载时需要) - if (approversData) { - const approvers = JSON.parse(approversData) as ApproverItem[] - setApprovers(approvers) - console.log('✅ Restored approvers from sessionStorage:', approvers) + console.log('📍 Page loaded/updated:', { + fromSelection, + path: location.pathname + }) + + // 恢复草稿数据(每次组件挂载都需要恢复) + const draftData = sessionStorage.getItem('businessTripDraft') + console.log('📦 Draft data in sessionStorage:', draftData) + + if (draftData) { + try { + const draft = JSON.parse(draftData) + console.log('📄 Parsed draft:', draft) + + // 恢复理由 + if (draft.reason) { + console.log('✏️ Restoring reason:', draft.reason) + setReason(draft.reason) + } + + // 恢复行程数据 + if (draft.itineraries && draft.itineraries.length > 0) { + const restoredItineraries = draft.itineraries.map((item: any) => ({ + ...item, + startTime: item.startTime ? new Date(item.startTime) : null, + endTime: item.endTime ? new Date(item.endTime) : null, + })) + console.log('🛤️ Restoring itineraries:', restoredItineraries) + setItineraries(restoredItineraries) + } + + // 有草稿数据,也尝试恢复人员数据 + if (!fromSelection) { + const approversData = sessionStorage.getItem('selectedApprovers') + if (approversData) { + const approvers = JSON.parse(approversData) as ApproverItem[] + setApprovers(approvers) + console.log('✅ Restored approvers (with draft):', approvers) + } + + const ccData = sessionStorage.getItem('selectedCc') + if (ccData) { + const cc = JSON.parse(ccData) as ApproverItem[] + setCcUsers(cc) + console.log('✅ Restored CC (with draft):', cc) + } + } + } catch (error) { + console.error('❌ Failed to restore draft:', error) + } + } else { + console.log('⚠️ No draft data found in sessionStorage') + // 没有草稿数据,清除旧的人员选择数据(开始新申请) + if (!fromSelection) { + console.log('🧹 Clearing old selection data (new application)') + sessionStorage.removeItem('selectedApprovers') + sessionStorage.removeItem('selectedCc') + setApprovers([]) + setCcUsers([]) + } } - if (ccData) { - const cc = JSON.parse(ccData) as ApproverItem[] - setCcUsers(cc) - console.log('✅ Restored CC from sessionStorage:', cc) + // 从选择页面返回,恢复所有人员数据 + if (fromSelection) { + console.log('🔄 Returning from selection page') + + const type = location.state?.type + console.log('📋 Selection type:', type) + + // 恢复所有人员数据(审批人 + 抄送人) + const approversData = sessionStorage.getItem('selectedApprovers') + if (approversData) { + const approvers = JSON.parse(approversData) as ApproverItem[] + setApprovers(approvers) + console.log('✅ Restored approvers from sessionStorage:', approvers) + } else { + setApprovers([]) + } + + const ccData = sessionStorage.getItem('selectedCc') + if (ccData) { + const cc = JSON.parse(ccData) as ApproverItem[] + setCcUsers(cc) + console.log('✅ Restored CC from sessionStorage:', cc) + } else { + setCcUsers([]) + } } - }, []) // 只在组件挂载时执行一次 + }, [location.state, location.pathname]) // 计算总时长(累加所有行程的天数) const totalDuration = useMemo(() => { @@ -102,7 +189,7 @@ export default function BusinessTripApply() { }, [itineraries]) const handleAddItinerary = () => { - setItineraries([...itineraries, { + const newItineraries = [...itineraries, { startTime: null, endTime: null, departure: '', @@ -110,7 +197,9 @@ export default function BusinessTripApply() { transport: '', tripType: '' as 'oneway' | 'roundtrip' | '', remark: '' - }]) + }] + setItineraries(newItineraries) + saveDraft() } const handleRemoveItinerary = (index: number) => { @@ -118,24 +207,54 @@ export default function BusinessTripApply() { Toast.show('至少保留一条行程') return } - setItineraries(itineraries.filter((_, i) => i !== index)) + const newItineraries = itineraries.filter((_, i) => i !== index) + setItineraries(newItineraries) + saveDraft() } const handleUpdateItinerary = (index: number, field: keyof ItineraryItem, value: any) => { const updated = [...itineraries] updated[index] = { ...updated[index], [field]: value } setItineraries(updated) + saveDraft() } const handleSelectApprovers = () => { + console.log('🚀 Navigating to select approvers') + saveDraft() // 导航前保存草稿 + + // 如果草稿中没有实际内容(空理由且没有有效行程),清除旧的人员数据 + const hasContent = reason.trim() !== '' || itineraries.some(item => item.startTime || item.destination.trim() !== '') + if (!hasContent) { + sessionStorage.removeItem('selectedApprovers') + sessionStorage.removeItem('selectedCc') + console.log('🧹 Cleared old selection data (no content)') + } + navigate('/business-trip/apply/select-approver', { state: { selected: approvers, type: 'approver' } }) } const handleSelectCc = () => { + console.log('🚀 Navigating to select CC') + saveDraft() // 导航前保存草稿 + + // 如果草稿中没有实际内容(空理由且没有有效行程),清除旧的人员数据 + const hasContent = reason.trim() !== '' || itineraries.some(item => item.startTime || item.destination.trim() !== '') + if (!hasContent) { + sessionStorage.removeItem('selectedApprovers') + sessionStorage.removeItem('selectedCc') + console.log('🧹 Cleared old selection data (no content)') + } + navigate('/business-trip/apply/select-cc', { state: { selected: ccUsers, type: 'cc' } }) } const handleSave = async () => { + // 移除当前焦点,避免按钮保持高亮状态 + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } + try { // 验证必填字段 if (!reason.trim()) { @@ -170,6 +289,7 @@ export default function BusinessTripApply() { // 保存成功后清除 sessionStorage sessionStorage.removeItem('selectedApprovers') sessionStorage.removeItem('selectedCc') + sessionStorage.removeItem('businessTripDraft') Toast.show('草稿已保存') navigate('/business-trip') @@ -179,6 +299,11 @@ export default function BusinessTripApply() { } const handleSubmit = () => { + // 移除当前焦点,避免按钮保持高亮状态 + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } + if (!reason.trim()) { Toast.show(t('businessTrip.pleaseEnterReason')) return @@ -197,6 +322,8 @@ export default function BusinessTripApply() { Dialog.confirm({ content: t('businessTrip.confirmSubmit'), + confirmText: '确认', + cancelText: '取消', onConfirm: async () => { try { // 准备行程数据 @@ -228,6 +355,7 @@ export default function BusinessTripApply() { // 提交成功后清除 sessionStorage sessionStorage.removeItem('selectedApprovers') sessionStorage.removeItem('selectedCc') + sessionStorage.removeItem('businessTripDraft') Toast.show(t('businessTrip.submitSuccess')) navigate('/business-trip') @@ -248,7 +376,10 @@ export default function BusinessTripApply() {