docs: 整理项目问题文档,修复审批列表数据混乱
- 新增 PROJECT_ISSUES.md 统一管理所有问题和解决方案 - 精简 CLAUDE.md,移除重复的问题记录部分 - 修复审批列表数据混乱问题(React 18 StrictMode + 状态管理) - 修复首次加载不显示数据问题(requestIdRef 清理时机) - 修复页码初始值错误(hasMore 默认值) - 修复后端响应序列化问题(Eloquent 模型转数组) - 添加审批人和抄送人重复校验 - 修复双 header 问题 - 调整申请页面 padding-bottom Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -197,203 +197,20 @@ docker logs x-server-backend -f
|
|||||||
2. frpc 将 8080 暴露到公网
|
2. frpc 将 8080 暴露到公网
|
||||||
3. 飞书回调地址配置为公网域名
|
3. 飞书回调地址配置为公网域名
|
||||||
|
|
||||||
## 已知问题和解决方案
|
### Webman 框架要点
|
||||||
|
|
||||||
### 页面间状态丢失问题 ⚠️ 重要!
|
**PSR-4 自动加载**
|
||||||
|
|
||||||
#### 问题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
|
|
||||||
// ❌ 错误用法 - 点击无反应
|
|
||||||
<DatePicker value={value} onConfirm={setValue}>
|
|
||||||
{(value) => <button>{value ? '已选择' : '请选择'}</button>}
|
|
||||||
</DatePicker>
|
|
||||||
|
|
||||||
// ✅ 正确用法 - 使用 visible 属性
|
|
||||||
const [visible, setVisible] = useState(false)
|
|
||||||
|
|
||||||
<div onClick={() => setVisible(true)}>
|
|
||||||
{value ? '已选择' : '请选择'}
|
|
||||||
</div>
|
|
||||||
<DatePicker
|
|
||||||
visible={visible}
|
|
||||||
onConfirm={(val) => {
|
|
||||||
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
|
|
||||||
<Selector showCheckMark={false} options={options} />
|
|
||||||
```
|
|
||||||
|
|
||||||
### PSR-4 自动加载
|
|
||||||
- 目录名必须是 PascalCase(Controller、Model、Service、Util)
|
- 目录名必须是 PascalCase(Controller、Model、Service、Util)
|
||||||
- 不能用小写(controller、model、service、util)
|
|
||||||
|
|
||||||
### React 版本
|
**数据库连接**
|
||||||
- 必须使用 React 18.3.1(不能用19)
|
|
||||||
- @types/react 和 @types/react-dom 也要用 v18
|
|
||||||
|
|
||||||
### 数据库连接
|
|
||||||
- 使用 Webman 官方 `webman/database` 包
|
- 使用 Webman 官方 `webman/database` 包
|
||||||
- 使用 `support\Db` facade 进行数据库操作
|
- 使用 `support\Db` facade 进行数据库操作
|
||||||
- 使用 `Db::connection()` 获取数据库连接进行事务处理
|
- 使用 `Db::connection()` 获取数据库连接进行事务处理
|
||||||
|
|
||||||
### Bootstrap 接口
|
**Bootstrap 接口**
|
||||||
- 方法签名:`public static function start(?\Workerman\Worker $worker): void`
|
- 方法签名:`public static function start(?\Workerman\Worker $worker): void`
|
||||||
- 需要添加 `use Workerman\Worker;`
|
|
||||||
|
|
||||||
### Webman 官方依赖包
|
**官方依赖包**
|
||||||
```bash
|
```bash
|
||||||
composer require workerman/webman:^2.0
|
composer require workerman/webman:^2.0
|
||||||
composer require webman/database:^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
|
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)` 方法设置状态
|
- 登录时必须调用 `login(user, token)` 方法设置状态
|
||||||
- Token 存储在 localStorage,key 为 `token`
|
- Token 存储在 localStorage,key 为 `token`
|
||||||
- 登录页面必须正确初始化 Zustand store,否则后续页面获取不到用户信息
|
|
||||||
|
|
||||||
### 类型定义注意
|
**类型定义**
|
||||||
- `User.id` 是 `number` 类型(后端返回整型)
|
- `User.id` 是 `number` 类型(后端返回整型)
|
||||||
- `User.company_id` 是 `number` 类型(数据库字段名)
|
- `User.company_id` 是 `number` 类型(数据库字段名)
|
||||||
|
|
||||||
### Docker 容器重启
|
|
||||||
- CMD 使用 `php start.php start`(不要用 `-d`)
|
|
||||||
- 前台运行,避免容器退出
|
|
||||||
|
|
||||||
## 语言切换位置
|
## 语言切换位置
|
||||||
|
|
||||||
1. **首页** - 右上角显示当前语言国旗,点击切换
|
1. **首页** - 右上角显示当前语言国旗,点击切换
|
||||||
@@ -442,3 +263,39 @@ composer require symfony/var-dumper:^7.0
|
|||||||
2. 实现公司切换功能
|
2. 实现公司切换功能
|
||||||
3. 完善权限控制
|
3. 完善权限控制
|
||||||
4. 添加更多业务类型(费用报销、请假申请、采购申请)
|
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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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<string | null>(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 字段
|
||||||
@@ -38,14 +38,19 @@ class ApprovalController extends BaseController
|
|||||||
$page = (int) $request->get('page', 1);
|
$page = (int) $request->get('page', 1);
|
||||||
$pageSize = (int) $request->get('page_size', 20);
|
$pageSize = (int) $request->get('page_size', 20);
|
||||||
|
|
||||||
// 添加日志
|
try {
|
||||||
error_log("Approval list request: userId={$userId}, type={$type}, page={$page}");
|
|
||||||
|
|
||||||
$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']));
|
// 使用 json() 全局函数返回 JSON
|
||||||
|
return json([
|
||||||
return $this->success($result);
|
'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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -183,10 +183,12 @@ class ApprovalService
|
|||||||
switch ($type) {
|
switch ($type) {
|
||||||
case 'my':
|
case 'my':
|
||||||
$query->where('applicant_id', $userId);
|
$query->where('applicant_id', $userId);
|
||||||
|
error_log("🔍 Debug 'my' query: userId={$userId}");
|
||||||
break;
|
break;
|
||||||
case 'pending':
|
case 'pending':
|
||||||
// 获取用户待审批的订单
|
// 获取用户待审批的订单
|
||||||
$pendingOrderIds = $this->getPendingOrderIds($userId);
|
$pendingOrderIds = $this->getPendingOrderIds($userId);
|
||||||
|
error_log("🔍 Debug 'pending' query: userId={$userId}, pendingOrderIds=" . json_encode($pendingOrderIds));
|
||||||
$query->whereIn('id', $pendingOrderIds);
|
$query->whereIn('id', $pendingOrderIds);
|
||||||
break;
|
break;
|
||||||
case 'approved':
|
case 'approved':
|
||||||
@@ -197,19 +199,43 @@ class ApprovalService
|
|||||||
->pluck('order_id')
|
->pluck('order_id')
|
||||||
->unique()
|
->unique()
|
||||||
->toArray();
|
->toArray();
|
||||||
|
error_log("🔍 Debug 'approved' query: userId={$userId}, approvedOrderIds=" . json_encode($approvedOrderIds));
|
||||||
$query->whereIn('id', $approvedOrderIds)
|
$query->whereIn('id', $approvedOrderIds)
|
||||||
->whereIn('status', [ApprovalOrder::STATUS_APPROVED, ApprovalOrder::STATUS_REJECTED, ApprovalOrder::STATUS_CANCELLED]);
|
->whereIn('status', [ApprovalOrder::STATUS_APPROVED, ApprovalOrder::STATUS_REJECTED, ApprovalOrder::STATUS_CANCELLED]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$total = $query->count();
|
$total = $query->count();
|
||||||
|
error_log("🔍 Debug total count: type={$type}, total={$total}");
|
||||||
|
|
||||||
$list = $query->orderBy('created_at', 'desc')
|
$list = $query->orderBy('created_at', 'desc')
|
||||||
->offset(($page - 1) * $pageSize)
|
->offset(($page - 1) * $pageSize)
|
||||||
->limit($pageSize)
|
->limit($pageSize)
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
error_log("🔍 Debug list count: type={$type}, page={$page}, count=" . $list->count());
|
||||||
|
|
||||||
|
// 转换为数组避免序列化问题
|
||||||
|
$listArray = $list->map(function ($item) {
|
||||||
return [
|
return [
|
||||||
'list' => $list,
|
'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,
|
'total' => $total,
|
||||||
'page' => $page,
|
'page' => $page,
|
||||||
'page_size' => $pageSize,
|
'page_size' => $pageSize,
|
||||||
|
|||||||
@@ -103,6 +103,26 @@ class BusinessTripService
|
|||||||
throw new \Exception('当前状态无法提交');
|
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')
|
$flow = ApprovalFlow::where('business_type', 'business_trip')
|
||||||
->where('status', 1)
|
->where('status', 1)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default function MobileLayout() {
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const mainRef = useRef<HTMLDivElement>(null)
|
const mainRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const showHeader = location.pathname !== '/login'
|
const showHeader = false // 所有页面都不显示 Header
|
||||||
const showTabBar = false // 暂时隐藏底部 Tab
|
const showTabBar = false // 暂时隐藏底部 Tab
|
||||||
|
|
||||||
// 路由变化时滚动到顶部
|
// 路由变化时滚动到顶部
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { List, Card, Tag, Tabs, PullToRefresh, InfiniteScroll } from 'antd-mobile'
|
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 [activeTab, setActiveTab] = useState<'my' | 'pending' | 'approved'>('my')
|
||||||
const [list, setList] = useState<ApprovalItem[]>([])
|
const [list, setList] = useState<ApprovalItem[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [hasMore, setHasMore] = useState(true)
|
const [hasMore, setHasMore] = useState(false) // 改为 false,避免挂载时立即触发加载更多
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
const currentTabRef = useRef(activeTab) // 用于追踪当前有效的 tab
|
||||||
|
const loadingRef = useRef(false) // 用于同步追踪 loading 状态
|
||||||
|
const requestIdRef = useRef<string | null>(null) // 用于追踪当前请求 ID
|
||||||
|
const mountedRef = useRef(true) // 组件挂载时立即设为 true
|
||||||
|
|
||||||
|
// 更新 ref 当 activeTab 变化时
|
||||||
|
useEffect(() => {
|
||||||
|
currentTabRef.current = activeTab
|
||||||
|
}, [activeTab])
|
||||||
|
|
||||||
// 加载数据 - 接收 tab 参数避免使用旧的 activeTab
|
// 加载数据 - 接收 tab 参数避免使用旧的 activeTab
|
||||||
const loadData = async (pageNum: number = 1, isRefresh: boolean = false, tab?: string) => {
|
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
|
// 使用传入的 tab 参数,如果没有则使用当前 activeTab
|
||||||
const currentTab = 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)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const result = await approvalApi.getList({
|
const result = await approvalApi.getList({
|
||||||
@@ -68,21 +93,36 @@ export default function ApprovalList() {
|
|||||||
page_size: 20,
|
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 || []
|
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)
|
setList(newList)
|
||||||
} else {
|
|
||||||
setList([...list, ...newList])
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasMore(newList.length >= 20)
|
setHasMore(newList.length >= 20)
|
||||||
setPage(pageNum)
|
setPage(pageNum)
|
||||||
|
} else {
|
||||||
|
console.log('⏭️ Skipping update - tab changed from', currentTab, 'to', currentTabRef.current)
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to load approvals:', error)
|
console.error('Failed to load approvals:', error)
|
||||||
} finally {
|
} finally {
|
||||||
|
loadingRef.current = false
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,6 +130,7 @@ export default function ApprovalList() {
|
|||||||
// 切换标签
|
// 切换标签
|
||||||
const handleTabChange = (key: string) => {
|
const handleTabChange = (key: string) => {
|
||||||
console.log('🔄 Tab changing from', activeTab, 'to', key)
|
console.log('🔄 Tab changing from', activeTab, 'to', key)
|
||||||
|
currentTabRef.current = key as 'my' | 'pending' | 'approved' // 立即更新 ref
|
||||||
setActiveTab(key as 'my' | 'pending' | 'approved')
|
setActiveTab(key as 'my' | 'pending' | 'approved')
|
||||||
setList([])
|
setList([])
|
||||||
setHasMore(true)
|
setHasMore(true)
|
||||||
@@ -100,17 +141,26 @@ export default function ApprovalList() {
|
|||||||
|
|
||||||
// 刷新
|
// 刷新
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
await loadData(1, true)
|
await loadData(1, true, activeTab)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载更多
|
// 加载更多
|
||||||
const handleLoadMore = async () => {
|
const handleLoadMore = async () => {
|
||||||
await loadData(page + 1, false)
|
await loadData(page + 1, false, activeTab)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始加载
|
// 初始加载
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
mountedRef.current = true
|
||||||
|
// 强制使用页码 1,忽略当前 page 状态
|
||||||
|
console.log('🚀 Initial load effect, forcing page=1')
|
||||||
loadData(1, true)
|
loadData(1, true)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log('🧹 Cleanup function called')
|
||||||
|
mountedRef.current = false
|
||||||
|
// 不清空 requestIdRef,让异步请求能够正常完成处理
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 查看详情
|
// 查看详情
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f5f5f5;
|
background: #f5f5f5;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
padding-bottom: 80px;
|
padding-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form {
|
.form {
|
||||||
|
|||||||
@@ -96,8 +96,23 @@ export const businessTripApi = {
|
|||||||
|
|
||||||
// ========== 审批 API ==========
|
// ========== 审批 API ==========
|
||||||
export const approvalApi = {
|
export const approvalApi = {
|
||||||
getList: (params: { type?: string; page?: number; page_size?: number }) =>
|
getList: (params: { type?: string; page?: number; page_size?: number }) => {
|
||||||
api.get('/approval/list', { params }),
|
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}`),
|
getDetail: (id: number) => api.get(`/approval/detail/${id}`),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user