文件结构优化: - docs/ 归档所有项目文档(PROJECT.md, PROJECT_ISSUES.md 等) - deploy/ 归档所有部署相关文件(docker-compose.prod.yml, nginx 配置等) - scripts/ 归档脚本文件(deploy.sh, rollback.sh) - tests/ 归档测试文件(test_data.php) 更新引用路径: - CLAUDE.md 更新文档路径引用 - 部署文档更新配置文件路径引用 清理文件: - 移除非必要的测试文件(TestController, 测试脚本等) - 移除重复/错误的目录 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
34 KiB
X-Server Mobile 项目问题汇总
本文档记录项目开发过程中遇到的问题和解决方案,便于 CLAUDE 后续参考。
1. 审批列表数据混乱问题
问题:审批列表的三个 tab(我发起的、待我审批、我已审批)显示的数据不正确
原因:
- 前端状态管理问题:
setActiveTab是异步的,loadData函数使用旧状态值 - React 18 StrictMode 导致 useEffect 执行两次,造成重复请求和状态混乱
解决方案:
// 在 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 再切换回来才显示
原因(版本1):
- React 18 StrictMode 导致组件快速 mount/unmount
- useEffect cleanup 函数中清空了 requestIdRef,导致正在进行的 API 请求响应被跳过
原因(版本2 - 最终问题):
- 使用
loadingRef.current检查是否正在加载 - React 18 StrictMode 导致 useEffect 执行两次
- 第一次请求设置
loadingRef.current = true - 第二次请求检查
loadingRef.current为 true,被阻塞 - 导致第二次请求没有发送,数据无法加载
解决方案(最终版本):
// 移除 loadingRef,只使用 requestId 追踪请求
const requestIdRef = useRef<string>('')
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 () => {
// 不清空任何 ref,让异步请求能够正常完成处理
}
}, [])
关键点:
- ❌ 不要使用
loadingRef检查来阻塞请求 - ❌ 不要在 cleanup 中清空
requestIdRef - ✅ 只使用
requestId来标识和忽略过期响应 - ✅ 在 requestId 中加入随机数避免 React 18 StrictMode 下的冲突
相关文件:frontend/src/pages/approval/list.tsx
3. 页码初始值导致请求错误数据
问题:前端请求 page=2 而不是 page=1
原因:
InfiniteScroll组件在挂载时触发loadMore,因为hasMore初始值是true
解决方案:
const [hasMore, setHasMore] = useState(false) // 改为 false,避免挂载时立即触发加载更多
相关文件:frontend/src/pages/approval/list.tsx
4. 后端响应序列化问题
问题:后端返回数据时发生错误,进程不断重启
原因:
- Eloquent 模型对象存在循环引用,JSON 序列化失败
解决方案:
// 在 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. 审批人和抄送人重复校验
问题:提交申请时,审批人和抄送人可能存在重复
解决方案:
// 在 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)
解决方案:
// 在 MobileLayout.tsx 中全局隐藏 Header
const showHeader = false
相关文件:frontend/src/components/layout/MobileLayout.tsx
8. 申请页面 padding-bottom 过大
问题:申请页面底部留白太大
解决方案:
.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 追踪异步状态
const loadingRef = useRef(false) // 同步状态,避免闭包陷阱
const requestIdRef = useRef<string | null>(null) // 追踪请求 ID
useEffect 依赖清理
useEffect(() => {
// 不要在 cleanup 中清空正在使用的 ref
return () => {
mountedRef.current = false
// requestIdRef.current = null // ❌ 不要清空,让异步请求正常完成
}
}, [])
函数参数优先于状态
// ❌ 错误:使用可能过期的状态
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 })
}
开发注意事项
- 数据库密码:mysql_xEsdPa
- 数据库名称:xserver(不是 x_server_mobile)
- MySQL 容器名:1Panel-mysql-409s
- 后端容器内路径:/app/(不是 /www/x-server-mobile/backend/)
- 调试后端:
- 编辑文件后需要复制到容器:
docker cp host_file container:/app/path - 重启后端:
docker compose restart backend - 或重载 workers:
docker exec x-server-backend php /app/webman reload
- 编辑文件后需要复制到容器:
- 查看日志:
docker logs x-server-backend -f --tail 50 - 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),避免容器退出
21. 发起人也是审批人时"待我审批"不显示
问题:用户提交申请后,既是发起人也是审批人,但"待我审批"中不显示该订单
原因:
BusinessTripService::submit()中硬编码current_nodes => [1]- 但数据库中节点 1 的审批人是其他用户(如用户 2)
- 提交申请时没有根据用户选择的审批人创建/更新节点
解决方案:
// 根据审批人创建审批节点
$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)
解决方案:
// 修改申请页面跳转逻辑
Toast.show(t('businessTrip.submitSuccess'))
navigate('/approval') // 改为跳转到审批列表
相关文件:frontend/src/pages/business-trip/apply.tsx
23. 发起人自动审批通过逻辑
需求:当发起人选择的审批人中包含自己时,提交时自动标记为已审批
实现逻辑:
- 检查发起人是否在审批人列表中
- 如果是,自动创建审批通过记录
- 根据节点类型判断是否完成:
- 单人审批(approver):发起人审批后节点完成
- 或签(or_approve):发起人审批后节点完成
- 会签(and_approve):需要所有审批人审批才完成
- 节点完成后移动到下一节点或完成审批
解决方案:
// 如果发起人在审批人列表中,自动标记为已审批
if (in_array($userId, $approverIds)) {
ApprovalRecord::create([
'order_id' => $approvalOrder->id,
'node_id' => $node->id,
'node_type' => $node->type,
'approver_id' => $userId,
'action' => 'approve',
'comment' => '发起人自动审批通过',
]);
// 判断节点是否完成
$nodeCompleted = false;
if ($node->type === 'approver') {
// 单人审批:发起人即完成
$nodeCompleted = true;
} elseif ($node->type === 'or_approve') {
// 或签:任一人通过即可
$nodeCompleted = true;
} elseif ($node->type === 'and_approve') {
// 会签:需要所有人通过
$approvedCount = ApprovalRecord::where('order_id', $approvalOrder->id)
->where('node_id', $node->id)
->where('action', 'approve')
->count();
if ($approvedCount >= count($approverIds)) {
$nodeCompleted = true;
}
}
// 如果节点完成,移动到下一节点
if ($nodeCompleted) {
$nextNodes = ApprovalNode::where('flow_id', $flow->id)
->where('order', '>', 0)
->orderBy('order')
->get();
if ($nextNodes->isEmpty()) {
// 审批完成
$approvalOrder->status = ApprovalOrder::STATUS_APPROVED;
$approvalOrder->current_nodes = [];
} else {
// 移动到下一节点
$approvalOrder->current_nodes = $nextNodes->pluck('id')->toArray();
}
$approvalOrder->save();
}
}
相关文件:backend/app/service/BusinessTripService.php
文件修改记录
前端文件
frontend/src/pages/approval/list.tsx- 修复状态管理和请求逻辑frontend/src/components/layout/MobileLayout.tsx- 隐藏 Headerfrontend/src/pages/business-trip/apply.module.css- 调整 padding-bottomfrontend/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 字段
24. 多端飞书登录功能实现
需求背景
支持两种登录场景:
- 飞书客户端内:静默自动登录,无需用户操作
- 飞书客户端外:邮箱密码登录 + 飞书扫码登录
实现方案
1. 飞书客户端内 - 静默授权
环境检测:
const ua = navigator.userAgent.toLowerCase()
const isInFeishu = ua.includes('feishu') || ua.includes('lark') ||
(window as any).ttjsBridge !== undefined
调用 ttjsBridge 获取授权码:
if ((window as any).ttjsBridge) {
(window as any).ttjsBridge.getAuthCode({
success: (authCode: string) => {
handleFeishuLogin(authCode)
},
fail: (error: any) => {
Toast.show('获取授权失败,请重试')
},
})
}
相关文件:frontend/src/pages/login/index.tsx
2. 飞书客户端外 - 扫码登录
后端接口:
POST /api/v1/auth/qrcode-login- 生成登录会话和二维码 URLGET /api/v1/auth/qrcode-login/status?session_id={id}- 轮询登录状态GET /login/qrcode/{id}- 飞书授权回调地址
数据库表:feishu_login_session
session_id- 会话 ID(UUID)status- pending | scanning | success | expiredauth_url- 飞书授权 URLexpires_at- 过期时间(2分钟)
前端实现:
- 使用
qrcode.react生成二维码 - 每 2 秒轮询一次登录状态
- 状态更新:loading → pending → scanning → success
相关文件:
frontend/src/pages/login/QRCodeLogin.tsx- 二维码登录组件backend/app/controller/AuthController.php- 后端接口backend/app/model/FeishuLoginSession.php- 会话模型
3. 登录流程图
飞书内:
┌─────────┐ ttjsBridge ┌──────────┐ auth_code ┌─────────┐
│ 用户 │ ───────────────> │ 飞书API │ ──────────────> │ 后端 │
└─────────┘ getAuthCode() └──────────┘ 静默授权 └─────────┘
│
v
返回 token + user
自动登录成功
飞书外(扫码):
┌─────────┐ ┌──────────┐ ┌─────────┐
│ 用户 │ ──> 显示二维码 ──> │ 手机扫码 │ ──> 飞书授权 ──> │ 后端 │
└─────────┘ └──────────┘ └─────────┘
│ │
│ 每2秒轮询 status │
│<─────────────────────────────────────────────────────────────│
│ v
│ 返回 token + user
│<─────────────────────────────────────────────────────────────│
│
v
自动登录成功
关键技术点
- ttjsBridge 类型声明:
declare global {
interface Window {
ttjsBridge?: {
getAuthCode: (params: any) => void
}
}
}
- 登录成功事件通知:
// QRCodeLogin 组件触发事件
window.dispatchEvent(new CustomEvent('feishu-login-success', {
detail: { user, token }
}))
// 主登录页监听事件
useEffect(() => {
const handleFeishuLoginSuccess = (e: any) => {
const { user, token } = e.detail
login(user, token)
}
window.addEventListener('feishu-login-success', handleFeishuLoginSuccess)
return () => {
window.removeEventListener('feishu-login-success', handleFeishuLoginSuccess)
}
}, [])
- 二维码状态管理:
const [status, setStatus] = useState<'loading' | 'pending' | 'scanning' | 'success' | 'expired'>('loading')
const getStatusColor = () => {
switch (status) {
case 'pending': return '#1677ff' // 蓝色 - 待扫码
case 'scanning': return '#faad14' // 橙色 - 已扫码待确认
case 'success': return '#52c41a' // 绿色 - 成功
case 'expired': return '#ff4d4f' // 红色 - 过期
}
}
依赖安装
npm install qrcode.react
数据库迁移
CREATE TABLE IF NOT EXISTS `feishu_login_session` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`company_id` bigint unsigned NOT NULL COMMENT '公司ID',
`tenant_key` varchar(64) NOT NULL COMMENT '租户密钥',
`status` varchar(20) NOT NULL DEFAULT 'pending' COMMENT 'pending: 待扫码, scanning: 已扫码待授权, success: 登录成功, expired: 已过期',
`user_id` bigint unsigned DEFAULT NULL COMMENT '用户ID(登录成功后填充)',
`session_id` varchar(64) NOT NULL COMMENT '会话ID',
`auth_url` text COMMENT '飞书授权URL',
`expires_at` datetime NOT NULL COMMENT '过期时间',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_session_id` (`session_id`),
KEY `idx_company_status` (`company_id`, `status`),
KEY `idx_expires_at` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='飞书扫码登录会话表';
配置要求
- 飞书应用
tenant_key:14e7bc0fe5985740 - 飞书授权回调地址:
https://xsm.dev.hnxinyouhe.com/login/qrcode/{id}
25. 审批列表 Tab 状态持久化问题
问题:查看审批详情后返回上一页,tab 重置为"我发起的",而不是保持用户之前选择的 tab
原因:
- React Router 的
navigate(-1)不传递location.state - 页面组件重新挂载时,
useState初始值恢复为默认值 - 即使使用
navigate(path, { state: { fromTab: activeTab } }),navigate(-1)时 state 仍然为 null
解决方案:
使用 sessionStorage 持久化 tab 状态,而不是依赖 location.state:
// 优先从 sessionStorage 恢复 tab,其次从 location.state,最后默认为 'my'
const savedTab = sessionStorage.getItem('approval-tab') as string
const stateTab = ((location.state as any)?.fromTab as string) || undefined
const initialTab = (savedTab && ['my', 'pending', 'approved', 'cc'].includes(savedTab)) ? savedTab : (stateTab || 'my')
const [activeTab, setActiveTab] = useState<'my' | 'pending' | 'approved' | 'cc'>(initialTab)
// 切换标签时保存到 sessionStorage
const handleTabChange = (key: string) => {
setActiveTab(key as 'my' | 'pending' | 'approved' | 'cc')
sessionStorage.setItem('approval-tab', key) // 保存到 sessionStorage
setList([])
setHasMore(false)
setPage(1)
loadData(1, true, key)
}
// 查看详情时同时保存 state(兼容其他场景)
const handleViewDetail = (id: number) => {
navigate(`/approval/${id}`, { state: { fromTab: activeTab } })
}
相关文件:
frontend/src/pages/approval/list.tsxfrontend/src/pages/business-trip/list.tsx
26. "我已审批"列表不显示已审批订单
问题:用户审批完成后,在"我已审批"列表中看不到该订单
根本原因:架构问题 - 数据模型混淆
问题分析
审批系统中有两个数据来源,它们的职责混淆了:
-
approval_node.approvers- 静态模板数据- 用途:定义审批流程模板中每个节点的默认审批人配置
- 问题:运行时不会根据用户实际选择的审批人更新
-
approval_order.form_data.approvers- 运行时数据- 用途:存储用户提交申请时实际选择的审批人
- 正确:这才是判断审批状态应该使用的数据
历史演变
| 时间 | BusinessTripService | ApprovalService | 结果 |
|---|---|---|---|
| 早期 | 使用 approval_node.approvers |
使用 approval_node.approvers |
一致但错误 |
| 修复后 | 使用 form_data.approvers |
使用 approval_node.approvers |
不一致! |
| 现在 | 使用 form_data.approvers |
使用 form_data.approvers |
一致且正确 |
代码修复
ApprovalService::isNodeCompleted() - 判断节点是否完成
// ❌ 旧代码:从 approval_node 获取审批人(错误)
$node = ApprovalNode::findOrFail($nodeId);
$approvers = $node->approvers ?? []; // 静态模板数据,可能为空
// ✅ 新代码:从 form_data 获取审批人(正确)
$order = ApprovalOrder::find($orderId);
$formData = $order->form_data ?? [];
$approvers = $formData['approvers'] ?? []; // 运行时数据,用户实际选择
ApprovalService::getPendingOrderIds() - 获取待审批订单
// ❌ 旧代码
$node = ApprovalNode::whereIn('id', $currentNodeIds)->first();
$approvers = $node->approvers ?? [];
// ✅ 新代码
$formData = $order->form_data ?? [];
$approvers = $formData['approvers'] ?? [];
兼容性处理:
// 兼容两种格式:[{id:1, name:'...'}] 或 [1]
if (!empty($approvers) && is_array($approvers[0] ?? null) && isset($approvers[0]['id'])) {
$approverIds = array_column($approvers, 'id');
} else {
$approverIds = $approvers;
}
相关文件:backend/app/service/ApprovalService.php
27. 审批流程不自动跳过通知节点
问题:审批节点完成后,下一节点是"通知"节点时,流程状态仍为"审批中",current_nodes 包含通知节点 ID
原因:
ApprovalService::approve()没有检查下一节点类型- 通知节点(notify)不应该阻止流程完成,应该自动跳过
解决方案:
// 移动到下一节点
$nextNodes = $this->getNextNodes($order->flow, $currentNode->order);
if (empty($nextNodes)) {
// 没有下一节点,审批完成
$order->status = ApprovalOrder::STATUS_APPROVED;
$order->current_nodes = [];
} else {
// 检查第一个下一节点
$firstNextNode = $nextNodes[0] ?? null;
if ($firstNextNode && $firstNextNode['type'] === 'notify') {
// 下一个是通知节点,跳过并完成流程
$order->status = ApprovalOrder::STATUS_APPROVED;
$order->current_nodes = [];
} else {
// 下一个是审批节点,移动到该节点
$order->current_nodes = collect($nextNodes)->pluck('id')->toArray();
}
}
相关文件:backend/app/service/ApprovalService.php
28. 架构问题总结 - 为什么问题一直出现
根本原因:数据模型职责不清晰
审批系统长期存在问题的根源是静态模板数据和运行时数据混淆:
| 数据源 | 表/字段 | 用途 | 问题 |
|---|---|---|---|
| 静态模板 | approval_node.approvers |
流程模板中每个节点的默认审批人 | 不应随用户选择变化 |
| 运行时数据 | approval_order.form_data.approvers |
用户提交时实际选择的审批人 | 判断审批状态的正确来源 |
为什么会反复出现问题
-
两个服务使用不同数据源
BusinessTripService修复后使用form_dataApprovalService仍使用approval_node- 导致相同操作在不同页面结果不一致
-
没有明确的数据访问规范
- 开发时容易混淆应该读取哪个数据源
- 缺少代码注释说明数据来源的用途
-
缺少单元测试
- 数据逻辑变更后没有自动化测试验证
- 依赖手工测试,容易遗漏边界情况
长期解决方案
-
代码层面:
- 在
ApprovalNode模型上添加注释,明确approvers是模板数据 - 在
ApprovalOrder模型上添加辅助方法getRuntimeApprovers(),统一访问入口 - 添加单元测试覆盖所有审批流程场景
- 在
-
数据库层面:
- 添加外键约束确保数据一致性
- 考虑将
approval_node.approvers重命名为default_approvers,更清晰表达语义
-
文档层面:
- 在开发文档中明确说明数据模型的设计原则
- 提供常见问题的排查指南
29. 数据库完整性建议
当前问题
approval_order表记录可能被孤立(业务表引用了不存在的 approval_order_id)- 没有外键约束确保引用完整性
- 清理数据时可能遗留孤立记录
建议的修复方案
-- 1. 添加外键约束(确保数据一致性)
ALTER TABLE business_trip
ADD CONSTRAINT fk_business_trip_approval_order
FOREIGN KEY (approval_order_id) REFERENCES approval_order(id)
ON DELETE SET NULL ON UPDATE CASCADE;
-- 2. 清理孤立数据
DELETE FROM approval_record
WHERE order_id NOT IN (SELECT id FROM approval_order);
-- 3. 添加触发器自动维护一致性
DELIMITER $$
CREATE TRIGGER before_approval_order_delete
BEFORE DELETE ON approval_order
FOR EACH ROW
BEGIN
UPDATE business_trip
SET approval_order_id = NULL
WHERE approval_order_id = OLD.id;
END$$
DELIMITER ;
相关文件:/tmp/fix_proposal.sql
最新修改记录(2025-02)
前端文件
frontend/src/pages/approval/list.tsx- 添加"抄送我的"tab,使用 sessionStorage 持久化 tab 状态frontend/src/pages/business-trip/list.tsx- 使用 sessionStorage 持久化 tab 状态frontend/src/modules/approval/types.ts- 添加 'cc' 类型到 ApprovalListParams
后端文件
backend/app/service/ApprovalService.php- 添加 'cc' case 支持"抄送我的"列表
- 修复
isNodeCompleted()使用form_data.approvers - 修复
getPendingOrderIds()使用form_data.approvers - 添加跳过 notify 节点逻辑
Git 提交记录
e904d5c- fix: 修复 ApprovalService 使用 form_data.approvers8ce070b- fix: 使用 sessionStorage 保持 tab 状态f3a9e44- fix: 返回列表时保持之前的 tab 状态ea7f0f8- feat: 添加"抄送我的"审批列表选项
30. 飞书卡片"查看详情"跳转错误(多公司支持)
问题:
- 点击飞书卡片"查看详情"后,页面闪两下然后跳转到
/login页面,没有 redirect 参数 - 最终 URL 变成
/login?redirect=%2Flogin,而不是预期的/login?redirect=/approval/detail/103?company=2
根本原因:
- 路由配置不匹配:后端生成
/approval/detail/{id}格式的 URL,但前端路由只有/approval/:id,导致触发通配符路由*直接跳转到/login - 详情页被 MobileLayout 包裹:详情页在 MobileLayout 的 children 中,MobileLayout 的登录检查在组件挂载前就拦截了请求,直接跳转到
/login,没有保留原始 URL - 登录页没有处理已登录状态:用户已登录访问详情页时,仍然显示登录界面
解决方案:
1. 修复路由配置
// frontend/src/router/index.tsx
const router = createBrowserRouter([
{
path: '/',
element: <MobileLayout />,
children: [
// ... 其他路由
{ path: 'approval/:id', element: <ApprovalDetail /> }, // 兼容旧格式
],
},
// 详情页移到顶层,支持未登录访问(页面自己处理登录检查)
{ path: '/approval/detail/:id', element: <ApprovalDetail /> },
{ path: '/business-trip/detail/:id', element: <BusinessTripDetail /> },
// ... 其他顶层路由
{ path: '*', element: <Navigate to='/login' replace /> },
])
2. 修改后端 URL 生成逻辑
// backend/app/service/ApprovalService.php
private function getDetailUrl(ApprovalOrder $order): string
{
$baseUrl = 'https://xsm.dev.hnxinyouhe.com';
// 统一使用审批详情页,不再跳转到具体业务模块的详情页
return "{$baseUrl}/approval/detail/{$order->id}";
}
3. 修改 BusinessTripService 通知 URL
// backend/app/service/BusinessTripService.php
// 构建审批链接(统一使用审批详情页)
$approvalUrl = "https://xsm.dev.hnxinyouhe.com/approval/detail/{$approvalOrder->id}";
// 使用 $approvalUrl 而不是 $detailUrl
$notificationService->sendApprovalNotification([
'url' => $approvalUrl,
// ...
]);
4. 修复登录页登录状态检查
// frontend/src/pages/login/index.tsx
useEffect(() => {
// 首先检查是否已登录
const token = localStorage.getItem('token')
const urlParams = new URLSearchParams(window.location.search)
const redirect = urlParams.get('redirect')
// 如果有 token,说明已登录
if (token) {
// 如果有 redirect 参数,跳转到目标页面
if (redirect) {
window.location.href = redirect
return
}
// 否则跳转到 dashboard
navigate('/dashboard')
return
}
// 没有token,继续登录流程
setLoading(false)
// ...
}, [user, login, navigate])
5. 禁用 OPcache(开发环境)
# 创建配置文件禁用 OPcache
echo 'opcache.enable=0' >> /usr/local/etc/php/conf.d/99-disable-opcache.ini
相关文件:
frontend/src/router/index.tsx- 路由配置backend/app/service/ApprovalService.php- URL 生成backend/app/service/BusinessTripService.php- 通知 URL 生成frontend/src/pages/login/index.tsx- 登录状态检查frontend/src/pages/approval/detail.tsx- 详情页登录检查frontend/src/pages/business-trip/detail.tsx- 详情页登录检查
注意事项:
- 详情页需要有自己独立的登录检查逻辑,不能依赖 MobileLayout
- 登录页需要优先检查 token,已登录则直接跳转
- 多公司支持:URL 需要包含
?company={id}参数 - 飞书卡片通知 URL 格式:
https://xsm.dev.hnxinyouhe.com/approval/detail/{order_id}?company={company_id}