chore: 移除已迁移的旧文件

This commit is contained in:
root
2026-02-27 18:01:32 +08:00
parent 4ceb5eac7e
commit c1e54d9457
11 changed files with 33 additions and 3089 deletions
-219
View File
@@ -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
View File
@@ -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
-601
View File
@@ -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
View File
@@ -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 开发环境
- ✅ 实现审批流程引擎基础框架
- ✅ 完成前端基础页面和路由
-941
View File
@@ -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: 添加"抄送我的"审批列表选项
-142
View File
@@ -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. 配置防火墙规则
-109
View File
@@ -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());
}
}
}
-529
View File
@@ -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";
+17 -17
View File
@@ -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>
</>
+7 -4
View File
@@ -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)
+9
View File
@@ -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;
}