2026-02-24 17:57:25 +08:00
|
|
|
|
# X-Server Mobile 项目文档
|
|
|
|
|
|
|
|
|
|
|
|
## 项目概述
|
|
|
|
|
|
|
|
|
|
|
|
这是一个基于飞书的企业内部H5移动工具平台,采用多租户架构,支持审批流程引擎。
|
|
|
|
|
|
|
|
|
|
|
|
**关键特性:**
|
|
|
|
|
|
- 支持5种语言:中文、英文、俄语、西语、法语
|
|
|
|
|
|
- 多租户架构(用户全局唯一,关联多个公司)
|
|
|
|
|
|
- 审批流程引擎(单人审批、会签、或签、通知)
|
|
|
|
|
|
- 飞书免登集成
|
|
|
|
|
|
|
|
|
|
|
|
**技术栈:**
|
|
|
|
|
|
- 前端:React 18.3.1 + TypeScript + Vite + Ant Design Mobile
|
|
|
|
|
|
- 后端:PHP 8.5 + Webman 2.x + MySQL + Redis
|
|
|
|
|
|
- 部署:Docker
|
|
|
|
|
|
|
|
|
|
|
|
## 国际化 (i18n) - 重要!
|
|
|
|
|
|
|
|
|
|
|
|
### 前端
|
|
|
|
|
|
- 配置:`frontend/src/i18n/`
|
|
|
|
|
|
- 翻译文件:`frontend/src/i18n/locales/{zh,en,ru,es,fr}.json`
|
|
|
|
|
|
- 语言切换:
|
|
|
|
|
|
- 首页右上角国旗按钮
|
|
|
|
|
|
- 个人中心 -> 语言设置
|
|
|
|
|
|
- 存储位置:localStorage `i18nextLng`
|
|
|
|
|
|
|
|
|
|
|
|
使用方式:
|
|
|
|
|
|
```tsx
|
|
|
|
|
|
import { useTranslation } from 'react-i18next'
|
|
|
|
|
|
const { t } = useTranslation()
|
|
|
|
|
|
<div>{t('common.confirm')}</div>
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 后端
|
|
|
|
|
|
- 翻译文件:`backend/lang/{zh,en,ru,es,fr}.php`
|
|
|
|
|
|
- 工具类:`backend/app/Util/Lang.php`
|
|
|
|
|
|
|
|
|
|
|
|
使用方式:
|
|
|
|
|
|
```php
|
|
|
|
|
|
use App\Util\Lang;
|
|
|
|
|
|
$message = Lang::get('common.confirm');
|
|
|
|
|
|
Lang::setLocale('en');
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 目录结构
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
/www/x-server-mobile/
|
|
|
|
|
|
├── frontend/ # React 前端 (运行在 5173)
|
|
|
|
|
|
│ ├── src/
|
|
|
|
|
|
│ │ ├── i18n/ # 国际化配置
|
|
|
|
|
|
│ │ ├── components/ # 组件
|
|
|
|
|
|
│ │ ├── pages/ # 页面
|
|
|
|
|
|
│ │ ├── modules/ # 业务模块 (approval等)
|
|
|
|
|
|
│ │ └── router/ # 路由
|
|
|
|
|
|
│ └── package.json
|
|
|
|
|
|
├── backend/ # PHP 后端 (运行在 8787)
|
|
|
|
|
|
│ ├── app/
|
|
|
|
|
|
│ │ ├── Controller/ # 控制器 (PascalCase命名)
|
|
|
|
|
|
│ │ ├── Model/ # 模型 (PascalCase命名)
|
|
|
|
|
|
│ │ ├── Service/ # 服务层
|
|
|
|
|
|
│ │ ├── Util/ # 工具类
|
|
|
|
|
|
│ │ └── Middleware/ # 中间件
|
|
|
|
|
|
│ ├── config/ # 配置
|
|
|
|
|
|
│ ├── lang/ # 语言文件
|
|
|
|
|
|
│ └── composer.json
|
|
|
|
|
|
├── docker-compose.yml # Docker 编排
|
|
|
|
|
|
├── nginx/ # OpenResty 配置
|
|
|
|
|
|
├── frpc/ # frpc 配置
|
|
|
|
|
|
├── PROJECT.md # 项目详细文档
|
|
|
|
|
|
└── IMPLEMENTATION_PLAN.md # 实现计划
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 环境配置
|
|
|
|
|
|
|
|
|
|
|
|
### 端口
|
|
|
|
|
|
- Frontend (Vite): 5173
|
|
|
|
|
|
- Backend (Webman): 8787
|
|
|
|
|
|
- OpenResty: 8080
|
|
|
|
|
|
- MySQL: 3306
|
|
|
|
|
|
- Redis: 6379
|
|
|
|
|
|
|
|
|
|
|
|
### 数据库
|
|
|
|
|
|
- 数据库名:x_server_mobile
|
|
|
|
|
|
- 密码:mysql_xEsdPa
|
|
|
|
|
|
- 网络:1panel-network
|
|
|
|
|
|
|
|
|
|
|
|
### Docker 容器
|
|
|
|
|
|
- 后端容器:x-server-backend
|
|
|
|
|
|
- 使用 Dockerfile:`backend/Dockerfile`
|
|
|
|
|
|
- PHP 版本:8.5
|
|
|
|
|
|
|
|
|
|
|
|
## 关键文件路径
|
|
|
|
|
|
|
|
|
|
|
|
### 前端关键文件
|
|
|
|
|
|
- 入口:`frontend/src/main.tsx`
|
|
|
|
|
|
- 路由:`frontend/src/router/index.tsx`
|
|
|
|
|
|
- 布局:`frontend/src/components/layout/`
|
|
|
|
|
|
- 状态管理:`frontend/src/stores/`
|
|
|
|
|
|
- rem 适配:`frontend/src/utils/rem.ts`
|
|
|
|
|
|
|
|
|
|
|
|
### 后端关键文件
|
|
|
|
|
|
- 入口:`backend/start.php`
|
|
|
|
|
|
- 路由:`backend/config/route.php`
|
|
|
|
|
|
- 数据库配置:`backend/config/database.php`
|
|
|
|
|
|
- Redis 配置:`backend/config/redis.php`
|
|
|
|
|
|
- 容器配置:`backend/config/container.php`
|
|
|
|
|
|
|
|
|
|
|
|
### 重要配置文件
|
|
|
|
|
|
- Docker 编排:`docker-compose.yml`
|
|
|
|
|
|
- OpenResty 配置:`nginx/x-server-api.conf`
|
|
|
|
|
|
- frpc 配置:`frpc/frpc-dev.ini`
|
|
|
|
|
|
|
|
|
|
|
|
## 审批流程引擎
|
|
|
|
|
|
|
|
|
|
|
|
### 节点类型
|
|
|
|
|
|
- `single_approve` - 单人审批
|
|
|
|
|
|
- `and_sign` - 会签(所有人通过)
|
|
|
|
|
|
- `or_sign` - 或签(任一人通过)
|
|
|
|
|
|
- `notify` - 通知节点
|
|
|
|
|
|
|
|
|
|
|
|
### 状态
|
|
|
|
|
|
- pending: 待审批
|
|
|
|
|
|
- approved: 已通过
|
|
|
|
|
|
- rejected: 已拒绝
|
|
|
|
|
|
- cancelled: 已撤销
|
|
|
|
|
|
|
|
|
|
|
|
### 关键文件
|
|
|
|
|
|
- 服务:`backend/app/Service/ApprovalService.php`
|
|
|
|
|
|
- 模型:`backend/app/Model/ApprovalOrder.php`
|
|
|
|
|
|
- 前端模块:`frontend/src/modules/approval/`
|
|
|
|
|
|
|
|
|
|
|
|
## 飞书集成
|
|
|
|
|
|
|
|
|
|
|
|
### 免登接口
|
|
|
|
|
|
- 回调:`POST /api/v1/auth/feishu-login`
|
|
|
|
|
|
|
|
|
|
|
|
### 环境变量
|
|
|
|
|
|
```env
|
|
|
|
|
|
FEISHU_APP_ID=cli_xxxxxxxxxxxxx
|
|
|
|
|
|
FEISHU_APP_SECRET=xxxxxxxxxxxxxxxxxxxx
|
|
|
|
|
|
FEISHU_ENCRYPT_KEY=xxxxxxxxxxxxxxxxxxxx
|
|
|
|
|
|
FEISHU_VERIFICATION_TOKEN=xxxxxxxxxxxxxxxxxxxx
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 常用命令
|
|
|
|
|
|
|
|
|
|
|
|
### 前端
|
|
|
|
|
|
```bash
|
|
|
|
|
|
cd /www/x-server-mobile/frontend
|
|
|
|
|
|
npm install
|
|
|
|
|
|
npm run dev
|
|
|
|
|
|
npm run build
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 后端
|
|
|
|
|
|
```bash
|
|
|
|
|
|
cd /www/x-server-mobile/backend
|
|
|
|
|
|
composer install
|
|
|
|
|
|
php start.php start
|
|
|
|
|
|
php start.php stop
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### Docker
|
|
|
|
|
|
```bash
|
|
|
|
|
|
cd /www/x-server-mobile
|
|
|
|
|
|
docker compose up -d
|
|
|
|
|
|
docker compose restart backend
|
|
|
|
|
|
docker logs x-server-backend -f
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 反向代理配置(开发环境)
|
|
|
|
|
|
|
|
|
|
|
|
用户已有 frpc 和 OpenResty,配置用于调试飞书登录。
|
|
|
|
|
|
|
|
|
|
|
|
1. OpenResty 监听 8080,代理到 8787
|
|
|
|
|
|
2. frpc 将 8080 暴露到公网
|
|
|
|
|
|
3. 飞书回调地址配置为公网域名
|
|
|
|
|
|
|
|
|
|
|
|
## 已知问题和解决方案
|
|
|
|
|
|
|
2026-02-25 15:22:12 +08:00
|
|
|
|
### 页面间状态丢失问题 ⚠️ 重要!
|
|
|
|
|
|
|
|
|
|
|
|
#### 问题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`
|
|
|
|
|
|
|
2026-02-25 18:09:04 +08:00
|
|
|
|
#### 问题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 自动保存作为备份机制(防止用户不导航就离开页面)
|
|
|
|
|
|
|
2026-02-25 15:22:12 +08:00
|
|
|
|
### 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} />
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-02-24 17:57:25 +08:00
|
|
|
|
### PSR-4 自动加载
|
|
|
|
|
|
- 目录名必须是 PascalCase(Controller、Model、Service、Util)
|
|
|
|
|
|
- 不能用小写(controller、model、service、util)
|
|
|
|
|
|
|
|
|
|
|
|
### React 版本
|
|
|
|
|
|
- 必须使用 React 18.3.1(不能用19)
|
|
|
|
|
|
- @types/react 和 @types/react-dom 也要用 v18
|
|
|
|
|
|
|
|
|
|
|
|
### 数据库连接
|
|
|
|
|
|
- 使用自定义 Db 类 (`backend/app/Util/Db.php`)
|
|
|
|
|
|
- 不使用 `support\Db`(Webman 2.x 没有)
|
|
|
|
|
|
|
|
|
|
|
|
### Bootstrap 接口
|
|
|
|
|
|
- 方法签名:`public static function start(?\Workerman\Worker $worker): void`
|
|
|
|
|
|
- 需要添加 `use Workerman\Worker;`
|
|
|
|
|
|
|
|
|
|
|
|
### Docker 容器重启
|
|
|
|
|
|
- CMD 使用 `php start.php start`(不要用 `-d`)
|
|
|
|
|
|
- 前台运行,避免容器退出
|
|
|
|
|
|
|
|
|
|
|
|
## 语言切换位置
|
|
|
|
|
|
|
|
|
|
|
|
1. **首页** - 右上角显示当前语言国旗,点击切换
|
|
|
|
|
|
2. **个人中心** -> 语言设置
|
|
|
|
|
|
|
|
|
|
|
|
## 当前开发状态
|
|
|
|
|
|
|
|
|
|
|
|
- ✅ 前端基础框架搭建完成
|
|
|
|
|
|
- ✅ 后端 API 框架搭建完成
|
|
|
|
|
|
- ✅ 5语言国际化支持完成
|
|
|
|
|
|
- ✅ Docker 开发环境配置完成
|
|
|
|
|
|
- ✅ 审批流程引擎基础框架完成
|
|
|
|
|
|
- 🚧 出差申请功能开发中
|
|
|
|
|
|
|
|
|
|
|
|
## 下一步工作
|
|
|
|
|
|
|
|
|
|
|
|
1. 完成出差申请前端页面
|
|
|
|
|
|
2. 实现审批列表和详情页
|
|
|
|
|
|
3. 集成飞书免登
|
|
|
|
|
|
4. 实现公司切换功能
|
|
|
|
|
|
5. 完善权限控制
|