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:
+50
@@ -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
|
||||
@@ -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. 完善权限控制
|
||||
@@ -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 相关配置。
|
||||
@@ -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
@@ -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 开发环境
|
||||
- ✅ 实现审批流程引擎基础框架
|
||||
- ✅ 完成前端基础页面和路由
|
||||
@@ -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. 配置防火墙规则
|
||||
@@ -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=
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+4750
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
];
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
|
||||
return new \Illuminate\Container\Container();
|
||||
@@ -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,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -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
|
||||
',
|
||||
];
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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`);
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
@@ -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' => 'Ошибка проверки данных',
|
||||
],
|
||||
];
|
||||
@@ -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' => '数据验证失败',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
chdir(__DIR__);
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
support\App::run();
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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?
|
||||
@@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
Generated
+4197
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// App.tsx 仅作为路由入口,具体内容在 router/index.tsx 中定义
|
||||
export { default } from './router'
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 设计稿基准
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "В прошлом году"
|
||||
}
|
||||
}
|
||||
@@ -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": "去年"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user