Initial commit: X-Server Mobile enterprise tool system

- Frontend: React 18 + TypeScript + Vite + Ant Design Mobile
- Backend: PHP 8.5 + Webman 2.x + MySQL + Redis
- i18n support: Chinese, English, Russian, Spanish, French
- Approval workflow engine
- Docker development environment
- Feishu integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-02-24 17:57:25 +08:00
commit 8d78e18267
121 changed files with 18563 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
# Dependencies
node_modules/
vendor/
# Build outputs
frontend/dist/
frontend/build/
frontend/.vite/
# Environment files
.env
.env.local
.env.production
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
logs/
backend/runtime/logs/
backend/runtime/cache/
# Cache
.cache/
.npm/
.eslintcache
# Testing
coverage/
# Docker volumes (if any)
docker-volumes/
# Temporary files
*.tmp
*.temp
.claude/
# Lock files (optional - uncomment if you want to exclude)
# package-lock.json
# composer.lock
+223
View File
@@ -0,0 +1,223 @@
# X-Server Mobile 项目文档
## 项目概述
这是一个基于飞书的企业内部H5移动工具平台,采用多租户架构,支持审批流程引擎。
**关键特性:**
- 支持5种语言:中文、英文、俄语、西语、法语
- 多租户架构(用户全局唯一,关联多个公司)
- 审批流程引擎(单人审批、会签、或签、通知)
- 飞书免登集成
**技术栈:**
- 前端:React 18.3.1 + TypeScript + Vite + Ant Design Mobile
- 后端:PHP 8.5 + Webman 2.x + MySQL + Redis
- 部署:Docker
## 国际化 (i18n) - 重要!
### 前端
- 配置:`frontend/src/i18n/`
- 翻译文件:`frontend/src/i18n/locales/{zh,en,ru,es,fr}.json`
- 语言切换:
- 首页右上角国旗按钮
- 个人中心 -> 语言设置
- 存储位置:localStorage `i18nextLng`
使用方式:
```tsx
import { useTranslation } from 'react-i18next'
const { t } = useTranslation()
<div>{t('common.confirm')}</div>
```
### 后端
- 翻译文件:`backend/lang/{zh,en,ru,es,fr}.php`
- 工具类:`backend/app/Util/Lang.php`
使用方式:
```php
use App\Util\Lang;
$message = Lang::get('common.confirm');
Lang::setLocale('en');
```
## 目录结构
```
/www/x-server-mobile/
├── frontend/ # React 前端 (运行在 5173)
│ ├── src/
│ │ ├── i18n/ # 国际化配置
│ │ ├── components/ # 组件
│ │ ├── pages/ # 页面
│ │ ├── modules/ # 业务模块 (approval等)
│ │ └── router/ # 路由
│ └── package.json
├── backend/ # PHP 后端 (运行在 8787)
│ ├── app/
│ │ ├── Controller/ # 控制器 (PascalCase命名)
│ │ ├── Model/ # 模型 (PascalCase命名)
│ │ ├── Service/ # 服务层
│ │ ├── Util/ # 工具类
│ │ └── Middleware/ # 中间件
│ ├── config/ # 配置
│ ├── lang/ # 语言文件
│ └── composer.json
├── docker-compose.yml # Docker 编排
├── nginx/ # OpenResty 配置
├── frpc/ # frpc 配置
├── PROJECT.md # 项目详细文档
└── IMPLEMENTATION_PLAN.md # 实现计划
```
## 环境配置
### 端口
- Frontend (Vite): 5173
- Backend (Webman): 8787
- OpenResty: 8080
- MySQL: 3306
- Redis: 6379
### 数据库
- 数据库名:x_server_mobile
- 密码:mysql_xEsdPa
- 网络:1panel-network
### Docker 容器
- 后端容器:x-server-backend
- 使用 Dockerfile:`backend/Dockerfile`
- PHP 版本:8.5
## 关键文件路径
### 前端关键文件
- 入口:`frontend/src/main.tsx`
- 路由:`frontend/src/router/index.tsx`
- 布局:`frontend/src/components/layout/`
- 状态管理:`frontend/src/stores/`
- rem 适配:`frontend/src/utils/rem.ts`
### 后端关键文件
- 入口:`backend/start.php`
- 路由:`backend/config/route.php`
- 数据库配置:`backend/config/database.php`
- Redis 配置:`backend/config/redis.php`
- 容器配置:`backend/config/container.php`
### 重要配置文件
- Docker 编排:`docker-compose.yml`
- OpenResty 配置:`nginx/x-server-api.conf`
- frpc 配置:`frpc/frpc-dev.ini`
## 审批流程引擎
### 节点类型
- `single_approve` - 单人审批
- `and_sign` - 会签(所有人通过)
- `or_sign` - 或签(任一人通过)
- `notify` - 通知节点
### 状态
- pending: 待审批
- approved: 已通过
- rejected: 已拒绝
- cancelled: 已撤销
### 关键文件
- 服务:`backend/app/Service/ApprovalService.php`
- 模型:`backend/app/Model/ApprovalOrder.php`
- 前端模块:`frontend/src/modules/approval/`
## 飞书集成
### 免登接口
- 回调:`POST /api/v1/auth/feishu-login`
### 环境变量
```env
FEISHU_APP_ID=cli_xxxxxxxxxxxxx
FEISHU_APP_SECRET=xxxxxxxxxxxxxxxxxxxx
FEISHU_ENCRYPT_KEY=xxxxxxxxxxxxxxxxxxxx
FEISHU_VERIFICATION_TOKEN=xxxxxxxxxxxxxxxxxxxx
```
## 常用命令
### 前端
```bash
cd /www/x-server-mobile/frontend
npm install
npm run dev
npm run build
```
### 后端
```bash
cd /www/x-server-mobile/backend
composer install
php start.php start
php start.php stop
```
### Docker
```bash
cd /www/x-server-mobile
docker compose up -d
docker compose restart backend
docker logs x-server-backend -f
```
## 反向代理配置(开发环境)
用户已有 frpc 和 OpenResty,配置用于调试飞书登录。
1. OpenResty 监听 8080,代理到 8787
2. frpc 将 8080 暴露到公网
3. 飞书回调地址配置为公网域名
## 已知问题和解决方案
### PSR-4 自动加载
- 目录名必须是 PascalCase(Controller、Model、Service、Util)
- 不能用小写(controller、model、service、util)
### React 版本
- 必须使用 React 18.3.1(不能用19)
- @types/react 和 @types/react-dom 也要用 v18
### 数据库连接
- 使用自定义 Db 类 (`backend/app/Util/Db.php`)
- 不使用 `support\Db`(Webman 2.x 没有)
### Bootstrap 接口
- 方法签名:`public static function start(?\Workerman\Worker $worker): void`
- 需要添加 `use Workerman\Worker;`
### Docker 容器重启
- CMD 使用 `php start.php start`(不要用 `-d`)
- 前台运行,避免容器退出
## 语言切换位置
1. **首页** - 右上角显示当前语言国旗,点击切换
2. **个人中心** -> 语言设置
## 当前开发状态
- ✅ 前端基础框架搭建完成
- ✅ 后端 API 框架搭建完成
- ✅ 5语言国际化支持完成
- ✅ Docker 开发环境配置完成
- ✅ 审批流程引擎基础框架完成
- 🚧 出差申请功能开发中
## 下一步工作
1. 完成出差申请前端页面
2. 实现审批列表和详情页
3. 集成飞书免登
4. 实现公司切换功能
5. 完善权限控制
+219
View File
@@ -0,0 +1,219 @@
# 开发环境反向代理配置指南
本指南用于配置开发环境的 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 相关配置。
+601
View File
@@ -0,0 +1,601 @@
# 企业内部工具系统 - 实现计划
## 项目概述
开发一个企业内部工具系统平台,以出差审批为首个功能模块,后续可持续扩展新功能。系统主要支持移动 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
@@ -0,0 +1,356 @@
# 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 开发环境
- ✅ 实现审批流程引擎基础框架
- ✅ 完成前端基础页面和路由
+142
View File
@@ -0,0 +1,142 @@
# 反向代理配置指南
本指南用于配置 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. 配置防火墙规则
+23
View File
@@ -0,0 +1,23 @@
# 应用配置
APP_DEBUG=true
APP_ENV=local
# 数据库配置
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=x_server_mobile
DB_USERNAME=root
DB_PASSWORD=
# Redis 配置
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DATABASE=0
# JWT 配置
JWT_SECRET=your-secret-key-change-in-production
# 飞书配置
FEISHU_APP_ID=
FEISHU_APP_SECRET=
+34
View File
@@ -0,0 +1,34 @@
FROM php:8.5-cli
# Install system dependencies
RUN apt-get update && apt-get install -y \
unzip \
git \
libzip-dev \
libpng-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
# Install PHP extensions
RUN docker-php-ext-install zip pdo pdo_mysql bcmath pcntl
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
# Set working directory
WORKDIR /app
# Copy composer files
COPY composer.json composer.lock* ./
# Install dependencies
RUN composer install --no-interaction --optimize-autoloader
# Copy application files
COPY . .
# Expose port
EXPOSE 8787
# Start webman in foreground mode
CMD ["php", "start.php", "start"]
@@ -0,0 +1,134 @@
<?php
namespace App\Controller;
use App\Service\ApprovalService;
use Webman\Http\Request;
use Webman\Http\Response;
class ApprovalController extends BaseController
{
private ApprovalService $approvalService;
public function __construct()
{
$this->approvalService = new ApprovalService();
}
/**
* 获取审批列表
*/
public function list(Request $request): Response
{
$type = $request->input('type', 'my');
$page = (int) $request->input('page', 1);
$pageSize = (int) $request->input('page_size', 20);
$userId = $this->getUserId($request);
if (!$userId) {
return $this->error('用户未登录', 401);
}
$result = $this->approvalService->getList($userId, $type, $page, $pageSize);
return $this->success($result);
}
/**
* 获取审批详情
*/
public function detail(Request $request): Response
{
$id = $request->route('id');
$order = \App\Model\ApprovalOrder::with(['flow', 'applicant', 'records.approver'])
->findOrFail($id);
return $this->success($order);
}
/**
* 提交审批
*/
public function submit(Request $request): Response
{
$data = $request->all();
$userId = $this->getUserId($request);
if (!$userId) {
return $this->error('用户未登录', 401);
}
$data['applicant_id'] = $userId;
try {
$order = $this->approvalService->submit($data);
return $this->success($order);
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
/**
* 审批通过
*/
public function approve(Request $request): Response
{
$id = $request->route('id');
$comment = $request->post('comment');
$userId = $this->getUserId($request);
if (!$userId) {
return $this->error('用户未登录', 401);
}
try {
$order = $this->approvalService->approve((int) $id, $userId, $comment);
return $this->success($order);
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
/**
* 审批拒绝
*/
public function reject(Request $request): Response
{
$id = $request->route('id');
$comment = $request->post('comment');
$userId = $this->getUserId($request);
if (!$userId) {
return $this->error('用户未登录', 401);
}
try {
$order = $this->approvalService->reject((int) $id, $userId, $comment);
return $this->success($order);
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
/**
* 撤销申请
*/
public function cancel(Request $request): Response
{
$id = $request->route('id');
$userId = $this->getUserId($request);
if (!$userId) {
return $this->error('用户未登录', 401);
}
try {
$order = $this->approvalService->cancel((int) $id, $userId);
return $this->success($order);
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Controller;
use App\Util\JwtUtil;
use Webman\Http\Request;
use Webman\Http\Response;
class AuthController extends BaseController
{
/**
* 用户登录
*/
public function login(Request $request): Response
{
$username = $request->post('username');
$password = $request->post('password');
// TODO: 实现真实的登录逻辑
if (!$username || !$password) {
return $this->error('用户名或密码不能空');
}
// 模拟用户数据
$user = [
'user_id' => 1,
'username' => $username,
'name' => '测试用户',
'company_id' => 1,
];
$token = JwtUtil::encode($user);
return $this->success([
'token' => $token,
'user' => $user,
]);
}
/**
* 飞书免登
*/
public function feishuLogin(Request $request): Response
{
$authCode = $request->post('auth_code');
$tenantKey = $request->post('tenant_key');
// TODO: 实现飞书免登逻辑
// 1. 根据 tenant_key 获取公司配置
// 2. 使用 auth_code 换取用户信息
// 3. 创建或更新用户
// 4. 生成 JWT Token
return $this->success([
'token' => JwtUtil::encode(['user_id' => 1, 'company_id' => 1]),
'user' => ['id' => 1, 'name' => '飞书用户', 'company_id' => 1],
]);
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Controller;
use Webman\Http\Request;
use Webman\Http\Response;
abstract class BaseController
{
/**
* 成功响应
*/
protected function success($data = null, string $message = 'success'): Response
{
return json([
'code' => 0,
'message' => $message,
'data' => $data,
]);
}
/**
* 错误响应
*/
protected function error(string $message = 'error', int $code = 1, $data = null): Response
{
return json([
'code' => $code,
'message' => $message,
'data' => $data,
]);
}
/**
* 获取当前用户ID
*/
protected function getUserId(Request $request): ?int
{
return $request->userId ?? null;
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Controller;
use Webman\Http\Request;
use Webman\Http\Response;
class BusinessTripController extends BaseController
{
/**
* 获取出差列表
*/
public function list(Request $request): Response
{
$type = $request->input('type', 'my');
// TODO: 从数据库获取出差列表
return $this->success([
'list' => [],
'total' => 0,
]);
}
/**
* 获取出差详情
*/
public function detail(Request $request): Response
{
$id = $request->route('id');
// TODO: 从数据库获取出差详情
return $this->success([
'id' => $id,
'title' => '测试出差',
]);
}
/**
* 提交出差申请
*/
public function apply(Request $request): Response
{
$data = $request->all();
// TODO: 保存出差数据并创建审批流程
return $this->success();
}
/**
* 更新出差申请
*/
public function update(Request $request): Response
{
$id = $request->route('id');
$data = $request->all();
// TODO: 更新出差数据
return $this->success();
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Controller;
use Webman\Http\Request;
use Webman\Http\Response;
class UserController extends BaseController
{
/**
* 获取用户信息
*/
public function info(Request $request): Response
{
$userId = $this->getUserId($request);
// TODO: 从数据库获取用户信息
return $this->success([
'id' => $userId,
'name' => '测试用户',
'email' => 'test@example.com',
'company_id' => 1,
]);
}
/**
* 获取用户菜单权限
*/
public function menu(Request $request): Response
{
// TODO: 根据用户权限返回菜单
return $this->success([
['id' => 1, 'name' => '首页', 'path' => '/dashboard'],
['id' => 2, 'name' => '出差审批', 'path' => '/business-trip'],
]);
}
/**
* 获取用户可切换的公司列表
*/
public function companies(Request $request): Response
{
$userId = $this->getUserId($request);
// TODO: 从数据库获取用户关联的公司列表
return $this->success([
['id' => 1, 'name' => '公司A'],
]);
}
/**
* 切换当前公司
*/
public function switchCompany(Request $request): Response
{
$companyId = $request->post('company_id');
// TODO: 更新用户当前公司
return $this->success();
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Model;
use support\Model;
class ApprovalFlow extends Model
{
protected $table = 'approval_flow';
protected $fillable = [
'business_type',
'name',
'description',
'status',
];
protected $casts = [
'status' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* 关联审批节点
*/
public function nodes()
{
return $this->hasMany(ApprovalNode::class, 'flow_id')->orderBy('order');
}
/**
* 获取启用的流程
*/
public function scopeActive($query)
{
return $query->where('status', 1);
}
/**
* 根据业务类型获取流程
*/
public function scopeByBusinessType($query, string $businessType)
{
return $query->where('business_type', $businessType);
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace App\Model;
use support\Model;
class ApprovalNode extends Model
{
protected $table = 'approval_node';
protected $fillable = [
'flow_id',
'type',
'name',
'order',
'approvers',
'conditions',
];
protected $casts = [
'approvers' => 'array',
'conditions' => 'array',
'order' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* 节点类型常量
*/
const TYPE_APPROVER = 'approver'; // 审批人(单人)
const TYPE_AND_APPROVE = 'and_approve'; // 会签(需所有人通过)
const TYPE_OR_APPROVE = 'or_approve'; // 或签(任一人通过)
const TYPE_NOTIFY = 'notify'; // 抄送(通知用)
/**
* 关联流程
*/
public function flow()
{
return $this->belongsTo(ApprovalFlow::class, 'flow_id');
}
/**
* 获取指定顺序的节点
*/
public function scopeByOrder($query, int $order)
{
return $query->where('order', $order);
}
/**
* 判断是否是审批节点
*/
public function isApprovalNode(): bool
{
return in_array($this->type, [
self::TYPE_APPROVER,
self::TYPE_AND_APPROVE,
self::TYPE_OR_APPROVE,
]);
}
/**
* 判断是否是会签
*/
public function isAndApprove(): bool
{
return $this->type === self::TYPE_AND_APPROVE;
}
/**
* 判断是否是或签
*/
public function isOrApprove(): bool
{
return $this->type === self::TYPE_OR_APPROVE;
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
namespace App\Model;
use support\Model;
class ApprovalOrder extends Model
{
protected $table = 'approval_order';
protected $fillable = [
'business_type',
'business_id',
'title',
'applicant_id',
'status',
'current_nodes',
'flow_id',
'form_data',
'attachments',
];
protected $casts = [
'current_nodes' => 'array',
'form_data' => 'array',
'attachments' => 'array',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* 审批状态常量
*/
const STATUS_DRAFT = 'draft'; // 草稿
const STATUS_PENDING = 'pending'; // 审批中
const STATUS_APPROVED = 'approved'; // 已通过
const STATUS_REJECTED = 'rejected'; // 已拒绝
const STATUS_CANCELLED = 'cancelled'; // 已撤销
/**
* 关联流程
*/
public function flow()
{
return $this->belongsTo(ApprovalFlow::class, 'flow_id');
}
/**
* 关联申请人
*/
public function applicant()
{
return $this->belongsTo(User::class, 'applicant_id');
}
/**
* 关联审批记录
*/
public function records()
{
return $this->hasMany(ApprovalRecord::class, 'order_id')->orderBy('created_at');
}
/**
* 判断是否可以编辑
*/
public function canEdit(): bool
{
return $this->status === self::STATUS_DRAFT;
}
/**
* 判断是否可以审批
*/
public function canApprove(): bool
{
return $this->status === self::STATUS_PENDING;
}
/**
* 判断是否已结束
*/
public function isEnded(): bool
{
return in_array($this->status, [
self::STATUS_APPROVED,
self::STATUS_REJECTED,
self::STATUS_CANCELLED,
]);
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Model;
use support\Model;
class ApprovalRecord extends Model
{
protected $table = 'approval_record';
protected $fillable = [
'order_id',
'node_id',
'node_type',
'approver_id',
'action',
'comment',
];
protected $casts = [
'created_at' => 'datetime',
];
/**
* 操作类型常量
*/
const ACTION_SUBMIT = 'submit'; // 提交
const ACTION_APPROVE = 'approve'; // 通过
const ACTION_REJECT = 'reject'; // 拒绝
const ACTION_CANCEL = 'cancel'; // 撤销
/**
* 关联审批单
*/
public function order()
{
return $this->belongsTo(ApprovalOrder::class, 'order_id');
}
/**
* 关联审批人
*/
public function approver()
{
return $this->belongsTo(User::class, 'approver_id');
}
/**
* 判断是否是批准操作
*/
public function isApproved(): bool
{
return $this->action === self::ACTION_APPROVE;
}
/**
* 判断是否是拒绝操作
*/
public function isRejected(): bool
{
return $this->action === self::ACTION_REJECT;
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Model;
use support\Model;
class Company extends Model
{
protected $table = 'company';
protected $fillable = [
'name',
'feishu_app_id',
'feishu_app_secret',
'feishu_tenant_key',
'status',
];
protected $casts = [
'status' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
protected $hidden = [
'feishu_app_secret',
];
/**
* 获取启用的公司
*/
public function scopeActive($query)
{
return $query->where('status', 1);
}
/**
* 根据飞书租户Key查找
*/
public function scopeByFeishuTenantKey($query, string $tenantKey)
{
return $query->where('feishu_tenant_key', $tenantKey);
}
/**
* 根据飞书AppID查找
*/
public function scopeByFeishuAppId($query, string $appId)
{
return $query->where('feishu_app_id', $appId);
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Model;
use support\Model;
class User extends Model
{
protected $table = 'user';
protected $fillable = [
'company_id',
'feishu_user_id',
'feishu_union_id',
'name',
'email',
'phone',
'avatar',
'status',
];
protected $casts = [
'company_id' => 'integer',
'status' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* 关联公司
*/
public function company()
{
return $this->belongsTo(Company::class, 'company_id');
}
/**
* 获取启用的用户
*/
public function scopeActive($query)
{
return $query->where('status', 1);
}
/**
* 根据飞书用户ID查找
*/
public function scopeByFeishuUserId($query, string $feishuUserId)
{
return $query->where('feishu_user_id', $feishuUserId);
}
/**
* 根据飞书 UnionID 查找
*/
public function scopeByFeishuUnionId($query, string $feishuUnionId)
{
return $query->where('feishu_union_id', $feishuUnionId);
}
}
+302
View File
@@ -0,0 +1,302 @@
<?php
namespace App\Service;
use App\Model\ApprovalFlow;
use App\Model\ApprovalNode;
use App\Model\ApprovalOrder;
use App\Model\ApprovalRecord;
use App\Model\User;
use Illuminate\Database\DatabaseManager;
use App\Util\Db;
use Illuminate\Database\Connection;
class ApprovalService
{
private Connection $db;
public function __construct()
{
// Initialize database if not already initialized
Db::init();
$this->db = Db::connection();
}
/**
* 提交审批
*/
public function submit(array $data): ApprovalOrder
{
return $this->db->transaction(function () use ($data) {
// 1. 获取审批流程
$flow = ApprovalFlow::active()
->byBusinessType($data['business_type'])
->with('nodes')
->findOrFail($data['flow_id']);
// 2. 创建审批单
$order = ApprovalOrder::create([
'business_type' => $data['business_type'],
'business_id' => $data['business_id'],
'title' => $data['title'],
'applicant_id' => $data['applicant_id'],
'status' => ApprovalOrder::STATUS_PENDING,
'current_nodes' => $this->getFirstNodeIds($flow),
'flow_id' => $flow->id,
'form_data' => $data['form_data'] ?? [],
'attachments' => $data['attachments'] ?? [],
]);
// 3. 记录提交操作
ApprovalRecord::create([
'order_id' => $order->id,
'node_id' => $flow->nodes->first()->id ?? 0,
'node_type' => 'submit',
'approver_id' => $data['applicant_id'],
'action' => ApprovalRecord::ACTION_SUBMIT,
'comment' => $data['comment'] ?? null,
]);
return $order->load(['flow', 'applicant']);
});
}
/**
* 审批通过
*/
public function approve(int $orderId, int $approverId, ?string $comment = null): ApprovalOrder
{
return $this->db->transaction(function () use ($orderId, $approverId, $comment) {
$order = ApprovalOrder::with(['flow.nodes'])->findOrFail($orderId);
if (!$order->canApprove()) {
throw new \Exception('当前状态无法审批');
}
$currentNodeIds = $order->current_nodes ?? [];
$currentNode = ApprovalNode::whereIn('id', $currentNodeIds)->first();
if (!$currentNode) {
throw new \Exception('当前节点不存在');
}
// 记录审批操作
ApprovalRecord::create([
'order_id' => $order->id,
'node_id' => $currentNode->id,
'node_type' => $currentNode->type,
'approver_id' => $approverId,
'action' => ApprovalRecord::ACTION_APPROVE,
'comment' => $comment,
]);
// 判断节点是否完成
if ($this->isNodeCompleted($order->id, $currentNode->id)) {
// 移动到下一节点
$nextNodes = $this->getNextNodes($order->flow, $currentNode->order);
if (empty($nextNodes)) {
// 审批完成
$order->status = ApprovalOrder::STATUS_APPROVED;
$order->current_nodes = [];
} else {
$order->current_nodes = collect($nextNodes)->pluck('id')->toArray();
}
$order->save();
}
return $order->fresh(['flow', 'applicant', 'records']);
});
}
/**
* 审批拒绝
*/
public function reject(int $orderId, int $approverId, ?string $comment = null): ApprovalOrder
{
return $this->db->transaction(function () use ($orderId, $approverId, $comment) {
$order = ApprovalOrder::findOrFail($orderId);
if (!$order->canApprove()) {
throw new \Exception('当前状态无法审批');
}
$currentNodeIds = $order->current_nodes ?? [];
$currentNode = ApprovalNode::whereIn('id', $currentNodeIds)->first();
// 记录拒绝操作
ApprovalRecord::create([
'order_id' => $order->id,
'node_id' => $currentNode->id ?? 0,
'node_type' => $currentNode->type ?? 'unknown',
'approver_id' => $approverId,
'action' => ApprovalRecord::ACTION_REJECT,
'comment' => $comment,
]);
// 更新状态为已拒绝
$order->status = ApprovalOrder::STATUS_REJECTED;
$order->current_nodes = [];
$order->save();
return $order->fresh(['flow', 'applicant', 'records']);
});
}
/**
* 撤销申请
*/
public function cancel(int $orderId, int $userId): ApprovalOrder
{
$order = ApprovalOrder::findOrFail($orderId);
if ($order->applicant_id !== $userId) {
throw new \Exception('只有申请人可以撤销');
}
if (!$order->canEdit() && !$order->canApprove()) {
throw new \Exception('当前状态无法撤销');
}
// 记录撤销操作
ApprovalRecord::create([
'order_id' => $order->id,
'node_id' => 0,
'node_type' => 'cancel',
'approver_id' => $userId,
'action' => ApprovalRecord::ACTION_CANCEL,
'comment' => '申请人撤销',
]);
$order->status = ApprovalOrder::STATUS_CANCELLED;
$order->current_nodes = [];
$order->save();
return $order->fresh(['flow', 'applicant', 'records']);
}
/**
* 获取审批列表
*/
public function getList(int $userId, string $type = 'my', int $page = 1, int $pageSize = 20): array
{
$query = ApprovalOrder::with(['flow', 'applicant']);
switch ($type) {
case 'my':
$query->where('applicant_id', $userId);
break;
case 'pending':
// 获取用户待审批的订单
$pendingOrderIds = $this->getPendingOrderIds($userId);
$query->whereIn('id', $pendingOrderIds);
break;
case 'approved':
// 获取用户已审批的订单
$approvedOrderIds = ApprovalRecord::where('approver_id', $userId)
->where('action', '!=', ApprovalRecord::ACTION_SUBMIT)
->pluck('order_id')
->unique()
->toArray();
$query->whereIn('id', $approvedOrderIds);
break;
}
$total = $query->count();
$list = $query->orderBy('created_at', 'desc')
->offset(($page - 1) * $pageSize)
->limit($pageSize)
->get();
return [
'list' => $list,
'total' => $total,
'page' => $page,
'page_size' => $pageSize,
];
}
/**
* 获取第一个节点ID列表
*/
private function getFirstNodeIds(ApprovalFlow $flow): array
{
return $flow->nodes
->where('order', 0)
->pluck('id')
->toArray();
}
/**
* 判断节点是否完成
*/
private function isNodeCompleted(int $orderId, int $nodeId): bool
{
$node = ApprovalNode::findOrFail($nodeId);
$records = ApprovalRecord::where('order_id', $orderId)
->where('node_id', $nodeId)
->where('action', ApprovalRecord::ACTION_APPROVE)
->get();
$approvers = $node->approvers ?? [];
if ($node->type === ApprovalNode::TYPE_AND_APPROVE) {
// 会签:需要所有人通过
return count($records) >= count($approvers);
} elseif ($node->type === ApprovalNode::TYPE_OR_APPROVE) {
// 或签:任一人通过即可
return count($records) >= 1;
} else {
// 单人审批:需要一人通过
return count($records) >= 1;
}
}
/**
* 获取下一节点
*/
private function getNextNodes(ApprovalFlow $flow, int $currentOrder): array
{
return $flow->nodes
->where('order', '>', $currentOrder)
->sortBy('order')
->values()
->toArray();
}
/**
* 获取用户待审批的订单ID列表
*/
private function getPendingOrderIds(int $userId): array
{
// 获取用户参与的待审批订单
$pendingOrders = ApprovalOrder::where('status', ApprovalOrder::STATUS_PENDING)
->whereNotNull('current_nodes')
->get();
$result = [];
foreach ($pendingOrders as $order) {
$currentNodeIds = $order->current_nodes ?? [];
$nodes = ApprovalNode::whereIn('id', $currentNodeIds)->get();
foreach ($nodes as $node) {
$approvers = $node->approvers ?? [];
if (in_array($userId, $approvers)) {
// 检查是否已审批
$hasApproved = ApprovalRecord::where('order_id', $order->id)
->where('node_id', $node->id)
->where('approver_id', $userId)
->exists();
if (!$hasApproved) {
$result[] = $order->id;
break;
}
}
}
}
return $result;
}
}
+252
View File
@@ -0,0 +1,252 @@
<?php
namespace App\Service;
use App\Model\Company;
use App\Model\User;
use support\Cache;
class FeishuService
{
private ?string $accessToken = null;
private ?int $companyId = null;
/**
* 设置当前操作的公司
*/
public function setCompany(int $companyId): self
{
$this->companyId = $companyId;
$this->accessToken = null;
return $this;
}
/**
* 根据 tenant_key 获取公司配置
*/
public function getCompanyByTenantKey(string $tenantKey): ?Company
{
return Company::active()->byFeishuTenantKey($tenantKey)->first();
}
/**
* 获取飞书 access_token
*/
public function getAccessToken(): string
{
if ($this->accessToken) {
return $this->accessToken;
}
if (!$this->companyId) {
throw new \Exception('未设置公司ID');
}
$company = Company::findOrFail($this->companyId);
// 从缓存获取
$cacheKey = "feishu:access_token:{$company->id}";
$token = Cache::get($cacheKey);
if ($token) {
$this->accessToken = $token;
return $token;
}
// 请求新的 token
$response = $this->httpPost('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', [
'app_id' => $company->feishu_app_id,
'app_secret' => $company->feishu_app_secret,
]);
if ($response['code'] !== 0) {
throw new \Exception('获取飞书 token 失败: ' . ($response['msg'] ?? '未知错误'));
}
$token = $response['tenant_access_token'];
$expire = $response['expire'] ?? 7200;
// 缓存 token(提前5分钟过期)
Cache::set($cacheKey, $token, $expire - 300);
$this->accessToken = $token;
return $token;
}
/**
* 根据 auth_code 获取用户信息
*/
public function getUserInfo(string $authCode): array
{
if (!$this->companyId) {
throw new \Exception('未设置公司ID');
}
$token = $this->getAccessToken();
// 获取用户 access_token
$response = $this->httpPost('https://open.feishu.cn/open-apis/authen/v1/oidc/access_token', [
'grant_type' => 'authorization_code',
'client_id' => Company::findOrFail($this->companyId)->feishu_app_id,
'client_secret' => Company::findOrFail($this->companyId)->feishu_app_secret,
'code' => $authCode,
], [
'Authorization: Bearer ' . $token,
]);
if ($response['code'] !== 0) {
throw new \Exception('获取用户信息失败: ' . ($response['msg'] ?? '未知错误'));
}
return $response['data'] ?? [];
}
/**
* 创建或更新用户
*/
public function createOrUpdateUser(array $feishuData, int $companyId): User
{
$unionId = $feishuData['union_id'] ?? null;
$userId = $feishuData['user_id'] ?? null;
$name = $feishuData['name'] ?? '未知';
$avatar = $feishuData['avatar_url'] ?? null;
$email = $feishuData['email'] ?? null;
if (!$unionId) {
throw new \Exception('缺少 union_id');
}
// 查找或创建用户
$user = User::byFeishuUnionId($unionId)->first();
if (!$user) {
$user = User::create([
'company_id' => $companyId,
'feishu_user_id' => $userId,
'feishu_union_id' => $unionId,
'name' => $name,
'email' => $email,
'avatar' => $avatar,
'status' => 1,
]);
} else {
// 更新用户信息
$user->update([
'feishu_user_id' => $userId,
'name' => $name,
'avatar' => $avatar,
'email' => $email,
]);
}
return $user;
}
/**
* 发送审批消息通知
*/
public function sendApprovalMessage(int $userId, string $title, string $url): bool
{
try {
$user = User::findOrFail($userId);
$company = Company::findOrFail($user->company_id);
$this->setCompany($company->id);
$token = $this->getAccessToken();
// 构建消息内容
$message = [
'msg_type' => 'interactive',
'card' => [
'header' => [
'title' => [
'content' => '审批通知',
'tag' => 'plain_text',
],
'template' => 'blue',
],
'elements' => [
[
'tag' => 'div',
'text' => [
'content' => $title,
'tag' => 'plain_text',
],
],
[
'tag' => 'action',
'actions' => [
[
'tag' => 'button',
'text' => [
'content' => '查看详情',
'tag' => 'plain_text',
],
'type' => 'default',
'url' => $url,
],
],
],
],
],
];
// 发送消息
$response = $this->httpPost(
"https://open.feishu.cn/open-apis/message/v4/send_messages",
[
'receive_id_type' => 'user_id',
'receive_id' => $user->feishu_user_id,
'msg_type' => 'interactive',
'content' => json_encode(['card' => $message['card']]),
],
[
'Authorization: Bearer ' . $token,
]
);
return ($response['code'] ?? -1) === 0;
} catch (\Exception $e) {
// 记录错误但不中断流程
error_log('发送飞书消息失败: ' . $e->getMessage());
return false;
}
}
/**
* HTTP POST 请求
*/
private function httpPost(string $url, array $data, array $headers = []): array
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$defaultHeaders = [
'Content-Type: application/json',
];
curl_setopt($ch, CURLOPT_HTTPHEADER, array_merge($defaultHeaders, $headers));
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
throw new \Exception("HTTP 请求失败: {$httpCode}");
}
$result = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \Exception('JSON 解析失败');
}
return $result;
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Util;
use Illuminate\Database\Capsule\Manager as Capsule;
use Illuminate\Events\Dispatcher;
use Illuminate\Container\Container;
use Illuminate\Database\Eloquent\Model as EloquentModel;
class Db
{
private static ?Capsule $capsule = null;
public static function init(): void
{
if (self::$capsule !== null) {
return;
}
$config = config('database');
self::$capsule = new Capsule();
// Add connection
self::$capsule->addConnection([
'driver' => 'mysql',
'host' => getenv('DB_HOST') ?: '127.0.0.1',
'port' => getenv('DB_PORT') ?: '3306',
'database' => getenv('DB_DATABASE') ?: 'x_server_mobile',
'username' => getenv('DB_USERNAME') ?: 'root',
'password' => getenv('DB_PASSWORD') ?: '',
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'strict' => true,
'engine' => null,
]);
// Set event dispatcher
self::$capsule->setEventDispatcher(new Dispatcher(new Container()));
// Make this Capsule instance available globally via static methods
self::$capsule->setAsGlobal();
// Setup the Eloquent ORM
self::$capsule->bootEloquent();
}
public static function connection(?string $name = null): \Illuminate\Database\Connection
{
if (self::$capsule === null) {
self::init();
}
return self::$capsule->connection($name);
}
public static function table(string $table): \Illuminate\Database\Query\Builder
{
return self::connection()->table($table);
}
public static function raw(string $value): \Illuminate\Database\Query\Expression
{
return self::connection()->raw($value);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Util;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Exception;
class JwtUtil
{
private static string $secret = 'your-secret-key-change-in-production';
private static int $expire = 86400 * 7; // 7 days
/**
* 生成 Token
*/
public static function encode(array $data): string
{
$payload = array_merge($data, [
'iat' => time(),
'exp' => time() + self::$expire,
]);
return JWT::encode($payload, self::$secret, 'HS256');
}
/**
* 解析 Token
*/
public static function decode(string $token): ?array
{
try {
$decoded = JWT::decode($token, new Key(self::$secret, 'HS256'));
return (array) $decoded;
} catch (Exception $e) {
return null;
}
}
/**
* 验证 Token
*/
public static function verify(string $token): bool
{
return self::decode($token) !== null;
}
}
+170
View File
@@ -0,0 +1,170 @@
<?php
namespace App\Util;
/**
* 多语言翻译类
* 支持:中文(zh)、英文(en)、俄语(ru)、西语(es)、法语(fr)
*/
class Lang
{
private static ?array $translations = null;
private static string $locale = 'zh';
private static string $fallbackLocale = 'zh';
/**
* 支持的语言列表
*/
public const SUPPORTED_LOCALES = ['zh', 'en', 'ru', 'es', 'fr'];
/**
* 语言名称映射
*/
public const LOCALE_NAMES = [
'zh' => '简体中文',
'en' => 'English',
'ru' => 'Русский',
'es' => 'Español',
'fr' => 'Français',
];
/**
* 初始化翻译
*/
private static function loadTranslations(): void
{
if (self::$translations !== null) {
return;
}
self::$translations = [];
foreach (self::SUPPORTED_LOCALES as $locale) {
$file = base_path() . "/lang/{$locale}.php";
if (file_exists($file)) {
self::$translations[$locale] = require $file;
}
}
}
/**
* 设置当前语言
*/
public static function setLocale(string $locale): void
{
if (in_array($locale, self::SUPPORTED_LOCALES)) {
self::$locale = $locale;
}
}
/**
* 获取当前语言
*/
public static function getLocale(): string
{
return self::$locale;
}
/**
* 从请求中获取语言
*/
public static function detectFromRequest(): string
{
// 从请求头获取语言
$request = \request();
$acceptLanguage = $request->header('Accept-Language');
if ($acceptLanguage) {
// 解析 Accept-Language 头
$locales = explode(',', $acceptLanguage);
foreach ($locales as $locale) {
$locale = trim(strstr($locale, ';', true));
$locale = substr($locale, 0, 2);
if (in_array($locale, self::SUPPORTED_LOCALES)) {
return $locale;
}
}
}
// 从查询参数获取
$lang = $request->input('lang');
if ($lang && in_array($lang, self::SUPPORTED_LOCALES)) {
return $lang;
}
return self::$fallbackLocale;
}
/**
* 翻译文本
*
* @param string $key 翻译键,使用点号分隔,如 'common.confirm'
* @param array $params 替换参数
* @param string|null $locale 指定语言,null 则使用当前语言
* @return string 翻译后的文本
*/
public static function get(string $key, array $params = [], ?string $locale = null): string
{
self::loadTranslations();
$locale = $locale ?? self::$locale;
// 获取翻译值
$value = self::getTranslationValue($key, $locale);
// 替换参数
if (!empty($params)) {
foreach ($params as $search => $replace) {
$value = str_replace(':' . $search, $replace, $value);
$value = str_replace('{' . $search . '}', $replace, $value);
}
}
return $value;
}
/**
* 获取翻译值
*/
private static function getTranslationValue(string $key, string $locale): string
{
$keys = explode('.', $key);
$value = self::$translations[$locale] ?? [];
foreach ($keys as $k) {
if (isset($value[$k])) {
$value = $value[$k];
} else {
// 尝试使用回退语言
if ($locale !== self::$fallbackLocale) {
return self::getTranslationValue($key, self::$fallbackLocale);
}
return $key;
}
}
return is_string($value) ? $value : $key;
}
/**
* 判断翻译是否存在
*/
public static function has(string $key, ?string $locale = null): bool
{
self::loadTranslations();
$locale = $locale ?? self::$locale;
$keys = explode('.', $key);
$value = self::$translations[$locale] ?? [];
foreach ($keys as $k) {
if (isset($value[$k])) {
$value = $value[$k];
} else {
return false;
}
}
return true;
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Middleware;
use App\Util\JwtUtil;
use Webman\Http\Request;
use Webman\Http\Response;
use support\Log;
class AuthMiddleware
{
public function process(Request $request, callable $handler): Response
{
// 获取 Token
$token = $request->header('Authorization');
$token = str_replace('Bearer ', '', $token);
if (!$token || !JwtUtil::verify($token)) {
return json([
'code' => 401,
'message' => 'Unauthorized',
'data' => null,
], 401);
}
// 解析 Token 并设置用户信息到请求
$payload = JwtUtil::decode($token);
if ($payload) {
$request->userId = $payload['user_id'] ?? null;
$request->companyId = $payload['company_id'] ?? null;
}
return $handler($request);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Middleware;
use Webman\Http\Request;
use Webman\Http\Response;
class CorsMiddleware
{
public function process(Request $request, callable $handler): Response
{
// 处理 OPTIONS 预检请求
if ($request->method() === 'OPTIONS') {
$response = response('');
} else {
$response = $handler($request);
}
// 添加 CORS 头
$response->withHeader('Access-Control-Allow-Origin', '*')
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
->withHeader('Access-Control-Max-Age', '86400');
return $response;
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"name": "x-server/mobile-backend",
"description": "Enterprise Tool System Backend",
"type": "project",
"require": {
"php": ">=8.1",
"workerman/webman": "^2.0",
"workerman/webman-framework": "^2.0",
"illuminate/database": "^10.0",
"illuminate/pagination": "^10.0",
"illuminate/redis": "^10.0",
"illuminate/events": "^10.0",
"firebase/php-jwt": "^6.10",
"vlucas/phpdotenv": "^5.6"
},
"require-dev": {
"mockery/mockery": "^1.5",
"phpunit/phpunit": "^10.0"
},
"autoload": {
"psr-4": {
"App\\": "app/"
}
},
"scripts": {
"post-package-install": [
"support\\Plugin::install"
],
"post-package-update": [
"support\\Plugin::install"
]
},
"config": {
"audit": {
"ignore": {
"firebase/php-jwt": "PKSA-y2cr-5h3j-g3ys"
}
},
"allow-plugins": {
"*": true
}
}
}
+4750
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
<?php
return [
'debug' => getenv('APP_DEBUG') ?: true,
'default_timezone' => 'Asia/Shanghai',
'request_class' => support\Request::class,
'public_path' => base_path() . '/public',
'runtime_path' => base_path() . '/runtime',
'controller_suffix' => 'Controller',
'controller_reuse' => true,
];
+3
View File
@@ -0,0 +1,3 @@
<?php
return new \Illuminate\Container\Container();
+24
View File
@@ -0,0 +1,24 @@
<?php
return [
'default' => 'mysql',
'connections' => [
'mysql' => [
'driver' => 'mysql',
'host' => getenv('DB_HOST') ?: 'mysql',
'port' => getenv('DB_PORT') ?: 3306,
'database' => getenv('DB_DATABASE') ?: 'x_server_mobile',
'username' => getenv('DB_USERNAME') ?: 'root',
'password' => getenv('DB_PASSWORD') ?: '123456',
'unix_socket' => getenv('DB_SOCKET') ?: '',
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'strict' => true,
'engine' => null,
'options' => [
\PDO::ATTR_TIMEOUT => 5,
],
],
],
];
+17
View File
@@ -0,0 +1,17 @@
<?php
use support\Log;
return [
'default' => [
'handlers' => [
[
'class' => Monolog\Handler\StreamHandler::class,
'constructor' => [
runtime_path() . '/logs/workerman.log',
Monolog\Logger::DEBUG,
],
],
],
],
];
@@ -0,0 +1,28 @@
<?php
return [
'enable' => true,
'build_dir' => BASE_PATH . DIRECTORY_SEPARATOR . 'build',
'phar_filename' => 'webman.phar',
'phar_format' => Phar::PHAR, // Phar archive format: Phar::PHAR, Phar::TAR, Phar::ZIP
'phar_compression' => Phar::NONE, // Compression method for Phar archive: Phar::NONE, Phar::GZ, Phar::BZ2
'bin_filename' => 'webman.bin',
'signature_algorithm'=> Phar::SHA256, //set the signature algorithm for a phar and apply it. The signature algorithm must be one of Phar::MD5, Phar::SHA1, Phar::SHA256, Phar::SHA512, or Phar::OPENSSL.
'private_key_file' => '', // The file path for certificate or OpenSSL private key file.
'exclude_pattern' => '#^(?!.*(composer.json|/.github/|/.idea/|/.git/|/.setting/|/runtime/|/vendor-bin/|/build/|/vendor/webman/admin/))(.*)$#',
'exclude_files' => [
'.env', 'LICENSE', 'composer.json', 'composer.lock', 'start.php', 'webman.phar', 'webman.bin'
],
'custom_ini' => '
memory_limit = 256M
',
];
+10
View File
@@ -0,0 +1,10 @@
<?php
return [
'default' => [
'host' => getenv('REDIS_HOST') ?: 'redis',
'port' => getenv('REDIS_PORT') ?: 6379,
'password' => getenv('REDIS_PASSWORD') ?: null,
'database' => getenv('REDIS_DATABASE') ?: 0,
],
];
+41
View File
@@ -0,0 +1,41 @@
<?php
use Webman\Route;
// API 路由
Route::group('/api/v1', function () {
// 认证路由
Route::post('/auth/login', [App\Controller\AuthController::class, 'login']);
Route::post('/auth/feishu-login', [App\Controller\AuthController::class, 'feishuLogin']);
// 用户路由(需要认证)
Route::group(function () {
Route::get('/user/info', [App\Controller\UserController::class, 'info']);
Route::get('/user/menu', [App\Controller\UserController::class, 'menu']);
Route::get('/user/companies', [App\Controller\UserController::class, 'companies']);
Route::post('/user/switch-company', [App\Controller\UserController::class, 'switchCompany']);
});
// 审批路由
Route::group(function () {
Route::get('/approval/list', [App\Controller\ApprovalController::class, 'list']);
Route::get('/approval/detail/{id}', [App\Controller\ApprovalController::class, 'detail']);
Route::post('/approval/submit', [App\Controller\ApprovalController::class, 'submit']);
Route::post('/approval/approve/{id}', [App\Controller\ApprovalController::class, 'approve']);
Route::post('/approval/reject/{id}', [App\Controller\ApprovalController::class, 'reject']);
Route::post('/approval/cancel/{id}', [App\Controller\ApprovalController::class, 'cancel']);
});
// 出差审批路由
Route::group(function () {
Route::get('/business-trip/list', [App\Controller\BusinessTripController::class, 'list']);
Route::get('/business-trip/detail/{id}', [App\Controller\BusinessTripController::class, 'detail']);
Route::post('/business-trip/apply', [App\Controller\BusinessTripController::class, 'apply']);
Route::put('/business-trip/{id}', [App\Controller\BusinessTripController::class, 'update']);
});
});
// 默认路由
Route::fallback(function () {
return json(['code' => 404, 'message' => 'Not Found']);
});
+23
View File
@@ -0,0 +1,23 @@
<?php
/**
* Webman server configuration
*/
use support\Log;
return [
'listen' => 'http://0.0.0.0:8787',
'transport' => 'tcp',
'context' => [],
'name' => 'webman',
'count' => (int)shell_exec('nproc') ?: 4,
'user' => '',
'group' => '',
'reusePort' => false,
'event_loop' => '',
'stop_timeout' => 2,
'pid_file' => runtime_path() . '/webman.pid',
'max_request' => 1000000,
'stdout_file' => runtime_path() . '/logs/stdout.log',
'log_file' => runtime_path() . '/logs/workerman.log',
'max_package_size' => 10 * 1024 * 1024,
];
+196
View File
@@ -0,0 +1,196 @@
-- 企业内部工具系统 - 数据库初始化脚本
-- 数据库: x_server_mobile
-- =====================================
-- 1. 公司表(飞书应用配置)
-- =====================================
CREATE TABLE IF NOT EXISTS `company` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '公司ID',
`name` VARCHAR(100) NOT NULL COMMENT '公司名称',
`feishu_app_id` VARCHAR(100) DEFAULT NULL COMMENT '飞书应用ID',
`feishu_app_secret` VARCHAR(255) DEFAULT NULL COMMENT '飞书应用密钥',
`feishu_tenant_key` VARCHAR(100) DEFAULT NULL COMMENT '飞书租户Key',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1=启用,0=禁用',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_feishu_app_id` (`feishu_app_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='公司表';
-- =====================================
-- 2. 用户表(全局用户)
-- =====================================
CREATE TABLE IF NOT EXISTS `user` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`company_id` BIGINT UNSIGNED NOT NULL COMMENT '所属公司ID',
`feishu_user_id` VARCHAR(100) DEFAULT NULL COMMENT '飞书用户ID',
`feishu_union_id` VARCHAR(100) DEFAULT NULL COMMENT '飞书UnionID(跨应用唯一)',
`name` VARCHAR(50) NOT NULL COMMENT '用户名',
`email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱',
`phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
`avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像URL',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1=正常,0=禁用',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_company_id` (`company_id`),
KEY `idx_feishu_user_id` (`feishu_user_id`),
KEY `idx_feishu_union_id` (`feishu_union_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
-- =====================================
-- 3. 用户与飞书应用关联表
-- =====================================
CREATE TABLE IF NOT EXISTS `user_feishu_binding` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID',
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
`company_id` BIGINT UNSIGNED NOT NULL COMMENT '公司ID',
`feishu_open_id` VARCHAR(100) DEFAULT NULL COMMENT '飞书OpenID',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_company` (`user_id`, `company_id`),
KEY `idx_feishu_open_id` (`feishu_open_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户飞书应用绑定表';
-- =====================================
-- 4. 审批流程配置表
-- =====================================
CREATE TABLE IF NOT EXISTS `approval_flow` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '流程ID',
`business_type` VARCHAR(50) NOT NULL COMMENT '业务类型标识(如:business-trip, purchase)',
`name` VARCHAR(100) NOT NULL COMMENT '流程名称',
`description` VARCHAR(255) DEFAULT NULL COMMENT '流程描述',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1=启用,0=禁用',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_business_type` (`business_type`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='审批流程配置表';
-- =====================================
-- 5. 审批流程节点表
-- =====================================
CREATE TABLE IF NOT EXISTS `approval_node` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '节点ID',
`flow_id` BIGINT UNSIGNED NOT NULL COMMENT '流程ID',
`type` ENUM('approver', 'and_approve', 'or_approve', 'notify') NOT NULL COMMENT '节点类型',
`name` VARCHAR(100) NOT NULL COMMENT '节点名称',
`order` INT NOT NULL DEFAULT 0 COMMENT '节点顺序',
`approvers` JSON DEFAULT NULL COMMENT '审批人列表(JSON数组)',
`conditions` JSON DEFAULT NULL COMMENT '条件判断(JSON)',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_flow_id` (`flow_id`),
KEY `idx_order` (`order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='审批流程节点表';
-- =====================================
-- 6. 审批单表
-- =====================================
CREATE TABLE IF NOT EXISTS `approval_order` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '审批单ID',
`business_type` VARCHAR(50) NOT NULL COMMENT '业务类型',
`business_id` BIGINT UNSIGNED NOT NULL COMMENT '业务单据ID',
`title` VARCHAR(200) NOT NULL COMMENT '审批标题',
`applicant_id` BIGINT UNSIGNED NOT NULL COMMENT '申请人ID',
`status` ENUM('draft', 'pending', 'approved', 'rejected', 'cancelled') NOT NULL DEFAULT 'draft' COMMENT '审批状态',
`current_nodes` JSON DEFAULT NULL COMMENT '当前待审批节点ID列表',
`flow_id` BIGINT UNSIGNED NOT NULL COMMENT '使用的流程ID',
`form_data` JSON DEFAULT NULL COMMENT '业务表单数据(JSON)',
`attachments` JSON DEFAULT NULL COMMENT '附件列表(JSON)',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_business` (`business_type`, `business_id`),
KEY `idx_applicant` (`applicant_id`),
KEY `idx_status` (`status`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='审批单表';
-- =====================================
-- 7. 审批记录表(审批轨迹)
-- =====================================
CREATE TABLE IF NOT EXISTS `approval_record` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '记录ID',
`order_id` BIGINT UNSIGNED NOT NULL COMMENT '审批单ID',
`node_id` BIGINT UNSIGNED NOT NULL COMMENT '节点ID',
`node_type` VARCHAR(20) NOT NULL COMMENT '节点类型',
`approver_id` BIGINT UNSIGNED NOT NULL COMMENT '审批人ID',
`action` ENUM('submit', 'approve', 'reject', 'cancel') NOT NULL COMMENT '操作类型',
`comment` VARCHAR(500) DEFAULT NULL COMMENT '审批意见',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
PRIMARY KEY (`id`),
KEY `idx_order_id` (`order_id`),
KEY `idx_approver_id` (`approver_id`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='审批记录表';
-- =====================================
-- 8. 出差申请表
-- =====================================
CREATE TABLE IF NOT EXISTS `business_trip` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '出差ID',
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '申请人ID',
`reason` VARCHAR(500) NOT NULL COMMENT '出差事由',
`start_date` DATE NOT NULL COMMENT '开始日期',
`end_date` DATE NOT NULL COMMENT '结束日期',
`destination` VARCHAR(200) NOT NULL COMMENT '目的地',
`estimated_cost` DECIMAL(10,2) DEFAULT 0.00 COMMENT '预计费用',
`status` ENUM('draft', 'pending', 'approved', 'rejected', 'cancelled') NOT NULL DEFAULT 'draft' COMMENT '状态',
`approval_order_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '关联审批单ID',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_status` (`status`),
KEY `idx_dates` (`start_date`, `end_date`),
KEY `idx_approval_order_id` (`approval_order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='出差申请表';
-- =====================================
-- 9. 出差行程明细表
-- =====================================
CREATE TABLE IF NOT EXISTS `business_trip_itinerary` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '行程ID',
`business_trip_id` BIGINT UNSIGNED NOT NULL COMMENT '出差申请ID',
`date` DATE NOT NULL COMMENT '行程日期',
`departure` VARCHAR(100) DEFAULT NULL COMMENT '出发地',
`destination` VARCHAR(100) NOT NULL COMMENT '目的地',
`transport` VARCHAR(50) DEFAULT NULL COMMENT '交通方式',
`accommodation` VARCHAR(100) DEFAULT NULL COMMENT '住宿安排',
`remark` VARCHAR(255) DEFAULT NULL COMMENT '备注',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_business_trip_id` (`business_trip_id`),
KEY `idx_date` (`date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='出差行程明细表';
-- =====================================
-- 初始化数据
-- =====================================
-- 插入默认公司
INSERT INTO `company` (`id`, `name`, `status`) VALUES
(1, '示例公司', 1)
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`);
-- 插入默认用户
INSERT INTO `user` (`id`, `company_id`, `name`, `email`, `status`) VALUES
(1, 1, '管理员', 'admin@example.com', 1)
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`);
-- 插入默认出差审批流程
INSERT INTO `approval_flow` (`id`, `business_type`, `name`, `status`) VALUES
(1, 'business-trip', '默认出差审批流程', 1)
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`);
-- 插入默认审批节点
INSERT INTO `approval_node` (`flow_id`, `type`, `name`, `order`, `approvers`) VALUES
(1, 'approver', '部门主管审批', 1, '[]'),
(1, 'approver', '人事审批', 2, '[]')
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`);
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* English translation file
*/
return [
'app' => [
'name' => 'Enterprise Tool System',
'title' => 'Enterprise Internal Tool Platform',
],
'common' => [
'success' => 'Operation successful',
'error' => 'Operation failed',
'confirm' => 'Confirm',
'cancel' => 'Cancel',
'submit' => 'Submit',
'save' => 'Save',
'delete' => 'Delete',
'edit' => 'Edit',
'search' => 'Search',
'refresh' => 'Refresh',
'back' => 'Back',
'close' => 'Close',
'loading' => 'Loading...',
'no_data' => 'No data',
'operation' => 'Operation',
'status' => 'Status',
'create_time' => 'Create Time',
'update_time' => 'Update Time',
'remark' => 'Remark',
],
'auth' => [
'login_success' => 'Login successful',
'login_failed' => 'Login failed',
'username_required' => 'Username is required',
'password_required' => 'Password is required',
'invalid_credentials' => 'Invalid username or password',
'token_expired' => 'Session expired, please login again',
'unauthorized' => 'Unauthorized, please login first',
],
'user' => [
'not_found' => 'User not found',
'info_updated' => 'Profile updated successfully',
'company_switched' => 'Company switched successfully',
],
'approval' => [
'submit_success' => 'Approval submitted successfully',
'approve_success' => 'Approved successfully',
'reject_success' => 'Rejected successfully',
'cancel_success' => 'Cancelled successfully',
'not_found' => 'Approval not found',
'cannot_approve' => 'Cannot approve in current status',
'cannot_cancel' => 'Cannot cancel in current status',
'not_owner' => 'Only the applicant can cancel',
'status' => [
'pending' => 'Pending',
'approved' => 'Approved',
'rejected' => 'Rejected',
'cancelled' => 'Cancelled',
],
],
'business_trip' => [
'submit_success' => 'Business trip submitted successfully',
'update_success' => 'Business trip updated successfully',
'not_found' => 'Business trip not found',
],
'error' => [
'network_error' => 'Network error, please try again later',
'server_error' => 'Server error',
'not_found' => 'Page not found',
'unknown_error' => 'Unknown error',
'params_error' => 'Parameter error',
'validation_error' => 'Validation failed',
],
];
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* Traducción al español (Spanish translation)
*/
return [
'app' => [
'name' => 'Sistema Empresarial',
'title' => 'Plataforma de Herramientas Empresariales',
],
'common' => [
'success' => 'Operación exitosa',
'error' => 'Operación fallida',
'confirm' => 'Confirmar',
'cancel' => 'Cancelar',
'submit' => 'Enviar',
'save' => 'Guardar',
'delete' => 'Eliminar',
'edit' => 'Editar',
'search' => 'Buscar',
'refresh' => 'Actualizar',
'back' => 'Atrás',
'close' => 'Cerrar',
'loading' => 'Cargando...',
'no_data' => 'Sin datos',
'operation' => 'Operación',
'status' => 'Estado',
'create_time' => 'Fecha de creación',
'update_time' => 'Fecha de actualización',
'remark' => 'Observación',
],
'auth' => [
'login_success' => 'Inicio de sesión exitoso',
'login_failed' => 'Error de inicio de sesión',
'username_required' => 'Se requiere usuario',
'password_required' => 'Se requiere contraseña',
'invalid_credentials' => 'Usuario o contraseña inválidos',
'token_expired' => 'Sesión expirada, inicie sesión nuevamente',
'unauthorized' => 'No autorizado, inicie sesión primero',
],
'user' => [
'not_found' => 'Usuario no encontrado',
'info_updated' => 'Perfil actualizado exitosamente',
'company_switched' => 'Empresa cambiada exitosamente',
],
'approval' => [
'submit_success' => 'Aprobación enviada exitosamente',
'approve_success' => 'Aprobado exitosamente',
'reject_success' => 'Rechazado exitosamente',
'cancel_success' => 'Cancelado exitosamente',
'not_found' => 'Aprobación no encontrada',
'cannot_approve' => 'No se puede aprobar en estado actual',
'cannot_cancel' => 'No se puede cancelar en estado actual',
'not_owner' => 'Solo el solicitante puede cancelar',
'status' => [
'pending' => 'Pendiente',
'approved' => 'Aprobado',
'rejected' => 'Rechazado',
'cancelled' => 'Cancelado',
],
],
'business_trip' => [
'submit_success' => 'Viaje enviado exitosamente',
'update_success' => 'Viaje actualizado exitosamente',
'not_found' => 'Viaje no encontrado',
],
'error' => [
'network_error' => 'Error de red, inténtelo más tarde',
'server_error' => 'Error del servidor',
'not_found' => 'Página no encontrada',
'unknown_error' => 'Error desconocido',
'params_error' => 'Error de parámetros',
'validation_error' => 'Validación fallida',
],
];
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* Traduction française (French translation)
*/
return [
'app' => [
'name' => 'Système d\'Entreprise',
'title' => 'Plateforme d\'Outils Entreprise',
],
'common' => [
'success' => 'Opération réussie',
'error' => 'Opération échouée',
'confirm' => 'Confirmer',
'cancel' => 'Annuler',
'submit' => 'Soumettre',
'save' => 'Enregistrer',
'delete' => 'Supprimer',
'edit' => 'Modifier',
'search' => 'Rechercher',
'refresh' => 'Actualiser',
'back' => 'Retour',
'close' => 'Fermer',
'loading' => 'Chargement...',
'no_data' => 'Aucune donnée',
'operation' => 'Opération',
'status' => 'Statut',
'create_time' => 'Date de création',
'update_time' => 'Date de mise à jour',
'remark' => 'Remarque',
],
'auth' => [
'login_success' => 'Connexion réussie',
'login_failed' => 'Échec de la connexion',
'username_required' => 'Nom d\'utilisateur requis',
'password_required' => 'Mot de passe requis',
'invalid_credentials' => 'Nom d\'utilisateur ou mot de passe invalide',
'token_expired' => 'Session expirée, veuillez vous reconnecter',
'unauthorized' => 'Non autorisé, veuillez vous connecter d\'abord',
],
'user' => [
'not_found' => 'Utilisateur introuvable',
'info_updated' => 'Profil mis à jour avec succès',
'company_switched' => 'Entreprise changée avec succès',
],
'approval' => [
'submit_success' => 'Approbation soumise avec succès',
'approve_success' => 'Approuvé avec succès',
'reject_success' => 'Rejeté avec succès',
'cancel_success' => 'Annulé avec succès',
'not_found' => 'Approbation introuvable',
'cannot_approve' => 'Impossible d\'approuver dans cet état',
'cannot_cancel' => 'Impossible d\'annuler dans cet état',
'not_owner' => 'Seul le demandeur peut annuler',
'status' => [
'pending' => 'En attente',
'approved' => 'Approuvé',
'rejected' => 'Rejeté',
'cancelled' => 'Annulé',
],
],
'business_trip' => [
'submit_success' => 'Déplacement soumis avec succès',
'update_success' => 'Déplacement mis à jour avec succès',
'not_found' => 'Déplacement introuvable',
],
'error' => [
'network_error' => 'Erreur réseau, veuillez réessayer plus tard',
'server_error' => 'Erreur du serveur',
'not_found' => 'Page introuvable',
'unknown_error' => 'Erreur inconnue',
'params_error' => 'Erreur de paramètres',
'validation_error' => 'Validation échouée',
],
];
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* Русский перевод (Russian translation)
*/
return [
'app' => [
'name' => 'Корпоративная система',
'title' => 'Корпоративная платформа инструментов',
],
'common' => [
'success' => 'Операция выполнена успешно',
'error' => 'Операция не удалась',
'confirm' => 'Подтвердить',
'cancel' => 'Отмена',
'submit' => 'Отправить',
'save' => 'Сохранить',
'delete' => 'Удалить',
'edit' => 'Редактировать',
'search' => 'Поиск',
'refresh' => 'Обновить',
'back' => 'Назад',
'close' => 'Закрыть',
'loading' => 'Загрузка...',
'no_data' => 'Нет данных',
'operation' => 'Операция',
'status' => 'Статус',
'create_time' => 'Время создания',
'update_time' => 'Время обновления',
'remark' => 'Примечание',
],
'auth' => [
'login_success' => 'Вход выполнен успешно',
'login_failed' => 'Ошибка входа',
'username_required' => 'Имя пользователя обязательно',
'password_required' => 'Пароль обязателен',
'invalid_credentials' => 'Неверное имя пользователя или пароль',
'token_expired' => 'Сессия истекла, войдите снова',
'unauthorized' => 'Не авторизован, войдите сначала',
],
'user' => [
'not_found' => 'Пользователь не найден',
'info_updated' => 'Профиль обновлен успешно',
'company_switched' => 'Компания изменена успешно',
],
'approval' => [
'submit_success' => 'Согласование отправлено успешно',
'approve_success' => 'Согласовано успешно',
'reject_success' => 'Отклонено успешно',
'cancel_success' => 'Отменено успешно',
'not_found' => 'Согласование не найдено',
'cannot_approve' => 'Невозможно согласовать в текущем статусе',
'cannot_cancel' => 'Невозможно отменить в текущем статусе',
'not_owner' => 'Только заявитель может отменить',
'status' => [
'pending' => 'Ожидает',
'approved' => 'Согласовано',
'rejected' => 'Отклонено',
'cancelled' => 'Отменено',
],
],
'business_trip' => [
'submit_success' => 'Командировка отправлена успешно',
'update_success' => 'Командировка обновлена успешно',
'not_found' => 'Командировка не найдена',
],
'error' => [
'network_error' => 'Ошибка сети, попробуйте позже',
'server_error' => 'Ошибка сервера',
'not_found' => 'Страница не найдена',
'unknown_error' => 'Неизвестная ошибка',
'params_error' => 'Ошибка параметров',
'validation_error' => 'Ошибка проверки данных',
],
];
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* 中文翻译文件
*/
return [
'app' => [
'name' => '企业工具系统',
'title' => '企业内部工具平台',
],
'common' => [
'success' => '操作成功',
'error' => '操作失败',
'confirm' => '确认',
'cancel' => '取消',
'submit' => '提交',
'save' => '保存',
'delete' => '删除',
'edit' => '编辑',
'search' => '搜索',
'refresh' => '刷新',
'back' => '返回',
'close' => '关闭',
'loading' => '加载中...',
'no_data' => '暂无数据',
'operation' => '操作',
'status' => '状态',
'create_time' => '创建时间',
'update_time' => '更新时间',
'remark' => '备注',
],
'auth' => [
'login_success' => '登录成功',
'login_failed' => '登录失败',
'username_required' => '用户名不能为空',
'password_required' => '密码不能为空',
'invalid_credentials' => '用户名或密码错误',
'token_expired' => '登录已过期,请重新登录',
'unauthorized' => '未授权,请先登录',
],
'user' => [
'not_found' => '用户不存在',
'info_updated' => '个人信息更新成功',
'company_switched' => '公司切换成功',
],
'approval' => [
'submit_success' => '审批提交成功',
'approve_success' => '审批通过',
'reject_success' => '已拒绝',
'cancel_success' => '已撤销',
'not_found' => '审批单不存在',
'cannot_approve' => '当前状态无法审批',
'cannot_cancel' => '当前状态无法撤销',
'not_owner' => '只有申请人可以撤销',
'status' => [
'pending' => '待审批',
'approved' => '已通过',
'rejected' => '已拒绝',
'cancelled' => '已撤销',
],
],
'business_trip' => [
'submit_success' => '出差申请提交成功',
'update_success' => '出差申请更新成功',
'not_found' => '出差申请不存在',
],
'error' => [
'network_error' => '网络错误,请稍后重试',
'server_error' => '服务器错误',
'not_found' => '页面不存在',
'unknown_error' => '未知错误',
'params_error' => '参数错误',
'validation_error' => '数据验证失败',
],
];
+1
View File
@@ -0,0 +1 @@
1
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env php
<?php
chdir(__DIR__);
require_once __DIR__ . '/vendor/autoload.php';
support\App::run();
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace support;
use Webman\App;
use Workerman\Worker;
class Bootstrap
{
public static function start(?Worker $worker): void
{
// 应用启动时的初始化
error_reporting(E_ALL);
ini_set('display_errors', 'on');
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
use Dotenv\Dotenv;
use support\Log;
use Webman\Bootstrap;
use Webman\Config;
use Webman\Middleware;
use Webman\Route;
use Webman\Util;
use Workerman\Events\Select;
use Workerman\Worker;
$worker = $worker ?? null;
if (empty(Worker::$eventLoopClass)) {
Worker::$eventLoopClass = Select::class;
}
set_error_handler(function ($level, $message, $file = '', $line = 0) {
if (error_reporting() & $level) {
throw new ErrorException($message, 0, $level, $file, $line);
}
});
if ($worker) {
register_shutdown_function(function ($startTime) {
if (time() - $startTime <= 0.1) {
sleep(1);
}
}, time());
}
if (class_exists('Dotenv\Dotenv') && file_exists(base_path(false) . '/.env')) {
if (method_exists('Dotenv\Dotenv', 'createUnsafeMutable')) {
Dotenv::createUnsafeMutable(base_path(false))->load();
} else {
Dotenv::createMutable(base_path(false))->load();
}
}
Config::clear();
support\App::loadAllConfig(['route']);
if ($timezone = config('app.default_timezone')) {
date_default_timezone_set($timezone);
}
foreach (config('autoload.files', []) as $file) {
include_once $file;
}
foreach (config('plugin', []) as $firm => $projects) {
foreach ($projects as $name => $project) {
if (!is_array($project)) {
continue;
}
foreach ($project['autoload']['files'] ?? [] as $file) {
include_once $file;
}
}
foreach ($projects['autoload']['files'] ?? [] as $file) {
include_once $file;
}
}
Middleware::load(config('middleware', []));
foreach (config('plugin', []) as $firm => $projects) {
foreach ($projects as $name => $project) {
if (!is_array($project) || $name === 'static') {
continue;
}
Middleware::load($project['middleware'] ?? []);
}
Middleware::load($projects['middleware'] ?? [], $firm);
if ($staticMiddlewares = config("plugin.$firm.static.middleware")) {
Middleware::load(['__static__' => $staticMiddlewares], $firm);
}
}
Middleware::load(['__static__' => config('static.middleware', [])]);
foreach (config('bootstrap', []) as $className) {
if (!class_exists($className)) {
$log = "Warning: Class $className setting in config/bootstrap.php not found\r\n";
echo $log;
Log::error($log);
continue;
}
/** @var Bootstrap $className */
$className::start($worker);
}
foreach (config('plugin', []) as $firm => $projects) {
foreach ($projects as $name => $project) {
if (!is_array($project)) {
continue;
}
foreach ($project['bootstrap'] ?? [] as $className) {
if (!class_exists($className)) {
$log = "Warning: Class $className setting in config/plugin/$firm/$name/bootstrap.php not found\r\n";
echo $log;
Log::error($log);
continue;
}
/** @var Bootstrap $className */
$className::start($worker);
}
}
foreach ($projects['bootstrap'] ?? [] as $className) {
/** @var string $className */
if (!class_exists($className)) {
$log = "Warning: Class $className setting in plugin/$firm/config/bootstrap.php not found\r\n";
echo $log;
Log::error($log);
continue;
}
/** @var Bootstrap $className */
$className::start($worker);
}
}
$directory = base_path() . '/plugin';
$paths = [config_path()];
foreach (Util::scanDir($directory) as $path) {
if (is_dir($path = "$path/config")) {
$paths[] = $path;
}
}
Route::load($paths);
Executable
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env php
<?php
use Webman\Config;
use Webman\Console\Command;
use Webman\Console\Util;
use support\Container;
use Dotenv\Dotenv;
if (!Phar::running()) {
chdir(__DIR__);
}
require_once __DIR__ . '/vendor/autoload.php';
if (!$appConfigFile = config_path('app.php')) {
throw new RuntimeException('Config file not found: app.php');
}
if (class_exists(Dotenv::class) && file_exists(run_path('.env'))) {
if (method_exists(Dotenv::class, 'createUnsafeImmutable')) {
Dotenv::createUnsafeImmutable(run_path())->load();
} else {
Dotenv::createMutable(run_path())->load();
}
}
$appConfig = require $appConfigFile;
if ($timezone = $appConfig['default_timezone'] ?? '') {
date_default_timezone_set($timezone);
}
if ($errorReporting = $appConfig['error_reporting'] ?? '') {
error_reporting($errorReporting);
}
if (!in_array($argv[1] ?? '', ['start', 'restart', 'stop', 'status', 'reload', 'connections'])) {
require_once __DIR__ . '/support/bootstrap.php';
} else {
if (class_exists('Support\App')) {
Support\App::loadAllConfig(['route']);
} else {
Config::reload(config_path(), ['route', 'container']);
}
}
$cli = new Command();
$cli->setName('webman cli');
$cli->installInternalCommands();
if (is_dir($command_path = Util::guessPath(app_path(), '/command', true))) {
$cli->installCommands($command_path);
}
foreach (config('plugin', []) as $firm => $projects) {
if (isset($projects['app'])) {
foreach (['', '/app'] as $app) {
if ($command_str = Util::guessPath(base_path() . "/plugin/$firm{$app}", 'command')) {
$command_path = base_path() . "/plugin/$firm{$app}/$command_str";
$cli->installCommands($command_path, "plugin\\$firm" . str_replace('/', '\\', $app) . "\\$command_str");
}
}
}
foreach ($projects as $name => $project) {
if (!is_array($project)) {
continue;
}
$project['command'] ??= [];
array_walk($project['command'], [$cli, 'createCommandInstance']);
}
}
$cli->run();
+136
View File
@@ -0,0 +1,136 @@
<?php
/**
* Start file for windows
*/
chdir(__DIR__);
require_once __DIR__ . '/vendor/autoload.php';
use Dotenv\Dotenv;
use support\App;
use Workerman\Worker;
ini_set('display_errors', 'on');
error_reporting(E_ALL);
if (class_exists('Dotenv\Dotenv') && file_exists(base_path() . '/.env')) {
if (method_exists('Dotenv\Dotenv', 'createUnsafeImmutable')) {
Dotenv::createUnsafeImmutable(base_path())->load();
} else {
Dotenv::createMutable(base_path())->load();
}
}
App::loadAllConfig(['route']);
$errorReporting = config('app.error_reporting');
if (isset($errorReporting)) {
error_reporting($errorReporting);
}
$runtimeProcessPath = runtime_path() . DIRECTORY_SEPARATOR . '/windows';
$paths = [
$runtimeProcessPath,
runtime_path('logs'),
runtime_path('views')
];
foreach ($paths as $path) {
if (!is_dir($path)) {
mkdir($path, 0777, true);
}
}
$processFiles = [];
if (config('server.listen')) {
$processFiles[] = __DIR__ . DIRECTORY_SEPARATOR . 'start.php';
}
foreach (config('process', []) as $processName => $config) {
$processFiles[] = write_process_file($runtimeProcessPath, $processName, '');
}
foreach (config('plugin', []) as $firm => $projects) {
foreach ($projects as $name => $project) {
if (!is_array($project)) {
continue;
}
foreach ($project['process'] ?? [] as $processName => $config) {
$processFiles[] = write_process_file($runtimeProcessPath, $processName, "$firm.$name");
}
}
foreach ($projects['process'] ?? [] as $processName => $config) {
$processFiles[] = write_process_file($runtimeProcessPath, $processName, $firm);
}
}
function write_process_file($runtimeProcessPath, $processName, $firm): string
{
$processParam = $firm ? "plugin.$firm.$processName" : $processName;
$configParam = $firm ? "config('plugin.$firm.process')['$processName']" : "config('process')['$processName']";
$fileContent = <<<EOF
<?php
require_once __DIR__ . '/../../vendor/autoload.php';
use Workerman\Worker;
use Workerman\Connection\TcpConnection;
use Webman\Config;
use support\App;
ini_set('display_errors', 'on');
error_reporting(E_ALL);
if (is_callable('opcache_reset')) {
opcache_reset();
}
if (!\$appConfigFile = config_path('app.php')) {
throw new RuntimeException('Config file not found: app.php');
}
\$appConfig = require \$appConfigFile;
if (\$timezone = \$appConfig['default_timezone'] ?? '') {
date_default_timezone_set(\$timezone);
}
App::loadAllConfig(['route']);
worker_start('$processParam', $configParam);
if (DIRECTORY_SEPARATOR != "/") {
Worker::\$logFile = config('server')['log_file'] ?? Worker::\$logFile;
TcpConnection::\$defaultMaxPackageSize = config('server')['max_package_size'] ?? 10*1024*1024;
}
Worker::runAll();
EOF;
$processFile = $runtimeProcessPath . DIRECTORY_SEPARATOR . "start_$processParam.php";
file_put_contents($processFile, $fileContent);
return $processFile;
}
if ($monitorConfig = config('process.monitor.constructor')) {
$monitorHandler = config('process.monitor.handler');
$monitor = new $monitorHandler(...array_values($monitorConfig));
}
function popen_processes($processFiles)
{
$cmd = '"' . PHP_BINARY . '" ' . implode(' ', $processFiles);
$descriptorspec = [STDIN, STDOUT, STDOUT];
$resource = proc_open($cmd, $descriptorspec, $pipes, null, null, ['bypass_shell' => true]);
if (!$resource) {
exit("Can not execute $cmd\r\n");
}
return $resource;
}
$resource = popen_processes($processFiles);
echo "\r\n";
while (1) {
sleep(1);
if (!empty($monitor) && $monitor->checkAllFilesChange()) {
$status = proc_get_status($resource);
$pid = $status['pid'];
shell_exec("taskkill /F /T /PID $pid");
proc_close($resource);
$resource = popen_processes($processFiles);
}
}
+26
View File
@@ -0,0 +1,26 @@
services:
# PHP 后端服务
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: x-server-backend
volumes:
- ./backend:/app
ports:
- "8787:8787"
environment:
- DB_HOST=mysql
- DB_PORT=3306
- DB_DATABASE=x_server_mobile
- DB_USERNAME=root
- DB_PASSWORD=123456
- REDIS_HOST=redis
- REDIS_PORT=6379
networks:
- 1panel-network
restart: unless-stopped
networks:
1panel-network:
external: true
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4197
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.62.11",
"antd-mobile": "^5.38.1",
"axios": "^1.7.9",
"dayjs": "^1.11.13",
"i18next": "^25.8.13",
"i18next-browser-languagedetector": "^8.2.1",
"postcss-pxtorem": "^6.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^16.5.4",
"react-router-dom": "^7.1.1",
"vconsole": "^1.0.1",
"zustand": "^5.0.2"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}
+20
View File
@@ -0,0 +1,20 @@
// 动态计算 rem 基准,适应桌面浏览器和真实移动设备
const setRootValue = () => {
// 桌面浏览器模拟移动端时,使用较小的基准
// 真实移动设备上,使用标准基准
return 16
}
export default {
plugins: {
'postcss-pxtorem': {
rootValue: setRootValue(),
propList: ['*'],
selectorBlackList: ['.no-rem'],
replace: true,
mediaQuery: false,
minPixelValue: 1,
exclude: /node_modules/i,
},
},
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+42
View File
@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+2
View File
@@ -0,0 +1,2 @@
// App.tsx 仅作为路由入口,具体内容在 router/index.tsx 中定义
export { default } from './router'
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,33 @@
.container {
background: var(--adm-color-background);
}
.header {
padding: 20px;
font-size: 16px;
font-weight: 600;
text-align: center;
border-bottom: 1px solid var(--adm-color-border);
}
.list {
width: 100%;
padding: 10px 0;
}
.item {
width: 100%;
padding: 12px 20px;
display: flex;
align-items: center;
gap: 12px;
}
.flag {
font-size: 20px;
}
.name {
font-size: 15px;
flex: 1;
}
@@ -0,0 +1,49 @@
import { Popup, Radio, Space } from 'antd-mobile'
import { useTranslation } from 'react-i18next'
import styles from './LanguageSwitcher.module.css'
const languages = [
{ code: 'zh', name: '简体中文', flag: '🇨🇳' },
{ code: 'en', name: 'English', flag: '🇬🇧' },
{ code: 'ru', name: 'Русский', flag: '🇷🇺' },
{ code: 'es', name: 'Español', flag: '🇪🇸' },
{ code: 'fr', name: 'Français', flag: '🇫🇷' },
]
export default function LanguageSwitcher({ onClose }: { onClose?: () => void }) {
const { i18n, t } = useTranslation()
const currentLang = i18n.language
const handleLanguageChange = (langCode: string) => {
i18n.changeLanguage(langCode)
onClose?.()
}
return (
<div className={styles.container}>
<div className={styles.header}>{t('settings.language')}</div>
<Radio.Group value={currentLang} onChange={(val) => handleLanguageChange(val)}>
<Space direction="vertical" className={styles.list}>
{languages.map((lang) => (
<Radio
key={lang.code}
value={lang.code}
className={styles.item}
checked={currentLang === lang.code}
>
<span className={styles.flag}>{lang.flag}</span>
<span className={styles.name}>{lang.name}</span>
</Radio>
))}
</Space>
</Radio.Group>
</div>
)
}
export function showLanguageSwitcher() {
Popup.show({
content: <LanguageSwitcher onClose={() => Popup.close()} />,
position: 'bottom',
})
}
@@ -0,0 +1,36 @@
.header {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
height: 44px;
padding: 0 12px;
background: linear-gradient(180deg, #1677ff 0%, #0958d9 100%);
color: #fff;
}
.backBtn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
background: transparent;
color: #fff;
cursor: pointer;
font-size: 20px;
}
.title {
flex: 1;
font-size: 17px;
font-weight: 500;
text-align: center;
}
.extra {
width: 32px;
}
+26
View File
@@ -0,0 +1,26 @@
import type { ReactNode } from 'react'
import { LeftOutline } from 'antd-mobile-icons'
import { useTranslation } from 'react-i18next'
import styles from './Header.module.css'
interface HeaderProps {
title?: string
onBack?: () => void
extra?: ReactNode
}
export default function Header({ title, onBack, extra }: HeaderProps) {
const { t } = useTranslation()
return (
<header className={styles.header}>
{onBack && (
<button className={styles.backBtn} onClick={onBack}>
<LeftOutline />
</button>
)}
<h1 className={styles.title}>{title || t('app.name')}</h1>
{extra && <div className={styles.extra}>{extra}</div>}
</header>
)
}
@@ -0,0 +1,24 @@
.layout {
display: flex;
flex-direction: column;
min-height: 100vh;
height: 100vh;
background-color: #f5f5f5;
overflow: hidden;
}
.main {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
/* TabBar 高度 50px + 安全区域 */
padding-bottom: calc(50px + env(safe-area-inset-bottom));
/* 确保滚动位置在顶部 */
scroll-behavior: smooth;
}
/* 重置滚动位置 */
.main::-webkit-scrollbar {
width: 0;
display: none;
}
@@ -0,0 +1,31 @@
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
import { useEffect, useRef } from 'react'
import TabBar from './TabBar'
import Header from './Header'
import styles from './MobileLayout.module.css'
export default function MobileLayout() {
const navigate = useNavigate()
const location = useLocation()
const mainRef = useRef<HTMLDivElement>(null)
const showHeader = location.pathname !== '/login'
const showTabBar = !location.pathname.includes('/business-trip/')
// 路由变化时滚动到顶部
useEffect(() => {
if (mainRef.current) {
mainRef.current.scrollTop = 0
}
}, [location.pathname])
return (
<div className={styles.layout}>
{showHeader && <Header onBack={() => navigate(-1)} />}
<main className={styles.main} ref={mainRef}>
<Outlet />
</main>
{showTabBar && <TabBar />}
</div>
)
}
@@ -0,0 +1,36 @@
.tabBar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
display: flex;
height: 50px;
padding-bottom: env(safe-area-inset-bottom);
background: #fff;
border-top: 1px solid #eee;
}
.tab {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
color: #999;
cursor: pointer;
transition: color 0.2s;
}
.tab.active {
color: #1677ff;
}
.icon {
font-size: 22px;
}
.label {
font-size: 11px;
}
+47
View File
@@ -0,0 +1,47 @@
import { useNavigate, useLocation } from 'react-router-dom'
import { AppOutline, AppstoreOutline, UserOutline } from 'antd-mobile-icons'
import { useTranslation } from 'react-i18next'
import styles from './TabBar.module.css'
interface TabItem {
key: string
path: string
icon: React.ReactNode
labelKey: string
}
const tabs: TabItem[] = [
{ key: 'home', path: '/dashboard', icon: <AppOutline />, labelKey: 'tab.home' },
{ key: 'apps', path: '/apps', icon: <AppstoreOutline />, labelKey: 'tab.apps' },
{ key: 'profile', path: '/profile', icon: <UserOutline />, labelKey: 'tab.profile' },
]
export default function TabBar() {
const navigate = useNavigate()
const location = useLocation()
const { t } = useTranslation()
const activeTab = tabs.find((tab) => location.pathname.startsWith(tab.path))?.key || 'home'
const handleTabChange = (key: string) => {
const tab = tabs.find((t) => t.key === key)
if (tab) {
navigate(tab.path)
}
}
return (
<div className={styles.tabBar}>
{tabs.map((tab) => (
<div
key={tab.key}
className={`${styles.tab} ${activeTab === tab.key ? styles.active : ''}`}
onClick={() => handleTabChange(tab.key)}
>
<span className={styles.icon}>{tab.icon}</span>
<span className={styles.label}>{t(tab.labelKey)}</span>
</div>
))}
</div>
)
}
+17
View File
@@ -0,0 +1,17 @@
// API 配置
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1'
export const API_TIMEOUT = 15000
// 应用配置
export const APP_NAME = '企业工具台'
export const APP_VERSION = '1.0.0'
// 环境配置
export const isDev = import.meta.env.DEV
export const isProd = import.meta.env.PROD
// 飞书配置
export const FEISHU_APP_ID = import.meta.env.VITE_FEISHU_APP_ID || ''
// rem 配置(移动端适配)
export const REM_BASE = 37.5 // 750px 设计稿基准
+39
View File
@@ -0,0 +1,39 @@
/**
* 国际化配置
* 支持:中文(zh)、英文(en)、俄语(ru)、西语(es)、法语(fr)
*/
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import zh from './locales/zh.json'
import en from './locales/en.json'
import ru from './locales/ru.json'
import es from './locales/es.json'
import fr from './locales/fr.json'
const resources = {
zh: { translation: zh },
en: { translation: en },
ru: { translation: ru },
es: { translation: es },
fr: { translation: fr },
}
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'zh',
lng: 'zh',
debug: import.meta.env.DEV,
interpolation: {
escapeValue: false,
},
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
},
})
export default i18n
+150
View File
@@ -0,0 +1,150 @@
{
"app": {
"name": "Enterprise Tool System",
"title": "Enterprise Internal Tool Platform"
},
"tab": {
"home": "Home",
"apps": "Apps",
"profile": "Profile"
},
"common": {
"loading": "Loading...",
"confirm": "Confirm",
"cancel": "Cancel",
"submit": "Submit",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"search": "Search",
"refresh": "Refresh",
"back": "Back",
"close": "Close",
"select": "Select",
"all": "All",
"more": "More",
"total": "{{count}} items in total",
"noData": "No data",
"operation": "Operation",
"status": "Status",
"remark": "Remark",
"createTime": "Create Time",
"updateTime": "Update Time"
},
"dashboard": {
"welcome": "Welcome Back",
"greeting": "Hello, {{name}}",
"quickActions": "Quick Actions",
"myTodos": "My Todos",
"myApprovals": "My Approvals",
"notifications": "Notifications",
"recentlyUsed": "Recently Used"
},
"apps": {
"title": "App Center",
"businessTrip": "Business Trip",
"reimbursement": "Reimbursement",
"leave": "Leave Request",
"overtime": "Overtime",
"purchase": "Purchase Request",
"approval": "Approval",
"report": "Reports",
"settings": "Settings",
"addressBook": "Address Book",
"notice": "Notices",
"calendar": "Calendar",
"file": "Files"
},
"businessTrip": {
"title": "Business Trip",
"apply": "Apply",
"myTrips": "My Trips",
"pendingApproval": "Pending",
"approved": "Approved",
"rejected": "Rejected",
"cancelled": "Cancelled",
"tripTitle": "Trip Title",
"destination": "Destination",
"startDate": "Start Date",
"endDate": "End Date",
"duration": "Duration",
"reason": "Reason",
"budget": "Budget",
"submitter": "Applicant",
"submitTime": "Submit Time",
"approver": "Approver",
"approvalStatus": "Status",
"approvalComment": "Comment",
"confirmSubmit": "Confirm to submit?",
"confirmCancel": "Confirm to cancel?",
"submitSuccess": "Submitted successfully",
"cancelSuccess": "Cancelled successfully"
},
"approval": {
"title": "Approval",
"myApproval": "My Approval",
"pending": "Pending",
"approved": "Approved",
"rejected": "Rejected",
"detail": "Detail",
"approve": "Approve",
"reject": "Reject",
"comment": "Comment",
"submitComment": "Submit",
"confirmApprove": "Confirm to approve?",
"confirmReject": "Confirm to reject?",
"approveSuccess": "Approved",
"rejectSuccess": "Rejected",
"noPending": "No pending approvals",
"status": {
"pending": "Pending",
"approved": "Approved",
"rejected": "Rejected",
"cancelled": "Cancelled"
}
},
"profile": {
"title": "Profile",
"myInfo": "My Info",
"myMessages": "Messages",
"settings": "Settings",
"about": "About",
"logout": "Logout",
"language": "Language",
"switchCompany": "Switch Company",
"notifications": "Notifications",
"security": "Security"
},
"login": {
"title": "Login",
"username": "Username",
"password": "Password",
"loginBtn": "Login",
"feishuLogin": "Feishu Login",
"loginSuccess": "Login successful",
"loginFailed": "Login failed"
},
"settings": {
"language": "Language",
"theme": "Theme",
"notifications": "Notifications"
},
"error": {
"networkError": "Network error, please try again later",
"serverError": "Server error",
"unauthorized": "Unauthorized, please login first",
"forbidden": "Forbidden",
"notFound": "Page not found",
"unknownError": "Unknown error"
},
"time": {
"today": "Today",
"yesterday": "Yesterday",
"thisWeek": "This Week",
"lastWeek": "Last Week",
"thisMonth": "This Month",
"lastMonth": "Last Month",
"thisYear": "This Year",
"lastYear": "Last Year"
}
}
+150
View File
@@ -0,0 +1,150 @@
{
"app": {
"name": "Sistema Empresarial",
"title": "Plataforma de Herramientas Empresariales"
},
"tab": {
"home": "Inicio",
"apps": "Aplicaciones",
"profile": "Perfil"
},
"common": {
"loading": "Cargando...",
"confirm": "Confirmar",
"cancel": "Cancelar",
"submit": "Enviar",
"save": "Guardar",
"delete": "Eliminar",
"edit": "Editar",
"search": "Buscar",
"refresh": "Actualizar",
"back": "Atrás",
"close": "Cerrar",
"select": "Seleccionar",
"all": "Todos",
"more": "Más",
"total": "{{count}} en total",
"noData": "Sin datos",
"operation": "Operación",
"status": "Estado",
"remark": "Observación",
"createTime": "Fecha de creación",
"updateTime": "Fecha de actualización"
},
"dashboard": {
"welcome": "Bienvenido de nuevo",
"greeting": "Hola, {{name}}",
"quickActions": "Acciones rápidas",
"myTodos": "Mis pendientes",
"myApprovals": "Mis aprobaciones",
"notifications": "Notificaciones",
"recentlyUsed": "Usados recientemente"
},
"apps": {
"title": "Centro de Aplicaciones",
"businessTrip": "Viaje de Negocios",
"reimbursement": "Reembolso",
"leave": "Solicitud de Permiso",
"overtime": "Horas Extra",
"purchase": "Solicitud de Compra",
"approval": "Aprobaciones",
"report": "Reportes",
"settings": "Configuración",
"addressBook": "Directorio",
"notice": "Avisos",
"calendar": "Calendario",
"file": "Archivos"
},
"businessTrip": {
"title": "Viaje de Negocios",
"apply": "Solicitar",
"myTrips": "Mis Viajes",
"pendingApproval": "Pendiente",
"approved": "Aprobado",
"rejected": "Rechazado",
"cancelled": "Cancelado",
"tripTitle": "Título del Viaje",
"destination": "Destino",
"startDate": "Fecha de Inicio",
"endDate": "Fecha de Fin",
"duration": "Duración",
"reason": "Motivo",
"budget": "Presupuesto",
"submitter": "Solicitante",
"submitTime": "Fecha de Solicitud",
"approver": "Aprobador",
"approvalStatus": "Estado de Aprobación",
"approvalComment": "Comentario",
"confirmSubmit": "¿Confirmar envío?",
"confirmCancel": "¿Confirmar cancelación?",
"submitSuccess": "Enviado exitosamente",
"cancelSuccess": "Cancelado exitosamente"
},
"approval": {
"title": "Aprobaciones",
"myApproval": "Mis Aprobaciones",
"pending": "Pendiente",
"approved": "Aprobado",
"rejected": "Rechazado",
"detail": "Detalle",
"approve": "Aprobar",
"reject": "Rechazar",
"comment": "Comentario",
"submitComment": "Enviar",
"confirmApprove": "¿Confirmar aprobación?",
"confirmReject": "¿Confirmar rechazo?",
"approveSuccess": "Aprobado",
"rejectSuccess": "Rechazado",
"noPending": "Sin aprobaciones pendientes",
"status": {
"pending": "Pendiente",
"approved": "Aprobado",
"rejected": "Rechazado",
"cancelled": "Cancelado"
}
},
"profile": {
"title": "Mi Perfil",
"myInfo": "Mi Información",
"myMessages": "Mis Mensajes",
"settings": "Configuración",
"about": "Acerca de",
"logout": "Cerrar Sesión",
"language": "Idioma",
"switchCompany": "Cambiar Empresa",
"notifications": "Notificaciones",
"security": "Seguridad"
},
"login": {
"title": "Iniciar Sesión",
"username": "Usuario",
"password": "Contraseña",
"loginBtn": "Entrar",
"feishuLogin": "Entrar con Feishu",
"loginSuccess": "Inicio de sesión exitoso",
"loginFailed": "Error de inicio de sesión"
},
"settings": {
"language": "Idioma",
"theme": "Tema",
"notifications": "Notificaciones"
},
"error": {
"networkError": "Error de red, inténtelo más tarde",
"serverError": "Error del servidor",
"unauthorized": "No autorizado, inicie sesión primero",
"forbidden": "Acceso prohibido",
"notFound": "Página no encontrada",
"unknownError": "Error desconocido"
},
"time": {
"today": "Hoy",
"yesterday": "Ayer",
"thisWeek": "Esta Semana",
"lastWeek": "Semana Pasada",
"thisMonth": "Este Mes",
"lastMonth": "Mes Pasado",
"thisYear": "Este Año",
"lastYear": "Año Pasado"
}
}
+150
View File
@@ -0,0 +1,150 @@
{
"app": {
"name": "Système d'Entreprise",
"title": "Plateforme d'Outils Entreprise"
},
"tab": {
"home": "Accueil",
"apps": "Applications",
"profile": "Profil"
},
"common": {
"loading": "Chargement...",
"confirm": "Confirmer",
"cancel": "Annuler",
"submit": "Soumettre",
"save": "Enregistrer",
"delete": "Supprimer",
"edit": "Modifier",
"search": "Rechercher",
"refresh": "Actualiser",
"back": "Retour",
"close": "Fermer",
"select": "Sélectionner",
"all": "Tous",
"more": "Plus",
"total": "{{count}} au total",
"noData": "Aucune donnée",
"operation": "Opération",
"status": "Statut",
"remark": "Remarque",
"createTime": "Date de création",
"updateTime": "Date de mise à jour"
},
"dashboard": {
"welcome": "Bienvenue",
"greeting": "Bonjour, {{name}}",
"quickActions": "Actions Rapides",
"myTodos": "Mes Tâches",
"myApprovals": "Mes Approbations",
"notifications": "Notifications",
"recentlyUsed": "Récemment Utilisés"
},
"apps": {
"title": "Centre d'Applications",
"businessTrip": "Déplacement",
"reimbursement": "Remboursement",
"leave": "Demande de Congé",
"overtime": "Heures Supplémentaires",
"purchase": "Demande d'Achat",
"approval": "Approbations",
"report": "Rapports",
"settings": "Paramètres",
"addressBook": "Contacts",
"notice": "Annonces",
"calendar": "Calendrier",
"file": "Fichiers"
},
"businessTrip": {
"title": "Déplacement Professionnel",
"apply": "Demander",
"myTrips": "Mes Déplacements",
"pendingApproval": "En Attente",
"approved": "Approuvé",
"rejected": "Rejeté",
"cancelled": "Annulé",
"tripTitle": "Titre du Déplacement",
"destination": "Destination",
"startDate": "Date de Début",
"endDate": "Date de Fin",
"duration": "Durée",
"reason": "Motif",
"budget": "Budget",
"submitter": "Demandeur",
"submitTime": "Date de Soumission",
"approver": "Approbateur",
"approvalStatus": "Statut d'Approbation",
"approvalComment": "Commentaire",
"confirmSubmit": "Confirmer la soumission?",
"confirmCancel": "Confirmer l'annulation?",
"submitSuccess": "Soumis avec succès",
"cancelSuccess": "Annulé avec succès"
},
"approval": {
"title": "Approbations",
"myApproval": "Mes Approbations",
"pending": "En Attente",
"approved": "Approuvé",
"rejected": "Rejeté",
"detail": "Détail",
"approve": "Approuver",
"reject": "Rejeter",
"comment": "Commentaire",
"submitComment": "Soumettre",
"confirmApprove": "Confirmer l'approbation?",
"confirmReject": "Confirmer le rejet?",
"approveSuccess": "Approuvé",
"rejectSuccess": "Rejeté",
"noPending": "Aucune approbation en attente",
"status": {
"pending": "En Attente",
"approved": "Approuvé",
"rejected": "Rejeté",
"cancelled": "Annulé"
}
},
"profile": {
"title": "Mon Profil",
"myInfo": "Mes Informations",
"myMessages": "Mes Messages",
"settings": "Paramètres",
"about": "À Propos",
"logout": "Déconnexion",
"language": "Langue",
"switchCompany": "Changer d'Entreprise",
"notifications": "Notifications",
"security": "Sécurité"
},
"login": {
"title": "Connexion",
"username": "Nom d'utilisateur",
"password": "Mot de passe",
"loginBtn": "Se Connecter",
"feishuLogin": "Connexion Feishu",
"loginSuccess": "Connexion réussie",
"loginFailed": "Échec de la connexion"
},
"settings": {
"language": "Langue",
"theme": "Thème",
"notifications": "Notifications"
},
"error": {
"networkError": "Erreur réseau, veuillez réessayer plus tard",
"serverError": "Erreur du serveur",
"unauthorized": "Non autorisé, veuillez vous connecter d'abord",
"forbidden": "Accès interdit",
"notFound": "Page non trouvée",
"unknownError": "Erreur inconnue"
},
"time": {
"today": "Aujourd'hui",
"yesterday": "Hier",
"thisWeek": "Cette Semaine",
"lastWeek": "Semaine Dernière",
"thisMonth": "Ce Mois",
"lastMonth": "Mois Dernier",
"thisYear": "Cette Année",
"lastYear": "Année Dernière"
}
}
+150
View File
@@ -0,0 +1,150 @@
{
"app": {
"name": "Корпоративная система",
"title": "Корпоративная платформа инструментов"
},
"tab": {
"home": "Главная",
"apps": "Приложения",
"profile": "Профиль"
},
"common": {
"loading": "Загрузка...",
"confirm": "Подтвердить",
"cancel": "Отмена",
"submit": "Отправить",
"save": "Сохранить",
"delete": "Удалить",
"edit": "Редактировать",
"search": "Поиск",
"refresh": "Обновить",
"back": "Назад",
"close": "Закрыть",
"select": "Выбрать",
"all": "Все",
"more": "Больше",
"total": "Всего {{count}}",
"noData": "Нет данных",
"operation": "Операция",
"status": "Статус",
"remark": "Примечание",
"createTime": "Время создания",
"updateTime": "Время обновления"
},
"dashboard": {
"welcome": "Добро пожаловать",
"greeting": "Здравствуйте, {{name}}",
"quickActions": "Быстрые действия",
"myTodos": "Мои задачи",
"myApprovals": "Мои согласования",
"notifications": "Уведомления",
"recentlyUsed": "Недавно использованные"
},
"apps": {
"title": "Центр приложений",
"businessTrip": "Командировка",
"reimbursement": "Возмещение расходов",
"leave": "Отпуск",
"overtime": "Сверхурочные",
"purchase": "Заявка на покупку",
"approval": "Согласование",
"report": "Отчеты",
"settings": "Настройки",
"addressBook": "Адресная книга",
"notice": "Объявления",
"calendar": "Календарь",
"file": "Файлы"
},
"businessTrip": {
"title": "Командировка",
"apply": "Подать заявку",
"myTrips": "Мои командировки",
"pendingApproval": "Ожидает согласования",
"approved": "Согласовано",
"rejected": "Отклонено",
"cancelled": "Отменено",
"tripTitle": "Название командировки",
"destination": "Место назначения",
"startDate": "Дата начала",
"endDate": "Дата окончания",
"duration": "Продолжительность",
"reason": "Причина",
"budget": "Бюджет",
"submitter": "Заявитель",
"submitTime": "Время подачи",
"approver": "Согласующий",
"approvalStatus": "Статус согласования",
"approvalComment": "Комментарий",
"confirmSubmit": "Подтвердить отправку?",
"confirmCancel": "Подтвердить отмену?",
"submitSuccess": "Успешно отправлено",
"cancelSuccess": "Успешно отменено"
},
"approval": {
"title": "Согласование",
"myApproval": "Мои согласования",
"pending": "Ожидает",
"approved": "Согласовано",
"rejected": "Отклонено",
"detail": "Детали",
"approve": "Согласовать",
"reject": "Отклонить",
"comment": "Комментарий",
"submitComment": "Отправить",
"confirmApprove": "Подтвердить согласование?",
"confirmReject": "Подтвердить отклонение?",
"approveSuccess": "Согласовано",
"rejectSuccess": "Отклонено",
"noPending": "Нет ожидающих согласований",
"status": {
"pending": "Ожидает",
"approved": "Согласовано",
"rejected": "Отклонено",
"cancelled": "Отменено"
}
},
"profile": {
"title": "Профиль",
"myInfo": "Моя информация",
"myMessages": "Мои сообщения",
"settings": "Настройки",
"about": "О программе",
"logout": "Выйти",
"language": "Язык",
"switchCompany": "Сменить компанию",
"notifications": "Уведомления",
"security": "Безопасность"
},
"login": {
"title": "Вход",
"username": "Имя пользователя",
"password": "Пароль",
"loginBtn": "Войти",
"feishuLogin": "Войти через Feishu",
"loginSuccess": "Успешный вход",
"loginFailed": "Ошибка входа"
},
"settings": {
"language": "Язык",
"theme": "Тема",
"notifications": "Уведомления"
},
"error": {
"networkError": "Ошибка сети, попробуйте позже",
"serverError": "Ошибка сервера",
"unauthorized": "Не авторизован, войдите сначала",
"forbidden": "Доступ запрещен",
"notFound": "Страница не найдена",
"unknownError": "Неизвестная ошибка"
},
"time": {
"today": "Сегодня",
"yesterday": "Вчера",
"thisWeek": "На этой неделе",
"lastWeek": "На прошлой неделе",
"thisMonth": "В этом месяце",
"lastMonth": "В прошлом месяце",
"thisYear": "В этом году",
"lastYear": "В прошлом году"
}
}
+150
View File
@@ -0,0 +1,150 @@
{
"app": {
"name": "企业工具系统",
"title": "企业内部工具平台"
},
"tab": {
"home": "首页",
"apps": "应用",
"profile": "我的"
},
"common": {
"loading": "加载中...",
"confirm": "确认",
"cancel": "取消",
"submit": "提交",
"save": "保存",
"delete": "删除",
"edit": "编辑",
"search": "搜索",
"refresh": "刷新",
"back": "返回",
"close": "关闭",
"select": "请选择",
"all": "全部",
"more": "更多",
"total": "共 {{count}} 条",
"noData": "暂无数据",
"operation": "操作",
"status": "状态",
"remark": "备注",
"createTime": "创建时间",
"updateTime": "更新时间"
},
"dashboard": {
"welcome": "欢迎回来",
"greeting": "你好,{{name}}",
"quickActions": "快捷操作",
"myTodos": "我的待办",
"myApprovals": "我的审批",
"notifications": "通知消息",
"recentlyUsed": "最近使用"
},
"apps": {
"title": "应用中心",
"businessTrip": "出差申请",
"reimbursement": "费用报销",
"leave": "请假申请",
"overtime": "加班申请",
"purchase": "采购申请",
"approval": "审批管理",
"report": "数据报表",
"settings": "系统设置",
"addressBook": "通讯录",
"notice": "通知公告",
"calendar": "日程安排",
"file": "文件管理"
},
"businessTrip": {
"title": "出差申请",
"apply": "申请出差",
"myTrips": "我的出差",
"pendingApproval": "待审批",
"approved": "已批准",
"rejected": "已拒绝",
"cancelled": "已撤销",
"tripTitle": "出差标题",
"destination": "目的地",
"startDate": "开始日期",
"endDate": "结束日期",
"duration": "出差天数",
"reason": "出差事由",
"budget": "预算金额",
"submitter": "申请人",
"submitTime": "申请时间",
"approver": "审批人",
"approvalStatus": "审批状态",
"approvalComment": "审批意见",
"confirmSubmit": "确认提交出差申请?",
"confirmCancel": "确认撤销此申请?",
"submitSuccess": "提交成功",
"cancelSuccess": "撤销成功"
},
"approval": {
"title": "审批管理",
"myApproval": "我的审批",
"pending": "待审批",
"approved": "已通过",
"rejected": "已拒绝",
"detail": "审批详情",
"approve": "通过",
"reject": "拒绝",
"comment": "审批意见",
"submitComment": "提交意见",
"confirmApprove": "确认通过此申请?",
"confirmReject": "确认拒绝此申请?",
"approveSuccess": "审批通过",
"rejectSuccess": "已拒绝",
"noPending": "暂无待审批事项",
"status": {
"pending": "待审批",
"approved": "已通过",
"rejected": "已拒绝",
"cancelled": "已撤销"
}
},
"profile": {
"title": "个人中心",
"myInfo": "我的信息",
"myMessages": "我的消息",
"settings": "设置",
"about": "关于",
"logout": "退出登录",
"language": "语言设置",
"switchCompany": "切换公司",
"notifications": "消息通知",
"security": "账号安全"
},
"login": {
"title": "登录",
"username": "用户名",
"password": "密码",
"loginBtn": "登录",
"feishuLogin": "飞书快捷登录",
"loginSuccess": "登录成功",
"loginFailed": "登录失败"
},
"settings": {
"language": "语言",
"theme": "主题",
"notifications": "通知设置"
},
"error": {
"networkError": "网络错误,请稍后重试",
"serverError": "服务器错误",
"unauthorized": "未授权,请先登录",
"forbidden": "无权访问",
"notFound": "页面不存在",
"unknownError": "未知错误"
},
"time": {
"today": "今天",
"yesterday": "昨天",
"thisWeek": "本周",
"lastWeek": "上周",
"thisMonth": "本月",
"lastMonth": "上月",
"thisYear": "今年",
"lastYear": "去年"
}
}
+68
View File
@@ -0,0 +1,68 @@
/* 移动端基础样式重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-size: 16px; /* 基础字体大小 */
}
html,
body {
width: 100%;
height: 100%;
}
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
font-size: 16px; /* 提高基础字体大小 */
line-height: 1.5;
color: #262626;
background-color: #f5f5f5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-size-adjust: 100%; /* 防止 iOS 自动调整字体大小 */
}
#root {
width: 100%;
height: 100%;
}
/* 链接样式 */
a {
color: #1677ff;
text-decoration: none;
}
a:active {
color: #0958d9;
}
/* 按钮重置 */
button {
border: none;
background: none;
padding: 0;
font: inherit;
cursor: pointer;
outline: none;
}
input,
textarea,
select {
font: inherit;
outline: none;
}
/* 安全区域适配 */
@supports (bottom: env(safe-area-inset-bottom)) {
body {
padding-bottom: env(safe-area-inset-bottom);
}
}
+25
View File
@@ -0,0 +1,25 @@
import { StrictMode, useEffect } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import router from './router'
import { initRem } from './utils/rem'
import './i18n'
import './index.css'
// 初始化 rem 适配
initRem()
function App() {
useEffect(() => {
// 确保在 DOM 加载后再次设置
initRem()
}, [])
return <RouterProvider router={router} />
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)
@@ -0,0 +1,59 @@
.actions {
display: flex;
gap: 12px;
padding: 16px;
background: #fff;
border-radius: 12px;
position: sticky;
bottom: 0;
z-index: 10;
}
.btn {
flex: 1;
padding: 12px;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.approveBtn {
background: #52c41a;
color: #fff;
}
.rejectBtn {
background: #ff4d4f;
color: #fff;
}
.cancelBtn {
background: #8c8c8c;
color: #fff;
}
.modalContent {
padding: 16px 0;
}
.textarea {
width: 100%;
padding: 10px;
border: 1px solid #e5e5e5;
border-radius: 8px;
font-size: 14px;
resize: none;
}
.textarea:focus {
outline: none;
border-color: #1677ff;
}
@@ -0,0 +1,111 @@
import { useApprovalAction } from '../hooks/useApprovalAction'
import type { ApprovalStatus } from '../types'
import { Modal } from 'antd-mobile'
import { useState } from 'react'
import styles from './ApprovalAction.module.css'
interface ApprovalActionProps {
orderId: string
status: ApprovalStatus
}
export default function ApprovalAction({ orderId, status }: ApprovalActionProps) {
const { approve, reject, cancel, isApproving, isRejecting, isCancelling } = useApprovalAction()
const [comment, setComment] = useState('')
const [showModal, setShowModal] = useState(false)
const [actionType, setActionType] = useState<'approve' | 'reject'>('approve')
const canAction = status === 'pending'
const canCancel = status === 'pending' || status === 'draft'
const handleApprove = () => {
approve({ orderId, comment: comment || undefined })
setShowModal(false)
setComment('')
}
const handleReject = () => {
reject({ orderId, comment: comment || '已拒绝' })
setShowModal(false)
setComment('')
}
const handleCancel = () => {
if (confirm('确定要撤销此申请吗?')) {
cancel(orderId)
}
}
const openModal = (action: 'approve' | 'reject') => {
setActionType(action)
setShowModal(true)
}
if (!canAction && !canCancel) {
return null
}
return (
<>
<div className={styles.actions}>
{canAction && (
<>
<button
className={`${styles.btn} ${styles.approveBtn}`}
onClick={() => openModal('approve')}
disabled={isApproving}
>
{isApproving ? '处理中...' : '通过'}
</button>
<button
className={`${styles.btn} ${styles.rejectBtn}`}
onClick={() => openModal('reject')}
disabled={isRejecting}
>
{isRejecting ? '处理中...' : '拒绝'}
</button>
</>
)}
{canCancel && !canAction && (
<button
className={`${styles.btn} ${styles.cancelBtn}`}
onClick={handleCancel}
disabled={isCancelling}
>
{isCancelling ? '处理中...' : '撤销申请'}
</button>
)}
</div>
<Modal
visible={showModal}
title={actionType === 'approve' ? '审批通过' : '审批拒绝'}
onClose={() => setShowModal(false)}
content={
<div className={styles.modalContent}>
<textarea
className={styles.textarea}
placeholder={actionType === 'approve' ? '请输入审批意见(可选)' : '请输入拒绝原因'}
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={3}
/>
</div>
}
actions={[
{
key: 'cancel',
text: '取消',
onClick: () => setShowModal(false),
},
{
key: 'confirm',
text: actionType === 'approve' ? '确认通过' : '确认拒绝',
primary: true,
onClick: actionType === 'approve' ? handleApprove : handleReject,
},
]}
/>
</>
)
}
@@ -0,0 +1,149 @@
.loading,
.error,
.empty {
padding: 40px 20px;
text-align: center;
color: #8c8c8c;
font-size: 14px;
}
.error {
color: #ff4d4f;
}
.detail {
padding: 16px;
padding-bottom: calc(16px + env(safe-area-inset-bottom));
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid #f5f5f5;
}
.title {
flex: 1;
font-size: 18px;
font-weight: 600;
color: #262626;
margin: 0;
}
.status {
padding: 4px 12px;
background: #f5f5f5;
border-radius: 4px;
font-size: 13px;
color: #1677ff;
}
.section {
margin-bottom: 20px;
}
.sectionTitle {
font-size: 16px;
font-weight: 600;
color: #262626;
margin: 0 0 12px 0;
}
.formData {
background: #fff;
border-radius: 8px;
padding: 12px;
}
.formRow {
display: flex;
padding: 8px 0;
border-bottom: 1px solid #f5f5f5;
}
.formRow:last-child {
border-bottom: none;
}
.formLabel {
width: 100px;
color: #8c8c8c;
font-size: 14px;
flex-shrink: 0;
}
.formValue {
flex: 1;
color: #262626;
font-size: 14px;
}
.timeline {
background: #fff;
border-radius: 8px;
padding: 12px;
}
.timelineItem {
display: flex;
gap: 12px;
padding: 12px 0;
position: relative;
}
.timelineItem:not(:last-child)::after {
content: '';
position: absolute;
left: 5px;
top: 28px;
bottom: -12px;
width: 2px;
background: #f0f0f0;
}
.timelineDot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #1677ff;
border: 2px solid #fff;
box-shadow: 0 0 0 2px #e6f4ff;
flex-shrink: 0;
margin-top: 4px;
}
.timelineContent {
flex: 1;
}
.timelineHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.timelineUser {
font-size: 14px;
color: #262626;
font-weight: 500;
}
.timelineAction {
font-size: 13px;
color: #1677ff;
}
.timelineComment {
font-size: 14px;
color: #595959;
margin-bottom: 4px;
}
.timelineTime {
font-size: 12px;
color: #8c8c8c;
}
@@ -0,0 +1,108 @@
import { useQuery } from '@tanstack/react-query'
import { getApprovalDetail } from '../services/approval'
import ApprovalFlow from './ApprovalFlow'
import ApprovalAction from './ApprovalAction'
import styles from './ApprovalDetail.module.css'
interface ApprovalDetailProps {
id: string
}
export default function ApprovalDetail({ id }: ApprovalDetailProps) {
const { data, isLoading, error } = useQuery({
queryKey: ['approval-detail', id],
queryFn: () => getApprovalDetail(id),
})
if (isLoading) {
return <div className={styles.loading}>加载中...</div>
}
if (error) {
return <div className={styles.error}>加载失败: {error.message}</div>
}
const order = data?.data
if (!order) {
return <div className={styles.empty}>未找到审批详情</div>
}
const getStatusText = (status: string) => {
const statusMap: Record<string, string> = {
draft: '草稿',
pending: '审批中',
approved: '已通过',
rejected: '已拒绝',
cancelled: '已撤销',
}
return statusMap[status] || status
}
return (
<div className={styles.detail}>
{/* 标题 */}
<div className={styles.header}>
<h1 className={styles.title}>{order.title}</h1>
<span className={styles.status}>{getStatusText(order.status)}</span>
</div>
{/* 审批流程 */}
{order.flow && (
<div className={styles.section}>
<h2 className={styles.sectionTitle}>审批流程</h2>
<ApprovalFlow nodes={order.flow.nodes || []} />
</div>
)}
{/* 表单数据 */}
{order.formData && Object.keys(order.formData).length > 0 && (
<div className={styles.section}>
<h2 className={styles.sectionTitle}>申请内容</h2>
<div className={styles.formData}>
{Object.entries(order.formData).map(([key, value]) => (
<div key={key} className={styles.formRow}>
<span className={styles.formLabel}>{key}:</span>
<span className={styles.formValue}>{String(value)}</span>
</div>
))}
</div>
</div>
)}
{/* 审批记录 */}
{order.timeline && order.timeline.length > 0 && (
<div className={styles.section}>
<h2 className={styles.sectionTitle}>审批记录</h2>
<div className={styles.timeline}>
{order.timeline.map((record: any) => (
<div key={record.id} className={styles.timelineItem}>
<div className={styles.timelineDot} />
<div className={styles.timelineContent}>
<div className={styles.timelineHeader}>
<span className={styles.timelineUser}>{record.approver?.name}</span>
<span className={styles.timelineAction}>
{record.action === 'approve' && '通过'}
{record.action === 'reject' && '拒绝'}
{record.action === 'submit' && '提交'}
{record.action === 'cancel' && '撤销'}
</span>
</div>
{record.comment && (
<div className={styles.timelineComment}>{record.comment}</div>
)}
<div className={styles.timelineTime}>
{new Date(record.createdAt).toLocaleString('zh-CN')}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* 操作按钮 */}
<ApprovalAction orderId={order.id} status={order.status} />
</div>
)
}
@@ -0,0 +1,62 @@
.flow {
padding: 16px 0;
}
.nodeWrapper {
display: flex;
align-items: flex-start;
}
.node {
display: flex;
align-items: center;
gap: 12px;
}
.nodeIcon {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 14px;
font-weight: 600;
flex-shrink: 0;
}
.nodeContent {
flex: 1;
}
.nodeName {
font-size: 14px;
color: #262626;
margin-bottom: 4px;
}
.nodeApprovers {
font-size: 12px;
color: #8c8c8c;
}
.approver {
display: inline-block;
padding: 2px 6px;
background: #f5f5f5;
border-radius: 4px;
margin-right: 4px;
margin-bottom: 4px;
}
.connector {
width: 28px;
padding-left: 13px;
}
.line {
width: 2px;
height: 20px;
background: #e5e5e5;
}
@@ -0,0 +1,77 @@
import type { ApprovalNode, NodeStatus } from '../types'
import styles from './ApprovalFlow.module.css'
interface ApprovalFlowProps {
nodes: ApprovalNode[]
currentStatus?: NodeStatus
}
export default function ApprovalFlow({ nodes, currentStatus }: ApprovalFlowProps) {
const getNodeStatus = (node: ApprovalNode): NodeStatus => {
if (node.status) return node.status
return 'pending'
}
const getNodeIcon = (status: NodeStatus): string => {
switch (status) {
case 'approved':
return '✓'
case 'rejected':
return '✕'
case 'skipped':
return '○'
default:
return '○'
}
}
const getNodeColor = (status: NodeStatus): string => {
switch (status) {
case 'approved':
return '#52c41a'
case 'rejected':
return '#ff4d4f'
case 'skipped':
return '#d9d9d9'
default:
return '#d9d9d9'
}
}
return (
<div className={styles.flow}>
{nodes.map((node, index) => {
const status = getNodeStatus(node)
const isLast = index === nodes.length - 1
return (
<div key={node.id} className={styles.nodeWrapper}>
<div className={styles.node}>
<div
className={styles.nodeIcon}
style={{ backgroundColor: getNodeColor(status) }}
>
{getNodeIcon(status)}
</div>
<div className={styles.nodeContent}>
<div className={styles.nodeName}>{node.name}</div>
<div className={styles.nodeApprovers}>
{node.approvers.map((approver) => (
<span key={approver.id} className={styles.approver}>
{approver.name}
</span>
))}
</div>
</div>
</div>
{!isLast && (
<div className={styles.connector}>
<div className={styles.line} />
</div>
)}
</div>
)
})}
</div>
)
}
@@ -0,0 +1,70 @@
.loading,
.error,
.empty {
padding: 40px 20px;
text-align: center;
color: #8c8c8c;
font-size: 14px;
}
.error {
color: #ff4d4f;
}
.list {
background: #fff;
border-radius: 12px;
overflow: hidden;
}
.item {
padding: 14px 16px;
border-bottom: 1px solid #f5f5f5;
cursor: pointer;
transition: background 0.2s;
}
.item:last-child {
border-bottom: none;
}
.item:active {
background: #f5f5f5;
}
.itemHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.title {
flex: 1;
font-size: 15px;
color: #262626;
font-weight: 500;
}
.status {
font-size: 13px;
padding: 2px 8px;
background: #f5f5f5;
border-radius: 4px;
}
.itemBody {
display: flex;
justify-content: space-between;
align-items: center;
}
.info {
font-size: 13px;
color: #8c8c8c;
}
.time {
font-size: 12px;
color: #bfbfbf;
}
@@ -0,0 +1,78 @@
import { useApprovalList } from '../hooks/useApprovalList'
import type { ApprovalListParams } from '../types'
import styles from './ApprovalList.module.css'
interface ApprovalListProps {
type?: ApprovalListParams['type']
onItemClick?: (id: string) => void
}
export default function ApprovalList({ type = 'my', onItemClick }: ApprovalListProps) {
const { data, isLoading, error } = useApprovalList({ type })
if (isLoading) {
return <div className={styles.loading}>加载中...</div>
}
if (error) {
return <div className={styles.error}>加载失败: {error.message}</div>
}
const list = data?.data?.list || []
if (list.length === 0) {
return <div className={styles.empty}>暂无数据</div>
}
const getStatusText = (status: string) => {
const statusMap: Record<string, string> = {
draft: '草稿',
pending: '审批中',
approved: '已通过',
rejected: '已拒绝',
cancelled: '已撤销',
}
return statusMap[status] || status
}
const getStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
draft: '#8c8c8c',
pending: '#1677ff',
approved: '#52c41a',
rejected: '#ff4d4f',
cancelled: '#8c8c8c',
}
return colorMap[status] || '#8c8c8c'
}
return (
<div className={styles.list}>
{list.map((item: any) => (
<div
key={item.id}
className={styles.item}
onClick={() => onItemClick?.(item.id)}
>
<div className={styles.itemHeader}>
<span className={styles.title}>{item.title}</span>
<span
className={styles.status}
style={{ color: getStatusColor(item.status) }}
>
{getStatusText(item.status)}
</span>
</div>
<div className={styles.itemBody}>
<div className={styles.info}>
申请人: {item.applicant?.name || '-'}
</div>
<div className={styles.time}>
{new Date(item.createdAt).toLocaleString('zh-CN')}
</div>
</div>
</div>
))}
</div>
)
}
@@ -0,0 +1,59 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { approveOrder, rejectOrder, cancelOrder } from '../services/approval'
import type { ApprovalActionParams } from '../types'
interface UseApprovalActionOptions {
onSuccess?: (data: any) => void
onError?: (error: Error) => void
}
export function useApprovalAction(options?: UseApprovalActionOptions) {
const queryClient = useQueryClient()
const approveMutation = useMutation({
mutationFn: ({ orderId, comment }: { orderId: string; comment?: string }) =>
approveOrder(orderId, comment),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['approval-list'] })
queryClient.invalidateQueries({ queryKey: ['approval-detail'] })
options?.onSuccess?.(data)
},
onError: (error) => {
options?.onError?.(error as Error)
},
})
const rejectMutation = useMutation({
mutationFn: ({ orderId, comment }: { orderId: string; comment?: string }) =>
rejectOrder(orderId, comment),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['approval-list'] })
queryClient.invalidateQueries({ queryKey: ['approval-detail'] })
options?.onSuccess?.(data)
},
onError: (error) => {
options?.onError?.(error as Error)
},
})
const cancelMutation = useMutation({
mutationFn: (orderId: string) => cancelOrder(orderId),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['approval-list'] })
queryClient.invalidateQueries({ queryKey: ['approval-detail'] })
options?.onSuccess?.(data)
},
onError: (error) => {
options?.onError?.(error as Error)
},
})
return {
approve: approveMutation.mutate,
reject: rejectMutation.mutate,
cancel: cancelMutation.mutate,
isApproving: approveMutation.isPending,
isRejecting: rejectMutation.isPending,
isCancelling: cancelMutation.isPending,
}
}
@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query'
import { getApprovalList } from '../services'
import type { ApprovalListParams } from '../types'
export function useApprovalList(params: ApprovalListParams = {}) {
const { type = 'my', page = 1, pageSize = 20 } = params
return useQuery({
queryKey: ['approval-list', type, page, pageSize],
queryFn: () => getApprovalList({ type, page, pageSize }),
})
}
@@ -0,0 +1,30 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { submitApproval } from '../services/approval'
import type { SubmitApprovalParams } from '../types'
interface UseSubmitApprovalOptions {
onSuccess?: (data: any) => void
onError?: (error: Error) => void
}
export function useApprovalSubmit(options?: UseSubmitApprovalOptions) {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (params: SubmitApprovalParams) => submitApproval(params),
onSuccess: (data) => {
// 刷新审批列表
queryClient.invalidateQueries({ queryKey: ['approval-list'] })
options?.onSuccess?.(data)
},
onError: (error) => {
options?.onError?.(error as Error)
},
})
return {
submitApproval: mutation.mutate,
isSubmitting: mutation.isPending,
error: mutation.error,
}
}
+29
View File
@@ -0,0 +1,29 @@
// 审批模块导出
// Types
export type {
ApprovalNode,
Approver,
ApprovalFlow,
ApprovalOrder,
Attachment,
ApprovalTimeline,
SubmitApprovalParams,
ApprovalListParams,
} from './types'
export { ApprovalStatus, ApprovalNodeType, NodeStatus } from './types'
// Components
export { default as ApprovalFlow } from './components/ApprovalFlow'
export { default as ApprovalList } from './components/ApprovalList'
export { default as ApprovalDetail } from './components/ApprovalDetail'
export { default as ApprovalAction } from './components/ApprovalAction'
// Hooks
export { useApprovalSubmit } from './hooks/useApprovalSubmit'
export { useApprovalAction } from './hooks/useApprovalAction'
export { useApprovalList } from './hooks/useApprovalList'
// Services
export * from './services/approval'
@@ -0,0 +1,42 @@
import { api } from '@/services/api'
import type {
ApprovalOrder,
ApprovalFlow,
SubmitApprovalParams,
ApprovalListParams,
} from '../types'
// 获取审批列表
export function getApprovalList(params: ApprovalListParams) {
return api.get<ApprovalOrder[]>('/approval/list', params)
}
// 获取审批详情
export function getApprovalDetail(id: string) {
return api.get<ApprovalOrder>(`/approval/detail/${id}`)
}
// 提交审批
export function submitApproval(data: SubmitApprovalParams) {
return api.post<{ order: ApprovalOrder; token?: string }>('/approval/submit', data)
}
// 审批通过
export function approveOrder(orderId: string, comment?: string) {
return api.post<ApprovalOrder>(`/approval/approve/${orderId}`, { comment })
}
// 审批拒绝
export function rejectOrder(orderId: string, comment?: string) {
return api.post<ApprovalOrder>(`/approval/reject/${orderId}`, { comment })
}
// 撤销申请
export function cancelOrder(orderId: string) {
return api.post<ApprovalOrder>(`/approval/cancel/${orderId}`)
}
// 获取审批流程
export function getApprovalFlow(type: string) {
return api.get<ApprovalFlow>(`/approval/flow/${type}`)
}
+112
View File
@@ -0,0 +1,112 @@
// 审批模块类型定义
export interface ApprovalNode {
id: string
type: ApprovalNodeType
name: string
approvers: Approver[]
order: number
status?: NodeStatus
}
export interface Approver {
id: string
name: string
avatar?: string
}
export interface ApprovalFlow {
id: string
businessType: string
name: string
nodes: ApprovalNode[]
}
export interface ApprovalOrder {
id: string
businessType: string
businessId: string
title: string
applicant: Approver
status: ApprovalStatus
currentNodes: string[]
flowId: string
formData: Record<string, any>
attachments: Attachment[]
timeline: ApprovalTimeline[]
createdAt: string
updatedAt: string
}
export interface Attachment {
id: string
name: string
url: string
size?: number
type?: string
}
export interface ApprovalTimeline {
id: string
nodeId: string
nodeType: string
approver: Approver
action: ApprovalAction
comment?: string
createdAt: string
}
// 类型常量
export const ApprovalStatus = {
DRAFT: 'draft',
PENDING: 'pending',
APPROVED: 'approved',
REJECTED: 'rejected',
CANCELLED: 'cancelled',
} as const
export type ApprovalStatus = (typeof ApprovalStatus)[keyof typeof ApprovalStatus]
export const ApprovalNodeType = {
APPROVER: 'approver',
AND_APPROVE: 'and_approve',
OR_APPROVE: 'or_approve',
NOTIFY: 'notify',
} as const
export type ApprovalNodeType = (typeof ApprovalNodeType)[keyof typeof ApprovalNodeType]
export const NodeStatus = {
PENDING: 'pending',
APPROVED: 'approved',
REJECTED: 'rejected',
SKIPPED: 'skipped',
} as const
export type NodeStatus = (typeof NodeStatus)[keyof typeof NodeStatus]
export type ApprovalAction = 'submit' | 'approve' | 'reject' | 'cancel'
// 提交审批参数
export interface SubmitApprovalParams {
businessType: string
businessId: string
title: string
flowType?: string
formData?: Record<string, any>
attachments?: Attachment[]
}
// 审批操作参数
export interface ApprovalActionParams {
orderId: string
action: 'approve' | 'reject'
comment?: string
}
// 列表查询参数
export interface ApprovalListParams {
type?: 'my' | 'pending' | 'approved'
page?: number
pageSize?: number
}
+80
View File
@@ -0,0 +1,80 @@
.apps {
min-height: 100vh;
padding: 16px;
padding-bottom: calc(16px + env(safe-area-inset-bottom));
background: #f5f5f5;
}
.header {
margin-bottom: 20px;
padding: 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
color: #fff;
}
.header h1 {
margin: 0 0 4px 0;
font-size: 20px;
font-weight: 600;
}
.header p {
margin: 0;
font-size: 13px;
opacity: 0.9;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.appCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 16px 8px;
background: #fff;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
}
.appCard:active {
transform: scale(0.95);
background: #fafafa;
}
.appIcon {
width: 56px;
height: 56px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
}
.appInfo {
text-align: center;
}
.appLabel {
display: block;
font-size: 13px;
color: #262626;
font-weight: 500;
}
.appDesc {
display: block;
font-size: 11px;
color: #8c8c8c;
margin-top: 2px;
}
.safeArea {
height: 16px;
}
+64
View File
@@ -0,0 +1,64 @@
import styles from './apps.module.css'
interface AppItem {
id: string
icon: string
label: string
path: string
color: string
description?: string
}
const apps: AppItem[] = [
{ id: '1', icon: '✈️', label: '出差申请', path: '/business-trip/apply', color: '#1677ff', description: '提交出差申请' },
{ id: '2', icon: '📝', label: '采购申请', path: '/purchase/apply', color: '#52c41a', description: '物品采购审批' },
{ id: '3', icon: '🏠', label: '会议室', path: '/meeting-room', color: '#faad14', description: '会议室预定' },
{ id: '4', icon: '📅', label: '日程管理', path: '/schedule', color: '#722ed1', description: '个人日程安排' },
{ id: '5', icon: '💰', label: '报销申请', path: '/expense/apply', color: '#eb2f96', description: '费用报销审批' },
{ id: '6', icon: '📋', label: '请假申请', path: '/leave/apply', color: '#13c2c2', description: '请假审批' },
{ id: '7', icon: '🚗', label: '用车申请', path: '/car/apply', color: '#f759ab', description: '公车使用申请' },
{ id: '8', icon: '🎁', label: '礼品申请', path: '/gift/apply', color: '#ff7a45', description: '礼品采购申请' },
{ id: '9', icon: '📊', label: '数据报表', path: '/reports', color: '#1890ff', description: '业务数据统计' },
{ id: '10', icon: '👥', label: '通讯录', path: '/contacts', color: '#096dd9', description: '内部通讯录' },
{ id: '11', icon: '📁', label: '文件中心', path: '/files', color: '#595959', description: '文件共享管理' },
{ id: '12', icon: '⚙️', label: '系统设置', path: '/settings', color: '#8c8c8c', description: '系统偏好设置' },
]
export default function Apps() {
const handleAppClick = (app: AppItem) => {
// TODO: 跳转到应用页面
console.log('点击应用:', app)
}
return (
<div className={styles.apps}>
<div className={styles.header}>
<h1>应用中心</h1>
<p>选择你需要的功能</p>
</div>
<div className={styles.grid}>
{apps.map((app) => (
<div
key={app.id}
className={styles.appCard}
onClick={() => handleAppClick(app)}
>
<div className={styles.appIcon} style={{ backgroundColor: `${app.color}15` }}>
<span style={{ fontSize: 32, color: app.color }}>{app.icon}</span>
</div>
<div className={styles.appInfo}>
<span className={styles.appLabel}>{app.label}</span>
{app.description && (
<span className={styles.appDesc}>{app.description}</span>
)}
</div>
</div>
))}
</div>
{/* 底部安全区域 */}
<div className={styles.safeArea} />
</div>
)
}
@@ -0,0 +1,40 @@
.apply {
padding: 16px;
}
.form {
background: #fff;
border-radius: 8px;
padding: 16px;
}
.group {
margin-bottom: 16px;
}
.group label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: #333;
}
.group input {
width: 100%;
padding: 10px 12px;
border: 1px solid #e5e5e5;
border-radius: 4px;
font-size: 14px;
}
.submitBtn {
width: 100%;
padding: 12px;
border: none;
border-radius: 6px;
background: #1677ff;
color: #fff;
font-size: 16px;
font-weight: 500;
cursor: pointer;
}
@@ -0,0 +1,23 @@
import styles from './apply.module.css'
export default function BusinessTripApply() {
return (
<div className={styles.apply}>
<form className={styles.form}>
<div className={styles.group}>
<label>出差事由</label>
<input type="text" placeholder="请输入出差事由" />
</div>
<div className={styles.group}>
<label>出差时间</label>
<input type="text" placeholder="请选择出差时间" />
</div>
<div className={styles.group}>
<label>出差地点</label>
<input type="text" placeholder="请输入出差地点" />
</div>
<button type="button" className={styles.submitBtn}>提交申请</button>
</form>
</div>
)
}
@@ -0,0 +1,39 @@
.detail {
padding: 16px;
}
.card {
background: #fff;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.card h2 {
margin: 0 0 12px 0;
font-size: 16px;
}
.actions {
display: flex;
gap: 12px;
}
.actions button {
flex: 1;
padding: 12px;
border: none;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
}
.approveBtn {
background: #52c41a;
color: #fff;
}
.rejectBtn {
background: #ff4d4f;
color: #fff;
}
@@ -0,0 +1,16 @@
import styles from './detail.module.css'
export default function BusinessTripDetail() {
return (
<div className={styles.detail}>
<div className={styles.card}>
<h2>出差申请详情</h2>
<p>详情内容...</p>
</div>
<div className={styles.actions}>
<button className={styles.approveBtn}>通过</button>
<button className={styles.rejectBtn}>拒绝</button>
</div>
</div>
)
}
@@ -0,0 +1,32 @@
.list {
padding: 16px;
}
.tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.tab {
flex: 1;
padding: 8px 12px;
border: none;
background: #f5f5f5;
border-radius: 6px;
font-size: 14px;
color: #666;
cursor: pointer;
}
.tab.active {
background: #1677ff;
color: #fff;
}
.empty {
padding: 60px 0;
text-align: center;
color: #999;
font-size: 14px;
}
+14
View File
@@ -0,0 +1,14 @@
import styles from './list.module.css'
export default function BusinessTripList() {
return (
<div className={styles.list}>
<div className={styles.tabs}>
<button className={`${styles.tab} ${styles.active}`}>我发起的</button>
<button className={styles.tab}>待我审批</button>
<button className={styles.tab}>我已审批</button>
</div>
<div className={styles.empty}>暂无出差申请</div>
</div>
)
}
@@ -0,0 +1,284 @@
.dashboard {
min-height: 100vh;
padding: 16px;
padding-bottom: calc(16px + env(safe-area-inset-bottom));
background: #f5f5f5;
}
/* 顶部欢迎区 */
.welcome {
margin-bottom: 16px;
padding: 20px;
background: linear-gradient(135deg, #1677ff 0%, #0958d9 100%);
border-radius: 16px;
color: #fff;
display: flex;
justify-content: space-between;
align-items: center;
}
.userInfo {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.greetingText {
flex: 1;
}
.langBtn {
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
border: none;
cursor: pointer;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.langBtn:active {
background: rgba(255, 255, 255, 0.3);
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 600;
}
.greeting {
margin: 0 0 4px 0;
font-size: 20px;
font-weight: 600;
}
.date {
margin: 0;
font-size: 13px;
opacity: 0.9;
}
/* 通用区块 */
.section {
margin-bottom: 16px;
}
.sectionTitle {
margin: 0 0 12px 0;
font-size: 16px;
font-weight: 600;
color: #262626;
}
.sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.more {
font-size: 13px;
color: #999;
}
/* 统计卡片 */
.statsGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.statCard {
background: #fff;
border-radius: 12px;
padding: 16px 12px;
text-align: center;
}
.statValue {
font-size: 24px;
font-weight: 600;
color: #262626;
margin-bottom: 4px;
}
.statTitle {
font-size: 12px;
color: #8c8c8c;
}
/* 待办事项 */
.todoList {
background: #fff;
border-radius: 12px;
overflow: hidden;
}
.todoItem {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid #f5f5f5;
cursor: pointer;
transition: background 0.2s;
}
.todoItem:last-child {
border-bottom: none;
}
.todoItem:active {
background: #f5f5f5;
}
.todoIcon {
width: 36px;
height: 36px;
border-radius: 8px;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.todoContent {
flex: 1;
min-width: 0;
}
.todoTitle {
font-size: 14px;
color: #262626;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.todoTime {
font-size: 12px;
color: #8c8c8c;
}
.todoArrow {
font-size: 18px;
color: #bfbfbf;
}
.empty {
padding: 40px 20px;
text-align: center;
background: #fff;
border-radius: 12px;
color: #8c8c8c;
font-size: 14px;
}
/* 快捷入口 */
.actionGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.actionCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 8px;
background: #fff;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
}
.actionCard:active {
transform: scale(0.95);
}
.actionIcon {
width: 52px;
height: 52px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
}
.actionLabel {
font-size: 12px;
color: #595959;
text-align: center;
}
/* 最新动态 */
.activityList {
background: #fff;
border-radius: 12px;
padding: 12px 16px;
}
.activityItem {
display: flex;
gap: 12px;
padding: 12px 0;
position: relative;
}
.activityItem:not(:last-child)::after {
content: '';
position: absolute;
left: 5px;
top: 32px;
bottom: -12px;
width: 2px;
background: #f0f0f0;
}
.activityDot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #1677ff;
border: 2px solid #fff;
box-shadow: 0 0 0 2px #e6f4ff;
flex-shrink: 0;
margin-top: 4px;
}
.activityContent {
flex: 1;
}
.activityContent > div:first-child {
font-size: 14px;
color: #262626;
margin-bottom: 4px;
}
.activityTime {
font-size: 12px;
color: #8c8c8c;
}
/* 底部安全区域 */
.safeArea {
height: 16px;
}

Some files were not shown because too many files have changed in this diff Show More