diff --git a/CLAUDE.md b/CLAUDE.md index a0cabfb..cc23bca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -197,203 +197,20 @@ docker logs x-server-backend -f 2. frpc 将 8080 暴露到公网 3. 飞书回调地址配置为公网域名 -## 已知问题和解决方案 +### Webman 框架要点 -### 页面间状态丢失问题 ⚠️ 重要! - -#### 问题1:navigate(-1) 不传递 state -**现象:** 使用 `navigate(-1, { state })` 返回上一页时,`location.state` 为 `null` - -**原因:** React Router 在使用 `navigate(-1)` 时,`state` 参数不可靠 - -**解决方案:** 使用 sessionStorage 传递数据 - -#### 问题2:多次选择导致状态丢失 -**现象:** -- 选择审批人后,再选择抄送人,审批人状态丢失 -- 先选择抄送人,再选择审批人,抄送人状态丢失 - -**原因:** -- 每次导航时,目标页面组件会重新挂载 -- 重新挂载时,所有 useState 都会重置为初始值 -- sessionStorage 在第一次读取后被清除,第二次挂载时无数据可恢复 - -**解决方案:** -1. **使用独立的 sessionStorage key**(不要共用一个 key) -2. **不在 useEffect 中立即清除 sessionStorage** -3. **每次挂载时从 sessionStorage 恢复状态** -4. **只在业务操作完成后才清除 sessionStorage**(提交、保存等) - -**正确实现:** - -```tsx -// 选择页面 - 使用独立的 key -const handleConfirm = () => { - if (type === 'approver') { - sessionStorage.setItem('selectedApprovers', JSON.stringify(selectedUsers)) - } else if (type === 'cc') { - sessionStorage.setItem('selectedCc', JSON.stringify(selectedUsers)) - } - navigate(-1) -} - -// 申请页面 - 每次挂载时恢复状态 -useEffect(() => { - const approversData = sessionStorage.getItem('selectedApprovers') - const ccData = sessionStorage.getItem('selectedCc') - - if (approversData) { - setApprovers(JSON.parse(approversData)) - } - if (ccData) { - setCcUsers(JSON.parse(ccData)) - } -}, []) - -// 提交/保存成功后清除 -const handleSubmit = async () => { - // ... 提交逻辑 ... - sessionStorage.removeItem('selectedApprovers') - sessionStorage.removeItem('selectedCc') - navigate('/business-trip') -} -``` - -**关键点:** -- ✅ 不同类型数据使用独立的 sessionStorage key -- ✅ 不在 useEffect 中清除 sessionStorage -- ✅ 每次挂载都恢复状态,确保组件重新挂载时状态不丢失 -- ✅ 只在业务操作完成后清除 sessionStorage - -**相关文件:** -- `frontend/src/pages/business-trip/select-approver.tsx` -- `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 自动保存作为备份机制(防止用户不导航就离开页面) - -### 申请页面完整数据流 - -申请页面(如出差申请)需要处理三种场景: - -1. **首次进入(新建申请)** - - 清除旧的 sessionStorage 数据 - - 加载默认审批人/抄送人 - - 初始化空表单 - -2. **从选择页面返回** - - 从 sessionStorage 恢复草稿数据 - - 从 sessionStorage 恢复人员数据(审批人/抄送人) - - 日期字符串需要转换为 Date 对象 - -3. **提交/保存成功** - - 清除所有 sessionStorage 数据 - - 导航回列表页 - -**实现要点:** -- 使用 `location.state?.fromSelection` 标记返回状态 -- 使用独立的 sessionStorage key:`businessTripDraft`, `selectedApprovers`, `selectedCc` -- 导航前立即保存草稿,不依赖 useEffect -- 日期对象序列化/反序列化处理 - -**相关文件:** -- `frontend/src/pages/business-trip/apply.tsx` - 申请页面主逻辑 -- `frontend/src/pages/business-trip/select-approver.tsx` - 审批人选择 -- `frontend/src/pages/business-trip/select-cc.tsx` - 抄送人选择 - -### Ant Design Mobile DatePicker 点击无反应 -**问题:** DatePicker 组件使用 children render prop 方式时,点击无法弹出选择框 - -**原因:** antd-mobile 5.x 的 DatePicker children render prop 点击事件绑定存在问题 - -**解决方案:** 使用 `visible` 属性手动控制弹框显示 - -```tsx -// ❌ 错误用法 - 点击无反应 - - {(value) => } - - -// ✅ 正确用法 - 使用 visible 属性 -const [visible, setVisible] = useState(false) - -
setVisible(true)}> - {value ? '已选择' : '请选择'} -
- { - setValue(val) - setVisible(false) - }} - onClose={() => setVisible(false)} - onCancel={() => setVisible(false)} -/> -``` - -**相关文件:** -- `frontend/src/main.tsx` - 需要导入 `import 'antd-mobile/es/global'` -- `frontend/src/pages/business-trip/apply.tsx` - 正确使用示例 - -### Ant Design Mobile 全局样式导入 -**必须导入全局样式:** -```tsx -import 'antd-mobile/es/global' -``` - -否则弹框、遮罩等组件将无法正常显示。 - -### Selector 组件对号图标隐藏 -**问题:** Selector 组件选中时显示对号图标和背景色,想要只保留背景色 - -**解决方案:** 使用 `showCheckMark` 属性 -```tsx - -``` - -### PSR-4 自动加载 +**PSR-4 自动加载** - 目录名必须是 PascalCase(Controller、Model、Service、Util) -- 不能用小写(controller、model、service、util) -### React 版本 -- 必须使用 React 18.3.1(不能用19) -- @types/react 和 @types/react-dom 也要用 v18 - -### 数据库连接 +**数据库连接** - 使用 Webman 官方 `webman/database` 包 - 使用 `support\Db` facade 进行数据库操作 - 使用 `Db::connection()` 获取数据库连接进行事务处理 -### Bootstrap 接口 +**Bootstrap 接口** - 方法签名:`public static function start(?\Workerman\Worker $worker): void` -- 需要添加 `use Workerman\Worker;` -### Webman 官方依赖包 +**官方依赖包** ```bash composer require workerman/webman:^2.0 composer require webman/database:^2.0 @@ -403,20 +220,24 @@ composer require illuminate/events:^11.0 composer require symfony/var-dumper:^7.0 ``` -### User Store 状态管理 -- 使用 Zustand with persistence +### 前端框架要点 + +**React 版本** +- 必须使用 React 18.3.1(不能用19) +- @types/react 和 @types/react-dom 也要用 v18 + +**Ant Design Mobile** +- 必须导入全局样式:`import 'antd-mobile/es/global'` +- DatePicker 使用 `visible` 属性控制弹框显示 + +**Zustand 状态管理** - 登录时必须调用 `login(user, token)` 方法设置状态 - Token 存储在 localStorage,key 为 `token` -- 登录页面必须正确初始化 Zustand store,否则后续页面获取不到用户信息 -### 类型定义注意 +**类型定义** - `User.id` 是 `number` 类型(后端返回整型) - `User.company_id` 是 `number` 类型(数据库字段名) -### Docker 容器重启 -- CMD 使用 `php start.php start`(不要用 `-d`) -- 前台运行,避免容器退出 - ## 语言切换位置 1. **首页** - 右上角显示当前语言国旗,点击切换 @@ -442,3 +263,39 @@ composer require symfony/var-dumper:^7.0 2. 实现公司切换功能 3. 完善权限控制 4. 添加更多业务类型(费用报销、请假申请、采购申请) + +--- + +## 快速参考 + +### 查看问题汇总 +所有历史问题已整理在项目根目录的 `PROJECT_ISSUES.md` 文件中。 + +### 让 CLAUDE 加载问题文档 +``` +请参考 /www/x-server-mobile/PROJECT_ISSUES.md 中的问题汇总 +``` + +### 调试后端 +```bash +# 查看日志 +docker logs x-server-backend -f --tail 50 + +# 重启后端 +docker compose restart backend + +# 复制文件到容器 +docker cp host_file x-server-backend:/app/app/service/xxx.php + +# 重载 workers(不重启整个服务) +docker exec x-server-backend php /app/webman reload +``` + +### 数据库操作 +```bash +# 进入 MySQL 容器 +docker exec -it 1Panel-mysql-409s mysql -uroot -pmysql_xEsdPa xserver + +# 运行测试数据脚本 +docker exec x-server-backend php /app/test_data.php +``` diff --git a/PROJECT_ISSUES.md b/PROJECT_ISSUES.md new file mode 100644 index 0000000..9ded337 --- /dev/null +++ b/PROJECT_ISSUES.md @@ -0,0 +1,366 @@ +# X-Server Mobile 项目问题汇总 + +本文档记录项目开发过程中遇到的问题和解决方案,便于 CLAUDE 后续参考。 + +--- + +## 1. 审批列表数据混乱问题 + +**问题**:审批列表的三个 tab(我发起的、待我审批、我已审批)显示的数据不正确 + +**原因**: +- 前端状态管理问题:`setActiveTab` 是异步的,`loadData` 函数使用旧状态值 +- React 18 StrictMode 导致 useEffect 执行两次,造成重复请求和状态混乱 + +**解决方案**: +```typescript +// 在 loadData 中添加 tab 参数,直接传递目标 tab 值 +const loadData = async (pageNum: number = 1, isRefresh: boolean = false, tab?: string) => { + const currentTab = tab || activeTab // 使用传入的 tab 参数 + // ... +} + +// 切换 tab 时直接传递新值 +const handleTabChange = (key: string) => { + setActiveTab(key as 'my' | 'pending' | 'approved') + loadData(1, true, key) // 直接传递 key +} +``` + +**相关文件**:`frontend/src/pages/approval/list.tsx` + +--- + +## 2. 列表首次加载不显示数据问题 + +**问题**:首次进入审批列表时,"我发起的"不显示数据,需要切换其他 tab 再切换回来才显示 + +**原因**: +- React 18 StrictMode 导致组件快速 mount/unmount +- useEffect cleanup 函数中 `requestIdRef.current = null` 清空了请求 ID +- 正在进行的 API 请求返回时,`requestIdRef.current` 已经是 null,导致响应被跳过 + +**解决方案**: +```typescript +useEffect(() => { + loadData(1, true) + + return () => { + mountedRef.current = false + // 不清空 requestIdRef,让异步请求能够正常完成处理 + } +}, []) +``` + +**相关文件**:`frontend/src/pages/approval/list.tsx` + +--- + +## 3. 页码初始值导致请求错误数据 + +**问题**:前端请求 page=2 而不是 page=1 + +**原因**: +- `InfiniteScroll` 组件在挂载时触发 `loadMore`,因为 `hasMore` 初始值是 `true` + +**解决方案**: +```typescript +const [hasMore, setHasMore] = useState(false) // 改为 false,避免挂载时立即触发加载更多 +``` + +**相关文件**:`frontend/src/pages/approval/list.tsx` + +--- + +## 4. 后端响应序列化问题 + +**问题**:后端返回数据时发生错误,进程不断重启 + +**原因**: +- Eloquent 模型对象存在循环引用,JSON 序列化失败 + +**解决方案**: +```php +// 在 ApprovalService::getList() 中将模型对象转换为纯数组 +$listArray = $list->map(function ($item) { + return [ + 'id' => $item->id, + 'title' => $item->title, + 'business_type' => $item->business_type, + 'status' => $item->status, + 'created_at' => $item->created_at ? $item->created_at->toDateTimeString() : null, + 'applicant' => $item->applicant ? [ + 'id' => $item->applicant->id, + 'name' => $item->applicant->name, + ] : null, + 'flow' => $item->flow ? [ + 'id' => $item->flow->id, + 'name' => $item->flow->name, + ] : null, + ]; +})->toArray(); + +return [ + 'list' => $listArray, + 'total' => $total, + 'page' => $page, + 'page_size' => $pageSize, +]; +``` + +**相关文件**:`backend/app/service/ApprovalService.php` + +--- + +## 5. 审批人和抄送人重复校验 + +**问题**:提交申请时,审批人和抄送人可能存在重复 + +**解决方案**: +```php +// 在 BusinessTripService::submit() 中添加校验 +$approverIds = array_column($approvers, 'id'); +$ccUserIds = array_column($ccUsers, 'id'); + +// 检查审批人列表中是否有重复 +if (count($approverIds) !== count(array_unique($approverIds))) { + throw new \Exception('审批人列表中有重复用户'); +} + +// 检查抄送人列表中是否有重复 +if (count($ccUserIds) !== count(array_unique($ccUserIds))) { + throw new \Exception('抄送人列表中有重复用户'); +} + +// 检查审批人和抄送人是否有重叠 +$overlap = array_intersect($approverIds, $ccUserIds); +if (!empty($overlap)) { + throw new \Exception('审批人和抄送人不能是同一个人'); +} +``` + +**相关文件**:`backend/app/service/BusinessTripService.php` + +--- + +## 6. 测试数据节点配置错误 + +**问题**:测试数据与实际审批节点配置不匹配 + +**解决方案**: +- 修正数据库中订单的 `current_nodes` 字段,使用正确的节点 ID +- 确保节点 ID 与实际审批人匹配 + +**相关数据库表**:`approval_node`, `approval_order` + +--- + +## 7. 双 header 问题 + +**问题**:部分页面有两个 header(MobileLayout 的 Header + 页面的 NavBar) + +**解决方案**: +```typescript +// 在 MobileLayout.tsx 中全局隐藏 Header +const showHeader = false +``` + +**相关文件**:`frontend/src/components/layout/MobileLayout.tsx` + +--- + +## 8. 申请页面 padding-bottom 过大 + +**问题**:申请页面底部留白太大 + +**解决方案**: +```css +.apply { + padding-bottom: 1.5rem; /* 从 80px 改为 1.5rem */ +} +``` + +**相关文件**:`frontend/src/pages/business-trip/apply.module.css` + +--- + +## 9. 前端文件路径大小写问题 + +**问题**:容器内使用小写目录 `service`,而本地编辑器可能是大写 `Service` + +**解决方案**: +- 注意容器内路径为 `/app/app/service/`(小写) +- 编辑文件后需要复制到容器并重启服务 + +--- + +## 10. React 状态管理最佳实践 + +### 使用 ref 追踪异步状态 +```typescript +const loadingRef = useRef(false) // 同步状态,避免闭包陷阱 +const requestIdRef = useRef(null) // 追踪请求 ID +``` + +### useEffect 依赖清理 +```typescript +useEffect(() => { + // 不要在 cleanup 中清空正在使用的 ref + return () => { + mountedRef.current = false + // requestIdRef.current = null // ❌ 不要清空,让异步请求正常完成 + } +}, []) +``` + +### 函数参数优先于状态 +```typescript +// ❌ 错误:使用可能过期的状态 +const loadData = async () => { + await api.get('/list', { type: activeTab }) // activeTab 可能是旧值 +} + +// ✅ 正确:直接传递参数 +const loadData = async (tab?: string) => { + const currentTab = tab || activeTab + await api.get('/list', { type: currentTab }) +} +``` + +--- + +## 开发注意事项 + +1. **数据库密码**:mysql_xEsdPa +2. **数据库名称**:xserver(不是 x_server_mobile) +3. **MySQL 容器名**:1Panel-mysql-409s +4. **后端容器内路径**:/app/(不是 /www/x-server-mobile/backend/) +5. **调试后端**: + - 编辑文件后需要复制到容器:`docker cp host_file container:/app/path` + - 重启后端:`docker compose restart backend` + - 或重载 workers:`docker exec x-server-backend php /app/webman reload` +6. **查看日志**:`docker logs x-server-backend -f --tail 50` +7. **React 18 StrictMode**:开发模式下 useEffect 会执行两次,需要注意副作用 + +--- + +## 历史问题汇总(早期开发阶段) + +### 1. 缺少 webman/database 包 +**解决方案**:`composer require webman/database:^2.0` + +### 2. 登录状态没有正确设置到 Zustand store +**解决方案**:在登录成功后调用 `login(result.data.user, result.data.token)` + +### 3. User 类型定义中 id 类型错误 +**解决方案**:将 `id: string` 改为 `id: number` + +### 4. 选择审批人后表单数据丢失 +**原因**:每次导航时组件重新挂载,sessionStorage 在第一次读取后被清除 +**解决方案**:使用独立的 sessionStorage key,不在 useEffect 中清除 + +### 5. DatePicker 点击无反应 +**原因**:antd-mobile 5.x 的 DatePicker children render prop 点击事件绑定有问题 +**解决方案**:使用 `visible` 属性手动控制弹框显示 + +### 6. 审批列表"我已审批"过滤逻辑错误 +**问题**:显示了所有已审批的订单,包括还在审批中的 +**解决方案**:只显示已完成状态的订单(approved、rejected、cancelled) + +### 7. Dialog 按钮不显示 +**问题**:使用 `confirmText`/`cancelText` 参数无效 +**解决方案**:使用 `actions` prop 自定义按钮 + +### 8. React key 重复警告 +**问题**:审批人和抄送人的 avatar 使用相同 key +**解决方案**:添加前缀区分 `approver-${id}` 和 `cc-${id}` + +### 9. 飞书登录回调地址 +**解决方案**:更新为 https://xsm.dev.hnxinyouhe.com/login + +### 10. 审批节点数据库重复 +**解决方案**:删除重复节点 + +### 11. 页面间状态丢失 +**问题**:`navigate(-1)` 不传递 state,`location.state` 为 null +**解决方案**:使用 sessionStorage 传递数据 + +### 12. sessionStorage 使用规范 +**问题**:多次选择导致状态丢失 +**关键点**: +- 不同类型数据使用独立的 sessionStorage key +- 不在 useEffect 中清除 sessionStorage +- 每次挂载时从 sessionStorage 恢复状态 +- 只在业务操作完成后清除 sessionStorage(提交、保存等) + +### 13. 导航前草稿保存不及时 +**解决方案**:在导航处理函数中立即保存草稿 + +### 14. 申请页面完整数据流 + +申请页面(如出差申请)需要处理三种场景: + +**场景1:首次进入(新建申请)** +- 清除旧的 sessionStorage 数据 +- 加载默认审批人/抄送人 +- 初始化空表单 + +**场景2:从选择页面返回** +- 从 sessionStorage 恢复草稿数据 +- 从 sessionStorage 恢复人员数据(审批人/抄送人) +- 日期字符串需要转换为 Date 对象 + +**场景3:提交/保存成功** +- 清除所有 sessionStorage 数据 +- 导航回列表页 + +**实现要点:** +- 使用 `location.state?.fromSelection` 标记返回状态 +- 使用独立的 sessionStorage key:`businessTripDraft`, `selectedApprovers`, `selectedCc` +- 导航前立即保存草稿,不依赖 useEffect +- 日期对象序列化/反序列化处理 + +**相关文件:** +- `frontend/src/pages/business-trip/apply.tsx` - 申请页面主逻辑 +- `frontend/src/pages/business-trip/select-approver.tsx` - 审批人选择 +- `frontend/src/pages/business-trip/select-cc.tsx` - 抄送人选择 + +### 16. Ant Design Mobile 全局样式 +**必须导入**:`import 'antd-mobile/es/global'`,否则弹框、遮罩等组件无法正常显示 + +### 17. Selector 组件隐藏对号图标 +**解决方案**:使用 `showCheckMark={false}` 属性 + +### 18. Bootstrap 接口签名 +**方法签名**:`public static function start(?\Workerman\Worker $worker): void` + +### 19. PSR-4 自动加载 +**目录名必须是 PascalCase**(Controller、Model、Service、Util) + +### 20. Docker 容器 CMD +**使用前台运行**:`php start.php start`(不要用 `-d`),避免容器退出 + +--- + +## 文件修改记录 + +### 前端文件 +- `frontend/src/pages/approval/list.tsx` - 修复状态管理和请求逻辑 +- `frontend/src/components/layout/MobileLayout.tsx` - 隐藏 Header +- `frontend/src/pages/business-trip/apply.module.css` - 调整 padding-bottom +- `frontend/src/pages/business-trip/apply.tsx` - 修复 key 重复、草稿保存 +- `frontend/src/pages/approval/detail.tsx` - Dialog 按钮修复、字段标签中文显示 +- `frontend/src/services/api.ts` - 添加请求日志 +- `frontend/src/main.tsx` - 导入 antd-mobile 全局样式 + +### 后端文件 +- `backend/app/service/BusinessTripService.php` - 添加重复校验 +- `backend/app/service/ApprovalService.php` - 数据序列化修复 +- `backend/app/controller/ApprovalController.php` - 响应返回修复 +- `backend/app/model/ApprovalOrder.php` - 模型定义 +- `backend/config/database.php` - 数据库配置 +- `backend/config/route.php` - 路由配置 + +### 数据库 +- `approval_node` 表 - 删除重复节点 +- `approval_order` 表 - 修正 current_nodes 字段 diff --git a/backend/app/controller/ApprovalController.php b/backend/app/controller/ApprovalController.php index 9573b19..bd2d352 100644 --- a/backend/app/controller/ApprovalController.php +++ b/backend/app/controller/ApprovalController.php @@ -38,14 +38,19 @@ class ApprovalController extends BaseController $page = (int) $request->get('page', 1); $pageSize = (int) $request->get('page_size', 20); - // 添加日志 - error_log("Approval list request: userId={$userId}, type={$type}, page={$page}"); + try { + $result = $this->service->getList($userId, $type, $page, $pageSize); - $result = $this->service->getList($userId, $type, $page, $pageSize); - - error_log("Approval list result: count=" . count($result['list'])); - - return $this->success($result); + // 使用 json() 全局函数返回 JSON + return json([ + 'code' => 0, + 'message' => 'success', + 'data' => $result, + ]); + } catch (\Exception $e) { + error_log("❌ Error in list controller: " . $e->getMessage() . "\n" . $e->getTraceAsString()); + return $this->error($e->getMessage()); + } } /** diff --git a/backend/app/service/ApprovalService.php b/backend/app/service/ApprovalService.php index fc5ff17..a808891 100644 --- a/backend/app/service/ApprovalService.php +++ b/backend/app/service/ApprovalService.php @@ -183,10 +183,12 @@ class ApprovalService switch ($type) { case 'my': $query->where('applicant_id', $userId); + error_log("🔍 Debug 'my' query: userId={$userId}"); break; case 'pending': // 获取用户待审批的订单 $pendingOrderIds = $this->getPendingOrderIds($userId); + error_log("🔍 Debug 'pending' query: userId={$userId}, pendingOrderIds=" . json_encode($pendingOrderIds)); $query->whereIn('id', $pendingOrderIds); break; case 'approved': @@ -197,19 +199,43 @@ class ApprovalService ->pluck('order_id') ->unique() ->toArray(); + error_log("🔍 Debug 'approved' query: userId={$userId}, approvedOrderIds=" . json_encode($approvedOrderIds)); $query->whereIn('id', $approvedOrderIds) ->whereIn('status', [ApprovalOrder::STATUS_APPROVED, ApprovalOrder::STATUS_REJECTED, ApprovalOrder::STATUS_CANCELLED]); break; } $total = $query->count(); + error_log("🔍 Debug total count: type={$type}, total={$total}"); + $list = $query->orderBy('created_at', 'desc') ->offset(($page - 1) * $pageSize) ->limit($pageSize) ->get(); + error_log("🔍 Debug list count: type={$type}, page={$page}, count=" . $list->count()); + + // 转换为数组避免序列化问题 + $listArray = $list->map(function ($item) { + return [ + 'id' => $item->id, + 'title' => $item->title, + 'business_type' => $item->business_type, + 'status' => $item->status, + 'created_at' => $item->created_at ? $item->created_at->toDateTimeString() : null, + 'applicant' => $item->applicant ? [ + 'id' => $item->applicant->id, + 'name' => $item->applicant->name, + ] : null, + 'flow' => $item->flow ? [ + 'id' => $item->flow->id, + 'name' => $item->flow->name, + ] : null, + ]; + })->toArray(); + return [ - 'list' => $list, + 'list' => $listArray, 'total' => $total, 'page' => $page, 'page_size' => $pageSize, diff --git a/backend/app/service/BusinessTripService.php b/backend/app/service/BusinessTripService.php index 0ec8a9f..1f16ff2 100644 --- a/backend/app/service/BusinessTripService.php +++ b/backend/app/service/BusinessTripService.php @@ -103,6 +103,26 @@ class BusinessTripService throw new \Exception('当前状态无法提交'); } + // 验证审批人和抄送人不能重复 + $approverIds = array_column($approvers, 'id'); + $ccUserIds = array_column($ccUsers, 'id'); + + // 检查审批人列表中是否有重复 + if (count($approverIds) !== count(array_unique($approverIds))) { + throw new \Exception('审批人列表中有重复用户'); + } + + // 检查抄送人列表中是否有重复 + if (count($ccUserIds) !== count(array_unique($ccUserIds))) { + throw new \Exception('抄送人列表中有重复用户'); + } + + // 检查审批人和抄送人是否有重叠 + $overlap = array_intersect($approverIds, $ccUserIds); + if (!empty($overlap)) { + throw new \Exception('审批人和抄送人不能是同一个人'); + } + // 获取审批流程(简化版:直接使用第一个审批人作为单人审批) $flow = ApprovalFlow::where('business_type', 'business_trip') ->where('status', 1) diff --git a/frontend/src/components/layout/MobileLayout.tsx b/frontend/src/components/layout/MobileLayout.tsx index 9d6de77..696fe3e 100644 --- a/frontend/src/components/layout/MobileLayout.tsx +++ b/frontend/src/components/layout/MobileLayout.tsx @@ -9,7 +9,7 @@ export default function MobileLayout() { const location = useLocation() const mainRef = useRef(null) - const showHeader = location.pathname !== '/login' + const showHeader = false // 所有页面都不显示 Header const showTabBar = false // 暂时隐藏底部 Tab // 路由变化时滚动到顶部 diff --git a/frontend/src/pages/approval/list.tsx b/frontend/src/pages/approval/list.tsx index 61f5f6c..6366337 100644 --- a/frontend/src/pages/approval/list.tsx +++ b/frontend/src/pages/approval/list.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { List, Card, Tag, Tabs, PullToRefresh, InfiniteScroll } from 'antd-mobile' @@ -49,17 +49,42 @@ export default function ApprovalList() { const [activeTab, setActiveTab] = useState<'my' | 'pending' | 'approved'>('my') const [list, setList] = useState([]) const [loading, setLoading] = useState(false) - const [hasMore, setHasMore] = useState(true) + const [hasMore, setHasMore] = useState(false) // 改为 false,避免挂载时立即触发加载更多 const [page, setPage] = useState(1) + const currentTabRef = useRef(activeTab) // 用于追踪当前有效的 tab + const loadingRef = useRef(false) // 用于同步追踪 loading 状态 + const requestIdRef = useRef(null) // 用于追踪当前请求 ID + const mountedRef = useRef(true) // 组件挂载时立即设为 true + + // 更新 ref 当 activeTab 变化时 + useEffect(() => { + currentTabRef.current = activeTab + }, [activeTab]) // 加载数据 - 接收 tab 参数避免使用旧的 activeTab const loadData = async (pageNum: number = 1, isRefresh: boolean = false, tab?: string) => { - if (loading) return + // 使用 ref 检查 loading,避免 React 18 StrictMode 双重调用 + if (loadingRef.current) { + console.log('⏸️ Request blocked - already loading') + return + } // 使用传入的 tab 参数,如果没有则使用当前 activeTab const currentTab = tab || activeTab - console.log('🔍 Loading approval list:', { currentTab, activeTab, tab, pageNum, isRefresh }) + const requestId = `${currentTab}-${pageNum}-${Date.now()}` + requestIdRef.current = requestId + console.log('🔍 Loading approval list START:', { + requestId, + currentTab, + activeTab, + tab, + pageNum, + isRefresh, + currentTabRef: currentTabRef.current + }) + + loadingRef.current = true setLoading(true) try { const result = await approvalApi.getList({ @@ -68,21 +93,36 @@ export default function ApprovalList() { page_size: 20, }) - console.log('📦 API response:', { type: currentTab, count: result?.list?.length, result }) + console.log('📦 API response received:', { + requestId, + type: currentTab, + count: result?.list?.length, + currentTabRefNow: currentTabRef.current + }) + + // 检查是否是最新的请求 + if (requestIdRef.current !== requestId) { + console.log('⏭️ Skipping outdated response:', { requestId, latestRequestId: requestIdRef.current }) + return + } + + console.log('📦 API response validated - processing data') const newList = result?.list || [] - if (isRefresh || pageNum === 1) { + // 只在当前 tab 与请求的 tab 一致时才更新数据 + if (currentTabRef.current === currentTab) { + console.log('✅ Updating list for tab:', currentTab, 'items:', newList.length) setList(newList) + setHasMore(newList.length >= 20) + setPage(pageNum) } else { - setList([...list, ...newList]) + console.log('⏭️ Skipping update - tab changed from', currentTab, 'to', currentTabRef.current) } - - setHasMore(newList.length >= 20) - setPage(pageNum) } catch (error: any) { console.error('Failed to load approvals:', error) } finally { + loadingRef.current = false setLoading(false) } } @@ -90,6 +130,7 @@ export default function ApprovalList() { // 切换标签 const handleTabChange = (key: string) => { console.log('🔄 Tab changing from', activeTab, 'to', key) + currentTabRef.current = key as 'my' | 'pending' | 'approved' // 立即更新 ref setActiveTab(key as 'my' | 'pending' | 'approved') setList([]) setHasMore(true) @@ -100,17 +141,26 @@ export default function ApprovalList() { // 刷新 const handleRefresh = async () => { - await loadData(1, true) + await loadData(1, true, activeTab) } // 加载更多 const handleLoadMore = async () => { - await loadData(page + 1, false) + await loadData(page + 1, false, activeTab) } // 初始加载 useEffect(() => { + mountedRef.current = true + // 强制使用页码 1,忽略当前 page 状态 + console.log('🚀 Initial load effect, forcing page=1') loadData(1, true) + + return () => { + console.log('🧹 Cleanup function called') + mountedRef.current = false + // 不清空 requestIdRef,让异步请求能够正常完成处理 + } }, []) // 查看详情 diff --git a/frontend/src/pages/business-trip/apply.module.css b/frontend/src/pages/business-trip/apply.module.css index 8b80fd5..e65faf9 100644 --- a/frontend/src/pages/business-trip/apply.module.css +++ b/frontend/src/pages/business-trip/apply.module.css @@ -2,7 +2,7 @@ min-height: 100vh; background: #f5f5f5; padding: 12px; - padding-bottom: 80px; + padding-bottom: 1.5rem; } .form { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index f9f099f..28a6752 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -96,8 +96,23 @@ export const businessTripApi = { // ========== 审批 API ========== export const approvalApi = { - getList: (params: { type?: string; page?: number; page_size?: number }) => - api.get('/approval/list', { params }), + getList: (params: { type?: string; page?: number; page_size?: number }) => { + console.log('🚀 API Request approval/list:', params) + return api.get('/approval/list', { params }) + .then(res => { + console.log('✅ API Response approval/list:', { + requestParams: params, + responseData: res, + listCount: res?.list?.length, + listIds: res?.list?.map((item: any) => ({ id: item.id, title: item.title, status: item.status })) + }) + return res + }) + .catch(err => { + console.error('❌ API Error approval/list:', err) + throw err + }) + }, getDetail: (id: number) => api.get(`/approval/detail/${id}`),