chore: 移除已迁移的旧文件
This commit is contained in:
@@ -1,219 +0,0 @@
|
||||
# 开发环境反向代理配置指南
|
||||
|
||||
本指南用于配置开发环境的 OpenResty 和 frpc,以便将本地 API 暴露到公网调试飞书登录。
|
||||
|
||||
## 前提条件
|
||||
|
||||
- 已安装并运行 OpenResty
|
||||
- 已安装并运行 frpc
|
||||
- Docker 中运行的后端服务 (localhost:8787)
|
||||
|
||||
## 配置步骤
|
||||
|
||||
### 1. OpenResty 配置
|
||||
|
||||
在你的 OpenResty 配置目录中创建以下配置文件:
|
||||
|
||||
```nginx
|
||||
# /usr/local/openresty/nginx/conf/conf.d/x-server-api.conf
|
||||
|
||||
server {
|
||||
listen 8080; # 本地开发端口,避免与 80 端口冲突
|
||||
server_name api.x-server.local;
|
||||
|
||||
# API 反向代理
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8787/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# CORS 配置(飞书请求需要)
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS';
|
||||
add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,X-Tenant-Key';
|
||||
|
||||
if ($request_method = 'OPTIONS') {
|
||||
return 204;
|
||||
}
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# 健康检查
|
||||
location /health {
|
||||
return 200 "OK\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
重载配置:
|
||||
```bash
|
||||
sudo openresty -s reload
|
||||
# 或
|
||||
sudo systemctl reload openresty
|
||||
```
|
||||
|
||||
### 2. frpc 配置
|
||||
|
||||
配置 frpc 将本地 OpenResty 服务暴露到公网:
|
||||
|
||||
```ini
|
||||
# ~/.frpc.ini 或 /etc/frp/frpc.ini
|
||||
|
||||
[common]
|
||||
server_addr = your-frp-server.com # 你的 frp 服务器地址
|
||||
server_port = 7000
|
||||
token = your-token
|
||||
|
||||
[x-server-api]
|
||||
type = http
|
||||
local_ip = 127.0.0.1
|
||||
local_port = 8080 # OpenResty 监听端口
|
||||
custom_domains = api-dev.x-server.your-domain.com # 开发环境域名
|
||||
|
||||
# 可选:启用加密和压缩
|
||||
use_encryption = true
|
||||
use_compression = true
|
||||
```
|
||||
|
||||
启动 frpc:
|
||||
```bash
|
||||
frpc -c ~/.frpc.ini
|
||||
```
|
||||
|
||||
### 3. 本地 hosts 配置
|
||||
|
||||
在本地开发时配置 hosts:
|
||||
|
||||
```bash
|
||||
# /etc/hosts
|
||||
127.0.0.1 api.x-server.local
|
||||
```
|
||||
|
||||
### 4. 飞书应用配置
|
||||
|
||||
在飞书开放平台配置开发环境回调 URL:
|
||||
|
||||
```
|
||||
https://api-dev.x-server.your-domain.com/api/v1/auth/feishu-login
|
||||
```
|
||||
|
||||
## 验证配置
|
||||
|
||||
### 1. 本地测试
|
||||
```bash
|
||||
# 直接访问后端
|
||||
curl http://localhost:8787/api/v1/user/info
|
||||
|
||||
# 通过 OpenResty 访问
|
||||
curl http://localhost:8080/api/v1/user/info
|
||||
|
||||
# 通过本地域名访问
|
||||
curl http://api.x-server.local/api/v1/user/info
|
||||
```
|
||||
|
||||
### 2. 公网测试
|
||||
```bash
|
||||
# 通过 frps 域名访问
|
||||
curl https://api-dev.x-server.your-domain.com/api/v1/user/info
|
||||
```
|
||||
|
||||
### 3. 飞书调试
|
||||
1. 在飞书开放平台创建测试应用
|
||||
2. 配置回调 URL 为开发环境域名
|
||||
3. 使用飞书 APP ID 和 APP SECRET 进行测试
|
||||
4. 检查后端日志查看请求详情
|
||||
|
||||
## 开发与生产环境区分
|
||||
|
||||
### 开发环境(使用 frpc)
|
||||
- 域名:`api-dev.x-server.your-domain.com`
|
||||
- 使用 frpc 暴露本地服务
|
||||
- CORS 允许所有来源
|
||||
- 详细日志输出
|
||||
|
||||
### 生产环境(不使用 frpc)
|
||||
- 域名:`api.x-server.com`
|
||||
- 直接部署到服务器
|
||||
- CORS 严格限制
|
||||
- 日志级别调整
|
||||
|
||||
## 故障排查
|
||||
|
||||
### OpenResty 无法访问后端
|
||||
1. 检查 Docker 容器是否运行:`docker ps`
|
||||
2. 检查端口是否正确:`docker ps | grep 8787`
|
||||
3. 检查网络连通性:`curl http://127.0.0.1:8787`
|
||||
|
||||
### frpc 连接失败
|
||||
1. 检查 frp 服务器是否可访问
|
||||
2. 检查 token 是否正确
|
||||
3. 查看 frpc 日志:`frpc -c ~/.frpc.ini`
|
||||
|
||||
### 飞书回调失败
|
||||
1. 检查域名 DNS 解析是否指向 frp 服务器
|
||||
2. 检查 frp 服务器是否配置了域名白名单
|
||||
3. 检查后端日志是否收到请求
|
||||
4. 验证 CORS 配置是否正确
|
||||
|
||||
## 安全建议
|
||||
|
||||
1. **开发环境使用独立的域名和 token**
|
||||
2. **定期更新 frp token**
|
||||
3. **不要在代码中硬编码生产环境配置**
|
||||
4. **使用环境变量管理敏感信息**
|
||||
5. **生产环境部署前移除 frpc 配置**
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 查看 Docker 容器日志
|
||||
docker logs x-server-backend -f
|
||||
|
||||
# 重启后端服务
|
||||
docker compose restart backend
|
||||
|
||||
# 重载 OpenResty 配置
|
||||
sudo openresty -s reload
|
||||
|
||||
# 查看 frpc 状态
|
||||
frpc status -c ~/.frpc.ini
|
||||
|
||||
# 测试 API 连通性
|
||||
curl -v http://localhost:8787/api/v1/user/info
|
||||
```
|
||||
|
||||
## 环境变量配置
|
||||
|
||||
创建 `.env.development` 文件用于开发环境:
|
||||
|
||||
```env
|
||||
# 后端配置
|
||||
DB_HOST=mysql
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=x_server_mobile
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=mysql_xEsdPa
|
||||
|
||||
# Redis 配置
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
|
||||
# 飞书配置(开发环境)
|
||||
FEISHU_APP_ID=cli_xxxxxxxxxxxxx
|
||||
FEISHU_APP_SECRET=xxxxxxxxxxxxxxxxxxxx
|
||||
FEISHU_ENCRYPT_KEY=xxxxxxxxxxxxxxxxxxxx
|
||||
FEISHU_VERIFICATION_TOKEN=xxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# API 配置
|
||||
API_BASE_URL=http://localhost:8787
|
||||
PUBLIC_API_URL=https://api-dev.x-server.your-domain.com
|
||||
```
|
||||
|
||||
生产环境使用 `.env.production` 文件,移除 frpc 相关配置。
|
||||
-171
@@ -1,171 +0,0 @@
|
||||
# Git 工作流
|
||||
|
||||
## 分支策略
|
||||
|
||||
采用 Git Flow 简化版工作流:
|
||||
|
||||
### 主要分支
|
||||
|
||||
| 分支 | 用途 | 说明 |
|
||||
|------|------|------|
|
||||
| `master` | 生产环境 | 保持稳定,只接受从 develop 合并的代码 |
|
||||
| `develop` | 开发环境 | 主开发分支,包含最新的开发功能 |
|
||||
| `feature/*` | 功能分支 | 从 develop 分支,开发完成后合并回 develop |
|
||||
| `hotfix/*` | 热修复分支 | 从 master 分支,修复后合并到 master 和 develop |
|
||||
|
||||
## 分支命名规范
|
||||
|
||||
### 功能分支
|
||||
```
|
||||
feature/功能名称
|
||||
例如:feature/i18n-support
|
||||
```
|
||||
|
||||
### 热修复分支
|
||||
```
|
||||
hotfix/问题描述
|
||||
例如:hotfix/login-error
|
||||
```
|
||||
|
||||
## 工作流程
|
||||
|
||||
### 1. 开发新功能
|
||||
|
||||
```bash
|
||||
# 从 develop 创建功能分支
|
||||
git checkout develop
|
||||
git pull origin develop
|
||||
git checkout -b feature/your-feature-name
|
||||
|
||||
# 开发并提交
|
||||
git add .
|
||||
git commit -m "feat: add your feature"
|
||||
|
||||
# 推送到远程
|
||||
git push -u origin feature/your-feature-name
|
||||
|
||||
# 完成后合并到 develop
|
||||
git checkout develop
|
||||
git merge feature/your-feature-name
|
||||
git push origin develop
|
||||
|
||||
# 删除功能分支(可选)
|
||||
git branch -d feature/your-feature-name
|
||||
```
|
||||
|
||||
### 2. 修复紧急问题
|
||||
|
||||
```bash
|
||||
# 从 master 创建热修复分支
|
||||
git checkout master
|
||||
git pull origin master
|
||||
git checkout -b hotfix/your-fix-name
|
||||
|
||||
# 修复并提交
|
||||
git add .
|
||||
git commit -m "fix: describe the fix"
|
||||
|
||||
# 推送到远程
|
||||
git push -u origin hotfix/your-fix-name
|
||||
|
||||
# 合并到 master 和 develop
|
||||
git checkout master
|
||||
git merge hotfix/your-fix-name
|
||||
git push origin master
|
||||
|
||||
git checkout develop
|
||||
git merge hotfix/your-fix-name
|
||||
git push origin develop
|
||||
```
|
||||
|
||||
### 3. 发布到生产
|
||||
|
||||
```bash
|
||||
# 将 develop 合并到 master
|
||||
git checkout master
|
||||
git merge develop
|
||||
git push origin master
|
||||
|
||||
# 打标签
|
||||
git tag -a v1.0.0 -m "Release version 1.0.0"
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
## 提交信息规范
|
||||
|
||||
使用 Conventional Commits 规范:
|
||||
|
||||
| 类型 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `feat` | 新功能 | `feat: add user login` |
|
||||
| `fix` | 修复bug | `fix: correct i18n loading` |
|
||||
| `docs` | 文档变更 | `docs: update README` |
|
||||
| `style` | 代码格式 | `style: format code` |
|
||||
| `refactor` | 重构 | `refactor: simplify API` |
|
||||
| `test` | 测试 | `test: add unit tests` |
|
||||
| `chore` | 构建/工具 | `chore: update dependencies` |
|
||||
|
||||
格式:
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
<body>
|
||||
|
||||
<footer>
|
||||
```
|
||||
|
||||
## 当前分支状态
|
||||
|
||||
| 分支 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| `master` | ✅ 稳定 | 生产代码(含5语言支持) |
|
||||
| `develop` | ✅ 活跃 | 主开发分支(含5语言支持) |
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 查看所有分支
|
||||
git branch -a
|
||||
|
||||
# 查看当前分支
|
||||
git branch --show-current
|
||||
|
||||
# 切换分支
|
||||
git checkout <branch-name>
|
||||
|
||||
# 创建并切换到新分支
|
||||
git checkout -b <new-branch>
|
||||
|
||||
# 删除本地分支
|
||||
git branch -d <branch-name>
|
||||
|
||||
# 删除远程分支
|
||||
git push origin --delete <branch-name>
|
||||
|
||||
# 查看分支差异
|
||||
git diff develop
|
||||
|
||||
# 查看分支树
|
||||
git log --graph --oneline --all
|
||||
```
|
||||
|
||||
## 发布流程
|
||||
|
||||
1. **开发阶段**:在 `develop` 分支进行开发
|
||||
2. **测试阶段**:创建 `release` 分支进行测试
|
||||
3. **发布阶段**:合并到 `master` 并打标签
|
||||
4. **维护阶段**:使用 `hotfix` 分支修复紧急问题
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **永远不要直接在 master 分支开发**
|
||||
2. **功能分支必须从 develop 分支创建**
|
||||
3. **提交前先拉取最新代码**
|
||||
4. **保持提交信息清晰规范**
|
||||
5. **及时删除已合并的功能分支**
|
||||
|
||||
## 远程仓库
|
||||
|
||||
- 仓库地址:http://219.157.255.213:25311/xinyouhe/x-server-mobile.git
|
||||
- 默认分支:develop(建议设置)
|
||||
- Pull Request:http://219.157.255.213:25311/xinyouhe/x-server-mobile/compare/master...develop
|
||||
@@ -1,601 +0,0 @@
|
||||
# 企业内部工具系统 - 实现计划
|
||||
|
||||
## 项目概述
|
||||
|
||||
开发一个企业内部工具系统平台,以出差审批为首个功能模块,后续可持续扩展新功能。系统主要支持移动 H5,嵌入飞书应用中使用。
|
||||
|
||||
### 核心目标
|
||||
- **H5 移动端优先**:专注于移动端体验,PC 端后期扩展
|
||||
- 从工具台层面设计可扩展的页面结构
|
||||
- **审批模块作为独立通用工具**:支持多样化审批场景,业务可复用
|
||||
- 良好的飞书集成能力(多公司、多应用架构)
|
||||
|
||||
---
|
||||
|
||||
## 技术方案
|
||||
|
||||
### 技术架构
|
||||
|
||||
#### 前端技术栈(H5 移动端)
|
||||
| 技术 | 用途 | 说明 |
|
||||
|------|------|------|
|
||||
| React 18 | 前端框架 | 配合 TypeScript |
|
||||
| Ant Design Mobile | UI 组件库 | 移动端组件库 |
|
||||
| Vant | 补充组件 | 必要时补充使用 |
|
||||
| Vite | 构建工具 | 快速开发体验 |
|
||||
| React Router | 路由管理 | 支持多页面应用 |
|
||||
| Zustand / Jotai | 状态管理 | 轻量级方案 |
|
||||
| React Query | 数据请求 | 服务端状态管理 |
|
||||
| PostCSS + pxtorem | 移动端适配 | rem 转换 |
|
||||
| VConsole | 调试工具 | 移动端调试 |
|
||||
|
||||
#### 后端技术栈
|
||||
| 技术 | 用途 | 说明 |
|
||||
|------|------|------|
|
||||
| Webman | PHP 框架 | 高性能常驻内存框架 |
|
||||
| MySQL | 数据库 | 关系型数据库 |
|
||||
| Redis | 缓存 | 会话、缓存存储 |
|
||||
| JWT | 认证 | Token 认证机制 |
|
||||
|
||||
### 多租户架构设计
|
||||
|
||||
**核心概念**:
|
||||
- 系统**不按公司隔离用户**,用户全局唯一
|
||||
- 用户关联所属公司,用于选择对应飞书应用配置
|
||||
- 审批流程跨公司,只识别人员
|
||||
- 飞书仅提供:系统入口 + 人员识别鉴权
|
||||
|
||||
**数据模型**:
|
||||
|
||||
```sql
|
||||
-- 公司表(飞书应用配置)
|
||||
CREATE TABLE company (
|
||||
id BIGINT PRIMARY KEY,
|
||||
name VARCHAR(100),
|
||||
feishu_app_id VARCHAR(100),
|
||||
feishu_app_secret VARCHAR(255),
|
||||
feishu_tenant_key VARCHAR(100),
|
||||
status TINYINT DEFAULT 1
|
||||
);
|
||||
|
||||
-- 用户表(全局用户)
|
||||
CREATE TABLE user (
|
||||
id BIGINT PRIMARY KEY,
|
||||
company_id BIGINT, -- 所属公司
|
||||
feishu_user_id VARCHAR(100), -- 飞书用户ID
|
||||
feishu_union_id VARCHAR(100),-- 飞书UnionID(跨应用唯一)
|
||||
name VARCHAR(50),
|
||||
email VARCHAR(100),
|
||||
phone VARCHAR(20),
|
||||
avatar VARCHAR(255),
|
||||
status TINYINT DEFAULT 1
|
||||
);
|
||||
|
||||
-- 用户与飞书应用关联(支持用户切换不同公司应用)
|
||||
CREATE TABLE user_feishu_binding (
|
||||
id BIGINT PRIMARY KEY,
|
||||
user_id BIGINT,
|
||||
company_id BIGINT,
|
||||
feishu_open_id VARCHAR(100),
|
||||
created_at TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### 项目目录结构(前后端分离)
|
||||
|
||||
```
|
||||
x-server-mobile/ # 项目根目录
|
||||
├── frontend/ # 前端项目(React + Vite)
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # 通用组件
|
||||
│ │ │ ├── approval/ # 审批组件(可复用)
|
||||
│ │ │ ├── layout/ # 布局组件
|
||||
│ │ │ └── common/ # 通用组件
|
||||
│ │ ├── pages/ # 页面
|
||||
│ │ │ ├── dashboard/ # 工具台首页
|
||||
│ │ │ ├── business-trip/ # 出差审批
|
||||
│ │ │ └── profile/ # 个人中心
|
||||
│ │ ├── hooks/ # 自定义 Hooks
|
||||
│ │ ├── services/ # API 服务
|
||||
│ │ ├── stores/ # 状态管理
|
||||
│ │ ├── utils/ # 工具函数
|
||||
│ │ ├── types/ # TypeScript 类型
|
||||
│ │ └── config/ # 配置文件
|
||||
│ ├── public/
|
||||
│ ├── package.json
|
||||
│ └── vite.config.ts
|
||||
│
|
||||
└── backend/ # 后端项目(Webman)
|
||||
├── app/
|
||||
│ ├── controller/
|
||||
│ ├── model/
|
||||
│ ├── service/
|
||||
│ ├── middleware/
|
||||
│ └── util/
|
||||
├── config/
|
||||
├── routes/
|
||||
├── support/
|
||||
├── composer.json
|
||||
└── start.php
|
||||
```
|
||||
|
||||
### 后端项目结构(Webman)
|
||||
```
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── controller/
|
||||
│ │ ├── ApprovalController.php # 审批控制器
|
||||
│ │ ├── BusinessTripController.php # 出差审批控制器
|
||||
│ │ ├── UserController.php # 用户控制器
|
||||
│ │ ├── CompanyController.php # 公司管理控制器
|
||||
│ │ └── BaseController.php # 基础控制器
|
||||
│ ├── model/
|
||||
│ │ ├── Approval.php # 审批模型
|
||||
│ │ ├── ApprovalNode.php # 审批节点模型
|
||||
│ │ ├── BusinessTrip.php # 出差模型
|
||||
│ │ ├── User.php # 用户模型
|
||||
│ │ ├── Company.php # 公司模型
|
||||
│ │ └── UserFeishuBinding.php # 用户飞书绑定模型
|
||||
│ ├── service/
|
||||
│ │ ├── ApprovalService.php # 审批服务
|
||||
│ │ ├── FeishuService.php # 飞书服务(多应用)
|
||||
│ │ ├── FeishuAppManager.php # 飞书应用配置管理
|
||||
│ │ ├── UserService.php # 用户服务
|
||||
│ │ └── CompanyService.php # 公司服务
|
||||
│ ├── middleware/
|
||||
│ │ ├── AuthMiddleware.php # 认证中间件
|
||||
│ │ ├── CompanyResolveMiddleware.php # 公司识别中间件
|
||||
│ │ └── CorsMiddleware.php # 跨域中间件
|
||||
│ └── util/
|
||||
│ ├── JwtUtil.php # JWT工具
|
||||
│ └── Response.php # 响应封装
|
||||
├── config/
|
||||
│ ├── route.php # 路由配置
|
||||
│ ├── database.php # 数据库配置
|
||||
│ ├── redis.php # Redis配置
|
||||
│ └── feishu.php # 飞书应用配置模板
|
||||
├── routes/
|
||||
│ └── api.php # API路由定义
|
||||
└── support/
|
||||
└── Bootstrap.php # 启动文件
|
||||
```
|
||||
|
||||
### API 接口设计
|
||||
|
||||
#### 通用 API 规范
|
||||
```
|
||||
基础URL: /api/v1
|
||||
响应格式:
|
||||
{
|
||||
"code": 0, // 0表示成功
|
||||
"message": "success",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
#### 审批相关接口
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /approval/list | 获取审批列表 |
|
||||
| GET | /approval/detail/:id | 获取审批详情 |
|
||||
| POST | /approval/submit | 提交审批申请 |
|
||||
| POST | /approval/approve/:id | 审批通过 |
|
||||
| POST | /approval/reject/:id | 审批拒绝 |
|
||||
| POST | /approval/cancel/:id | 撤销申请 |
|
||||
| GET | /approval/flow/:type | 获取审批流程配置 |
|
||||
|
||||
#### 出差审批接口
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /business-trip/list | 获取出差列表 |
|
||||
| GET | /business-trip/detail/:id | 获取出差详情 |
|
||||
| POST | /business-trip/apply | 提交出差申请 |
|
||||
| PUT | /business-trip/:id | 更新出差申请(草稿状态) |
|
||||
|
||||
#### 用户认证接口(多飞书应用)
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | /auth/login | 用户登录 |
|
||||
| POST | /auth/feishu-login | 飞书免登(自动识别公司应用) |
|
||||
| GET | /user/info | 获取用户信息 |
|
||||
| GET | /user/menu | 获取用户菜单权限 |
|
||||
| GET | /user/companies | 获取用户可切换的公司列表 |
|
||||
| POST | /user/switch-company | 切换当前公司 |
|
||||
|
||||
#### 飞书应用管理接口(管理端)
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | /admin/company/list | 获取公司列表 |
|
||||
| POST | /admin/company/add | 添加公司飞书应用配置 |
|
||||
| PUT | /admin/company/:id | 更新公司飞书配置 |
|
||||
| DELETE | /admin/company/:id | 删除公司配置 |
|
||||
|
||||
---
|
||||
|
||||
## 功能模块设计
|
||||
|
||||
### 1. 工具台架构(Dashboard - H5 移动端)
|
||||
|
||||
**设计理念**:工具台作为统一入口,支持模块化扩展,专注于移动端体验
|
||||
|
||||
```
|
||||
工具台首页(H5)
|
||||
├── 顶部导航栏
|
||||
│ ├── Logo + 系统名称
|
||||
│ ├── 搜索框(全局搜索)
|
||||
│ ├── 通知图标
|
||||
│ └── 用户头像
|
||||
├── 主内容区
|
||||
│ ├── 待办事项卡片(横向滚动)
|
||||
│ ├── 快捷入口(九宫格/宫格)
|
||||
│ ├── 数据统计卡片
|
||||
│ └── 最新动态列表
|
||||
└── 底部导航栏(TabBar)
|
||||
├── 首页
|
||||
├── 应用(宫格展开所有工具)
|
||||
├── 消息
|
||||
└── 我的
|
||||
```
|
||||
|
||||
**移动端适配要点**:
|
||||
- 使用 TabBar 替代侧边栏(符合移动端习惯)
|
||||
- 750px 设计稿 → rem 自适应转换
|
||||
- 触摸手势支持(下拉刷新、上拉加载)
|
||||
- 安全区域适配(刘海屏、底部横条)
|
||||
|
||||
**关键文件**:
|
||||
- `src/pages/dashboard/index.tsx` - 工具台主页
|
||||
- `src/components/layout/MobileLayout.tsx` - 移动端主布局
|
||||
- `src/components/layout/TabBar.tsx` - 底部导航栏
|
||||
- `src/components/layout/Header.tsx` - 顶部栏
|
||||
|
||||
### 2. 通用审批模块设计(独立工具)
|
||||
|
||||
**设计理念**:审批模块作为独立的通用工具,与具体业务解耦,支持多样化的审批场景
|
||||
|
||||
**核心特性**:
|
||||
- ✅ 业务无关:不绑定特定业务表单和流程
|
||||
- ✅ 流程灵活:支持单级、多级、会签、或签、抄送等任意组合
|
||||
- ✅ 动态配置:审批流程可动态配置和调整
|
||||
- ✅ 易于集成:任何业务都可快速接入审批模块
|
||||
|
||||
**核心数据模型**:
|
||||
|
||||
```typescript
|
||||
// 审批流程节点类型
|
||||
enum ApprovalNodeType {
|
||||
APPROVER = 'approver', // 审批人(单人)
|
||||
AND_APPROVE = 'and_approve', // 会签(需所有人通过)
|
||||
OR_APPROVE = 'or_approve', // 或签(任一人通过)
|
||||
NOTIFY = 'notify' // 抄送(通知用)
|
||||
}
|
||||
|
||||
// 审批单状态
|
||||
enum ApprovalStatus {
|
||||
DRAFT = 'draft', // 草稿
|
||||
PENDING = 'pending', // 审批中
|
||||
APPROVED = 'approved', // 已通过
|
||||
REJECTED = 'rejected', // 已拒绝
|
||||
CANCELLED = 'cancelled' // 已撤销
|
||||
}
|
||||
|
||||
// 审批流程配置(业务无关)
|
||||
interface ApprovalFlow {
|
||||
id: string;
|
||||
businessType: string; // 业务类型标识(如:business-trip, purchase)
|
||||
name: string;
|
||||
nodes: ApprovalNode[]; // 流程节点
|
||||
}
|
||||
|
||||
// 审批节点
|
||||
interface ApprovalNode {
|
||||
id: string;
|
||||
type: ApprovalNodeType;
|
||||
approvers: User[]; // 审批人列表
|
||||
order: number; // 节点顺序
|
||||
conditions?: any; // 条件判断(可选,用于条件分支)
|
||||
}
|
||||
|
||||
// 审批单(业务通用)
|
||||
interface ApprovalOrder {
|
||||
id: string;
|
||||
businessType: string; // 业务类型
|
||||
businessId: string; // 业务单据ID
|
||||
title: string; // 审批标题
|
||||
applicant: User; // 申请人
|
||||
status: ApprovalStatus;
|
||||
currentNodes: string[]; // 当前待审批节点ID
|
||||
flowId: string; // 使用的流程ID
|
||||
formData: any; // 业务表单数据(JSON存储)
|
||||
attachments: Attachment[]; // 附件
|
||||
timeline: ApprovalTimeline[]; // 审批轨迹
|
||||
}
|
||||
```
|
||||
|
||||
**审批模块架构**:
|
||||
|
||||
```
|
||||
src/modules/approval/ # 审批模块(独立)
|
||||
├── components/ # UI组件
|
||||
│ ├── ApprovalFlow.tsx # 流程可视化
|
||||
│ ├── ApprovalDetail.tsx # 审批详情
|
||||
│ ├── ApprovalList.tsx # 审批列表
|
||||
│ └── ApprovalAction.tsx # 审批操作
|
||||
├── hooks/ # 自定义Hooks
|
||||
│ ├── useApprovalSubmit.ts # 提交审批
|
||||
│ ├── useApprovalAction.ts # 审批操作
|
||||
│ └── useApprovalList.ts # 获取列表
|
||||
├── services/ # API服务
|
||||
│ └── approval.ts
|
||||
├── types/ # 类型定义
|
||||
│ └── index.ts
|
||||
└── utils/ # 工具函数
|
||||
├── flowParser.ts # 流程解析
|
||||
└── statusCalculator.ts # 状态计算
|
||||
```
|
||||
|
||||
**业务接入示例(出差审批)**:
|
||||
|
||||
```typescript
|
||||
// 业务模块只需要调用审批模块的Hook
|
||||
import { useApprovalSubmit } from '@/modules/approval/hooks'
|
||||
|
||||
function BusinessTripApply() {
|
||||
const { submitApproval } = useApprovalSubmit('business-trip')
|
||||
|
||||
const handleSubmit = async (formData: BusinessTripFormData) => {
|
||||
// 1. 先保存业务数据
|
||||
const businessTripId = await saveBusinessTrip(formData)
|
||||
|
||||
// 2. 提交审批(传入业务类型和业务ID)
|
||||
await submitApproval({
|
||||
businessId: businessTripId,
|
||||
title: `出差申请 - ${formData.destination}`,
|
||||
formData: formData,
|
||||
flowType: 'default' // 使用的流程模板
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 出差审批模块
|
||||
|
||||
**页面结构**:
|
||||
```
|
||||
出差审批
|
||||
├── 申请列表页
|
||||
│ ├── 我发起的
|
||||
│ ├── 待我审批
|
||||
│ ├── 我已审批
|
||||
│ └── 抄送我的
|
||||
├── 申请表单页
|
||||
│ ├── 基本信息(出差事由、时间、地点)
|
||||
│ ├── 行程明细
|
||||
│ ├── 费用预算
|
||||
│ └── 附件上传
|
||||
└── 审批详情页
|
||||
├── 表单内容展示
|
||||
├── 流程进度可视化
|
||||
├── 审批历史
|
||||
└── 操作按钮
|
||||
```
|
||||
|
||||
**关键文件**:
|
||||
- `src/pages/business-trip/list.tsx` - 列表页
|
||||
- `src/pages/business-trip/apply.tsx` - 申请表单
|
||||
- `src/pages/business-trip/detail.tsx` - 详情页
|
||||
- `src/pages/business-trip/types.ts` - 出差相关类型
|
||||
|
||||
### 4. 飞书集成(多应用架构)
|
||||
|
||||
**集成要点**:
|
||||
|
||||
#### 后端 - 多飞书应用管理
|
||||
```php
|
||||
// 飞书应用管理器
|
||||
class FeishuAppManager
|
||||
{
|
||||
// 根据飞书租户Key获取公司配置
|
||||
public function getCompanyByTenantKey(string $tenantKey): Company
|
||||
{
|
||||
// 从数据库查询对应的飞书应用配置
|
||||
}
|
||||
|
||||
// 获取指定公司的飞书 access_token
|
||||
public function getAccessToken(int $companyId): string
|
||||
{
|
||||
// 缓存 access_token,过期自动刷新
|
||||
}
|
||||
|
||||
// 根据用户ID获取其所属公司的飞书配置
|
||||
public function getUserFeishuConfig(int $userId): array
|
||||
{
|
||||
// 返回用户所属公司的飞书 AppID、AppSecret 等
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 前端 - 飞书 SDK 封装
|
||||
```typescript
|
||||
// src/utils/feishu.ts
|
||||
export const FeishuSDK = {
|
||||
// 获取免登授权码
|
||||
getAuthCode: () => { /* ... */ },
|
||||
|
||||
// 判断是否在飞书环境
|
||||
isFeishuEnv: () => { /* ... */ },
|
||||
|
||||
// 获取当前租户信息(用于后端识别公司)
|
||||
getTenantContext: () => {
|
||||
// 从飞书容器获取 tenant_key 或其他标识
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 飞书免登流程(多应用)
|
||||
```
|
||||
1. 用户在飞书中打开应用
|
||||
2. 前端获取 auth_code 和 tenant_key
|
||||
3. 调用后端 /auth/feishu-login
|
||||
4. 后端根据 tenant_key 查询对应公司配置
|
||||
5. 使用该公司的 AppSecret 换取用户信息
|
||||
6. 生成 JWT Token(包含 user_id 和 company_id)
|
||||
7. 返回登录信息
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 实施步骤
|
||||
|
||||
### 阶段一:项目初始化
|
||||
**前端:**
|
||||
1. 初始化 Vite + React + TypeScript 项目
|
||||
2. 安装 Ant Design 及相关依赖
|
||||
3. 配置路由、状态管理、请求库(axios + React Query)
|
||||
4. 配置 Tailwind CSS
|
||||
|
||||
**后端:**
|
||||
1. 安装 Webman 框架
|
||||
2. 配置 MySQL、Redis 连接
|
||||
3. 配置 JWT 认证
|
||||
4. 设置 CORS 中间件
|
||||
5. **创建多租户数据表(company, user, user_feishu_binding)**
|
||||
|
||||
### 阶段二:工具台框架(H5)
|
||||
**前端:**
|
||||
1. 实现 MobileLayout 移动端布局组件
|
||||
2. 实现 TabBar 底部导航栏
|
||||
3. 实现 Header 组件
|
||||
4. 创建工具台首页 Dashboard(H5 适配)
|
||||
5. 配置 rem 移动端适配
|
||||
|
||||
**后端:**
|
||||
1. 实现 /auth/login 登录接口
|
||||
2. **实现 /auth/feishu-login 飞书免登(多应用识别)**
|
||||
3. 实现 /user/info 用户信息接口
|
||||
4. 实现 /user/menu 菜单权限接口
|
||||
5. **实现 FeishuAppManager 服务(多应用配置管理)**
|
||||
6. **实现 CompanyResolveMiddleware 中间件**
|
||||
|
||||
### 阶段三:独立审批模块(通用工具)
|
||||
**前端:**
|
||||
1. 创建 `src/modules/approval/` 独立模块目录
|
||||
2. 定义审批相关 TypeScript 类型(业务无关)
|
||||
3. 实现 ApprovalFlow 流程可视化组件
|
||||
4. 实现 ApprovalDetail 详情页(通用)
|
||||
5. 实现 ApprovalList 列表页(通用)
|
||||
6. 实现 ApprovalAction 操作组件
|
||||
7. **封装审批 Hooks(useApprovalSubmit, useApprovalAction)**
|
||||
8. **提供业务接入示例**
|
||||
|
||||
**后端:**
|
||||
1. 设计审批流程数据表(approval_flow, approval_node)
|
||||
2. 设计审批单数据表(approval_order, approval_record)
|
||||
3. 实现审批服务层逻辑(单级、多级、会签、或签)
|
||||
4. **确保业务无关性,通过 businessType + businessId 关联业务**
|
||||
5. 实现审批相关 API 接口
|
||||
|
||||
### 阶段四:出差审批业务(作为审批模块的使用示例)
|
||||
**前端:**
|
||||
1. 创建出差申请表单页面
|
||||
2. **调用审批模块的 useApprovalSubmit Hook**
|
||||
3. 创建出差审批列表(复用审批模块组件)
|
||||
4. 创建出差审批详情(复用审批模块组件 + 业务表单展示)
|
||||
|
||||
**后端:**
|
||||
1. 设计出差数据表(business_trip, business_trip_itinerary)
|
||||
2. 实现出差业务逻辑(与审批解耦)
|
||||
3. 实现出差相关 API 接口
|
||||
4. **配置出差审批的默认流程模板**
|
||||
|
||||
### 阶段五:飞书集成与 H5 适配(多应用架构)
|
||||
**前端:**
|
||||
1. 集成飞书 JSAPI SDK
|
||||
2. 实现飞书免登逻辑(获取 tenant_key)
|
||||
3. **H5 移动端样式优化**
|
||||
4. **配置 pxtorem(750px 设计稿适配)**
|
||||
5. **安全区域适配(刘海屏、底部横条)**
|
||||
6. 集成 VConsole 调试工具
|
||||
|
||||
**后端:**
|
||||
1. **实现 FeishuService(支持多应用配置)**
|
||||
2. **实现飞书应用配置管理接口(增删改查)**
|
||||
3. **实现用户切换公司功能**
|
||||
4. **实现飞书消息推送(根据用户所属公司推送)**
|
||||
|
||||
---
|
||||
|
||||
## 待确认问题
|
||||
|
||||
1. **飞书应用配置**:是否有飞书应用的 App ID、App Secret?
|
||||
2. **权限设计**:是否需要 RBAC 权限系统?
|
||||
3. **数据 Mock**:开发阶段是否需要 Mock 数据?
|
||||
4. **部署环境**:开发/测试/生产环境配置?
|
||||
|
||||
---
|
||||
|
||||
## 关键文件清单
|
||||
|
||||
### 前端核心文件(H5)
|
||||
| 文件路径 | 说明 |
|
||||
|---------|------|
|
||||
| `src/App.tsx` | 应用入口 |
|
||||
| `src/main.tsx` | React 挂载点 |
|
||||
| `src/router/index.tsx` | 路由配置 |
|
||||
| `src/components/layout/MobileLayout.tsx` | 移动端主布局 |
|
||||
| `src/components/layout/TabBar.tsx` | 底部导航栏 |
|
||||
| `src/components/layout/Header.tsx` | 顶部栏 |
|
||||
| `src/pages/dashboard/index.tsx` | 工具台首页 |
|
||||
| `src/modules/approval/` | **审批模块(独立)** |
|
||||
| `src/pages/business-trip/` | 出差审批业务 |
|
||||
| `src/services/api.ts` | API 基础配置 |
|
||||
| `src/utils/feishu.ts` | 飞书 SDK 封装 |
|
||||
| `src/config/rem.ts` | rem 转换配置 |
|
||||
| `package.json` | 项目依赖 |
|
||||
| `vite.config.ts` | Vite 配置 |
|
||||
| `tsconfig.json` | TypeScript 配置 |
|
||||
| `postcss.config.js` | PostCSS 配置(pxtorem) |
|
||||
|
||||
### 后端核心文件
|
||||
| 文件路径 | 说明 |
|
||||
|---------|------|
|
||||
| `app/controller/BaseController.php` | 基础控制器 |
|
||||
| `app/controller/ApprovalController.php` | 审批控制器 |
|
||||
| `app/controller/BusinessTripController.php` | 出差审批控制器 |
|
||||
| `app/controller/UserController.php` | 用户控制器 |
|
||||
| `app/model/Approval.php` | 审批模型 |
|
||||
| `app/model/ApprovalNode.php` | 审批节点模型 |
|
||||
| `app/service/ApprovalService.php` | 审批服务 |
|
||||
| `app/service/FeishuService.php` | 飞书服务 |
|
||||
| `app/middleware/AuthMiddleware.php` | 认证中间件 |
|
||||
| `config/route.php` | 路由配置 |
|
||||
| `config/database.php` | 数据库配置 |
|
||||
| `routes/api.php` | API 路由定义 |
|
||||
|
||||
---
|
||||
|
||||
## 验证计划
|
||||
|
||||
### 前端验证(H5)
|
||||
1. **运行项目**:`npm run dev` 确保项目启动
|
||||
2. **路由测试**:访问各个页面确保路由正常
|
||||
3. **审批模块测试**:验证审批组件的通用性和可复用性
|
||||
4. **H5 适配测试**:
|
||||
- 测试不同屏幕尺寸(375px - 768px)
|
||||
- 测试刘海屏和底部横条适配
|
||||
- 测试触摸手势交互
|
||||
5. **飞书环境测试**:
|
||||
- 在飞书中测试免登
|
||||
- 在飞书中测试 H5 适配
|
||||
- 测试多公司应用切换
|
||||
6. **真机测试**:在真实移动设备上测试
|
||||
|
||||
### 后端验证
|
||||
1. **启动 Webman**:`php start.php start` 确保服务启动
|
||||
2. **API 测试**:使用 Postman/Apifox 测试各接口
|
||||
3. **数据库测试**:验证数据表创建和 CRUD 操作
|
||||
4. **认证测试**:验证 JWT 认证流程
|
||||
5. **多租户测试**:
|
||||
- 测试多公司飞书应用配置
|
||||
- 测试跨公司审批流程
|
||||
6. **飞书集成测试**:验证飞书免登和消息推送
|
||||
-356
@@ -1,356 +0,0 @@
|
||||
# X-Server Mobile 企业工具系统
|
||||
|
||||
## 项目概述
|
||||
|
||||
一个基于飞书的企业内部H5移动工具平台,采用多租户架构,支持审批流程引擎和多种业务应用。
|
||||
|
||||
**技术栈:**
|
||||
- 前端:React 18 + TypeScript + Vite + Ant Design Mobile
|
||||
- 后端:PHP 8.5 + Webman 2.x + MySQL + Redis
|
||||
- 部署:Docker + OpenResty + frpc (开发环境)
|
||||
|
||||
**支持语言:**
|
||||
- 🇨🇳 简体中文 (zh)
|
||||
- 🇬🇧 English (en)
|
||||
- 🇷🇺 Русский (ru)
|
||||
- 🇪🇸 Español (es)
|
||||
- 🇫🇷 Français (fr)
|
||||
|
||||
## 国际化 (i18n)
|
||||
|
||||
### 前端国际化
|
||||
|
||||
**位置:** `frontend/src/i18n/`
|
||||
|
||||
**使用方式:**
|
||||
```tsx
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
function MyComponent() {
|
||||
const { t } = useTranslation()
|
||||
return <div>{t('common.confirm')}</div>
|
||||
}
|
||||
```
|
||||
|
||||
**切换语言:**
|
||||
```tsx
|
||||
import { showLanguageSwitcher } from '@/components/LanguageSwitcher'
|
||||
|
||||
// 显示语言选择弹窗
|
||||
showLanguageSwitcher()
|
||||
```
|
||||
|
||||
**语言存储:** 浏览器 localStorage,键名为 `i18nextLng`
|
||||
|
||||
### 后端国际化
|
||||
|
||||
**位置:** `backend/lang/`
|
||||
|
||||
**使用方式:**
|
||||
```php
|
||||
use App\Util\Lang;
|
||||
|
||||
// 获取翻译
|
||||
$message = Lang::get('common.confirm');
|
||||
|
||||
// 带参数的翻译
|
||||
$message = Lang::get('approval.status', ['status' => 'pending']);
|
||||
|
||||
// 检查翻译是否存在
|
||||
if (Lang::has('common.submit')) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**设置语言:**
|
||||
```php
|
||||
// 从请求检测语言
|
||||
$locale = Lang::detectFromRequest();
|
||||
Lang::setLocale($locale);
|
||||
```
|
||||
|
||||
## 项目关键特性
|
||||
|
||||
### 1. 多租户架构
|
||||
|
||||
- 用户全局唯一,关联多个公司
|
||||
- 租户切换功能
|
||||
- 公司级数据隔离
|
||||
|
||||
### 2. 审批流程引擎
|
||||
|
||||
**支持节点类型:**
|
||||
- 单人审批 (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/` - 前端审批模块
|
||||
|
||||
### 3. 飞书集成
|
||||
|
||||
**飞书免登:**
|
||||
- 回调地址:`/api/v1/auth/feishu-login`
|
||||
- 配置项:
|
||||
- `FEISHU_APP_ID` - 飞书应用 ID
|
||||
- `FEISHU_APP_SECRET` - 飞书应用密钥
|
||||
- `FEISHU_ENCRYPT_KEY` - 加密密钥
|
||||
- `FEISHU_VERIFICATION_TOKEN` - 验证令牌
|
||||
|
||||
### 4. 数据库设计
|
||||
|
||||
**核心表:**
|
||||
- `users` - 用户表(全局唯一)
|
||||
- `companies` - 公司表
|
||||
- `user_company` - 用户公司关联表
|
||||
- `approval_flows` - 审批流程定义
|
||||
- `approval_nodes` - 审批节点
|
||||
- `approval_orders` - 审批单
|
||||
- `approval_records` - 审批记录
|
||||
- `business_trips` - 出差申请
|
||||
- `business_trip_items` - 出差明细
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
x-server-mobile/
|
||||
├── frontend/ # React 前端
|
||||
│ ├── src/
|
||||
│ │ ├── i18n/ # 国际化配置
|
||||
│ │ │ ├── locales/ # 翻译文件
|
||||
│ │ │ └── index.ts
|
||||
│ │ ├── components/ # 通用组件
|
||||
│ │ │ ├── layout/ # 布局组件
|
||||
│ │ │ └── LanguageSwitcher/
|
||||
│ │ ├── modules/ # 业务模块
|
||||
│ │ │ └── approval/ # 审批模块
|
||||
│ │ ├── pages/ # 页面组件
|
||||
│ │ ├── stores/ # 状态管理
|
||||
│ │ ├── router/ # 路由配置
|
||||
│ │ └── utils/ # 工具函数
|
||||
│ └── package.json
|
||||
├── backend/ # PHP 后端
|
||||
│ ├── app/
|
||||
│ │ ├── Controller/ # 控制器
|
||||
│ │ ├── Model/ # 数据模型
|
||||
│ │ ├── Service/ # 业务逻辑
|
||||
│ │ ├── Util/ # 工具类
|
||||
│ │ │ ├── Lang.php # 国际化工具
|
||||
│ │ │ └── Db.php # 数据库连接
|
||||
│ │ └── Middleware/ # 中间件
|
||||
│ ├── config/ # 配置文件
|
||||
│ ├── lang/ # 语言文件
|
||||
│ ├── database/migrations/ # 数据库迁移
|
||||
│ ├── composer.json
|
||||
│ └── Dockerfile
|
||||
├── docker-compose.yml # Docker 编排
|
||||
├── nginx/ # OpenResty 配置
|
||||
├── frpc/ # frpc 配置
|
||||
└── IMPLEMENTATION_PLAN.md # 实现计划
|
||||
```
|
||||
|
||||
## 开发环境设置
|
||||
|
||||
### 端口分配
|
||||
|
||||
| 服务 | 端口 |
|
||||
|------|------|
|
||||
| Frontend (Vite) | 5173 |
|
||||
| Backend (Webman) | 8787 |
|
||||
| OpenResty | 8080 |
|
||||
| MySQL | 3306 |
|
||||
| Redis | 6379 |
|
||||
|
||||
### 环境变量
|
||||
|
||||
**前端 (frontend/.env):**
|
||||
```env
|
||||
VITE_API_BASE_URL=http://localhost:8787/api/v1
|
||||
```
|
||||
|
||||
**后端 (backend/.env):**
|
||||
```env
|
||||
DB_HOST=mysql
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=x_server_mobile
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=mysql_xEsdPa
|
||||
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
|
||||
APP_DEBUG=true
|
||||
|
||||
# 飞书配置
|
||||
FEISHU_APP_ID=cli_xxxxxxxxxxxxx
|
||||
FEISHU_APP_SECRET=xxxxxxxxxxxxxxxxxxxx
|
||||
FEISHU_ENCRYPT_KEY=xxxxxxxxxxxxxxxxxxxx
|
||||
FEISHU_VERIFICATION_TOKEN=xxxxxxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
### 反向代理配置(开发环境)
|
||||
|
||||
**frpc 配置:** `frpc/frpc-dev.ini`
|
||||
```ini
|
||||
[common]
|
||||
server_addr = your-frp-server.com
|
||||
server_port = 7000
|
||||
token = your-frp-token
|
||||
|
||||
[x-server-api-dev]
|
||||
type = http
|
||||
local_ip = 127.0.0.1
|
||||
local_port = 8080
|
||||
custom_domains = api-dev.x-server.your-domain.com
|
||||
```
|
||||
|
||||
**OpenResty 配置:** `nginx/x-server-api.conf`
|
||||
```nginx
|
||||
server {
|
||||
listen 8080;
|
||||
server_name api.x-server.local;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8787/api/;
|
||||
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS';
|
||||
add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,X-Tenant-Key';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
### 认证接口
|
||||
|
||||
- `POST /api/v1/auth/login` - 用户名密码登录
|
||||
- `POST /api/v1/auth/feishu-login` - 飞书免登
|
||||
|
||||
### 用户接口
|
||||
|
||||
- `GET /api/v1/user/info` - 获取用户信息
|
||||
- `GET /api/v1/user/menu` - 获取用户菜单
|
||||
- `GET /api/v1/user/companies` - 获取公司列表
|
||||
- `POST /api/v1/user/switch-company` - 切换公司
|
||||
|
||||
### 审批接口
|
||||
|
||||
- `GET /api/v1/approval/list` - 审批列表
|
||||
- `GET /api/v1/approval/detail/{id}` - 审批详情
|
||||
- `POST /api/v1/approval/submit` - 提交审批
|
||||
- `POST /api/v1/approval/approve/{id}` - 审批通过
|
||||
- `POST /api/v1/approval/reject/{id}` - 审批拒绝
|
||||
- `POST /api/v1/approval/cancel/{id}` - 撤销申请
|
||||
|
||||
### 出差接口
|
||||
|
||||
- `GET /api/v1/business-trip/list` - 出差列表
|
||||
- `GET /api/v1/business-trip/detail/{id}` - 出差详情
|
||||
- `POST /api/v1/business-trip/apply` - 申请出差
|
||||
- `PUT /api/v1/business-trip/{id}` - 更新申请
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 前端开发
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install # 安装依赖
|
||||
npm run dev # 启动开发服务器
|
||||
npm run build # 构建生产版本
|
||||
```
|
||||
|
||||
### 后端开发
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
composer install # 安装依赖
|
||||
php start.php start # 启动 Webman
|
||||
php start.php stop # 停止 Webman
|
||||
```
|
||||
|
||||
### Docker 开发
|
||||
|
||||
```bash
|
||||
# 启动所有服务
|
||||
docker compose up -d
|
||||
|
||||
# 仅启动后端
|
||||
docker compose up -d backend
|
||||
|
||||
# 查看日志
|
||||
docker logs x-server-backend -f
|
||||
|
||||
# 重启服务
|
||||
docker compose restart backend
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 如何添加新的翻译文本?
|
||||
|
||||
**前端:** 在 `frontend/src/i18n/locales/` 对应语言的 JSON 文件中添加
|
||||
```json
|
||||
{
|
||||
"myModule": {
|
||||
"myKey": "翻译文本"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**后端:** 在 `backend/lang/` 对应语言的 PHP 文件中添加
|
||||
```php
|
||||
return [
|
||||
'myModule' => [
|
||||
'myKey' => '翻译文本',
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
### Q: 如何添加新的应用模块?
|
||||
|
||||
1. 在 `frontend/src/pages/` 创建新页面
|
||||
2. 在 `frontend/src/router/index.tsx` 添加路由
|
||||
3. 在 `backend/app/Controller/` 创建控制器
|
||||
4. 在 `backend/config/route.php` 添加 API 路由
|
||||
|
||||
### Q: 数据库连接失败怎么办?
|
||||
|
||||
1. 检查 MySQL 容器是否运行:`docker ps | grep mysql`
|
||||
2. 检查数据库配置:`backend/.env`
|
||||
3. 检查网络连接:`docker network ls | grep 1panel-network`
|
||||
|
||||
### Q: 前端页面白屏?
|
||||
|
||||
1. 检查浏览器控制台错误
|
||||
2. 检查 React 版本:确保是 18.3.1
|
||||
3. 检查路由配置
|
||||
4. 尝试清除缓存:`rm -rf frontend/node_modules/.vite`
|
||||
|
||||
## 安全建议
|
||||
|
||||
1. **生产环境务必使用 HTTPS**
|
||||
2. **定期更新依赖包**
|
||||
3. **配置防火墙规则**
|
||||
4. **使用强密码**
|
||||
5. **定期备份数据库**
|
||||
6. **不要在代码中硬编码敏感信息**
|
||||
|
||||
## 更新日志
|
||||
|
||||
### 2024-02-24
|
||||
- ✅ 升级 PHP 到 8.5
|
||||
- ✅ 添加 5 语言国际化支持
|
||||
- ✅ 配置 Docker 开发环境
|
||||
- ✅ 实现审批流程引擎基础框架
|
||||
- ✅ 完成前端基础页面和路由
|
||||
@@ -1,941 +0,0 @@
|
||||
# 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 再切换回来才显示
|
||||
|
||||
**原因(版本1)**:
|
||||
- React 18 StrictMode 导致组件快速 mount/unmount
|
||||
- useEffect cleanup 函数中清空了 requestIdRef,导致正在进行的 API 请求响应被跳过
|
||||
|
||||
**原因(版本2 - 最终问题)**:
|
||||
- 使用 `loadingRef.current` 检查是否正在加载
|
||||
- React 18 StrictMode 导致 useEffect 执行两次
|
||||
- 第一次请求设置 `loadingRef.current = true`
|
||||
- 第二次请求检查 `loadingRef.current` 为 true,被阻塞
|
||||
- 导致第二次请求没有发送,数据无法加载
|
||||
|
||||
**解决方案(最终版本)**:
|
||||
```typescript
|
||||
// 移除 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`
|
||||
|
||||
**解决方案**:
|
||||
```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`),避免容器退出
|
||||
|
||||
### 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`
|
||||
|
||||
### 23. 发起人自动审批通过逻辑
|
||||
|
||||
**需求**:当发起人选择的审批人中包含自己时,提交时自动标记为已审批
|
||||
|
||||
**实现逻辑**:
|
||||
1. 检查发起人是否在审批人列表中
|
||||
2. 如果是,自动创建审批通过记录
|
||||
3. 根据节点类型判断是否完成:
|
||||
- 单人审批(approver):发起人审批后节点完成
|
||||
- 或签(or_approve):发起人审批后节点完成
|
||||
- 会签(and_approve):需要所有审批人审批才完成
|
||||
4. 节点完成后移动到下一节点或完成审批
|
||||
|
||||
**解决方案**:
|
||||
```php
|
||||
// 如果发起人在审批人列表中,自动标记为已审批
|
||||
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` - 隐藏 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 字段
|
||||
|
||||
---
|
||||
|
||||
## 24. 多端飞书登录功能实现
|
||||
|
||||
### 需求背景
|
||||
支持两种登录场景:
|
||||
1. **飞书客户端内**:静默自动登录,无需用户操作
|
||||
2. **飞书客户端外**:邮箱密码登录 + 飞书扫码登录
|
||||
|
||||
### 实现方案
|
||||
|
||||
#### 1. 飞书客户端内 - 静默授权
|
||||
**环境检测**:
|
||||
```typescript
|
||||
const ua = navigator.userAgent.toLowerCase()
|
||||
const isInFeishu = ua.includes('feishu') || ua.includes('lark') ||
|
||||
(window as any).ttjsBridge !== undefined
|
||||
```
|
||||
|
||||
**调用 ttjsBridge 获取授权码**:
|
||||
```typescript
|
||||
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. 飞书客户端外 - 扫码登录
|
||||
|
||||
**后端接口**:
|
||||
1. `POST /api/v1/auth/qrcode-login` - 生成登录会话和二维码 URL
|
||||
2. `GET /api/v1/auth/qrcode-login/status?session_id={id}` - 轮询登录状态
|
||||
3. `GET /login/qrcode/{id}` - 飞书授权回调地址
|
||||
|
||||
**数据库表**:`feishu_login_session`
|
||||
- `session_id` - 会话 ID(UUID)
|
||||
- `status` - pending | scanning | success | expired
|
||||
- `auth_url` - 飞书授权 URL
|
||||
- `expires_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
|
||||
自动登录成功
|
||||
```
|
||||
|
||||
### 关键技术点
|
||||
|
||||
1. **ttjsBridge 类型声明**:
|
||||
```typescript
|
||||
declare global {
|
||||
interface Window {
|
||||
ttjsBridge?: {
|
||||
getAuthCode: (params: any) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **登录成功事件通知**:
|
||||
```typescript
|
||||
// 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)
|
||||
}
|
||||
}, [])
|
||||
```
|
||||
|
||||
3. **二维码状态管理**:
|
||||
```typescript
|
||||
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' // 红色 - 过期
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 依赖安装
|
||||
```bash
|
||||
npm install qrcode.react
|
||||
```
|
||||
|
||||
### 数据库迁移
|
||||
```sql
|
||||
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
|
||||
|
||||
**原因**:
|
||||
1. React Router 的 `navigate(-1)` 不传递 `location.state`
|
||||
2. 页面组件重新挂载时,`useState` 初始值恢复为默认值
|
||||
3. 即使使用 `navigate(path, { state: { fromTab: activeTab } })`,`navigate(-1)` 时 state 仍然为 null
|
||||
|
||||
**解决方案**:
|
||||
使用 `sessionStorage` 持久化 tab 状态,而不是依赖 `location.state`:
|
||||
|
||||
```typescript
|
||||
// 优先从 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.tsx`
|
||||
- `frontend/src/pages/business-trip/list.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 26. "我已审批"列表不显示已审批订单
|
||||
|
||||
**问题**:用户审批完成后,在"我已审批"列表中看不到该订单
|
||||
|
||||
**根本原因**:架构问题 - 数据模型混淆
|
||||
|
||||
### 问题分析
|
||||
|
||||
审批系统中有两个数据来源,它们的职责混淆了:
|
||||
|
||||
1. **`approval_node.approvers`** - 静态模板数据
|
||||
- 用途:定义审批流程模板中每个节点的默认审批人配置
|
||||
- 问题:运行时不会根据用户实际选择的审批人更新
|
||||
|
||||
2. **`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()** - 判断节点是否完成
|
||||
```php
|
||||
// ❌ 旧代码:从 approval_node 获取审批人(错误)
|
||||
$node = ApprovalNode::findOrFail($nodeId);
|
||||
$approvers = $node->approvers ?? []; // 静态模板数据,可能为空
|
||||
|
||||
// ✅ 新代码:从 form_data 获取审批人(正确)
|
||||
$order = ApprovalOrder::find($orderId);
|
||||
$formData = $order->form_data ?? [];
|
||||
$approvers = $formData['approvers'] ?? []; // 运行时数据,用户实际选择
|
||||
```
|
||||
|
||||
**ApprovalService::getPendingOrderIds()** - 获取待审批订单
|
||||
```php
|
||||
// ❌ 旧代码
|
||||
$node = ApprovalNode::whereIn('id', $currentNodeIds)->first();
|
||||
$approvers = $node->approvers ?? [];
|
||||
|
||||
// ✅ 新代码
|
||||
$formData = $order->form_data ?? [];
|
||||
$approvers = $formData['approvers'] ?? [];
|
||||
```
|
||||
|
||||
**兼容性处理**:
|
||||
```php
|
||||
// 兼容两种格式:[{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)不应该阻止流程完成,应该自动跳过
|
||||
|
||||
**解决方案**:
|
||||
```php
|
||||
// 移动到下一节点
|
||||
$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` | 用户提交时实际选择的审批人 | 判断审批状态的正确来源 |
|
||||
|
||||
### 为什么会反复出现问题
|
||||
|
||||
1. **两个服务使用不同数据源**
|
||||
- `BusinessTripService` 修复后使用 `form_data`
|
||||
- `ApprovalService` 仍使用 `approval_node`
|
||||
- 导致相同操作在不同页面结果不一致
|
||||
|
||||
2. **没有明确的数据访问规范**
|
||||
- 开发时容易混淆应该读取哪个数据源
|
||||
- 缺少代码注释说明数据来源的用途
|
||||
|
||||
3. **缺少单元测试**
|
||||
- 数据逻辑变更后没有自动化测试验证
|
||||
- 依赖手工测试,容易遗漏边界情况
|
||||
|
||||
### 长期解决方案
|
||||
|
||||
1. **代码层面**:
|
||||
- 在 `ApprovalNode` 模型上添加注释,明确 `approvers` 是模板数据
|
||||
- 在 `ApprovalOrder` 模型上添加辅助方法 `getRuntimeApprovers()`,统一访问入口
|
||||
- 添加单元测试覆盖所有审批流程场景
|
||||
|
||||
2. **数据库层面**:
|
||||
- 添加外键约束确保数据一致性
|
||||
- 考虑将 `approval_node.approvers` 重命名为 `default_approvers`,更清晰表达语义
|
||||
|
||||
3. **文档层面**:
|
||||
- 在开发文档中明确说明数据模型的设计原则
|
||||
- 提供常见问题的排查指南
|
||||
|
||||
---
|
||||
|
||||
## 29. 数据库完整性建议
|
||||
|
||||
### 当前问题
|
||||
- `approval_order` 表记录可能被孤立(业务表引用了不存在的 approval_order_id)
|
||||
- 没有外键约束确保引用完整性
|
||||
- 清理数据时可能遗留孤立记录
|
||||
|
||||
### 建议的修复方案
|
||||
|
||||
```sql
|
||||
-- 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.approvers
|
||||
- `8ce070b` - fix: 使用 sessionStorage 保持 tab 状态
|
||||
- `f3a9e44` - fix: 返回列表时保持之前的 tab 状态
|
||||
- `ea7f0f8` - feat: 添加"抄送我的"审批列表选项
|
||||
@@ -1,142 +0,0 @@
|
||||
# 反向代理配置指南
|
||||
|
||||
本指南用于配置 OpenResty 和 frpc 以将 API 暴露到公网,便于调试飞书登录。
|
||||
|
||||
## 环境要求
|
||||
|
||||
- 已安装 OpenResty 或 Nginx
|
||||
- 已安装 frpc
|
||||
- 拥有可用的 frp 服务器
|
||||
- 拥有可配置的域名
|
||||
|
||||
## 安装步骤
|
||||
|
||||
### 1. 安装 OpenResty (Ubuntu/Debian)
|
||||
|
||||
```bash
|
||||
# 导入 GPG 密钥
|
||||
wget -qO - https://openresty.org/package/pubkey.gpg | sudo apt-key add -
|
||||
|
||||
# 添加 OpenResty 仓库
|
||||
sudo apt-get -y install -y wget gnupg ca-certificates
|
||||
wget -O - https://openresty.org/package/pubkey.gpg | sudo apt-key add -
|
||||
echo "deb http://openresty.org/package/ubuntu $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/openresty.list
|
||||
|
||||
# 安装 OpenResty
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y openresty
|
||||
|
||||
# 启动服务
|
||||
sudo systemctl start openresty
|
||||
sudo systemctl enable openresty
|
||||
```
|
||||
|
||||
### 2. 配置 OpenResty
|
||||
|
||||
将 `nginx/x-server-api.conf` 复制到 OpenResty 配置目录:
|
||||
|
||||
```bash
|
||||
sudo cp nginx/x-server-api.conf /usr/local/openresty/nginx/conf/conf.d/
|
||||
sudo mkdir -p /usr/local/openresty/nginx/logs
|
||||
sudo openresty -s reload
|
||||
```
|
||||
|
||||
### 3. 安装 frpc
|
||||
|
||||
```bash
|
||||
# 下载 frpc(根据你的系统选择版本)
|
||||
wget https://github.com/fatedier/frp/releases/download/v0.52.3/frp_0.52.3_linux_amd64.tar.gz
|
||||
|
||||
# 解压
|
||||
tar xzf frp_0.52.3_linux_amd64.tar.gz
|
||||
cd frp_0.52.3_linux_amd64
|
||||
|
||||
# 复制到系统路径
|
||||
sudo cp frpc /usr/local/bin/
|
||||
sudo chmod +x /usr/local/bin/frpc
|
||||
```
|
||||
|
||||
### 4. 配置 frpc
|
||||
|
||||
编辑 `frpc/frpc.ini`,填入你的 frp 服务器信息:
|
||||
|
||||
```ini
|
||||
[common]
|
||||
server_addr = your-frp-server.com # 你的 frp 服务器地址
|
||||
server_port = 7000 # frp 服务器端口
|
||||
token = your-frp-token # 认证令牌
|
||||
|
||||
[x-server-api]
|
||||
type = http
|
||||
local_ip = 127.0.0.1
|
||||
local_port = 8787
|
||||
custom_domains = api.x-server.your-domain.com # 你的公网域名
|
||||
```
|
||||
|
||||
### 5. 启动 frpc
|
||||
|
||||
```bash
|
||||
# 前台运行(测试)
|
||||
frpc -c frpc/frpc.ini
|
||||
|
||||
# 后台运行(生产)
|
||||
nohup frpc -c frpc/frpc.ini > /dev/null 2>&1 &
|
||||
```
|
||||
|
||||
### 6. 配置 DNS
|
||||
|
||||
将你的域名 `api.x-server.your-domain.com` 的 A 记录指向你的 frp 服务器 IP。
|
||||
|
||||
### 7. 配置飞书应用
|
||||
|
||||
在飞书开放平台配置回调 URL:
|
||||
|
||||
```
|
||||
https://api.x-server.your-domain.com/api/v1/auth/feishu-login
|
||||
```
|
||||
|
||||
## 验证配置
|
||||
|
||||
### 测试本地 API
|
||||
|
||||
```bash
|
||||
curl http://localhost:8787/api/v1/user/info
|
||||
```
|
||||
|
||||
### 测试反向代理
|
||||
|
||||
```bash
|
||||
curl http://api.x-server.local/api/v1/user/info
|
||||
```
|
||||
|
||||
### 测试公网访问
|
||||
|
||||
```bash
|
||||
curl https://api.x-server.your-domain.com/api/v1/user/info
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### frpc 连接失败
|
||||
|
||||
1. 检查 frp 服务器是否在线
|
||||
2. 检查 token 是否正确
|
||||
3. 检查端口是否被防火墙阻止
|
||||
|
||||
### 域名无法访问
|
||||
|
||||
1. 检查 DNS 解析是否生效
|
||||
2. 检查 frp 服务器是否配置了该域名
|
||||
3. 检查 frpc 日志
|
||||
|
||||
### CORS 错误
|
||||
|
||||
如果前端调用 API 时出现 CORS 错误,检查 nginx 配置中的 CORS 头部设置。
|
||||
|
||||
## 安全建议
|
||||
|
||||
1. **生产环境务必使用 HTTPS**
|
||||
2. 配置 IP 白名单限制访问
|
||||
3. 使用强密码和 token
|
||||
4. 定期更新 frp 和 OpenResty 版本
|
||||
5. 配置防火墙规则
|
||||
@@ -1,109 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace app\controller;
|
||||
|
||||
use app\service\FeishuNotificationService;
|
||||
use Webman\Http\Request;
|
||||
use Webman\Http\Response;
|
||||
|
||||
class TestController extends BaseController
|
||||
{
|
||||
/**
|
||||
* 测试发送飞书通知
|
||||
*/
|
||||
public function testNotification(Request $request): Response
|
||||
{
|
||||
$userId = (int) ($request->get('user_id') ?? 1);
|
||||
$orderId = (int) ($request->get('order_id') ?? 0);
|
||||
$real = (int) ($request->get('real') ?? 0); // real=1 发送真实业务通知
|
||||
|
||||
try {
|
||||
$notification = new FeishuNotificationService();
|
||||
|
||||
// 获取用户的company_id和feishu_app_id
|
||||
$user = \app\model\User::find($userId);
|
||||
if (!$user) {
|
||||
return $this->error('用户不存在');
|
||||
}
|
||||
|
||||
if (!$user->feishu_app_id) {
|
||||
return $this->error('用户未绑定飞书应用');
|
||||
}
|
||||
|
||||
$notification->setCompany($user->company_id);
|
||||
$notification->setApp($user->feishu_app_id);
|
||||
|
||||
// 如果 real=1,发送真实业务通知
|
||||
if ($real && $orderId) {
|
||||
$order = \app\model\ApprovalOrder::find($orderId);
|
||||
if (!$order) {
|
||||
return $this->error('审批单不存在');
|
||||
}
|
||||
|
||||
// 获取业务数据
|
||||
$businessTrip = \app\model\BusinessTrip::find($order->business_id);
|
||||
if (!$businessTrip) {
|
||||
return $this->error('出差申请不存在');
|
||||
}
|
||||
|
||||
// 构建真实业务内容
|
||||
$detailUrl = "https://xsm.dev.hnxinyouhe.com/business-trip/detail/{$businessTrip->id}";
|
||||
$content = "**出差原因**: {$businessTrip->reason}\n";
|
||||
$content .= "**目的地**: {$businessTrip->destination}\n";
|
||||
$content .= "**时间**: {$businessTrip->start_date} 至 {$businessTrip->end_date}";
|
||||
|
||||
$params = [
|
||||
'user_id' => $userId,
|
||||
'title' => '新的出差审批申请',
|
||||
'applicant' => $businessTrip->user->name,
|
||||
'type' => '出差申请',
|
||||
'content' => $content,
|
||||
'url' => $detailUrl,
|
||||
'order_id' => $orderId,
|
||||
];
|
||||
|
||||
$note = '真实业务通知(含审批按钮)';
|
||||
} else {
|
||||
// 发送测试消息
|
||||
$params = [
|
||||
'user_id' => $userId,
|
||||
'title' => '测试审批通知',
|
||||
'applicant' => '系统测试',
|
||||
'type' => '测试申请',
|
||||
'content' => '这是一条测试消息,用于验证飞书通知功能是否正常工作。如果看到"通过"和"拒绝"按钮,说明按钮功能正常。',
|
||||
'url' => $orderId ? "https://xsm.dev.hnxinyouhe.com/approval/detail/{$orderId}" : 'https://xsm.dev.hnxinyouhe.com/dashboard',
|
||||
];
|
||||
|
||||
// 如果提供了 order_id,添加审批按钮
|
||||
if ($orderId) {
|
||||
$params['order_id'] = $orderId;
|
||||
}
|
||||
|
||||
$note = $orderId ? '测试消息(含审批按钮)' : '测试消息(不含审批按钮)';
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
$result = $notification->sendApprovalNotification($params);
|
||||
|
||||
if ($result) {
|
||||
// 获取用户在该应用下的feishu_user_id
|
||||
$feishuUserId = $user->getFeishuUserId($user->feishu_app_id);
|
||||
|
||||
return $this->success([
|
||||
'message' => '消息发送成功',
|
||||
'user_id' => $userId,
|
||||
'user_name' => $user->name,
|
||||
'feishu_user_id' => $feishuUserId,
|
||||
'feishu_app_id' => $user->feishu_app_id,
|
||||
'has_order_id' => !empty($orderId),
|
||||
'type' => $real ? '真实业务通知' : '测试消息',
|
||||
'note' => $note,
|
||||
]);
|
||||
} else {
|
||||
return $this->error('消息发送失败,请查看日志');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('测试失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,529 +0,0 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
require_once __DIR__ . '/support/bootstrap.php';
|
||||
|
||||
use support\Db;
|
||||
|
||||
$userId1 = 1; // 李佳博
|
||||
$userId2 = 2; // 闫继光
|
||||
$userId3 = 3; // 孟启
|
||||
|
||||
// 获取出差审批流程
|
||||
$flow = Db::table('approval_flow')->where('business_type', 'business_trip')->where('status', 1)->first();
|
||||
|
||||
if (!$flow) {
|
||||
echo "Error: No approval flow found\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
echo "Creating test data...\n";
|
||||
|
||||
// 首先删除现有的测试数据(只保留测试数据,id > 10)
|
||||
echo "Cleaning up old test data...\n";
|
||||
Db::table('approval_record')->where('order_id', '>', 10)->delete();
|
||||
Db::table('business_trip_itinerary')->whereIn('business_trip_id', function($q) {
|
||||
$q->select('id')->from('business_trip')->where('id', '>', 10);
|
||||
})->delete();
|
||||
Db::table('business_trip')->where('id', '>', 10)->delete();
|
||||
Db::table('approval_order')->where('id', '>', 10)->delete();
|
||||
echo "Cleanup completed.\n";
|
||||
|
||||
// ========== 用户1(李佳博)发起的订单 ==========
|
||||
|
||||
// 1. 审批中 - 用户1发起的,等待用户2审批
|
||||
$trip1 = Db::table('business_trip')->insertGetId([
|
||||
'user_id' => $userId1,
|
||||
'reason' => '杭州客户洽谈',
|
||||
'start_date' => '2026-03-25',
|
||||
'end_date' => '2026-03-26',
|
||||
'destination' => '杭州',
|
||||
'estimated_cost' => 3000,
|
||||
'status' => 'pending',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-1 hour')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-1 hour')),
|
||||
]);
|
||||
|
||||
Db::table('business_trip_itinerary')->insert([
|
||||
'business_trip_id' => $trip1,
|
||||
'start_time' => '2026-03-25 08:00:00',
|
||||
'end_time' => '2026-03-26 18:00:00',
|
||||
'departure' => '郑州',
|
||||
'destination' => '杭州',
|
||||
'transport' => '高铁',
|
||||
'trip_type' => 'roundtrip',
|
||||
'remark' => '洽谈业务合作',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-1 hour')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-1 hour')),
|
||||
]);
|
||||
|
||||
$order1 = Db::table('approval_order')->insertGetId([
|
||||
'business_type' => 'business_trip',
|
||||
'business_id' => $trip1,
|
||||
'title' => '出差申请 - 杭州客户洽谈',
|
||||
'applicant_id' => $userId1,
|
||||
'status' => 'pending',
|
||||
'current_nodes' => json_encode([2]), // 节点2是抄送人-孟启(作为审批人)
|
||||
'flow_id' => $flow->id,
|
||||
'form_data' => json_encode(['reason' => '杭州客户洽谈', 'approvers' => [$userId2]]),
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-1 hour')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-1 hour')),
|
||||
]);
|
||||
|
||||
Db::table('business_trip')->where('id', $trip1)->update(['approval_order_id' => $order1]);
|
||||
|
||||
Db::table('approval_record')->insert([
|
||||
'order_id' => $order1,
|
||||
'node_id' => 1,
|
||||
'node_type' => 'single_approve',
|
||||
'approver_id' => $userId1,
|
||||
'action' => 'submit',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-1 hour')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-1 hour')),
|
||||
]);
|
||||
|
||||
echo "Order $order1: 审批中 - 杭州客户洽谈(用户1发起)\n";
|
||||
|
||||
// 2. 已通过 - 用户1发起的,已被批准
|
||||
$trip2 = Db::table('business_trip')->insertGetId([
|
||||
'user_id' => $userId1,
|
||||
'reason' => '南京技术调研',
|
||||
'start_date' => '2026-03-28',
|
||||
'end_date' => '2026-03-29',
|
||||
'destination' => '南京',
|
||||
'estimated_cost' => 2500,
|
||||
'status' => 'approved',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-2 days')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-1 day')),
|
||||
]);
|
||||
|
||||
Db::table('business_trip_itinerary')->insert([
|
||||
'business_trip_id' => $trip2,
|
||||
'start_time' => '2026-03-28 08:00:00',
|
||||
'end_time' => '2026-03-29 18:00:00',
|
||||
'departure' => '郑州',
|
||||
'destination' => '南京',
|
||||
'transport' => '高铁',
|
||||
'trip_type' => 'roundtrip',
|
||||
'remark' => '技术调研学习',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-2 days')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-2 days')),
|
||||
]);
|
||||
|
||||
$order2 = Db::table('approval_order')->insertGetId([
|
||||
'business_type' => 'business_trip',
|
||||
'business_id' => $trip2,
|
||||
'title' => '出差申请 - 南京技术调研',
|
||||
'applicant_id' => $userId1,
|
||||
'status' => 'approved',
|
||||
'current_nodes' => json_encode([]),
|
||||
'flow_id' => $flow->id,
|
||||
'form_data' => json_encode(['reason' => '南京技术调研', 'approvers' => [$userId2]]),
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-2 days')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-1 day')),
|
||||
]);
|
||||
|
||||
Db::table('business_trip')->where('id', $trip2)->update(['approval_order_id' => $order2]);
|
||||
|
||||
Db::table('approval_record')->insert([
|
||||
'order_id' => $order2,
|
||||
'node_id' => 1,
|
||||
'node_type' => 'single_approve',
|
||||
'approver_id' => $userId1,
|
||||
'action' => 'submit',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-2 days')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-2 days')),
|
||||
]);
|
||||
|
||||
Db::table('approval_record')->insert([
|
||||
'order_id' => $order2,
|
||||
'node_id' => 1,
|
||||
'node_type' => 'single_approve',
|
||||
'approver_id' => $userId2,
|
||||
'action' => 'approve',
|
||||
'comment' => '同意,请及时反馈调研结果',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-1 day')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-1 day')),
|
||||
]);
|
||||
|
||||
echo "Order $order2: 已通过 - 南京技术调研(用户1发起)\n";
|
||||
|
||||
// 3. 已拒绝 - 用户1发起的,已被拒绝
|
||||
$trip3 = Db::table('business_trip')->insertGetId([
|
||||
'user_id' => $userId1,
|
||||
'reason' => '成都展会参观',
|
||||
'start_date' => '2026-04-01',
|
||||
'end_date' => '2026-04-03',
|
||||
'destination' => '成都',
|
||||
'estimated_cost' => 6000,
|
||||
'status' => 'rejected',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-3 days')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-2 days')),
|
||||
]);
|
||||
|
||||
Db::table('business_trip_itinerary')->insert([
|
||||
'business_trip_id' => $trip3,
|
||||
'start_time' => '2026-04-01 08:00:00',
|
||||
'end_time' => '2026-04-03 18:00:00',
|
||||
'departure' => '郑州',
|
||||
'destination' => '成都',
|
||||
'transport' => '飞机',
|
||||
'trip_type' => 'roundtrip',
|
||||
'remark' => '参观行业展会',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-3 days')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-3 days')),
|
||||
]);
|
||||
|
||||
$order3 = Db::table('approval_order')->insertGetId([
|
||||
'business_type' => 'business_trip',
|
||||
'business_id' => $trip3,
|
||||
'title' => '出差申请 - 成都展会参观',
|
||||
'applicant_id' => $userId1,
|
||||
'status' => 'rejected',
|
||||
'current_nodes' => json_encode([]),
|
||||
'flow_id' => $flow->id,
|
||||
'form_data' => json_encode(['reason' => '成都展会参观', 'approvers' => [$userId2]]),
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-3 days')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-2 days')),
|
||||
]);
|
||||
|
||||
Db::table('business_trip')->where('id', $trip3)->update(['approval_order_id' => $order3]);
|
||||
|
||||
Db::table('approval_record')->insert([
|
||||
'order_id' => $order3,
|
||||
'node_id' => 1,
|
||||
'node_type' => 'single_approve',
|
||||
'approver_id' => $userId1,
|
||||
'action' => 'submit',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-3 days')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-3 days')),
|
||||
]);
|
||||
|
||||
Db::table('approval_record')->insert([
|
||||
'order_id' => $order3,
|
||||
'node_id' => 1,
|
||||
'node_type' => 'single_approve',
|
||||
'approver_id' => $userId2,
|
||||
'action' => 'reject',
|
||||
'comment' => '预算紧张,暂不批准参加展会',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-2 days')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-2 days')),
|
||||
]);
|
||||
|
||||
echo "Order $order3: 已拒绝 - 成都展会参观(用户1发起)\n";
|
||||
|
||||
// 4. 已撤回 - 用户1发起后自行撤回
|
||||
$trip4 = Db::table('business_trip')->insertGetId([
|
||||
'user_id' => $userId1,
|
||||
'reason' => '西安供应商考察',
|
||||
'start_date' => '2026-04-05',
|
||||
'end_date' => '2026-04-06',
|
||||
'destination' => '西安',
|
||||
'estimated_cost' => 3500,
|
||||
'status' => 'cancelled',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-4 days')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-3 days')),
|
||||
]);
|
||||
|
||||
Db::table('business_trip_itinerary')->insert([
|
||||
'business_trip_id' => $trip4,
|
||||
'start_time' => '2026-04-05 08:00:00',
|
||||
'end_time' => '2026-04-06 18:00:00',
|
||||
'departure' => '郑州',
|
||||
'destination' => '西安',
|
||||
'transport' => '高铁',
|
||||
'trip_type' => 'roundtrip',
|
||||
'remark' => '考察供应商',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-4 days')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-4 days')),
|
||||
]);
|
||||
|
||||
$order4 = Db::table('approval_order')->insertGetId([
|
||||
'business_type' => 'business_trip',
|
||||
'business_id' => $trip4,
|
||||
'title' => '出差申请 - 西安供应商考察',
|
||||
'applicant_id' => $userId1,
|
||||
'status' => 'cancelled',
|
||||
'current_nodes' => json_encode([]),
|
||||
'flow_id' => $flow->id,
|
||||
'form_data' => json_encode(['reason' => '西安供应商考察', 'approvers' => [$userId2]]),
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-4 days')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-3 days')),
|
||||
]);
|
||||
|
||||
Db::table('business_trip')->where('id', $trip4)->update(['approval_order_id' => $order4]);
|
||||
|
||||
Db::table('approval_record')->insert([
|
||||
'order_id' => $order4,
|
||||
'node_id' => 1,
|
||||
'node_type' => 'single_approve',
|
||||
'approver_id' => $userId1,
|
||||
'action' => 'submit',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-4 days')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-4 days')),
|
||||
]);
|
||||
|
||||
Db::table('approval_record')->insert([
|
||||
'order_id' => $order4,
|
||||
'node_id' => 0,
|
||||
'node_type' => 'cancel',
|
||||
'approver_id' => $userId1,
|
||||
'action' => 'cancel',
|
||||
'comment' => '申请人撤销',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-3 days')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-3 days')),
|
||||
]);
|
||||
|
||||
echo "Order $order4: 已撤回 - 西安供应商考察(用户1发起)\n";
|
||||
|
||||
// ========== 其他用户发起,待用户1审批的订单 ==========
|
||||
|
||||
// 5. 待用户1审批 - 用户2发起
|
||||
$trip5 = Db::table('business_trip')->insertGetId([
|
||||
'user_id' => $userId2,
|
||||
'reason' => '北京技术交流',
|
||||
'start_date' => '2026-03-20',
|
||||
'end_date' => '2026-03-22',
|
||||
'destination' => '北京',
|
||||
'estimated_cost' => 5000,
|
||||
'status' => 'pending',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-5 hours')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-5 hours')),
|
||||
]);
|
||||
|
||||
Db::table('business_trip_itinerary')->insert([
|
||||
'business_trip_id' => $trip5,
|
||||
'start_time' => '2026-03-20 08:00:00',
|
||||
'end_time' => '2026-03-22 18:00:00',
|
||||
'departure' => '郑州',
|
||||
'destination' => '北京',
|
||||
'transport' => '高铁',
|
||||
'trip_type' => 'roundtrip',
|
||||
'remark' => '参加技术交流会议',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-5 hours')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-5 hours')),
|
||||
]);
|
||||
|
||||
$order5 = Db::table('approval_order')->insertGetId([
|
||||
'business_type' => 'business_trip',
|
||||
'business_id' => $trip5,
|
||||
'title' => '出差申请 - 北京技术交流',
|
||||
'applicant_id' => $userId2,
|
||||
'status' => 'pending',
|
||||
'current_nodes' => json_encode([1]), // 节点1是主管审批,审批人是用户1
|
||||
'flow_id' => $flow->id,
|
||||
'form_data' => json_encode(['reason' => '北京技术交流', 'approvers' => [$userId1]]),
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-5 hours')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-5 hours')),
|
||||
]);
|
||||
|
||||
Db::table('business_trip')->where('id', $trip5)->update(['approval_order_id' => $order5]);
|
||||
|
||||
Db::table('approval_record')->insert([
|
||||
'order_id' => $order5,
|
||||
'node_id' => 1,
|
||||
'node_type' => 'single_approve',
|
||||
'approver_id' => $userId2,
|
||||
'action' => 'submit',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-5 hours')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-5 hours')),
|
||||
]);
|
||||
|
||||
echo "Order $order5: 待审批 - 北京技术交流(用户2发起,用户1审批)\n";
|
||||
|
||||
// 6. 待用户1审批 - 用户3发起
|
||||
$trip6 = Db::table('business_trip')->insertGetId([
|
||||
'user_id' => $userId3,
|
||||
'reason' => '上海客户拜访',
|
||||
'start_date' => '2026-03-18',
|
||||
'end_date' => '2026-03-19',
|
||||
'destination' => '上海',
|
||||
'estimated_cost' => 4000,
|
||||
'status' => 'pending',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-3 hours')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-3 hours')),
|
||||
]);
|
||||
|
||||
Db::table('business_trip_itinerary')->insert([
|
||||
'business_trip_id' => $trip6,
|
||||
'start_time' => '2026-03-18 08:00:00',
|
||||
'end_time' => '2026-03-19 18:00:00',
|
||||
'departure' => '郑州',
|
||||
'destination' => '上海',
|
||||
'transport' => '高铁',
|
||||
'trip_type' => 'roundtrip',
|
||||
'remark' => '拜访重要客户',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-3 hours')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-3 hours')),
|
||||
]);
|
||||
|
||||
$order6 = Db::table('approval_order')->insertGetId([
|
||||
'business_type' => 'business_trip',
|
||||
'business_id' => $trip6,
|
||||
'title' => '出差申请 - 上海客户拜访',
|
||||
'applicant_id' => $userId3,
|
||||
'status' => 'pending',
|
||||
'current_nodes' => json_encode([1]), // 节点1是主管审批,审批人是用户1
|
||||
'flow_id' => $flow->id,
|
||||
'form_data' => json_encode(['reason' => '上海客户拜访', 'approvers' => [$userId1]]),
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-3 hours')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-3 hours')),
|
||||
]);
|
||||
|
||||
Db::table('business_trip')->where('id', $trip6)->update(['approval_order_id' => $order6]);
|
||||
|
||||
Db::table('approval_record')->insert([
|
||||
'order_id' => $order6,
|
||||
'node_id' => 1,
|
||||
'node_type' => 'single_approve',
|
||||
'approver_id' => $userId3,
|
||||
'action' => 'submit',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-3 hours')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-3 hours')),
|
||||
]);
|
||||
|
||||
echo "Order $order6: 待审批 - 上海客户拜访(用户3发起,用户1审批)\n";
|
||||
|
||||
// ========== 用户1已审批的订单(已通过/已拒绝) ==========
|
||||
|
||||
// 7. 用户1已审批(已通过)- 用户2发起
|
||||
$trip7 = Db::table('business_trip')->insertGetId([
|
||||
'user_id' => $userId2,
|
||||
'reason' => '广州展会',
|
||||
'start_date' => '2026-03-10',
|
||||
'end_date' => '2026-03-12',
|
||||
'destination' => '广州',
|
||||
'estimated_cost' => 8000,
|
||||
'status' => 'approved',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-7 days')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-6 days')),
|
||||
]);
|
||||
|
||||
Db::table('business_trip_itinerary')->insert([
|
||||
'business_trip_id' => $trip7,
|
||||
'start_time' => '2026-03-10 08:00:00',
|
||||
'end_time' => '2026-03-12 18:00:00',
|
||||
'departure' => '郑州',
|
||||
'destination' => '广州',
|
||||
'transport' => '飞机',
|
||||
'trip_type' => 'roundtrip',
|
||||
'remark' => '参加行业展会',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-7 days')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-7 days')),
|
||||
]);
|
||||
|
||||
$order7 = Db::table('approval_order')->insertGetId([
|
||||
'business_type' => 'business_trip',
|
||||
'business_id' => $trip7,
|
||||
'title' => '出差申请 - 广州展会',
|
||||
'applicant_id' => $userId2,
|
||||
'status' => 'approved',
|
||||
'current_nodes' => json_encode([]),
|
||||
'flow_id' => $flow->id,
|
||||
'form_data' => json_encode(['reason' => '广州展会', 'approvers' => [$userId1]]),
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-7 days')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-6 days')),
|
||||
]);
|
||||
|
||||
Db::table('business_trip')->where('id', $trip7)->update(['approval_order_id' => $order7]);
|
||||
|
||||
Db::table('approval_record')->insert([
|
||||
'order_id' => $order7,
|
||||
'node_id' => 1,
|
||||
'node_type' => 'single_approve',
|
||||
'approver_id' => $userId2,
|
||||
'action' => 'submit',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-7 days')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-7 days')),
|
||||
]);
|
||||
|
||||
Db::table('approval_record')->insert([
|
||||
'order_id' => $order7,
|
||||
'node_id' => 1,
|
||||
'node_type' => 'single_approve',
|
||||
'approver_id' => $userId1,
|
||||
'action' => 'approve',
|
||||
'comment' => '同意参加展会,注意控制预算',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-6 days')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-6 days')),
|
||||
]);
|
||||
|
||||
echo "Order $order7: 已通过 - 广州展会(用户1已审批)\n";
|
||||
|
||||
// 8. 用户1已审批(已拒绝)- 用户3发起
|
||||
$trip8 = Db::table('business_trip')->insertGetId([
|
||||
'user_id' => $userId3,
|
||||
'reason' => '深圳培训',
|
||||
'start_date' => '2026-03-05',
|
||||
'end_date' => '2026-03-06',
|
||||
'destination' => '深圳',
|
||||
'estimated_cost' => 2000,
|
||||
'status' => 'rejected',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-10 days')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-9 days')),
|
||||
]);
|
||||
|
||||
Db::table('business_trip_itinerary')->insert([
|
||||
'business_trip_id' => $trip8,
|
||||
'start_time' => '2026-03-05 08:00:00',
|
||||
'end_time' => '2026-03-06 18:00:00',
|
||||
'departure' => '郑州',
|
||||
'destination' => '深圳',
|
||||
'transport' => '高铁',
|
||||
'trip_type' => 'roundtrip',
|
||||
'remark' => '参加产品培训',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-10 days')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-10 days')),
|
||||
]);
|
||||
|
||||
$order8 = Db::table('approval_order')->insertGetId([
|
||||
'business_type' => 'business_trip',
|
||||
'business_id' => $trip8,
|
||||
'title' => '出差申请 - 深圳培训',
|
||||
'applicant_id' => $userId3,
|
||||
'status' => 'rejected',
|
||||
'current_nodes' => json_encode([]),
|
||||
'flow_id' => $flow->id,
|
||||
'form_data' => json_encode(['reason' => '深圳培训', 'approvers' => [$userId1]]),
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-10 days')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-9 days')),
|
||||
]);
|
||||
|
||||
Db::table('business_trip')->where('id', $trip8)->update(['approval_order_id' => $order8]);
|
||||
|
||||
Db::table('approval_record')->insert([
|
||||
'order_id' => $order8,
|
||||
'node_id' => 1,
|
||||
'node_type' => 'single_approve',
|
||||
'approver_id' => $userId3,
|
||||
'action' => 'submit',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-10 days')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-10 days')),
|
||||
]);
|
||||
|
||||
Db::table('approval_record')->insert([
|
||||
'order_id' => $order8,
|
||||
'node_id' => 1,
|
||||
'node_type' => 'single_approve',
|
||||
'approver_id' => $userId1,
|
||||
'action' => 'reject',
|
||||
'comment' => '工作繁忙,暂不安排培训',
|
||||
'created_at' => date('Y-m-d H:i:s', strtotime('-9 days')),
|
||||
'updated_at' => date('Y-m-d H:i:s', strtotime('-9 days')),
|
||||
]);
|
||||
|
||||
echo "Order $order8: 已拒绝 - 深圳培训(用户1已审批)\n";
|
||||
|
||||
echo "\n========== 数据汇总 ==========\n";
|
||||
echo "用户1(李佳博)发起的订单:\n";
|
||||
echo " - 订单$order1: 审批中(杭州客户洽谈)\n";
|
||||
echo " - 订单$order2: 已通过(南京技术调研)\n";
|
||||
echo " - 订单$order3: 已拒绝(成都展会参观)\n";
|
||||
echo " - 订单$order4: 已撤回(西安供应商考察)\n";
|
||||
echo "\n待用户1审批的订单:\n";
|
||||
echo " - 订单$order5: 闫继光 - 北京技术交流\n";
|
||||
echo " - 订单$order6: 孟启 - 上海客户拜访\n";
|
||||
echo "\n用户1已审批的订单:\n";
|
||||
echo " - 订单$order7: 已通过 - 闫继光(广州展会)\n";
|
||||
echo " - 订单$order8: 已拒绝 - 孟启(深圳培训)\n";
|
||||
echo "\n=== 测试数据创建完成 ===\n";
|
||||
@@ -10,7 +10,7 @@ export default function QuickAction() {
|
||||
const [message, setMessage] = useState('处理中...')
|
||||
|
||||
useEffect(() => {
|
||||
console.log('🚀 QuickAction 页面加载')
|
||||
console.log('QuickAction 页面加载')
|
||||
|
||||
const processAction = async () => {
|
||||
try {
|
||||
@@ -19,7 +19,7 @@ export default function QuickAction() {
|
||||
const orderId = urlParams.get('order_id')
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
console.log('🚀 快速审批参数:', { action, orderId, hasToken: !!token })
|
||||
console.log('快速审批参数:', { action, orderId, hasToken: !!token })
|
||||
|
||||
if (!action || !orderId) {
|
||||
setStatus('error')
|
||||
@@ -33,7 +33,7 @@ export default function QuickAction() {
|
||||
|
||||
// 检查是否登录
|
||||
if (!token) {
|
||||
console.log('⚠️ 未登录')
|
||||
console.log('未登录')
|
||||
setStatus('unauthorized')
|
||||
setMessage('请先登录')
|
||||
Toast.show('请先登录')
|
||||
@@ -48,52 +48,52 @@ export default function QuickAction() {
|
||||
setMessage('正在执行审批...')
|
||||
|
||||
// 执行审批操作
|
||||
console.log('📝 执行审批操作:', action, 'order_id:', orderId)
|
||||
console.log('执行审批操作:', action, 'order_id:', orderId)
|
||||
|
||||
let result
|
||||
if (action === 'approve') {
|
||||
console.log('✅ 调用 approve API')
|
||||
console.log('调用 approve API')
|
||||
result = await approvalApi.approve(Number(orderId), '')
|
||||
setMessage('已通过')
|
||||
} else if (action === 'reject') {
|
||||
console.log('❌ 调用 reject API')
|
||||
console.log('调用 reject API')
|
||||
result = await approvalApi.reject(Number(orderId), '已拒绝')
|
||||
setMessage('已拒绝')
|
||||
} else {
|
||||
throw new Error('未知操作类型')
|
||||
}
|
||||
|
||||
console.log('✅ 审批操作完成:', result)
|
||||
console.log('审批操作完成:', result)
|
||||
setStatus('success')
|
||||
|
||||
// 操作成功后关闭页面
|
||||
setTimeout(() => {
|
||||
console.log('✅ 操作完成,尝试关闭页面')
|
||||
console.log('操作完成,尝试关闭页面')
|
||||
|
||||
// 尝试关闭页面(在飞书客户端或新窗口中)
|
||||
try {
|
||||
// 尝试飞书关闭方法
|
||||
if ((window as any).tt && typeof (window as any).tt.close === 'function') {
|
||||
console.log('🔒 使用 tt.close() 关闭页面')
|
||||
console.log('使用 tt.close() 关闭页面')
|
||||
;(window as any).tt.close()
|
||||
} else if ((window as any).webkit && typeof (window as any).webkit.messageHandlers.postMessage === 'object') {
|
||||
console.log('📱 尝试 iOS WKWebView 关闭')
|
||||
console.log('尝试 iOS WKWebView 关闭')
|
||||
;(window as any).webkit.messageHandlers.postMessage({ action: 'close' })
|
||||
} else if (typeof window.close === 'function') {
|
||||
console.log('🌐 使用 window.close() 关闭')
|
||||
console.log('使用 window.close() 关闭')
|
||||
window.close()
|
||||
} else {
|
||||
console.log('📋 无法关闭页面,跳转到审批列表')
|
||||
console.log('无法关闭页面,跳转到审批列表')
|
||||
navigate('/approval', { replace: true })
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('⚠️ 关闭页面失败,跳转到列表')
|
||||
console.log('关闭页面失败,跳转到列表')
|
||||
navigate('/approval', { replace: true })
|
||||
}
|
||||
}, 1500)
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('❌ 操作失败:', error)
|
||||
console.error('操作失败:', error)
|
||||
setStatus('error')
|
||||
setMessage('操作失败: ' + (error.message || '未知错误'))
|
||||
Toast.show('操作失败: ' + (error.message || '未知错误'))
|
||||
@@ -129,7 +129,7 @@ export default function QuickAction() {
|
||||
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<div style={{ fontSize: '64px' }}>✅</div>
|
||||
<div style={{ fontSize: '64px' }}>{'\u2705'}</div>
|
||||
<p style={{ marginTop: '16px', color: '#52c41a', fontSize: '18px' }}>{message}</p>
|
||||
<p style={{ fontSize: '14px', color: '#999' }}>页面即将关闭...</p>
|
||||
</>
|
||||
@@ -137,14 +137,14 @@ export default function QuickAction() {
|
||||
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<div style={{ fontSize: '64px' }}>❌</div>
|
||||
<div style={{ fontSize: '64px' }}>{'\u274C'}</div>
|
||||
<p style={{ marginTop: '16px', color: '#ff4d4f', fontSize: '18px' }}>{message}</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'unauthorized' && (
|
||||
<>
|
||||
<div style={{ fontSize: '64px }}>🔒</div>
|
||||
<div style={{ fontSize: '64px' }}>{'\uD83D\uDD12'}</div>
|
||||
<p style={{ marginTop: '16px', color: '#ff8800', fontSize: '18px' }}>需要登录</p>
|
||||
<p style={{ fontSize: '14px', color: '#999' }}>正在跳转到登录页面...</p>
|
||||
</>
|
||||
|
||||
@@ -91,13 +91,16 @@ export default function DebugFeishuPage() {
|
||||
}, [])
|
||||
|
||||
const testFeishuLogin = async () => {
|
||||
const appId = 'cli_a8033d607cba500d'
|
||||
// 从 URL 获取 company 参数
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const companyParam = urlParams.get('company')
|
||||
console.log('🔍 URL 中的 company 参数:', companyParam)
|
||||
|
||||
// 优先使用 tt 对象
|
||||
if (window.tt && typeof window.tt.requestAuthCode === 'function') {
|
||||
console.log('🔧 使用 tt.requestAuthCode')
|
||||
// 不传 appId,让飞书自动选择(后端会根据 company 参数处理)
|
||||
window.tt.requestAuthCode({
|
||||
appId: appId,
|
||||
success: (res: any) => {
|
||||
alert('✅ 获取授权码成功: ' + res.code?.substring(0, 30))
|
||||
console.log('授权码:', res)
|
||||
@@ -117,7 +120,7 @@ export default function DebugFeishuPage() {
|
||||
try {
|
||||
if (typeof h5sdk.requestAuthCode === 'function') {
|
||||
h5sdk.requestAuthCode({
|
||||
appId: appId,
|
||||
// 不传 appId,让飞书自动选择(后端会根据 company 参数处理)
|
||||
success: (res: any) => {
|
||||
alert('✅ h5sdk 获取授权码成功: ' + res.code?.substring(0, 30))
|
||||
console.log('授权码:', res)
|
||||
@@ -141,7 +144,7 @@ export default function DebugFeishuPage() {
|
||||
try {
|
||||
if (typeof bridge.requestAuthCode === 'function') {
|
||||
bridge.requestAuthCode({
|
||||
appId: appId,
|
||||
// 不传 appId,让飞书自动选择(后端会根据 company 参数处理)
|
||||
success: (res: any) => {
|
||||
alert('✅ bridge 获取授权码成功: ' + res.code?.substring(0, 30))
|
||||
console.log('授权码:', res)
|
||||
|
||||
@@ -26,3 +26,12 @@ location /login/qrcode {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 飞书卡片回调(明文传输,无需加密验证)
|
||||
location /feishu/card/callback {
|
||||
proxy_pass http://127.0.0.1:8787;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user