diff --git a/PROJECT_ISSUES.md b/PROJECT_ISSUES.md index 9ded337..c38c63e 100644 --- a/PROJECT_ISSUES.md +++ b/PROJECT_ISSUES.md @@ -31,27 +31,73 @@ const handleTabChange = (key: string) => { --- -## 2. 列表首次加载不显示数据问题 +## 2. 列表首次加载不显示数据问题(多次修复) **问题**:首次进入审批列表时,"我发起的"不显示数据,需要切换其他 tab 再切换回来才显示 -**原因**: +**原因(版本1)**: - React 18 StrictMode 导致组件快速 mount/unmount -- useEffect cleanup 函数中 `requestIdRef.current = null` 清空了请求 ID -- 正在进行的 API 请求返回时,`requestIdRef.current` 已经是 null,导致响应被跳过 +- useEffect cleanup 函数中清空了 requestIdRef,导致正在进行的 API 请求响应被跳过 -**解决方案**: +**原因(版本2 - 最终问题)**: +- 使用 `loadingRef.current` 检查是否正在加载 +- React 18 StrictMode 导致 useEffect 执行两次 +- 第一次请求设置 `loadingRef.current = true` +- 第二次请求检查 `loadingRef.current` 为 true,被阻塞 +- 导致第二次请求没有发送,数据无法加载 + +**解决方案(最终版本)**: ```typescript +// 移除 loadingRef,只使用 requestId 追踪请求 +const requestIdRef = useRef('') + +const loadData = async (pageNum: number = 1, isRefresh: boolean = false, tab?: string) => { + // 生成唯一的请求 ID(包含随机数避免冲突) + const requestId = `${tab || activeTab}-${pageNum}-${Date.now()}-${Math.random()}` + requestIdRef.current = requestId + + const currentTab = tab || activeTab + + setLoading(true) + + try { + const result = await approvalApi.getList({ + type: currentTab, + page: pageNum, + page_size: 20, + }) + + // 检查是否是最新的请求 + if (requestIdRef.current !== requestId) { + return // 跳过过期响应 + } + + const newList = result?.list || [] + setList(newList) + setHasMore(newList.length >= 20) + setPage(pageNum) + } catch (error: any) { + console.error('Failed to load approvals:', error) + } finally { + setLoading(false) + } +} + +// useEffect cleanup 不做任何操作 useEffect(() => { loadData(1, true) - return () => { - mountedRef.current = false - // 不清空 requestIdRef,让异步请求能够正常完成处理 + // 不清空任何 ref,让异步请求能够正常完成处理 } }, []) ``` +**关键点**: +- ❌ 不要使用 `loadingRef` 检查来阻塞请求 +- ❌ 不要在 cleanup 中清空 `requestIdRef` +- ✅ 只使用 `requestId` 来标识和忽略过期响应 +- ✅ 在 requestId 中加入随机数避免 React 18 StrictMode 下的冲突 + **相关文件**:`frontend/src/pages/approval/list.tsx` --- @@ -340,6 +386,61 @@ const loadData = async (tab?: string) => { ### 20. Docker 容器 CMD **使用前台运行**:`php start.php start`(不要用 `-d`),避免容器退出 +### 21. 发起人也是审批人时"待我审批"不显示 + +**问题**:用户提交申请后,既是发起人也是审批人,但"待我审批"中不显示该订单 + +**原因**: +- `BusinessTripService::submit()` 中硬编码 `current_nodes => [1]` +- 但数据库中节点 1 的审批人是其他用户(如用户 2) +- 提交申请时没有根据用户选择的审批人创建/更新节点 + +**解决方案**: +```php +// 根据审批人创建审批节点 +$approverIds = array_column($approvers, 'id'); +$node = ApprovalNode::where('flow_id', $flow->id) + ->where('order', 0) + ->first(); + +if (!$node) { + // 创建第一个审批节点 + $node = ApprovalNode::create([ + 'flow_id' => $flow->id, + 'type' => count($approverIds) > 1 ? 'and_approve' : 'approver', + 'name' => '审批', + 'order' => 0, + 'approvers' => $approverIds, + ]); +} else { + // 更新现有节点的审批人 + $node->approvers = $approverIds; + $node->save(); +} + +// 使用创建的节点 ID +$approvalOrder = ApprovalOrder::create([ + // ... + 'current_nodes' => [$node->id], + // ... +]); +``` + +**相关文件**:`backend/app/service/BusinessTripService.php` + +### 22. 申请提交后跳转到错误的页面 + +**问题**:提交申请后跳转到出差列表 (`/business-trip`),而不是审批列表 (`/approval`) + +**解决方案**: +```typescript +// 修改申请页面跳转逻辑 +Toast.show(t('businessTrip.submitSuccess')) +navigate('/approval') // 改为跳转到审批列表 +``` + +**相关文件**:`frontend/src/pages/business-trip/apply.tsx` + --- ## 文件修改记录 diff --git a/backend/app/service/BusinessTripService.php b/backend/app/service/BusinessTripService.php index 1f16ff2..a96cc14 100644 --- a/backend/app/service/BusinessTripService.php +++ b/backend/app/service/BusinessTripService.php @@ -123,7 +123,7 @@ class BusinessTripService throw new \Exception('审批人和抄送人不能是同一个人'); } - // 获取审批流程(简化版:直接使用第一个审批人作为单人审批) + // 获取审批流程 $flow = ApprovalFlow::where('business_type', 'business_trip') ->where('status', 1) ->first(); @@ -138,6 +138,49 @@ class BusinessTripService ]); } + // 根据审批人创建审批节点 + $approverIds = array_column($approvers, 'id'); + $node = ApprovalNode::where('flow_id', $flow->id) + ->where('order', 0) + ->first(); + + if (!$node) { + // 创建第一个审批节点 + $node = ApprovalNode::create([ + 'flow_id' => $flow->id, + 'type' => count($approverIds) > 1 ? 'and_approve' : 'approver', + 'name' => '审批', + 'order' => 0, + 'approvers' => $approverIds, + ]); + } else { + // 更新现有节点的审批人 + $node->approvers = $approverIds; + $node->save(); + } + + // 创建抄送节点(如果有抄送人) + $ccUserIds = array_column($ccUsers, 'id'); + if (!empty($ccUserIds)) { + $ccNode = ApprovalNode::where('flow_id', $flow->id) + ->where('type', 'notify') + ->where('order', 1) + ->first(); + + if (!$ccNode) { + ApprovalNode::create([ + 'flow_id' => $flow->id, + 'type' => 'notify', + 'name' => '抄送', + 'order' => 1, + 'approvers' => $ccUserIds, + ]); + } else { + $ccNode->approvers = $ccUserIds; + $ccNode->save(); + } + } + // 创建审批单 $approvalOrder = ApprovalOrder::create([ 'business_type' => 'business_trip', @@ -145,7 +188,7 @@ class BusinessTripService 'title' => '出差申请 - ' . $businessTrip->user->name, 'applicant_id' => $userId, 'status' => ApprovalOrder::STATUS_PENDING, - 'current_nodes' => [1], // 简化:使用节点1 + 'current_nodes' => [$node->id], // 使用创建的节点 ID 'flow_id' => $flow->id, 'form_data' => [ 'reason' => $businessTrip->reason, @@ -165,8 +208,8 @@ class BusinessTripService // 记录提交操作 ApprovalRecord::create([ 'order_id' => $approvalOrder->id, - 'node_id' => 1, - 'node_type' => 'single_approve', + 'node_id' => $node->id, + 'node_type' => $node->type, 'approver_id' => $userId, 'action' => 'submit', 'comment' => null, diff --git a/frontend/src/pages/approval/list.tsx b/frontend/src/pages/approval/list.tsx index 6366337..13c5e8f 100644 --- a/frontend/src/pages/approval/list.tsx +++ b/frontend/src/pages/approval/list.tsx @@ -49,31 +49,19 @@ 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(false) // 改为 false,避免挂载时立即触发加载更多 + const [hasMore, setHasMore] = useState(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]) + const requestIdRef = useRef('') - // 加载数据 - 接收 tab 参数避免使用旧的 activeTab + // 加载数据 - 接收 tab 参数 const loadData = async (pageNum: number = 1, isRefresh: boolean = false, tab?: string) => { - // 使用 ref 检查 loading,避免 React 18 StrictMode 双重调用 - if (loadingRef.current) { - console.log('⏸️ Request blocked - already loading') - return - } - - // 使用传入的 tab 参数,如果没有则使用当前 activeTab - const currentTab = tab || activeTab - const requestId = `${currentTab}-${pageNum}-${Date.now()}` + // 生成新的请求 ID + const requestId = `${tab || activeTab}-${pageNum}-${Date.now()}-${Math.random()}` requestIdRef.current = requestId + const currentTab = tab || activeTab + console.log('🔍 Loading approval list START:', { requestId, currentTab, @@ -81,11 +69,11 @@ export default function ApprovalList() { tab, pageNum, isRefresh, - currentTabRef: currentTabRef.current + requestIdRefNow: requestIdRef.current }) - loadingRef.current = true setLoading(true) + try { const result = await approvalApi.getList({ type: currentTab, @@ -95,34 +83,30 @@ export default function ApprovalList() { console.log('📦 API response received:', { requestId, - type: currentTab, - count: result?.list?.length, - currentTabRefNow: currentTabRef.current + currentTab, + requestIdRefNow: requestIdRef.current, + count: result?.list?.length }) // 检查是否是最新的请求 if (requestIdRef.current !== requestId) { - console.log('⏭️ Skipping outdated response:', { requestId, latestRequestId: requestIdRef.current }) + console.log('⏭️ Skipping outdated response:', { + requestId, + latestRequestId: requestIdRef.current + }) return } - console.log('📦 API response validated - processing data') - const newList = result?.list || [] - // 只在当前 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 { - console.log('⏭️ Skipping update - tab changed from', currentTab, 'to', currentTabRef.current) - } + // 直接更新数据 + console.log('✅ Updating list for tab:', currentTab, 'items:', newList.length) + setList(newList) + setHasMore(newList.length >= 20) + setPage(pageNum) } catch (error: any) { - console.error('Failed to load approvals:', error) + console.error('❌ Failed to load approvals:', error) } finally { - loadingRef.current = false setLoading(false) } } @@ -130,12 +114,11 @@ 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) + setHasMore(false) setPage(1) - // 直接传入新的 tab 值,避免使用旧的 activeTab + // 直接传入新的 tab 值 loadData(1, true, key) } @@ -151,15 +134,11 @@ export default function ApprovalList() { // 初始加载 useEffect(() => { - mountedRef.current = true - // 强制使用页码 1,忽略当前 page 状态 - console.log('🚀 Initial load effect, forcing page=1') + console.log('🚀 Initial load effect') loadData(1, true) return () => { console.log('🧹 Cleanup function called') - mountedRef.current = false - // 不清空 requestIdRef,让异步请求能够正常完成处理 } }, []) diff --git a/frontend/src/pages/business-trip/apply.tsx b/frontend/src/pages/business-trip/apply.tsx index df7850e..dedb630 100644 --- a/frontend/src/pages/business-trip/apply.tsx +++ b/frontend/src/pages/business-trip/apply.tsx @@ -270,7 +270,7 @@ export default function BusinessTripApply() { sessionStorage.removeItem('businessTripDraft') Toast.show('草稿已保存') - navigate('/business-trip') + navigate('/approval') } catch (error: any) { Toast.show(error.message || '保存失败') } @@ -336,7 +336,7 @@ export default function BusinessTripApply() { sessionStorage.removeItem('businessTripDraft') Toast.show(t('businessTrip.submitSuccess')) - navigate('/business-trip') + navigate('/approval') } catch (error: any) { Toast.show(error.message || '提交失败') }