mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-02 08:29:21 +00:00
feat: 重构前端项目结构并优化代码
refactor: 迁移前端资源文件至web目录 feat: 新增多种图标资源 style: 统一代码风格和格式化配置 docs: 更新README和文档说明 chore: 更新依赖和配置文件 fix: 修复部分类型定义和枚举 perf: 优化路由和组件加载逻辑
This commit is contained in:
+68
-20
@@ -98,10 +98,17 @@ This is about **how source directories are split** (package by feature vs by lay
|
|||||||
```sh
|
```sh
|
||||||
FastapiAdmin
|
FastapiAdmin
|
||||||
├─ backend # Backend project (FastAPI + Python)
|
├─ backend # Backend project (FastAPI + Python)
|
||||||
├─ frontend # Web frontend project (Vue3 + Element Plus)
|
├─ frontend # Frontend project directory
|
||||||
├─ devops # Deployment configurations
|
│ ├── web # Web frontend (Vue3 + Element Plus)
|
||||||
├─ docker-compose.yaml # Docker orchestration file
|
│ ├── app # Mobile (UniApp)
|
||||||
├─ deploy.sh # One-click deployment script
|
│ └── docs # Documentation site (VitePress)
|
||||||
|
├─ docker # Docker configuration
|
||||||
|
│ ├── backend # Backend Dockerfile
|
||||||
|
│ ├── nginx # Nginx configuration and static files
|
||||||
|
│ ├── mysql # MySQL data directory
|
||||||
|
│ └── redis # Redis data directory
|
||||||
|
├─ deploy.sh # One-click deployment script (Linux)
|
||||||
|
├─ deploy.bat # One-click startup script (Windows)
|
||||||
├─ LICENSE # Open source license
|
├─ LICENSE # Open source license
|
||||||
|─ README.en.md # English documentation
|
|─ README.en.md # English documentation
|
||||||
└─ README.md # Chinese documentation
|
└─ README.md # Chinese documentation
|
||||||
@@ -109,35 +116,39 @@ FastapiAdmin
|
|||||||
|
|
||||||
## 🏗️ Local Architecture & Default Ports
|
## 🏗️ Local Architecture & Default Ports
|
||||||
|
|
||||||
Aligned with **`backend/env/.env.dev.example`** and **`frontend/.env.development.example`**; if you changed `.env.dev` / `.env.development`, use your local values.
|
Aligned with **`backend/env/.env.dev.example`** and **`frontend/web/.env.development`**; if you changed `.env.dev` / `.env.development`, use your local values.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
subgraph client[Browser]
|
subgraph client[Browser]
|
||||||
U[User]
|
U[User]
|
||||||
end
|
end
|
||||||
subgraph fe[frontend dev]
|
subgraph fe[Frontend Development]
|
||||||
V[Vue3 + Vite]
|
V[Vue3 Web] -->|5173| U
|
||||||
|
A[UniApp H5] -->|8080| U
|
||||||
|
D[VitePress Docs] -->|5174| U
|
||||||
end
|
end
|
||||||
subgraph be[backend]
|
subgraph be[Backend]
|
||||||
A[FastAPI / Uvicorn]
|
F[FastAPI / Uvicorn] -->|8001| V
|
||||||
|
F -->|8001| A
|
||||||
|
F -->|8001| D
|
||||||
end
|
end
|
||||||
subgraph data[Data]
|
subgraph data[Data Layer]
|
||||||
DB[(Database)]
|
DB[(Database)]
|
||||||
R[(Redis)]
|
R[(Redis)]
|
||||||
end
|
end
|
||||||
U --> V
|
F --> DB
|
||||||
V -->|REST via VITE_API_BASE_URL| A
|
F --> R
|
||||||
A --> DB
|
|
||||||
A --> R
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| Component | Config key | Example default (dev template) |
|
| Component | Config key | Example default (dev template) |
|
||||||
|-------------|------------|--------------------------------|
|
|-------------|------------|--------------------------------|
|
||||||
| Web UI | `frontend/.env.development` → `VITE_APP_PORT` | **5180** → **`http://127.0.0.1:5180`** |
|
| Web Frontend | `frontend/web/.env.development` → `VITE_PORT` | **5173** → **`http://127.0.0.1:5173`** |
|
||||||
|
| Mobile H5 | `frontend/app` dev server | **8080** → **`http://127.0.0.1:8080`** |
|
||||||
|
| Docs Site | `frontend/docs` dev server | **5174** → **`http://127.0.0.1:5174`** |
|
||||||
| Backend HTTP | `backend/env/.env.dev` → `SERVER_HOST` / `SERVER_PORT` | **`0.0.0.0:8001`** → **`http://127.0.0.1:8001`** |
|
| Backend HTTP | `backend/env/.env.dev` → `SERVER_HOST` / `SERVER_PORT` | **`0.0.0.0:8001`** → **`http://127.0.0.1:8001`** |
|
||||||
| API base URL | `VITE_API_BASE_URL` | **`http://127.0.0.1:8001`** |
|
| API base URL | `VITE_API_BASE_URL` | **`http://127.0.0.1:8001`** |
|
||||||
| API prefix | `ROOT_PATH` + `VITE_APP_BASE_API` | **`/api/v1`** on both sides |
|
| API prefix | `ROOT_PATH` (backend) | **`/api/v1`** |
|
||||||
| Swagger / Redoc | — | **`http://127.0.0.1:8001/docs`**, `/redoc` |
|
| Swagger / Redoc | — | **`http://127.0.0.1:8001/docs`**, `/redoc` |
|
||||||
| WebSocket (optional) | `VITE_APP_WS_ENDPOINT` | e.g. **`ws://127.0.0.1:8001`** |
|
| WebSocket (optional) | `VITE_APP_WS_ENDPOINT` | e.g. **`ws://127.0.0.1:8001`** |
|
||||||
| DB port | `DATABASE_PORT` | Template uses MySQL **`3306`**; PostgreSQL often **`5432`** |
|
| DB port | `DATABASE_PORT` | Template uses MySQL **`3306`**; PostgreSQL often **`5432`** |
|
||||||
@@ -255,27 +266,52 @@ python main.py run --env=dev
|
|||||||
|
|
||||||
### Frontend Setup
|
### Frontend Setup
|
||||||
|
|
||||||
|
#### Web Frontend (Vue3)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend/web
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm run dev
|
pnpm run dev
|
||||||
|
# Build production version
|
||||||
|
pnpm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Mobile (UniApp)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend/app
|
||||||
|
pnpm install
|
||||||
|
pnpm run dev:h5 # Start H5 development server
|
||||||
|
# Build production version
|
||||||
|
pnpm run build:h5
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Documentation Site (VitePress)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend/docs
|
||||||
|
pnpm install
|
||||||
|
pnpm run dev
|
||||||
|
# Build production version
|
||||||
pnpm run build
|
pnpm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
### After startup
|
### After startup
|
||||||
|
|
||||||
When using **`.env.dev.example` / `.env.development.example`** as-is:
|
When using **`.env.dev.example` / `.env.development`** as-is:
|
||||||
|
|
||||||
| Service | URL (example) |
|
| Service | URL (example) |
|
||||||
|---------|----------------|
|
|---------|----------------|
|
||||||
| Web UI (Vite) | `http://127.0.0.1:5180` |
|
| Web Frontend (Vite) | `http://127.0.0.1:5173` |
|
||||||
|
| Mobile H5 (UniApp) | `http://127.0.0.1:8080` |
|
||||||
|
| Documentation Site (VitePress) | `http://127.0.0.1:5174` |
|
||||||
| Backend base | `http://127.0.0.1:8001` |
|
| Backend base | `http://127.0.0.1:8001` |
|
||||||
| Swagger | `http://127.0.0.1:8001/docs` |
|
| Swagger | `http://127.0.0.1:8001/docs` |
|
||||||
| API prefix | `http://127.0.0.1:8001/api/v1` (matches `ROOT_PATH`) |
|
| API prefix | `http://127.0.0.1:8001/api/v1` (matches `ROOT_PATH`) |
|
||||||
|
|
||||||
### 🐳 Docker Deployment
|
### 🐳 Docker Deployment
|
||||||
|
|
||||||
#### Method 1: Execute script inside project (Recommended)
|
#### Method 1: Use deploy.sh script (Recommended)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clone the repository to server
|
# 1. Clone the repository to server
|
||||||
@@ -291,6 +327,18 @@ chmod +x deploy.sh
|
|||||||
|
|
||||||
# Stop services
|
# Stop services
|
||||||
./deploy.sh stop
|
./deploy.sh stop
|
||||||
|
|
||||||
|
# Restart services
|
||||||
|
./deploy.sh restart
|
||||||
|
|
||||||
|
# Only update code and restart (without rebuilding images)
|
||||||
|
./deploy.sh update
|
||||||
|
|
||||||
|
# Skip frontend build (use uploaded static files)
|
||||||
|
./deploy.sh --skip-frontend
|
||||||
|
|
||||||
|
# Enable frontend build (build on server)
|
||||||
|
./deploy.sh --build-frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Method 2: Execute script outside project
|
#### Method 2: Execute script outside project
|
||||||
|
|||||||
@@ -98,10 +98,17 @@
|
|||||||
```sh
|
```sh
|
||||||
FastapiAdmin
|
FastapiAdmin
|
||||||
├─ backend # 后端工程 (FastAPI + Python)
|
├─ backend # 后端工程 (FastAPI + Python)
|
||||||
├─ frontend # Web前端工程 (Vue3 + Element Plus)
|
├─ frontend # 前端工程目录
|
||||||
├─ devops # 部署配置
|
│ ├── web # Web前端 (Vue3 + Element Plus)
|
||||||
├─ docker-compose.yaml # Docker编排文件
|
│ ├── app # 移动端 (UniApp)
|
||||||
├─ deploy.sh # 一键部署脚本
|
│ └── docs # 文档网站 (VitePress)
|
||||||
|
├─ docker # Docker 配置
|
||||||
|
│ ├── backend # 后端 Dockerfile
|
||||||
|
│ ├── nginx # Nginx 配置和静态文件
|
||||||
|
│ ├── mysql # MySQL 数据目录
|
||||||
|
│ └── redis # Redis 数据目录
|
||||||
|
├─ deploy.sh # 一键部署脚本 (Linux)
|
||||||
|
├─ deploy.bat # 一键启动脚本 (Windows)
|
||||||
├─ LICENSE # 开源协议
|
├─ LICENSE # 开源协议
|
||||||
|─ README.en.md # 英文文档
|
|─ README.en.md # 英文文档
|
||||||
└─ README.md # 中文文档
|
└─ README.md # 中文文档
|
||||||
@@ -109,35 +116,39 @@ FastapiAdmin
|
|||||||
|
|
||||||
## 🏗️ 本地架构与默认端口
|
## 🏗️ 本地架构与默认端口
|
||||||
|
|
||||||
与仓库内 **`backend/env/.env.dev.example`**、**`frontend/.env.development.example`** 保持一致;若你本地已改 `.env.dev` / `.env.development`,以实际文件为准。
|
与仓库内 **`backend/env/.env.dev.example`**、**`frontend/web/.env.development`** 保持一致;若你本地已改 `.env.dev` / `.env.development`,以实际文件为准。
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
subgraph client[浏览器]
|
subgraph client[浏览器]
|
||||||
U[用户]
|
U[用户]
|
||||||
end
|
end
|
||||||
subgraph fe[frontend 开发]
|
subgraph fe[前端开发]
|
||||||
V[Vue3 + Vite]
|
V[Vue3 Web] -->|5173| U
|
||||||
|
A[UniApp H5] -->|8080| U
|
||||||
|
D[VitePress 文档] -->|5174| U
|
||||||
end
|
end
|
||||||
subgraph be[backend]
|
subgraph be[后端]
|
||||||
A[FastAPI / Uvicorn]
|
F[FastAPI / Uvicorn] -->|8001| V
|
||||||
|
F -->|8001| A
|
||||||
|
F -->|8001| D
|
||||||
end
|
end
|
||||||
subgraph data[数据层]
|
subgraph data[数据层]
|
||||||
DB[(数据库)]
|
DB[(数据库)]
|
||||||
R[(Redis)]
|
R[(Redis)]
|
||||||
end
|
end
|
||||||
U --> V
|
F --> DB
|
||||||
V -->|REST 见 VITE_API_BASE_URL| A
|
F --> R
|
||||||
A --> DB
|
|
||||||
A --> R
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| 组件 | 配置项 | 示例默认值(开发模板) |
|
| 组件 | 配置项 | 示例默认值(开发模板) |
|
||||||
|------|--------|------------------------|
|
|------|--------|------------------------|
|
||||||
| 前端页面 | `frontend/.env.development` → `VITE_APP_PORT` | **5180**,即 **`http://127.0.0.1:5180`** |
|
| Web前端 | `frontend/web/.env.development` → `VITE_PORT` | **5173**,即 **`http://127.0.0.1:5173`** |
|
||||||
|
| 移动端H5 | `frontend/app` 开发服务器 | **8080**,即 **`http://127.0.0.1:8080`** |
|
||||||
|
| 文档服务 | `frontend/docs` 开发服务器 | **5174**,即 **`http://127.0.0.1:5174`** |
|
||||||
| 后端 HTTP | `backend/env/.env.dev` → `SERVER_HOST` / `SERVER_PORT` | **`0.0.0.0:8001`**,本机访问 **`http://127.0.0.1:8001`** |
|
| 后端 HTTP | `backend/env/.env.dev` → `SERVER_HOST` / `SERVER_PORT` | **`0.0.0.0:8001`**,本机访问 **`http://127.0.0.1:8001`** |
|
||||||
| 前端请求后端 | `VITE_API_BASE_URL` | **`http://127.0.0.1:8001`** |
|
| 前端请求后端 | `VITE_API_BASE_URL` | **`http://127.0.0.1:8001`** |
|
||||||
| API 前缀 | `ROOT_PATH`(后端)+ `VITE_APP_BASE_API`(前端) | 后端 **`/api/v1`**;前端代理前缀 **`/api/v1`** |
|
| API 前缀 | `ROOT_PATH`(后端) | 后端 **`/api/v1`** |
|
||||||
| Swagger / Redoc | — | **`http://127.0.0.1:8001/docs`**、`/redoc` |
|
| Swagger / Redoc | — | **`http://127.0.0.1:8001/docs`**、`/redoc` |
|
||||||
| WebSocket(可选) | `VITE_APP_WS_ENDPOINT` | 示例 **`ws://127.0.0.1:8001`** |
|
| WebSocket(可选) | `VITE_APP_WS_ENDPOINT` | 示例 **`ws://127.0.0.1:8001`** |
|
||||||
| 数据库端口 | `DATABASE_PORT` | 模板为 MySQL **`3306`**;PostgreSQL 常见 **`5432`** |
|
| 数据库端口 | `DATABASE_PORT` | 模板为 MySQL **`3306`**;PostgreSQL 常见 **`5432`** |
|
||||||
@@ -259,8 +270,30 @@ python main.py run --env=dev
|
|||||||
|
|
||||||
### 前端启动
|
### 前端启动
|
||||||
|
|
||||||
|
#### Web前端 (Vue3)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend/web
|
||||||
|
pnpm install
|
||||||
|
pnpm run dev
|
||||||
|
# 构建生产版本
|
||||||
|
pnpm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 移动端 (UniApp)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend/app
|
||||||
|
pnpm install
|
||||||
|
pnpm run dev:h5 # 启动H5开发服务器
|
||||||
|
# 构建生产版本
|
||||||
|
pnpm run build:h5
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 文档网站 (VitePress)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend/docs
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm run dev
|
pnpm run dev
|
||||||
# 构建生产版本
|
# 构建生产版本
|
||||||
@@ -269,18 +302,20 @@ pnpm run build
|
|||||||
|
|
||||||
### 启动后访问
|
### 启动后访问
|
||||||
|
|
||||||
与 **`.env.dev.example` / `.env.development.example`** 对齐时:
|
与 **`.env.dev.example` / `.env.development`** 对齐时:
|
||||||
|
|
||||||
| 服务 | 地址(示例) |
|
| 服务 | 地址(示例) |
|
||||||
|------|----------------|
|
|------|----------------|
|
||||||
| 前端 Web(Vite) | `http://127.0.0.1:5180` |
|
| Web前端 (Vite) | `http://127.0.0.1:5173` |
|
||||||
|
| 移动端H5 (UniApp) | `http://127.0.0.1:8080` |
|
||||||
|
| 文档网站 (VitePress) | `http://127.0.0.1:5174` |
|
||||||
| 后端 API 根 | `http://127.0.0.1:8001` |
|
| 后端 API 根 | `http://127.0.0.1:8001` |
|
||||||
| Swagger | `http://127.0.0.1:8001/docs` |
|
| Swagger | `http://127.0.0.1:8001/docs` |
|
||||||
| 业务接口前缀 | `http://127.0.0.1:8001/api/v1`(与 `ROOT_PATH` 一致) |
|
| 业务接口前缀 | `http://127.0.0.1:8001/api/v1`(与 `ROOT_PATH` 一致) |
|
||||||
|
|
||||||
### 🐳 Docker 部署
|
### 🐳 Docker 部署
|
||||||
|
|
||||||
#### 方式一:脚本放在项目内执行(推荐)
|
#### 使用 deploy.sh 脚本(推荐)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 克隆代码到服务器
|
# 1. 克隆代码到服务器
|
||||||
@@ -300,32 +335,14 @@ chmod +x deploy.sh
|
|||||||
# 重启服务
|
# 重启服务
|
||||||
./deploy.sh restart
|
./deploy.sh restart
|
||||||
|
|
||||||
# 更新代码并重启(不重新构建镜像,适合后端代码热更新)
|
# 仅更新代码并重启(不重新构建镜像)
|
||||||
./deploy.sh update
|
./deploy.sh update
|
||||||
```
|
|
||||||
|
|
||||||
#### 方式二:脚本放在项目外执行
|
# 跳过前端构建(使用已上传的静态文件)
|
||||||
|
./deploy.sh --skip-frontend
|
||||||
|
|
||||||
```bash
|
# 启用前端构建(在服务器上构建)
|
||||||
# 1. 将部署脚本复制到服务器
|
./deploy.sh --build-frontend
|
||||||
cp deploy.sh /home/
|
|
||||||
cd /home
|
|
||||||
chmod +x deploy.sh
|
|
||||||
|
|
||||||
# 2. 执行一键部署(会自动克隆项目)
|
|
||||||
./deploy.sh
|
|
||||||
|
|
||||||
# 查看容器日志
|
|
||||||
./deploy.sh logs
|
|
||||||
|
|
||||||
# 停止服务
|
|
||||||
./deploy.sh stop
|
|
||||||
|
|
||||||
# 重启服务
|
|
||||||
./deploy.sh restart
|
|
||||||
|
|
||||||
# 更新代码并重启(不重新构建镜像,适合后端代码热更新)
|
|
||||||
./deploy.sh update
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> **注意**:
|
> **注意**:
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
['app/main.py'],
|
||||||
|
pathex=[],
|
||||||
|
binaries=[],
|
||||||
|
datas=[],
|
||||||
|
hiddenimports=[],
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=[],
|
||||||
|
noarchive=False,
|
||||||
|
optimize=0,
|
||||||
|
)
|
||||||
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name='fastapi-backend',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=True,
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
)
|
||||||
@@ -42,3 +42,4 @@ typer==0.19.0 # 命令行工具
|
|||||||
user-agents==2.2.0 # 获取用户UA
|
user-agents==2.2.0 # 获取用户UA
|
||||||
uvicorn==0.30.6 # uvicorn web 框架
|
uvicorn==0.30.6 # uvicorn web 框架
|
||||||
websockets>=15.0.1,<16.0 # websocket(prefect>=3.5 需 >=15.0.1;与部署 requirements.txt 一致)
|
websockets>=15.0.1,<16.0 # websocket(prefect>=3.5 需 >=15.0.1;与部署 requirements.txt 一致)
|
||||||
|
pyinstaller>=6.14.2
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# 环境配置
|
||||||
|
TEST_TYPE=UI
|
||||||
|
ENVIRONMENT=测试环境
|
||||||
|
ENVIRONMENT_HOST=https://xxx.xxx.com
|
||||||
|
ENVIRONMENT_TOKEN=xxxx
|
||||||
|
|
||||||
|
# 测试人员
|
||||||
|
TESTER=张三
|
||||||
|
|
||||||
|
# 公共配置
|
||||||
|
DELETE_ON_OFF=False
|
||||||
|
EMAIL_ON_OFF=False
|
||||||
|
DINGDING_ON_OFF=False
|
||||||
|
QIWEI_ON_OFF=True
|
||||||
|
JENKINS_URL=http://xxx.xxx.com:10086/job/auto_test/
|
||||||
|
REPORT_URL=http://xxx.xxx.com:10086/job/auto_test
|
||||||
|
|
||||||
|
# 邮件配置
|
||||||
|
EMAIL_SMTP_SERVER=smtp.qq.com
|
||||||
|
EMAIL_SENDER=xx@qq.com
|
||||||
|
EMAIL_PASSWORD=xxx
|
||||||
|
EMAIL_RECEIVER=xx@qq.com
|
||||||
|
|
||||||
|
# 钉钉配置
|
||||||
|
DINGDING_SECRET=xxx
|
||||||
|
DINGDING_WEBHOOK=https://oapi.dingtalk.com/robot/send?access_token=xxx
|
||||||
|
DINGDING_AT_MOBILES=["xxx"]
|
||||||
|
|
||||||
|
# 企业微信配置
|
||||||
|
WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxx
|
||||||
|
|
||||||
|
# 浏览器配置
|
||||||
|
BROWSER_TYPE=chromium
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/.idea/*
|
||||||
|
/.pytest_cache/
|
||||||
|
/venv/
|
||||||
|
.venv
|
||||||
|
.vscode/*
|
||||||
|
|
||||||
|
reports/*
|
||||||
|
logs/*
|
||||||
|
case/api/.DS_Store
|
||||||
|
case/.DS_Store
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
木兰宽松许可证,第2版
|
||||||
|
|
||||||
|
木兰宽松许可证,第2版
|
||||||
|
|
||||||
|
2020年1月 http://license.coscl.org.cn/MulanPSL2
|
||||||
|
|
||||||
|
您对“软件”的复制、使用、修改及分发受木兰宽松许可证,第2版(“本许可证”)的如下条款的约束:
|
||||||
|
|
||||||
|
0. 定义
|
||||||
|
|
||||||
|
“软件” 是指由“贡献”构成的许可在“本许可证”下的程序和相关文档的集合。
|
||||||
|
|
||||||
|
“贡献” 是指由任一“贡献者”许可在“本许可证”下的受版权法保护的作品。
|
||||||
|
|
||||||
|
“贡献者” 是指将受版权法保护的作品许可在“本许可证”下的自然人或“法人实体”。
|
||||||
|
|
||||||
|
“法人实体” 是指提交贡献的机构及其“关联实体”。
|
||||||
|
|
||||||
|
“关联实体” 是指,对“本许可证”下的行为方而言,控制、受控制或与其共同受控制的机构,此处的控制是
|
||||||
|
指有受控方或共同受控方至少50%直接或间接的投票权、资金或其他有价证券。
|
||||||
|
|
||||||
|
1. 授予版权许可
|
||||||
|
|
||||||
|
每个“贡献者”根据“本许可证”授予您永久性的、全球性的、免费的、非独占的、不可撤销的版权许可,您可
|
||||||
|
以复制、使用、修改、分发其“贡献”,不论修改与否。
|
||||||
|
|
||||||
|
2. 授予专利许可
|
||||||
|
|
||||||
|
每个“贡献者”根据“本许可证”授予您永久性的、全球性的、免费的、非独占的、不可撤销的(根据本条规定
|
||||||
|
撤销除外)专利许可,供您制造、委托制造、使用、许诺销售、销售、进口其“贡献”或以其他方式转移其“贡
|
||||||
|
献”。前述专利许可仅限于“贡献者”现在或将来拥有或控制的其“贡献”本身或其“贡献”与许可“贡献”时的“软
|
||||||
|
件”结合而将必然会侵犯的专利权利要求,不包括对“贡献”的修改或包含“贡献”的其他结合。如果您或您的“
|
||||||
|
关联实体”直接或间接地,就“软件”或其中的“贡献”对任何人发起专利侵权诉讼(包括反诉或交叉诉讼)或
|
||||||
|
其他专利维权行动,指控其侵犯专利权,则“本许可证”授予您对“软件”的专利许可自您提起诉讼或发起维权
|
||||||
|
行动之日终止。
|
||||||
|
|
||||||
|
3. 无商标许可
|
||||||
|
|
||||||
|
“本许可证”不提供对“贡献者”的商品名称、商标、服务标志或产品名称的商标许可,但您为满足第4条规定
|
||||||
|
的声明义务而必须使用除外。
|
||||||
|
|
||||||
|
4. 分发限制
|
||||||
|
|
||||||
|
您可以在任何媒介中将“软件”以源程序形式或可执行形式重新分发,不论修改与否,但您必须向接收者提供“
|
||||||
|
本许可证”的副本,并保留“软件”中的版权、商标、专利及免责声明。
|
||||||
|
|
||||||
|
5. 免责声明与责任限制
|
||||||
|
|
||||||
|
“软件”及其中的“贡献”在提供时不带任何明示或默示的担保。在任何情况下,“贡献者”或版权所有者不对
|
||||||
|
任何人因使用“软件”或其中的“贡献”而引发的任何直接或间接损失承担责任,不论因何种原因导致或者基于
|
||||||
|
何种法律理论,即使其曾被建议有此种损失的可能性。
|
||||||
|
|
||||||
|
6. 语言
|
||||||
|
|
||||||
|
“本许可证”以中英文双语表述,中英文版本具有同等法律效力。如果中英文版本存在任何冲突不一致,以中文
|
||||||
|
版为准。
|
||||||
|
|
||||||
|
条款结束
|
||||||
|
|
||||||
|
如何将木兰宽松许可证,第2版,应用到您的软件
|
||||||
|
|
||||||
|
如果您希望将木兰宽松许可证,第2版,应用到您的新软件,为了方便接收者查阅,建议您完成如下三步:
|
||||||
|
|
||||||
|
1, 请您补充如下声明中的空白,包括软件名、软件的首次发表年份以及您作为版权人的名字;
|
||||||
|
|
||||||
|
2, 请您在软件包的一级目录下创建以“LICENSE”为名的文件,将整个许可证文本放入该文件中;
|
||||||
|
|
||||||
|
3, 请将如下声明文本放入每个源文件的头部注释中。
|
||||||
|
|
||||||
|
Copyright (c) [Year] [name of copyright holder]
|
||||||
|
[Software Name] is licensed under Mulan PSL v2.
|
||||||
|
You can use this software according to the terms and conditions of the Mulan
|
||||||
|
PSL v2.
|
||||||
|
You may obtain a copy of Mulan PSL v2 at:
|
||||||
|
http://license.coscl.org.cn/MulanPSL2
|
||||||
|
THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY
|
||||||
|
KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
|
||||||
|
NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
|
||||||
|
See the Mulan PSL v2 for more details.
|
||||||
|
|
||||||
|
Mulan Permissive Software License,Version 2
|
||||||
|
|
||||||
|
Mulan Permissive Software License,Version 2 (Mulan PSL v2)
|
||||||
|
|
||||||
|
January 2020 http://license.coscl.org.cn/MulanPSL2
|
||||||
|
|
||||||
|
Your reproduction, use, modification and distribution of the Software shall
|
||||||
|
be subject to Mulan PSL v2 (this License) with the following terms and
|
||||||
|
conditions:
|
||||||
|
|
||||||
|
0. Definition
|
||||||
|
|
||||||
|
Software means the program and related documents which are licensed under
|
||||||
|
this License and comprise all Contribution(s).
|
||||||
|
|
||||||
|
Contribution means the copyrightable work licensed by a particular
|
||||||
|
Contributor under this License.
|
||||||
|
|
||||||
|
Contributor means the Individual or Legal Entity who licenses its
|
||||||
|
copyrightable work under this License.
|
||||||
|
|
||||||
|
Legal Entity means the entity making a Contribution and all its
|
||||||
|
Affiliates.
|
||||||
|
|
||||||
|
Affiliates means entities that control, are controlled by, or are under
|
||||||
|
common control with the acting entity under this License, ‘control’ means
|
||||||
|
direct or indirect ownership of at least fifty percent (50%) of the voting
|
||||||
|
power, capital or other securities of controlled or commonly controlled
|
||||||
|
entity.
|
||||||
|
|
||||||
|
1. Grant of Copyright License
|
||||||
|
|
||||||
|
Subject to the terms and conditions of this License, each Contributor hereby
|
||||||
|
grants to you a perpetual, worldwide, royalty-free, non-exclusive,
|
||||||
|
irrevocable copyright license to reproduce, use, modify, or distribute its
|
||||||
|
Contribution, with modification or not.
|
||||||
|
|
||||||
|
2. Grant of Patent License
|
||||||
|
|
||||||
|
Subject to the terms and conditions of this License, each Contributor hereby
|
||||||
|
grants to you a perpetual, worldwide, royalty-free, non-exclusive,
|
||||||
|
irrevocable (except for revocation under this Section) patent license to
|
||||||
|
make, have made, use, offer for sale, sell, import or otherwise transfer its
|
||||||
|
Contribution, where such patent license is only limited to the patent claims
|
||||||
|
owned or controlled by such Contributor now or in future which will be
|
||||||
|
necessarily infringed by its Contribution alone, or by combination of the
|
||||||
|
Contribution with the Software to which the Contribution was contributed.
|
||||||
|
The patent license shall not apply to any modification of the Contribution,
|
||||||
|
and any other combination which includes the Contribution. If you or your
|
||||||
|
Affiliates directly or indirectly institute patent litigation (including a
|
||||||
|
cross claim or counterclaim in a litigation) or other patent enforcement
|
||||||
|
activities against any individual or entity by alleging that the Software or
|
||||||
|
any Contribution in it infringes patents, then any patent license granted to
|
||||||
|
you under this License for the Software shall terminate as of the date such
|
||||||
|
litigation or activity is filed or taken.
|
||||||
|
|
||||||
|
3. No Trademark License
|
||||||
|
|
||||||
|
No trademark license is granted to use the trade names, trademarks, service
|
||||||
|
marks, or product names of Contributor, except as required to fulfill notice
|
||||||
|
requirements in section 4.
|
||||||
|
|
||||||
|
4. Distribution Restriction
|
||||||
|
|
||||||
|
You may distribute the Software in any medium with or without modification,
|
||||||
|
whether in source or executable forms, provided that you provide recipients
|
||||||
|
with a copy of this License and retain copyright, patent, trademark and
|
||||||
|
disclaimer statements in the Software.
|
||||||
|
|
||||||
|
5. Disclaimer of Warranty and Limitation of Liability
|
||||||
|
|
||||||
|
THE SOFTWARE AND CONTRIBUTION IN IT ARE PROVIDED WITHOUT WARRANTIES OF ANY
|
||||||
|
KIND, EITHER EXPRESS OR IMPLIED. IN NO EVENT SHALL ANY CONTRIBUTOR OR
|
||||||
|
COPYRIGHT HOLDER BE LIABLE TO YOU FOR ANY DAMAGES, INCLUDING, BUT NOT
|
||||||
|
LIMITED TO ANY DIRECT, OR INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||||
|
FROM YOUR USE OR INABILITY TO USE THE SOFTWARE OR THE CONTRIBUTION IN IT, NO
|
||||||
|
MATTER HOW IT’S CAUSED OR BASED ON WHICH LEGAL THEORY, EVEN IF ADVISED OF
|
||||||
|
THE POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
6. Language
|
||||||
|
|
||||||
|
THIS LICENSE IS WRITTEN IN BOTH CHINESE AND ENGLISH, AND THE CHINESE VERSION
|
||||||
|
AND ENGLISH VERSION SHALL HAVE THE SAME LEGAL EFFECT. IN THE CASE OF
|
||||||
|
DIVERGENCE BETWEEN THE CHINESE AND ENGLISH VERSIONS, THE CHINESE VERSION
|
||||||
|
SHALL PREVAIL.
|
||||||
|
|
||||||
|
END OF THE TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply the Mulan Permissive Software License,Version 2
|
||||||
|
(Mulan PSL v2) to Your Software
|
||||||
|
|
||||||
|
To apply the Mulan PSL v2 to your work, for easy identification by
|
||||||
|
recipients, you are suggested to complete following three steps:
|
||||||
|
|
||||||
|
i. Fill in the blanks in following statement, including insert your software
|
||||||
|
name, the year of the first publication of your software, and your name
|
||||||
|
identified as the copyright owner;
|
||||||
|
|
||||||
|
ii. Create a file named "LICENSE" which contains the whole context of this
|
||||||
|
License in the first directory of your software package;
|
||||||
|
|
||||||
|
iii. Attach the statement to the appropriate annotated syntax at the
|
||||||
|
beginning of each source file.
|
||||||
|
|
||||||
|
Copyright (c) [Year] [name of copyright holder]
|
||||||
|
[Software Name] is licensed under Mulan PSL v2.
|
||||||
|
You can use this software according to the terms and conditions of the Mulan
|
||||||
|
PSL v2.
|
||||||
|
You may obtain a copy of Mulan PSL v2 at:
|
||||||
|
http://license.coscl.org.cn/MulanPSL2
|
||||||
|
THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY
|
||||||
|
KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
|
||||||
|
NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
|
||||||
|
See the Mulan PSL v2 for more details.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Auto_Test
|
||||||
|
|
||||||
|
## 项目简介
|
||||||
|
|
||||||
|
Auto_Test 是一个完全开源免费的自动化测试框架,旨在整合多种工具和技术,提供全面的API和UI自动化测试解决方案。通过使用pytest、playwright、locust、pytest-html、requests、Jenkins等技术,本项目能够帮助测试员高效地进行自动化测试,并生成详细的测试报告。支持通过Jenkins构建发送测试结果通知到邮件、钉钉、企微,方便团队成员及时了解测试状态。
|
||||||
|
|
||||||
|
## 关键特性
|
||||||
|
|
||||||
|
- **多技术整合**:结合Python、pytest、playwright等多种工具,实现API和UI自动化测试。
|
||||||
|
- **详细报告**:使用pytest-html生成美观且详细的测试报告。
|
||||||
|
- **数据处理**:利用openpyxl和pandas处理Excel数据,方便测试数据管理和结果分析。
|
||||||
|
- **通知集成**:通过DingTalk发送测试结果通知,确保团队及时了解测试状态。
|
||||||
|
- **性能测试**:利用locust进行性能测试,评估系统在高负载下的性能表现。
|
||||||
|
- **数据伪造**:借助Faker生成假数据,丰富测试场景。
|
||||||
|
- **持续集成**:通过Jenkins实现持续集成,确保项目代码始终处于良好状态。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```plaintext
|
||||||
|
AUTO_TEST
|
||||||
|
├── case # 测试数据目录
|
||||||
|
│ ├── api # 接口测试数据目录
|
||||||
|
│ ├── locustfiles # locust性能测试数据目录
|
||||||
|
│ └── ui # UI测试数据目录
|
||||||
|
├── common # 全局公共模块
|
||||||
|
├── config # 配置文件
|
||||||
|
├── logs # 日志目录
|
||||||
|
├── report # 测试报告
|
||||||
|
├── testCase # 测试用例目录
|
||||||
|
│ ├── api # 接口测试用例目录
|
||||||
|
│ └── ui # UI测试用例目录
|
||||||
|
├── tools # 工具类目录
|
||||||
|
├── .env # 环境变量配置
|
||||||
|
├── locust.conf # locust性能测试配置文件
|
||||||
|
├── main.py # 测试主入口
|
||||||
|
├── pytest.ini # pytest配置文件
|
||||||
|
├── README.md # 项目说明文档
|
||||||
|
└── requirements.txt # 依赖包版本文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
1.工具:
|
||||||
|
|
||||||
|
- python下载地址: <https://www.python.org/download>
|
||||||
|
- pycharm下载地址: <https://www.jetbrains.com/pycharm>
|
||||||
|
|
||||||
|
2.搭建步骤
|
||||||
|
|
||||||
|
- 2.1拉取代码
|
||||||
|
- git clone <https://gitee.com/fastapiadmin/AutoTest.git>
|
||||||
|
- 查看本地和远程所有分支: git branch -a
|
||||||
|
- 切换分支:git checkout [branch-name]
|
||||||
|
|
||||||
|
- 2.2创建虚拟环境:
|
||||||
|
- python -m venv venv
|
||||||
|
- venv/Scripts/activate
|
||||||
|
- 回车激活环境
|
||||||
|
|
||||||
|
- 2.3安装项目依赖包
|
||||||
|
- pip install -r requirements.txt
|
||||||
|
- pip install -r requirements.txt --upgrade
|
||||||
|
|
||||||
|
- 2.4运行项目
|
||||||
|
- 运行前检查config目录中Config.ini文件是否配置正确
|
||||||
|
- python main.py
|
||||||
|
|
||||||
|
- 2.5ui录制脚本
|
||||||
|
- playwright codegen
|
||||||
|
|
||||||
|
- 如果没有找到浏览器驱动则执行下方,一次会下载三个浏览器,chromium、firefox、webkit
|
||||||
|
playwright install
|
||||||
|
|
||||||
|
- 或者指定下载浏览器
|
||||||
|
playwright install chromium
|
||||||
|
playwright install firefox
|
||||||
|
playwright install webkit
|
||||||
|
|
||||||
|
- 性能测试
|
||||||
|
- https://docs.locust.io/en/stable/api.html
|
||||||
|
|
||||||
|
## 🎨 社区交流
|
||||||
|
|
||||||
|
| 群组二维码 | 微信支付二维码 |
|
||||||
|
| --- | --- |
|
||||||
|
|  |  |
|
||||||
Binary file not shown.
@@ -0,0 +1,142 @@
|
|||||||
|
Case:
|
||||||
|
- Test:
|
||||||
|
name: test_001_yaml
|
||||||
|
run: yes
|
||||||
|
type: json
|
||||||
|
request:
|
||||||
|
method: POST
|
||||||
|
url: $host/gateway/traceplan/trackPlan/getData
|
||||||
|
headers:
|
||||||
|
Content-Type: application/json;charset=UTF-8
|
||||||
|
token: $token
|
||||||
|
data: {"staSelect":"09100101","startTime":"2025-10-14 00:00:00","endTime":"2025-10-14 00:30:00","page":1,"limit":50,"showApplication":true,"trackStatus":"4"}
|
||||||
|
expected:
|
||||||
|
code: 200
|
||||||
|
isSuccess: true
|
||||||
|
- Test:
|
||||||
|
name: test_002_yaml
|
||||||
|
run: yes
|
||||||
|
type: json
|
||||||
|
request:
|
||||||
|
method: POST
|
||||||
|
url: $host/gateway/traceplan/trackPlan/getData
|
||||||
|
headers:
|
||||||
|
Content-Type: application/json;charset=UTF-8
|
||||||
|
token: $token
|
||||||
|
data: {"staSelect":"09100101","startTime":"2025-10-14 00:00:00","endTime":"2025-10-14 00:30:00","page":1,"limit":50,"showApplication":true,"trackStatus":"4"}
|
||||||
|
expected:
|
||||||
|
code: 200
|
||||||
|
isSuccess: true
|
||||||
|
- Test:
|
||||||
|
name: test_003_yaml
|
||||||
|
run: yes
|
||||||
|
type: json
|
||||||
|
request:
|
||||||
|
method: POST
|
||||||
|
url: $host/gateway/traceplan/trackPlan/getData
|
||||||
|
headers:
|
||||||
|
Content-Type: application/json;charset=UTF-8
|
||||||
|
token: $token
|
||||||
|
data: {"staSelect":"09100101","startTime":"2025-10-14 00:00:00","endTime":"2025-10-14 00:30:00","page":1,"limit":50,"showApplication":true,"trackStatus":"4"}
|
||||||
|
expected:
|
||||||
|
code: 200
|
||||||
|
isSuccess: true
|
||||||
|
- Test:
|
||||||
|
name: test_004_yaml
|
||||||
|
run: yes
|
||||||
|
type: json
|
||||||
|
request:
|
||||||
|
method: POST
|
||||||
|
url: $host/gateway/traceplan/trackPlan/getData
|
||||||
|
headers:
|
||||||
|
Content-Type: application/json;charset=UTF-8
|
||||||
|
token: $token
|
||||||
|
data: {"staSelect":"09100101","startTime":"2025-10-14 00:00:00","endTime":"2025-10-14 00:30:00","page":1,"limit":50,"showApplication":true,"trackStatus":"4"}
|
||||||
|
expected:
|
||||||
|
code: 200
|
||||||
|
isSuccess: true
|
||||||
|
- Test:
|
||||||
|
name: test_005_yaml
|
||||||
|
run: yes
|
||||||
|
type: json
|
||||||
|
request:
|
||||||
|
method: POST
|
||||||
|
url: $host/gateway/traceplan/trackPlan/getData
|
||||||
|
headers:
|
||||||
|
Content-Type: application/json;charset=UTF-8
|
||||||
|
token: $token
|
||||||
|
data: {"staSelect":"09100101","startTime":"2025-10-14 00:00:00","endTime":"2025-10-14 00:30:00","page":1,"limit":50,"showApplication":true,"trackStatus":"4"}
|
||||||
|
expected:
|
||||||
|
code: 200
|
||||||
|
isSuccess: true
|
||||||
|
- Test:
|
||||||
|
name: test_006_yaml
|
||||||
|
run: yes
|
||||||
|
type: json
|
||||||
|
request:
|
||||||
|
method: POST
|
||||||
|
url: $host/gateway/traceplan/trackPlan/getData
|
||||||
|
headers:
|
||||||
|
Content-Type: application/json;charset=UTF-8
|
||||||
|
token: $token
|
||||||
|
data: {"staSelect":"09100101","startTime":"2025-10-14 00:00:00","endTime":"2025-10-14 00:30:00","page":1,"limit":50,"showApplication":true,"trackStatus":"4"}
|
||||||
|
expected:
|
||||||
|
code: 200
|
||||||
|
isSuccess: true
|
||||||
|
- Test:
|
||||||
|
name: test_007_yaml
|
||||||
|
run: yes
|
||||||
|
type: json
|
||||||
|
request:
|
||||||
|
method: POST
|
||||||
|
url: $host/gateway/traceplan/trackPlan/getData
|
||||||
|
headers:
|
||||||
|
Content-Type: application/json;charset=UTF-8
|
||||||
|
token: $token
|
||||||
|
data: {"staSelect":"09100101","startTime":"2025-10-14 00:00:00","endTime":"2025-10-14 00:30:00","page":1,"limit":50,"showApplication":true,"trackStatus":"4"}
|
||||||
|
expected:
|
||||||
|
code: 200
|
||||||
|
isSuccess: true
|
||||||
|
- Test:
|
||||||
|
name: test_008_yaml
|
||||||
|
run: yes
|
||||||
|
type: json
|
||||||
|
request:
|
||||||
|
method: POST
|
||||||
|
url: $host/gateway/traceplan/trackPlan/getData
|
||||||
|
headers:
|
||||||
|
Content-Type: application/json;charset=UTF-8
|
||||||
|
token: $token
|
||||||
|
data: {"staSelect":"09100101","startTime":"2025-10-14 00:00:00","endTime":"2025-10-14 00:30:00","page":1,"limit":50,"showApplication":true,"trackStatus":"4"}
|
||||||
|
expected:
|
||||||
|
code: 200
|
||||||
|
isSuccess: true
|
||||||
|
- Test:
|
||||||
|
name: test_009_yaml
|
||||||
|
run: yes
|
||||||
|
type: json
|
||||||
|
request:
|
||||||
|
method: POST
|
||||||
|
url: $host/gateway/traceplan/trackPlan/getData
|
||||||
|
headers:
|
||||||
|
Content-Type: application/json;charset=UTF-8
|
||||||
|
token: $token
|
||||||
|
data: {"staSelect":"09100101","startTime":"2025-10-14 00:00:00","endTime":"2025-10-14 00:30:00","page":1,"limit":50,"showApplication":true,"trackStatus":"4"}
|
||||||
|
expected:
|
||||||
|
code: 200
|
||||||
|
isSuccess: true
|
||||||
|
- Test:
|
||||||
|
name: test_010_yaml
|
||||||
|
run: yes
|
||||||
|
type: json
|
||||||
|
request:
|
||||||
|
method: POST
|
||||||
|
url: $host/gateway/traceplan/trackPlan/getData
|
||||||
|
headers:
|
||||||
|
Content-Type: application/json;charset=UTF-8
|
||||||
|
token: $token
|
||||||
|
data: {"staSelect":"09100101","startTime":"2025-10-14 00:00:00","endTime":"2025-10-14 00:30:00","page":1,"limit":50,"showApplication":true,"trackStatus":"4"}
|
||||||
|
expected:
|
||||||
|
code: 200
|
||||||
|
isSuccess: true
|
||||||
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import time
|
||||||
|
from locust import HttpUser, task, between, User
|
||||||
|
from locust.runners import MasterRunner
|
||||||
|
|
||||||
|
from locust import events
|
||||||
|
|
||||||
|
@events.test_start.add_listener
|
||||||
|
def on_test_start(environment, **kwargs):
|
||||||
|
print("开始监听")
|
||||||
|
|
||||||
|
@events.test_stop.add_listener
|
||||||
|
def on_test_stop(environment, **kwargs):
|
||||||
|
print("结束监听")
|
||||||
|
|
||||||
|
@events.init.add_listener
|
||||||
|
def on_locust_init(environment, **kwargs):
|
||||||
|
if isinstance(environment.runner, MasterRunner):
|
||||||
|
print("我在master节点上")
|
||||||
|
else:
|
||||||
|
print("我在worker节点或独立节点上")
|
||||||
|
|
||||||
|
class QuickstartUser(HttpUser):
|
||||||
|
# 例如,让每个用户在每次任务执行之间等待0.5到10秒:
|
||||||
|
wait_time = between(0.5, 10)
|
||||||
|
|
||||||
|
# 例如,下一个用户类会睡一秒,然后两秒,再三秒,依此类推。
|
||||||
|
last_wait_time = 0
|
||||||
|
|
||||||
|
def wait_time(self):
|
||||||
|
self.last_wait_time += 1
|
||||||
|
return self.last_wait_time
|
||||||
|
|
||||||
|
# @task 会有一个可选的权重参数,可以用来指定任务的执行比率。在 以下示例,任务2被选中的概率是任务1的两倍:
|
||||||
|
@task(3)
|
||||||
|
def task1(self):
|
||||||
|
self.client.get("/hello")
|
||||||
|
self.client.get("/world")
|
||||||
|
|
||||||
|
@task(6)
|
||||||
|
def task2(self):
|
||||||
|
for item_id in range(10):
|
||||||
|
self.client.get(f"/item?id={item_id}", name="/item")
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def my_task(self):
|
||||||
|
with self.client.get("/", catch_response=True) as response:
|
||||||
|
if response.text != "Success":
|
||||||
|
response.failure("Got wrong response")
|
||||||
|
elif response.elapsed.total_seconds() > 0.5:
|
||||||
|
response.failure("Request took too long")
|
||||||
|
elif response.status_code == 404:
|
||||||
|
raise RescheduleTask()
|
||||||
|
|
||||||
|
def on_start(self):
|
||||||
|
self.client.post("/login", json={"username":"foo", "password":"bar"})
|
||||||
|
|
||||||
|
# 例如,假设我们有一个WebUser类和一个MobileUser类,我们想让MobileUser类的用户权重为1,而WebUser类的用户权重为3。
|
||||||
|
class WebUser(User):
|
||||||
|
weight = 3
|
||||||
|
...
|
||||||
|
|
||||||
|
class MobileUser(User):
|
||||||
|
weight = 1
|
||||||
|
...
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
@@ -0,0 +1,75 @@
|
|||||||
|
# 在最佳情况下(在 -loop 内执行小请求),单个 Locust 进程(限制在一个 CPU 核心)
|
||||||
|
# 使用 FastHttpUser 可以每秒处理大约 16000 个请求,使用 HttpUser 可以执行 4000
|
||||||
|
# 个请求(测试于 2021 年 M1 MacBook Pro 和 Python 3.11 上)
|
||||||
|
|
||||||
|
from locust import TaskSet, task, HttpUser, run_single_user, FastHttpUser
|
||||||
|
from locust.clients import ResponseContextManager
|
||||||
|
from locust.runners import logger
|
||||||
|
from locust import HttpUser, task, between
|
||||||
|
|
||||||
|
class Task(TaskSet):
|
||||||
|
tasks = {ThreadPage:15, write_post:1}
|
||||||
|
@tag('thread')
|
||||||
|
@task(3)
|
||||||
|
def demo_api(self):
|
||||||
|
# 基于注释中的接口信息设置请求参数
|
||||||
|
payload = {}
|
||||||
|
|
||||||
|
# 设置请求头
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"token": "xxxx"
|
||||||
|
}
|
||||||
|
|
||||||
|
path = "/gateway/xxx"
|
||||||
|
logger.info(f"请求路径 {path}")
|
||||||
|
with self.client.post(path, json=payload, headers=headers, catch_response=True) as res:
|
||||||
|
res: ResponseContextManager
|
||||||
|
if res.status_code != 200:
|
||||||
|
# 输出请求的完整 URL 和状态码
|
||||||
|
logger.error(f"请求失败. 接口地址: {res.request.url}, 状态码: {res.status_code}")
|
||||||
|
# 输出响应文本
|
||||||
|
logger.error(f"响应文本: {res.text}")
|
||||||
|
# 标记请求为失败
|
||||||
|
res.failure(res.text)
|
||||||
|
else:
|
||||||
|
# 检查响应内容是否包含预期的数据结构
|
||||||
|
try:
|
||||||
|
response_json = res.json()
|
||||||
|
if not response_json:
|
||||||
|
res.failure("响应体为空")
|
||||||
|
except ValueError:
|
||||||
|
res.failure("响应体不是有效的 JSON 格式")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def on_start(self):
|
||||||
|
logger.info('s'*10 + '测试开始' + 's'*10)
|
||||||
|
|
||||||
|
def on_stop(self):
|
||||||
|
logger.info('s'*10 + '测试结束' + 's'*10)
|
||||||
|
|
||||||
|
# class Test(HttpUser):
|
||||||
|
class Test(FastHttpUser):
|
||||||
|
wait_time = between(1, 5)
|
||||||
|
|
||||||
|
# 根据注释中的接口地址更新host配置
|
||||||
|
host = 'https://xxx.com'
|
||||||
|
tasks = [Task, ]
|
||||||
|
|
||||||
|
@task
|
||||||
|
def t(self):
|
||||||
|
with self.rest("POST", "/", json={"foo": 1}) as resp:
|
||||||
|
if resp.js is None:
|
||||||
|
pass # no need to do anything, already marked as failed
|
||||||
|
elif "bar" not in resp.js:
|
||||||
|
resp.failure(f"'bar' missing from response {resp.text}")
|
||||||
|
elif resp.js["bar"] != 42:
|
||||||
|
resp.failure(f"'bar' had an unexpected value: {resp.js['bar']}")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
# 修复变量名错误,应该是Test类而不是test
|
||||||
|
run_single_user(Test)
|
||||||
|
|
||||||
|
# 启动 Locust 测试
|
||||||
|
# locust -f locust.py
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from typing import Dict, Any
|
||||||
|
from playwright.sync_api import Page, expect
|
||||||
|
|
||||||
|
|
||||||
|
class BasePage:
|
||||||
|
def __init__(self, page: Page) -> None:
|
||||||
|
self.page = page
|
||||||
|
|
||||||
|
def login(self, test_data: Dict[str, Any]) -> bool:
|
||||||
|
# 设置页面加载超时
|
||||||
|
self.page.set_default_timeout(30000)
|
||||||
|
|
||||||
|
# 访问登录页面并等待页面完全加载
|
||||||
|
self.page.goto(test_data["url"])
|
||||||
|
self.page.wait_for_load_state("networkidle") # 等待网络活动空闲
|
||||||
|
|
||||||
|
# 等待用户名输入框可见且可交互
|
||||||
|
username_input = self.page.get_by_role("textbox", name="用户名")
|
||||||
|
username_input.wait_for(state="visible", timeout=10000)
|
||||||
|
username_input.click()
|
||||||
|
username_input.fill(test_data["username"])
|
||||||
|
|
||||||
|
# 等待密码输入框可见且可交互
|
||||||
|
password_input = self.page.get_by_role("textbox", name="请输入登录密码")
|
||||||
|
password_input.wait_for(state="visible", timeout=10000)
|
||||||
|
password_input.click()
|
||||||
|
password_input.fill(test_data["password"])
|
||||||
|
|
||||||
|
# 等待登录按钮可见且可交互
|
||||||
|
login_button = self.page.get_by_role("button", name="登录")
|
||||||
|
login_button.wait_for(state="visible", timeout=10000)
|
||||||
|
# 使用click()方法本身会等待元素可交互,不需要单独检查enabled状态
|
||||||
|
login_button.click()
|
||||||
|
|
||||||
|
# 等待登录后页面加载和弹窗出现
|
||||||
|
self.page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
# 等待SaaS系统使用引导文本出现
|
||||||
|
self.page.wait_for_selector("text=SaaS系统使用引导", timeout=15000)
|
||||||
|
expect(self.page.get_by_text("SaaS系统使用引导")).to_be_visible()
|
||||||
|
expect(self.page.get_by_text("登录成功")).to_be_visible()
|
||||||
|
|
||||||
|
# 等待关闭按钮可见且可交互
|
||||||
|
close_button = self.page.get_by_role("button", name="Close")
|
||||||
|
close_button.wait_for(state="visible", timeout=10000)
|
||||||
|
# 使用click()方法本身会等待元素可交互,不需要单独检查enabled状态
|
||||||
|
close_button.click()
|
||||||
|
|
||||||
|
# 关闭登录成功提示
|
||||||
|
self.page.locator(".el-icon.el-notification__closeBtn > svg").click()
|
||||||
|
|
||||||
|
# 等待弹窗关闭
|
||||||
|
self.page.wait_for_selector("text=SaaS系统使用引导", state="hidden", timeout=5000)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def logout(self) -> bool:
|
||||||
|
# 等待用户名显示
|
||||||
|
expect(self.page.get_by_text("涛")).to_be_visible(timeout=20000)
|
||||||
|
self.page.get_by_text("涛").click()
|
||||||
|
# 等待退出登录选项显示
|
||||||
|
self.page.wait_for_selector("text=退出登录", state="visible", timeout=10000)
|
||||||
|
|
||||||
|
expect(self.page.get_by_text("退出登录")).to_be_visible(timeout=10000)
|
||||||
|
self.page.get_by_text("退出登录").click()
|
||||||
|
# 等待确认对话框显示
|
||||||
|
self.page.wait_for_selector("text=确定要退出登录吗?", state="visible", timeout=10000)
|
||||||
|
|
||||||
|
expect(self.page.get_by_text("确定要退出登录吗?")).to_be_visible(timeout=10000)
|
||||||
|
expect(self.page.get_by_role("button", name="确认")).to_be_visible(timeout=10000)
|
||||||
|
self.page.get_by_role("button", name="确认").click()
|
||||||
|
# 等待退出成功提示显示
|
||||||
|
self.page.wait_for_selector("text=退出成功", state="visible", timeout=10000)
|
||||||
|
|
||||||
|
expect(self.page.get_by_text("退出成功")).to_be_visible(timeout=10000)
|
||||||
|
# 关闭退出成功提示
|
||||||
|
self.page.get_by_role("img").nth(4).click()
|
||||||
|
|
||||||
|
return True
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def result(self, res: requests.Response) -> dict:
|
||||||
|
"""
|
||||||
|
:param res: requests响应对象
|
||||||
|
:return: 格式化后的响应字典
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response_dicts = dict()
|
||||||
|
|
||||||
|
# 响应状态码
|
||||||
|
response_dicts['code'] = res.status_code
|
||||||
|
|
||||||
|
# 响应body
|
||||||
|
response_body = res.json()
|
||||||
|
|
||||||
|
# 确保响应体是字典格式
|
||||||
|
if isinstance(response_body, dict):
|
||||||
|
response_dicts['body'] = response_body
|
||||||
|
|
||||||
|
# 响应秒时间
|
||||||
|
response_dicts['time_total'] = res.elapsed.total_seconds() # 秒为单位
|
||||||
|
|
||||||
|
logging.info(f"【请求响应结果为: {response_dicts}】")
|
||||||
|
return response_dicts
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"响应结果处理异常:{e}")
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import logging
|
||||||
|
|
||||||
|
class Requests:
|
||||||
|
def __init__(self):
|
||||||
|
"""
|
||||||
|
单例模式保证测试过程中使用的都是一个session对象
|
||||||
|
"""
|
||||||
|
self.session = requests.Session()
|
||||||
|
|
||||||
|
def send_request(self,url: str, method: str, data_type: str, headers: dict = None, data: dict = None):
|
||||||
|
"""
|
||||||
|
:param url: 请求url
|
||||||
|
:param method: 请求方法
|
||||||
|
:param data_type: 入参关键字, params(查询参数类型,明文传输,一般在url?参数名=参数值), data(一般用于form表单类型参数), json(一般用于json类型请求参数)
|
||||||
|
:param headers: 请求头
|
||||||
|
:param data: 参数数据,默认等于None
|
||||||
|
:return: 返回res对象
|
||||||
|
"""
|
||||||
|
res = None
|
||||||
|
try:
|
||||||
|
logging.info(f"""
|
||||||
|
【请求地址】Url: {url}
|
||||||
|
【请求类型】Method: {method}
|
||||||
|
【请求头】Headers: {headers}
|
||||||
|
【请求数据】Data: {data}
|
||||||
|
""")
|
||||||
|
if data_type == 'params':
|
||||||
|
res = self.session.request(method=method, url=url, params=data, headers=headers)
|
||||||
|
elif data_type == 'data':
|
||||||
|
res = self.session.request(method=method, url=url, data=data, headers=headers)
|
||||||
|
elif data_type == 'json':
|
||||||
|
res = self.session.request(method=method, url=url, json=data, headers=headers)
|
||||||
|
elif data_type == 'file':
|
||||||
|
res = self.session.request(method=method, url=url, files=data, headers=headers)
|
||||||
|
else:
|
||||||
|
logging.error('parametric_key为params、json、data、file, 不支持其他类型')
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"发送请求异常:{e}")
|
||||||
|
return res
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# -*- coding: utf-8 -*-#
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
from pathlib import Path
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from tools.random_tool import ContextPack
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file='.env',
|
||||||
|
env_file_encoding='utf-8',
|
||||||
|
extra='ignore'
|
||||||
|
)
|
||||||
|
|
||||||
|
BANNER: str = """
|
||||||
|
__ _ _ _____ ___ _____ ____ __ _____
|
||||||
|
/ /\ | | | | | / / \ | | | |_ ( (` | |
|
||||||
|
/_/--\ \_\_/ |_| \_\_/ |_| |_|__ _)_) |_|
|
||||||
|
"""
|
||||||
|
# ===================公共配置======================
|
||||||
|
# 项目的根目录
|
||||||
|
BASE_PATH: Path = Path(__file__).parent.parent
|
||||||
|
# 用例所需测试文件路径
|
||||||
|
CASE_PATH: Path = BASE_PATH / "case"
|
||||||
|
# log路径
|
||||||
|
LOG_PATH: Path = BASE_PATH / "logs" / (ContextPack().get_day_time + '.log')
|
||||||
|
# 项目测试报告
|
||||||
|
REPORT_PATH: Path = BASE_PATH / "reports"
|
||||||
|
# 项目测试报告路径
|
||||||
|
HTML_REPORT_PATH: Path = REPORT_PATH / (ContextPack().get_day_time + '.html')
|
||||||
|
# api数据路径
|
||||||
|
API_EXCEL_FILE: Path = CASE_PATH / "api" / "case.xlsx"
|
||||||
|
API_YAML_PATH: Path = CASE_PATH / "api" / 'case.yaml'
|
||||||
|
# UI截图路径
|
||||||
|
UI_IMG_PATH: Path = REPORT_PATH / "img"
|
||||||
|
|
||||||
|
# ===================环境配置======================
|
||||||
|
TEST_TYPE: str
|
||||||
|
ENVIRONMENT: str
|
||||||
|
ENVIRONMENT_HOST: str
|
||||||
|
ENVIRONMENT_TOKEN: str
|
||||||
|
|
||||||
|
# ===================测试人员======================
|
||||||
|
TESTER: str
|
||||||
|
|
||||||
|
# ===================公共配置======================
|
||||||
|
DELETE_ON_OFF: bool
|
||||||
|
EMAIL_ON_OFF: bool
|
||||||
|
DINGDING_ON_OFF: bool
|
||||||
|
JENKINS_URL: str
|
||||||
|
REPORT_URL: str
|
||||||
|
|
||||||
|
# ===================邮件配置======================
|
||||||
|
EMAIL_SMTP_SERVER: str
|
||||||
|
EMAIL_SENDER: str
|
||||||
|
EMAIL_PASSWORD: str
|
||||||
|
EMAIL_RECEIVER: str
|
||||||
|
|
||||||
|
# ===================钉钉配置======================
|
||||||
|
DINGDING_SECRET: str
|
||||||
|
DINGDING_WEBHOOK: str
|
||||||
|
DINGDING_AT_MOBILES: list[str]
|
||||||
|
|
||||||
|
# ===================浏览器配置======================
|
||||||
|
BROWSER_TYPE: str
|
||||||
|
|
||||||
|
# ===================企业微信配置======================
|
||||||
|
QIWEI_ON_OFF: bool
|
||||||
|
WEBHOOK_URL: str
|
||||||
|
|
||||||
|
@lru_cache()
|
||||||
|
def get_settings():
|
||||||
|
return Settings()
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
locustfile = locust_files/my_locust_file.py
|
||||||
|
headless = true
|
||||||
|
master = true
|
||||||
|
expect-workers = 5
|
||||||
|
host = https://target-system
|
||||||
|
users = 100
|
||||||
|
spawn-rate = 10
|
||||||
|
run-time = 10m
|
||||||
|
tags = [Critical, Normal]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from config.setting import settings
|
||||||
|
|
||||||
|
# 获取Jenkins选项参数(切记参数名称不可以使用中文)
|
||||||
|
# import os
|
||||||
|
# TEST_TYPE = os.environ['TESTTYPE']
|
||||||
|
# ENVIRONMENT: str = os.environ['ENVIRONMENT']
|
||||||
|
# API_HOST: str = os.environ['APIHOST']
|
||||||
|
# TESTER: str = os.environ['TESTER']
|
||||||
|
|
||||||
|
args = [
|
||||||
|
'-sv',
|
||||||
|
f'--html={settings.HTML_REPORT_PATH}',
|
||||||
|
'--self-contained-html', # 静态html, 不需要额外的资源文件
|
||||||
|
f'--log-file={settings.LOG_PATH}',
|
||||||
|
'-W', 'ignore:Module already imported:pytest.PytestWarning'
|
||||||
|
]
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
# 执行测试
|
||||||
|
pytest.main(args)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
[pytest]
|
||||||
|
; addopts = -v -s -q --report=_report.html --tester=Phoenixy --title=测试报告 --desc=报告描述信息 --template=2
|
||||||
|
; addopts = -v -s -q --html=./reports/result.html --self-contained-html
|
||||||
|
|
||||||
|
generate_report_on_test = True
|
||||||
|
environment_table_redact_list = ^foo$
|
||||||
|
.*redact.*
|
||||||
|
bar
|
||||||
|
render_collapsed = failed,error,skipped
|
||||||
|
|
||||||
|
testpaths = test_case/ui
|
||||||
|
python_files = test_*.py *_test .py test*.py
|
||||||
|
python_classes = Test* Test_* test* test_*
|
||||||
|
python_functions = test_* test*
|
||||||
|
|
||||||
|
log_file_level = INFO
|
||||||
|
log_file_format = %(asctime)s | %(levelname)s | %(filename)s - %(funcName)s[line:%(lineno)d]: %(message)s
|
||||||
|
log_file_date_format = %Y-%m-%d %H:%M:%S
|
||||||
|
|
||||||
|
log_cli = True
|
||||||
|
log_cli_level = INFO
|
||||||
|
; log_cli_format = %(asctime)s | %(levelname)s | %(filename)s - %(funcName)s[line:%(lineno)d]: %(message)s
|
||||||
|
; log_cli_date_format=%Y-%m-%d %H:%M:%S
|
||||||
|
|
||||||
|
# --html: 生成html的测试报告,后面 需要跟上所创建的文件位置及文件名称(前提安装插件:pytest-html)
|
||||||
|
; pytest -vs --html ./reports/result.html
|
||||||
|
; # -n: 多线程运行(前提安装插件:pytest-xdist)
|
||||||
|
; pytest -vs -n=2
|
||||||
|
; # --reruns num: 失败重跑(前提安装插件:pytest-rerunfailres)
|
||||||
|
; pytest -vs --reruns=2
|
||||||
|
; # -x: 出现一个用例失败则停止测试
|
||||||
|
; pytest -vs -x
|
||||||
|
; # --maxfail: 出现几个失败才终止
|
||||||
|
; pytest -vs --maxfail=2
|
||||||
|
; # -k: 运行测试用例名称中包含某个字符串的测试用例,我们可以采用or表示或者,采用and表示都
|
||||||
|
; pytest -vs -k "qiuluo"
|
||||||
|
; pytest -vs -k "qiuluo or weiliang"
|
||||||
|
; pytest -vs -k "qiuluo and weiliang"
|
||||||
|
; # -m:冒烟用例执行,后面需要跟一个冒烟名称,执行user_manage这个分组
|
||||||
|
; pytest -vs -m user_manage
|
||||||
|
|
||||||
|
; 下面是解决中文乱码问题:是为了在测试用例收集完成后对测试项的 name 和 nodeid 进行处理,以确保它们在控制台上的显示是正确的。
|
||||||
|
disable_test_id_escaping_and_forfeit_all_rights_to_community_support = True
|
||||||
|
|
||||||
|
filterwarnings =
|
||||||
|
error
|
||||||
|
ignore::UserWarning
|
||||||
|
ignore:function ham\(\) is deprecated:DeprecationWarning
|
||||||
Binary file not shown.
@@ -0,0 +1,119 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import pytest
|
||||||
|
from _pytest.terminal import TerminalReporter
|
||||||
|
import pytest_html
|
||||||
|
import pytest_html.extras
|
||||||
|
from pytest_metadata.plugin import metadata_key
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from config.setting import settings
|
||||||
|
from tools.email_tool import EmailPack
|
||||||
|
from tools.dingding_tool import DingTalkPack
|
||||||
|
from tools.qiwei_tool import QiWeiPack
|
||||||
|
|
||||||
|
# 常量定义
|
||||||
|
TEST_TYPE: str = settings.TEST_TYPE
|
||||||
|
ENVIRONMENT: str = settings.ENVIRONMENT
|
||||||
|
TESTER: str = settings.TESTER
|
||||||
|
|
||||||
|
"""
|
||||||
|
module级别的测试初始化和结果收集
|
||||||
|
class级别的测试初始化和结果收集
|
||||||
|
function级别的测试初始化和结果收集
|
||||||
|
"""
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
def init_session():
|
||||||
|
"""会话级别的测试初始化和结果收集"""
|
||||||
|
if TEST_TYPE not in ['API', 'UI']:
|
||||||
|
raise ValueError("请输入正确的自动化测试类型['API','UI']")
|
||||||
|
if settings.DELETE_ON_OFF == 'True':
|
||||||
|
if os.path.exists(settings.REPORT_PATH):
|
||||||
|
shutil.rmtree(settings.REPORT_PATH)
|
||||||
|
logging.info("历史报告数据清理完成")
|
||||||
|
logging.info(f"{settings.BANNER}\n"
|
||||||
|
f"开始执行 {TEST_TYPE} 自动化测试\n"
|
||||||
|
f"测试环境: {ENVIRONMENT}\n"
|
||||||
|
f"测试人员: {TESTER}")
|
||||||
|
yield
|
||||||
|
logging.info(f"========== {TEST_TYPE} 自动化测试结束 ==========")
|
||||||
|
|
||||||
|
def pytest_terminal_summary(terminalreporter: TerminalReporter, exitstatus: int, config: pytest.Config):
|
||||||
|
"""收集终端测试结果"""
|
||||||
|
stats = terminalreporter.stats
|
||||||
|
result = {
|
||||||
|
'total': terminalreporter._numcollected,
|
||||||
|
'pass_num': len(stats.get('passed', [])),
|
||||||
|
'fail_num': len(stats.get('failed', [])),
|
||||||
|
'skip_num': len(stats.get('skipped', [])),
|
||||||
|
'error_num': len(stats.get('error', [])),
|
||||||
|
'rate': f"{((len(stats.get('passed', [])) / terminalreporter._numcollected * 100) if terminalreporter._numcollected else 0):.2f}%",
|
||||||
|
'duration': f'{round(time.time() - terminalreporter._sessionstarttime, 2)}秒',
|
||||||
|
'reprot_url': settings.REPORT_URL,
|
||||||
|
'jenkins_url': settings.JENKINS_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.info(f"总用例数: {result['total']} | 通过: {result['pass_num']}| 失败: {result['fail_num']} | 跳过: {result['skip_num']} | 错误: {result['error_num']} | 成功率: {result['rate']} | 总耗时: {result['duration']}")
|
||||||
|
|
||||||
|
# 发送邮件
|
||||||
|
if settings.EMAIL_ON_OFF:
|
||||||
|
EmailPack(
|
||||||
|
fromaddr=settings.EMAIL_SENDER,
|
||||||
|
password=settings.EMAIL_PASSWORD,
|
||||||
|
toaddrs=[settings.EMAIL_RECEIVER],
|
||||||
|
server_host=settings.EMAIL_SMTP_SERVER,
|
||||||
|
).send_default_email(
|
||||||
|
title=TEST_TYPE,
|
||||||
|
environment=ENVIRONMENT,
|
||||||
|
tester=TESTER,
|
||||||
|
**result
|
||||||
|
)
|
||||||
|
|
||||||
|
# 发送钉钉消息
|
||||||
|
if settings.DINGDING_ON_OFF:
|
||||||
|
DingTalkPack(settings.DINGDING_WEBHOOK, settings.DINGDING_SECRET).send_dingding(
|
||||||
|
title=TEST_TYPE,
|
||||||
|
environment=ENVIRONMENT,
|
||||||
|
tester=TESTER,
|
||||||
|
**result
|
||||||
|
)
|
||||||
|
|
||||||
|
# 发送企业微信消息
|
||||||
|
if settings.QIWEI_ON_OFF:
|
||||||
|
QiWeiPack(settings.WEBHOOK_URL).send_test_report(
|
||||||
|
title=TEST_TYPE,
|
||||||
|
environment=ENVIRONMENT,
|
||||||
|
tester=TESTER,
|
||||||
|
**result
|
||||||
|
)
|
||||||
|
|
||||||
|
def pytest_html_report_title(report):
|
||||||
|
"""设置报告标题"""
|
||||||
|
report.title = f"{TEST_TYPE}自动化测试报告"
|
||||||
|
|
||||||
|
def pytest_configure(config: pytest.Config):
|
||||||
|
"""测试配置"""
|
||||||
|
config.stash[metadata_key].update({
|
||||||
|
"项目名称": f"{TEST_TYPE} 自动化测试",
|
||||||
|
"测试类型": TEST_TYPE,
|
||||||
|
"测试环境": ENVIRONMENT,
|
||||||
|
"测试人员": TESTER,
|
||||||
|
"开始时间": datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
})
|
||||||
|
|
||||||
|
@pytest.hookimpl(hookwrapper=True)
|
||||||
|
def pytest_runtest_makereport(item, call: pytest.CallInfo):
|
||||||
|
"""处理测试报告"""
|
||||||
|
outcome = yield
|
||||||
|
report: pytest.TestReport = outcome.get_result()
|
||||||
|
|
||||||
|
if report.when == "call" and report.failed:
|
||||||
|
extras = getattr(report, 'extras', [])
|
||||||
|
if os.path.exists(settings.LOG_PATH):
|
||||||
|
extras.append(pytest_html.extras.url(settings.LOG_PATH, name="查看详细日志"))
|
||||||
|
report.extras = extras
|
||||||
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import logging
|
||||||
|
from typing import Dict
|
||||||
|
from config.setting import settings
|
||||||
|
|
||||||
|
from tools.excel_tool import ExcelPack
|
||||||
|
from tools.yaml_tool import YamlPack
|
||||||
|
from tools.manage import Manage
|
||||||
|
|
||||||
|
manage = Manage()
|
||||||
|
excel = ExcelPack(file_name=settings.API_EXCEL_FILE, api_host=settings.ENVIRONMENT_HOST, token=settings.ENVIRONMENT_TOKEN)
|
||||||
|
yaml = YamlPack(yaml_path=settings.API_YAML_PATH, api_host=settings.ENVIRONMENT_HOST, token=settings.ENVIRONMENT_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExcel:
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('case', excel.load_test_cases(), ids=lambda x: x['name'])
|
||||||
|
def test_excel_api(self, case: Dict) -> None:
|
||||||
|
"""Excel数据驱动的API测试类"""
|
||||||
|
logging.info(f"开始执行用例: {case['name']}")
|
||||||
|
|
||||||
|
# 检查是否需要执行
|
||||||
|
if str(case['run']).lower() != 'yes':
|
||||||
|
logging.info(f"跳过用例: {case['name']}")
|
||||||
|
excel.write_to_excel(row=case['row'], result=manage.create_result(case['name'], 'skip'))
|
||||||
|
pytest.skip()
|
||||||
|
|
||||||
|
# 执行测试用例
|
||||||
|
result = manage.execute_test_case(case)
|
||||||
|
|
||||||
|
# 写入结果
|
||||||
|
excel.write_to_excel(row=case['row'], result=result)
|
||||||
|
|
||||||
|
# 断言结果
|
||||||
|
assert result['result'] == 'pass', (
|
||||||
|
f"用例执行失败: {case['name']}\n"
|
||||||
|
f"请求信息: {case['request']}\n"
|
||||||
|
f"期望结果: {case['expected']}\n"
|
||||||
|
f"实际结果: {result['response']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('case', yaml.load_test_cases(), ids=lambda x: x['name'])
|
||||||
|
def test_yaml_api(self, case: Dict) -> None:
|
||||||
|
"""Yaml数据驱动的API测试类"""
|
||||||
|
logging.info(f"开始执行用例: {case['name']}")
|
||||||
|
|
||||||
|
# 检查是否需要执行
|
||||||
|
if not case['run']:
|
||||||
|
logging.info(f"跳过用例: {case['name']}")
|
||||||
|
pytest.skip()
|
||||||
|
|
||||||
|
# 执行测试用例
|
||||||
|
result = manage.execute_test_case(case)
|
||||||
|
|
||||||
|
# 断言结果
|
||||||
|
assert result['result'] == 'pass', (
|
||||||
|
f"用例执行失败: {case['name']}\n"
|
||||||
|
f"请求信息: {case['request']}\n"
|
||||||
|
f"期望结果: {case['expected']}\n"
|
||||||
|
f"实际结果: {result['response']}"
|
||||||
|
)
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import datetime
|
||||||
|
import shutil
|
||||||
|
import pytest
|
||||||
|
from pytest_metadata.plugin import metadata_key
|
||||||
|
from _pytest.terminal import TerminalReporter
|
||||||
|
|
||||||
|
from config.setting import settings
|
||||||
|
from tools.email_tool import EmailPack
|
||||||
|
from tools.dingding_tool import DingTalkPack
|
||||||
|
from tools.qiwei_tool import QiWeiPack
|
||||||
|
|
||||||
|
# 常量定义
|
||||||
|
TEST_TYPE: str = settings.TEST_TYPE
|
||||||
|
ENVIRONMENT: str = settings.ENVIRONMENT
|
||||||
|
TESTER: str = settings.TESTER
|
||||||
|
|
||||||
|
# 状态常量
|
||||||
|
success = "✓"
|
||||||
|
fail = "✗"
|
||||||
|
|
||||||
|
# 全局page对象,用于在整个测试会话中共享
|
||||||
|
GLOBAL_PAGE = None
|
||||||
|
|
||||||
|
"""
|
||||||
|
module级别的测试初始化和结果收集
|
||||||
|
class级别的测试初始化和结果收集
|
||||||
|
function级别的测试初始化和结果收集
|
||||||
|
"""
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
def init_session():
|
||||||
|
"""会话级别的测试初始化和结果收集 - 使用Playwright,确保唯一驱动实例"""
|
||||||
|
if TEST_TYPE not in ['API', 'UI']:
|
||||||
|
raise ValueError("请输入正确的自动化测试类型['API','UI']")
|
||||||
|
if settings.DELETE_ON_OFF == 'True':
|
||||||
|
if settings.REPORT_PATH.exists():
|
||||||
|
shutil.rmtree(settings.REPORT_PATH)
|
||||||
|
logging.info("历史报告数据清理完成")
|
||||||
|
logging.info(
|
||||||
|
f"{settings.BANNER}\n"
|
||||||
|
f"开始执行 {TEST_TYPE} 自动化测试\n"
|
||||||
|
f"测试环境: {ENVIRONMENT}\n"
|
||||||
|
f"测试人员: {TESTER}"
|
||||||
|
)
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
playwright = sync_playwright().start()
|
||||||
|
browser = playwright.chromium.launch(headless=False)
|
||||||
|
context = browser.new_context(record_video_dir="./report/videos/", viewport={"width": 1920, "height": 1080}) # 生成适配视频
|
||||||
|
page = context.new_page()
|
||||||
|
# 更新全局page对象
|
||||||
|
global GLOBAL_PAGE
|
||||||
|
GLOBAL_PAGE = page
|
||||||
|
logging.info(f"{success} ==> Playwright 浏览器实例初始化完成")
|
||||||
|
# 返回page对象供测试用例使用
|
||||||
|
yield page
|
||||||
|
# 清理资源
|
||||||
|
try:
|
||||||
|
context.close()
|
||||||
|
browser.close()
|
||||||
|
playwright.stop()
|
||||||
|
logging.info(f"{success} ==> Playwright 资源已全部释放")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"{fail} ==> Playwright 资源释放失败: {str(e)}")
|
||||||
|
else:
|
||||||
|
logging.info(f"========== {TEST_TYPE} 自动化测试结束 ==========")
|
||||||
|
|
||||||
|
def pytest_terminal_summary(terminalreporter: TerminalReporter, exitstatus: int, config: pytest.Config):
|
||||||
|
"""收集终端测试结果"""
|
||||||
|
stats = terminalreporter.stats
|
||||||
|
result = {
|
||||||
|
'total': terminalreporter._numcollected,
|
||||||
|
'passed': len(stats.get('passed', [])),
|
||||||
|
'failed': len(stats.get('failed', [])),
|
||||||
|
'skipped': len(stats.get('skipped', [])),
|
||||||
|
'error': len(stats.get('error', [])),
|
||||||
|
'success_rate': f"{((len(stats.get('passed', [])) / terminalreporter._numcollected * 100) if terminalreporter._numcollected else 0):.2f}%",
|
||||||
|
'duration': f'{round(time.time() - terminalreporter._sessionstarttime, 2)}秒',
|
||||||
|
'reprot_url': settings.REPORT_URL,
|
||||||
|
'jenkins_url': settings.JENKINS_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.info(f"总用例数: {result['total']} | 通过: {result['passed']}| 失败: {result['failed']} | 跳过: {result['skipped']} | 错误: {result['error']} | 成功率: {result['success_rate']} | 总耗时: {result['duration']}")
|
||||||
|
|
||||||
|
# 发送邮件
|
||||||
|
if settings.EMAIL_ON_OFF:
|
||||||
|
EmailPack(
|
||||||
|
fromaddr=settings.EMAIL_SENDER,
|
||||||
|
password=settings.EMAIL_PASSWORD,
|
||||||
|
toaddrs=[settings.EMAIL_RECEIVER],
|
||||||
|
server_host=settings.EMAIL_SMTP_SERVER
|
||||||
|
).send_default_email(
|
||||||
|
title=TEST_TYPE,
|
||||||
|
environment=ENVIRONMENT,
|
||||||
|
tester=TESTER,
|
||||||
|
**result
|
||||||
|
)
|
||||||
|
|
||||||
|
# 发送钉钉消息
|
||||||
|
if settings.DINGDING_ON_OFF:
|
||||||
|
DingTalkPack(settings.DINGDING_WEBHOOK, settings.DINGDING_SECRET).send_dingding(
|
||||||
|
title=TEST_TYPE,
|
||||||
|
environment=ENVIRONMENT,
|
||||||
|
tester=TESTER,
|
||||||
|
**result
|
||||||
|
)
|
||||||
|
|
||||||
|
# 发送企业微信消息
|
||||||
|
if settings.QIWEI_ON_OFF:
|
||||||
|
# 调整参数名以匹配企业微信工具的要求
|
||||||
|
qiwei_result = {
|
||||||
|
'total': result['total'],
|
||||||
|
'pass_num': result['passed'],
|
||||||
|
'fail_num': result['failed'],
|
||||||
|
'error_num': result['error'],
|
||||||
|
'skip_num': result['skipped'],
|
||||||
|
'rate': result['success_rate'],
|
||||||
|
'duration': result['duration'],
|
||||||
|
'report_url': result['reprot_url'], # 修正拼写错误
|
||||||
|
'jenkins_url': result['jenkins_url']
|
||||||
|
}
|
||||||
|
QiWeiPack(settings.WEBHOOK_URL).send_test_report(
|
||||||
|
title=TEST_TYPE,
|
||||||
|
environment=ENVIRONMENT,
|
||||||
|
tester=TESTER,
|
||||||
|
**qiwei_result
|
||||||
|
)
|
||||||
|
|
||||||
|
def pytest_html_report_title(report):
|
||||||
|
"""设置报告标题"""
|
||||||
|
report.title = f"{TEST_TYPE}自动化测试报告"
|
||||||
|
|
||||||
|
def pytest_configure(config: pytest.Config):
|
||||||
|
"""测试配置"""
|
||||||
|
config.stash[metadata_key].update({
|
||||||
|
"项目名称": f"{TEST_TYPE} 自动化测试",
|
||||||
|
"测试类型": TEST_TYPE,
|
||||||
|
"测试环境": ENVIRONMENT,
|
||||||
|
"测试人员": TESTER,
|
||||||
|
"开始时间": datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
})
|
||||||
|
|
||||||
|
@pytest.hookimpl(hookwrapper=True)
|
||||||
|
def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo):
|
||||||
|
pytest_html = item.config.pluginmanager.getplugin('html')
|
||||||
|
outcome = yield
|
||||||
|
report: pytest.TestReport = outcome.get_result()
|
||||||
|
extras = getattr(report, 'extras', [])
|
||||||
|
extras.append(pytest_html.extras.url(str(settings.LOG_PATH), name="查看运行日志"))
|
||||||
|
if report.when == 'call' or report.when == "setup":
|
||||||
|
xfail = hasattr(report, 'wasxfail')
|
||||||
|
if (report.skipped and xfail) or (report.failed and not xfail):
|
||||||
|
file_name = report.nodeid.replace("::", "_")+".png"
|
||||||
|
# 使用全局page对象
|
||||||
|
base64_img = screenshot_base64()
|
||||||
|
if file_name:
|
||||||
|
html = """
|
||||||
|
<div>
|
||||||
|
<img src="data:image/png;base64,%s" alt="screenshot" style="width:460px;height:230px;" align="right" onclick="window.open(this.src)"/>
|
||||||
|
</div>
|
||||||
|
""" % base64_img
|
||||||
|
extras.append(pytest_html.extras.html(html))
|
||||||
|
report.extras = extras
|
||||||
|
report.description = str(item.function.__doc__)
|
||||||
|
|
||||||
|
def screenshot_base64():
|
||||||
|
"""获取当前页面的截图base64编码,使用全局page对象"""
|
||||||
|
# 生成简洁的截图文件名(年月日时分秒格式)
|
||||||
|
settings.UI_IMG_PATH.mkdir(parents=True, exist_ok=True)
|
||||||
|
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
screenshot_path = settings.UI_IMG_PATH / f"{timestamp}.png"
|
||||||
|
# 使用全局page对象
|
||||||
|
global GLOBAL_PAGE
|
||||||
|
page = GLOBAL_PAGE
|
||||||
|
# 检查page对象是否存在
|
||||||
|
if page and hasattr(page, 'screenshot'):
|
||||||
|
try:
|
||||||
|
screenshot_bytes = page.screenshot(path=screenshot_path)
|
||||||
|
logging.info(f"{success} ==> 保存失败截图: {screenshot_path}")
|
||||||
|
return base64.b64encode(screenshot_bytes).decode('utf-8')
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"{fail} ==> 截图失败: {str(e)}")
|
||||||
|
return ''
|
||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from playwright.sync_api import Page
|
||||||
|
from case.ui.pages.base_page import BasePage
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures('init_session')
|
||||||
|
class TestUI:
|
||||||
|
|
||||||
|
# 定义测试数据
|
||||||
|
TEST_DATA = [{参数化1},{参数化2}]
|
||||||
|
|
||||||
|
@pytest.mark.ui
|
||||||
|
@pytest.mark.parametrize("test_data", TEST_DATA)
|
||||||
|
def test_login_scenarios(self, init_session: Page, test_data: Dict[str, Any]):
|
||||||
|
"""参数化测试不同的登录场景"""
|
||||||
|
emposat_page = BasePage(init_session)
|
||||||
|
|
||||||
|
login_result = emposat_page.login(test_data=test_data)
|
||||||
|
assert login_result == True, "登录失败"
|
||||||
|
|
||||||
|
logout_result = emposat_page.logout()
|
||||||
|
assert logout_result == True, "退出登录失败"
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# -*- coding: utf-8 -*-#
|
||||||
|
|
||||||
|
# 钉钉发送消息模块
|
||||||
|
from typing import Any
|
||||||
|
from dingtalkchatbot.chatbot import DingtalkChatbot, FeedLink
|
||||||
|
|
||||||
|
class DingTalkPack:
|
||||||
|
|
||||||
|
def __init__(self, webhook: str, secret: str, at_mobiles: list = None):
|
||||||
|
|
||||||
|
self.webhook=webhook
|
||||||
|
self.secret=secret
|
||||||
|
self.at_mobiles=at_mobiles
|
||||||
|
|
||||||
|
self.ding_news = DingtalkChatbot(webhook=self.webhook, secret=self.secret, pc_slide=False, fail_notice=False)
|
||||||
|
|
||||||
|
def send_text(self, msg: str, mobiles: list = None) -> None:
|
||||||
|
"""
|
||||||
|
发送文本信息
|
||||||
|
:param msg: 文本内容
|
||||||
|
:param mobiles: 用户电话
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
if not mobiles:
|
||||||
|
self.ding_news.send_text(msg=msg, is_at_all=True)
|
||||||
|
else:
|
||||||
|
if isinstance(mobiles, list):
|
||||||
|
self.ding_news.send_text(msg=msg, at_mobiles=mobiles)
|
||||||
|
else:
|
||||||
|
raise TypeError("mobiles类型错误 不是list类型.")
|
||||||
|
|
||||||
|
def send_link(self, title: str, text: str, message_url: str, pic_url: str) -> None:
|
||||||
|
"""
|
||||||
|
发送link通知
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.ding_news.send_link(title=title, text=text, message_url=message_url, pic_url=pic_url)
|
||||||
|
except Exception:
|
||||||
|
raise
|
||||||
|
|
||||||
|
def send_markdown(self, title: str, msg: str, mobiles: list = None) -> None:
|
||||||
|
"""
|
||||||
|
:param mobiles:
|
||||||
|
:param title:
|
||||||
|
:param msg:
|
||||||
|
markdown 格式
|
||||||
|
"""
|
||||||
|
if mobiles is None:
|
||||||
|
self.ding_news.send_markdown(title=title, text=msg, is_at_all=True)
|
||||||
|
else:
|
||||||
|
if isinstance(mobiles, list):
|
||||||
|
self.ding_news.send_markdown(title=title, text=msg, at_mobiles=mobiles)
|
||||||
|
else:
|
||||||
|
raise TypeError("mobiles类型错误 不是list类型.")
|
||||||
|
|
||||||
|
def feed_link(self, title: str, message_url: str, pic_url: str) -> Any:
|
||||||
|
"""
|
||||||
|
发送link类型
|
||||||
|
:param title:
|
||||||
|
:param message_url:
|
||||||
|
:param pic_url:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return FeedLink(title=title, message_url=message_url, pic_url=pic_url)
|
||||||
|
|
||||||
|
def send_feed_link(self, *arg) -> None:
|
||||||
|
"""
|
||||||
|
发送feedlink类型
|
||||||
|
:param arg:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.ding_news.send_feed_card(list(arg))
|
||||||
|
except Exception:
|
||||||
|
raise
|
||||||
|
|
||||||
|
def send_dingding(self, title, environment, tester, total, pass_num, fail_num, error_num, skip_num, rate, duration, reprot_url, jenkins_url):
|
||||||
|
"""
|
||||||
|
发送钉钉通知
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
self.ding_news.send_markdown(
|
||||||
|
title=f'【{title}测试执行完毕提醒',
|
||||||
|
text=f"<font color=\'#FFA500\'>[通知] </font>测试执行完成 \n\n --- \n\n" +
|
||||||
|
"执行环境:<font color=\"#1d953f\">%s </font>" % environment + "\n\n" +
|
||||||
|
"执行人员:<font color=\"#f2eada\">@%s</font>" % tester + "\n\n --- \n\n" +
|
||||||
|
"运行总数:<font color=\"#d71345\">%s </font>" % total + "\n\n" +
|
||||||
|
"通过数量:<font color=\"#1d953f\">%s </font>" % pass_num + "\n\n" +
|
||||||
|
"失败数量:<font color=\"#c63c26\">%s </font>" % fail_num + "\n\n" +
|
||||||
|
"异常数量:<font color=\"#fdb933\">%s </font>" % error_num + "\n\n" +
|
||||||
|
"跳过数量:<font color=\"#2585a6\">%s </font>" % skip_num + "\n\n" +
|
||||||
|
"成功率为:<font color=\"#1d953f\">%s </font>" % rate + "\n\n" +
|
||||||
|
"运行时间:<font color=\"#464547\">%s </font>" % duration + "\n\n" +
|
||||||
|
"报告详情:[点击查看](%s)" % reprot_url + "\n\n" +
|
||||||
|
"构建地址:[点击查看](%s) 详细情况可登录jenkins平台查看,非相关负责人员可忽略此消息。谢谢。" % jenkins_url ,
|
||||||
|
at_mobiles=self.at_mobiles
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import smtplib
|
||||||
|
import time
|
||||||
|
from email.header import Header
|
||||||
|
from email.mime.application import MIMEApplication
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
class EmailPack:
|
||||||
|
# 初始化发件人,密码,收件人列表
|
||||||
|
def __init__(self, fromaddr: str, password: str, toaddrs: list, server_host: str):
|
||||||
|
"""
|
||||||
|
:param REPORT_END_PATH:
|
||||||
|
"""
|
||||||
|
self.fromaddr = fromaddr
|
||||||
|
self.password = password
|
||||||
|
self.toaddrs = toaddrs
|
||||||
|
self.server_host = server_host
|
||||||
|
|
||||||
|
self.server = smtplib.SMTP(self.server_host)
|
||||||
|
self.message = MIMEMultipart()
|
||||||
|
|
||||||
|
# 设置发件人名称,主题,内容,附件
|
||||||
|
def _set_message(self, name: str, title: str, content: str, filelist: list):
|
||||||
|
"""
|
||||||
|
:param name:
|
||||||
|
:param title:
|
||||||
|
:param content:
|
||||||
|
:param filelist:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
tm = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time()))
|
||||||
|
self.message['From'] = Header(f"{name}<{self.fromaddr}>", 'utf-8') # 发件人名称和地址
|
||||||
|
self.message['Subject'] = Header(title + "_" + tm, 'utf-8') # 邮件主题
|
||||||
|
self.message.attach(MIMEText(content)) # 邮件内容
|
||||||
|
if filelist is not None: # 邮件附件
|
||||||
|
for file in filelist:
|
||||||
|
fileApart = MIMEApplication( open(file, 'rb').read(), file.split('.')[-1])
|
||||||
|
fileApart.add_header('Content-Disposition', 'attachment', filename=file.split("\\")[-1])
|
||||||
|
self.message.attach(fileApart)
|
||||||
|
|
||||||
|
# 发送邮件
|
||||||
|
def _send_message(self):
|
||||||
|
"""
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.server.login(self.fromaddr, self.password)
|
||||||
|
self.server.sendmail(self.fromaddr, self.toaddrs, self.message.as_string())
|
||||||
|
logging.info(f'【邮件发送成功!收件人:{self.toaddrs}】')
|
||||||
|
self.server.quit()
|
||||||
|
except smtplib.SMTPException as e:
|
||||||
|
logging.error(f'【邮件发送异常!{e}】')
|
||||||
|
|
||||||
|
# 默认发送邮件
|
||||||
|
def send_default_email(self, title: str, environment: str, tester: str, total: int, pass_num: int, fail_num: int, error_num: int, skip_num: int, rate: str, duration: str, reprot_url: str, jenkins_url: str, report_path: list):
|
||||||
|
"""
|
||||||
|
:rtype: object
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
|
||||||
|
self._set_message(
|
||||||
|
name="自动化测试",
|
||||||
|
title=f'{title}测试执行完毕提醒!',
|
||||||
|
content=f'''
|
||||||
|
各位同事, 大家好:
|
||||||
|
自动化用例执行完成,执行结果如下:
|
||||||
|
**********************************
|
||||||
|
执行环境: {environment}
|
||||||
|
执行人员: {tester}
|
||||||
|
运行总数: {total}
|
||||||
|
通过: {pass_num}
|
||||||
|
失败: {fail_num}
|
||||||
|
异常: {error_num}
|
||||||
|
跳过: {skip_num}
|
||||||
|
成功率: {rate}
|
||||||
|
总耗时: {duration}
|
||||||
|
报告详情: [点击查看]({reprot_url})
|
||||||
|
**********************************
|
||||||
|
jenkins地址:{jenkins_url}
|
||||||
|
详细情况可登录jenkins平台查看,非相关负责人员可忽略此消息。谢谢。
|
||||||
|
''',
|
||||||
|
filelist=[report_path]
|
||||||
|
)
|
||||||
|
self._send_message()
|
||||||
|
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
import pandas as pd
|
||||||
|
from openpyxl.styles import Font
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
import logging
|
||||||
|
|
||||||
|
class ExcelPack:
|
||||||
|
# Excel列结构配置
|
||||||
|
COLUMNS = {
|
||||||
|
'name': 0,
|
||||||
|
'run': 1,
|
||||||
|
'token': 2,
|
||||||
|
'url': 3,
|
||||||
|
'data': 4,
|
||||||
|
'method': 5,
|
||||||
|
'type': 6,
|
||||||
|
'header': 7,
|
||||||
|
'code': 8,
|
||||||
|
'isSuccess': 9,
|
||||||
|
'result': 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 结果样式配置
|
||||||
|
STYLES = {
|
||||||
|
'pass': (3, '00FF00'), # 绿色
|
||||||
|
'fail': (4, 'FF0000'), # 红色
|
||||||
|
'skip': (6, '800080'), # 紫色
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, file_name: str, api_host: str, token: str):
|
||||||
|
self.file_name = file_name
|
||||||
|
self._df = None
|
||||||
|
self.api_host = api_host
|
||||||
|
self.token = token
|
||||||
|
|
||||||
|
@property
|
||||||
|
def df(self) -> pd.DataFrame:
|
||||||
|
if self._df is None:
|
||||||
|
self._df = pd.read_excel(self.file_name)
|
||||||
|
logging.info(f"成功读取Excel文件: {self.file_name}")
|
||||||
|
return self._df
|
||||||
|
|
||||||
|
def _get_cell_value(self, row: int, col_name: str) -> str:
|
||||||
|
"""统一的单元格获取方法"""
|
||||||
|
value = self.df.iloc[row, self.COLUMNS[col_name]]
|
||||||
|
return str(value).strip() if pd.notna(value) else ''
|
||||||
|
|
||||||
|
def _write_cell(self, row: int, col_name: str, value: str, style: Optional[int] = None):
|
||||||
|
"""增量更新单元格内容,保留其他数据格式"""
|
||||||
|
|
||||||
|
# 更新DataFrame中的值
|
||||||
|
col = self.COLUMNS[col_name]
|
||||||
|
self.df.iloc[row, col] = value
|
||||||
|
|
||||||
|
# 直接加载工作簿进行更新
|
||||||
|
workbook = load_workbook(self.file_name)
|
||||||
|
worksheet = workbook.active
|
||||||
|
|
||||||
|
# 写入新值
|
||||||
|
cell = worksheet.cell(row=row + 2, column=col + 1)
|
||||||
|
cell.value = value
|
||||||
|
|
||||||
|
# 应用样式
|
||||||
|
if style and isinstance(style, (int, str)):
|
||||||
|
style_config = self.STYLES.get(str(style))
|
||||||
|
if style_config and len(style_config) >= 2:
|
||||||
|
color = style_config[1]
|
||||||
|
cell.font = Font(bold=True, color=color)
|
||||||
|
else:
|
||||||
|
logging.warning(f"无效的样式配置: {style}")
|
||||||
|
|
||||||
|
# 保存工作簿
|
||||||
|
workbook.save(self.file_name)
|
||||||
|
|
||||||
|
def load_test_cases(self) -> List[Dict]:
|
||||||
|
"""加载测试用例"""
|
||||||
|
test_cases = []
|
||||||
|
|
||||||
|
for row in range(1, len(self.df)):
|
||||||
|
headers = eval(self._get_cell_value(row, 'header')) if self._get_cell_value(row, 'header') else {}
|
||||||
|
if self._get_cell_value(row, 'token').lower() == 'yes':
|
||||||
|
headers['token'] = self.token
|
||||||
|
|
||||||
|
case = {
|
||||||
|
'name': self._get_cell_value(row, 'name'),
|
||||||
|
'run': self._get_cell_value(row, 'run'),
|
||||||
|
'token': self._get_cell_value(row, 'token'),
|
||||||
|
'type': self._get_cell_value(row, 'type'),
|
||||||
|
'request': {
|
||||||
|
'url': f"{self.api_host}{self._get_cell_value(row, 'url')}",
|
||||||
|
'method': self._get_cell_value(row, 'method'),
|
||||||
|
'headers': headers,
|
||||||
|
'data': json.loads(self._get_cell_value(row, 'data')) if self._get_cell_value(row, 'data') else None,
|
||||||
|
},
|
||||||
|
'expected': {
|
||||||
|
'code': int(self._get_cell_value(row, 'code')),
|
||||||
|
'isSuccess': bool(self._get_cell_value(row, 'isSuccess')),
|
||||||
|
},
|
||||||
|
'result': {
|
||||||
|
'result': self._get_cell_value(row, 'result'),
|
||||||
|
},
|
||||||
|
'row': row,
|
||||||
|
}
|
||||||
|
test_cases.append(case)
|
||||||
|
return test_cases
|
||||||
|
|
||||||
|
def write_to_excel(self, row: int, result: Dict) -> None:
|
||||||
|
"""写入结果到Excel"""
|
||||||
|
try:
|
||||||
|
# 更新数据
|
||||||
|
self._write_cell(row, 'result', result['result'], result['result'])
|
||||||
|
logging.info(f"测试结果写入成功: {result['name']}")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"写入测试结果失败: {str(e)}")
|
||||||
|
logging.exception(e)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
from common.reponse_tool import Response
|
||||||
|
from common.request_tool import Requests
|
||||||
|
|
||||||
|
|
||||||
|
class Manage:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def create_result(self, name: str, status: str = 'skip', response: str = None) -> Dict:
|
||||||
|
"""创建统一的结果对象"""
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'result': status,
|
||||||
|
'response': response
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute_test_case(self, case: Dict) -> Dict:
|
||||||
|
"""执行测试用例"""
|
||||||
|
try:
|
||||||
|
# 构建请求数据
|
||||||
|
request_data = {
|
||||||
|
'url': case['request']['url'],
|
||||||
|
'method': case['request']['method'],
|
||||||
|
'data': case['request']['data'],
|
||||||
|
'headers': case['request']['headers'],
|
||||||
|
'data_type': case.get('type', 'json')
|
||||||
|
}
|
||||||
|
|
||||||
|
# 发送请求并获取响应
|
||||||
|
response = Response().result(Requests().send_request(**request_data))
|
||||||
|
|
||||||
|
# 断言
|
||||||
|
# 情况1:如果响应体包含'isSuccess'字段(按预期格式)
|
||||||
|
if case['expected']['isSuccess'] and case['expected']['code'] == response['code']:
|
||||||
|
status = 'pass'
|
||||||
|
else:
|
||||||
|
status = 'fail'
|
||||||
|
|
||||||
|
result = self.create_result(case['name'], status, response)
|
||||||
|
logging.info(f"用例执行完成: {case['name']}, 结果: {status}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"执行用例异常: {str(e)}"
|
||||||
|
logging.error(error_msg)
|
||||||
|
result = self.create_result(case['name'], 'fail', error_msg)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
|
||||||
|
|
||||||
|
# -*- coding: utf-8 -*-#
|
||||||
|
|
||||||
|
# 企业微信发送消息模块
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
from typing import Any, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class QiWeiPack:
|
||||||
|
"""
|
||||||
|
企业微信机器人消息发送封装类
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, webhook_url: str):
|
||||||
|
"""
|
||||||
|
初始化企业微信机器人
|
||||||
|
:param webhook_url: 企业微信机器人的webhook地址
|
||||||
|
"""
|
||||||
|
self.webhook_url = webhook_url
|
||||||
|
self.headers = {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
def _send_request(self, data: dict) -> dict:
|
||||||
|
"""
|
||||||
|
发送请求到企业微信API
|
||||||
|
:param data: 消息数据
|
||||||
|
:return: 响应结果
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
self.webhook_url,
|
||||||
|
data=json.dumps(data),
|
||||||
|
headers=self.headers
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"发送企业微信消息失败: {str(e)}")
|
||||||
|
|
||||||
|
def send_text(self, content: str, mentioned_list: Optional[List[str]] = None, mentioned_mobile_list: Optional[List[str]] = None) -> dict:
|
||||||
|
"""
|
||||||
|
发送文本消息
|
||||||
|
:param content: 文本内容
|
||||||
|
:param mentioned_list: @的用户列表,如["user1", "user2"]
|
||||||
|
:param mentioned_mobile_list: @的手机号列表,如["13800138000"]
|
||||||
|
:return: 响应结果
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
"msgtype": "text",
|
||||||
|
"text": {
|
||||||
|
"content": content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mentioned_list:
|
||||||
|
data["text"]["mentioned_list"] = mentioned_list
|
||||||
|
if mentioned_mobile_list:
|
||||||
|
data["text"]["mentioned_mobile_list"] = mentioned_mobile_list
|
||||||
|
|
||||||
|
return self._send_request(data)
|
||||||
|
|
||||||
|
def send_markdown(self, content: str) -> dict:
|
||||||
|
"""
|
||||||
|
发送markdown消息
|
||||||
|
:param content: markdown内容
|
||||||
|
:return: 响应结果
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
"msgtype": "markdown",
|
||||||
|
"markdown": {
|
||||||
|
"content": content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self._send_request(data)
|
||||||
|
|
||||||
|
def send_image(self, base64: str, md5: str) -> dict:
|
||||||
|
"""
|
||||||
|
发送图片消息
|
||||||
|
:param base64: 图片base64编码
|
||||||
|
:param md5: 图片md5值
|
||||||
|
:return: 响应结果
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
"msgtype": "image",
|
||||||
|
"image": {
|
||||||
|
"base64": base64,
|
||||||
|
"md5": md5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self._send_request(data)
|
||||||
|
|
||||||
|
def send_news(self, articles: List[dict]) -> dict:
|
||||||
|
"""
|
||||||
|
发送图文消息
|
||||||
|
:param articles: 图文列表,每个元素包含title、description、url、picurl字段
|
||||||
|
:return: 响应结果
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
"msgtype": "news",
|
||||||
|
"news": {
|
||||||
|
"articles": articles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self._send_request(data)
|
||||||
|
|
||||||
|
def send_file(self, media_id: str) -> dict:
|
||||||
|
"""
|
||||||
|
发送文件消息
|
||||||
|
:param media_id: 文件媒体ID(需要先上传文件获取)
|
||||||
|
:return: 响应结果
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
"msgtype": "file",
|
||||||
|
"file": {
|
||||||
|
"media_id": media_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self._send_request(data)
|
||||||
|
|
||||||
|
def send_taskcard(self, title: str, description: str, url: str, btn_json_list: List[dict]) -> dict:
|
||||||
|
"""
|
||||||
|
发送任务卡片消息
|
||||||
|
:param title: 标题
|
||||||
|
:param description: 描述
|
||||||
|
:param url: 点击卡片跳转的URL
|
||||||
|
:param btn_json_list: 按钮列表
|
||||||
|
:return: 响应结果
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
"msgtype": "taskcard",
|
||||||
|
"taskcard": {
|
||||||
|
"title": title,
|
||||||
|
"description": description,
|
||||||
|
"url": url,
|
||||||
|
"btn_json_list": btn_json_list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self._send_request(data)
|
||||||
|
|
||||||
|
def send_test_report(self, title: str, environment: str, tester: str, total: int, pass_num: int,
|
||||||
|
fail_num: int, error_num: int, skip_num: int, rate: str, duration: str,
|
||||||
|
report_url: str, jenkins_url: str) -> dict:
|
||||||
|
"""
|
||||||
|
发送测试报告通知
|
||||||
|
:param title: 测试标题
|
||||||
|
:param environment: 执行环境
|
||||||
|
:param tester: 执行人员
|
||||||
|
:param total: 运行总数
|
||||||
|
:param pass_num: 通过数量
|
||||||
|
:param fail_num: 失败数量
|
||||||
|
:param error_num: 异常数量
|
||||||
|
:param skip_num: 跳过数量
|
||||||
|
:param rate: 成功率
|
||||||
|
:param duration: 运行时间
|
||||||
|
:param report_url: 报告地址
|
||||||
|
:param jenkins_url: Jenkins地址
|
||||||
|
:return: 响应结果
|
||||||
|
"""
|
||||||
|
# 企业微信markdown支持的颜色有限,这里使用emoji和加粗来突出显示
|
||||||
|
content = (
|
||||||
|
f"## {title}测试执行完毕提醒\n\n" +
|
||||||
|
"📢 **测试执行完成**\n\n" +
|
||||||
|
"---\n\n" +
|
||||||
|
f"**执行环境:** {environment}\n\n" +
|
||||||
|
f"**执行人员:** {tester}\n\n" +
|
||||||
|
"---\n\n" +
|
||||||
|
f"**运行总数:** {total}\n\n" +
|
||||||
|
f"**通过数量:** ✅ {pass_num}\n\n" +
|
||||||
|
f"**失败数量:** ❌ {fail_num}\n\n" +
|
||||||
|
f"**异常数量:** ⚠️ {error_num}\n\n" +
|
||||||
|
f"**跳过数量:** ⏭️ {skip_num}\n\n" +
|
||||||
|
f"**成功率:** {rate}\n\n" +
|
||||||
|
f"**运行时间:** {duration}\n\n" +
|
||||||
|
f"**报告详情:** [点击查看]({report_url})\n\n" +
|
||||||
|
f"**构建地址:** [点击查看]({jenkins_url})\n\n" +
|
||||||
|
"详细情况可登录Jenkins平台查看,非相关负责人员可忽略此消息。谢谢。"
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.send_markdown(content)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import string
|
||||||
|
import random
|
||||||
|
import datetime
|
||||||
|
from faker import Faker
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
|
||||||
|
class ContextPack:
|
||||||
|
def __init__(self):
|
||||||
|
self.faker = Faker(locale='zh_CN')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def get_phone(self) -> int:
|
||||||
|
"""
|
||||||
|
:return: 随机生成手机号码
|
||||||
|
"""
|
||||||
|
phone = self.faker.phone_number()
|
||||||
|
return phone
|
||||||
|
|
||||||
|
@property
|
||||||
|
def get_id_number(self) -> int:
|
||||||
|
"""
|
||||||
|
|
||||||
|
:return: 随机生成身份证号码
|
||||||
|
"""
|
||||||
|
|
||||||
|
id_number = self.faker.ssn()
|
||||||
|
return id_number
|
||||||
|
|
||||||
|
@property
|
||||||
|
def get_female_name(self) -> str:
|
||||||
|
"""
|
||||||
|
|
||||||
|
:return: 女生姓名
|
||||||
|
"""
|
||||||
|
female_name = self.faker.name_male()
|
||||||
|
return female_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def get_male_name(self) -> str:
|
||||||
|
"""
|
||||||
|
|
||||||
|
:return: 男生姓名
|
||||||
|
"""
|
||||||
|
male_name = self.faker.name_female()
|
||||||
|
return male_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def get_email(self) -> str:
|
||||||
|
"""
|
||||||
|
|
||||||
|
:return: 生成邮箱
|
||||||
|
"""
|
||||||
|
email = self.faker.email()
|
||||||
|
return email
|
||||||
|
|
||||||
|
@property
|
||||||
|
def merchantSelfOperatedShop(self) -> int:
|
||||||
|
"""
|
||||||
|
|
||||||
|
:return: 商家端自营店铺ID
|
||||||
|
"""
|
||||||
|
SelfOperatedShop = 515
|
||||||
|
return SelfOperatedShop
|
||||||
|
|
||||||
|
@property
|
||||||
|
def get_secend_time(self)->datetime:
|
||||||
|
"""
|
||||||
|
计算当前时间: 年-月-日 时:分:秒:毫秒
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return datetime.datetime.now()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def get_day_time(self)->str:
|
||||||
|
"""
|
||||||
|
计算当前时间: 年-月-日
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return datetime.datetime.now().strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
def generate_bank_card(self):
|
||||||
|
"""
|
||||||
|
生成随机银行卡号
|
||||||
|
"""
|
||||||
|
return self.faker.credit_card_number()
|
||||||
|
|
||||||
|
def generate_email(self):
|
||||||
|
"""
|
||||||
|
生成随机邮箱号
|
||||||
|
"""
|
||||||
|
return self.random_str(4) + self.faker.free_email()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def generate_company(self):
|
||||||
|
"""
|
||||||
|
生成随机公司名称
|
||||||
|
"""
|
||||||
|
return self.faker.company_prefix() + self.random_str(5) + '测试' + self.faker.company_suffix()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def generate_name(self):
|
||||||
|
"""
|
||||||
|
生成随机姓名
|
||||||
|
"""
|
||||||
|
return self.faker.name()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def generate_unreal_phone(self):
|
||||||
|
"""
|
||||||
|
生成不存在的手机号
|
||||||
|
"""
|
||||||
|
phone = random.choice(['100', '110', '120']) + \
|
||||||
|
''.join(random.choice('0123456789') for _ in range(8))
|
||||||
|
return phone
|
||||||
|
|
||||||
|
def random_str(self, str_len: int) -> str:
|
||||||
|
"""从a-zA-Z0-9生成制定数量的随机字符
|
||||||
|
|
||||||
|
:param str_len: 字符串长度
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
str_len = int(str_len)
|
||||||
|
except ValueError:
|
||||||
|
raise Exception("调用随机字符失败,[ %s ]长度参数有误!" % str_len)
|
||||||
|
strings = ''.join(random.sample(string.hexdigits, +str_len))
|
||||||
|
return strings
|
||||||
|
|
||||||
|
def random_int(self, scope) -> int:
|
||||||
|
"""获取随机整型数据
|
||||||
|
|
||||||
|
:param scope: 数据范围
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
start_num, end_num = scope.split(",")
|
||||||
|
start_num = int(start_num)
|
||||||
|
end_num = int(end_num)
|
||||||
|
except ValueError:
|
||||||
|
raise Exception("调用随机整数失败,[ %s ]范围参数有误!" % str(scope))
|
||||||
|
if start_num <= end_num:
|
||||||
|
number = random.randint(start_num, end_num)
|
||||||
|
else:
|
||||||
|
number = random.randint(end_num, start_num)
|
||||||
|
return number
|
||||||
|
|
||||||
|
def random_float(self, data) -> float:
|
||||||
|
"""获取随机浮点数据
|
||||||
|
|
||||||
|
:param data: 数组
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
start_num, end_num, accuracy = data.split(",")
|
||||||
|
start_num = int(start_num)
|
||||||
|
end_num = int(end_num)
|
||||||
|
accuracy = int(accuracy)
|
||||||
|
except ValueError:
|
||||||
|
raise Exception("调用随机浮点数失败,[ %s ]范围参数或精度有误!" % data)
|
||||||
|
|
||||||
|
if start_num <= end_num:
|
||||||
|
number = random.uniform(start_num, end_num)
|
||||||
|
else:
|
||||||
|
number = random.uniform(end_num, start_num)
|
||||||
|
number = round(number, accuracy)
|
||||||
|
return number
|
||||||
|
|
||||||
|
def random_choice(self, data):
|
||||||
|
"""获取数组随机值
|
||||||
|
|
||||||
|
:param data: 数组
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
_list = data.split(",")
|
||||||
|
each = random.choice(_list)
|
||||||
|
return each
|
||||||
|
|
||||||
|
def get_date_mark(self, now, mark, num):
|
||||||
|
if 'y' == mark:
|
||||||
|
return now + relativedelta(years=num)
|
||||||
|
elif 'm' == mark:
|
||||||
|
return now + relativedelta(months=num)
|
||||||
|
elif 'd' == mark:
|
||||||
|
return now + relativedelta(days=num)
|
||||||
|
elif 'h' == mark:
|
||||||
|
return now + relativedelta(hours=num)
|
||||||
|
elif 'M' == mark:
|
||||||
|
return now + relativedelta(minutes=num)
|
||||||
|
elif 's' == mark:
|
||||||
|
return now + relativedelta(seconds=num)
|
||||||
|
else:
|
||||||
|
raise Exception("日期字段标识[ %s ]错误, 请使用[年y,月m,日d,时h,分M,秒s]标识!" % mark)
|
||||||
|
|
||||||
|
def generate_date(self, expr=''):
|
||||||
|
"""生成日期对象(不含时分秒)
|
||||||
|
|
||||||
|
:param expr: 日期表达式,如"d-1"代表日期减1
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
today = datetime.date.today()
|
||||||
|
if expr:
|
||||||
|
try:
|
||||||
|
mark = expr[:1]
|
||||||
|
num = int(expr[1:])
|
||||||
|
except (TypeError, NameError):
|
||||||
|
raise Exception("调用生成日期失败,日期表达式[ %s ]有误!" % expr)
|
||||||
|
return self.get_date_mark(today, mark, num)
|
||||||
|
else:
|
||||||
|
return today
|
||||||
|
|
||||||
|
def generate_datetime(self, expr=''):
|
||||||
|
"""生成日期时间对象(含时分秒)
|
||||||
|
|
||||||
|
:param expr: 日期表达式,如"d-1"代表日期减1
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
now = datetime.datetime.now().replace(microsecond=0)
|
||||||
|
if expr:
|
||||||
|
try:
|
||||||
|
mark = expr[:1]
|
||||||
|
num = int(expr[1:])
|
||||||
|
except (TypeError, NameError):
|
||||||
|
raise Exception("调用生成日期失败,日期表达式[ %s ]有误!" % expr)
|
||||||
|
return self.get_date_mark(now, mark, num)
|
||||||
|
else:
|
||||||
|
return now
|
||||||
|
|
||||||
|
def generate_timestamp(self, expr='') -> int:
|
||||||
|
"""生成时间戳(13位)
|
||||||
|
|
||||||
|
:param expr: 日期表达式,如"d-1"代表日期减1
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
datetime_obj = self.generate_datetime(expr)
|
||||||
|
return int(datetime.datetime.timestamp(datetime_obj)) * 1000
|
||||||
|
|
||||||
|
def generate_guid(self) -> str:
|
||||||
|
"""基于MAC地址+时间戳+随机数来生成GUID
|
||||||
|
|
||||||
|
:param:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
return str(uuid.uuid1()).upper()
|
||||||
|
|
||||||
|
def generate_wxid(self):
|
||||||
|
"""基于AUTO标识+26位英文字母大小写+数字生成伪微信ID
|
||||||
|
|
||||||
|
:param:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
return 'AUTO' + ''.join(random.sample(string.ascii_letters + string.digits, 24))
|
||||||
|
|
||||||
|
def generate_noid(self, expr=''):
|
||||||
|
"""基于6位随机数字+出生日期+4位随机数生成伪身份证
|
||||||
|
|
||||||
|
:param expr: 日期表达式,如"d-1"代表日期减1
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
# birthday = generate_date(expr)
|
||||||
|
# birthday = str(birthday).replace('-', '')
|
||||||
|
# return int(str(random.randint(100000, 999999)) + birthday + str(random.randint(1000, 9999)))
|
||||||
|
faker = Faker(locale='zh_CN')
|
||||||
|
return faker.ssn()
|
||||||
|
|
||||||
|
def generate_phone(self):
|
||||||
|
"""基于三大运营商号段+随机数生成伪手机号
|
||||||
|
|
||||||
|
:param:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
ctcc = [133, 153, 173, 177, 180, 181, 189, 191, 193, 199]
|
||||||
|
cucc = [130, 131, 132, 155, 156, 166, 175, 176, 185, 186, 166]
|
||||||
|
cmcc = [134, 135, 136, 137, 138, 139, 147, 150, 151, 152, 157, 158, 159, 172, 178, 182, 183, 184, 187, 188, 198]
|
||||||
|
begin = 10 ** 7
|
||||||
|
end = 10 ** 8 - 1
|
||||||
|
prefix = random.choice(ctcc + cucc + cmcc)
|
||||||
|
return str(prefix) + str(random.randint(begin, end))
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from typing import Dict, List
|
||||||
|
import yaml
|
||||||
|
from string import Template
|
||||||
|
import logging
|
||||||
|
|
||||||
|
class YamlPack:
|
||||||
|
# yaml文件全部内容
|
||||||
|
def __init__(self, yaml_path, api_host: str, token: str):
|
||||||
|
# yaml中的变量
|
||||||
|
variable = {
|
||||||
|
'host': api_host,
|
||||||
|
'token': token
|
||||||
|
}
|
||||||
|
self.pass_num = 0
|
||||||
|
self.fail_num = 0
|
||||||
|
self.yaml_path = yaml_path
|
||||||
|
with open(self.yaml_path, 'r', encoding="utf-8") as f:
|
||||||
|
re = Template(f.read()).substitute(variable)
|
||||||
|
self.data = yaml.safe_load(re)
|
||||||
|
logging.info(f"成功读取Yaml文件: {self.yaml_path}")
|
||||||
|
|
||||||
|
# Test目录下的全部用例
|
||||||
|
def load_test_cases(self) -> List[Dict]:
|
||||||
|
test_list = []
|
||||||
|
case = self.data["Case"]
|
||||||
|
for a in case:
|
||||||
|
for k, v in a.items():
|
||||||
|
if k == "Test":
|
||||||
|
test_list.append(v)
|
||||||
|
return test_list
|
||||||
|
|
||||||
+358
@@ -0,0 +1,358 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal EnableDelayedExpansion
|
||||||
|
|
||||||
|
set "SCRIPT_DIR=%~dp0"
|
||||||
|
set "BACKEND_DIR=%SCRIPT_DIR%backend"
|
||||||
|
set "FRONTEND_DIR=%SCRIPT_DIR%frontend"
|
||||||
|
set "WEB_DIR=%FRONTEND_DIR%\web"
|
||||||
|
set "APP_DIR=%FRONTEND_DIR%\app"
|
||||||
|
set "DOCS_DIR=%FRONTEND_DIR%\docs"
|
||||||
|
|
||||||
|
set "LOG_DIR=%SCRIPT_DIR%logs"
|
||||||
|
mkdir "%LOG_DIR%" 2>nul
|
||||||
|
|
||||||
|
set "BACKEND_LOG=%LOG_DIR%\backend.log"
|
||||||
|
set "WEB_LOG=%LOG_DIR%\web.log"
|
||||||
|
set "APP_LOG=%LOG_DIR%\app.log"
|
||||||
|
set "DOCS_LOG=%LOG_DIR%\docs.log"
|
||||||
|
|
||||||
|
set "BACKEND_PID_FILE=%LOG_DIR%\backend.pid"
|
||||||
|
set "WEB_PID_FILE=%LOG_DIR%\web.pid"
|
||||||
|
set "APP_PID_FILE=%LOG_DIR%\app.pid"
|
||||||
|
set "DOCS_PID_FILE=%LOG_DIR%\docs.pid"
|
||||||
|
|
||||||
|
:: 颜色定义
|
||||||
|
set "RED=[91m"
|
||||||
|
set "GREEN=[92m"
|
||||||
|
set "YELLOW=[93m"
|
||||||
|
set "BLUE=[94m"
|
||||||
|
set "CYAN=[96m"
|
||||||
|
set "RESET=[0m"
|
||||||
|
|
||||||
|
echo %GREEN%========================================%RESET%
|
||||||
|
echo %GREEN%📦 FastapiAdmin 全局启动脚本%RESET%
|
||||||
|
echo %GREEN%========================================%RESET%
|
||||||
|
echo.
|
||||||
|
|
||||||
|
if "%1"=="" goto :show_help
|
||||||
|
if "%1"=="start" goto :start_all
|
||||||
|
if "%1"=="stop" goto :stop_all
|
||||||
|
if "%1"=="restart" goto :restart_all
|
||||||
|
if "%1"=="status" goto :check_status
|
||||||
|
if "%1"=="help" goto :show_help
|
||||||
|
if "%1"=="--help" goto :show_help
|
||||||
|
if "%1"=="-h" goto :show_help
|
||||||
|
|
||||||
|
echo %RED%❌ 未知参数: %1%RESET%
|
||||||
|
goto :show_help
|
||||||
|
|
||||||
|
:show_help
|
||||||
|
echo %BLUE%使用说明:%RESET%
|
||||||
|
echo %GREEN% %~nx0 start %RESET% - 启动所有服务
|
||||||
|
set "YELLOW=[93m"
|
||||||
|
echo %GREEN% %~nx0 stop %RESET% - 停止所有服务
|
||||||
|
set "YELLOW=[93m"
|
||||||
|
echo %GREEN% %~nx0 restart %RESET% - 重启所有服务
|
||||||
|
set "YELLOW=[93m"
|
||||||
|
echo %GREEN% %~nx0 status %RESET% - 检查服务状态
|
||||||
|
set "YELLOW=[93m"
|
||||||
|
echo %GREEN% %~nx0 help %RESET% - 显示此帮助信息
|
||||||
|
set "YELLOW=[93m"
|
||||||
|
echo.
|
||||||
|
echo %BLUE%服务说明:%RESET%
|
||||||
|
echo %GREEN% 🚀 后端服务 %RESET% - FastAPI 后端 (默认端口: 8001)
|
||||||
|
echo %GREEN% 🌐 前端 Web %RESET% - Vue 前端 (默认端口: 5173)
|
||||||
|
echo %GREEN% 📱 前端 App %RESET% - UniApp 应用 (默认端口: 8080)
|
||||||
|
echo %GREEN% 📚 文档服务 %RESET% - VitePress 文档 (默认端口: 5174)
|
||||||
|
echo.
|
||||||
|
echo %BLUE%日志位置:%RESET%
|
||||||
|
echo %GREEN% %LOG_DIR%\%RESET%
|
||||||
|
goto :end
|
||||||
|
|
||||||
|
:start_all
|
||||||
|
echo %CYAN%🚀 开始启动所有服务...%RESET%
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: 启动后端
|
||||||
|
echo %BLUE%🔄 启动后端服务...%RESET%
|
||||||
|
if exist "%BACKEND_PID_FILE%" (
|
||||||
|
set /p PID=<"%BACKEND_PID_FILE%"
|
||||||
|
tasklist /FI "PID eq %PID%" 2>NUL | find /I "%PID%" >NUL
|
||||||
|
if %errorlevel%==0 (
|
||||||
|
echo %YELLOW%⚠️ 后端服务已在运行 (PID: %PID%)%RESET%
|
||||||
|
) else (
|
||||||
|
echo %GREEN%✅ 启动后端服务...%RESET%
|
||||||
|
cd /d "%BACKEND_DIR%"
|
||||||
|
start "FastapiAdmin Backend" /B uv run main.py run --env=dev > "%BACKEND_LOG%" 2>&1
|
||||||
|
:: 获取新启动进程的 PID
|
||||||
|
for /f "tokens=2" %%a in ('tasklist /FI "WINDOWTITLE eq FastapiAdmin Backend" /NH') do (
|
||||||
|
echo %%a > "%BACKEND_PID_FILE%"
|
||||||
|
echo %GREEN%✅ 后端服务已启动 (PID: %%a)%RESET%
|
||||||
|
)
|
||||||
|
cd /d "%SCRIPT_DIR%"
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo %GREEN%✅ 启动后端服务...%RESET%
|
||||||
|
cd /d "%BACKEND_DIR%"
|
||||||
|
start "FastapiAdmin Backend" /B uv run main.py run --env=dev > "%BACKEND_LOG%" 2>&1
|
||||||
|
:: 获取新启动进程的 PID
|
||||||
|
for /f "tokens=2" %%a in ('tasklist /FI "WINDOWTITLE eq FastapiAdmin Backend" /NH') do (
|
||||||
|
echo %%a > "%BACKEND_PID_FILE%"
|
||||||
|
echo %GREEN%✅ 后端服务已启动 (PID: %%a)%RESET%
|
||||||
|
)
|
||||||
|
cd /d "%SCRIPT_DIR%"
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: 启动前端 Web
|
||||||
|
echo %BLUE%🔄 启动前端 Web 服务...%RESET%
|
||||||
|
if exist "%WEB_PID_FILE%" (
|
||||||
|
set /p PID=<"%WEB_PID_FILE%"
|
||||||
|
tasklist /FI "PID eq %PID%" 2>NUL | find /I "%PID%" >NUL
|
||||||
|
if %errorlevel%==0 (
|
||||||
|
echo %YELLOW%⚠️ 前端 Web 服务已在运行 (PID: %PID%)%RESET%
|
||||||
|
) else (
|
||||||
|
echo %GREEN%✅ 启动前端 Web 服务...%RESET%
|
||||||
|
cd /d "%WEB_DIR%"
|
||||||
|
start "FastapiAdmin Web" /B pnpm dev > "%WEB_LOG%" 2>&1
|
||||||
|
:: 获取新启动进程的 PID
|
||||||
|
for /f "tokens=2" %%a in ('tasklist /FI "WINDOWTITLE eq FastapiAdmin Web" /NH') do (
|
||||||
|
echo %%a > "%WEB_PID_FILE%"
|
||||||
|
echo %GREEN%✅ 前端 Web 服务已启动 (PID: %%a)%RESET%
|
||||||
|
)
|
||||||
|
cd /d "%SCRIPT_DIR%"
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo %GREEN%✅ 启动前端 Web 服务...%RESET%
|
||||||
|
cd /d "%WEB_DIR%"
|
||||||
|
start "FastapiAdmin Web" /B pnpm dev > "%WEB_LOG%" 2>&1
|
||||||
|
:: 获取新启动进程的 PID
|
||||||
|
for /f "tokens=2" %%a in ('tasklist /FI "WINDOWTITLE eq FastapiAdmin Web" /NH') do (
|
||||||
|
echo %%a > "%WEB_PID_FILE%"
|
||||||
|
echo %GREEN%✅ 前端 Web 服务已启动 (PID: %%a)%RESET%
|
||||||
|
)
|
||||||
|
cd /d "%SCRIPT_DIR%"
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: 启动前端 App
|
||||||
|
echo %BLUE%🔄 启动前端 App 服务...%RESET%
|
||||||
|
if exist "%APP_PID_FILE%" (
|
||||||
|
set /p PID=<"%APP_PID_FILE%"
|
||||||
|
tasklist /FI "PID eq %PID%" 2>NUL | find /I "%PID%" >NUL
|
||||||
|
if %errorlevel%==0 (
|
||||||
|
echo %YELLOW%⚠️ 前端 App 服务已在运行 (PID: %PID%)%RESET%
|
||||||
|
) else (
|
||||||
|
echo %GREEN%✅ 启动前端 App 服务...%RESET%
|
||||||
|
cd /d "%APP_DIR%"
|
||||||
|
start "FastapiAdmin App" /B pnpm dev:h5 > "%APP_LOG%" 2>&1
|
||||||
|
:: 获取新启动进程的 PID
|
||||||
|
for /f "tokens=2" %%a in ('tasklist /FI "WINDOWTITLE eq FastapiAdmin App" /NH') do (
|
||||||
|
echo %%a > "%APP_PID_FILE%"
|
||||||
|
echo %GREEN%✅ 前端 App 服务已启动 (PID: %%a)%RESET%
|
||||||
|
)
|
||||||
|
cd /d "%SCRIPT_DIR%"
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo %GREEN%✅ 启动前端 App 服务...%RESET%
|
||||||
|
cd /d "%APP_DIR%"
|
||||||
|
start "FastapiAdmin App" /B pnpm dev:h5 > "%APP_LOG%" 2>&1
|
||||||
|
:: 获取新启动进程的 PID
|
||||||
|
for /f "tokens=2" %%a in ('tasklist /FI "WINDOWTITLE eq FastapiAdmin App" /NH') do (
|
||||||
|
echo %%a > "%APP_PID_FILE%"
|
||||||
|
echo %GREEN%✅ 前端 App 服务已启动 (PID: %%a)%RESET%
|
||||||
|
)
|
||||||
|
cd /d "%SCRIPT_DIR%"
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: 启动文档服务
|
||||||
|
echo %BLUE%🔄 启动文档服务...%RESET%
|
||||||
|
if exist "%DOCS_PID_FILE%" (
|
||||||
|
set /p PID=<"%DOCS_PID_FILE%"
|
||||||
|
tasklist /FI "PID eq %PID%" 2>NUL | find /I "%PID%" >NUL
|
||||||
|
if %errorlevel%==0 (
|
||||||
|
echo %YELLOW%⚠️ 文档服务已在运行 (PID: %PID%)%RESET%
|
||||||
|
) else (
|
||||||
|
echo %GREEN%✅ 启动文档服务...%RESET%
|
||||||
|
cd /d "%DOCS_DIR%"
|
||||||
|
start "FastapiAdmin Docs" /B pnpm dev > "%DOCS_LOG%" 2>&1
|
||||||
|
:: 获取新启动进程的 PID
|
||||||
|
for /f "tokens=2" %%a in ('tasklist /FI "WINDOWTITLE eq FastapiAdmin Docs" /NH') do (
|
||||||
|
echo %%a > "%DOCS_PID_FILE%"
|
||||||
|
echo %GREEN%✅ 文档服务已启动 (PID: %%a)%RESET%
|
||||||
|
)
|
||||||
|
cd /d "%SCRIPT_DIR%"
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo %GREEN%✅ 启动文档服务...%RESET%
|
||||||
|
cd /d "%DOCS_DIR%"
|
||||||
|
start "FastapiAdmin Docs" /B pnpm dev > "%DOCS_LOG%" 2>&1
|
||||||
|
:: 获取新启动进程的 PID
|
||||||
|
for /f "tokens=2" %%a in ('tasklist /FI "WINDOWTITLE eq FastapiAdmin Docs" /NH') do (
|
||||||
|
echo %%a > "%DOCS_PID_FILE%"
|
||||||
|
echo %GREEN%✅ 文档服务已启动 (PID: %%a)%RESET%
|
||||||
|
)
|
||||||
|
cd /d "%SCRIPT_DIR%"
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
echo %GREEN%🎉 所有服务启动完成!%RESET%
|
||||||
|
echo.
|
||||||
|
echo %BLUE%访问地址:%RESET%
|
||||||
|
echo %GREEN% 🚀 后端 API: http://localhost:8001/api/v1/docs%RESET%
|
||||||
|
echo %GREEN% 🌐 前端 Web: http://localhost:5173%RESET%
|
||||||
|
echo %GREEN% 📱 前端 App: http://localhost:8080%RESET%
|
||||||
|
echo %GREEN% 📚 文档服务: http://localhost:5174%RESET%
|
||||||
|
echo.
|
||||||
|
goto :end
|
||||||
|
|
||||||
|
:stop_all
|
||||||
|
echo %CYAN%⏹️ 开始停止所有服务...%RESET%
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: 停止后端
|
||||||
|
if exist "%BACKEND_PID_FILE%" (
|
||||||
|
set /p PID=<"%BACKEND_PID_FILE%"
|
||||||
|
tasklist /FI "PID eq %PID%" 2>NUL | find /I "%PID%" >NUL
|
||||||
|
if %errorlevel%==0 (
|
||||||
|
echo %GREEN%✅ 停止后端服务 (PID: %PID%)%RESET%
|
||||||
|
taskkill /PID %PID% /F >NUL 2>&1
|
||||||
|
del "%BACKEND_PID_FILE%" >NUL 2>&1
|
||||||
|
) else (
|
||||||
|
echo %YELLOW%⚠️ 后端服务未在运行%RESET%
|
||||||
|
del "%BACKEND_PID_FILE%" >NUL 2>&1
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo %YELLOW%⚠️ 后端服务未在运行%RESET%
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: 停止前端 Web
|
||||||
|
if exist "%WEB_PID_FILE%" (
|
||||||
|
set /p PID=<"%WEB_PID_FILE%"
|
||||||
|
tasklist /FI "PID eq %PID%" 2>NUL | find /I "%PID%" >NUL
|
||||||
|
if %errorlevel%==0 (
|
||||||
|
echo %GREEN%✅ 停止前端 Web 服务 (PID: %PID%)%RESET%
|
||||||
|
taskkill /PID %PID% /F >NUL 2>&1
|
||||||
|
del "%WEB_PID_FILE%" >NUL 2>&1
|
||||||
|
) else (
|
||||||
|
echo %YELLOW%⚠️ 前端 Web 服务未在运行%RESET%
|
||||||
|
del "%WEB_PID_FILE%" >NUL 2>&1
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo %YELLOW%⚠️ 前端 Web 服务未在运行%RESET%
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: 停止前端 App
|
||||||
|
if exist "%APP_PID_FILE%" (
|
||||||
|
set /p PID=<"%APP_PID_FILE%"
|
||||||
|
tasklist /FI "PID eq %PID%" 2>NUL | find /I "%PID%" >NUL
|
||||||
|
if %errorlevel%==0 (
|
||||||
|
echo %GREEN%✅ 停止前端 App 服务 (PID: %PID%)%RESET%
|
||||||
|
taskkill /PID %PID% /F >NUL 2>&1
|
||||||
|
del "%APP_PID_FILE%" >NUL 2>&1
|
||||||
|
) else (
|
||||||
|
echo %YELLOW%⚠️ 前端 App 服务未在运行%RESET%
|
||||||
|
del "%APP_PID_FILE%" >NUL 2>&1
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo %YELLOW%⚠️ 前端 App 服务未在运行%RESET%
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: 停止文档服务
|
||||||
|
if exist "%DOCS_PID_FILE%" (
|
||||||
|
set /p PID=<"%DOCS_PID_FILE%"
|
||||||
|
tasklist /FI "PID eq %PID%" 2>NUL | find /I "%PID%" >NUL
|
||||||
|
if %errorlevel%==0 (
|
||||||
|
echo %GREEN%✅ 停止文档服务 (PID: %PID%)%RESET%
|
||||||
|
taskkill /PID %PID% /F >NUL 2>&1
|
||||||
|
del "%DOCS_PID_FILE%" >NUL 2>&1
|
||||||
|
) else (
|
||||||
|
echo %YELLOW%⚠️ 文档服务未在运行%RESET%
|
||||||
|
del "%DOCS_PID_FILE%" >NUL 2>&1
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo %YELLOW%⚠️ 文档服务未在运行%RESET%
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
echo %GREEN%🎉 所有服务已停止!%RESET%
|
||||||
|
echo.
|
||||||
|
goto :end
|
||||||
|
|
||||||
|
:restart_all
|
||||||
|
echo %CYAN%🔄 开始重启所有服务...%RESET%
|
||||||
|
echo.
|
||||||
|
goto :stop_all
|
||||||
|
:restart_all_continue
|
||||||
|
echo.
|
||||||
|
goto :start_all
|
||||||
|
|
||||||
|
:check_status
|
||||||
|
echo %CYAN%🔍 检查服务状态...%RESET%
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: 检查后端
|
||||||
|
if exist "%BACKEND_PID_FILE%" (
|
||||||
|
set /p PID=<"%BACKEND_PID_FILE%"
|
||||||
|
tasklist /FI "PID eq %PID%" 2>NUL | find /I "%PID%" >NUL
|
||||||
|
if %errorlevel%==0 (
|
||||||
|
echo %GREEN%✅ 后端服务: 运行中 (PID: %PID%)%RESET%
|
||||||
|
) else (
|
||||||
|
echo %RED%❌ 后端服务: 已停止%RESET%
|
||||||
|
del "%BACKEND_PID_FILE%" >NUL 2>&1
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo %RED%❌ 后端服务: 未启动%RESET%
|
||||||
|
)
|
||||||
|
|
||||||
|
:: 检查前端 Web
|
||||||
|
if exist "%WEB_PID_FILE%" (
|
||||||
|
set /p PID=<"%WEB_PID_FILE%"
|
||||||
|
tasklist /FI "PID eq %PID%" 2>NUL | find /I "%PID%" >NUL
|
||||||
|
if %errorlevel%==0 (
|
||||||
|
echo %GREEN%✅ 前端 Web: 运行中 (PID: %PID%)%RESET%
|
||||||
|
) else (
|
||||||
|
echo %RED%❌ 前端 Web: 已停止%RESET%
|
||||||
|
del "%WEB_PID_FILE%" >NUL 2>&1
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo %RED%❌ 前端 Web: 未启动%RESET%
|
||||||
|
)
|
||||||
|
|
||||||
|
:: 检查前端 App
|
||||||
|
if exist "%APP_PID_FILE%" (
|
||||||
|
set /p PID=<"%APP_PID_FILE%"
|
||||||
|
tasklist /FI "PID eq %PID%" 2>NUL | find /I "%PID%" >NUL
|
||||||
|
if %errorlevel%==0 (
|
||||||
|
echo %GREEN%✅ 前端 App: 运行中 (PID: %PID%)%RESET%
|
||||||
|
) else (
|
||||||
|
echo %RED%❌ 前端 App: 已停止%RESET%
|
||||||
|
del "%APP_PID_FILE%" >NUL 2>&1
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo %RED%❌ 前端 App: 未启动%RESET%
|
||||||
|
)
|
||||||
|
|
||||||
|
:: 检查文档服务
|
||||||
|
if exist "%DOCS_PID_FILE%" (
|
||||||
|
set /p PID=<"%DOCS_PID_FILE%"
|
||||||
|
tasklist /FI "PID eq %PID%" 2>NUL | find /I "%PID%" >NUL
|
||||||
|
if %errorlevel%==0 (
|
||||||
|
echo %GREEN%✅ 文档服务: 运行中 (PID: %PID%)%RESET%
|
||||||
|
) else (
|
||||||
|
echo %RED%❌ 文档服务: 已停止%RESET%
|
||||||
|
del "%DOCS_PID_FILE%" >NUL 2>&1
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo %RED%❌ 文档服务: 未启动%RESET%
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
goto :end
|
||||||
|
|
||||||
|
:end
|
||||||
|
echo %GREEN%========================================%RESET%
|
||||||
|
echo %GREEN%📦 FastapiAdmin 启动脚本%RESET%
|
||||||
|
echo %GREEN%========================================%RESET%
|
||||||
|
endlocal
|
||||||
@@ -1,18 +1,15 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 设置脚本选项,提高健壮性
|
||||||
|
set -e # 遇到错误立即退出
|
||||||
|
set -u # 遇到未定义变量立即退出
|
||||||
|
set -o pipefail # 管道命令失败时整个命令失败
|
||||||
|
|
||||||
# 设置全局变量
|
# 设置全局变量
|
||||||
PROJECT_NAME="FastapiAdmin"
|
PROJECT_NAME="FastapiAdmin"
|
||||||
WORK_DIR="$(cd "$(dirname "$0")" && pwd)"
|
WORK_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
DOCKER_DIR="${WORK_DIR}/docker"
|
||||||
# 检测脚本执行位置
|
ENV_FILE="${DOCKER_DIR}/.env"
|
||||||
# 如果当前目录包含 docker-compose.yaml,说明脚本在项目内执行
|
|
||||||
if [ -f "${WORK_DIR}/docker-compose.yaml" ] || [ -f "${WORK_DIR}/docker-compose.yml" ]; then
|
|
||||||
# 脚本在项目内执行,项目路径就是当前目录
|
|
||||||
PROJECT_PATH="${WORK_DIR}"
|
|
||||||
else
|
|
||||||
# 脚本在项目外执行,项目路径是子目录
|
|
||||||
PROJECT_PATH="${WORK_DIR}/${PROJECT_NAME}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
GIT_REPO="https://gitee.com/fastapiadmin/${PROJECT_NAME}.git"
|
GIT_REPO="https://gitee.com/fastapiadmin/${PROJECT_NAME}.git"
|
||||||
|
|
||||||
@@ -22,124 +19,176 @@ UPDATE_FRONTEND=false
|
|||||||
UPDATE_FASTAPP=false
|
UPDATE_FASTAPP=false
|
||||||
# 是否有更新官网
|
# 是否有更新官网
|
||||||
UPDATE_FASTDOCS=false
|
UPDATE_FASTDOCS=false
|
||||||
|
# 是否跳过前端构建
|
||||||
|
SKIP_FRONTEND=true
|
||||||
|
|
||||||
|
# 部署状态跟踪
|
||||||
|
DEPLOY_STATUS=""
|
||||||
|
|
||||||
# 日志级别控制
|
# 日志级别控制
|
||||||
LOG_LEVEL=${LOG_LEVEL:-INFO}
|
LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
|
|
||||||
# 打印带时间戳的日志
|
# 颜色定义
|
||||||
|
readonly COLOR_RESET='\033[0m'
|
||||||
|
readonly COLOR_RED='\033[0;31m'
|
||||||
|
readonly COLOR_GREEN='\033[0;32m'
|
||||||
|
readonly COLOR_YELLOW='\033[0;33m'
|
||||||
|
readonly COLOR_BLUE='\033[0;34m'
|
||||||
|
readonly COLOR_CYAN='\033[0;36m'
|
||||||
|
|
||||||
|
# 打印带时间戳的日志(带颜色)
|
||||||
log() {
|
log() {
|
||||||
local message="$1"
|
local message="$1"
|
||||||
local level=${2:-INFO}
|
local level=${2:-INFO}
|
||||||
|
local color=""
|
||||||
|
|
||||||
|
case $level in
|
||||||
|
DEBUG) color="${COLOR_RESET}" ;;
|
||||||
|
INFO) color="${COLOR_BLUE}" ;;
|
||||||
|
WARN) color="${COLOR_YELLOW}" ;;
|
||||||
|
ERROR) color="${COLOR_RED}" ;;
|
||||||
|
SUCCESS) color="${COLOR_GREEN}" ;;
|
||||||
|
*) color="${COLOR_RESET}" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
# 根据日志级别决定是否输出
|
|
||||||
case $LOG_LEVEL in
|
case $LOG_LEVEL in
|
||||||
DEBUG) ;;
|
DEBUG) ;;
|
||||||
INFO) [[ $level == "DEBUG" ]] && return ;;
|
INFO) [[ $level == "DEBUG" ]] && return ;;
|
||||||
WARN) [[ $level == "DEBUG" || $level == "INFO" ]] && return ;;
|
WARN) [[ $level == "DEBUG" || $level == "INFO" ]] && return ;;
|
||||||
ERROR) [[ $level != "ERROR" ]] && return ;;
|
ERROR) [[ $level != "ERROR" ]] && return ;;
|
||||||
|
SUCCESS) [[ $level == "DEBUG" || $level == "INFO" || $level == "WARN" ]] && return ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $message"
|
echo -e "${color}[$(date '+%Y-%m-%d %H:%M:%S')] [${level}] ${message}${COLOR_RESET}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查并设置脚本执行权限
|
||||||
|
check_and_setup_permissions() {
|
||||||
|
log "==========🔐 检查脚本执行权限...==========" "INFO"
|
||||||
|
|
||||||
|
local script_path="${WORK_DIR}/deploy.sh"
|
||||||
|
local docker_compose_file="${DOCKER_DIR}/docker-compose.yaml"
|
||||||
|
|
||||||
|
if [ ! -x "$script_path" ]; then
|
||||||
|
log "⚠️ deploy.sh 缺少执行权限,正在自动设置..." "WARN"
|
||||||
|
chmod +x "$script_path"
|
||||||
|
log "✅ deploy.sh 执行权限已设置" "INFO"
|
||||||
|
else
|
||||||
|
log "✅ deploy.sh 执行权限正常" "INFO"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$docker_compose_file" ]; then
|
||||||
|
log "❌ docker-compose.yaml 文件不存在: ${docker_compose_file}" "ERROR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "✅ 权限检查完成" "INFO"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 加载环境变量
|
||||||
|
load_env() {
|
||||||
|
log "==========🔍 加载环境变量...==========" "INFO"
|
||||||
|
|
||||||
|
if [ -f "${ENV_FILE}" ]; then
|
||||||
|
log "📁 加载环境变量文件: ${ENV_FILE}" "INFO"
|
||||||
|
set -a
|
||||||
|
source "${ENV_FILE}"
|
||||||
|
set +a
|
||||||
|
log "✅ 环境变量加载成功" "INFO"
|
||||||
|
else
|
||||||
|
log "⚠️ 环境变量文件 ${ENV_FILE} 不存在,使用默认值" "WARN"
|
||||||
|
if [ -f "${DOCKER_DIR}/.env.example" ]; then
|
||||||
|
log "📁 从示例文件创建环境变量文件" "INFO"
|
||||||
|
cp "${DOCKER_DIR}/.env.example" "${ENV_FILE}"
|
||||||
|
log "✅ 环境变量文件创建成功" "INFO"
|
||||||
|
set -a
|
||||||
|
source "${ENV_FILE}"
|
||||||
|
set +a
|
||||||
|
else
|
||||||
|
log "❌ 环境变量示例文件 ${DOCKER_DIR}/.env.example 不存在" "ERROR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# 检查系统依赖
|
# 检查系统依赖
|
||||||
check_permissions() {
|
check_permissions() {
|
||||||
log "==========🔍 检查权限...==========" "INFO"
|
|
||||||
# 检查脚本文件是否有执行权限
|
|
||||||
if [ ! -x "$0" ]; then
|
|
||||||
log "⚠️ 当前脚本没有执行权限,请使用 chmod +x $0 添加执行权限" "ERROR"
|
|
||||||
exit 1
|
|
||||||
else
|
|
||||||
log "✅ 脚本已有执行权限" "INFO"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "==========🔍 检查系统依赖...==========" "INFO"
|
log "==========🔍 检查系统依赖...==========" "INFO"
|
||||||
local missing_deps=()
|
local missing_deps=()
|
||||||
|
local optional_deps_missing=()
|
||||||
|
|
||||||
for cmd in git docker node npm pnpm; do
|
for cmd in git docker docker-compose; do
|
||||||
if ! command -v $cmd &> /dev/null; then
|
if ! command -v $cmd &> /dev/null; then
|
||||||
missing_deps+=($cmd)
|
missing_deps+=($cmd)
|
||||||
log "❌ $cmd 未安装" "ERROR"
|
log "❌ $cmd 未安装" "ERROR"
|
||||||
else
|
else
|
||||||
log "🎉 $cmd 已安装 - $($cmd --version 2>/dev/null || $cmd -v)" "INFO"
|
local version=$($cmd --version 2>/dev/null | head -n1 || $cmd -v 2>/dev/null | head -n1)
|
||||||
|
log "🎉 $cmd 已安装 - ${version}" "INFO"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
if command -v docker &> /dev/null; then
|
||||||
|
if docker compose version &> /dev/null; then
|
||||||
|
log "🎉 docker compose 已安装 - $(docker compose version | head -n1)" "INFO"
|
||||||
|
elif docker-compose --version &> /dev/null; then
|
||||||
|
log "🎉 docker-compose 已安装 - $(docker-compose --version | head -n1)" "INFO"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d "frontend/web" ] || [ -d "frontend/app" ] || [ -d "frontend/docs" ]; then
|
||||||
|
for cmd in node pnpm; do
|
||||||
|
if ! command -v $cmd &> /dev/null; then
|
||||||
|
optional_deps_missing+=($cmd)
|
||||||
|
log "⚠️ $cmd 未安装(前端构建需要)" "WARN"
|
||||||
|
else
|
||||||
|
log "🎉 $cmd 已安装 - $($cmd --version 2>/dev/null || $cmd -v)" "INFO"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
if [ ${#missing_deps[@]} -ne 0 ]; then
|
if [ ${#missing_deps[@]} -ne 0 ]; then
|
||||||
log "❌ 缺少依赖: ${missing_deps[*]}" "ERROR"
|
log "❌ 缺少必需依赖: ${missing_deps[*]}" "ERROR"
|
||||||
|
log "❌ 请安装缺失的依赖后重试" "ERROR"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "✅ 所有依赖检查通过" "INFO"
|
log "✅ 所有必需依赖检查通过" "INFO"
|
||||||
}
|
|
||||||
|
|
||||||
# 停止项目容器
|
|
||||||
stop_project() {
|
|
||||||
log "==========⏹️ 停止项目容器...==========" "INFO"
|
|
||||||
log "📂 当前工作目录: $(pwd)" "INFO"
|
|
||||||
log "🎯 项目完整路径: ${PROJECT_PATH}" "INFO"
|
|
||||||
log "🗂️ 工作目录: ${WORK_DIR}" "INFO"
|
|
||||||
|
|
||||||
if [ -d "${PROJECT_PATH}" ]; then
|
|
||||||
log "📂 进入项目目录: ${PROJECT_PATH}" "INFO"
|
|
||||||
cd "${PROJECT_PATH}" || { log "❌ 无法进入项目目录:${PROJECT_PATH}" "ERROR"; exit 1; }
|
|
||||||
|
|
||||||
if [ -f "docker-compose.yaml" ] || [ -f "docker-compose.yml" ]; then
|
|
||||||
docker compose down || { log "❌ 停止容器失败" "ERROR"; exit 1; }
|
|
||||||
log "✅ 项目容器已停止并删除" "INFO"
|
|
||||||
else
|
|
||||||
log "❌ docker-compose 文件未找到" "ERROR"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log "📥 项目不存在,开始克隆代码到 ${PROJECT_PATH}" "INFO"
|
|
||||||
log "📂 进入工作目录: ${WORK_DIR}" "INFO"
|
|
||||||
cd "${WORK_DIR}" || { log "❌ 无法进入工作目录:${WORK_DIR}" "ERROR"; exit 1; }
|
|
||||||
git clone "${GIT_REPO}" || { log "❌ 项目克隆失败:${GIT_REPO}" "ERROR"; exit 1; }
|
|
||||||
log "📂 克隆完成,进入项目目录: ${PROJECT_PATH}" "INFO"
|
|
||||||
cd "${PROJECT_PATH}" || { log "❌ 无法进入项目目录:${PROJECT_PATH}" "ERROR"; exit 1; }
|
|
||||||
UPDATE_FRONTEND=true
|
|
||||||
UPDATE_FASTAPP=true
|
|
||||||
UPDATE_FASTDOCS=true
|
|
||||||
log "✅ 代码克隆成功,当前目录: $(pwd)" "INFO"
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# 更新代码
|
# 更新代码
|
||||||
update_code() {
|
update_code() {
|
||||||
log "==========🔍 更新最新代码...==========" "INFO"
|
log "==========🔄 更新最新代码...==========" "INFO"
|
||||||
log "📂 当前工作目录: $(pwd)" "INFO"
|
log "📂 当前工作目录: $(pwd)" "INFO"
|
||||||
log "🎯 项目路径: ${PROJECT_PATH}" "INFO"
|
log "🎯 项目路径: ${WORK_DIR}" "INFO"
|
||||||
|
|
||||||
# 确保我们在正确的目录
|
if [ "$(pwd)" != "${WORK_DIR}" ]; then
|
||||||
if [ "$(pwd)" != "${PROJECT_PATH}" ]; then
|
cd "${WORK_DIR}" || { log "❌ 无法进入项目根目录" "ERROR"; exit 1; }
|
||||||
cd "${PROJECT_PATH}" || { log "❌ 无法进入项目目录:${PROJECT_PATH}" "ERROR"; exit 1; }
|
log "📂 进入项目根目录" "INFO"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 检查是否是新克隆的项目
|
if [ -d ".git" ]; then
|
||||||
if [ ! -d ".git" ]; then
|
local current_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
|
||||||
|
log "📂 当前Git分支: ${current_branch}" "INFO"
|
||||||
|
git fetch origin || { log "⚠️ 获取远程分支失败" "WARN"; }
|
||||||
|
git pull --force || { log "❌ 拉取更新失败" "ERROR"; exit 1; }
|
||||||
|
local commit_info=$(git log -1 --oneline 2>/dev/null || echo "无法获取提交信息")
|
||||||
|
log "📝 最新提交: ${commit_info}" "INFO"
|
||||||
|
else
|
||||||
log "📥 初始化Git仓库..." "INFO"
|
log "📥 初始化Git仓库..." "INFO"
|
||||||
git init
|
git init
|
||||||
git remote add origin "${GIT_REPO}"
|
git remote add origin "${GIT_REPO}"
|
||||||
git pull origin master
|
git pull origin master || git pull origin main || { log "❌ 拉取代码失败" "ERROR"; exit 1; }
|
||||||
else
|
|
||||||
# 保存当前分支
|
|
||||||
local current_branch=$(git rev-parse --abbrev-ref HEAD)
|
|
||||||
log "📂 当前Git分支: ${current_branch}" "INFO"
|
|
||||||
git pull --force || { log "❌ 拉取更新失败" "ERROR"; exit 1; }
|
|
||||||
git log -1 --oneline || { log "❌ 获取提交信息失败" "ERROR"; exit 1; }
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 检查目录存在性并设置更新标志
|
if [ -d "frontend/web" ]; then
|
||||||
if [ -d "frontend" ]; then
|
|
||||||
UPDATE_FRONTEND=true
|
UPDATE_FRONTEND=true
|
||||||
log "📦 检测到前端工程" "INFO"
|
log "📦 检测到前端工程" "INFO"
|
||||||
fi
|
fi
|
||||||
if [ -d "fastapp" ]; then
|
if [ -d "frontend/app" ]; then
|
||||||
UPDATE_FASTAPP=true
|
UPDATE_FASTAPP=true
|
||||||
log "📦 检测到移动端工程" "INFO"
|
log "📦 检测到移动端工程" "INFO"
|
||||||
fi
|
fi
|
||||||
if [ -d "fastdocs" ]; then
|
if [ -d "frontend/docs" ]; then
|
||||||
UPDATE_FASTDOCS=true
|
UPDATE_FASTDOCS=true
|
||||||
log "📦 检测到官网工程" "INFO"
|
log "📦 检测到官网工程" "INFO"
|
||||||
fi
|
fi
|
||||||
@@ -149,108 +198,235 @@ update_code() {
|
|||||||
|
|
||||||
# 打包前端
|
# 打包前端
|
||||||
build_frontend() {
|
build_frontend() {
|
||||||
log "==========🚀 打包前端...==========" "INFO"
|
log "==========🚀 构建前端...==========" "INFO"
|
||||||
|
|
||||||
|
if [ "$SKIP_FRONTEND" = true ]; then
|
||||||
|
log "⚠️ 跳过前端构建(使用已上传的静态文件)" "WARN"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v node &> /dev/null || ! command -v pnpm &> /dev/null; then
|
||||||
|
log "⚠️ Node.js 或 pnpm 未安装,跳过前端构建" "WARN"
|
||||||
|
log "💡 提示: 请在本地构建前端,然后将 dist 目录上传到 docker/nginx/ 对应目录" "WARN"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d "frontend/web" ] && [ "$UPDATE_FRONTEND" = true ]; then
|
||||||
|
log "📦 开始构建前端工程..." "INFO"
|
||||||
|
cd frontend/web || { log "❌ 无法进入前端目录" "ERROR"; exit 1; }
|
||||||
|
|
||||||
# 构建前端
|
|
||||||
if [ -d "frontend" ] && [ "$UPDATE_FRONTEND" = true ]; then
|
|
||||||
cd frontend || { log "❌ 无法进入前端目录" "ERROR"; exit 1; }
|
|
||||||
log "📦 安装前端依赖..." "INFO"
|
log "📦 安装前端依赖..." "INFO"
|
||||||
pnpm install || { log "❌ 前端依赖安装失败" "ERROR"; exit 1; }
|
pnpm install --frozen-lockfile || pnpm install || { log "❌ 前端依赖安装失败" "WARN"; cd ..; return 0; }
|
||||||
|
|
||||||
log "🔨 打包前端工程..." "INFO"
|
log "🔨 打包前端工程..." "INFO"
|
||||||
pnpm run build || { log "❌ 前端工程打包失败" "ERROR"; exit 1; }
|
pnpm run build || { log "❌ 前端工程打包失败" "WARN"; cd ..; return 0; }
|
||||||
log "✅ 前端工程打包成功" "INFO"
|
log "✅ 前端工程打包成功" "INFO"
|
||||||
|
|
||||||
|
mkdir -p "${WORK_DIR}/docker/nginx/web"
|
||||||
|
|
||||||
|
if [ -d "dist" ]; then
|
||||||
|
rm -rf "${WORK_DIR}/docker/nginx/web"/*
|
||||||
|
mv -f "dist"/* "${WORK_DIR}/docker/nginx/web/" || { log "❌ 移动前端打包到docker/nginx/web目录失败" "ERROR"; cd ..; exit 1; }
|
||||||
|
log "✅ 前端打包文件已移动到 docker/nginx/web" "INFO"
|
||||||
|
fi
|
||||||
|
|
||||||
cd .. || { log "❌ 无法返回项目根目录" "ERROR"; exit 1; }
|
cd .. || { log "❌ 无法返回项目根目录" "ERROR"; exit 1; }
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 构建小程序
|
if [ -d "frontend/app" ] && [ "$UPDATE_FASTAPP" = true ]; then
|
||||||
if [ -d "fastapp" ] && [ "$UPDATE_FASTAPP" = true ]; then
|
log "📦 开始构建小程序工程..." "INFO"
|
||||||
cd fastapp || { log "❌ 无法进入小程序目录" "ERROR"; exit 1; }
|
cd frontend/app || { log "❌ 无法进入小程序目录" "ERROR"; exit 1; }
|
||||||
|
|
||||||
log "📦 安装小程序依赖..." "INFO"
|
log "📦 安装小程序依赖..." "INFO"
|
||||||
pnpm install || { log "❌ 小程序依赖安装失败" "ERROR"; exit 1; }
|
pnpm install --frozen-lockfile || pnpm install || { log "❌ 小程序依赖安装失败" "WARN"; cd ..; return 0; }
|
||||||
|
|
||||||
log "🔨 打包小程序工程..." "INFO"
|
log "🔨 打包小程序工程..." "INFO"
|
||||||
pnpm run build:h5 || { log "❌ 小程序工程打包失败" "ERROR"; exit 1; }
|
pnpm run build:h5 || { log "❌ 小程序工程打包失败" "WARN"; cd ..; return 0; }
|
||||||
log "✅ 小程序工程打包成功" "INFO"
|
log "✅ 小程序工程打包成功" "INFO"
|
||||||
|
|
||||||
|
mkdir -p "${WORK_DIR}/docker/nginx/app"
|
||||||
|
|
||||||
|
if [ -d "dist" ]; then
|
||||||
|
rm -rf "${WORK_DIR}/docker/nginx/app"/*
|
||||||
|
mv -f "dist"/* "${WORK_DIR}/docker/nginx/app/" || { log "❌ 移动小程序打包到docker/nginx/app目录失败" "ERROR"; cd ..; exit 1; }
|
||||||
|
log "✅ 小程序打包文件已移动到 docker/nginx/app" "INFO"
|
||||||
|
fi
|
||||||
|
|
||||||
cd .. || { log "❌ 无法返回项目根目录" "ERROR"; exit 1; }
|
cd .. || { log "❌ 无法返回项目根目录" "ERROR"; exit 1; }
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 构建项目文档
|
if [ -d "frontend/docs" ] && [ "$UPDATE_FASTDOCS" = true ]; then
|
||||||
if [ -d "fastdocs" ] && [ "$UPDATE_FASTDOCS" = true ]; then
|
log "📦 开始构建项目文档..." "INFO"
|
||||||
cd fastdocs || { log "❌ 无法进入项目文档目录" "ERROR"; exit 1; }
|
cd frontend/docs || { log "❌ 无法进入项目文档目录" "ERROR"; exit 1; }
|
||||||
|
|
||||||
log "📦 安装项目文档依赖..." "INFO"
|
log "📦 安装项目文档依赖..." "INFO"
|
||||||
pnpm install || { log "❌ 项目文档依赖安装失败" "ERROR"; exit 1; }
|
pnpm install --frozen-lockfile || pnpm install || { log "❌ 项目文档依赖安装失败" "WARN"; cd ..; return 0; }
|
||||||
|
|
||||||
log "🔨 打包项目文档..." "INFO"
|
log "🔨 打包项目文档..." "INFO"
|
||||||
pnpm run docs:build || { log "❌ 项目文档打包生成失败" "ERROR"; exit 1; }
|
pnpm run docs:build || { log "❌ 项目文档打包生成失败" "WARN"; cd ..; return 0; }
|
||||||
log "✅ 项目文档打包成功" "INFO"
|
log "✅ 项目文档打包成功" "INFO"
|
||||||
|
|
||||||
|
mkdir -p "${WORK_DIR}/docker/nginx/docs"
|
||||||
|
|
||||||
|
if [ -d "dist" ]; then
|
||||||
|
rm -rf "${WORK_DIR}/docker/nginx/docs"/*
|
||||||
|
mv -f "dist"/* "${WORK_DIR}/docker/nginx/docs/" || { log "❌ 移动项目文档打包到docker/nginx/docs目录失败" "ERROR"; cd ..; exit 1; }
|
||||||
|
log "✅ 项目文档打包文件已移动到 docker/nginx/docs" "INFO"
|
||||||
|
fi
|
||||||
|
|
||||||
cd .. || { log "❌ 无法返回项目根目录" "ERROR"; exit 1; }
|
cd .. || { log "❌ 无法返回项目根目录" "ERROR"; exit 1; }
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# 构建镜像&启动容器
|
# 构建镜像
|
||||||
start_containers() {
|
build_containers() {
|
||||||
log "==========🚀 构建镜像&启动容器...==========" "INFO"
|
log "==========🔨 构建Docker镜像...==========" "INFO"
|
||||||
cd "${PROJECT_PATH}" || { log "❌ 无法进入项目目录:${PROJECT_PATH}" "ERROR"; exit 1; }
|
cd "${DOCKER_DIR}" || { log "❌ 无法进入项目 docker 目录" "ERROR"; exit 1; }
|
||||||
docker compose build || { log "❌ 镜像构建失败" "ERROR"; exit 1; }
|
log "📂 进入项目 docker 目录: $(pwd)" "INFO"
|
||||||
log "✅ Docker镜像构建成功" "INFO"
|
|
||||||
docker compose up -d --force-recreate || { log "❌ 容器启动失败" "ERROR"; exit 1; }
|
|
||||||
sleep 5
|
|
||||||
log "✅ 容器启动成功" "INFO"
|
|
||||||
|
|
||||||
log "==========🗑️ 清理72小时前的旧镜像...==========" "INFO"
|
export DOCKER_BUILDKIT=1
|
||||||
# 只清理与项目相关的镜像
|
docker compose build --no-cache || { log "❌ 镜像构建失败" "ERROR"; exit 1; }
|
||||||
|
log "✅ Docker镜像构建成功" "SUCCESS"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 启动容器
|
||||||
|
start_containers() {
|
||||||
|
log "==========🚀 启动容器...==========" "INFO"
|
||||||
|
cd "${DOCKER_DIR}" || { log "❌ 无法进入项目 docker 目录" "ERROR"; exit 1; }
|
||||||
|
|
||||||
|
if docker compose ps --format '{{.Names}}' 2>/dev/null | grep -q .; then
|
||||||
|
log "📦 停止旧容器..." "INFO"
|
||||||
|
docker compose down || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "🚀 启动新容器..." "INFO"
|
||||||
|
docker compose up -d --force-recreate || { log "❌ 容器启动失败" "ERROR"; exit 1; }
|
||||||
|
|
||||||
|
log "⏳ 等待容器启动完成..." "INFO"
|
||||||
|
local max_wait=60
|
||||||
|
local wait_count=0
|
||||||
|
|
||||||
|
while [ $wait_count -lt $max_wait ]; do
|
||||||
|
if docker compose ps mysql --format '{{.Status}}' 2>/dev/null | grep -q "healthy"; then
|
||||||
|
log "✅ MySQL 已就绪" "SUCCESS"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
wait_count=$((wait_count + 2))
|
||||||
|
echo -n "."
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
log "📊 容器状态检查..." "INFO"
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
log "✅ 项目容器已启动" "SUCCESS"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 停止项目容器
|
||||||
|
stop_containers() {
|
||||||
|
log "==========⏹️ 停止项目容器...==========" "INFO"
|
||||||
|
|
||||||
|
if [ -d "${DOCKER_DIR}" ]; then
|
||||||
|
cd "${DOCKER_DIR}" || { log "❌ 无法进入项目 docker 目录" "ERROR"; exit 1; }
|
||||||
|
log "📂 进入项目 docker 目录" "INFO"
|
||||||
|
|
||||||
|
docker compose down || { log "⚠️ 停止容器时发生错误" "WARN"; }
|
||||||
|
log "✅ 项目容器已停止并删除" "INFO"
|
||||||
|
|
||||||
|
cd .. || { log "❌ 无法返回项目根目录" "ERROR"; exit 1; }
|
||||||
|
log "📂 返回项目根目录" "INFO"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 重启容器
|
||||||
|
restart_containers() {
|
||||||
|
log "==========🔄 重启容器...==========" "INFO"
|
||||||
|
cd "${DOCKER_DIR}" || { log "❌ 无法进入项目 docker 目录" "ERROR"; exit 1; }
|
||||||
|
docker compose restart || { log "❌ 容器重启失败" "ERROR"; exit 1; }
|
||||||
|
log "✅ 容器重启成功" "SUCCESS"
|
||||||
|
docker compose ps
|
||||||
|
}
|
||||||
|
|
||||||
|
# 清理旧镜像
|
||||||
|
clear_old_images() {
|
||||||
|
log "==========🗑️ 清理旧镜像...==========" "INFO"
|
||||||
local project_images=$(docker images | grep ${PROJECT_NAME} | awk '{print $3}' | wc -l)
|
local project_images=$(docker images | grep ${PROJECT_NAME} | awk '{print $3}' | wc -l)
|
||||||
if [ $project_images -gt 0 ]; then
|
if [ $project_images -gt 0 ]; then
|
||||||
local before_count=$(docker images | grep ${PROJECT_NAME} | wc -l)
|
local before_count=$(docker images | grep ${PROJECT_NAME} | wc -l)
|
||||||
# 修改: 使用更准确的过滤方式清理镜像
|
docker image prune -f --filter "until=72h" --filter "label=com.docker.compose.project=${PROJECT_NAME}" >/dev/null 2>&1 || true
|
||||||
docker image prune -f --filter "until=72h" --filter "label=com.docker.compose.project=${PROJECT_NAME}" >/dev/null 2>&1
|
|
||||||
local after_count=$(docker images | grep ${PROJECT_NAME} | wc -l)
|
local after_count=$(docker images | grep ${PROJECT_NAME} | wc -l)
|
||||||
log "✅ 旧镜像清理完成,清理了 $((before_count - after_count)) 个镜像" "INFO"
|
local cleaned_count=$((before_count - after_count))
|
||||||
|
log "✅ 旧镜像清理完成,清理了 ${cleaned_count} 个镜像" "SUCCESS"
|
||||||
else
|
else
|
||||||
log "⚠️ 没有找到项目相关镜像,跳过清理" "WARN"
|
log "⚠️ 没有找到项目相关镜像,跳过清理" "WARN"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# 显示所有完整日志的函数
|
# 显示日志
|
||||||
show_containers_logs() {
|
show_containers_logs() {
|
||||||
log "==========📋 查看所有容器日志... ==========" "INFO"
|
log "==========📋 查看所有容器日志... ==========" "INFO"
|
||||||
cd "${PROJECT_PATH}" || { log "❌ 无法进入项目目录:${PROJECT_PATH}" "ERROR"; exit 1; }
|
cd "${DOCKER_DIR}" || { log "❌ 无法进入项目 docker 目录" "ERROR"; exit 1; }
|
||||||
|
|
||||||
# 显示容器状态
|
|
||||||
log "📊 当前容器状态:" "INFO"
|
log "📊 当前容器状态:" "INFO"
|
||||||
docker compose ps --format "table {{.Service}}\t{{.Name}}\t{{.Status}}\t{{.Ports}}"
|
docker compose ps --format "table {{.Service}}\t{{.Name}}\t{{.Status}}\t{{.Ports}}"
|
||||||
|
|
||||||
log "📋 后端服务日志:" "INFO"
|
log "📋 服务日志:" "INFO"
|
||||||
echo "----------------------------------------"
|
echo "----------------------------------------"
|
||||||
docker compose logs backend
|
docker compose logs
|
||||||
echo "----------------------------------------"
|
echo "----------------------------------------"
|
||||||
|
}
|
||||||
|
|
||||||
log "📋 Nginx服务日志:" "INFO"
|
# 验证部署
|
||||||
echo "----------------------------------------"
|
verify_deployment() {
|
||||||
docker compose logs nginx
|
log "==========🔍 验证部署结果...==========" "INFO"
|
||||||
echo "----------------------------------------"
|
|
||||||
|
|
||||||
log "📋 MySQL服务日志:" "INFO"
|
local all_healthy=true
|
||||||
echo "----------------------------------------"
|
|
||||||
docker compose logs mysql
|
|
||||||
echo "----------------------------------------"
|
|
||||||
|
|
||||||
log "📋 Redis服务日志:" "INFO"
|
log "📊 检查容器运行状态..." "INFO"
|
||||||
echo "----------------------------------------"
|
for service in mysql redis backend nginx; do
|
||||||
docker compose logs redis
|
local status=$(docker compose ps "$service" --format '{{.Status}}' 2>/dev/null || echo "not found")
|
||||||
echo "----------------------------------------"
|
if echo "$status" | grep -q "Up\|healthy"; then
|
||||||
|
log "✅ ${service}: ${status}" "SUCCESS"
|
||||||
|
else
|
||||||
|
log "❌ ${service}: ${status}" "ERROR"
|
||||||
|
all_healthy=false
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
log "💡 实时日志查看命令:" "INFO"
|
log "📊 检查端口监听..." "INFO"
|
||||||
log " - 查看所有服务实时日志:docker compose logs -f" "INFO"
|
local backend_port="${BACKEND_PORT:-8001}"
|
||||||
log " - 查看单个服务实时日志:docker compose logs -f [服务名]" "INFO"
|
if command -v nc &> /dev/null; then
|
||||||
log " - 服务名:backend, nginx, mysql, redis" "INFO"
|
if nc -z localhost ${backend_port} 2>/dev/null; then
|
||||||
|
log "✅ 后端服务端口 ${backend_port} 已监听" "SUCCESS"
|
||||||
|
else
|
||||||
|
log "⚠️ 后端服务端口 ${backend_port} 未监听" "WARN"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
local http_port="${HTTP_PORT:-80}"
|
||||||
|
if nc -z localhost ${http_port} 2>/dev/null; then
|
||||||
|
log "✅ Nginx服务端口 ${http_port} 已监听" "SUCCESS"
|
||||||
|
else
|
||||||
|
log "⚠️ Nginx服务端口 ${http_port} 未监听" "WARN"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$all_healthy" = true ]; then
|
||||||
|
log "✅ 所有服务运行正常!" "SUCCESS"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log "⚠️ 部分服务可能存在问题,请检查日志" "WARN"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# 信号处理
|
# 信号处理
|
||||||
handle_interrupt() {
|
handle_interrupt() {
|
||||||
|
log "" "INFO"
|
||||||
log "==========⚠️ 收到中断信号,正在停止部署...==========" "WARN"
|
log "==========⚠️ 收到中断信号,正在停止部署...==========" "WARN"
|
||||||
# 如果在容器启动阶段中断,尝试停止容器
|
if [ -d "${DOCKER_DIR}" ]; then
|
||||||
if [ -d "${PROJECT_PATH}" ]; then
|
cd "${DOCKER_DIR}"
|
||||||
cd "${PROJECT_PATH}"
|
docker compose down >/dev/null 2>&1 || true
|
||||||
docker compose down >/dev/null 2>&1
|
|
||||||
fi
|
fi
|
||||||
exit 130
|
exit 130
|
||||||
}
|
}
|
||||||
@@ -258,94 +434,160 @@ handle_interrupt() {
|
|||||||
# 主函数
|
# 主函数
|
||||||
main() {
|
main() {
|
||||||
log "==========🚀 开始部署流程==========" "INFO"
|
log "==========🚀 开始部署流程==========" "INFO"
|
||||||
log "📂 脚本所在目录: ${WORK_DIR}" "INFO"
|
log "📂 脚本所在目录: $(pwd)" "INFO"
|
||||||
log "🎯 项目完整路径: ${PROJECT_PATH}" "INFO"
|
log "📅 部署时间: $(date '+%Y-%m-%d %H:%M:%S')" "INFO"
|
||||||
|
|
||||||
|
DEPLOY_STATUS="检查脚本权限"
|
||||||
|
log "==========🔐 开始${DEPLOY_STATUS}...==========" "INFO"
|
||||||
|
check_and_setup_permissions
|
||||||
|
log "✅ ${DEPLOY_STATUS}完成" "INFO"
|
||||||
|
|
||||||
|
DEPLOY_STATUS="加载环境变量"
|
||||||
|
log "==========🔍 开始${DEPLOY_STATUS}...==========" "INFO"
|
||||||
|
load_env
|
||||||
|
log "✅ ${DEPLOY_STATUS}完成" "INFO"
|
||||||
|
|
||||||
|
DEPLOY_STATUS="检查系统依赖"
|
||||||
|
log "==========🔍 开始${DEPLOY_STATUS}...==========" "INFO"
|
||||||
check_permissions
|
check_permissions
|
||||||
stop_project
|
log "✅ ${DEPLOY_STATUS}完成" "INFO"
|
||||||
update_code
|
|
||||||
# build_frontend
|
|
||||||
start_containers
|
|
||||||
show_containers_logs
|
|
||||||
|
|
||||||
log "🎉 部署完成!以下是访问信息:
|
DEPLOY_STATUS="停止现有容器"
|
||||||
📌 官网: https://service.fastapiadmin.com
|
log "==========⏹️ 开始${DEPLOY_STATUS}...==========" "INFO"
|
||||||
📌 前端: https://service.fastapiadmin.com/web
|
stop_containers
|
||||||
📌 小程序: https://service.fastapiadmin.com/app
|
log "✅ ${DEPLOY_STATUS}完成" "INFO"
|
||||||
📌 登录信息: 账号 admin,密码 123456" "SUCCESS"
|
|
||||||
|
DEPLOY_STATUS="更新代码"
|
||||||
|
log "==========🔄 开始${DEPLOY_STATUS}...==========" "INFO"
|
||||||
|
update_code
|
||||||
|
log "✅ ${DEPLOY_STATUS}完成" "INFO"
|
||||||
|
|
||||||
|
DEPLOY_STATUS="构建前端"
|
||||||
|
log "==========🚀 开始${DEPLOY_STATUS}...==========" "INFO"
|
||||||
|
build_frontend
|
||||||
|
log "✅ ${DEPLOY_STATUS}完成" "INFO"
|
||||||
|
|
||||||
|
DEPLOY_STATUS="构建镜像"
|
||||||
|
log "==========🔨 开始${DEPLOY_STATUS}...==========" "INFO"
|
||||||
|
build_containers
|
||||||
|
log "✅ ${DEPLOY_STATUS}完成" "INFO"
|
||||||
|
|
||||||
|
DEPLOY_STATUS="启动容器"
|
||||||
|
log "==========🚀 开始${DEPLOY_STATUS}...==========" "INFO"
|
||||||
|
start_containers
|
||||||
|
log "✅ ${DEPLOY_STATUS}完成" "INFO"
|
||||||
|
|
||||||
|
DEPLOY_STATUS="验证部署"
|
||||||
|
log "==========🔍 开始${DEPLOY_STATUS}...==========" "INFO"
|
||||||
|
verify_deployment || log "⚠️ 部署验证发现问题,请检查日志" "WARN"
|
||||||
|
log "✅ ${DEPLOY_STATUS}完成" "INFO"
|
||||||
|
|
||||||
|
DEPLOY_STATUS="显示日志"
|
||||||
|
log "==========📋 开始${DEPLOY_STATUS}...==========" "INFO"
|
||||||
|
show_containers_logs
|
||||||
|
log "✅ ${DEPLOY_STATUS}完成" "INFO"
|
||||||
|
|
||||||
|
log "" "INFO"
|
||||||
|
log "🎉🎉🎉 部署完成!🎉🎉🎉" "SUCCESS"
|
||||||
|
log "" "INFO"
|
||||||
|
log "📌 访问信息:" "INFO"
|
||||||
|
log " 🌐 官网: http://域名(或ip:${HTTP_PORT:-80})" "INFO"
|
||||||
|
log " 🌐 前端: http://域名(或ip:${HTTP_PORT:-80})/web" "INFO"
|
||||||
|
log " 🌐 小程序: http://域名(或ip:${HTTP_PORT:-80})/app" "INFO"
|
||||||
|
log " 🔌 后端API: http://域名(或ip):${BACKEND_PORT:-8001}/api/v1/docs" "INFO"
|
||||||
|
log " 📝 登录信息: 账号 admin,密码 123456" "INFO"
|
||||||
|
log "" "INFO"
|
||||||
|
log "💡 常用命令:" "INFO"
|
||||||
|
log " 🚀 启动服务: ./deploy.sh start" "INFO"
|
||||||
|
log " ⏹️ 停止服务: ./deploy.sh stop" "INFO"
|
||||||
|
log " 🔄 重启服务: ./deploy.sh restart" "INFO"
|
||||||
|
log " 📋 查看日志: ./deploy.sh logs" "INFO"
|
||||||
|
log " 🔍 验证部署: ./deploy.sh verify" "INFO"
|
||||||
|
log " 🧹 清理镜像: ./deploy.sh clean" "INFO"
|
||||||
|
log " ℹ️ 显示帮助: ./deploy.sh help|-h" "INFO"
|
||||||
|
log "" "INFO"
|
||||||
|
log "🔧 前端构建选项:" "INFO"
|
||||||
|
log " 默认: 跳过前端构建,使用已上传的静态文件" "INFO"
|
||||||
|
log " 启用: ./deploy.sh --build-frontend" "INFO"
|
||||||
|
log "" "INFO"
|
||||||
}
|
}
|
||||||
|
|
||||||
# 设置信号处理
|
# 设置信号处理
|
||||||
trap handle_interrupt INT TERM
|
trap handle_interrupt INT TERM
|
||||||
|
|
||||||
|
|
||||||
# 解析命令行参数
|
# 解析命令行参数
|
||||||
# 如果没有参数,则默认执行部署流程
|
|
||||||
if [ $# -eq 0 ]; then
|
if [ $# -eq 0 ]; then
|
||||||
main "$@"
|
main "$@"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 重启容器函数
|
|
||||||
restart_containers() {
|
|
||||||
log "==========🔄 重启容器...==========" "INFO"
|
|
||||||
cd "${PROJECT_PATH}" || { log "❌ 无法进入项目目录:${PROJECT_PATH}" "ERROR"; exit 1; }
|
|
||||||
docker compose restart || { log "❌ 容器重启失败" "ERROR"; exit 1; }
|
|
||||||
log "✅ 容器重启成功" "INFO"
|
|
||||||
docker compose ps
|
|
||||||
}
|
|
||||||
|
|
||||||
# 更新代码并重启函数
|
|
||||||
update_and_restart() {
|
|
||||||
log "==========🔄 更新代码并重启...==========" "INFO"
|
|
||||||
update_code
|
|
||||||
cd "${PROJECT_PATH}" || { log "❌ 无法进入项目目录:${PROJECT_PATH}" "ERROR"; exit 1; }
|
|
||||||
docker compose restart || { log "❌ 容器重启失败" "ERROR"; exit 1; }
|
|
||||||
log "✅ 代码更新并重启成功" "INFO"
|
|
||||||
docker compose ps
|
|
||||||
}
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
|
--skip-frontend)
|
||||||
|
SKIP_FRONTEND=true
|
||||||
|
log "⚠️ 跳过前端构建" "WARN"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--build-frontend)
|
||||||
|
SKIP_FRONTEND=false
|
||||||
|
log "🔨 启用前端构建" "INFO"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
start)
|
start)
|
||||||
main
|
load_env
|
||||||
|
start_containers
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
stop)
|
stop)
|
||||||
stop_project
|
load_env
|
||||||
|
stop_containers
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
restart)
|
restart)
|
||||||
|
load_env
|
||||||
restart_containers
|
restart_containers
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
update)
|
|
||||||
update_and_restart
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
logs)
|
logs)
|
||||||
|
load_env
|
||||||
show_containers_logs
|
show_containers_logs
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
help|-h)
|
verify)
|
||||||
|
load_env
|
||||||
|
verify_deployment
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
clean)
|
||||||
|
clear_old_images
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
help|-h|--help)
|
||||||
echo "使用说明:"
|
echo "使用说明:"
|
||||||
echo " $0 [选项]"
|
echo " $0 [选项]"
|
||||||
echo ""
|
echo ""
|
||||||
echo "选项:"
|
echo "选项:"
|
||||||
echo " stop 停止所有容器"
|
echo " start 启动所有容器"
|
||||||
echo " start 启动所有容器(完整部署流程)"
|
echo " stop 停止所有容器"
|
||||||
echo " restart 重启所有容器"
|
echo " restart 重启所有容器"
|
||||||
echo " update 更新代码并重启容器(不重新构建镜像)"
|
echo " logs 查看所有容器日志"
|
||||||
echo " logs 查看所有容器日志"
|
echo " verify 验证部署状态"
|
||||||
echo " help|-h 显示此帮助信息"
|
echo " clean 清理旧镜像"
|
||||||
|
echo " --skip-frontend 跳过前端构建(默认)"
|
||||||
|
echo " --build-frontend 启用前端构建(在服务器上构建)"
|
||||||
|
echo " help|-h 显示此帮助信息"
|
||||||
echo ""
|
echo ""
|
||||||
echo "默认执行完整部署流程:"
|
echo "默认执行完整部署流程:"
|
||||||
echo " 1. 检查权限"
|
echo " 1. 检查脚本权限"
|
||||||
echo " 2. 停止现有容器"
|
echo " 2. 加载环境变量"
|
||||||
echo " 3. 更新代码"
|
echo " 3. 检查系统依赖"
|
||||||
echo " 4. 构建前端"
|
echo " 4. 停止现有容器"
|
||||||
echo " 5. 启动容器"
|
echo " 5. 更新代码"
|
||||||
echo " 6. 显示日志"
|
echo " 6. 构建前端(默认跳过,使用已上传的静态文件)"
|
||||||
|
echo " 7. 构建镜像"
|
||||||
|
echo " 8. 启动容器"
|
||||||
|
echo " 9. 验证部署"
|
||||||
|
echo " 10. 显示日志"
|
||||||
echo ""
|
echo ""
|
||||||
echo "日志查看命令:"
|
echo "日志查看命令:"
|
||||||
echo " 查看实时日志:docker compose logs -f [服务名]"
|
echo " 查看实时日志:docker compose logs -f [服务名]"
|
||||||
@@ -354,6 +596,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "未知参数: $1"
|
echo "未知参数: $1"
|
||||||
|
echo "使用 $0 help 查看帮助信息"
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -1,291 +0,0 @@
|
|||||||
# FastapiAdmin 部署文档
|
|
||||||
|
|
||||||
本文档详细描述了 FastapiAdmin 项目的部署流程、环境要求、配置说明及常见问题排查方法。
|
|
||||||
|
|
||||||
## 项目简介
|
|
||||||
|
|
||||||
FastapiAdmin 是一个基于 FastAPI 和 Vue3 的管理系统,包含后端 API、前端界面、移动端应用和文档网站。项目采用 Docker 容器化部署,便于在不同环境中快速部署和运行。
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```sh
|
|
||||||
FastapiAdmin/
|
|
||||||
├── backend/ # 后端应用代码
|
|
||||||
├── frontend/ # 前端应用代码
|
|
||||||
├── devops/ # 部署配置文件
|
|
||||||
│ ├── backend/ # 后端部署目录
|
|
||||||
│ ├── mysql/ # MySQL 部署目录
|
|
||||||
│ ├── nginx/ # Nginx 部署目录
|
|
||||||
│ ├── redis/ # Redis 部署目录
|
|
||||||
│ └── README.md # 部署说明文档
|
|
||||||
├── deploy.sh # 一键部署脚本
|
|
||||||
└── docker-compose.yaml # Docker 编排文件
|
|
||||||
```
|
|
||||||
|
|
||||||
## 系统要求
|
|
||||||
|
|
||||||
在部署项目之前,请确保服务器满足以下要求:
|
|
||||||
|
|
||||||
- **操作系统**:Linux (推荐 Ubuntu 20.04+) 或 macOS
|
|
||||||
- **Docker**:20.10.0 或更高版本
|
|
||||||
- **Docker Compose**:2.0.0 或更高版本
|
|
||||||
- **Git**:2.0.0 或更高版本
|
|
||||||
- **Node.js**:16.0.0 或更高版本
|
|
||||||
- **npm/pnpm**:8.0.0 或更高版本
|
|
||||||
- **内存**:至少 4GB RAM
|
|
||||||
- **磁盘空间**:至少 20GB 可用空间
|
|
||||||
|
|
||||||
## 部署前准备
|
|
||||||
|
|
||||||
### 1. 环境检查
|
|
||||||
|
|
||||||
首先,检查系统是否已安装必要的依赖:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 检查 Docker 版本
|
|
||||||
docker --version
|
|
||||||
# 检查 Docker Compose 版本
|
|
||||||
docker compose version
|
|
||||||
# 检查 Git 版本
|
|
||||||
git --version
|
|
||||||
# 检查 Node.js 版本
|
|
||||||
node --version
|
|
||||||
# 检查 npm 版本
|
|
||||||
npm --version
|
|
||||||
# 检查 pnpm 版本
|
|
||||||
pnpm --version
|
|
||||||
```
|
|
||||||
|
|
||||||
如果缺少任何依赖,请先安装它们。
|
|
||||||
|
|
||||||
### 2. 克隆项目
|
|
||||||
|
|
||||||
如果尚未克隆项目代码,可以使用以下命令:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://gitee.com/fastapiadmin/fastapiadmin.git
|
|
||||||
cd fastapiadmin
|
|
||||||
```
|
|
||||||
|
|
||||||
## 一键部署
|
|
||||||
|
|
||||||
项目提供了一键部署脚本 `deploy.sh`,可以自动完成从代码拉取到服务启动的全过程。
|
|
||||||
|
|
||||||
### 1. 赋予脚本执行权限
|
|
||||||
|
|
||||||
```bash
|
|
||||||
chmod +x deploy.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 执行部署脚本
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./deploy.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
部署脚本会自动执行以下步骤:
|
|
||||||
|
|
||||||
- 检查系统依赖
|
|
||||||
- 停止现有容器(如果存在)
|
|
||||||
- 拉取最新代码
|
|
||||||
- 构建 Docker 镜像
|
|
||||||
- 启动容器
|
|
||||||
- 显示容器状态和日志
|
|
||||||
|
|
||||||
## 部署脚本命令详解
|
|
||||||
|
|
||||||
部署脚本支持以下命令行参数:
|
|
||||||
|
|
||||||
### 完整部署(默认)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./deploy.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
执行完整的部署流程,包括检查依赖、停止容器、更新代码、构建镜像、启动容器和显示日志。
|
|
||||||
|
|
||||||
### 仅停止服务
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./deploy.sh --stop
|
|
||||||
```
|
|
||||||
|
|
||||||
停止所有正在运行的项目容器。
|
|
||||||
|
|
||||||
### 启动服务
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./deploy.sh --start
|
|
||||||
```
|
|
||||||
|
|
||||||
启动所有服务(等同于执行完整部署流程)。
|
|
||||||
|
|
||||||
### 查看日志
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./deploy.sh --logs
|
|
||||||
# 或简写
|
|
||||||
./deploy.sh -l
|
|
||||||
```
|
|
||||||
|
|
||||||
显示所有容器的日志信息。
|
|
||||||
|
|
||||||
### 查看帮助信息
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./deploy.sh --help
|
|
||||||
# 或简写
|
|
||||||
./deploy.sh -h
|
|
||||||
```
|
|
||||||
|
|
||||||
显示脚本使用说明。
|
|
||||||
|
|
||||||
## 部署流程详解
|
|
||||||
|
|
||||||
部署脚本的执行流程如下:
|
|
||||||
|
|
||||||
1. **权限和依赖检查**
|
|
||||||
- 检查脚本执行权限
|
|
||||||
- 检查系统依赖(git、docker、node、npm、pnpm)
|
|
||||||
|
|
||||||
2. **停止现有容器**
|
|
||||||
- 如果项目目录存在,停止并删除现有容器
|
|
||||||
- 如果项目目录不存在,从 Git 仓库克隆代码
|
|
||||||
|
|
||||||
3. **更新代码**
|
|
||||||
- 拉取最新的代码更新
|
|
||||||
- 检测前端、移动端和文档目录是否存在
|
|
||||||
|
|
||||||
4. **构建前端(当前已注释,如需启用请取消注释 deploy.sh 中的 build_frontend 调用)**
|
|
||||||
- 安装前端依赖并构建前端工程
|
|
||||||
- 安装移动端依赖并构建移动端工程
|
|
||||||
- 安装文档依赖并构建文档
|
|
||||||
|
|
||||||
5. **构建镜像和启动容器**
|
|
||||||
- 构建项目所需的 Docker 镜像
|
|
||||||
- 启动所有服务容器
|
|
||||||
- 清理 72 小时前的旧镜像
|
|
||||||
|
|
||||||
6. **显示容器状态和日志**
|
|
||||||
- 显示所有容器的运行状态
|
|
||||||
- 显示各服务的日志信息
|
|
||||||
|
|
||||||
## 服务访问
|
|
||||||
|
|
||||||
部署完成后,可以通过以下地址访问各服务:
|
|
||||||
|
|
||||||
- **前端管理系统**:[https://service.fastapiadmin.com/web](https://service.fastapiadmin.com/web)
|
|
||||||
- **移动端应用**:[https://service.fastapiadmin.com/app](https://service.fastapiadmin.com/app)
|
|
||||||
- **项目文档**:[https://service.fastapiadmin.com](https://service.fastapiadmin.com)
|
|
||||||
- **API 文档**:[https://service.fastapiadmin.com/api/v1/docs](https://service.fastapiadmin.com/api/v1/docs)
|
|
||||||
|
|
||||||
默认登录信息:
|
|
||||||
|
|
||||||
- **用户名**:admin
|
|
||||||
- **密码**:123456
|
|
||||||
|
|
||||||
> **注意**:实际部署时,请根据服务器配置修改域名或使用 IP 地址访问。
|
|
||||||
|
|
||||||
## 容器管理
|
|
||||||
|
|
||||||
### 查看容器状态
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose ps
|
|
||||||
```
|
|
||||||
|
|
||||||
### 查看实时日志
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查看所有服务的实时日志
|
|
||||||
docker compose logs -f
|
|
||||||
|
|
||||||
# 查看单个服务的实时日志(例如后端服务)
|
|
||||||
docker compose logs -f backend
|
|
||||||
```
|
|
||||||
|
|
||||||
### 停止服务
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose down
|
|
||||||
```
|
|
||||||
|
|
||||||
### 重启服务
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose restart
|
|
||||||
```
|
|
||||||
|
|
||||||
### 进入容器
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 进入后端容器
|
|
||||||
docker compose exec backend bash
|
|
||||||
|
|
||||||
# 进入数据库容器
|
|
||||||
docker compose exec mysql bash
|
|
||||||
```
|
|
||||||
|
|
||||||
## 问题排查
|
|
||||||
|
|
||||||
### 常见问题
|
|
||||||
|
|
||||||
#### 1. 端口冲突
|
|
||||||
|
|
||||||
如果启动容器时出现端口冲突,请检查是否有其他服务占用了相同的端口,并在 docker-compose.yaml 中修改端口映射。
|
|
||||||
|
|
||||||
#### 2. 数据库连接失败
|
|
||||||
|
|
||||||
- 检查数据库容器是否正常运行
|
|
||||||
- 确认数据库配置是否正确(用户名、密码、数据库名)
|
|
||||||
- 查看后端服务日志获取详细错误信息
|
|
||||||
|
|
||||||
#### 3. 前端访问后端 API 失败
|
|
||||||
|
|
||||||
- 检查 Nginx 配置是否正确
|
|
||||||
- 确认 API 路径配置是否匹配
|
|
||||||
- 查看浏览器控制台的网络请求和错误信息
|
|
||||||
|
|
||||||
### 配置文件
|
|
||||||
|
|
||||||
以下是可能需要修改的关键配置文件:
|
|
||||||
|
|
||||||
1. **后端配置**:`backend/env/.env.prod.py`
|
|
||||||
2. **前端配置**:`frontend/vite.config.ts` 和 `frontend/.env.production`
|
|
||||||
3. **Docker 配置**:`docker-compose.yaml`
|
|
||||||
4. **Nginx 配置**:`devops/nginx/nginx.conf`
|
|
||||||
|
|
||||||
### 日志查看
|
|
||||||
|
|
||||||
遇到问题时,建议先查看相关服务的日志:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查看后端服务日志
|
|
||||||
docker compose logs backend
|
|
||||||
|
|
||||||
# 查看 Nginx 服务日志
|
|
||||||
docker compose logs nginx
|
|
||||||
|
|
||||||
# 查看 MySQL 服务日志
|
|
||||||
docker compose logs mysql
|
|
||||||
|
|
||||||
# 查看 Redis 服务日志
|
|
||||||
docker compose logs redis
|
|
||||||
```
|
|
||||||
|
|
||||||
## 安全建议
|
|
||||||
|
|
||||||
1. **修改默认密码**:部署后请立即修改默认的管理员密码
|
|
||||||
2. **配置 HTTPS**:在生产环境中请配置 SSL 证书,启用 HTTPS
|
|
||||||
3. **限制访问**:根据实际需求配置防火墙规则,限制对敏感服务的访问
|
|
||||||
4. **定期更新**:定期拉取最新代码并更新部署,以获取安全补丁和新功能
|
|
||||||
|
|
||||||
## 维护建议
|
|
||||||
|
|
||||||
1. **定期备份**:定期备份数据库和重要配置文件
|
|
||||||
2. **监控服务**:设置服务监控,及时发现并处理异常情况
|
|
||||||
3. **清理镜像**:定期清理不再使用的 Docker 镜像,释放磁盘空间
|
|
||||||
|
|
||||||
## 联系与支持
|
|
||||||
|
|
||||||
如有任何问题或需要技术支持,请联系项目维护者。
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# 使用官方的 Python 3.10 镜像作为基础镜像
|
|
||||||
FROM python:3.10
|
|
||||||
|
|
||||||
# 使用 LABEL 替代 MAINTAINER
|
|
||||||
LABEL maintainer="948080782@qq.com"
|
|
||||||
|
|
||||||
# 设置时区
|
|
||||||
ENV TZ Asia/Shanghai
|
|
||||||
|
|
||||||
# 设置容器内工作目录
|
|
||||||
WORKDIR /home
|
|
||||||
|
|
||||||
# 将当前主机目录全部文件复制至容器工作目录
|
|
||||||
COPY ./backend/requirements.txt .
|
|
||||||
|
|
||||||
# 安装依赖
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
|
|
||||||
|
|
||||||
# 运行应用
|
|
||||||
CMD ["python", "main.py", "run", "--env=prod"]
|
|
||||||
|
|
||||||
# 暴露端口
|
|
||||||
EXPOSE 8001
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
# 应用服务
|
|
||||||
services:
|
|
||||||
# 数据库服务
|
|
||||||
mysql:
|
|
||||||
container_name: mysql
|
|
||||||
image: mysql:8.0
|
|
||||||
restart: always
|
|
||||||
environment:
|
|
||||||
TZ: "Asia/Shanghai"
|
|
||||||
MYSQL_ROOT_PASSWORD: "your_mysql_root_password"
|
|
||||||
MYSQL_DATABASE: "fastapiadmin"
|
|
||||||
MYSQL_PASSWORD: "your_mysql_password"
|
|
||||||
MYSQL_USER: "fastapiadmin"
|
|
||||||
MYSQL_TZINFO_TO_SYS_TABLES: 1 # 初始化MySQL时区表,解决时差问题
|
|
||||||
ports:
|
|
||||||
- "3306:3306"
|
|
||||||
volumes:
|
|
||||||
# 挂载数据卷,持久化MySQL数据(docker-compose down不会删除数据)
|
|
||||||
- ./devops/mysql/data:/var/lib/mysql
|
|
||||||
# 挂载宿主机时区文件,双重保障时区同步(只读,避免容器修改)
|
|
||||||
- /etc/localtime:/etc/localtime:ro
|
|
||||||
- /etc/timezone:/etc/timezone:ro
|
|
||||||
networks:
|
|
||||||
- app_network
|
|
||||||
# 健康检查:检测MySQL是否真正就绪,避免后端启动早于MySQL
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pyour_mysql_root_password"]
|
|
||||||
interval: 5s # 每5秒检测一次
|
|
||||||
timeout: 30s # 超时时间30秒
|
|
||||||
retries: 10 # 重试10次,失败则认为容器未就绪
|
|
||||||
start_period: 20s # 容器启动后,延迟20秒开始检测
|
|
||||||
|
|
||||||
# redis服务
|
|
||||||
redis:
|
|
||||||
container_name: redis
|
|
||||||
image: redis:7.0
|
|
||||||
restart: always
|
|
||||||
environment:
|
|
||||||
TZ: "Asia/Shanghai"
|
|
||||||
ports:
|
|
||||||
- "6379:6379"
|
|
||||||
volumes:
|
|
||||||
# 挂载Redis配置文件,实现自定义配置
|
|
||||||
- ./devops/redis/data:/data
|
|
||||||
command: redis-server --requirepass your_redis_password
|
|
||||||
networks:
|
|
||||||
- app_network
|
|
||||||
|
|
||||||
# 后端服务
|
|
||||||
backend:
|
|
||||||
container_name: backend
|
|
||||||
build:
|
|
||||||
context: ./
|
|
||||||
dockerfile: ./devops/backend/Dockerfile
|
|
||||||
image: backend:latest
|
|
||||||
restart: always
|
|
||||||
environment:
|
|
||||||
TZ: "Asia/Shanghai"
|
|
||||||
DATABASE_HOST: "mysql"
|
|
||||||
DATABASE_PASSWORD: "your_mysql_password"
|
|
||||||
REDIS_HOST: "redis"
|
|
||||||
REDIS_PASSWORD: "your_redis_password"
|
|
||||||
ports:
|
|
||||||
- "8001:8001"
|
|
||||||
volumes:
|
|
||||||
- ./backend:/home
|
|
||||||
depends_on:
|
|
||||||
- mysql
|
|
||||||
- redis
|
|
||||||
networks:
|
|
||||||
- app_network
|
|
||||||
|
|
||||||
# 前端nginx服务
|
|
||||||
nginx:
|
|
||||||
container_name: nginx
|
|
||||||
image: nginx:latest
|
|
||||||
restart: always
|
|
||||||
environment:
|
|
||||||
TZ: "Asia/Shanghai"
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
- "443:443"
|
|
||||||
volumes:
|
|
||||||
- ./devops/nginx/nginx.conf:/etc/nginx/nginx.conf
|
|
||||||
# 挂载前端dist目录,实现动态加载前端资源
|
|
||||||
- ./devops/nginx/frontend:/usr/share/nginx/html/frontend
|
|
||||||
# 挂载H5dist目录,实现动态加载H5资源
|
|
||||||
- ./devops/nginx/fastapp:/usr/share/nginx/html/fastapp
|
|
||||||
# 挂载官网dist目录,实现动态加载文档资源
|
|
||||||
- ./devops/nginx/fastdocs:/usr/share/nginx/html/fastdocs
|
|
||||||
# ssl 证书,如果无证书则注释这行,并在nginx.conf中注释ssl相关配置
|
|
||||||
- ./devops/nginx/ssl:/etc/nginx/ssl:ro
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
networks:
|
|
||||||
- app_network
|
|
||||||
|
|
||||||
# 网络
|
|
||||||
networks:
|
|
||||||
app_network:
|
|
||||||
driver: bridge
|
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
# 数据库配置
|
||||||
|
MYSQL_ROOT_PASSWORD=fastapiadmin_root
|
||||||
|
MYSQL_DATABASE=fastapiadmin
|
||||||
|
MYSQL_USER=fastapiadmin
|
||||||
|
MYSQL_PASSWORD=fastapiadmin123
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
|
||||||
|
# Redis配置
|
||||||
|
REDIS_PASSWORD=fastapiadmin_redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
# 后端配置
|
||||||
|
BACKEND_PORT=8001
|
||||||
|
|
||||||
|
# Nginx配置
|
||||||
|
HTTP_PORT=80
|
||||||
|
HTTPS_PORT=443
|
||||||
|
|
||||||
|
# 项目配置
|
||||||
|
PROJECT_NAME=FastapiAdmin
|
||||||
|
|
||||||
|
# 部署环境
|
||||||
|
DEPLOY_ENV=prod
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 数据库配置
|
||||||
|
MYSQL_ROOT_PASSWORD=your_mysql_root_password
|
||||||
|
MYSQL_DATABASE=fastapiadmin
|
||||||
|
MYSQL_USER=fastapiadmin
|
||||||
|
MYSQL_PASSWORD=your_mysql_password
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
|
||||||
|
# Redis配置
|
||||||
|
REDIS_PASSWORD=your_redis_password
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
# 后端配置
|
||||||
|
BACKEND_PORT=8001
|
||||||
|
|
||||||
|
# Nginx配置
|
||||||
|
HTTP_PORT=80
|
||||||
|
HTTPS_PORT=443
|
||||||
|
|
||||||
|
# 项目配置
|
||||||
|
PROJECT_NAME=FastapiAdmin
|
||||||
|
|
||||||
|
# 部署环境
|
||||||
|
DEPLOY_ENV=prod
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# FastapiAdmin 部署说明
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
docker/
|
||||||
|
├── backend/ # 后端服务配置
|
||||||
|
│ └── Dockerfile # 后端Dockerfile
|
||||||
|
├── nginx/ # Nginx配置
|
||||||
|
│ ├── nginx.conf # Nginx配置文件
|
||||||
|
│ ├── ssl/ # SSL证书目录
|
||||||
|
│ ├── web/ # 前端静态文件
|
||||||
|
│ ├── app/ # 小程序静态文件
|
||||||
|
│ └── docs/ # 文档静态文件
|
||||||
|
├── mysql/ # MySQL数据目录
|
||||||
|
│ └── data/ # MySQL数据持久化目录
|
||||||
|
├── redis/ # Redis数据目录
|
||||||
|
│ └── data/ # Redis数据持久化目录
|
||||||
|
├── docker-compose.yaml # Docker Compose配置文件
|
||||||
|
├── .env # 环境变量配置文件
|
||||||
|
├── .env.example # 环境变量示例文件
|
||||||
|
└── README.md # 部署说明文档
|
||||||
|
```
|
||||||
|
|
||||||
|
## 部署准备
|
||||||
|
|
||||||
|
### 系统依赖
|
||||||
|
|
||||||
|
- Git
|
||||||
|
- Docker
|
||||||
|
- Docker Compose
|
||||||
|
- Node.js (前端构建需要)
|
||||||
|
- pnpm (前端构建需要)
|
||||||
|
|
||||||
|
### 环境配置
|
||||||
|
|
||||||
|
1. **配置环境变量**
|
||||||
|
|
||||||
|
复制 `.env.example` 文件为 `.env`,并根据实际情况修改配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
主要配置项:
|
||||||
|
|
||||||
|
- `MYSQL_ROOT_PASSWORD`: MySQL根密码
|
||||||
|
- `MYSQL_DATABASE`: 数据库名
|
||||||
|
- `MYSQL_USER`: 数据库用户
|
||||||
|
- `MYSQL_PASSWORD`: 数据库密码
|
||||||
|
- `REDIS_PASSWORD`: Redis密码
|
||||||
|
- `BACKEND_PORT`: 后端服务端口
|
||||||
|
- `HTTP_PORT`: HTTP端口
|
||||||
|
- `HTTPS_PORT`: HTTPS端口
|
||||||
|
|
||||||
|
2. **SSL证书配置** (可选)
|
||||||
|
|
||||||
|
如果需要HTTPS,将SSL证书文件放在 `nginx/ssl/` 目录下:
|
||||||
|
- `server.key`: 私钥文件
|
||||||
|
- `server.pem`: 证书文件
|
||||||
|
|
||||||
|
## 部署流程
|
||||||
|
|
||||||
|
### 1. 执行部署脚本
|
||||||
|
|
||||||
|
在项目根目录执行部署脚本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
部署脚本会自动执行以下步骤:
|
||||||
|
|
||||||
|
1. 加载环境变量
|
||||||
|
2. 检查系统依赖
|
||||||
|
3. 停止现有容器
|
||||||
|
4. 更新代码
|
||||||
|
5. 构建前端(可选)
|
||||||
|
6. 构建镜像
|
||||||
|
7. 启动容器
|
||||||
|
8. 显示日志
|
||||||
|
|
||||||
|
### 2. 命令选项
|
||||||
|
|
||||||
|
部署脚本支持以下命令选项:
|
||||||
|
|
||||||
|
- `./deploy.sh start` - 启动所有容器
|
||||||
|
- `./deploy.sh stop` - 停止所有容器
|
||||||
|
- `./deploy.sh restart` - 重启所有容器
|
||||||
|
- `./deploy.sh logs` - 查看所有容器日志
|
||||||
|
- `./deploy.sh help` - 显示帮助信息
|
||||||
|
|
||||||
|
## 访问信息
|
||||||
|
|
||||||
|
部署完成后,可以通过以下地址访问:
|
||||||
|
|
||||||
|
- **官网**: https://域名(或ip:端口)
|
||||||
|
- **前端**: https://域名(或ip:端口)/web
|
||||||
|
- **小程序**: https://域名(或ip:端口)/app
|
||||||
|
- **登录信息**: 账号 admin,密码 123456
|
||||||
|
|
||||||
|
## 日志管理
|
||||||
|
|
||||||
|
### 查看容器日志
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看所有容器日志
|
||||||
|
./deploy.sh logs
|
||||||
|
|
||||||
|
# 查看实时日志
|
||||||
|
cd docker
|
||||||
|
docker compose logs -f [服务名]
|
||||||
|
|
||||||
|
# 服务名:backend, nginx, mysql, redis
|
||||||
|
```
|
||||||
|
|
||||||
|
### 清理旧镜像
|
||||||
|
|
||||||
|
部署脚本会自动清理72小时前的旧镜像,以节省磁盘空间。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 1. 环境变量文件不存在
|
||||||
|
|
||||||
|
如果 `.env` 文件不存在,部署脚本会自动从 `.env.example` 创建。
|
||||||
|
|
||||||
|
### 2. 前端构建失败
|
||||||
|
|
||||||
|
如果服务器资源不足,建议在本地构建前端,然后将 `dist` 目录上传到 `docker/nginx/` 对应目录。
|
||||||
|
|
||||||
|
### 3. 容器启动失败
|
||||||
|
|
||||||
|
检查容器日志,查看具体错误信息:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docker
|
||||||
|
docker compose logs [服务名]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 数据库连接失败
|
||||||
|
|
||||||
|
确保 `.env` 文件中的数据库配置正确,并且MySQL容器正常运行。
|
||||||
|
|
||||||
|
### 5. 端口冲突
|
||||||
|
|
||||||
|
如果端口被占用,修改 `.env` 文件中的端口配置。
|
||||||
|
|
||||||
|
## 安全建议
|
||||||
|
|
||||||
|
1. **修改默认密码**:部署后请修改默认的数据库密码和Redis密码
|
||||||
|
2. **更新SSL证书**:使用有效的SSL证书,定期更新
|
||||||
|
3. **限制访问**:配置防火墙,限制不必要的端口访问
|
||||||
|
4. **定期备份**:定期备份数据库和重要数据
|
||||||
|
|
||||||
|
## 版本更新
|
||||||
|
|
||||||
|
当项目有新版本时,执行部署脚本即可自动更新:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会自动拉取最新代码,构建新镜像,重启容器。
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# 第一阶段:构建依赖
|
||||||
|
FROM python:3.10-slim as builder
|
||||||
|
|
||||||
|
# 设置时区
|
||||||
|
ENV TZ Asia/Shanghai
|
||||||
|
|
||||||
|
# 设置容器内工作目录
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# 安装系统依赖
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 复制依赖文件
|
||||||
|
COPY ./backend/requirements.txt .
|
||||||
|
|
||||||
|
# 安装依赖到指定目录
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple \
|
||||||
|
--target=/home/deps
|
||||||
|
|
||||||
|
# 第二阶段:运行环境
|
||||||
|
FROM python:3.10-slim
|
||||||
|
|
||||||
|
# 使用 LABEL 替代 MAINTAINER
|
||||||
|
LABEL maintainer="948080782@qq.com"
|
||||||
|
|
||||||
|
# 设置时区
|
||||||
|
ENV TZ Asia/Shanghai
|
||||||
|
|
||||||
|
# 创建非root用户
|
||||||
|
RUN useradd -m -u 1001 fastapiadmin
|
||||||
|
|
||||||
|
# 设置容器内工作目录
|
||||||
|
WORKDIR /home
|
||||||
|
|
||||||
|
# 从构建阶段复制依赖
|
||||||
|
COPY --from=builder /home/deps /home/deps
|
||||||
|
|
||||||
|
# 设置PYTHONPATH
|
||||||
|
ENV PYTHONPATH=/home/deps:$PYTHONPATH
|
||||||
|
|
||||||
|
# 复制应用代码
|
||||||
|
COPY ./backend/ .
|
||||||
|
|
||||||
|
# 更改文件权限
|
||||||
|
RUN chown -R fastapiadmin:fastapiadmin /home
|
||||||
|
|
||||||
|
# 切换到非root用户
|
||||||
|
USER fastapiadmin
|
||||||
|
|
||||||
|
# 运行应用
|
||||||
|
CMD ["python", "main.py", "run", "--env=prod"]
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 8001
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# 小程序静态文件目录
|
||||||
|
|
||||||
|
请将小程序 H5 构建产物放置于此目录。
|
||||||
|
|
||||||
|
构建后应包含:
|
||||||
|
- index.html
|
||||||
|
- static/ (或其他静态资源)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# 官网静态文件目录
|
||||||
|
|
||||||
|
请将官网构建产物放置于此目录。
|
||||||
|
|
||||||
|
构建后应包含:
|
||||||
|
- index.html
|
||||||
|
- static/ (或其他静态资源)
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
########### 每个指令必须有分号结束。#################
|
########### 每个指令必须有分号结束。#################
|
||||||
worker_processes 1; # 允许生成的进程数,默认为1:
|
worker_processes 1; # 允许生成的进程数,默认为1:
|
||||||
pid /var/run/nginx.pid; # 指定 Nginx 进程运行文件存放地址
|
pid /var/run/nginx.pid; # 指定 Nginx 进程运行文件存放地址
|
||||||
error_log /var/log/nginx/error.log;
|
error_log /var/log/nginx/error.log;
|
||||||
|
|
||||||
events {
|
events {
|
||||||
accept_mutex on; # 设置网络连接序列化,防止惊群现象发生,默认为on
|
accept_mutex on; # 设置网络连接序列化,防止惊群现象发生,默认为on
|
||||||
@@ -20,10 +20,8 @@ http {
|
|||||||
|
|
||||||
# 访问服务日志
|
# 访问服务日志
|
||||||
access_log on;
|
access_log on;
|
||||||
|
sendfile on;
|
||||||
sendfile on; # 允许sendfile方式传输文件,默认为off,可以在http块、server块、location块
|
keepalive_timeout 75;
|
||||||
|
|
||||||
keepalive_timeout 75; # 连接超时时间,默认为75秒,可以在http、server、location块
|
|
||||||
|
|
||||||
# HTTP server块 - 重定向到HTTPS
|
# HTTP server块 - 重定向到HTTPS
|
||||||
server {
|
server {
|
||||||
@@ -48,30 +46,30 @@ http {
|
|||||||
ssl_session_cache shared:SSL:10m;
|
ssl_session_cache shared:SSL:10m;
|
||||||
ssl_session_timeout 10m;
|
ssl_session_timeout 10m;
|
||||||
|
|
||||||
# 官网代理 - 根路径访问官网
|
# 官网 - 根路径访问官网
|
||||||
location / {
|
location / {
|
||||||
root /usr/share/nginx/html/fastdocs/dist;
|
root /usr/share/nginx/html/docs/dist;
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
try_files $uri $uri/ /index.html; #解决页面刷新404问题
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 前端代理 - /web访问前端
|
# 前端 - /web访问前端
|
||||||
location /web {
|
location /web {
|
||||||
alias /usr/share/nginx/html/frontend/dist;
|
alias /usr/share/nginx/html/web/dist;
|
||||||
try_files $uri $uri/ /web/index.html; #解决页面刷新404问题
|
try_files $uri $uri/ /web/index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 小程序代理 - /app访问小程序
|
# 小程序 - /app访问小程序
|
||||||
location /app {
|
location /app {
|
||||||
alias /usr/share/nginx/html/fastapp/dist/build/h5;
|
alias /usr/share/nginx/html/app/dist/build/h5;
|
||||||
try_files $uri $uri/ /app/index.html; #解决页面刷新404问题
|
try_files $uri $uri/ /app/index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 后端代理
|
# 后端代理
|
||||||
location /api/v1 {
|
location /api/v1 {
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-NginX-Proxy true;
|
proxy_set_header X-NginX-Proxy true;
|
||||||
proxy_connect_timeout 300s;
|
proxy_connect_timeout 300s;
|
||||||
@@ -85,9 +83,9 @@ http {
|
|||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
}
|
}
|
||||||
|
|
||||||
error_page 500 502 503 504 /50x.html;
|
error_page 500 502 503 504 /50x.html;
|
||||||
location = /50x.html {
|
location = /50x.html {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-----BEGIN RSA PRIVATE KEY-----
|
||||||
|
MIIEowIBAAKCAQEAzttQDKbMEkUh3KpmuwnpjvO7PP/x6OzI86kEaCL/LfHrNOu+
|
||||||
|
leXFrxk7dBcfbLyqpmh7uBZeIUg8qnl4e6oFd7DXdX9bT1tiJJRtngIqUVL/lzoN
|
||||||
|
5TMVVN6s3Kbuhe1siMkDu59MF1BIDfAQOgNQFRoO3edQtGiOHt2z41fX75npuo7Z
|
||||||
|
2yXHFDPeBBK3mP0Y60hju2ACYhdI9ZRkbUu5bo/gZbrb4/CZhEZIbU2n1mexg6mc
|
||||||
|
Vmz2DpIt0ABklT4vx1dcWMBXm8tK8KR3WM7KGYvavOR8dBGPTfcrzZ7DumHPvgZI
|
||||||
|
XYEmLqf9/fOBNzGic+4x1XXtqrgexWqCRs97JQIDAQABAoIBAALTOWeZDSHj20Ml
|
||||||
|
h/ZswrtVBH7K1aBYsjGr8KdpcLm/FD5VD+dW9md62a8vJeHjr02gn1hh5H0ap2c7
|
||||||
|
ErDIyfDiCQV/np0EdFISkuDngzjGx5QRNoUJONhN1K6oyTVGteMl6XBq0wkMCCRW
|
||||||
|
XdUIjf5iAcejgiq3Q4IUE2V62tCf00T/7CKHGIACDGsgvVIl3QXvFy//tchnOJi1
|
||||||
|
z7WygRRXmKrvf2hFERwhp7h8qyFGS0RChtVY8W8rT3p0YjATQn75+T9K2LBZsPSW
|
||||||
|
/cMPIBOsJCxbXQqqSLLKmmzoaeA1rduaSVDVRH15aRJ77kPHqnTAdrgmc7uO1gBi
|
||||||
|
1vZKRkECgYEA7iFTC6uo02QfECi+Otded2w6OvSw5TjuRqlK8xeP/deQ1TNj2Gtz
|
||||||
|
y+nywdFeymqNYMU1pBH3zJQFyDTd2N5ztR7XETUVqh2/i3gRcgj7l20fv52YNhsq
|
||||||
|
p86OigS3lHMYSkzPx+VKkBJyc7JEp28aOFs6xozbX5WJgnu4pwvsQ+ECgYEA3mEy
|
||||||
|
9sg/+ZXFEw3MY+e34N//fXV4DgRn8ssZAHepC/Te0QPc2a77HQ76B399hYT/Cm6m
|
||||||
|
cNfZ+0FKig+NE+KdMFZ4khcqnI+nfcwzlOCY3AltHo4SJbOWSW/AH1/SiszPHnWi
|
||||||
|
/RtEHIgxUO6Vg8odCRTMjI5Tn/SzHTesavanH8UCgYAaZNcvk/dCdvZ6dAmhu/1c
|
||||||
|
f0J1BmoU/hpq+pi0GQh8ukfzWZ7XfhF3qJafpPU8ulKxRyqK5PLyWfXKGxH716w7
|
||||||
|
hw+PeLLOCD4S11wXp6c7QX0jX/RfCuPPdbWW1q8CZT9r/NeK8H8zONg2uquhY08k
|
||||||
|
r/fzyekGzyxOqiftXAT0AQKBgAqHcaFLo/POSpF3YskMLKGiGXe4OyH2xXEyepGS
|
||||||
|
GNfvFSmyVB+ecFdU4TO/rpLG1EiLKqhHIBQMaFoXGGfrn+OtBHJuVF8rUhPbukl/
|
||||||
|
RN2OOLNsqKFOWnxJpUAXCmH2Box7p2byhowPSr55PW0LAcrvSCwThRQawuxMOYaq
|
||||||
|
/Qi9AoGBANuVlK6PGaE9C2F8aOdzV40/c1pugDMGy1UhiVTdf4tNXWUAyFXjpPKO
|
||||||
|
9BW9iUC0BnYNflJwuejYwzMpu0SQTWpwNTgZ2kvXTq5k+3p+WB6PYuz6otfvZNbG
|
||||||
|
0AaTXSlFK9XQ5cIoVbUKzZGLX/g38dPYIm5rRnP6O6NH/E4AiAo2
|
||||||
|
-----END RSA PRIVATE KEY-----
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIGJjCCBQ6gAwIBAgIQD74yY155PWDBA/hoM+TguTANBgkqhkiG9w0BAQsFADBu
|
||||||
|
MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
|
||||||
|
d3cuZGlnaWNlcnQuY29tMS0wKwYDVQQDEyRFbmNyeXB0aW9uIEV2ZXJ5d2hlcmUg
|
||||||
|
RFYgVExTIENBIC0gRzIwHhcNMjUwODE4MDAwMDAwWhcNMjYwODE3MjM1OTU5WjAj
|
||||||
|
MSEwHwYDVQQDExhzZXJ2aWNlLmZhc3RhcGlhZG1pbi5jb20wggEiMA0GCSqGSIb3
|
||||||
|
DQEBAQUAA4IBDwAwggEKAoIBAQDO21AMpswSRSHcqma7CemO87s8//Ho7MjzqQRo
|
||||||
|
Iv8t8es0676V5cWvGTt0Fx9svKqmaHu4Fl4hSDyqeXh7qgV3sNd1f1tPW2IklG2e
|
||||||
|
AipRUv+XOg3lMxVU3qzcpu6F7WyIyQO7n0wXUEgN8BA6A1AVGg7d51C0aI4e3bPj
|
||||||
|
V9fvmem6jtnbJccUM94EEreY/RjrSGO7YAJiF0j1lGRtS7luj+Blutvj8JmERkht
|
||||||
|
TafWZ7GDqZxWbPYOki3QAGSVPi/HV1xYwFeby0rwpHdYzsoZi9q85Hx0EY9N9yvN
|
||||||
|
nsO6Yc++BkhdgSYup/3984E3MaJz7jHVde2quB7FaoJGz3slAgMBAAGjggMJMIID
|
||||||
|
BTAfBgNVHSMEGDAWgBR435GQX+7erPbFdevVTFVT7yRKtjAdBgNVHQ4EFgQUH6rO
|
||||||
|
Uuzt5oaI+OKEH1/CTKptVDUwQQYDVR0RBDowOIIYc2VydmljZS5mYXN0YXBpYWRt
|
||||||
|
aW4uY29tghx3d3cuc2VydmljZS5mYXN0YXBpYWRtaW4uY29tMD4GA1UdIAQ3MDUw
|
||||||
|
MwYGZ4EMAQIBMCkwJwYIKwYBBQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNlcnQuY29t
|
||||||
|
L0NQUzAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUF
|
||||||
|
BwMCMIGABggrBgEFBQcBAQR0MHIwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRp
|
||||||
|
Z2ljZXJ0LmNvbTBKBggrBgEFBQcwAoY+aHR0cDovL2NhY2VydHMuZGlnaWNlcnQu
|
||||||
|
Y29tL0VuY3J5cHRpb25FdmVyeXdoZXJlRFZUTFNDQS1HMi5jcnQwDAYDVR0TAQH/
|
||||||
|
BAIwADCCAX4GCisGAQQB1nkCBAIEggFuBIIBagFoAHYA1219ENGn9XfCx+lf1wC/
|
||||||
|
+YLJM1pl4dCzAXMXwMjFaXcAAAGYvZSjUAAABAMARzBFAiEA/PxBgTEUC5gfKL20
|
||||||
|
ZqrgGdbcySamDtflh3JdxR4JQXUCIEt/ujuodbeG2nrxnfkt+L+h08maf3Qm0TiS
|
||||||
|
c994RCM+AHYAwjF+V0UZo0XufzjespBB68fCIVoiv3/Vta12mtkOUs0AAAGYvZSj
|
||||||
|
igAABAMARzBFAiAlz51wosOOTyuxzj6LrdIYuShO0FHTSpWJrF0B/bHHKQIhANkS
|
||||||
|
QaufbcAKIChv8Jt7zBgPdhGkT+2MlZVERwanjHliAHYAlE5Dh/rswe+B8xkkJqgY
|
||||||
|
ZQHH0184AgE/cmd9VTcuGdgAAAGYvZSjnQAABAMARzBFAiB+MXarEEExSmxyrTXV
|
||||||
|
RLt4eDU/wXu2n3xaYQagt8WMHwIhAIneSXcWtQpBc/dyQL3TH/egJDjgMgl8l/F+
|
||||||
|
F6VOA30RMA0GCSqGSIb3DQEBCwUAA4IBAQAv3uihVpCQL8LmE20ztwrkj2E4YjuG
|
||||||
|
CCb2eQp0n3KrUTU5Glsi/8leDTSLFRB5yiPtsE3S1L8AJGgZYMqwXmg/9kGIJJrP
|
||||||
|
hseJHjfjCntPhiWm9roN1NBeqBPiaJzjg/YqK1t5Uoz350Byyx2UZGkVSjRpy7tz
|
||||||
|
iy3ZvDfTQhpZxjsWEO6NsKlzu96JgnscYvUkVTyf9guGuqVE4lrWk3/6rw4WhDSc
|
||||||
|
IPUPFaw296ooa6aITyWzg2CqIuusrHYV2ATgSHzQZeY7vJbIivrXPPKtUYnm7wW4
|
||||||
|
X7eI4thVlUaRpUEJI1L19LlE4CmFmI/Xiqdk/PeSnYEpF0TaeHLe/Gac
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIEqjCCA5KgAwIBAgIQDeD/te5iy2EQn2CMnO1e0zANBgkqhkiG9w0BAQsFADBh
|
||||||
|
MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
|
||||||
|
d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH
|
||||||
|
MjAeFw0xNzExMjcxMjQ2NDBaFw0yNzExMjcxMjQ2NDBaMG4xCzAJBgNVBAYTAlVT
|
||||||
|
MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j
|
||||||
|
b20xLTArBgNVBAMTJEVuY3J5cHRpb24gRXZlcnl3aGVyZSBEViBUTFMgQ0EgLSBH
|
||||||
|
MjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAO8Uf46i/nr7pkgTDqnE
|
||||||
|
eSIfCFqvPnUq3aF1tMJ5hh9MnO6Lmt5UdHfBGwC9Si+XjK12cjZgxObsL6Rg1njv
|
||||||
|
NhAMJ4JunN0JGGRJGSevbJsA3sc68nbPQzuKp5Jc8vpryp2mts38pSCXorPR+sch
|
||||||
|
QisKA7OSQ1MjcFN0d7tbrceWFNbzgL2csJVQeogOBGSe/KZEIZw6gXLKeFe7mupn
|
||||||
|
NYJROi2iC11+HuF79iAttMc32Cv6UOxixY/3ZV+LzpLnklFq98XORgwkIJL1HuvP
|
||||||
|
ha8yvb+W6JislZJL+HLFtidoxmI7Qm3ZyIV66W533DsGFimFJkz3y0GeHWuSVMbI
|
||||||
|
lfsCAwEAAaOCAU8wggFLMB0GA1UdDgQWBBR435GQX+7erPbFdevVTFVT7yRKtjAf
|
||||||
|
BgNVHSMEGDAWgBROIlQgGJXm427mD/r6uRLtBhePOTAOBgNVHQ8BAf8EBAMCAYYw
|
||||||
|
HQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMBIGA1UdEwEB/wQIMAYBAf8C
|
||||||
|
AQAwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdp
|
||||||
|
Y2VydC5jb20wQgYDVR0fBDswOTA3oDWgM4YxaHR0cDovL2NybDMuZGlnaWNlcnQu
|
||||||
|
Y29tL0RpZ2lDZXJ0R2xvYmFsUm9vdEcyLmNybDBMBgNVHSAERTBDMDcGCWCGSAGG
|
||||||
|
/WwBAjAqMCgGCCsGAQUFBwIBFhxodHRwczovL3d3dy5kaWdpY2VydC5jb20vQ1BT
|
||||||
|
MAgGBmeBDAECATANBgkqhkiG9w0BAQsFAAOCAQEAoBs1eCLKakLtVRPFRjBIJ9LJ
|
||||||
|
L0s8ZWum8U8/1TMVkQMBn+CPb5xnCD0GSA6L/V0ZFrMNqBirrr5B241OesECvxIi
|
||||||
|
98bZ90h9+q/X5eMyOD35f8YTaEMpdnQCnawIwiHx06/0BfiTj+b/XQih+mqt3ZXe
|
||||||
|
xNCJqKexdiB2IWGSKcgahPacWkk/BAQFisKIFYEqHzV974S3FAz/8LIfD58xnsEN
|
||||||
|
GfzyIDkH3JrwYZ8caPTf6ZX9M1GrISN8HnWTtdNCH2xEajRa/h9ZBXjUyFKQrGk2
|
||||||
|
n2hcLrfZSbynEC/pSw/ET7H5nWwckjmAJ1l9fcnbqkU/pf6uMQmnfl0JQjJNSg==
|
||||||
|
-----END CERTIFICATE-----
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# 前端静态文件目录
|
||||||
|
|
||||||
|
请将前端构建产物放置于此目录。
|
||||||
|
|
||||||
|
构建后应包含:
|
||||||
|
- index.html
|
||||||
|
- static/ (或其他静态资源)
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# 环境
|
|
||||||
VITE_APP_ENV=development
|
|
||||||
|
|
||||||
# 项目名称
|
|
||||||
VITE_APP_TITLE=fastapiadmin
|
|
||||||
|
|
||||||
# 网络请求公用地址,根据实际部署情况修改
|
|
||||||
VITE_API_BASE_URL=http://127.0.0.1:8001
|
|
||||||
|
|
||||||
# 代理前缀
|
|
||||||
VITE_APP_BASE_API=/api/v1
|
|
||||||
|
|
||||||
# 应用端口
|
|
||||||
VITE_APP_PORT=5180
|
|
||||||
|
|
||||||
# 超时时间
|
|
||||||
VITE_TIMEOUT=10000
|
|
||||||
|
|
||||||
# WebSocket 端点(不配置则关闭),ai对话需要配置
|
|
||||||
VITE_APP_WS_ENDPOINT=ws://127.0.0.1:8001
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# 环境
|
|
||||||
VITE_APP_ENV=production
|
|
||||||
|
|
||||||
# 项目名称
|
|
||||||
VITE_APP_TITLE=fastapiadmin
|
|
||||||
|
|
||||||
# 网络请求公用地址,如果没有认证可以使用http
|
|
||||||
VITE_API_BASE_URL=https://your-domain.com
|
|
||||||
|
|
||||||
# 代理前缀
|
|
||||||
VITE_APP_BASE_API=/api/v1
|
|
||||||
|
|
||||||
# 应用端口
|
|
||||||
VITE_APP_PORT=5180
|
|
||||||
|
|
||||||
# 超时时间
|
|
||||||
VITE_TIMEOUT=10000
|
|
||||||
|
|
||||||
# WebSocket 端点(不配置则关闭),ai对话需要配置,如果没有认证wss需要改为ws
|
|
||||||
VITE_APP_WS_ENDPOINT=wss://your-domain.com
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
extends: [
|
|
||||||
"stylelint-config-recommended",
|
|
||||||
"stylelint-config-recommended-scss",
|
|
||||||
"stylelint-config-recommended-vue/scss",
|
|
||||||
"stylelint-config-html/vue",
|
|
||||||
"stylelint-config-recess-order",
|
|
||||||
],
|
|
||||||
|
|
||||||
plugins: [
|
|
||||||
"stylelint-prettier", // 统一代码风格,格式冲突时以 Prettier 规则为准
|
|
||||||
],
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
files: ["**/*.{vue,html}"],
|
|
||||||
customSyntax: "postcss-html",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ["**/*.{css,scss}"],
|
|
||||||
customSyntax: "postcss-scss",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
rules: {
|
|
||||||
"prettier/prettier": true, // 强制执行 Prettier 格式化规则(需配合 .prettierrc 配置文件)
|
|
||||||
"no-empty-source": null, // 允许空的样式文件
|
|
||||||
"declaration-property-value-no-unknown": null, // 允许非常规数值格式 ,如 height: calc(100% - 50)
|
|
||||||
// 允许使用未知伪类
|
|
||||||
"selector-pseudo-class-no-unknown": [
|
|
||||||
true,
|
|
||||||
{
|
|
||||||
ignorePseudoClasses: ["global", "export", "deep"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
// 允许使用未知伪元素
|
|
||||||
"at-rule-no-unknown": null, // 禁用默认的未知 at-rule 检查
|
|
||||||
"scss/at-rule-no-unknown": true, // 启用 SCSS 特定的 at-rule 检查
|
|
||||||
},
|
|
||||||
};
|
|
||||||
Submodule
+1
Submodule frontend/app added at 87d9f70f4f
Submodule
+1
Submodule frontend/docs added at 9a71c419d2
@@ -1,237 +0,0 @@
|
|||||||
// https://eslint.org/docs/latest/use/configure/configuration-files-new
|
|
||||||
|
|
||||||
import eslint from "@eslint/js";
|
|
||||||
import globals from "globals";
|
|
||||||
// TypeScript支持
|
|
||||||
import * as typescriptEslint from "typescript-eslint";
|
|
||||||
// Vue支持
|
|
||||||
import pluginVue from "eslint-plugin-vue";
|
|
||||||
import vueParser from "vue-eslint-parser";
|
|
||||||
// 代码风格与格式化
|
|
||||||
import configPrettier from "eslint-config-prettier";
|
|
||||||
import prettierPlugin from "eslint-plugin-prettier";
|
|
||||||
|
|
||||||
// 解析自动导入配置
|
|
||||||
import fs from "node:fs";
|
|
||||||
let autoImportGlobals = {};
|
|
||||||
try {
|
|
||||||
autoImportGlobals =
|
|
||||||
JSON.parse(fs.readFileSync("./.eslintrc-auto-import.json", "utf-8")).globals || {};
|
|
||||||
} catch (error) {
|
|
||||||
// 文件不存在或解析错误时使用空对象
|
|
||||||
console.warn("Could not load auto-import globals", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Element Plus组件
|
|
||||||
const elementPlusComponents = {
|
|
||||||
// Element Plus 组件添加为全局变量,避免 no-undef 报错
|
|
||||||
ElInput: "readonly",
|
|
||||||
ElSelect: "readonly",
|
|
||||||
ElSwitch: "readonly",
|
|
||||||
ElCascader: "readonly",
|
|
||||||
ElInputNumber: "readonly",
|
|
||||||
ElTimePicker: "readonly",
|
|
||||||
ElTimeSelect: "readonly",
|
|
||||||
ElDatePicker: "readonly",
|
|
||||||
ElTreeSelect: "readonly",
|
|
||||||
ElText: "readonly",
|
|
||||||
ElRadioGroup: "readonly",
|
|
||||||
ElCheckboxGroup: "readonly",
|
|
||||||
ElOption: "readonly",
|
|
||||||
ElRadio: "readonly",
|
|
||||||
ElCheckbox: "readonly",
|
|
||||||
ElInputTag: "readonly",
|
|
||||||
ElForm: "readonly",
|
|
||||||
ElFormItem: "readonly",
|
|
||||||
ElTable: "readonly",
|
|
||||||
ElTableColumn: "readonly",
|
|
||||||
ElButton: "readonly",
|
|
||||||
ElDialog: "readonly",
|
|
||||||
ElPagination: "readonly",
|
|
||||||
ElMessage: "readonly",
|
|
||||||
ElMessageBox: "readonly",
|
|
||||||
ElNotification: "readonly",
|
|
||||||
ElTree: "readonly",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default [
|
|
||||||
// 忽略文件配置
|
|
||||||
{
|
|
||||||
ignores: [
|
|
||||||
"**/node_modules/**",
|
|
||||||
"**/dist/**",
|
|
||||||
"**/*.min.*",
|
|
||||||
"**/auto-imports.d.ts",
|
|
||||||
"**/components.d.ts",
|
|
||||||
"**/types/**/*.d.ts",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
|
|
||||||
// 基础 JavaScript 配置
|
|
||||||
eslint.configs.recommended,
|
|
||||||
|
|
||||||
// Vue 推荐配置
|
|
||||||
...pluginVue.configs["flat/recommended"],
|
|
||||||
|
|
||||||
// TypeScript 推荐配置
|
|
||||||
...typescriptEslint.configs.recommended,
|
|
||||||
|
|
||||||
// 全局配置
|
|
||||||
{
|
|
||||||
// 指定要检查的文件
|
|
||||||
files: ["**/*.{js,mjs,cjs,ts,mts,cts,vue}"],
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: "latest",
|
|
||||||
sourceType: "module",
|
|
||||||
globals: {
|
|
||||||
...globals.browser, // 浏览器环境全局变量
|
|
||||||
...globals.node, // Node.js 环境全局变量
|
|
||||||
...globals.es2022, // ES2022 全局对象
|
|
||||||
...autoImportGlobals, // 自动导入的 API 函数
|
|
||||||
...elementPlusComponents, // Element Plus 组件
|
|
||||||
// 全局类型定义,解决 TypeScript 中定义但 ESLint 不识别的问题
|
|
||||||
PageQuery: "readonly",
|
|
||||||
PageResult: "readonly",
|
|
||||||
OptionType: "readonly",
|
|
||||||
ApiResponse: "readonly",
|
|
||||||
ExcelResult: "readonly",
|
|
||||||
CommonType: "readonly",
|
|
||||||
updatorType: "readonly",
|
|
||||||
creatorType: "readonly",
|
|
||||||
TagView: "readonly",
|
|
||||||
AppSettings: "readonly",
|
|
||||||
__APP_INFO__: "readonly",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
vue: pluginVue,
|
|
||||||
"@typescript-eslint": typescriptEslint.plugin,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
// 基础规则
|
|
||||||
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
|
|
||||||
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
|
|
||||||
|
|
||||||
// ES6+ 规则
|
|
||||||
"prefer-const": "error",
|
|
||||||
"no-var": "error",
|
|
||||||
"object-shorthand": "error",
|
|
||||||
|
|
||||||
// 最佳实践
|
|
||||||
eqeqeq: "off",
|
|
||||||
"no-multi-spaces": "error",
|
|
||||||
"no-multiple-empty-lines": ["error", { max: 1, maxBOF: 0, maxEOF: 0 }],
|
|
||||||
|
|
||||||
// 禁用与 TypeScript 冲突的规则
|
|
||||||
"no-unused-vars": "off",
|
|
||||||
"no-undef": "off",
|
|
||||||
"no-redeclare": "off",
|
|
||||||
"@typescript-eslint/ban-ts-comment": "off",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Vue 文件特定配置
|
|
||||||
{
|
|
||||||
files: ["**/*.vue"],
|
|
||||||
languageOptions: {
|
|
||||||
parser: vueParser,
|
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: "latest",
|
|
||||||
sourceType: "module",
|
|
||||||
parser: typescriptEslint.parser,
|
|
||||||
extraFileExtensions: [".vue"],
|
|
||||||
tsconfigRootDir: __dirname,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
// Vue 规则
|
|
||||||
"vue/multi-word-component-names": "off",
|
|
||||||
"vue/no-v-html": "off",
|
|
||||||
"vue/require-default-prop": "off",
|
|
||||||
"vue/require-explicit-emits": "error",
|
|
||||||
"vue/no-unused-vars": "error",
|
|
||||||
"vue/no-mutating-props": "off",
|
|
||||||
"vue/valid-v-for": "warn",
|
|
||||||
"vue/no-template-shadow": "warn",
|
|
||||||
"vue/return-in-computed-property": "warn",
|
|
||||||
"vue/block-order": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
order: ["template", "script", "style"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"vue/html-self-closing": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
html: {
|
|
||||||
void: "always",
|
|
||||||
normal: "never",
|
|
||||||
component: "always",
|
|
||||||
},
|
|
||||||
svg: "always",
|
|
||||||
math: "always",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"vue/component-name-in-template-casing": ["error", "PascalCase"],
|
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// TypeScript 文件特定配置
|
|
||||||
{
|
|
||||||
files: ["**/*.{ts,tsx,mts,cts}"],
|
|
||||||
languageOptions: {
|
|
||||||
parser: typescriptEslint.parser,
|
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: "latest",
|
|
||||||
sourceType: "module",
|
|
||||||
project: "./tsconfig.json",
|
|
||||||
tsconfigRootDir: __dirname,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
// TypeScript 规则
|
|
||||||
"@typescript-eslint/no-explicit-any": "off", // 允许使用any类型,方便开发
|
|
||||||
"@typescript-eslint/no-empty-function": "off",
|
|
||||||
"@typescript-eslint/no-empty-object-type": "off",
|
|
||||||
"@typescript-eslint/ban-ts-comment": "off",
|
|
||||||
"@typescript-eslint/no-non-null-assertion": "off",
|
|
||||||
"@typescript-eslint/no-unused-vars": "warn", // 降级为警告
|
|
||||||
"@typescript-eslint/no-unused-expressions": "warn", // 降级为警告
|
|
||||||
"@typescript-eslint/consistent-type-imports": "off", // 关闭强制使用type import
|
|
||||||
"@typescript-eslint/no-import-type-side-effects": "error",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// .d.ts 文件配置
|
|
||||||
{
|
|
||||||
files: ["**/*.d.ts"],
|
|
||||||
rules: {
|
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
|
||||||
"@typescript-eslint/no-unused-vars": "off",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// CURD 组件配置
|
|
||||||
{
|
|
||||||
files: ["**/components/CURD/**/*.{ts,vue}"],
|
|
||||||
rules: {
|
|
||||||
"no-unused-vars": "off",
|
|
||||||
"@typescript-eslint/no-unused-vars": "off",
|
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Prettier 集成(必须放在最后)
|
|
||||||
{
|
|
||||||
plugins: {
|
|
||||||
prettier: prettierPlugin, // 将 Prettier 的输出作为 ESLint 的问题来报告
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
...configPrettier.rules,
|
|
||||||
"prettier/prettier": ["error", {}, { usePrettierrc: true }],
|
|
||||||
"arrow-body-style": "off",
|
|
||||||
"prefer-arrow-callback": "off",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" href="/logo.png" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta name="description" content="Vue3 + Vite + TypeScript + Element-Plus 的后台管理模板 " />
|
|
||||||
<meta
|
|
||||||
name="keywords"
|
|
||||||
content="vue,element-plus,typescript,vue-element-admin,vue3-element-admin"
|
|
||||||
/>
|
|
||||||
<title>%VITE_APP_TITLE%</title>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div id="app">
|
|
||||||
<div class="loading-container">
|
|
||||||
<div class="loading-spinner">
|
|
||||||
<div class="loading-bar"></div>
|
|
||||||
<div class="loading-bar"></div>
|
|
||||||
<div class="loading-bar"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
#app {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 24px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-spinner {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-bar {
|
|
||||||
width: 4px;
|
|
||||||
height: 24px;
|
|
||||||
background-color: #498cff;
|
|
||||||
border-radius: 2px;
|
|
||||||
animation: loading-animation 1.2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-bar:nth-child(1) {
|
|
||||||
animation-delay: 0s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-bar:nth-child(2) {
|
|
||||||
animation-delay: 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-bar:nth-child(3) {
|
|
||||||
animation-delay: 0.4s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes loading-animation {
|
|
||||||
0% {
|
|
||||||
opacity: 0.3;
|
|
||||||
transform: scaleY(0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scaleY(1.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
opacity: 0.3;
|
|
||||||
transform: scaleY(0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</html>
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
<!-- plop(curd-list) 生成:补全 API、searchConfig、contentConfig、表格列与弹窗 -->
|
|
||||||
<template>
|
|
||||||
<div class="app-container">
|
|
||||||
<PageSearch
|
|
||||||
ref="searchRef"
|
|
||||||
:search-config="searchConfig"
|
|
||||||
@query-click="handleQueryClick"
|
|
||||||
@reset-click="handleResetClick"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PageContent ref="contentRef" :content-config="contentConfig">
|
|
||||||
<template #toolbar="{ toolbarRight, onToolbar, removeIds, cols }">
|
|
||||||
<CrudToolbarLeft
|
|
||||||
:remove-ids="removeIds"
|
|
||||||
:perm-create="['{{permPrefix}}:create']"
|
|
||||||
:perm-delete="['{{permPrefix}}:delete']"
|
|
||||||
:perm-patch="['{{permPrefix}}:patch']"
|
|
||||||
@add="handleOpenDialog('create')"
|
|
||||||
@delete="onToolbar('delete')"
|
|
||||||
@more="handleMoreClick"
|
|
||||||
/>
|
|
||||||
<div class="data-table__toolbar--right">
|
|
||||||
<CrudToolbarRight :buttons="toolbarRight" :cols="cols" :on-toolbar="onToolbar" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #table="{ data, loading, tableRef, onSelectionChange, pagination }">
|
|
||||||
<div class="data-table__content">
|
|
||||||
<el-table
|
|
||||||
:ref="tableRef as any"
|
|
||||||
v-loading="loading"
|
|
||||||
row-key="id"
|
|
||||||
:data="data"
|
|
||||||
height="calc(100vh - 350px)"
|
|
||||||
max-height="calc(100vh - 350px)"
|
|
||||||
border
|
|
||||||
stripe
|
|
||||||
@selection-change="onSelectionChange"
|
|
||||||
>
|
|
||||||
<template #empty>
|
|
||||||
<el-empty :image-size="80" description="暂无数据" />
|
|
||||||
</template>
|
|
||||||
<!-- TODO: el-table-column -->
|
|
||||||
</el-table>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</PageContent>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
defineOptions({ name: "{{componentName}}", inheritAttrs: false });
|
|
||||||
|
|
||||||
import { reactive } from "vue";
|
|
||||||
import CrudToolbarLeft from "@/components/CURD/CrudToolbarLeft.vue";
|
|
||||||
import CrudToolbarRight from "@/components/CURD/CrudToolbarRight.vue";
|
|
||||||
import PageSearch from "@/components/CURD/PageSearch.vue";
|
|
||||||
import PageContent from "@/components/CURD/PageContent.vue";
|
|
||||||
import { useCrudList } from "@/components/CURD/useCrudList";
|
|
||||||
import type { IContentConfig, ISearchConfig } from "@/components/CURD/types";
|
|
||||||
|
|
||||||
const { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList } = useCrudList();
|
|
||||||
|
|
||||||
const searchConfig = reactive<ISearchConfig>({
|
|
||||||
permPrefix: "{{permPrefix}}",
|
|
||||||
formItems: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const contentConfig = reactive<IContentConfig>({
|
|
||||||
permPrefix: "{{permPrefix}}",
|
|
||||||
toolbar: [],
|
|
||||||
defaultToolbar: ["import", "export", "patch", "refresh", "filter"],
|
|
||||||
cols: [],
|
|
||||||
/** TODO: 接入列表 API,返回 { total, list } 供 PageContent 解析 */
|
|
||||||
indexAction: async () => ({ total: 0, list: [] }),
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleOpenDialog(_type: "create" | "update" | "detail", _id?: string) {
|
|
||||||
// TODO
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleMoreClick(_status: string) {
|
|
||||||
// TODO 批量启用/停用
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
/** @param {import("plop").NodePlopAPI} plop */
|
|
||||||
export default function (plop) {
|
|
||||||
plop.setGenerator("curd-list", {
|
|
||||||
description: "列表页骨架:PageSearch + PageContent + CrudToolbarLeft/Right + useCrudList",
|
|
||||||
prompts: [
|
|
||||||
{
|
|
||||||
type: "input",
|
|
||||||
name: "modulePath",
|
|
||||||
message: "views 下路径(如 module_example/demo)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "input",
|
|
||||||
name: "componentName",
|
|
||||||
message: "组件 name(PascalCase,如 Demo)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "input",
|
|
||||||
name: "permPrefix",
|
|
||||||
message: "权限前缀(如 module_example:demo)",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
type: "add",
|
|
||||||
path: "src/views/{{modulePath}}/index.vue",
|
|
||||||
templateFile: "plop-templates/curd-list-page.vue.hbs",
|
|
||||||
skipIfExists: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<template>
|
|
||||||
<el-config-provider :locale="locale" :size="size">
|
|
||||||
<!-- 开启水印 -->
|
|
||||||
<el-watermark
|
|
||||||
:font="{ color: fontColor }"
|
|
||||||
:content="showWatermark ? watermarkContent : ''"
|
|
||||||
:z-index="9999"
|
|
||||||
class="wh-full"
|
|
||||||
>
|
|
||||||
<router-view />
|
|
||||||
|
|
||||||
<!-- AI 助手 -->
|
|
||||||
<AiAssistant v-if="enableAiAssistant" />
|
|
||||||
</el-watermark>
|
|
||||||
</el-config-provider>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { useAppStore, useSettingsStore, useUserStore } from "@/store";
|
|
||||||
import { defaultSettings } from "@/settings";
|
|
||||||
import { ThemeMode } from "@/enums/settings/theme.enum";
|
|
||||||
import { ComponentSize } from "@/enums/settings/layout.enum";
|
|
||||||
import AiAssistant from "@/components/AiAssistant/index.vue";
|
|
||||||
|
|
||||||
const appStore = useAppStore();
|
|
||||||
const settingsStore = useSettingsStore();
|
|
||||||
const userStore = useUserStore();
|
|
||||||
|
|
||||||
const locale = computed(() => appStore.locale);
|
|
||||||
const size = computed(() => appStore.size as ComponentSize);
|
|
||||||
const showWatermark = computed(() => settingsStore.showWatermark);
|
|
||||||
const watermarkContent = defaultSettings.watermarkContent;
|
|
||||||
|
|
||||||
// 只有在启用 AI 助手且用户已登录时才显示
|
|
||||||
// 使用 userInfo 作为响应式依赖,当用户退出登录时会自动更新
|
|
||||||
const enableAiAssistant = computed(() => {
|
|
||||||
const isEnabled = settingsStore.userEnableAi;
|
|
||||||
const isLoggedIn = userStore.basicInfo && Object.keys(userStore.basicInfo).length > 0;
|
|
||||||
return isEnabled && isLoggedIn;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 明亮/暗黑主题水印字体颜色适配
|
|
||||||
const fontColor = computed(() => {
|
|
||||||
return settingsStore.theme === ThemeMode.DARK ? "rgba(255, 255, 255, .15)" : "rgba(0, 0, 0, .15)";
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,901 +0,0 @@
|
|||||||
<template>
|
|
||||||
<!-- 悬浮按钮 -->
|
|
||||||
<div class="ai-assistant">
|
|
||||||
<!-- AI 助手图标按钮 -->
|
|
||||||
<el-button
|
|
||||||
v-if="!dialogVisible && !fabCollapsed"
|
|
||||||
class="ai-fab-button"
|
|
||||||
type="primary"
|
|
||||||
circle
|
|
||||||
size="large"
|
|
||||||
:style="fabStyle"
|
|
||||||
@contextmenu.prevent="fabCollapsed = true"
|
|
||||||
@click="handleOpen"
|
|
||||||
>
|
|
||||||
<div class="i-svg:ai ai-icon" />
|
|
||||||
</el-button>
|
|
||||||
|
|
||||||
<!-- 收缩态:贴边小标签,避免遮挡表单控件 -->
|
|
||||||
<div
|
|
||||||
v-if="!dialogVisible && fabCollapsed"
|
|
||||||
class="ai-fab-tab"
|
|
||||||
:style="fabStyle"
|
|
||||||
@click="fabCollapsed = false"
|
|
||||||
>
|
|
||||||
AI
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- AI 对话框 -->
|
|
||||||
<el-dialog
|
|
||||||
v-model="dialogVisible"
|
|
||||||
title="AI 智能助手"
|
|
||||||
width="600px"
|
|
||||||
:close-on-click-modal="false"
|
|
||||||
draggable
|
|
||||||
class="ai-assistant-dialog"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<div class="dialog-header">
|
|
||||||
<div class="i-svg:ai header-icon" />
|
|
||||||
<span class="title">AI 智能助手</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 命令输入 -->
|
|
||||||
<div class="command-input">
|
|
||||||
<el-input
|
|
||||||
v-model="command"
|
|
||||||
type="textarea"
|
|
||||||
:rows="3"
|
|
||||||
placeholder="试试说:打开角色管理页面 或者:跳转到用户管理 按 Ctrl+Enter 快速发送"
|
|
||||||
:disabled="loading"
|
|
||||||
@keydown.ctrl.enter="handleExecute"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 快捷命令示例 -->
|
|
||||||
<div class="quick-commands">
|
|
||||||
<div class="section-title">💡 试试这些命令:</div>
|
|
||||||
<el-tag
|
|
||||||
v-for="example in examples"
|
|
||||||
:key="example"
|
|
||||||
class="command-tag"
|
|
||||||
@click="command = example"
|
|
||||||
>
|
|
||||||
{{ example }}
|
|
||||||
</el-tag>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- AI 响应结果 -->
|
|
||||||
<div v-if="response" class="ai-response">
|
|
||||||
<el-alert :title="response.explanation" type="success" :closable="false" show-icon />
|
|
||||||
|
|
||||||
<!-- 将要执行的操作 -->
|
|
||||||
<div v-if="response.action" class="action-preview">
|
|
||||||
<div class="action-title">🎯 将要执行:</div>
|
|
||||||
<div class="action-content">
|
|
||||||
<div v-if="response.action.type === 'navigate'">
|
|
||||||
<el-icon><Position /></el-icon>
|
|
||||||
跳转到:
|
|
||||||
<strong>{{ response.action.pageName }}</strong>
|
|
||||||
<span v-if="response.action.query" class="query-info">
|
|
||||||
并搜索:
|
|
||||||
<el-tag type="warning" size="small">{{ response.action.query }}</el-tag>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="response.action.type === 'navigate-and-execute'">
|
|
||||||
<el-icon><Position /></el-icon>
|
|
||||||
跳转至:
|
|
||||||
<strong>{{ response.action.pageName }}</strong>
|
|
||||||
<span v-if="response.action.query" class="query-info">
|
|
||||||
并搜索:
|
|
||||||
<el-tag type="warning" size="small">{{ response.action.query }}</el-tag>
|
|
||||||
</span>
|
|
||||||
<el-divider direction="vertical" />
|
|
||||||
<el-icon><Tools /></el-icon>
|
|
||||||
执行:
|
|
||||||
<strong>{{ response.action.functionCall.name }}</strong>
|
|
||||||
</div>
|
|
||||||
<div v-if="response.action.type === 'execute'">
|
|
||||||
<el-icon><Tools /></el-icon>
|
|
||||||
执行:
|
|
||||||
<strong>{{ response.action.functionName }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<div class="dialog-footer">
|
|
||||||
<el-button @click="handleClose">取消</el-button>
|
|
||||||
<el-button type="primary" :loading="loading" @click="handleExecute">
|
|
||||||
<el-icon><MagicStick /></el-icon>
|
|
||||||
执行命令
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { nextTick, onBeforeUnmount, onMounted, watch, ref, computed } from "vue";
|
|
||||||
import { useRouter } from "vue-router";
|
|
||||||
import { ElMessage } from "element-plus";
|
|
||||||
import { useSettingsStore } from "@/store";
|
|
||||||
import { AiChatAPI, ChatSession, ChatSessionDetail } from "@/api/module_ai/chat";
|
|
||||||
|
|
||||||
type ToolFunctionCall = {
|
|
||||||
name: string;
|
|
||||||
arguments: Record<string, any>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 统一的动作描述(区分“跳转”、“跳转+执行”、“仅执行”三种场景)
|
|
||||||
type AiAction =
|
|
||||||
| {
|
|
||||||
type: "navigate";
|
|
||||||
path: string;
|
|
||||||
pageName: string;
|
|
||||||
query?: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "navigate-and-execute";
|
|
||||||
path: string;
|
|
||||||
pageName: string;
|
|
||||||
query?: string;
|
|
||||||
functionCall: ToolFunctionCall;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "execute";
|
|
||||||
functionName: string;
|
|
||||||
functionCall: ToolFunctionCall;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AiResponse = {
|
|
||||||
explanation: string;
|
|
||||||
action: AiAction | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const settingsStore = useSettingsStore();
|
|
||||||
|
|
||||||
// 状态管理
|
|
||||||
const dialogVisible = ref(false);
|
|
||||||
const command = ref("");
|
|
||||||
const loading = ref(false);
|
|
||||||
const response = ref<AiResponse | null>(null);
|
|
||||||
|
|
||||||
// 会话管理
|
|
||||||
const sessions = ref<ChatSession[]>([]);
|
|
||||||
const currentSessionId = ref<string | null>(null);
|
|
||||||
const currentSession = ref<ChatSessionDetail | null>(null);
|
|
||||||
const sessionLoading = ref(false);
|
|
||||||
|
|
||||||
const fabCollapsed = useStorage<boolean>("vea:ui:ai_assistant_fab_collapsed", false);
|
|
||||||
|
|
||||||
const fabRight = ref(30);
|
|
||||||
const fabBottom = ref(80);
|
|
||||||
const fabStyle = computed(() => ({
|
|
||||||
right: `${fabRight.value}px`,
|
|
||||||
bottom: `${fabBottom.value}px`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const isElementVisible = (el: Element) => {
|
|
||||||
const style = window.getComputedStyle(el);
|
|
||||||
if (style.display === "none" || style.visibility === "hidden") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return (el as HTMLElement).getClientRects().length > 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getActiveRightDrawerWidth = (): number => {
|
|
||||||
const drawers = Array.from(document.querySelectorAll(".el-drawer"));
|
|
||||||
for (let i = drawers.length - 1; i >= 0; i--) {
|
|
||||||
const drawer = drawers[i] as HTMLElement;
|
|
||||||
if (!isElementVisible(drawer)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const rect = drawer.getBoundingClientRect();
|
|
||||||
if (rect.width > 0 && rect.right >= window.innerWidth - 8) {
|
|
||||||
return rect.width;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateFabPosition = () => {
|
|
||||||
const safeMargin = 24;
|
|
||||||
const drawerWidth = getActiveRightDrawerWidth() || 0;
|
|
||||||
const baseRight = drawerWidth + 30;
|
|
||||||
|
|
||||||
// base position
|
|
||||||
const nextRight = baseRight;
|
|
||||||
let nextBottom = 80;
|
|
||||||
|
|
||||||
// Avoid Element Plus popper overlays (select dropdown, icon picker, date picker, etc.)
|
|
||||||
// If the FAB would overlap any visible popper, push it upward.
|
|
||||||
const fabSize = fabCollapsed.value ? 42 : 60;
|
|
||||||
const computeFabRect = (rightPx: number, bottomPx: number) => {
|
|
||||||
const right = window.innerWidth - rightPx;
|
|
||||||
const left = right - fabSize;
|
|
||||||
const bottom = window.innerHeight - bottomPx;
|
|
||||||
const top = bottom - fabSize;
|
|
||||||
return { left, right, top, bottom };
|
|
||||||
};
|
|
||||||
|
|
||||||
const intersects = (
|
|
||||||
a: { left: number; right: number; top: number; bottom: number },
|
|
||||||
b: DOMRect
|
|
||||||
) => {
|
|
||||||
return !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom);
|
|
||||||
};
|
|
||||||
|
|
||||||
const poppers = Array.from(document.querySelectorAll(".el-popper"));
|
|
||||||
for (const popper of poppers) {
|
|
||||||
if (!isElementVisible(popper)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const rect = (popper as HTMLElement).getBoundingClientRect();
|
|
||||||
if (rect.width <= 0 || rect.height <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidateFabRect = computeFabRect(nextRight, nextBottom);
|
|
||||||
if (intersects(candidateFabRect, rect)) {
|
|
||||||
const requiredBottom = Math.ceil(window.innerHeight - rect.top + safeMargin);
|
|
||||||
nextBottom = Math.max(nextBottom, requiredBottom);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// clamp so the button doesn't get pushed off-screen
|
|
||||||
const maxBottom = window.innerHeight - fabSize - safeMargin;
|
|
||||||
nextBottom = Math.min(nextBottom, Math.max(0, maxBottom));
|
|
||||||
|
|
||||||
fabRight.value = nextRight + (drawerWidth > 0 ? safeMargin : 0);
|
|
||||||
fabBottom.value = nextBottom;
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
fabCollapsed,
|
|
||||||
() => {
|
|
||||||
updateFabPosition();
|
|
||||||
},
|
|
||||||
{ flush: "post" }
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => settingsStore.settingsVisible,
|
|
||||||
() => {
|
|
||||||
nextTick(() => {
|
|
||||||
scheduleUpdateFabPositionBurst();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ flush: "post" }
|
|
||||||
);
|
|
||||||
|
|
||||||
let domObserver: MutationObserver | null = null;
|
|
||||||
let rafId: number | null = null;
|
|
||||||
|
|
||||||
const scheduleUpdateFabPosition = () => {
|
|
||||||
if (rafId != null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
rafId = window.requestAnimationFrame(() => {
|
|
||||||
rafId = null;
|
|
||||||
updateFabPosition();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleUpdateFabPositionBurst = (frames = 18) => {
|
|
||||||
let count = 0;
|
|
||||||
const tick = () => {
|
|
||||||
scheduleUpdateFabPosition();
|
|
||||||
count += 1;
|
|
||||||
if (count < frames) {
|
|
||||||
window.requestAnimationFrame(tick);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
tick();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 快捷命令示例
|
|
||||||
const examples = [
|
|
||||||
"跳转到用户管理",
|
|
||||||
"打开角色管理页面",
|
|
||||||
"修改test用户的姓名为测试人员",
|
|
||||||
"获取姓名为张三的用户信息",
|
|
||||||
];
|
|
||||||
|
|
||||||
// 加载会话列表
|
|
||||||
const loadSessions = async () => {
|
|
||||||
try {
|
|
||||||
sessionLoading.value = true;
|
|
||||||
const response = await AiChatAPI.getSessionList({ page_no: 1, page_size: 100 });
|
|
||||||
if (response.data?.success) {
|
|
||||||
sessions.value = response.data.data?.items || [];
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("加载会话列表失败:", error);
|
|
||||||
} finally {
|
|
||||||
sessionLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 加载会话详情
|
|
||||||
const loadSessionDetail = async (sessionId: string) => {
|
|
||||||
try {
|
|
||||||
sessionLoading.value = true;
|
|
||||||
const response = await AiChatAPI.getSessionDetail(sessionId);
|
|
||||||
if (response.data?.success) {
|
|
||||||
currentSessionId.value = sessionId;
|
|
||||||
currentSession.value = response.data.data;
|
|
||||||
} else {
|
|
||||||
ElMessage.error("获取会话详情失败");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("获取会话详情失败:", error);
|
|
||||||
ElMessage.error("获取会话详情失败");
|
|
||||||
} finally {
|
|
||||||
sessionLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 打开对话框
|
|
||||||
const handleOpen = () => {
|
|
||||||
dialogVisible.value = true;
|
|
||||||
command.value = "";
|
|
||||||
response.value = null;
|
|
||||||
loadSessions();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 关闭对话框
|
|
||||||
const handleClose = () => {
|
|
||||||
dialogVisible.value = false;
|
|
||||||
command.value = "";
|
|
||||||
response.value = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 执行命令
|
|
||||||
const handleExecute = async () => {
|
|
||||||
const rawCommand = command.value.trim();
|
|
||||||
if (!rawCommand) {
|
|
||||||
ElMessage.warning("请输入命令");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 优先检测无需调用 AI 的纯跳转命令
|
|
||||||
const directNavigation = tryDirectNavigate(rawCommand);
|
|
||||||
if (directNavigation && directNavigation.action) {
|
|
||||||
response.value = directNavigation;
|
|
||||||
await executeAction(directNavigation.action);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 调用 AI API 解析命令
|
|
||||||
const result = await AiChatAPI.chat({
|
|
||||||
message: rawCommand,
|
|
||||||
session_id: currentSessionId.value || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.data.success) {
|
|
||||||
ElMessage.error(result.data.msg || "命令解析失败");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新会话ID
|
|
||||||
if (result.data.data?.session_id) {
|
|
||||||
currentSessionId.value = result.data.data.session_id;
|
|
||||||
// 重新加载会话列表和详情
|
|
||||||
await loadSessions();
|
|
||||||
await loadSessionDetail(result.data.data.session_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析 AI 返回的操作类型
|
|
||||||
const action = parseAction(result.data.data, rawCommand);
|
|
||||||
response.value = {
|
|
||||||
explanation: result.data.data?.response ?? "命令解析成功,准备执行操作",
|
|
||||||
action,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 等待用户确认后执行
|
|
||||||
if (action) {
|
|
||||||
await executeAction(action);
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("AI 命令执行失败:", error);
|
|
||||||
ElMessage.error(error.message || "命令执行失败");
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 路由配置映射表
|
|
||||||
const routeConfig = [
|
|
||||||
{ keywords: ["用户", "user", "user list"], path: "/system/user", name: "用户管理" },
|
|
||||||
{ keywords: ["角色", "role"], path: "/system/role", name: "角色管理" },
|
|
||||||
{ keywords: ["菜单", "menu"], path: "/system/menu", name: "菜单管理" },
|
|
||||||
{ keywords: ["部门", "dept"], path: "/system/dept", name: "部门管理" },
|
|
||||||
{ keywords: ["字典", "dict"], path: "/system/dict", name: "字典管理" },
|
|
||||||
{ keywords: ["日志", "log"], path: "/system/log", name: "系统日志" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 根据函数名推断路由(如 getUserInfo -> /system/user)
|
|
||||||
const normalizeText = (text: string) => text.replace(/\s+/g, " ").trim().toLowerCase();
|
|
||||||
|
|
||||||
const inferRouteFromFunction = (functionName: string) => {
|
|
||||||
const fnLower = normalizeText(functionName);
|
|
||||||
for (const config of routeConfig) {
|
|
||||||
// 检查函数名是否包含关键词(如 getUserInfo 包含 user)
|
|
||||||
if (config.keywords.some((kw) => fnLower.includes(kw.toLowerCase()))) {
|
|
||||||
return { path: config.path, name: config.name };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 根据命令文本匹配路由
|
|
||||||
const matchRouteFromCommand = (cmd: string) => {
|
|
||||||
const normalized = normalizeText(cmd);
|
|
||||||
for (const config of routeConfig) {
|
|
||||||
if (config.keywords.some((kw) => normalized.includes(kw.toLowerCase()))) {
|
|
||||||
return { path: config.path, name: config.name };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractKeywordFromCommand = (cmd: string): string => {
|
|
||||||
const normalized = normalizeText(cmd);
|
|
||||||
// 从 routeConfig 动态获取所有数据类型关键词
|
|
||||||
const allKeywords = routeConfig.flatMap((config) =>
|
|
||||||
config.keywords.map((kw) => kw.toLowerCase())
|
|
||||||
);
|
|
||||||
const keywordsPattern = allKeywords.join("|");
|
|
||||||
|
|
||||||
const patterns = [
|
|
||||||
new RegExp(`(?:查询|获取|搜索|查找|找).*?([^\\s,。]+?)(?:的)?(?:${keywordsPattern})`, "i"),
|
|
||||||
new RegExp(`(?:${keywordsPattern}).*?([^\\s,。]+?)(?:的|信息|详情)?`, "i"),
|
|
||||||
new RegExp(
|
|
||||||
`(?:姓名为|名字叫|叫做|名称为|名是|为)([^\\s,。]+?)(?:的)?(?:${keywordsPattern})?`,
|
|
||||||
"i"
|
|
||||||
),
|
|
||||||
new RegExp(`([^\\s,。]+?)(?:的)?(?:${keywordsPattern})(?:信息|详情)?`, "i"),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const pattern of patterns) {
|
|
||||||
const match = normalized.match(pattern);
|
|
||||||
if (match && match[1]) {
|
|
||||||
let extracted = match[1].trim();
|
|
||||||
extracted = extracted.replace(/姓名为|名字叫|叫做|名称为|名是|为|的|信息|详情/g, "");
|
|
||||||
if (
|
|
||||||
extracted &&
|
|
||||||
!allKeywords.some((type) => extracted.toLowerCase().includes(type.toLowerCase()))
|
|
||||||
) {
|
|
||||||
return extracted;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const tryDirectNavigate = (rawCommand: string): AiResponse | null => {
|
|
||||||
const navigationIntents = ["跳转", "打开", "进入", "前往", "去", "浏览", "查看"];
|
|
||||||
const operationIntents = [
|
|
||||||
"修改",
|
|
||||||
"更新",
|
|
||||||
"变更",
|
|
||||||
"删除",
|
|
||||||
"添加",
|
|
||||||
"创建",
|
|
||||||
"设置",
|
|
||||||
"获取",
|
|
||||||
"查询",
|
|
||||||
"搜索",
|
|
||||||
];
|
|
||||||
|
|
||||||
const hasNavigationIntent = navigationIntents.some((keyword) => rawCommand.includes(keyword));
|
|
||||||
const hasOperationIntent = operationIntents.some((keyword) => rawCommand.includes(keyword));
|
|
||||||
|
|
||||||
if (!hasNavigationIntent || hasOperationIntent) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const routeInfo = matchRouteFromCommand(rawCommand);
|
|
||||||
if (!routeInfo) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyword = extractKeywordFromCommand(rawCommand);
|
|
||||||
const action: AiAction = {
|
|
||||||
type: "navigate",
|
|
||||||
path: routeInfo.path,
|
|
||||||
pageName: routeInfo.name,
|
|
||||||
query: keyword || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
explanation: `检测到跳转命令,正在前往 ${routeInfo.name}`,
|
|
||||||
action,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// 解析 AI 返回的操作类型
|
|
||||||
const parseAction = (result: any, rawCommand: string): AiAction | null => {
|
|
||||||
const cmd = normalizeText(rawCommand);
|
|
||||||
// 后端返回的是 function_calls(下划线命名)
|
|
||||||
const functionCalls = result.function_calls || result.functionCalls;
|
|
||||||
const primaryCall = functionCalls?.[0];
|
|
||||||
const functionName = primaryCall?.name;
|
|
||||||
|
|
||||||
// 优先从函数名推断路由,其次从命令文本匹配
|
|
||||||
let routeInfo = functionName ? inferRouteFromFunction(functionName) : null;
|
|
||||||
if (!routeInfo) {
|
|
||||||
routeInfo = matchRouteFromCommand(cmd);
|
|
||||||
}
|
|
||||||
|
|
||||||
const routePath = routeInfo?.path || "";
|
|
||||||
const pageName = routeInfo?.name || "";
|
|
||||||
const keyword = extractKeywordFromCommand(cmd);
|
|
||||||
|
|
||||||
if (primaryCall && functionName) {
|
|
||||||
const fnNameLower = functionName.toLowerCase();
|
|
||||||
|
|
||||||
// 1) 查询类函数(query/search/list/get)-> 跳转并执行筛选操作
|
|
||||||
const isQueryFunction =
|
|
||||||
fnNameLower.includes("query") ||
|
|
||||||
fnNameLower.includes("search") ||
|
|
||||||
fnNameLower.includes("list") ||
|
|
||||||
fnNameLower.includes("get");
|
|
||||||
|
|
||||||
if (isQueryFunction) {
|
|
||||||
// 统一使用 keywords 参数(约定大于配置)
|
|
||||||
const args = (primaryCall.arguments || {}) as Record<string, unknown>;
|
|
||||||
const keywords =
|
|
||||||
typeof args.keywords === "string" && args.keywords.trim().length > 0
|
|
||||||
? args.keywords
|
|
||||||
: keyword;
|
|
||||||
|
|
||||||
if (routePath) {
|
|
||||||
return {
|
|
||||||
type: "navigate-and-execute",
|
|
||||||
path: routePath,
|
|
||||||
pageName,
|
|
||||||
functionCall: primaryCall,
|
|
||||||
query: keywords || undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) 其他操作类函数(修改/删除/创建/更新等)-> 跳转并执行
|
|
||||||
const isModifyFunction =
|
|
||||||
fnNameLower.includes("update") ||
|
|
||||||
fnNameLower.includes("modify") ||
|
|
||||||
fnNameLower.includes("edit") ||
|
|
||||||
fnNameLower.includes("delete") ||
|
|
||||||
fnNameLower.includes("remove") ||
|
|
||||||
fnNameLower.includes("create") ||
|
|
||||||
fnNameLower.includes("add") ||
|
|
||||||
fnNameLower.includes("save");
|
|
||||||
|
|
||||||
if (isModifyFunction && routePath) {
|
|
||||||
return {
|
|
||||||
type: "navigate-and-execute",
|
|
||||||
path: routePath,
|
|
||||||
pageName,
|
|
||||||
functionCall: primaryCall,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) 其他未匹配的函数,如果有路由则跳转,否则执行
|
|
||||||
if (routePath) {
|
|
||||||
return {
|
|
||||||
type: "navigate-and-execute",
|
|
||||||
path: routePath,
|
|
||||||
pageName,
|
|
||||||
functionCall: primaryCall,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: "execute",
|
|
||||||
functionName,
|
|
||||||
functionCall: primaryCall,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4) 无函数调用,仅跳转
|
|
||||||
if (routePath) {
|
|
||||||
return {
|
|
||||||
type: "navigate",
|
|
||||||
path: routePath,
|
|
||||||
pageName,
|
|
||||||
query: keyword || undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 定时器引用(用于清理)
|
|
||||||
let navigationTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let executeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
// 执行操作
|
|
||||||
const executeAction = async (action: AiAction) => {
|
|
||||||
// 🎯 新增:跳转并执行操作
|
|
||||||
if (action.type === "navigate-and-execute") {
|
|
||||||
ElMessage.success(`正在跳转到 ${action.pageName} 并执行操作...`);
|
|
||||||
|
|
||||||
// 清理之前的定时器
|
|
||||||
if (navigationTimer) {
|
|
||||||
clearTimeout(navigationTimer);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 跳转并传递待执行的操作信息
|
|
||||||
navigationTimer = setTimeout(() => {
|
|
||||||
navigationTimer = null;
|
|
||||||
const queryParams: any = {
|
|
||||||
// 通过 URL 参数传递 AI 操作信息
|
|
||||||
aiAction: encodeURIComponent(
|
|
||||||
JSON.stringify({
|
|
||||||
functionName: action.functionCall.name,
|
|
||||||
arguments: action.functionCall.arguments,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 如果有查询关键字,也一并传递
|
|
||||||
if (action.query) {
|
|
||||||
queryParams.keywords = action.query;
|
|
||||||
queryParams.autoSearch = "true";
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push({
|
|
||||||
path: action.path,
|
|
||||||
query: queryParams,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 关闭对话框
|
|
||||||
handleClose();
|
|
||||||
}, 800);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action.type === "navigate") {
|
|
||||||
// 检查是否已经在目标页面
|
|
||||||
const currentPath = router.currentRoute.value.path;
|
|
||||||
|
|
||||||
if (currentPath === action.path) {
|
|
||||||
// 如果已经在目标页面
|
|
||||||
if (action.query) {
|
|
||||||
// 有查询关键字,直接在当前页面执行搜索
|
|
||||||
ElMessage.info(`您已在 ${action.pageName} 页面,为您执行搜索:${action.query}`);
|
|
||||||
|
|
||||||
// 触发路由更新,让页面执行搜索
|
|
||||||
router.replace({
|
|
||||||
path: action.path,
|
|
||||||
query: {
|
|
||||||
keywords: action.query,
|
|
||||||
autoSearch: "true",
|
|
||||||
_t: Date.now().toString(), // 添加时间戳强制刷新
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 没有查询关键字,只是跳转,给出提示
|
|
||||||
ElMessage.warning(`您已经在 ${action.pageName} 页面了`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭对话框
|
|
||||||
handleClose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 不在目标页面,正常跳转
|
|
||||||
ElMessage.success(`正在跳转到 ${action.pageName}...`);
|
|
||||||
|
|
||||||
// 清理之前的定时器
|
|
||||||
if (navigationTimer) {
|
|
||||||
clearTimeout(navigationTimer);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 延迟一下让用户看到提示
|
|
||||||
navigationTimer = setTimeout(() => {
|
|
||||||
navigationTimer = null;
|
|
||||||
// 跳转并传递查询参数
|
|
||||||
router.push({
|
|
||||||
path: action.path,
|
|
||||||
query: action.query
|
|
||||||
? {
|
|
||||||
keywords: action.query, // 传递关键字参数
|
|
||||||
autoSearch: "true", // 标记自动搜索
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 关闭对话框
|
|
||||||
handleClose();
|
|
||||||
}, 1000);
|
|
||||||
} else if (action.type === "execute") {
|
|
||||||
// 执行函数调用
|
|
||||||
ElMessage.info("功能开发中,请前往 AI 命令助手页面体验完整功能");
|
|
||||||
|
|
||||||
// 清理之前的定时器
|
|
||||||
if (executeTimer) {
|
|
||||||
clearTimeout(executeTimer);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 可以跳转到完整的 AI 命令页面
|
|
||||||
executeTimer = setTimeout(() => {
|
|
||||||
executeTimer = null;
|
|
||||||
router.push("/function/ai-command");
|
|
||||||
handleClose();
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 组件卸载时清理定时器
|
|
||||||
onMounted(() => {
|
|
||||||
updateFabPosition();
|
|
||||||
window.addEventListener("resize", updateFabPosition);
|
|
||||||
|
|
||||||
domObserver = new MutationObserver(() => {
|
|
||||||
scheduleUpdateFabPosition();
|
|
||||||
});
|
|
||||||
domObserver.observe(document.body, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ["class", "style"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
window.removeEventListener("resize", updateFabPosition);
|
|
||||||
if (domObserver) {
|
|
||||||
domObserver.disconnect();
|
|
||||||
domObserver = null;
|
|
||||||
}
|
|
||||||
if (rafId != null) {
|
|
||||||
window.cancelAnimationFrame(rafId);
|
|
||||||
rafId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (navigationTimer) {
|
|
||||||
clearTimeout(navigationTimer);
|
|
||||||
navigationTimer = null;
|
|
||||||
}
|
|
||||||
if (executeTimer) {
|
|
||||||
clearTimeout(executeTimer);
|
|
||||||
executeTimer = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.ai-assistant {
|
|
||||||
.ai-fab-button {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 9999;
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
box-shadow: var(--el-box-shadow);
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: var(--el-box-shadow-dark);
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-icon {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-fab-tab {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 9999;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 42px;
|
|
||||||
height: 42px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--el-color-white);
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
background: var(--el-color-primary);
|
|
||||||
border-radius: 999px;
|
|
||||||
box-shadow: var(--el-box-shadow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-assistant-dialog {
|
|
||||||
.dialog-header {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.header-icon {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.command-input {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-commands {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.command-tag {
|
|
||||||
margin-right: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: var(--el-box-shadow-light);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-response {
|
|
||||||
margin-top: 16px;
|
|
||||||
|
|
||||||
.action-preview {
|
|
||||||
padding: 12px;
|
|
||||||
margin-top: 12px;
|
|
||||||
background-color: var(--el-fill-color-light);
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
.action-title {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-content {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
color: var(--el-text-color-regular);
|
|
||||||
|
|
||||||
.el-icon {
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.query-info {
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-footer {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<template>
|
|
||||||
<component :is="linkType" v-bind="linkProps(to)">
|
|
||||||
<slot />
|
|
||||||
</component>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
defineOptions({
|
|
||||||
name: "AppLink",
|
|
||||||
inheritAttrs: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
import { isExternal } from "@/utils/index";
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
to: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const isExternalLink = computed(() => {
|
|
||||||
return isExternal(props.to.path || "");
|
|
||||||
});
|
|
||||||
|
|
||||||
const linkType = computed(() => (isExternalLink.value ? "a" : "router-link"));
|
|
||||||
|
|
||||||
const linkProps = (to: any) => {
|
|
||||||
if (isExternalLink.value) {
|
|
||||||
return {
|
|
||||||
href: to.path,
|
|
||||||
target: "_blank",
|
|
||||||
rel: "noopener noreferrer",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { to };
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
<template>
|
|
||||||
<el-breadcrumb class="flex-y-center">
|
|
||||||
<el-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="item.path">
|
|
||||||
<span
|
|
||||||
v-if="item.redirect === 'noredirect' || index === breadcrumbs.length - 1"
|
|
||||||
class="color-gray-400"
|
|
||||||
>
|
|
||||||
{{ translateRouteTitle(item.meta.title) }}
|
|
||||||
</span>
|
|
||||||
<a v-else @click.prevent="handleLink(item)">
|
|
||||||
{{ translateRouteTitle(item.meta.title) }}
|
|
||||||
</a>
|
|
||||||
</el-breadcrumb-item>
|
|
||||||
</el-breadcrumb>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { RouteLocationMatched } from "vue-router";
|
|
||||||
import { compile } from "path-to-regexp";
|
|
||||||
import router from "@/router";
|
|
||||||
import { translateRouteTitle } from "@/utils/i18n";
|
|
||||||
|
|
||||||
const currentRoute = useRoute();
|
|
||||||
const pathCompile = (path: string) => {
|
|
||||||
const { params } = currentRoute;
|
|
||||||
const toPath = compile(path);
|
|
||||||
return toPath(params);
|
|
||||||
};
|
|
||||||
|
|
||||||
const breadcrumbs = ref<Array<RouteLocationMatched>>([]);
|
|
||||||
|
|
||||||
function getBreadcrumb() {
|
|
||||||
let matched = currentRoute.matched.filter((item) => item.meta && item.meta.title);
|
|
||||||
const first = matched[0];
|
|
||||||
if (!isDashboard(first)) {
|
|
||||||
matched = [{ path: "/home", meta: { title: "dashboard" } } as any].concat(matched);
|
|
||||||
}
|
|
||||||
breadcrumbs.value = matched.filter((item) => {
|
|
||||||
return item.meta && item.meta.title && item.meta.breadcrumb !== false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDashboard(route: RouteLocationMatched) {
|
|
||||||
const name = route && route.name;
|
|
||||||
if (!name) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return name.toString().trim().toLocaleLowerCase() === "Dashboard".toLocaleLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLink(item: any) {
|
|
||||||
const { redirect, path } = item;
|
|
||||||
if (redirect) {
|
|
||||||
router.push(redirect).catch((err) => {
|
|
||||||
console.warn(err);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(pathCompile(path)).catch((err) => {
|
|
||||||
console.warn(err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => currentRoute.path,
|
|
||||||
(path) => {
|
|
||||||
if (path.startsWith("/redirect/")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
getBreadcrumb();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
onBeforeMount(() => {
|
|
||||||
getBreadcrumb();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
// 覆盖 element-plus 的样式
|
|
||||||
.el-breadcrumb__inner,
|
|
||||||
.el-breadcrumb__inner a {
|
|
||||||
font-weight: 400 !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
<template>
|
|
||||||
<el-dialog
|
|
||||||
v-model="visible"
|
|
||||||
:width="width"
|
|
||||||
:draggable="draggable"
|
|
||||||
:fullscreen="fullscreen"
|
|
||||||
:show-close="false"
|
|
||||||
:class="dialogClass"
|
|
||||||
:modal-class="modalClass"
|
|
||||||
align-center
|
|
||||||
destroy-on-close
|
|
||||||
v-bind="dialogAttrs"
|
|
||||||
@close="emit('close')"
|
|
||||||
@opened="emit('opened')"
|
|
||||||
>
|
|
||||||
<template #header="{ titleId, titleClass, close }">
|
|
||||||
<div class="curd-dialog-header">
|
|
||||||
<span :id="titleId" :class="titleClass">{{ title }}</span>
|
|
||||||
<div class="curd-dialog-header__actions">
|
|
||||||
<el-tooltip :content="fullscreen ? '还原' : '全屏'" placement="top">
|
|
||||||
<el-button text circle type="primary" @click="fullscreen = !fullscreen">
|
|
||||||
<el-icon>
|
|
||||||
<Fold v-if="fullscreen" />
|
|
||||||
<FullScreen v-else />
|
|
||||||
</el-icon>
|
|
||||||
</el-button>
|
|
||||||
</el-tooltip>
|
|
||||||
<el-tooltip content="关闭" placement="top">
|
|
||||||
<el-button text circle @click="close">
|
|
||||||
<el-icon><Close /></el-icon>
|
|
||||||
</el-button>
|
|
||||||
</el-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<slot />
|
|
||||||
<template v-if="$slots.footer" #footer>
|
|
||||||
<slot name="footer" />
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { Close, Fold, FullScreen } from "@element-plus/icons-vue";
|
|
||||||
import type { DialogProps } from "element-plus";
|
|
||||||
import { computed, ref, useAttrs, watch } from "vue";
|
|
||||||
|
|
||||||
defineOptions({ inheritAttrs: false });
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
modelValue: boolean;
|
|
||||||
title?: string;
|
|
||||||
width?: string | number;
|
|
||||||
/** 默认可拖拽;全屏时 Element Plus 会限制拖拽 */
|
|
||||||
draggable?: boolean;
|
|
||||||
/** 透传到 el-dialog 的 class */
|
|
||||||
dialogClass?: string;
|
|
||||||
/** 遮罩层自定义 class */
|
|
||||||
modalClass?: string;
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
draggable: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
"update:modelValue": [v: boolean];
|
|
||||||
close: [];
|
|
||||||
opened: [];
|
|
||||||
"fullscreen-change": [isFullscreen: boolean];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const attrs = useAttrs();
|
|
||||||
const fullscreen = ref(false);
|
|
||||||
|
|
||||||
// 监听全屏状态变化并发出事件
|
|
||||||
watch(fullscreen, (newVal) => {
|
|
||||||
emit("fullscreen-change", newVal);
|
|
||||||
});
|
|
||||||
|
|
||||||
const dialogClass = computed(() => {
|
|
||||||
const a = attrs.class;
|
|
||||||
return [props.dialogClass, a].filter(Boolean);
|
|
||||||
});
|
|
||||||
|
|
||||||
const visible = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (v: boolean) => emit("update:modelValue", v),
|
|
||||||
});
|
|
||||||
|
|
||||||
/** 透传除 modelValue 外的 el-dialog 属性(如 top、modal-class、append-to-body 等) */
|
|
||||||
const dialogAttrs = computed(() => {
|
|
||||||
const a = { ...attrs } as Record<string, unknown>;
|
|
||||||
delete a.class;
|
|
||||||
return a as Partial<Omit<DialogProps, "modelValue">>;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.curd-dialog-header {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
|
||||||
padding-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.curd-dialog-header__actions {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-shrink: 0;
|
|
||||||
gap: 2px;
|
|
||||||
align-items: center;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
<template>
|
|
||||||
<el-drawer
|
|
||||||
v-model="visible"
|
|
||||||
:size="size"
|
|
||||||
:direction="direction"
|
|
||||||
:show-close="false"
|
|
||||||
:class="drawerClassMerged"
|
|
||||||
destroy-on-close
|
|
||||||
v-bind="drawerAttrs"
|
|
||||||
@close="emit('close')"
|
|
||||||
@opened="emit('opened')"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<div class="curd-drawer-header">
|
|
||||||
<span class="curd-drawer-header__title">{{ title }}</span>
|
|
||||||
<div class="curd-drawer-header__actions">
|
|
||||||
<el-tooltip content="关闭" placement="top">
|
|
||||||
<el-button text circle @click="visible = false">
|
|
||||||
<el-icon><Close /></el-icon>
|
|
||||||
</el-button>
|
|
||||||
</el-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<slot />
|
|
||||||
<template v-if="$slots.footer" #footer>
|
|
||||||
<slot name="footer" />
|
|
||||||
</template>
|
|
||||||
</el-drawer>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { Close } from "@element-plus/icons-vue";
|
|
||||||
import type { DrawerProps } from "element-plus";
|
|
||||||
import { computed, useAttrs } from "vue";
|
|
||||||
|
|
||||||
defineOptions({ inheritAttrs: false });
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
modelValue: boolean;
|
|
||||||
title?: string;
|
|
||||||
size?: string | number;
|
|
||||||
direction?: "rtl" | "ltr" | "ttb" | "btt";
|
|
||||||
/** 透传到 el-drawer 的 class */
|
|
||||||
drawerClass?: string;
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
direction: "rtl",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
"update:modelValue": [v: boolean];
|
|
||||||
close: [];
|
|
||||||
opened: [];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const attrs = useAttrs();
|
|
||||||
|
|
||||||
const visible = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (v: boolean) => emit("update:modelValue", v),
|
|
||||||
});
|
|
||||||
|
|
||||||
const drawerClassMerged = computed(() => {
|
|
||||||
const a = attrs.class;
|
|
||||||
return [props.drawerClass, a].filter(Boolean);
|
|
||||||
});
|
|
||||||
|
|
||||||
const drawerAttrs = computed(() => {
|
|
||||||
const a = { ...attrs } as Record<string, unknown>;
|
|
||||||
delete a.class;
|
|
||||||
return a as Partial<Omit<DrawerProps, "modelValue">>;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.curd-drawer-header {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
|
||||||
padding-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.curd-drawer-header__title {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.curd-drawer-header__actions {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-shrink: 0;
|
|
||||||
gap: 2px;
|
|
||||||
align-items: center;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="curd-export-modal-host">
|
|
||||||
<!-- 导出弹窗 -->
|
|
||||||
<EnhancedDialog
|
|
||||||
v-model="exportsModalVisible"
|
|
||||||
title="导出数据"
|
|
||||||
width="600px"
|
|
||||||
dialog-class="curd-embed-dialog"
|
|
||||||
@close="handleCloseExportsModal"
|
|
||||||
>
|
|
||||||
<!-- 滚动 -->
|
|
||||||
<el-scrollbar max-height="60vh">
|
|
||||||
<!-- 表单 -->
|
|
||||||
<el-form
|
|
||||||
ref="exportsFormRef"
|
|
||||||
style="padding-right: var(--el-dialog-padding-primary)"
|
|
||||||
:model="exportsFormData"
|
|
||||||
:rules="exportsFormRules"
|
|
||||||
>
|
|
||||||
<el-form-item label="文件名" prop="filename">
|
|
||||||
<el-input v-model="exportsFormData.filename" placeholder="请输入文件名" clearable />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="工作表名" prop="sheetname">
|
|
||||||
<el-input v-model="exportsFormData.sheetname" placeholder="请输入工作表名" clearable />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="数据源" prop="origin">
|
|
||||||
<el-select v-model="exportsFormData.origin">
|
|
||||||
<el-option
|
|
||||||
label="当前数据 (当前页的数据)"
|
|
||||||
:value="ExportsOriginEnum.CURRENT"
|
|
||||||
:disabled="!pageData?.length"
|
|
||||||
/>
|
|
||||||
<el-option
|
|
||||||
label="选中数据 (所有选中的数据)"
|
|
||||||
:value="ExportsOriginEnum.SELECTED"
|
|
||||||
:disabled="!selectionData?.length"
|
|
||||||
/>
|
|
||||||
<el-option
|
|
||||||
label="全量数据 (所有分页的数据)"
|
|
||||||
:value="ExportsOriginEnum.REMOTE"
|
|
||||||
:disabled="!props.contentConfig.exportsAction"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="字段" prop="fields">
|
|
||||||
<el-checkbox-group v-model="exportsFormData.fields">
|
|
||||||
<template v-for="col in cols" :key="col.prop">
|
|
||||||
<el-checkbox v-if="col.prop" :value="col.prop" :label="col.label" />
|
|
||||||
</template>
|
|
||||||
</el-checkbox-group>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
</el-scrollbar>
|
|
||||||
<!-- 弹窗底部操作按钮 -->
|
|
||||||
<template #footer>
|
|
||||||
<div style="padding-right: var(--el-dialog-padding-primary)">
|
|
||||||
<el-button type="primary" @click="handleExportsSubmit">确 定</el-button>
|
|
||||||
<el-button @click="handleCloseExportsModal">取 消</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</EnhancedDialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import EnhancedDialog from "./EnhancedDialog.vue";
|
|
||||||
import ExcelJS from "exceljs";
|
|
||||||
import type { IContentConfig, IObject } from "./types";
|
|
||||||
import { useThrottleFn } from "@vueuse/core";
|
|
||||||
import { type FormInstance, type FormRules, ElMessage } from "element-plus";
|
|
||||||
import { nextTick, ref, reactive, computed } from "vue";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 导出模态框组件属性定义
|
|
||||||
*/
|
|
||||||
interface ExportModalProps {
|
|
||||||
/** 内容配置 */
|
|
||||||
contentConfig: Pick<IContentConfig, "permPrefix" | "cols" | "exportsAction">;
|
|
||||||
/** 查询参数 */
|
|
||||||
queryParams?: IObject;
|
|
||||||
/** 页面数据 */
|
|
||||||
pageData?: IObject[];
|
|
||||||
/** 选中数据 */
|
|
||||||
selectionData?: IObject[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 定义接收的属性
|
|
||||||
const props = defineProps<ExportModalProps>();
|
|
||||||
|
|
||||||
// 定义模型值(控制弹窗显示/隐藏)
|
|
||||||
const exportsModalVisible = defineModel<boolean>("modelValue", {
|
|
||||||
required: true,
|
|
||||||
default: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 导出数据源枚举
|
|
||||||
*/
|
|
||||||
enum ExportsOriginEnum {
|
|
||||||
/** 当前数据 */
|
|
||||||
CURRENT = "current",
|
|
||||||
/** 选中数据 */
|
|
||||||
SELECTED = "selected",
|
|
||||||
/** 远程数据 */
|
|
||||||
REMOTE = "remote",
|
|
||||||
}
|
|
||||||
|
|
||||||
const exportsFormRef = ref<FormInstance>();
|
|
||||||
const exportsFormData = reactive({
|
|
||||||
filename: "",
|
|
||||||
sheetname: "",
|
|
||||||
fields: [] as string[],
|
|
||||||
origin: ExportsOriginEnum.CURRENT,
|
|
||||||
});
|
|
||||||
const exportsFormRules: FormRules = {
|
|
||||||
fields: [{ required: true, message: "请选择字段" }],
|
|
||||||
origin: [{ required: true, message: "请选择数据源" }],
|
|
||||||
};
|
|
||||||
|
|
||||||
// 表格列
|
|
||||||
const cols = computed(() =>
|
|
||||||
props.contentConfig.cols.map((col) => {
|
|
||||||
if (col.initFn) {
|
|
||||||
col.initFn(col);
|
|
||||||
}
|
|
||||||
if (col.show === undefined) {
|
|
||||||
col.show = true;
|
|
||||||
}
|
|
||||||
if (col.prop !== undefined && col.columnKey === undefined && col["column-key"] === undefined) {
|
|
||||||
col.columnKey = col.prop;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
col.type === "selection" &&
|
|
||||||
col.reserveSelection === undefined &&
|
|
||||||
col["reserve-selection"] === undefined
|
|
||||||
) {
|
|
||||||
// 配合表格row-key实现跨页多选
|
|
||||||
col.reserveSelection = true;
|
|
||||||
}
|
|
||||||
return col;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// 初始化字段
|
|
||||||
const initFields = () => {
|
|
||||||
const fields: string[] = [];
|
|
||||||
cols.value.forEach((item) => {
|
|
||||||
if (item.prop !== undefined) {
|
|
||||||
fields.push(item.prop);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
exportsFormData.fields = fields;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
initFields();
|
|
||||||
|
|
||||||
// 关闭导出弹窗
|
|
||||||
function handleCloseExportsModal() {
|
|
||||||
exportsModalVisible.value = false;
|
|
||||||
exportsFormRef.value?.resetFields();
|
|
||||||
nextTick(() => {
|
|
||||||
exportsFormRef.value?.clearValidate();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导出
|
|
||||||
function handleExports() {
|
|
||||||
try {
|
|
||||||
const filename = exportsFormData.filename
|
|
||||||
? exportsFormData.filename
|
|
||||||
: props.contentConfig.permPrefix || "export";
|
|
||||||
const sheetname = exportsFormData.sheetname ? exportsFormData.sheetname : "sheet";
|
|
||||||
const workbook = new ExcelJS.Workbook();
|
|
||||||
const worksheet = workbook.addWorksheet(sheetname);
|
|
||||||
const columns: Partial<ExcelJS.Column>[] = [];
|
|
||||||
cols.value.forEach((col) => {
|
|
||||||
if (col.label && col.prop && exportsFormData.fields.includes(col.prop)) {
|
|
||||||
columns.push({ header: col.label, key: col.prop });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
worksheet.columns = columns;
|
|
||||||
|
|
||||||
if (exportsFormData.origin === ExportsOriginEnum.REMOTE) {
|
|
||||||
if (props.contentConfig.exportsAction) {
|
|
||||||
const lastFormData = props.queryParams ?? {};
|
|
||||||
props.contentConfig
|
|
||||||
.exportsAction(lastFormData)
|
|
||||||
.then((res) => {
|
|
||||||
worksheet.addRows(res);
|
|
||||||
workbook.xlsx
|
|
||||||
.writeBuffer()
|
|
||||||
.then((buffer) => {
|
|
||||||
saveXlsx(buffer, filename as string);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("导出远程数据失败:", error);
|
|
||||||
ElMessage.error("导出远程数据失败");
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("获取远程数据失败:", error);
|
|
||||||
ElMessage.error("获取远程数据失败");
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
ElMessage.error("未配置exportsAction");
|
|
||||||
}
|
|
||||||
} else if (exportsFormData.origin === ExportsOriginEnum.SELECTED) {
|
|
||||||
const rows = props.selectionData ?? [];
|
|
||||||
worksheet.addRows(rows);
|
|
||||||
workbook.xlsx
|
|
||||||
.writeBuffer()
|
|
||||||
.then((buffer) => {
|
|
||||||
saveXlsx(buffer, filename as string);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("导出选中数据失败:", error);
|
|
||||||
ElMessage.error("导出选中数据失败");
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const rows = props.pageData ?? [];
|
|
||||||
worksheet.addRows(rows);
|
|
||||||
workbook.xlsx
|
|
||||||
.writeBuffer()
|
|
||||||
.then((buffer) => {
|
|
||||||
saveXlsx(buffer, filename as string);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("导出当前数据失败:", error);
|
|
||||||
ElMessage.error("导出当前数据失败");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("导出失败:", error);
|
|
||||||
ElMessage.error("导出失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导出确认
|
|
||||||
const handleExportsSubmit = useThrottleFn(() => {
|
|
||||||
exportsFormRef.value?.validate((valid: boolean) => {
|
|
||||||
if (valid) {
|
|
||||||
handleExports();
|
|
||||||
handleCloseExportsModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
// 浏览器保存文件
|
|
||||||
function saveXlsx(fileData: any, fileName: string) {
|
|
||||||
try {
|
|
||||||
const fileType =
|
|
||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8";
|
|
||||||
|
|
||||||
const blob = new Blob([fileData], { type: fileType });
|
|
||||||
const downloadUrl = window.URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
const downloadLink = document.createElement("a");
|
|
||||||
downloadLink.href = downloadUrl;
|
|
||||||
downloadLink.download = fileName;
|
|
||||||
|
|
||||||
document.body.appendChild(downloadLink);
|
|
||||||
downloadLink.click();
|
|
||||||
|
|
||||||
document.body.removeChild(downloadLink);
|
|
||||||
window.URL.revokeObjectURL(downloadUrl);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("保存文件失败:", error);
|
|
||||||
ElMessage.error("保存文件失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提供给父组件的方法
|
|
||||||
defineExpose({
|
|
||||||
handleCloseExportsModal,
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="curd-import-modal-host">
|
|
||||||
<!-- 导入弹窗 -->
|
|
||||||
<EnhancedDialog
|
|
||||||
v-model="importModalVisible"
|
|
||||||
:title="props.title"
|
|
||||||
:width="props.width"
|
|
||||||
dialog-class="curd-embed-dialog"
|
|
||||||
@close="handleClose"
|
|
||||||
>
|
|
||||||
<!-- 滚动 -->
|
|
||||||
<el-scrollbar :max-height="props.maxHeight">
|
|
||||||
<!-- 表单 -->
|
|
||||||
<el-form
|
|
||||||
ref="importFormRef"
|
|
||||||
style="padding-right: var(--el-dialog-padding-primary)"
|
|
||||||
:model="importFormData"
|
|
||||||
:rules="importFormRules"
|
|
||||||
>
|
|
||||||
<el-form-item prop="files">
|
|
||||||
<el-upload
|
|
||||||
ref="uploadRef"
|
|
||||||
v-model:file-list="importFormData.files"
|
|
||||||
class="w-full"
|
|
||||||
:accept="props.accept"
|
|
||||||
:drag="true"
|
|
||||||
:limit="props.limit"
|
|
||||||
:auto-upload="false"
|
|
||||||
:on-exceed="handleFileExceed"
|
|
||||||
>
|
|
||||||
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
|
|
||||||
<div class="el-upload__text">
|
|
||||||
{{ props.dropText || "将文件拖到此处,或" }}
|
|
||||||
<em>{{ props.browseText || "点击上传" }}</em>
|
|
||||||
</div>
|
|
||||||
<template #tip>
|
|
||||||
<div class="el-upload__tip flex flex-wrap gap-2">
|
|
||||||
<el-text v-if="props.note" type="warning" class="mx-1">{{ props.note }}</el-text>
|
|
||||||
<el-text v-if="props.fileTypeWarning" type="danger" class="mx-1">
|
|
||||||
{{ props.fileTypeWarning }}
|
|
||||||
</el-text>
|
|
||||||
<el-link
|
|
||||||
v-if="props.showTemplateDownload"
|
|
||||||
v-hasPerm="[`${props.contentConfig.permPrefix}:download`]"
|
|
||||||
class="mx-1"
|
|
||||||
type="primary"
|
|
||||||
icon="download"
|
|
||||||
underline="never"
|
|
||||||
@click="handleDownloadTemplate"
|
|
||||||
>
|
|
||||||
{{ props.templateDownloadText || "下载模板" }}
|
|
||||||
</el-link>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-upload>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
</el-scrollbar>
|
|
||||||
<template #footer>
|
|
||||||
<div style="padding-right: var(--el-dialog-padding-primary)">
|
|
||||||
<el-button @click="handleClose">{{ props.cancelButtonText || "取 消" }}</el-button>
|
|
||||||
<el-button
|
|
||||||
type="primary"
|
|
||||||
:disabled="importFormData.files.length === 0 || props.loading"
|
|
||||||
:loading="props.loading"
|
|
||||||
@click="handleUpload"
|
|
||||||
>
|
|
||||||
{{ props.confirmButtonText || "确 定" }}
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</EnhancedDialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import EnhancedDialog from "./EnhancedDialog.vue";
|
|
||||||
import { ElMessage, type UploadUserFile } from "element-plus";
|
|
||||||
import { ref, reactive } from "vue";
|
|
||||||
import type { IContentConfig, IObject } from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 导入模态框组件属性定义
|
|
||||||
*/
|
|
||||||
interface ImportModalProps {
|
|
||||||
/**
|
|
||||||
* 弹窗标题
|
|
||||||
*/
|
|
||||||
title?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 弹窗宽度
|
|
||||||
*/
|
|
||||||
width?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 最大高度
|
|
||||||
*/
|
|
||||||
maxHeight?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 接受的文件类型
|
|
||||||
*/
|
|
||||||
accept?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 文件数量限制
|
|
||||||
*/
|
|
||||||
limit?: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否显示下载模板按钮
|
|
||||||
*/
|
|
||||||
showTemplateDownload?: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 拖放提示文本
|
|
||||||
*/
|
|
||||||
dropText?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 浏览按钮文本
|
|
||||||
*/
|
|
||||||
browseText?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 模板下载按钮文本
|
|
||||||
*/
|
|
||||||
templateDownloadText?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 取消按钮文本
|
|
||||||
*/
|
|
||||||
cancelButtonText?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 确认按钮文本
|
|
||||||
*/
|
|
||||||
confirmButtonText?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注意事项文本
|
|
||||||
*/
|
|
||||||
note?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 文件类型警告文本
|
|
||||||
*/
|
|
||||||
fileTypeWarning?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传文件的参数名
|
|
||||||
*/
|
|
||||||
uploadFileName?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传请求的额外参数
|
|
||||||
*/
|
|
||||||
uploadData?: IObject;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 导入配置
|
|
||||||
*/
|
|
||||||
contentConfig: IContentConfig;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传loading状态
|
|
||||||
*/
|
|
||||||
loading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 定义props
|
|
||||||
const props = withDefaults(defineProps<ImportModalProps>(), {
|
|
||||||
title: "导入数据",
|
|
||||||
width: "600px",
|
|
||||||
maxHeight: "60vh",
|
|
||||||
accept:
|
|
||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel",
|
|
||||||
limit: 1,
|
|
||||||
showTemplateDownload: true,
|
|
||||||
note: "注意事项:",
|
|
||||||
fileTypeWarning: "格式为*.xlsx / *.xls,文件不超过 5MB",
|
|
||||||
uploadFileName: "file",
|
|
||||||
uploadData: () => ({}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 定义模型值(控制弹窗显示/隐藏)
|
|
||||||
const importModalVisible = defineModel<boolean>("modelValue", {
|
|
||||||
required: true,
|
|
||||||
default: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 定义事件
|
|
||||||
const emit = defineEmits<{
|
|
||||||
/** 导入成功事件 */
|
|
||||||
"import-success": [data: any];
|
|
||||||
/** 导入失败事件 */
|
|
||||||
"import-fail": [error: any];
|
|
||||||
/** 关闭事件 */
|
|
||||||
close: [];
|
|
||||||
/** 下载模板事件 */
|
|
||||||
"download-template": [];
|
|
||||||
/** 上传事件 */
|
|
||||||
upload: [formData: FormData, file: File];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
// 引用
|
|
||||||
const importFormRef = ref(null);
|
|
||||||
const uploadRef = ref(null);
|
|
||||||
|
|
||||||
// 表单数据
|
|
||||||
const importFormData = reactive<{
|
|
||||||
files: UploadUserFile[];
|
|
||||||
}>({
|
|
||||||
files: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
// 表单规则
|
|
||||||
const importFormRules = {
|
|
||||||
files: [{ required: true, message: "文件不能为空", trigger: "blur" }],
|
|
||||||
};
|
|
||||||
|
|
||||||
// 文件超出个数限制
|
|
||||||
const handleFileExceed = () => {
|
|
||||||
ElMessage.warning(`只能上传${props.limit}个文件`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 浏览器保存文件
|
|
||||||
function saveXlsx(fileData: any, fileName: string) {
|
|
||||||
const fileType =
|
|
||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8";
|
|
||||||
|
|
||||||
const blob = new Blob([fileData], { type: fileType });
|
|
||||||
const downloadUrl = window.URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
const downloadLink = document.createElement("a");
|
|
||||||
downloadLink.href = downloadUrl;
|
|
||||||
downloadLink.download = fileName;
|
|
||||||
|
|
||||||
document.body.appendChild(downloadLink);
|
|
||||||
downloadLink.click();
|
|
||||||
|
|
||||||
document.body.removeChild(downloadLink);
|
|
||||||
window.URL.revokeObjectURL(downloadUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 下载导入模板
|
|
||||||
function handleDownloadTemplate() {
|
|
||||||
try {
|
|
||||||
const importTemplate = props.contentConfig.importTemplate;
|
|
||||||
if (typeof importTemplate === "string") {
|
|
||||||
window.open(importTemplate);
|
|
||||||
} else if (typeof importTemplate === "function") {
|
|
||||||
importTemplate().then((response) => {
|
|
||||||
const fileData = response.data;
|
|
||||||
const fileName = decodeURI(
|
|
||||||
response.headers["content-disposition"].split(";")[1].split("=")[1]
|
|
||||||
);
|
|
||||||
saveXlsx(fileData, fileName);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
ElMessage.error("未配置importTemplate");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("下载模板失败:", error);
|
|
||||||
ElMessage.error("下载模板失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 上传文件 - 由父组件实现具体逻辑
|
|
||||||
const handleUpload = async () => {
|
|
||||||
if (!importFormData.files.length) {
|
|
||||||
ElMessage.warning("请选择文件");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const file = importFormData.files[0].raw as File;
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append(props.uploadFileName, file);
|
|
||||||
|
|
||||||
Object.keys(props.uploadData).forEach((key) => {
|
|
||||||
formData.append(key, props.uploadData[key]);
|
|
||||||
});
|
|
||||||
|
|
||||||
emit("upload", formData, file);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("上传失败:", error);
|
|
||||||
ElMessage.error("上传失败:" + error.message || error);
|
|
||||||
emit("import-fail", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 关闭弹窗
|
|
||||||
const handleClose = () => {
|
|
||||||
importFormData.files.length = 0;
|
|
||||||
importModalVisible.value = false;
|
|
||||||
emit("close");
|
|
||||||
};
|
|
||||||
|
|
||||||
// 提供给父组件的方法
|
|
||||||
defineExpose({
|
|
||||||
handleClose,
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,997 +0,0 @@
|
|||||||
<template>
|
|
||||||
<el-card
|
|
||||||
class="data-table flex-1 min-h-0"
|
|
||||||
:shadow="config.cardShadow ?? 'never'"
|
|
||||||
:class="contentConfig.cardClass"
|
|
||||||
:body-style="cardBodyStyle"
|
|
||||||
>
|
|
||||||
<template v-if="slots.header" #header>
|
|
||||||
<slot name="header" />
|
|
||||||
</template>
|
|
||||||
<!-- 表格工具栏:#toolbar 可完全自定义;默认左右分栏 -->
|
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
config.showToolbar !== false &&
|
|
||||||
(slots.toolbar || toolbarLeftBtn.length > 0 || toolbarRightBtn.length > 0)
|
|
||||||
"
|
|
||||||
class="data-table__toolbar"
|
|
||||||
>
|
|
||||||
<slot
|
|
||||||
name="toolbar"
|
|
||||||
:toolbar-left="toolbarLeftBtn"
|
|
||||||
:toolbar-right="toolbarRightBtn"
|
|
||||||
:on-toolbar="handleToolbar"
|
|
||||||
:remove-ids="removeIds"
|
|
||||||
:cols="cols"
|
|
||||||
>
|
|
||||||
<CrudToolbarLeft
|
|
||||||
v-if="toolbarLeftBtn.length > 0"
|
|
||||||
:remove-ids="removeIds"
|
|
||||||
:config-buttons="toolbarLeftBtn"
|
|
||||||
@toolbar="handleToolbar"
|
|
||||||
/>
|
|
||||||
<div class="data-table__toolbar--right">
|
|
||||||
<CrudToolbarRight :buttons="toolbarRightBtn" :cols="cols" :on-toolbar="handleToolbar" />
|
|
||||||
</div>
|
|
||||||
</slot>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 列表:默认内置 el-table;传入 #table 插槽可完全自定义(普通表 / 树表等) -->
|
|
||||||
<div class="data-table__content">
|
|
||||||
<slot
|
|
||||||
name="table"
|
|
||||||
:data="pageData"
|
|
||||||
:loading="loading"
|
|
||||||
:table-ref="tableRef"
|
|
||||||
:on-selection-change="handleSelectionChange"
|
|
||||||
:pagination="pagination"
|
|
||||||
>
|
|
||||||
<el-table
|
|
||||||
ref="tableRef"
|
|
||||||
v-loading="loading"
|
|
||||||
v-bind="contentConfig.table"
|
|
||||||
:data="pageData"
|
|
||||||
:row-key="pk"
|
|
||||||
class="flex-1"
|
|
||||||
@selection-change="handleSelectionChange"
|
|
||||||
@filter-change="handleFilterChange"
|
|
||||||
>
|
|
||||||
<template v-for="col in cols" :key="col.prop">
|
|
||||||
<el-table-column v-if="col.show" v-bind="col">
|
|
||||||
<template #default="scope">
|
|
||||||
<!-- 显示图片 -->
|
|
||||||
<template v-if="col.templet === 'image'">
|
|
||||||
<template v-if="col.prop">
|
|
||||||
<template v-if="Array.isArray(scope.row[col.prop])">
|
|
||||||
<template v-for="(item, index) in scope.row[col.prop]" :key="item">
|
|
||||||
<el-image
|
|
||||||
:src="item"
|
|
||||||
:preview-src-list="scope.row[col.prop]"
|
|
||||||
:initial-index="Number(index)"
|
|
||||||
:preview-teleported="true"
|
|
||||||
:style="`width: ${col.imageWidth ?? 40}px; height: ${col.imageHeight ?? 40}px`"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<el-image
|
|
||||||
:src="scope.row[col.prop]"
|
|
||||||
:preview-src-list="[scope.row[col.prop]]"
|
|
||||||
:preview-teleported="true"
|
|
||||||
:style="`width: ${col.imageWidth ?? 40}px; height: ${col.imageHeight ?? 40}px`"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
<!-- 根据行的selectList属性返回对应列表值 -->
|
|
||||||
<template v-else-if="col.templet === 'list'">
|
|
||||||
<template v-if="col.prop">
|
|
||||||
{{ (col.selectList ?? {})[scope.row[col.prop]] }}
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
<!-- 格式化显示链接 -->
|
|
||||||
<template v-else-if="col.templet === 'url'">
|
|
||||||
<template v-if="col.prop">
|
|
||||||
<el-link type="primary" :href="scope.row[col.prop]" target="_blank">
|
|
||||||
{{ scope.row[col.prop] }}
|
|
||||||
</el-link>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
<!-- 生成开关组件 -->
|
|
||||||
<template v-else-if="col.templet === 'switch'">
|
|
||||||
<template v-if="col.prop">
|
|
||||||
<!-- pageData.length>0: 解决el-switch组件会在表格初始化的时候触发一次change事件 -->
|
|
||||||
<el-switch
|
|
||||||
v-model="scope.row[col.prop]"
|
|
||||||
:active-value="col.activeValue ?? 1"
|
|
||||||
:inactive-value="col.inactiveValue ?? 0"
|
|
||||||
:inline-prompt="true"
|
|
||||||
:active-text="col.activeText ?? ''"
|
|
||||||
:inactive-text="col.inactiveText ?? ''"
|
|
||||||
:validate-event="false"
|
|
||||||
:disabled="col.disabled"
|
|
||||||
@change="
|
|
||||||
pageData.length > 0 &&
|
|
||||||
handleModify(col.prop, scope.row[col.prop], scope.row)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
<!-- 生成输入框组件 -->
|
|
||||||
<template v-else-if="col.templet === 'input'">
|
|
||||||
<template v-if="col.prop">
|
|
||||||
<el-input
|
|
||||||
v-model="scope.row[col.prop]"
|
|
||||||
:type="col.inputType ?? 'text'"
|
|
||||||
:disabled="col.disabled"
|
|
||||||
@blur="handleModify(col.prop, scope.row[col.prop], scope.row)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
<!-- 格式化为价格 -->
|
|
||||||
<template v-else-if="col.templet === 'price'">
|
|
||||||
<template v-if="col.prop">
|
|
||||||
{{ `${col.priceFormat ?? "¥"}${scope.row[col.prop]}` }}
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
<!-- 格式化为百分比 -->
|
|
||||||
<template v-else-if="col.templet === 'percent'">
|
|
||||||
<template v-if="col.prop">{{ scope.row[col.prop] }}%</template>
|
|
||||||
</template>
|
|
||||||
<!-- 显示图标 -->
|
|
||||||
<template v-else-if="col.templet === 'icon'">
|
|
||||||
<template v-if="col.prop">
|
|
||||||
<template v-if="scope.row[col.prop].startsWith('el-icon-')">
|
|
||||||
<el-icon>
|
|
||||||
<component :is="scope.row[col.prop].replace('el-icon-', '')" />
|
|
||||||
</el-icon>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="i-svg:{{ scope.row[col.prop] }}" />
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
<!-- 格式化时间 -->
|
|
||||||
<template v-else-if="col.templet === 'date'">
|
|
||||||
<template v-if="col.prop">
|
|
||||||
{{
|
|
||||||
scope.row[col.prop]
|
|
||||||
? useDateFormat(
|
|
||||||
scope.row[col.prop],
|
|
||||||
col.dateFormat ?? "YYYY-MM-DD HH:mm:ss"
|
|
||||||
).value
|
|
||||||
: ""
|
|
||||||
}}
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
<!-- 列操作栏 -->
|
|
||||||
<template v-else-if="col.templet === 'tool'">
|
|
||||||
<template v-for="(btn, index) in tableToolbarBtn" :key="index">
|
|
||||||
<el-button
|
|
||||||
v-if="btn.render === undefined || btn.render(scope.row)"
|
|
||||||
v-hasPerm="btn.perm ?? '*:*:*'"
|
|
||||||
v-bind="btn.attrs"
|
|
||||||
@click="
|
|
||||||
handleOperate({
|
|
||||||
name: btn.name,
|
|
||||||
row: scope.row,
|
|
||||||
column: scope.column,
|
|
||||||
$index: scope.$index,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ btn.text }}
|
|
||||||
</el-button>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
<!-- 自定义 -->
|
|
||||||
<template v-else-if="col.templet === 'custom'">
|
|
||||||
<slot :name="col.slotName ?? col.prop" :prop="col.prop" v-bind="scope" />
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</template>
|
|
||||||
</el-table>
|
|
||||||
</slot>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-if="showPagination" #footer>
|
|
||||||
<el-scrollbar :class="['h-8!', { 'flex-x-end': contentConfig?.pagePosition === 'right' }]">
|
|
||||||
<el-pagination
|
|
||||||
v-bind="pagination"
|
|
||||||
@size-change="handleSizeChange"
|
|
||||||
@current-change="handleCurrentChange"
|
|
||||||
/>
|
|
||||||
</el-scrollbar>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 导出弹窗 -->
|
|
||||||
<EnhancedDialog
|
|
||||||
v-model="exportsModalVisible"
|
|
||||||
title="导出数据"
|
|
||||||
width="600px"
|
|
||||||
dialog-class="curd-embed-dialog"
|
|
||||||
modal-class="curd-embed-dialog"
|
|
||||||
@close="handleCloseExportsModal"
|
|
||||||
>
|
|
||||||
<!-- 滚动 -->
|
|
||||||
<el-scrollbar max-height="60vh">
|
|
||||||
<!-- 表单 -->
|
|
||||||
<el-form
|
|
||||||
ref="exportsFormRef"
|
|
||||||
style="padding-right: var(--el-dialog-padding-primary)"
|
|
||||||
:model="exportsFormData"
|
|
||||||
:rules="exportsFormRules"
|
|
||||||
>
|
|
||||||
<el-form-item label="文件名" prop="filename">
|
|
||||||
<el-input v-model="exportsFormData.filename" clearable />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="工作表名" prop="sheetname">
|
|
||||||
<el-input v-model="exportsFormData.sheetname" clearable />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="数据源" prop="origin">
|
|
||||||
<el-select v-model="exportsFormData.origin">
|
|
||||||
<el-option label="当前数据 (当前页的数据)" :value="ExportsOriginEnum.CURRENT" />
|
|
||||||
<el-option
|
|
||||||
label="选中数据 (所有选中的数据)"
|
|
||||||
:value="ExportsOriginEnum.SELECTED"
|
|
||||||
:disabled="selectionData.length <= 0"
|
|
||||||
/>
|
|
||||||
<el-option
|
|
||||||
label="全量数据 (所有分页的数据)"
|
|
||||||
:value="ExportsOriginEnum.REMOTE"
|
|
||||||
:disabled="contentConfig.exportsAction === undefined"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="字段" prop="fields">
|
|
||||||
<el-checkbox-group v-model="exportsFormData.fields">
|
|
||||||
<template v-for="col in cols" :key="col.prop">
|
|
||||||
<el-checkbox v-if="col.prop" :value="col.prop" :label="col.label" />
|
|
||||||
</template>
|
|
||||||
</el-checkbox-group>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
</el-scrollbar>
|
|
||||||
<!-- 弹窗底部操作按钮 -->
|
|
||||||
<template #footer>
|
|
||||||
<div style="padding-right: var(--el-dialog-padding-primary)">
|
|
||||||
<el-button type="primary" @click="handleExportsSubmit">确 定</el-button>
|
|
||||||
<el-button @click="handleCloseExportsModal">取 消</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</EnhancedDialog>
|
|
||||||
<!-- 导入弹窗 -->
|
|
||||||
<EnhancedDialog
|
|
||||||
v-model="importModalVisible"
|
|
||||||
title="导入数据"
|
|
||||||
width="600px"
|
|
||||||
dialog-class="curd-embed-dialog"
|
|
||||||
modal-class="curd-embed-dialog"
|
|
||||||
@close="handleCloseImportModal"
|
|
||||||
>
|
|
||||||
<!-- 滚动 -->
|
|
||||||
<el-scrollbar max-height="60vh">
|
|
||||||
<!-- 表单 -->
|
|
||||||
<el-form
|
|
||||||
ref="importFormRef"
|
|
||||||
style="padding-right: var(--el-dialog-padding-primary)"
|
|
||||||
:model="importFormData"
|
|
||||||
:rules="importFormRules"
|
|
||||||
>
|
|
||||||
<el-form-item label="文件名" prop="files">
|
|
||||||
<el-upload
|
|
||||||
ref="uploadRef"
|
|
||||||
v-model:file-list="importFormData.files"
|
|
||||||
class="w-full"
|
|
||||||
accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel"
|
|
||||||
:drag="true"
|
|
||||||
:limit="1"
|
|
||||||
:auto-upload="false"
|
|
||||||
:on-exceed="handleFileExceed"
|
|
||||||
>
|
|
||||||
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
|
|
||||||
<div class="el-upload__text">
|
|
||||||
<span>将文件拖到此处,或</span>
|
|
||||||
<em>点击上传</em>
|
|
||||||
</div>
|
|
||||||
<template #tip>
|
|
||||||
<div class="el-upload__tip">
|
|
||||||
*.xlsx / *.xls
|
|
||||||
<el-link
|
|
||||||
v-if="contentConfig.importTemplate"
|
|
||||||
type="primary"
|
|
||||||
icon="download"
|
|
||||||
underline="never"
|
|
||||||
@click="handleDownloadTemplate"
|
|
||||||
>
|
|
||||||
下载模板
|
|
||||||
</el-link>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-upload>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
</el-scrollbar>
|
|
||||||
<!-- 弹窗底部操作按钮 -->
|
|
||||||
<template #footer>
|
|
||||||
<div style="padding-right: var(--el-dialog-padding-primary)">
|
|
||||||
<el-button
|
|
||||||
type="primary"
|
|
||||||
:disabled="importFormData.files.length === 0"
|
|
||||||
@click="handleImportSubmit"
|
|
||||||
>
|
|
||||||
确 定
|
|
||||||
</el-button>
|
|
||||||
<el-button @click="handleCloseImportModal">取 消</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</EnhancedDialog>
|
|
||||||
</el-card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { useDateFormat, useThrottleFn } from "@vueuse/core";
|
|
||||||
import {
|
|
||||||
ElMessage,
|
|
||||||
genFileId,
|
|
||||||
type FormInstance,
|
|
||||||
type FormRules,
|
|
||||||
type UploadInstance,
|
|
||||||
type UploadRawFile,
|
|
||||||
type UploadUserFile,
|
|
||||||
type TableInstance,
|
|
||||||
} from "element-plus";
|
|
||||||
import ExcelJS from "exceljs";
|
|
||||||
import { reactive, ref, computed, nextTick, useSlots } from "vue";
|
|
||||||
import CrudToolbarLeft from "./CrudToolbarLeft.vue";
|
|
||||||
import CrudToolbarRight from "./CrudToolbarRight.vue";
|
|
||||||
import EnhancedDialog from "./EnhancedDialog.vue";
|
|
||||||
import type { IContentConfig, IObject, IOperateData } from "./types";
|
|
||||||
import type { IToolsButton } from "./types";
|
|
||||||
|
|
||||||
const slots = useSlots();
|
|
||||||
|
|
||||||
const cardBodyStyle = {
|
|
||||||
display: "flex",
|
|
||||||
flex: 1,
|
|
||||||
flexDirection: "column" as const,
|
|
||||||
minHeight: 0,
|
|
||||||
overflow: "hidden",
|
|
||||||
padding: "16px 20px",
|
|
||||||
};
|
|
||||||
|
|
||||||
// 定义接收的属性
|
|
||||||
const props = defineProps<{ contentConfig: IContentConfig }>();
|
|
||||||
// 定义自定义事件
|
|
||||||
const emit = defineEmits<{
|
|
||||||
addClick: [];
|
|
||||||
exportClick: [];
|
|
||||||
toolbarClick: [name: string];
|
|
||||||
editClick: [row: IObject];
|
|
||||||
filterChange: [data: IObject];
|
|
||||||
operateClick: [data: IOperateData];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
// 表格工具栏按钮配置
|
|
||||||
const config = computed(() => props.contentConfig);
|
|
||||||
const buttonConfig = reactive<Record<string, IObject>>({
|
|
||||||
add: { text: "新增", attrs: { icon: "plus", type: "success" }, perm: "create" },
|
|
||||||
delete: { text: "删除", attrs: { icon: "delete", type: "danger" }, perm: "delete" },
|
|
||||||
patch: { text: "批量修改", attrs: { icon: "edit", type: "warning" }, perm: "patch" },
|
|
||||||
import: { text: "导入", attrs: { icon: "upload", type: "info" }, perm: "import" },
|
|
||||||
export: { text: "导出", attrs: { icon: "download", type: "warning" }, perm: "export" },
|
|
||||||
refresh: { text: "刷新", attrs: { icon: "refresh", type: "success" }, perm: "*:*:*" },
|
|
||||||
filter: { text: "筛选列", attrs: { icon: "operation", type: "danger" }, perm: "*:*:*" },
|
|
||||||
view: { text: "详情", attrs: { icon: "view", type: "primary" }, perm: "detail" },
|
|
||||||
edit: { text: "编辑", attrs: { icon: "edit", type: "primary" }, perm: "update" },
|
|
||||||
});
|
|
||||||
|
|
||||||
// 主键
|
|
||||||
const pk = props.contentConfig.pk ?? "id";
|
|
||||||
// 权限名称前缀
|
|
||||||
const authPrefix = computed(() => props.contentConfig.permPrefix);
|
|
||||||
|
|
||||||
// 获取按钮权限标识
|
|
||||||
function getButtonPerm(action: string): string | null {
|
|
||||||
// 如果action已经包含完整路径(包含冒号),则直接使用
|
|
||||||
if (action.includes(":")) {
|
|
||||||
return action;
|
|
||||||
}
|
|
||||||
// 否则使用权限前缀组合
|
|
||||||
return authPrefix.value ? `${authPrefix.value}:${action}` : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否有权限
|
|
||||||
// function hasButtonPerm(action: string): boolean {
|
|
||||||
// const perm = getButtonPerm(action);
|
|
||||||
// // 如果没有设置权限标识,则默认具有权限
|
|
||||||
// if (!perm) return true;
|
|
||||||
// return hasAuth(perm);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 创建工具栏按钮
|
|
||||||
function createToolbar(toolbar: Array<string | IToolsButton>, attr = {}) {
|
|
||||||
return toolbar.map((item) => {
|
|
||||||
const isString = typeof item === "string";
|
|
||||||
const name = isString ? item : item?.name || "";
|
|
||||||
const base = (isString ? buttonConfig[item] : buttonConfig[name]) as IObject | undefined;
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
text: isString ? buttonConfig[item].text : (item?.text ?? base?.text),
|
|
||||||
// 对象写法(如 { name: 'refresh', perm: 'refresh' })需合并 buttonConfig 默认 attrs,否则无 icon/type
|
|
||||||
attrs: {
|
|
||||||
...attr,
|
|
||||||
...(isString ? buttonConfig[item].attrs : { ...base?.attrs, ...item?.attrs }),
|
|
||||||
},
|
|
||||||
render: isString ? undefined : (item?.render ?? undefined),
|
|
||||||
perm: isString
|
|
||||||
? getButtonPerm(buttonConfig[item].perm)
|
|
||||||
: item?.perm
|
|
||||||
? getButtonPerm(item.perm as string)
|
|
||||||
: "*:*:*",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 左侧工具栏按钮
|
|
||||||
const toolbarLeftBtn = computed(() => {
|
|
||||||
if (!config.value.toolbar || config.value.toolbar.length === 0) return [];
|
|
||||||
return createToolbar(config.value.toolbar, {});
|
|
||||||
});
|
|
||||||
|
|
||||||
const hideColumnFilter = computed(() => {
|
|
||||||
if (config.value.hideColumnFilter === true) return true;
|
|
||||||
if (config.value.hideColumnFilter === false) return false;
|
|
||||||
return Boolean(slots.table);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 右侧工具栏按钮
|
|
||||||
const toolbarRightBtn = computed(() => {
|
|
||||||
if (!config.value.defaultToolbar || config.value.defaultToolbar.length === 0) return [];
|
|
||||||
const raw = createToolbar(config.value.defaultToolbar, { circle: true });
|
|
||||||
if (hideColumnFilter.value) {
|
|
||||||
return raw.filter((b) => b.name !== "filter");
|
|
||||||
}
|
|
||||||
return raw;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 表格操作工具栏(优先使用 templet 为 tool 的列,避免仅数据列时取错最后一列)
|
|
||||||
const toolColumn = computed(
|
|
||||||
() => config.value.cols.find((c) => c.templet === "tool") ?? config.value.cols.at(-1)
|
|
||||||
);
|
|
||||||
const tableToolbar = computed(() => toolColumn.value?.operat ?? ["edit", "delete"]);
|
|
||||||
const tableToolbarBtn = computed(() =>
|
|
||||||
createToolbar(tableToolbar.value, { link: true, size: "small" })
|
|
||||||
);
|
|
||||||
|
|
||||||
// 表格列
|
|
||||||
const cols = ref(
|
|
||||||
props.contentConfig.cols.map((col) => {
|
|
||||||
if (col.initFn) {
|
|
||||||
col.initFn(col);
|
|
||||||
}
|
|
||||||
if (col.show === undefined) {
|
|
||||||
col.show = true;
|
|
||||||
}
|
|
||||||
if (col.prop !== undefined && col.columnKey === undefined && col["column-key"] === undefined) {
|
|
||||||
col.columnKey = col.prop;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
col.type === "selection" &&
|
|
||||||
col.reserveSelection === undefined &&
|
|
||||||
col["reserve-selection"] === undefined
|
|
||||||
) {
|
|
||||||
// 配合表格row-key实现跨页多选
|
|
||||||
col.reserveSelection = true;
|
|
||||||
}
|
|
||||||
return col;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
// 加载状态
|
|
||||||
const loading = ref(false);
|
|
||||||
// 列表数据
|
|
||||||
const pageData = ref<IObject[]>([]);
|
|
||||||
// 显示分页
|
|
||||||
const showPagination = props.contentConfig.pagination !== false;
|
|
||||||
// 分页配置
|
|
||||||
const defaultPagination = {
|
|
||||||
background: true,
|
|
||||||
layout: "total, sizes, prev, pager, next, jumper",
|
|
||||||
pageSize: 20,
|
|
||||||
pageSizes: [10, 20, 30, 50],
|
|
||||||
total: 0,
|
|
||||||
currentPage: 1,
|
|
||||||
};
|
|
||||||
const pagination = reactive(
|
|
||||||
typeof props.contentConfig.pagination === "object"
|
|
||||||
? { ...defaultPagination, ...props.contentConfig.pagination }
|
|
||||||
: defaultPagination
|
|
||||||
);
|
|
||||||
// 分页相关的请求参数
|
|
||||||
const request = props.contentConfig.request ?? {
|
|
||||||
page_no: 1,
|
|
||||||
page_size: 10,
|
|
||||||
};
|
|
||||||
|
|
||||||
const tableRef = ref<TableInstance>();
|
|
||||||
|
|
||||||
// 行选中
|
|
||||||
const selectionData = ref<IObject[]>([]);
|
|
||||||
// 删除ID集合 用于批量删除
|
|
||||||
const removeIds = ref<(number | string)[]>([]);
|
|
||||||
function handleSelectionChange(selection: any[]) {
|
|
||||||
selectionData.value = selection;
|
|
||||||
removeIds.value = selection.map((item) => item[pk]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取行选中
|
|
||||||
function getSelectionData() {
|
|
||||||
return selectionData.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刷新
|
|
||||||
function handleRefresh(isRestart = false) {
|
|
||||||
fetchPageData(lastFormData, isRestart);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除
|
|
||||||
function handleDelete(id?: number | string) {
|
|
||||||
let ids = "";
|
|
||||||
if (id !== undefined && id !== null && id !== "") {
|
|
||||||
ids = String(id);
|
|
||||||
} else if (removeIds.value.length) {
|
|
||||||
ids = removeIds.value.map(String).join(",");
|
|
||||||
}
|
|
||||||
if (!ids) {
|
|
||||||
ElMessage.warning("请勾选删除项");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dc = props.contentConfig.deleteConfirm;
|
|
||||||
ElMessageBox.confirm(dc?.message ?? "确认删除?", dc?.title ?? "警告", {
|
|
||||||
confirmButtonText: dc?.confirmButtonText ?? "确定",
|
|
||||||
cancelButtonText: dc?.cancelButtonText ?? "取消",
|
|
||||||
type: dc?.type ?? "warning",
|
|
||||||
})
|
|
||||||
.then(function () {
|
|
||||||
if (props.contentConfig.deleteAction) {
|
|
||||||
props.contentConfig
|
|
||||||
.deleteAction(ids)
|
|
||||||
.then(() => {
|
|
||||||
removeIds.value = [];
|
|
||||||
//清空选中项
|
|
||||||
tableRef.value?.clearSelection();
|
|
||||||
handleRefresh(true);
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
} else {
|
|
||||||
ElMessage.error("未配置deleteAction");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导出表单
|
|
||||||
const fields: string[] = [];
|
|
||||||
cols.value.forEach((item) => {
|
|
||||||
if (item.prop !== undefined) {
|
|
||||||
fields.push(item.prop);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const enum ExportsOriginEnum {
|
|
||||||
CURRENT = "current",
|
|
||||||
SELECTED = "selected",
|
|
||||||
REMOTE = "remote",
|
|
||||||
}
|
|
||||||
const exportsModalVisible = ref(false);
|
|
||||||
const exportsFormRef = ref<FormInstance>();
|
|
||||||
const exportsFormData = reactive({
|
|
||||||
filename: "",
|
|
||||||
sheetname: "",
|
|
||||||
fields,
|
|
||||||
origin: ExportsOriginEnum.CURRENT,
|
|
||||||
});
|
|
||||||
const exportsFormRules: FormRules = {
|
|
||||||
fields: [{ required: true, message: "请选择字段" }],
|
|
||||||
origin: [{ required: true, message: "请选择数据源" }],
|
|
||||||
};
|
|
||||||
// 打开导出弹窗
|
|
||||||
function handleOpenExportsModal() {
|
|
||||||
exportsModalVisible.value = true;
|
|
||||||
}
|
|
||||||
// 导出确认
|
|
||||||
const handleExportsSubmit = useThrottleFn(() => {
|
|
||||||
exportsFormRef.value?.validate((valid: boolean) => {
|
|
||||||
if (valid) {
|
|
||||||
handleExports();
|
|
||||||
handleCloseExportsModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 3000);
|
|
||||||
// 关闭导出弹窗
|
|
||||||
function handleCloseExportsModal() {
|
|
||||||
exportsModalVisible.value = false;
|
|
||||||
exportsFormRef.value?.resetFields();
|
|
||||||
nextTick(() => {
|
|
||||||
exportsFormRef.value?.clearValidate();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 导出
|
|
||||||
function handleExports() {
|
|
||||||
const filename = exportsFormData.filename
|
|
||||||
? exportsFormData.filename
|
|
||||||
: props.contentConfig.permPrefix || "export";
|
|
||||||
const sheetname = exportsFormData.sheetname ? exportsFormData.sheetname : "sheet";
|
|
||||||
const workbook = new ExcelJS.Workbook();
|
|
||||||
const worksheet = workbook.addWorksheet(sheetname);
|
|
||||||
const columns: Partial<ExcelJS.Column>[] = [];
|
|
||||||
cols.value.forEach((col) => {
|
|
||||||
if (col.label && col.prop && exportsFormData.fields.includes(col.prop)) {
|
|
||||||
columns.push({ header: col.label, key: col.prop });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
worksheet.columns = columns;
|
|
||||||
if (exportsFormData.origin === ExportsOriginEnum.REMOTE) {
|
|
||||||
if (props.contentConfig.exportsAction) {
|
|
||||||
props.contentConfig.exportsAction(lastFormData).then((res) => {
|
|
||||||
worksheet.addRows(res);
|
|
||||||
workbook.xlsx
|
|
||||||
.writeBuffer()
|
|
||||||
.then((buffer) => {
|
|
||||||
saveXlsx(buffer, filename as string);
|
|
||||||
})
|
|
||||||
.catch((error) => console.log(error));
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
ElMessage.error("未配置exportsAction");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
worksheet.addRows(
|
|
||||||
exportsFormData.origin === ExportsOriginEnum.SELECTED ? selectionData.value : pageData.value
|
|
||||||
);
|
|
||||||
workbook.xlsx
|
|
||||||
.writeBuffer()
|
|
||||||
.then((buffer) => {
|
|
||||||
saveXlsx(buffer, filename as string);
|
|
||||||
})
|
|
||||||
.catch((error) => console.log(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导入表单
|
|
||||||
let isFileImport = false;
|
|
||||||
const uploadRef = ref<UploadInstance>();
|
|
||||||
const importModalVisible = ref(false);
|
|
||||||
const importFormRef = ref<FormInstance>();
|
|
||||||
const importFormData = reactive<{
|
|
||||||
files: UploadUserFile[];
|
|
||||||
}>({
|
|
||||||
files: [],
|
|
||||||
});
|
|
||||||
const importFormRules: FormRules = {
|
|
||||||
files: [{ required: true, message: "请选择文件" }],
|
|
||||||
};
|
|
||||||
// 打开导入弹窗
|
|
||||||
function handleOpenImportModal(isFile: boolean = false) {
|
|
||||||
importModalVisible.value = true;
|
|
||||||
isFileImport = isFile;
|
|
||||||
}
|
|
||||||
// 覆盖前一个文件
|
|
||||||
function handleFileExceed(files: File[]) {
|
|
||||||
uploadRef.value!.clearFiles();
|
|
||||||
const file = files[0] as UploadRawFile;
|
|
||||||
file.uid = genFileId();
|
|
||||||
uploadRef.value!.handleStart(file);
|
|
||||||
}
|
|
||||||
// 下载导入模板
|
|
||||||
function handleDownloadTemplate() {
|
|
||||||
const importTemplate = props.contentConfig.importTemplate;
|
|
||||||
if (typeof importTemplate === "string") {
|
|
||||||
window.open(importTemplate);
|
|
||||||
} else if (typeof importTemplate === "function") {
|
|
||||||
importTemplate().then((response) => {
|
|
||||||
const fileData = response.data;
|
|
||||||
const fileName = decodeURI(
|
|
||||||
response.headers["content-disposition"].split(";")[1].split("=")[1]
|
|
||||||
);
|
|
||||||
saveXlsx(fileData, fileName);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
ElMessage.error("未配置importTemplate");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 导入确认
|
|
||||||
const handleImportSubmit = useThrottleFn(() => {
|
|
||||||
importFormRef.value?.validate((valid: boolean) => {
|
|
||||||
if (valid) {
|
|
||||||
if (isFileImport) {
|
|
||||||
handleImport();
|
|
||||||
} else {
|
|
||||||
handleImports();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 3000);
|
|
||||||
// 关闭导入弹窗
|
|
||||||
function handleCloseImportModal() {
|
|
||||||
importModalVisible.value = false;
|
|
||||||
importFormRef.value?.resetFields();
|
|
||||||
nextTick(() => {
|
|
||||||
importFormRef.value?.clearValidate();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 文件导入
|
|
||||||
function handleImport() {
|
|
||||||
const importAction = props.contentConfig.importAction;
|
|
||||||
if (importAction === undefined) {
|
|
||||||
ElMessage.error("未配置importAction");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
importAction(importFormData.files[0].raw as File).then(() => {
|
|
||||||
ElMessage.success("导入数据成功");
|
|
||||||
handleCloseImportModal();
|
|
||||||
handleRefresh(true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 导入
|
|
||||||
function handleImports() {
|
|
||||||
const importsAction = props.contentConfig.importsAction;
|
|
||||||
if (importsAction === undefined) {
|
|
||||||
ElMessage.error("未配置importsAction");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 获取选择的文件
|
|
||||||
const file = importFormData.files[0].raw as File;
|
|
||||||
// 创建Workbook实例
|
|
||||||
const workbook = new ExcelJS.Workbook();
|
|
||||||
// 使用FileReader对象来读取文件内容
|
|
||||||
const fileReader = new FileReader();
|
|
||||||
// 二进制字符串的形式加载文件
|
|
||||||
fileReader.readAsArrayBuffer(file);
|
|
||||||
fileReader.onload = (ev) => {
|
|
||||||
if (ev.target !== null && ev.target.result !== null) {
|
|
||||||
const result = ev.target.result as ArrayBuffer;
|
|
||||||
// 从 buffer中加载数据解析
|
|
||||||
workbook.xlsx
|
|
||||||
.load(result)
|
|
||||||
.then((workbook) => {
|
|
||||||
// 解析后的数据
|
|
||||||
const data = [];
|
|
||||||
// 获取第一个worksheet内容
|
|
||||||
const worksheet = workbook.getWorksheet(1);
|
|
||||||
if (worksheet) {
|
|
||||||
// 获取第一行的标题
|
|
||||||
const fields: any[] = [];
|
|
||||||
worksheet.getRow(1).eachCell((cell) => {
|
|
||||||
fields.push(cell.value);
|
|
||||||
});
|
|
||||||
// 遍历工作表的每一行(从第二行开始,因为第一行通常是标题行)
|
|
||||||
for (let rowNumber = 2; rowNumber <= worksheet.rowCount; rowNumber++) {
|
|
||||||
const rowData: IObject = {};
|
|
||||||
const row = worksheet.getRow(rowNumber);
|
|
||||||
// 遍历当前行的每个单元格
|
|
||||||
row.eachCell((cell, colNumber) => {
|
|
||||||
// 获取标题对应的键,并将当前单元格的值存储到相应的属性名中
|
|
||||||
rowData[fields[colNumber - 1]] = cell.value;
|
|
||||||
});
|
|
||||||
// 将当前行的数据对象添加到数组中
|
|
||||||
data.push(rowData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (data.length === 0) {
|
|
||||||
ElMessage.error("未解析到数据");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
importsAction(data).then(() => {
|
|
||||||
ElMessage.success("导入数据成功");
|
|
||||||
handleCloseImportModal();
|
|
||||||
handleRefresh(true);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((error) => console.log(error));
|
|
||||||
} else {
|
|
||||||
ElMessage.error("读取文件失败");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 操作栏
|
|
||||||
function handleToolbar(name: string) {
|
|
||||||
switch (name) {
|
|
||||||
case "refresh":
|
|
||||||
handleRefresh();
|
|
||||||
break;
|
|
||||||
case "export":
|
|
||||||
handleOpenExportsModal();
|
|
||||||
break;
|
|
||||||
case "imports":
|
|
||||||
handleOpenImportModal();
|
|
||||||
break;
|
|
||||||
case "add":
|
|
||||||
emit("addClick");
|
|
||||||
break;
|
|
||||||
case "delete":
|
|
||||||
handleDelete();
|
|
||||||
break;
|
|
||||||
case "patch":
|
|
||||||
emit("toolbarClick", name);
|
|
||||||
break;
|
|
||||||
case "import":
|
|
||||||
handleOpenImportModal(true);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
emit("toolbarClick", name);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 操作列
|
|
||||||
function handleOperate(data: IOperateData) {
|
|
||||||
switch (data.name) {
|
|
||||||
case "delete":
|
|
||||||
if (props.contentConfig?.deleteAction) {
|
|
||||||
handleDelete(data.row[pk]);
|
|
||||||
} else {
|
|
||||||
emit("operateClick", data);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
emit("operateClick", data);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 属性修改
|
|
||||||
function handleModify(field: string, value: boolean | string | number, row: Record<string, any>) {
|
|
||||||
if (props.contentConfig.modifyAction) {
|
|
||||||
props.contentConfig.modifyAction({
|
|
||||||
[pk]: row[pk],
|
|
||||||
field,
|
|
||||||
value,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
ElMessage.error("未配置modifyAction");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分页切换
|
|
||||||
function handleSizeChange(value: number) {
|
|
||||||
pagination.pageSize = value;
|
|
||||||
handleRefresh();
|
|
||||||
}
|
|
||||||
function handleCurrentChange(value: number) {
|
|
||||||
pagination.currentPage = value;
|
|
||||||
handleRefresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 远程数据筛选
|
|
||||||
let filterParams: IObject = {};
|
|
||||||
function handleFilterChange(newFilters: any) {
|
|
||||||
const filters: IObject = {};
|
|
||||||
for (const key in newFilters) {
|
|
||||||
const col = cols.value.find((col) => {
|
|
||||||
return col.columnKey === key || col["column-key"] === key;
|
|
||||||
});
|
|
||||||
if (col && col.filterJoin !== undefined) {
|
|
||||||
filters[key] = newFilters[key].join(col.filterJoin);
|
|
||||||
} else {
|
|
||||||
filters[key] = newFilters[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
filterParams = { ...filterParams, ...filters };
|
|
||||||
emit("filterChange", filterParams);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取筛选条件
|
|
||||||
function getFilterParams() {
|
|
||||||
return filterParams;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取分页数据
|
|
||||||
let lastFormData = {};
|
|
||||||
function getIndexActionErrorMessage(err: unknown): string {
|
|
||||||
if (err && typeof err === "object" && "response" in err) {
|
|
||||||
const d = (err as { response?: { data?: { msg?: string; message?: string } } }).response?.data;
|
|
||||||
if (d?.msg) return String(d.msg);
|
|
||||||
if (d?.message) return String(d.message);
|
|
||||||
}
|
|
||||||
if (err instanceof Error && err.message) return err.message;
|
|
||||||
return "数据加载失败";
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchPageData(formData: IObject = {}, isRestart = false) {
|
|
||||||
loading.value = true;
|
|
||||||
// 上一次搜索条件
|
|
||||||
lastFormData = formData;
|
|
||||||
// 重置页码
|
|
||||||
if (isRestart) {
|
|
||||||
pagination.currentPage = 1;
|
|
||||||
}
|
|
||||||
props.contentConfig
|
|
||||||
.indexAction(
|
|
||||||
showPagination
|
|
||||||
? {
|
|
||||||
[request.page_no]: pagination.currentPage,
|
|
||||||
[request.page_size]: pagination.pageSize,
|
|
||||||
...formData,
|
|
||||||
}
|
|
||||||
: formData
|
|
||||||
)
|
|
||||||
.then((data) => {
|
|
||||||
if (showPagination) {
|
|
||||||
if (props.contentConfig.parseData) {
|
|
||||||
data = props.contentConfig.parseData(data);
|
|
||||||
}
|
|
||||||
pagination.total = data.total;
|
|
||||||
pageData.value = data.list;
|
|
||||||
} else {
|
|
||||||
pageData.value = data;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
console.error(err);
|
|
||||||
ElMessage.error(getIndexActionErrorMessage(err));
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
loading.value = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (props.contentConfig.initialFetch !== false) {
|
|
||||||
fetchPageData();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导出Excel
|
|
||||||
function exportPageData(formData: IObject = {}) {
|
|
||||||
if (props.contentConfig.exportAction) {
|
|
||||||
props.contentConfig.exportAction(formData).then((response) => {
|
|
||||||
const fileData = response.data;
|
|
||||||
const fileName = decodeURI(
|
|
||||||
response.headers["content-disposition"].split(";")[1].split("=")[1]
|
|
||||||
);
|
|
||||||
saveXlsx(fileData, fileName);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
ElMessage.error("未配置exportAction");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 浏览器保存文件
|
|
||||||
function saveXlsx(fileData: any, fileName: string) {
|
|
||||||
const fileType =
|
|
||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8";
|
|
||||||
|
|
||||||
const blob = new Blob([fileData], { type: fileType });
|
|
||||||
const downloadUrl = window.URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
const downloadLink = document.createElement("a");
|
|
||||||
downloadLink.href = downloadUrl;
|
|
||||||
downloadLink.download = fileName;
|
|
||||||
|
|
||||||
document.body.appendChild(downloadLink);
|
|
||||||
downloadLink.click();
|
|
||||||
|
|
||||||
document.body.removeChild(downloadLink);
|
|
||||||
window.URL.revokeObjectURL(downloadUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 暴露的属性和方法
|
|
||||||
defineExpose({
|
|
||||||
fetchPageData,
|
|
||||||
exportPageData,
|
|
||||||
getFilterParams,
|
|
||||||
getSelectionData,
|
|
||||||
handleRefresh,
|
|
||||||
handleToolbar,
|
|
||||||
handleDelete,
|
|
||||||
pageData,
|
|
||||||
pagination,
|
|
||||||
tableRef,
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
:deep(.curd-embed-dialog) {
|
|
||||||
padding-right: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,344 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<el-card v-bind="cardAttrs">
|
|
||||||
<!-- 搜索表单区域 -->
|
|
||||||
<el-form
|
|
||||||
:key="'page-search-expand-' + isExpand"
|
|
||||||
ref="queryFormRef"
|
|
||||||
label-suffix=":"
|
|
||||||
v-bind="formAttrs"
|
|
||||||
:model="queryParams"
|
|
||||||
:class="isGrid"
|
|
||||||
@submit.prevent="handleQuery"
|
|
||||||
>
|
|
||||||
<template v-for="(item, index) in formItems" :key="item.prop">
|
|
||||||
<el-form-item
|
|
||||||
v-show="isExpand ? true : index < showNumber"
|
|
||||||
:label="item?.label"
|
|
||||||
:prop="item.prop"
|
|
||||||
>
|
|
||||||
<!-- Label -->
|
|
||||||
<template #label>
|
|
||||||
<span class="flex-y-center">
|
|
||||||
{{ item?.label || "" }}
|
|
||||||
<el-tooltip v-if="item?.tips" v-bind="getTooltipProps(item.tips)">
|
|
||||||
<QuestionFilled class="w-4 h-4 mx-1" />
|
|
||||||
</el-tooltip>
|
|
||||||
<span v-if="searchConfig.colon" class="ml-0.5">:</span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<ElCascader
|
|
||||||
v-if="item.type === 'cascader'"
|
|
||||||
v-model="queryParams[item.prop]"
|
|
||||||
v-bind="{ style: { width: '100%' }, ...item.attrs }"
|
|
||||||
v-on="item.events || {}"
|
|
||||||
/>
|
|
||||||
<component
|
|
||||||
:is="getCustomComponent(item.type) || componentMap.get(item.type)"
|
|
||||||
v-else
|
|
||||||
v-model="queryParams[item.prop]"
|
|
||||||
v-bind="{ style: { width: '100%' }, ...item.attrs }"
|
|
||||||
v-on="item.events || {}"
|
|
||||||
>
|
|
||||||
<template v-if="item.type === 'select'">
|
|
||||||
<template v-for="opt in item.options" :key="opt.value">
|
|
||||||
<el-option :label="opt.label" :value="opt.value" />
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
<!-- 自定义组件的插槽支持 -->
|
|
||||||
<template v-if="getCustomComponent(item.type) && item.slotName">
|
|
||||||
<template v-for="slot in Object.keys($slots)" :key="slot">
|
|
||||||
<slot :name="slot"></slot>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</component>
|
|
||||||
</el-form-item>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<el-form-item :class="{ 'col-[auto/-1] justify-self-end': searchConfig?.grid === 'right' }">
|
|
||||||
<!-- 自定义按钮组 -->
|
|
||||||
<template v-if="searchConfig?.customButtons && searchConfig.customButtons.length > 0">
|
|
||||||
<template v-for="button in searchConfig.customButtons" :key="button.key">
|
|
||||||
<el-button
|
|
||||||
v-if="!button.perm || hasPermission(button.perm)"
|
|
||||||
v-bind="button.attrs"
|
|
||||||
@click="handleCustomButtonClick(button)"
|
|
||||||
>
|
|
||||||
{{ button.text }}
|
|
||||||
</el-button>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 默认搜索/重置按钮 -->
|
|
||||||
<template v-else>
|
|
||||||
<!-- 搜索按钮 -->
|
|
||||||
<el-button
|
|
||||||
v-if="
|
|
||||||
!searchConfig?.showSearchButton ||
|
|
||||||
hasPermission(searchConfig.searchButtonPerm || [])
|
|
||||||
"
|
|
||||||
icon="search"
|
|
||||||
type="primary"
|
|
||||||
@click="handleQuery"
|
|
||||||
>
|
|
||||||
搜索
|
|
||||||
</el-button>
|
|
||||||
<!-- 重置按钮 -->
|
|
||||||
<el-button
|
|
||||||
v-if="
|
|
||||||
!searchConfig?.showResetButton || hasPermission(searchConfig.resetButtonPerm || [])
|
|
||||||
"
|
|
||||||
icon="refresh"
|
|
||||||
@click="handleReset"
|
|
||||||
>
|
|
||||||
重置
|
|
||||||
</el-button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 展开/收起 -->
|
|
||||||
<template v-if="isExpandable && formItems.length > showNumber">
|
|
||||||
<el-link class="ml-3" type="primary" underline="never" @click="isExpand = !isExpand">
|
|
||||||
{{ isExpand ? "收起" : "展开" }}
|
|
||||||
<component :is="isExpand ? ArrowUp : ArrowDown" class="w-4 h-4 ml-2" />
|
|
||||||
</el-link>
|
|
||||||
</template>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { IObject, IForm, ISearchConfig, ISearchComponent } from "./types";
|
|
||||||
import { ArrowUp, ArrowDown, QuestionFilled } from "@element-plus/icons-vue";
|
|
||||||
import type { FormInstance } from "element-plus";
|
|
||||||
import InputTag from "@/components/InputTag/index.vue";
|
|
||||||
import { getCurrentInstance, nextTick, watch } from "vue";
|
|
||||||
import {
|
|
||||||
ElInput,
|
|
||||||
ElSelect,
|
|
||||||
ElCascader,
|
|
||||||
ElInputNumber,
|
|
||||||
ElDatePicker,
|
|
||||||
ElTimePicker,
|
|
||||||
ElTimeSelect,
|
|
||||||
ElTreeSelect,
|
|
||||||
ElInputTag,
|
|
||||||
ElRadioGroup,
|
|
||||||
ElCheckboxGroup,
|
|
||||||
ElSwitch,
|
|
||||||
ElRate,
|
|
||||||
ElSlider,
|
|
||||||
} from "element-plus";
|
|
||||||
|
|
||||||
// 定义接收的属性
|
|
||||||
const props = defineProps<{ searchConfig: ISearchConfig }>();
|
|
||||||
// 自定义事件
|
|
||||||
const emit = defineEmits<{
|
|
||||||
queryClick: [queryParams: IObject];
|
|
||||||
resetClick: [queryParams: IObject];
|
|
||||||
dateRangeChange: [prop: string, range: [Date, Date] | [string, string] | [null, null]];
|
|
||||||
customButtonClick: [buttonKey: string, queryParams: IObject];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
// 时间范围处理相关
|
|
||||||
const dateRangeRefs = ref<Record<string, any>>({});
|
|
||||||
|
|
||||||
// 组件映射表
|
|
||||||
const componentMap = new Map<ISearchComponent, any>([
|
|
||||||
// @ts-ignore
|
|
||||||
["input", markRaw(ElInput)], // @ts-ignore
|
|
||||||
["select", markRaw(ElSelect)], // @ts-ignore
|
|
||||||
["cascader", markRaw(ElCascader)], // @ts-ignore
|
|
||||||
["input-number", markRaw(ElInputNumber)], // @ts-ignore
|
|
||||||
["date-picker", markRaw(ElDatePicker)], // @ts-ignore
|
|
||||||
["time-picker", markRaw(ElTimePicker)], // @ts-ignore
|
|
||||||
["time-select", markRaw(ElTimeSelect)], // @ts-ignore
|
|
||||||
["tree-select", markRaw(ElTreeSelect)], // @ts-ignore
|
|
||||||
["input-tag", markRaw(ElInputTag)], // @ts-ignore
|
|
||||||
["custom-tag", markRaw(InputTag)], // @ts-ignore
|
|
||||||
["radio", markRaw(ElRadioGroup)], // @ts-ignore
|
|
||||||
["checkbox", markRaw(ElCheckboxGroup)], // @ts-ignore
|
|
||||||
["switch", markRaw(ElSwitch)], // @ts-ignore
|
|
||||||
["rate", markRaw(ElRate)], // @ts-ignore
|
|
||||||
["slider", markRaw(ElSlider)], // @ts-ignore
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 自定义组件映射(从searchConfig中获取)
|
|
||||||
const getCustomComponent = (componentName: string) => {
|
|
||||||
return props.searchConfig?.customComponents?.[componentName] || null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 存储表单实例
|
|
||||||
const queryFormRef = ref<FormInstance>();
|
|
||||||
// 存储查询参数
|
|
||||||
const queryParams = reactive<IObject>({});
|
|
||||||
// 响应式的formItems
|
|
||||||
const formItems = reactive(props.searchConfig?.formItems ?? []);
|
|
||||||
// 是否可展开/收缩
|
|
||||||
const isExpandable = ref(props.searchConfig?.isExpandable ?? true);
|
|
||||||
// 是否已展开
|
|
||||||
const isExpand = ref(false);
|
|
||||||
// 表单项展示数量,若可展开,超出展示数量的表单项隐藏
|
|
||||||
const showNumber = computed(() =>
|
|
||||||
isExpandable.value ? (props.searchConfig?.showNumber ?? 3) : formItems.length
|
|
||||||
);
|
|
||||||
// 卡片组件自定义属性(阴影、自定义边距样式等)
|
|
||||||
const cardAttrs = computed<IObject>(() => {
|
|
||||||
return {
|
|
||||||
class: "search-container",
|
|
||||||
shadow: "never",
|
|
||||||
style: { marginBottom: "0" },
|
|
||||||
...props.searchConfig?.cardAttrs,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
// 表单组件自定义属性(label位置、宽度、对齐方式等)
|
|
||||||
const formAttrs = computed<IForm>(() => {
|
|
||||||
const custom = props.searchConfig?.form ?? {};
|
|
||||||
if (props.searchConfig?.grid) {
|
|
||||||
return { ...custom, inline: false };
|
|
||||||
}
|
|
||||||
return { inline: true, ...custom };
|
|
||||||
});
|
|
||||||
/** 默认 flex(scoped);grid 时见 .curd-page-search--grid */
|
|
||||||
const isGrid = computed(() =>
|
|
||||||
props.searchConfig?.grid ? "curd-page-search--grid" : "curd-page-search--flex"
|
|
||||||
);
|
|
||||||
|
|
||||||
/** 展开/收起后:表单已随 :key 重建,再触发 resize 让日期范围等组件量宽 */
|
|
||||||
watch(isExpand, () => {
|
|
||||||
nextTick(() => {
|
|
||||||
window.dispatchEvent(new Event("resize"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取tooltip提示框属性
|
|
||||||
const getTooltipProps = (tips: string | IObject) => {
|
|
||||||
return typeof tips === "string" ? { content: tips } : tips;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 查询前对 input 做 trim;去掉空条件,避免 GET 出现 name=&created_id= 等导致后端 Optional[int] 解析失败 */
|
|
||||||
function buildQueryPayload() {
|
|
||||||
const q = { ...queryParams };
|
|
||||||
for (const item of formItems) {
|
|
||||||
if (item.type === "input" && typeof q[item.prop] === "string") {
|
|
||||||
q[item.prop] = q[item.prop].trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const out: IObject = {};
|
|
||||||
for (const key of Object.keys(q)) {
|
|
||||||
const v = q[key];
|
|
||||||
if (v === "" || v === null || v === undefined) continue;
|
|
||||||
if (Array.isArray(v) && v.length === 0) continue;
|
|
||||||
out[key] = v;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查询/重置操作
|
|
||||||
const handleQuery = () => emit("queryClick", buildQueryPayload());
|
|
||||||
const handleReset = () => {
|
|
||||||
queryFormRef.value?.resetFields();
|
|
||||||
// 重置所有时间范围组件
|
|
||||||
Object.values(dateRangeRefs.value).forEach((ref) => {
|
|
||||||
if (ref && typeof ref.reset === "function") {
|
|
||||||
ref.reset();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
emit("resetClick", buildQueryPayload());
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理时间范围变化
|
|
||||||
const handleDateRangeChange = (
|
|
||||||
prop: string,
|
|
||||||
range: [Date, Date] | [string, string] | [null, null]
|
|
||||||
) => {
|
|
||||||
emit("dateRangeChange", prop, range);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理自定义按钮点击
|
|
||||||
const handleCustomButtonClick = (button: any) => {
|
|
||||||
// 如果配置了自定义处理器,优先使用
|
|
||||||
if (button.handler && typeof button.handler === "function") {
|
|
||||||
button.handler(queryParams, getCurrentInstance());
|
|
||||||
}
|
|
||||||
// 触发自定义事件
|
|
||||||
emit("customButtonClick", button.key, queryParams);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 权限控制检查
|
|
||||||
const hasPermission = (perm: string | string[]): boolean => {
|
|
||||||
if (!perm || !props.searchConfig?.permPrefix) return true;
|
|
||||||
// 这里应该根据实际的权限系统来实现
|
|
||||||
// 暂时返回 true,实际项目中需要对接权限系统
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
formItems.forEach((item) => {
|
|
||||||
if (item?.initFn) {
|
|
||||||
item.initFn(item);
|
|
||||||
}
|
|
||||||
if (getCustomComponent(item.type ?? "")) {
|
|
||||||
queryParams[item.prop] = item.initialValue ?? null;
|
|
||||||
} else if (["input-tag", "custom-tag", "cascader"].includes(item?.type ?? "")) {
|
|
||||||
queryParams[item.prop] = Array.isArray(item.initialValue) ? item.initialValue : [];
|
|
||||||
} else if (
|
|
||||||
item.type === "date-picker" &&
|
|
||||||
String(item.attrs?.type ?? "")
|
|
||||||
.toLowerCase()
|
|
||||||
.includes("range")
|
|
||||||
) {
|
|
||||||
queryParams[item.prop] = item.initialValue ?? [];
|
|
||||||
} else if (item.type === "select") {
|
|
||||||
queryParams[item.prop] = item.initialValue !== undefined ? item.initialValue : null;
|
|
||||||
} else if (item.type === "input-number") {
|
|
||||||
queryParams[item.prop] = item.initialValue ?? null;
|
|
||||||
} else {
|
|
||||||
queryParams[item.prop] = item.initialValue ?? "";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// 暴露的属性和方法
|
|
||||||
defineExpose({
|
|
||||||
// 获取分页数据(与「搜索」提交时一致,含 input trim)
|
|
||||||
getQueryParams: () => buildQueryPayload(),
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
:deep(.el-input-number .el-input__inner) {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-form-item {
|
|
||||||
margin-right: 0;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 卡片参与 flex 布局链时允许高度随内容收回,避免展开后再收起仍「撑住」 */
|
|
||||||
.search-container :deep(.el-card__body) {
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 默认行内搜索:明确左起排布,避免展开宽表单项后 flex 残留 justify/end 对齐 */
|
|
||||||
.curd-page-search--flex {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
align-content: flex-start;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.curd-page-search--grid {
|
|
||||||
display: grid !important;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import type { IObject, IComponentType, ISearchComponent } from "./types";
|
|
||||||
import { markRaw } from "vue";
|
|
||||||
import InputTag from "@/components/InputTag/index.vue";
|
|
||||||
import IconSelect from "@/components/IconSelect/index.vue";
|
|
||||||
import DatePicker from "@/components/DatePicker/index.vue";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取提示属性
|
|
||||||
* @param tips 提示内容
|
|
||||||
* @returns 提示属性对象
|
|
||||||
*/
|
|
||||||
export const getTooltipProps = (tips: string | IObject) => {
|
|
||||||
return typeof tips === "string" ? { content: tips } : tips;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 模态框组件映射表
|
|
||||||
*/
|
|
||||||
export const modalComponentMap = new Map<IComponentType, any>([
|
|
||||||
// @ts-ignore
|
|
||||||
["input", markRaw(ElInput)],
|
|
||||||
// @ts-ignore
|
|
||||||
["select", markRaw(ElSelect)],
|
|
||||||
// @ts-ignore
|
|
||||||
["switch", markRaw(ElSwitch)],
|
|
||||||
// @ts-ignore
|
|
||||||
["cascader", markRaw(ElCascader)],
|
|
||||||
// @ts-ignore
|
|
||||||
["input-number", markRaw(ElInputNumber)],
|
|
||||||
// @ts-ignore
|
|
||||||
["input-tag", markRaw(InputTag)],
|
|
||||||
// @ts-ignore
|
|
||||||
["time-picker", markRaw(ElTimePicker)],
|
|
||||||
// @ts-ignore
|
|
||||||
["time-select", markRaw(ElTimeSelect)],
|
|
||||||
// @ts-ignore
|
|
||||||
["date-picker", markRaw(ElDatePicker)],
|
|
||||||
// @ts-ignore
|
|
||||||
["tree-select", markRaw(ElTreeSelect)],
|
|
||||||
// @ts-ignore
|
|
||||||
["custom-tag", markRaw(InputTag)],
|
|
||||||
// @ts-ignore
|
|
||||||
["text", markRaw(ElText)],
|
|
||||||
// @ts-ignore
|
|
||||||
["radio", markRaw(ElRadioGroup)],
|
|
||||||
// @ts-ignore
|
|
||||||
["checkbox", markRaw(ElCheckboxGroup)],
|
|
||||||
// @ts-ignore
|
|
||||||
["icon-select", markRaw(IconSelect)],
|
|
||||||
// @ts-ignore
|
|
||||||
["custom", ""],
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 搜索组件映射表
|
|
||||||
*/
|
|
||||||
export const searchComponentMap = new Map<ISearchComponent, any>([
|
|
||||||
// @ts-ignore
|
|
||||||
["input", markRaw(ElInput)],
|
|
||||||
// @ts-ignore
|
|
||||||
["select", markRaw(ElSelect)],
|
|
||||||
// @ts-ignore
|
|
||||||
["cascader", markRaw(ElCascader)],
|
|
||||||
// @ts-ignore
|
|
||||||
["input-number", markRaw(ElInputNumber)],
|
|
||||||
// @ts-ignore
|
|
||||||
["date-picker", markRaw(DatePicker)],
|
|
||||||
// @ts-ignore
|
|
||||||
["time-picker", markRaw(ElTimePicker)],
|
|
||||||
// @ts-ignore
|
|
||||||
["time-select", markRaw(ElTimeSelect)],
|
|
||||||
// @ts-ignore
|
|
||||||
["tree-select", markRaw(ElTreeSelect)],
|
|
||||||
// @ts-ignore
|
|
||||||
["input-tag", markRaw(ElInputTag)],
|
|
||||||
// @ts-ignore
|
|
||||||
["custom-tag", markRaw(InputTag)],
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 子组件映射表
|
|
||||||
*/
|
|
||||||
export const childrenMap = new Map<IComponentType, any>([
|
|
||||||
// @ts-ignore
|
|
||||||
["select", markRaw(ElOption)],
|
|
||||||
// @ts-ignore
|
|
||||||
["radio", markRaw(ElRadio)],
|
|
||||||
// @ts-ignore
|
|
||||||
["checkbox", markRaw(ElCheckbox)],
|
|
||||||
]);
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div cursor-pointer flex-center rounded class="el" :class="padding">
|
|
||||||
<slot></slot>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
defineProps({
|
|
||||||
padding: {
|
|
||||||
type: String,
|
|
||||||
default: "p-2",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.el {
|
|
||||||
transition: 0.3s var(--el-transition-function-ease-in-out-bezier);
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--el-fill-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<!-- 复制组件 -->
|
|
||||||
<template>
|
|
||||||
<el-button link :style="style" @click="handleClipboard">
|
|
||||||
<slot>
|
|
||||||
<el-icon><DocumentCopy color="var(--el-color-primary)" /></el-icon>
|
|
||||||
</slot>
|
|
||||||
</el-button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
defineOptions({
|
|
||||||
name: "CopyButton",
|
|
||||||
inheritAttrs: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
text: {
|
|
||||||
type: String,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
style: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleClipboard() {
|
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
||||||
// 使用 Clipboard API
|
|
||||||
navigator.clipboard
|
|
||||||
.writeText(props.text)
|
|
||||||
.then(() => {
|
|
||||||
ElMessage.success(t("common.copySuccess"));
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
ElMessage.warning(t("common.copyFailed"));
|
|
||||||
console.log("[CopyButton] Copy failed", error);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 兼容性处理(useClipboard 有兼容性问题)
|
|
||||||
const input = document.createElement("input");
|
|
||||||
input.style.position = "absolute";
|
|
||||||
input.style.left = "-9999px";
|
|
||||||
input.setAttribute("value", props.text);
|
|
||||||
document.body.appendChild(input);
|
|
||||||
input.select();
|
|
||||||
try {
|
|
||||||
const successful = document.execCommand("copy");
|
|
||||||
if (successful) {
|
|
||||||
ElMessage.success(t("common.copySuccess"));
|
|
||||||
} else {
|
|
||||||
ElMessage.warning(t("common.copyFailed"));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
ElMessage.warning(t("common.copyFailed"));
|
|
||||||
console.log("[CopyButton] Copy failed.", err);
|
|
||||||
} finally {
|
|
||||||
document.body.removeChild(input);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
<!-- 日期选择器 -->
|
|
||||||
<template>
|
|
||||||
<div class="custom-date-picker">
|
|
||||||
<el-date-picker
|
|
||||||
:model-value="modelValue"
|
|
||||||
type="datetimerange"
|
|
||||||
format="YYYY-MM-DD HH:mm:ss"
|
|
||||||
range-separator="至"
|
|
||||||
start-placeholder="开始日期"
|
|
||||||
end-placeholder="结束日期"
|
|
||||||
:shortcuts="shortcuts"
|
|
||||||
@update:model-value="(val) => emit('update:model-value', val)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
// 定义组件属性
|
|
||||||
defineProps({
|
|
||||||
modelValue: {
|
|
||||||
type: Array as unknown as () => [Date, Date] | [],
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 定义事件
|
|
||||||
const emit = defineEmits(["update:model-value"]);
|
|
||||||
|
|
||||||
// 快捷选项配置
|
|
||||||
const shortcuts = [
|
|
||||||
{
|
|
||||||
text: "近一天",
|
|
||||||
value: () => {
|
|
||||||
const end = new Date();
|
|
||||||
const start = new Date();
|
|
||||||
// 修正:起始时间为当前时间往前 24 小时
|
|
||||||
start.setTime(start.getTime() - 3600 * 1000 * 24);
|
|
||||||
return [start, end];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "近三天",
|
|
||||||
value: () => {
|
|
||||||
const end = new Date();
|
|
||||||
const start = new Date();
|
|
||||||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 3);
|
|
||||||
return [start, end];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "近一周",
|
|
||||||
value: () => {
|
|
||||||
const end = new Date();
|
|
||||||
const start = new Date();
|
|
||||||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 7);
|
|
||||||
return [start, end];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "近一个月",
|
|
||||||
value: () => {
|
|
||||||
const end = new Date();
|
|
||||||
const start = new Date();
|
|
||||||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 30);
|
|
||||||
return [start, end];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "近三个月",
|
|
||||||
value: () => {
|
|
||||||
const end = new Date();
|
|
||||||
const start = new Date();
|
|
||||||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 90);
|
|
||||||
return [start, end];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.custom-date-picker {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
<!--
|
|
||||||
* 基于 ECharts 的 Vue3 图表组件
|
|
||||||
* 版权所有 © 2021-present 有来开源组织
|
|
||||||
*
|
|
||||||
* 开源协议:https://opensource.org/licenses/MIT
|
|
||||||
* 项目地址:https://gitee.com/youlaiorg/vue3-element-admin
|
|
||||||
* 参考:https://echarts.apache.org/handbook/zh/basics/import/#%E6%8C%89%E9%9C%80%E5%BC%95%E5%85%A5-echarts-%E5%9B%BE%E8%A1%A8%E5%92%8C%E7%BB%84%E4%BB%B6
|
|
||||||
*
|
|
||||||
* 在使用时,请保留此注释,感谢您对开源的支持!
|
|
||||||
-->
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div ref="chartRef" :style="{ width, height }"></div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
// 引入 echarts 核心模块,核心模块提供了 echarts 使用必须要的接口。
|
|
||||||
import * as echarts from "echarts/core";
|
|
||||||
// 引入柱状、折线和饼图常用图表
|
|
||||||
import { BarChart, LineChart, PieChart, RadarChart } from "echarts/charts";
|
|
||||||
// 引入标题,提示框,直角坐标系,数据集,内置数据转换器组件,
|
|
||||||
import { GridComponent, TooltipComponent, LegendComponent } from "echarts/components";
|
|
||||||
// 引入 Canvas 渲染器,注意引入 CanvasRenderer 或者 SVGRenderer 是必须的一步
|
|
||||||
import { CanvasRenderer } from "echarts/renderers";
|
|
||||||
|
|
||||||
import { useResizeObserver } from "@vueuse/core";
|
|
||||||
|
|
||||||
// 按需注册组件
|
|
||||||
echarts.use([
|
|
||||||
RadarChart,
|
|
||||||
CanvasRenderer,
|
|
||||||
BarChart,
|
|
||||||
LineChart,
|
|
||||||
PieChart,
|
|
||||||
GridComponent,
|
|
||||||
TooltipComponent,
|
|
||||||
LegendComponent,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
options: echarts.EChartsCoreOption;
|
|
||||||
width?: string;
|
|
||||||
height?: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const chartRef = ref<HTMLDivElement | null>(null);
|
|
||||||
let chartInstance: echarts.ECharts | null = null;
|
|
||||||
|
|
||||||
// 初始化图表
|
|
||||||
const initChart = () => {
|
|
||||||
if (chartRef.value) {
|
|
||||||
chartInstance = echarts.init(chartRef.value);
|
|
||||||
if (props.options) {
|
|
||||||
chartInstance.setOption(props.options);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 监听尺寸变化,自动调整
|
|
||||||
useResizeObserver(chartRef, () => {
|
|
||||||
chartInstance?.resize();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听 options 变化,更新图表
|
|
||||||
watch(
|
|
||||||
() => props.options,
|
|
||||||
(newOptions) => {
|
|
||||||
if (chartInstance && newOptions) {
|
|
||||||
chartInstance.setOption(newOptions);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
nextTick(() => initChart());
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
chartInstance?.dispose();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<!-- 自定义 iframe 组件 -->
|
|
||||||
<template>
|
|
||||||
<div v-loading="loading" :style="'height:' + height">
|
|
||||||
<iframe :src="url" frameborder="0" width="100%" height="100%" scrolling="auto" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, computed, onMounted } from "vue";
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
src: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const height = ref(document.documentElement.clientHeight - 94.5 + "px;");
|
|
||||||
const loading = ref(true);
|
|
||||||
const url = computed(() => props.src);
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
loading.value = false;
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
/** 关闭tag标签 */
|
|
||||||
.app-container {
|
|
||||||
/* 50px = navbar = 50px */
|
|
||||||
height: calc(100vh - 50px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 开启tag标签 */
|
|
||||||
.hasTagsView {
|
|
||||||
.app-container {
|
|
||||||
/* 84px = navbar + tags-view = 50px + 34px */
|
|
||||||
height: calc(100vh - 84px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
<!-- 引导页 -->
|
|
||||||
<template>
|
|
||||||
<el-tour v-model="open" :show-close="false" @change="handleChange">
|
|
||||||
<el-tour-step
|
|
||||||
v-for="(step, index) in steps"
|
|
||||||
:key="index"
|
|
||||||
:target="step.target"
|
|
||||||
:title="step.title"
|
|
||||||
:description="step.description"
|
|
||||||
:prev-button-props="{
|
|
||||||
children: t('common.prevLabel'),
|
|
||||||
onClick: handlePrevClick,
|
|
||||||
}"
|
|
||||||
:next-button-props="{
|
|
||||||
children: nextBtnName(index),
|
|
||||||
onClick: handleNextClick,
|
|
||||||
}"
|
|
||||||
:placement="step.placement"
|
|
||||||
/>
|
|
||||||
<template #indicators>
|
|
||||||
<el-button size="small" @click="handleSkip">{{ t("common.skipLabel") }}</el-button>
|
|
||||||
</template>
|
|
||||||
</el-tour>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed } from "vue";
|
|
||||||
import { useSettingsStore } from "@/store";
|
|
||||||
|
|
||||||
const settingStore = useSettingsStore();
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
// 是否可见
|
|
||||||
modelValue: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
teleport: {
|
|
||||||
type: [String, Object] as PropType<string | HTMLElement | null>,
|
|
||||||
default: "body",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(["update:modelValue", "change", "prev", "next", "skip"]);
|
|
||||||
|
|
||||||
const open = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (val) => emit("update:modelValue", val),
|
|
||||||
});
|
|
||||||
|
|
||||||
interface TourStep {
|
|
||||||
target: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
placement: "top" | "bottom" | "left" | "right";
|
|
||||||
}
|
|
||||||
|
|
||||||
const layout = settingStore.layout;
|
|
||||||
|
|
||||||
const menuTarget = (): string => {
|
|
||||||
if (layout === "left") {
|
|
||||||
return ".layout__sidebar";
|
|
||||||
} else if (layout === "top") {
|
|
||||||
return ".layout__header-left";
|
|
||||||
} else {
|
|
||||||
return ".layout__header-menu";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 内置引导步骤数据
|
|
||||||
const steps: TourStep[] = [
|
|
||||||
{
|
|
||||||
target: menuTarget(),
|
|
||||||
title: t("common.menu"),
|
|
||||||
description: t("common.menuDes"),
|
|
||||||
placement: layout === "left" ? "right" : "bottom",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
target: ".navbar-actions",
|
|
||||||
title: t("common.tool"),
|
|
||||||
description: t("common.toolDes"),
|
|
||||||
placement: "bottom",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
target: ".tags-container",
|
|
||||||
title: t("common.tagsView"),
|
|
||||||
description: t("common.tagsViewDes"),
|
|
||||||
placement: "bottom",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// 当前步数
|
|
||||||
const currentStep = ref(0);
|
|
||||||
|
|
||||||
// 动态设置下一步按钮名称
|
|
||||||
const nextBtnName = computed(() => (index: number) => {
|
|
||||||
if (index === steps.length - 1) {
|
|
||||||
return t("common.doneLabel");
|
|
||||||
}
|
|
||||||
return t("common.nextLabel");
|
|
||||||
});
|
|
||||||
|
|
||||||
// 步数切换时触发
|
|
||||||
function handleChange(step: number) {
|
|
||||||
currentStep.value = step;
|
|
||||||
emit("change", step);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 点击跳过按钮时触发
|
|
||||||
function handleSkip() {
|
|
||||||
open.value = false;
|
|
||||||
emit("skip");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 点击上一步按钮时触发
|
|
||||||
function handlePrevClick() {
|
|
||||||
emit("prev");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 点击下一步按钮时触发
|
|
||||||
function handleNextClick() {
|
|
||||||
emit("next");
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 可根据需要添加自定义样式 */
|
|
||||||
.el-tour__content .el-tour-indicators {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<!-- 折叠按钮 -->
|
|
||||||
<template>
|
|
||||||
<div class="hamburger-wrapper" @click="toggleClick">
|
|
||||||
<div :class="['i-svg:collapse', { hamburger: true, 'is-active': isActive }]" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
defineProps({
|
|
||||||
isActive: { type: Boolean, required: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(["toggleClick"]);
|
|
||||||
|
|
||||||
function toggleClick() {
|
|
||||||
emit("toggleClick");
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.hamburger-wrapper {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0 15px;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
.hamburger {
|
|
||||||
vertical-align: middle;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
transform: scaleX(-1);
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-active {
|
|
||||||
transform: scaleX(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<template>
|
|
||||||
<el-scrollbar>
|
|
||||||
<div class="flex-y-center gap-2">
|
|
||||||
<el-tag
|
|
||||||
v-for="tag in tags"
|
|
||||||
:key="tag"
|
|
||||||
closable
|
|
||||||
:disable-transitions="false"
|
|
||||||
v-bind="config.tagAttrs"
|
|
||||||
@close="handleClose(tag)"
|
|
||||||
>
|
|
||||||
{{ tag }}
|
|
||||||
</el-tag>
|
|
||||||
<el-input
|
|
||||||
v-if="inputVisible"
|
|
||||||
ref="inputRef"
|
|
||||||
v-model.trim="inputValue"
|
|
||||||
style="min-width: 100px"
|
|
||||||
@keyup.enter.stop.prevent="handleInputConfirm"
|
|
||||||
@blur.stop.prevent="handleInputConfirm"
|
|
||||||
/>
|
|
||||||
<el-button v-else v-bind="config.buttonAttrs" @click="showInput">
|
|
||||||
{{ config.buttonAttrs.btnText ? config.buttonAttrs.btnText : "+ New Tag" }}
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</el-scrollbar>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { InputInstance } from "element-plus";
|
|
||||||
|
|
||||||
const inputValue = ref("");
|
|
||||||
const inputVisible = ref(false);
|
|
||||||
const inputRef = ref<InputInstance>();
|
|
||||||
|
|
||||||
// 定义 model,用于与父组件的 v-model绑定
|
|
||||||
const tags = defineModel<string[]>();
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
config: {
|
|
||||||
type: Object as () => {
|
|
||||||
buttonAttrs: Record<string, any>;
|
|
||||||
inputAttrs: Record<string, any>;
|
|
||||||
tagAttrs: Record<string, any>;
|
|
||||||
},
|
|
||||||
default: () => ({
|
|
||||||
buttonAttrs: {},
|
|
||||||
inputAttrs: {},
|
|
||||||
tagAttrs: {},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleClose = (tag: string) => {
|
|
||||||
if (tags.value) {
|
|
||||||
const newTags = tags.value.filter((t) => t !== tag);
|
|
||||||
tags.value = [...newTags];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const showInput = () => {
|
|
||||||
inputVisible.value = true;
|
|
||||||
nextTick(() => inputRef.value?.focus());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInputConfirm = () => {
|
|
||||||
if (inputValue.value) {
|
|
||||||
const newTags = [...(tags.value || []), inputValue.value];
|
|
||||||
tags.value = newTags;
|
|
||||||
}
|
|
||||||
inputVisible.value = false;
|
|
||||||
inputValue.value = "";
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
<template>
|
|
||||||
<el-form
|
|
||||||
ref="formRef"
|
|
||||||
:model="crontabValueObj"
|
|
||||||
label-width="auto"
|
|
||||||
label-suffix=":"
|
|
||||||
:inline="true"
|
|
||||||
class="interval-tab-form"
|
|
||||||
>
|
|
||||||
<el-form-item label="秒" prop="second" class="form-item">
|
|
||||||
<el-select v-model="crontabValueObj.second" placeholder="秒" clearable>
|
|
||||||
<el-option label="每秒" value="*">*</el-option>
|
|
||||||
<el-option
|
|
||||||
v-for="second in seconds"
|
|
||||||
:key="second"
|
|
||||||
:label="second"
|
|
||||||
:value="second.toString()"
|
|
||||||
>
|
|
||||||
{{ second }}
|
|
||||||
</el-option>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="分" prop="min" class="form-item">
|
|
||||||
<el-select v-model="crontabValueObj.min" placeholder="分" clearable>
|
|
||||||
<el-option label="每分" value="*">*</el-option>
|
|
||||||
<el-option v-for="min in minutes" :key="min" :label="min" :value="min.toString()">
|
|
||||||
{{ min }}
|
|
||||||
</el-option>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="时" prop="hour" class="form-item">
|
|
||||||
<el-select v-model="crontabValueObj.hour" placeholder="时" clearable>
|
|
||||||
<el-option label="每时" value="*">*</el-option>
|
|
||||||
<el-option v-for="hour in hours" :key="hour" :label="hour" :value="hour.toString()">
|
|
||||||
{{ hour }}
|
|
||||||
</el-option>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="天" prop="day" class="form-item">
|
|
||||||
<el-select v-model="crontabValueObj.day" placeholder="天" clearable>
|
|
||||||
<el-option label="每天" value="*">*</el-option>
|
|
||||||
<el-option v-for="day in days" :key="day" :label="day" :value="day">{{ day }}</el-option>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="周" prop="week" class="form-item">
|
|
||||||
<el-select v-model="crontabValueObj.week" placeholder="周" clearable>
|
|
||||||
<el-option label="每周" value="*">*</el-option>
|
|
||||||
<el-option
|
|
||||||
v-for="week in weekOptions"
|
|
||||||
:key="week.value"
|
|
||||||
:label="week.label"
|
|
||||||
:value="week.value"
|
|
||||||
>
|
|
||||||
{{ week.label }}
|
|
||||||
</el-option>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<el-button @click="emit('cancel')">取消</el-button>
|
|
||||||
<el-button type="primary" @click="handleConfirm">确认</el-button>
|
|
||||||
</div>
|
|
||||||
</el-form>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
// 定义接口,增强类型安全
|
|
||||||
interface CrontabValue {
|
|
||||||
second: string;
|
|
||||||
min: string;
|
|
||||||
hour: string;
|
|
||||||
day: string;
|
|
||||||
week: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WeekOption {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 定义props
|
|
||||||
const props = defineProps<{
|
|
||||||
cronValue?: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
// 定义emits
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: "confirm", value: string): void;
|
|
||||||
(e: "cancel"): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const formRef = ref();
|
|
||||||
|
|
||||||
// 响应式数据
|
|
||||||
const crontabValueObj = ref<CrontabValue>({
|
|
||||||
second: "*",
|
|
||||||
min: "*",
|
|
||||||
hour: "*",
|
|
||||||
day: "*",
|
|
||||||
week: "*",
|
|
||||||
});
|
|
||||||
|
|
||||||
// 常量定义,避免魔法数字
|
|
||||||
const MAX_SECONDS = 60;
|
|
||||||
const MAX_MINUTES = 60;
|
|
||||||
const MAX_HOURS = 24;
|
|
||||||
const MAX_DAYS = 31;
|
|
||||||
|
|
||||||
// 生成选择器选项
|
|
||||||
const seconds = ref(Array.from({ length: MAX_SECONDS }, (_, i) => i));
|
|
||||||
const minutes = ref(Array.from({ length: MAX_MINUTES }, (_, i) => i));
|
|
||||||
const hours = ref(Array.from({ length: MAX_HOURS }, (_, i) => i));
|
|
||||||
const days = ref(Array.from({ length: MAX_DAYS }, (_, i) => i + 1));
|
|
||||||
const weekOptions: WeekOption[] = [
|
|
||||||
{ value: "1", label: "周一" },
|
|
||||||
{ value: "2", label: "周二" },
|
|
||||||
{ value: "3", label: "周三" },
|
|
||||||
{ value: "4", label: "周四" },
|
|
||||||
{ value: "5", label: "周五" },
|
|
||||||
{ value: "6", label: "周六" },
|
|
||||||
{ value: "7", label: "周日" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 初始化时设置cron值
|
|
||||||
onMounted(() => {
|
|
||||||
if (props.cronValue) {
|
|
||||||
setCron(props.cronValue);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 处理确认
|
|
||||||
const handleConfirm = () => {
|
|
||||||
// 简单验证
|
|
||||||
const obj = crontabValueObj.value;
|
|
||||||
if (!obj.second || !obj.min || !obj.hour || !obj.day || !obj.week) {
|
|
||||||
ElMessage.warning("请完善所有时间选项");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cronStr = `${obj.second} ${obj.min} ${obj.hour} ${obj.day} ${obj.week}`;
|
|
||||||
emit("confirm", cronStr);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 设置cron表达式的值
|
|
||||||
const setCron = (cronStr: string) => {
|
|
||||||
if (!cronStr) return;
|
|
||||||
const parts = cronStr.split(" ");
|
|
||||||
if (parts.length !== 5) {
|
|
||||||
ElMessage.warning("无效的cron表达式格式");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [second, min, hour, day, week] = parts;
|
|
||||||
crontabValueObj.value = {
|
|
||||||
second: second || "*",
|
|
||||||
min: min || "*",
|
|
||||||
hour: hour || "*",
|
|
||||||
day: day || "*",
|
|
||||||
week: week || "*",
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// 暴露方法给父组件
|
|
||||||
defineExpose({ setCron });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.interval-tab-form {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 16px 8px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-item {
|
|
||||||
width: calc(20% - 8px);
|
|
||||||
min-width: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 响应式调整
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.form-item {
|
|
||||||
width: calc(33.33% - 8px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.form-item {
|
|
||||||
width: calc(50% - 8px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="json-pretty-wrapper" :style="{ maxHeight: height }">
|
|
||||||
<VueJsonPretty
|
|
||||||
v-if="isJson"
|
|
||||||
:data="parsed"
|
|
||||||
:show-line="true"
|
|
||||||
:show-icon="true"
|
|
||||||
:show-double-quotes="false"
|
|
||||||
:show-length="true"
|
|
||||||
:deep="3"
|
|
||||||
/>
|
|
||||||
<pre v-else class="json-pretty-fallback">{{ displayText }}</pre>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from "vue";
|
|
||||||
import VueJsonPretty from "vue-json-pretty";
|
|
||||||
import "vue-json-pretty/lib/styles.css";
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
value: { type: [String, Object, Array, Number, Boolean], default: "" },
|
|
||||||
height: { type: String, default: "240px" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsed = computed(() => {
|
|
||||||
const v: any = props.value;
|
|
||||||
if (typeof v === "string") {
|
|
||||||
try {
|
|
||||||
return JSON.parse(v);
|
|
||||||
} catch {
|
|
||||||
return v; // 非 JSON 字符串,原样展示
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return v;
|
|
||||||
});
|
|
||||||
|
|
||||||
const isJson = computed(() => typeof parsed.value === "object" && parsed.value !== null);
|
|
||||||
const displayText = computed(() => {
|
|
||||||
const v: any = props.value;
|
|
||||||
return typeof v === "string" ? v : JSON.stringify(v, null, 2);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.json-pretty-wrapper {
|
|
||||||
padding: 8px;
|
|
||||||
overflow: auto;
|
|
||||||
background-color: var(--el-fill-color-blank);
|
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.json-pretty-fallback {
|
|
||||||
margin: 0;
|
|
||||||
font-family:
|
|
||||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
|
|
||||||
monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
word-break: break-all;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<!-- 语言切换 -->
|
|
||||||
<template>
|
|
||||||
<el-dropdown trigger="click" @command="handleLanguageChange">
|
|
||||||
<div class="i-svg:language" :class="size" />
|
|
||||||
<template #dropdown>
|
|
||||||
<el-dropdown-menu>
|
|
||||||
<el-dropdown-item
|
|
||||||
v-for="item in langOptions"
|
|
||||||
:key="item.value"
|
|
||||||
:disabled="appStore.language === item.value"
|
|
||||||
:command="item.value"
|
|
||||||
>
|
|
||||||
{{ item.label }}
|
|
||||||
</el-dropdown-item>
|
|
||||||
</el-dropdown-menu>
|
|
||||||
</template>
|
|
||||||
</el-dropdown>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { useAppStore } from "@/store/modules/app.store";
|
|
||||||
import { LanguageEnum } from "@/enums/settings/locale.enum";
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
size: {
|
|
||||||
type: String,
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const langOptions = [
|
|
||||||
{ label: "中文", value: LanguageEnum.ZH_CN },
|
|
||||||
{ label: "English", value: LanguageEnum.EN },
|
|
||||||
];
|
|
||||||
|
|
||||||
const appStore = useAppStore();
|
|
||||||
const { locale, t } = useI18n();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理语言切换
|
|
||||||
*
|
|
||||||
* @param lang 语言(zh-cn、en)
|
|
||||||
*/
|
|
||||||
function handleLanguageChange(lang: string) {
|
|
||||||
locale.value = lang;
|
|
||||||
appStore.changeLanguage(lang);
|
|
||||||
|
|
||||||
ElMessage.success(t("langSelect.message.success"));
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="markdown-content" v-html="renderedContent"></div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from "vue";
|
|
||||||
import MarkdownIt from "markdown-it";
|
|
||||||
import markdownItHighlightjs from "markdown-it-highlightjs";
|
|
||||||
import hljs from "highlight.js";
|
|
||||||
import "highlight.js/styles/atom-one-light.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
content: string;
|
|
||||||
maxLength?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
maxLength: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const md: MarkdownIt = new MarkdownIt({
|
|
||||||
html: true,
|
|
||||||
linkify: true,
|
|
||||||
typographer: true,
|
|
||||||
breaks: true,
|
|
||||||
highlight(str: string, lang: string): string {
|
|
||||||
if (lang && hljs.getLanguage(lang)) {
|
|
||||||
try {
|
|
||||||
return `<pre class="hljs"><code>${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value}</code></pre>`;
|
|
||||||
} catch {
|
|
||||||
return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`;
|
|
||||||
},
|
|
||||||
}).use(markdownItHighlightjs);
|
|
||||||
|
|
||||||
const defaultRender =
|
|
||||||
md.renderer.rules.link_open ||
|
|
||||||
function (tokens: any[], idx: number, options: any, env: any, self: any) {
|
|
||||||
return self.renderToken(tokens, idx, options, env, self);
|
|
||||||
};
|
|
||||||
|
|
||||||
md.renderer.rules.link_open = function (
|
|
||||||
tokens: any[],
|
|
||||||
idx: number,
|
|
||||||
options: any,
|
|
||||||
env: any,
|
|
||||||
self: any
|
|
||||||
) {
|
|
||||||
tokens[idx].attrPush(["target", "_blank"]);
|
|
||||||
tokens[idx].attrPush(["rel", "noopener noreferrer"]);
|
|
||||||
return defaultRender(tokens, idx, options, env, self);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderedContent = computed(() => {
|
|
||||||
if (!props.content) return "";
|
|
||||||
const content =
|
|
||||||
props.maxLength && props.content.length > props.maxLength
|
|
||||||
? props.content.substring(0, props.maxLength) + "..."
|
|
||||||
: props.content;
|
|
||||||
return md.render(content);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.markdown-content {
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
word-wrap: break-word;
|
|
||||||
|
|
||||||
:deep(pre) {
|
|
||||||
padding: 12px;
|
|
||||||
margin: 12px 0;
|
|
||||||
overflow-x: auto;
|
|
||||||
background: var(--el-fill-color-light);
|
|
||||||
border-radius: 6px;
|
|
||||||
|
|
||||||
code {
|
|
||||||
font-family: "Courier New", Courier, monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(code) {
|
|
||||||
padding: 2px 6px;
|
|
||||||
font-family: "Courier New", Courier, monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
background: var(--el-fill-color-light);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(p) {
|
|
||||||
margin: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(ul),
|
|
||||||
:deep(ol) {
|
|
||||||
padding-left: 24px;
|
|
||||||
margin: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(li) {
|
|
||||||
margin: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(a) {
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(blockquote) {
|
|
||||||
padding: 8px 16px;
|
|
||||||
margin: 12px 0;
|
|
||||||
background: var(--el-fill-color-light);
|
|
||||||
border-left: 4px solid var(--el-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(table) {
|
|
||||||
width: 100%;
|
|
||||||
margin: 12px 0;
|
|
||||||
border-collapse: collapse;
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid var(--el-border-color-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
font-weight: 600;
|
|
||||||
background: var(--el-fill-color-light);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,582 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div @click="openSearchModal">
|
|
||||||
<!-- <div class="i-svg:search" /> -->
|
|
||||||
<div class="command-palette-trigger" role="button" tabindex="0" aria-label="打开搜索面板">
|
|
||||||
<div class="command-palette-trigger__left">
|
|
||||||
<div class="i-svg:search" />
|
|
||||||
<span class="command-palette-trigger__text">搜索菜单</span>
|
|
||||||
</div>
|
|
||||||
<kbd class="command-palette-trigger__kbd">Ctrl K</kbd>
|
|
||||||
</div>
|
|
||||||
<el-dialog
|
|
||||||
v-model="isModalVisible"
|
|
||||||
width="30%"
|
|
||||||
:append-to-body="true"
|
|
||||||
:show-close="false"
|
|
||||||
@close="closeSearchModal"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<el-input
|
|
||||||
ref="searchInputRef"
|
|
||||||
v-model="searchKeyword"
|
|
||||||
size="large"
|
|
||||||
placeholder="输入菜单名称关键字搜索"
|
|
||||||
clearable
|
|
||||||
@keyup.enter="selectActiveResult"
|
|
||||||
@input="updateSearchResults"
|
|
||||||
@keydown.up.prevent="navigateResults('up')"
|
|
||||||
@keydown.down.prevent="navigateResults('down')"
|
|
||||||
@keydown.esc="closeSearchModal"
|
|
||||||
>
|
|
||||||
<template #prepend>
|
|
||||||
<el-button icon="Search" />
|
|
||||||
</template>
|
|
||||||
</el-input>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="search-result">
|
|
||||||
<!-- 搜索历史 -->
|
|
||||||
<template v-if="searchKeyword === '' && searchHistory.length > 0">
|
|
||||||
<div class="search-history">
|
|
||||||
<div class="search-history__title">
|
|
||||||
搜索历史
|
|
||||||
<el-button
|
|
||||||
type="primary"
|
|
||||||
text
|
|
||||||
size="small"
|
|
||||||
class="search-history__clear"
|
|
||||||
@click="clearHistory"
|
|
||||||
>
|
|
||||||
<el-icon><Delete /></el-icon>
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
<ul class="search-history__list">
|
|
||||||
<li
|
|
||||||
v-for="(item, index) in searchHistory"
|
|
||||||
:key="index"
|
|
||||||
class="search-history__item"
|
|
||||||
@click="navigateToRoute(item)"
|
|
||||||
>
|
|
||||||
<div class="search-history__icon">
|
|
||||||
<el-icon><Clock /></el-icon>
|
|
||||||
</div>
|
|
||||||
<span class="search-history__name">{{ item.title }}</span>
|
|
||||||
<div class="search-history__action">
|
|
||||||
<el-icon @click.stop="removeHistoryItem(index)"><Close /></el-icon>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 搜索结果 -->
|
|
||||||
<template v-else>
|
|
||||||
<ul v-if="displayResults.length > 0">
|
|
||||||
<li
|
|
||||||
v-for="(item, index) in displayResults"
|
|
||||||
:key="item.path"
|
|
||||||
:class="[
|
|
||||||
'search-result__item',
|
|
||||||
{
|
|
||||||
'search-result__item--active': index === activeIndex,
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
@click="navigateToRoute(item)"
|
|
||||||
>
|
|
||||||
<el-icon v-if="item.icon && item.icon.startsWith('el-icon')">
|
|
||||||
<component :is="item.icon.replace('el-icon-', '')" />
|
|
||||||
</el-icon>
|
|
||||||
<div v-else-if="item.icon" :class="`i-svg:${item.icon}`" />
|
|
||||||
<div v-else class="i-svg:menu" />
|
|
||||||
<span class="ml-2">{{ item.title }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 无搜索历史显示 -->
|
|
||||||
<div v-if="searchKeyword === '' && searchHistory.length === 0" class="no-history">
|
|
||||||
<p class="no-history__text">没有搜索历史</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<div class="dialog-footer">
|
|
||||||
<div class="ctrl-k-hint">
|
|
||||||
<span class="ctrl-k-text">Ctrl+K 快速打开</span>
|
|
||||||
</div>
|
|
||||||
<div class="shortcuts-group">
|
|
||||||
<div class="key-box">
|
|
||||||
<div class="key-btn">选择</div>
|
|
||||||
</div>
|
|
||||||
<div class="arrow-box">
|
|
||||||
<div class="arrow-up-down">
|
|
||||||
<div class="key-btn">
|
|
||||||
<div class="i-svg:up" />
|
|
||||||
</div>
|
|
||||||
<div class="key-btn ml-1">
|
|
||||||
<div class="i-svg:down" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span class="key-text">切换</span>
|
|
||||||
</div>
|
|
||||||
<div class="key-box">
|
|
||||||
<div class="key-btn esc-btn">ESC</div>
|
|
||||||
<span class="key-text">关闭</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import router from "@/router";
|
|
||||||
import { usePermissionStore } from "@/store";
|
|
||||||
import { isExternal } from "@/utils";
|
|
||||||
import { RouteRecordRaw, LocationQueryRaw } from "vue-router";
|
|
||||||
import { Clock, Close, Delete } from "@element-plus/icons-vue";
|
|
||||||
|
|
||||||
const HISTORY_KEY = "menu_search_history";
|
|
||||||
const MAX_HISTORY = 5;
|
|
||||||
|
|
||||||
const permissionStore = usePermissionStore();
|
|
||||||
const isModalVisible = ref(false);
|
|
||||||
const searchKeyword = ref("");
|
|
||||||
const searchInputRef = ref();
|
|
||||||
const excludedRoutes = ref(["/redirect", "/login", "/401", "/404", "/500", "/:pathMatch(.*)*"]);
|
|
||||||
const menuItems = ref<SearchItem[]>([]);
|
|
||||||
const searchResults = ref<SearchItem[]>([]);
|
|
||||||
const activeIndex = ref(-1);
|
|
||||||
const searchHistory = ref<SearchItem[]>([]);
|
|
||||||
|
|
||||||
interface SearchItem {
|
|
||||||
title: string;
|
|
||||||
path: string;
|
|
||||||
name?: string;
|
|
||||||
icon?: string;
|
|
||||||
redirect?: string;
|
|
||||||
params?: LocationQueryRaw;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从本地存储加载搜索历史
|
|
||||||
function loadSearchHistory() {
|
|
||||||
const historyStr = localStorage.getItem(HISTORY_KEY);
|
|
||||||
if (historyStr) {
|
|
||||||
try {
|
|
||||||
searchHistory.value = JSON.parse(historyStr);
|
|
||||||
} catch {
|
|
||||||
searchHistory.value = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存搜索历史到本地存储
|
|
||||||
function saveSearchHistory() {
|
|
||||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(searchHistory.value));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加项目到搜索历史
|
|
||||||
function addToHistory(item: SearchItem) {
|
|
||||||
// 检查是否已存在
|
|
||||||
const index = searchHistory.value.findIndex((i) => i.path === item.path);
|
|
||||||
|
|
||||||
// 如果存在则移除
|
|
||||||
if (index !== -1) {
|
|
||||||
searchHistory.value.splice(index, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加到历史开头
|
|
||||||
searchHistory.value.unshift(item);
|
|
||||||
|
|
||||||
// 限制历史记录数量
|
|
||||||
if (searchHistory.value.length > MAX_HISTORY) {
|
|
||||||
searchHistory.value = searchHistory.value.slice(0, MAX_HISTORY);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存到本地存储
|
|
||||||
saveSearchHistory();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 移除历史记录项
|
|
||||||
function removeHistoryItem(index: number) {
|
|
||||||
searchHistory.value.splice(index, 1);
|
|
||||||
saveSearchHistory();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清空历史记录
|
|
||||||
function clearHistory() {
|
|
||||||
searchHistory.value = [];
|
|
||||||
localStorage.removeItem(HISTORY_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 注册全局快捷键
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
|
||||||
// 判断是否为Ctrl+K组合键
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
|
||||||
e.preventDefault(); // 阻止默认行为
|
|
||||||
openSearchModal();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加键盘事件监听
|
|
||||||
onMounted(() => {
|
|
||||||
loadRoutes(permissionStore.routes);
|
|
||||||
loadSearchHistory();
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 移除键盘事件监听
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 打开搜索模态框
|
|
||||||
function openSearchModal() {
|
|
||||||
searchKeyword.value = "";
|
|
||||||
activeIndex.value = -1;
|
|
||||||
isModalVisible.value = true;
|
|
||||||
setTimeout(() => {
|
|
||||||
searchInputRef.value.focus();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭搜索模态框
|
|
||||||
function closeSearchModal() {
|
|
||||||
isModalVisible.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新搜索结果
|
|
||||||
function updateSearchResults() {
|
|
||||||
activeIndex.value = -1;
|
|
||||||
if (searchKeyword.value) {
|
|
||||||
const keyword = searchKeyword.value.toLowerCase();
|
|
||||||
searchResults.value = menuItems.value.filter((item) =>
|
|
||||||
item.title.toLowerCase().includes(keyword)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
searchResults.value = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 显示搜索结果
|
|
||||||
const displayResults = computed(() => searchResults.value);
|
|
||||||
|
|
||||||
// 执行搜索
|
|
||||||
function selectActiveResult() {
|
|
||||||
if (displayResults.value.length > 0 && activeIndex.value >= 0) {
|
|
||||||
navigateToRoute(displayResults.value[activeIndex.value]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导航搜索结果
|
|
||||||
function navigateResults(direction: string) {
|
|
||||||
if (displayResults.value.length === 0) return;
|
|
||||||
|
|
||||||
if (direction === "up") {
|
|
||||||
activeIndex.value =
|
|
||||||
activeIndex.value <= 0 ? displayResults.value.length - 1 : activeIndex.value - 1;
|
|
||||||
} else if (direction === "down") {
|
|
||||||
activeIndex.value =
|
|
||||||
activeIndex.value >= displayResults.value.length - 1 ? 0 : activeIndex.value + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 跳转到
|
|
||||||
function navigateToRoute(item: SearchItem) {
|
|
||||||
closeSearchModal();
|
|
||||||
// 添加到历史记录
|
|
||||||
addToHistory(item);
|
|
||||||
|
|
||||||
if (isExternal(item.path)) {
|
|
||||||
window.open(item.path, "_blank");
|
|
||||||
} else {
|
|
||||||
router.push({ path: item.path, query: item.params });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadRoutes(routes: RouteRecordRaw[], parentPath = "") {
|
|
||||||
routes.forEach((route) => {
|
|
||||||
// 计算完整路径
|
|
||||||
const path = route.path.startsWith("/")
|
|
||||||
? route.path
|
|
||||||
: `${parentPath}${parentPath.endsWith("/") ? "" : "/"}${route.path}`;
|
|
||||||
|
|
||||||
// 检查是否需要排除
|
|
||||||
if (excludedRoutes.value.includes(route.path) || isExternal(route.path) || route.meta?.hidden)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// 处理有子路由的情况
|
|
||||||
if (route.children) {
|
|
||||||
// 如果父路由本身有title,也添加到menuItems中
|
|
||||||
if (route.meta?.title) {
|
|
||||||
const title = route.meta.title === "dashboard" ? "首页" : route.meta.title;
|
|
||||||
menuItems.value.push({
|
|
||||||
title,
|
|
||||||
path,
|
|
||||||
name: typeof route.name === "string" ? route.name : undefined,
|
|
||||||
icon: route.meta.icon,
|
|
||||||
redirect: typeof route.redirect === "string" ? route.redirect : undefined,
|
|
||||||
params: route.meta.params
|
|
||||||
? JSON.parse(JSON.stringify(toRaw(route.meta.params)))
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 递归处理子路由
|
|
||||||
loadRoutes(route.children, path);
|
|
||||||
}
|
|
||||||
// 处理没有子路由但有title的情况
|
|
||||||
else if (route.meta?.title) {
|
|
||||||
const title = route.meta.title === "dashboard" ? "首页" : route.meta.title;
|
|
||||||
menuItems.value.push({
|
|
||||||
title,
|
|
||||||
path,
|
|
||||||
name: typeof route.name === "string" ? route.name : undefined,
|
|
||||||
icon: route.meta.icon,
|
|
||||||
redirect: typeof route.redirect === "string" ? route.redirect : undefined,
|
|
||||||
params: route.meta.params
|
|
||||||
? JSON.parse(JSON.stringify(toRaw(route.meta.params)))
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.command-palette-trigger {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 12px;
|
|
||||||
user-select: none;
|
|
||||||
background: var(--el-fill-color-light);
|
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.command-palette-trigger__left {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.command-palette-trigger__left :deep([class^="i-svg:"]) {
|
|
||||||
color: var(--el-text-color-secondary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.command-palette-trigger__text {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.command-palette-trigger__kbd {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 2px 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
background: var(--el-bg-color-overlay);
|
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.command-palette-trigger:focus-visible {
|
|
||||||
outline: 2px solid var(--el-color-primary);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.command-palette-trigger:hover {
|
|
||||||
border-color: var(--el-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-result {
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
|
|
||||||
ul {
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&--active {
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
background-color: var(--el-menu-hover-bg-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 搜索历史样式 */
|
|
||||||
.search-history {
|
|
||||||
&__title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 34px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__clear {
|
|
||||||
padding: 2px;
|
|
||||||
font-size: 12px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--el-color-danger);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__list {
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-right: 10px;
|
|
||||||
font-size: 16px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__name {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__action {
|
|
||||||
padding: 4px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
border-radius: 4px;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--el-color-danger);
|
|
||||||
background-color: var(--el-fill-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 40px;
|
|
||||||
padding: 0 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--el-fill-color-light);
|
|
||||||
|
|
||||||
.search-history__action {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 没有搜索历史时的样式 */
|
|
||||||
.no-history {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100px;
|
|
||||||
|
|
||||||
&__text {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcuts-group {
|
|
||||||
display: flex;
|
|
||||||
gap: 15px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.key-box {
|
|
||||||
display: flex;
|
|
||||||
gap: 5px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.arrow-box {
|
|
||||||
display: flex;
|
|
||||||
gap: 5px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.arrow-up-down {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.key-btn {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 32px;
|
|
||||||
height: 20px;
|
|
||||||
padding: 0 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-regular);
|
|
||||||
background-color: var(--el-fill-color-blank);
|
|
||||||
border: 1px solid var(--el-border-color);
|
|
||||||
border-radius: 3px;
|
|
||||||
box-shadow: var(--el-box-shadow-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.esc-btn {
|
|
||||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.key-text {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ctrl-k-hint {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ctrl-k-text {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 适配Element Plus对话框
|
|
||||||
:deep(.el-dialog__footer) {
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding-top: 10px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
<!-- 操作列自适应宽度 -->
|
|
||||||
<template>
|
|
||||||
<el-table-column
|
|
||||||
:label="label"
|
|
||||||
:fixed="fixed"
|
|
||||||
:align="align"
|
|
||||||
:show-overflow-tooltip="showOverflowTooltip"
|
|
||||||
:width="finalWidth"
|
|
||||||
>
|
|
||||||
<template #default="{ row, column, $index }">
|
|
||||||
<div v-auto-width class="operation-buttons">
|
|
||||||
<slot v-bind="{ row, column, $index }"></slot>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
interface Props {
|
|
||||||
listDataLength: number;
|
|
||||||
prop?: string;
|
|
||||||
label?: string;
|
|
||||||
fixed?: string | boolean;
|
|
||||||
align?: string;
|
|
||||||
width?: number | string;
|
|
||||||
showOverflowTooltip?: boolean;
|
|
||||||
minWidth?: number | string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
label: "操作",
|
|
||||||
fixed: "right",
|
|
||||||
align: "center",
|
|
||||||
minWidth: 80,
|
|
||||||
});
|
|
||||||
|
|
||||||
const count = ref(0);
|
|
||||||
const operationWidth = ref(Number(props.minWidth) || 80);
|
|
||||||
|
|
||||||
// 计算操作列宽度
|
|
||||||
const calculateWidth = () => {
|
|
||||||
count.value++;
|
|
||||||
|
|
||||||
if (count.value !== props.listDataLength) return;
|
|
||||||
const maxWidth = getOperationMaxWidth();
|
|
||||||
operationWidth.value = Math.max(maxWidth, Number(props.minWidth));
|
|
||||||
count.value = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 计算最终宽度
|
|
||||||
const finalWidth = computed(() => {
|
|
||||||
const widthNum = typeof props.width === "number" ? props.width : Number(props.width);
|
|
||||||
const minWidthNum = typeof props.minWidth === "number" ? props.minWidth : Number(props.minWidth);
|
|
||||||
return widthNum || operationWidth.value || minWidthNum;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 自适应宽度指令
|
|
||||||
const vAutoWidth = {
|
|
||||||
mounted() {
|
|
||||||
// 初次挂载的时候计算一次
|
|
||||||
calculateWidth();
|
|
||||||
},
|
|
||||||
updated() {
|
|
||||||
// 数据更新时重新计算一次
|
|
||||||
calculateWidth();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取按钮数量和宽带来获取操作组的最大宽度
|
|
||||||
* 注意使用时需要使用 `class="operation-buttons"` 的标签包裹操作按钮
|
|
||||||
* @returns {number} 返回操作组的最大宽度
|
|
||||||
*/
|
|
||||||
const getOperationMaxWidth = () => {
|
|
||||||
const el = document.getElementsByClassName("operation-buttons");
|
|
||||||
|
|
||||||
// 取操作组的最大宽度
|
|
||||||
let maxWidth = 0;
|
|
||||||
let totalWidth: any = 0;
|
|
||||||
Array.prototype.forEach.call(el, (item) => {
|
|
||||||
// 获取每个item的dom
|
|
||||||
const buttons = item.querySelectorAll(".el-button");
|
|
||||||
// 获取每行按钮的总宽度
|
|
||||||
totalWidth = Array.from(buttons).reduce((acc, button: any) => {
|
|
||||||
return acc + button.scrollWidth + 14; // 每个按钮的宽度加上预留宽度
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
// 获取最大的宽度
|
|
||||||
if (totalWidth > maxWidth) maxWidth = totalWidth;
|
|
||||||
});
|
|
||||||
|
|
||||||
return maxWidth;
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
<!-- 分页组件 -->
|
|
||||||
<template>
|
|
||||||
<el-scrollbar>
|
|
||||||
<div :class="{ hidden: hidden }" class="pagination">
|
|
||||||
<el-pagination
|
|
||||||
v-model:current-page="currentPage"
|
|
||||||
v-model:page-size="pageSize"
|
|
||||||
:background="background"
|
|
||||||
:layout="layout"
|
|
||||||
:page-sizes="pageSizes"
|
|
||||||
:total="total"
|
|
||||||
@size-change="handleSizeChange"
|
|
||||||
@current-change="handleCurrentChange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</el-scrollbar>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const props = defineProps({
|
|
||||||
total: {
|
|
||||||
type: Number as PropType<number>,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
pageSizes: {
|
|
||||||
type: Array as PropType<number[]>,
|
|
||||||
default() {
|
|
||||||
return [10, 20, 30, 50];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
layout: {
|
|
||||||
type: String,
|
|
||||||
default: "total, sizes, prev, pager, next, jumper",
|
|
||||||
},
|
|
||||||
background: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
autoScroll: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
hidden: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(["pagination"]);
|
|
||||||
|
|
||||||
const currentPage = defineModel("page", {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
default: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const pageSize = defineModel("limit", {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
default: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.total,
|
|
||||||
(newVal: number) => {
|
|
||||||
const lastPage = Math.ceil(newVal / pageSize.value);
|
|
||||||
if (newVal > 0 && currentPage.value > lastPage) {
|
|
||||||
currentPage.value = lastPage;
|
|
||||||
emit("pagination", { page: currentPage.value, limit: pageSize.value });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleSizeChange(val: number) {
|
|
||||||
currentPage.value = 1;
|
|
||||||
emit("pagination", { page: currentPage.value, limit: val });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCurrentChange(val: number) {
|
|
||||||
emit("pagination", { page: val, limit: pageSize.value });
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.pagination {
|
|
||||||
padding: 12px;
|
|
||||||
|
|
||||||
&.hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,427 +0,0 @@
|
|||||||
<!--
|
|
||||||
TextScroll 组件 - 文本滚动公告
|
|
||||||
|
|
||||||
功能:
|
|
||||||
- 支持水平方向文本滚动
|
|
||||||
- 提供多种预设样式(默认、成功、警告、危险、信息)
|
|
||||||
- 支持自定义滚动速度和方向
|
|
||||||
- 可选的打字机输入效果
|
|
||||||
- 鼠标悬停时暂停滚动
|
|
||||||
- 可选的关闭按钮
|
|
||||||
-->
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
ref="containerRef"
|
|
||||||
class="text-scroll-container"
|
|
||||||
:class="[`text-scroll--${props.type}`]"
|
|
||||||
:typewriter="props.typewriter ? 'true' : undefined"
|
|
||||||
>
|
|
||||||
<!-- 左侧图标 -->
|
|
||||||
<div class="left-icon">
|
|
||||||
<el-icon><Bell /></el-icon>
|
|
||||||
</div>
|
|
||||||
<!-- 滚动内容包装器 -->
|
|
||||||
<div class="scroll-wrapper">
|
|
||||||
<div
|
|
||||||
ref="scrollContent"
|
|
||||||
class="text-scroll-content"
|
|
||||||
:class="{ scrolling: shouldScroll }"
|
|
||||||
:style="scrollStyle"
|
|
||||||
>
|
|
||||||
<!-- 滚动内容,复制两份以实现无缝滚动 -->
|
|
||||||
<div class="scroll-item" v-html="sanitizedContent" />
|
|
||||||
<div class="scroll-item" v-html="sanitizedContent" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- 可选的关闭按钮 -->
|
|
||||||
<div v-if="showClose" class="right-icon" @click="handleRightIconClick">
|
|
||||||
<el-icon><Close /></el-icon>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 使用案例 -->
|
|
||||||
<!-- <div class="app-container">
|
|
||||||
<TextScroll text="这是一条基础的滚动公告,默认向左滚动。" typewriter />
|
|
||||||
|
|
||||||
<TextScroll type="success" text="这是一条成功类型的滚动公告" typewriter />
|
|
||||||
|
|
||||||
<TextScroll type="warning" text="这是一条警告类型的滚动公告" />
|
|
||||||
|
|
||||||
<TextScroll type="danger" text="这是一条危险类型的滚动公告" />
|
|
||||||
|
|
||||||
<TextScroll type="info" text="这是一条信息类型的滚动公告" />
|
|
||||||
|
|
||||||
<TextScroll text="这是一条速度较慢、向右滚动的公告" :speed="30" direction="right" show-close />
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { useElementHover } from "@vueuse/core";
|
|
||||||
|
|
||||||
const emit = defineEmits(["close"]);
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** 滚动文本内容(必填) */
|
|
||||||
text: string;
|
|
||||||
/** 滚动速度,数值越小滚动越慢 */
|
|
||||||
speed?: number;
|
|
||||||
/** 滚动方向:左侧或右侧 */
|
|
||||||
direction?: "left" | "right";
|
|
||||||
/** 样式类型 */
|
|
||||||
type?: "default" | "success" | "warning" | "danger" | "info";
|
|
||||||
/** 是否显示关闭按钮 */
|
|
||||||
showClose?: boolean;
|
|
||||||
/** 是否启用打字机效果 */
|
|
||||||
typewriter?: boolean;
|
|
||||||
/** 打字机效果的速度,数值越小打字越快 */
|
|
||||||
typewriterSpeed?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 定义组件属性及默认值
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
speed: 70,
|
|
||||||
direction: "left",
|
|
||||||
type: "default",
|
|
||||||
showClose: false,
|
|
||||||
typewriter: false,
|
|
||||||
typewriterSpeed: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 容器元素引用
|
|
||||||
const containerRef = ref<HTMLElement | null>(null);
|
|
||||||
// 使用 vueuse 的 useElementHover 检测鼠标悬停状态
|
|
||||||
const isHovered = useElementHover(containerRef);
|
|
||||||
// 滚动内容元素引用
|
|
||||||
const scrollContent = ref<HTMLElement | null>(null);
|
|
||||||
// 动画持续时间(秒)
|
|
||||||
const animationDuration = ref(0);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 打字机效果相关状态
|
|
||||||
*/
|
|
||||||
// 当前已显示的文本内容
|
|
||||||
const currentText = ref("");
|
|
||||||
// 打字机定时器引用,用于清理
|
|
||||||
let typewriterTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
// 打字机效果是否已完成
|
|
||||||
const isTypewriterComplete = ref(false);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算是否应该滚动
|
|
||||||
* 条件:
|
|
||||||
* 1. 鼠标未悬停在组件上
|
|
||||||
* 2. 如果启用了打字机效果,则需要等待打字效果完成
|
|
||||||
*/
|
|
||||||
const shouldScroll = computed(() => {
|
|
||||||
if (props.typewriter) {
|
|
||||||
return !isHovered.value && isTypewriterComplete.value;
|
|
||||||
}
|
|
||||||
return !isHovered.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算最终显示的内容
|
|
||||||
* 如果启用了打字机效果,则显示当前已打出的文本
|
|
||||||
* 否则直接显示完整文本
|
|
||||||
* 注意:内容支持 HTML,使用时需注意 XSS 风险
|
|
||||||
*/
|
|
||||||
const sanitizedContent = computed(() => (props.typewriter ? currentText.value : props.text));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算滚动样式
|
|
||||||
* 包括动画持续时间、播放状态和方向
|
|
||||||
* 这些值通过 CSS 变量传递给样式
|
|
||||||
*/
|
|
||||||
const scrollStyle = computed(() => ({
|
|
||||||
"--animation-duration": `${animationDuration.value}s`,
|
|
||||||
"--animation-play-state": shouldScroll.value ? "running" : "paused",
|
|
||||||
"--animation-direction": props.direction === "left" ? "normal" : "reverse",
|
|
||||||
}));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算动画持续时间
|
|
||||||
* 根据内容宽度和设定的速度计算出合适的动画持续时间
|
|
||||||
* 内容越长或速度值越小,动画持续时间越长
|
|
||||||
*/
|
|
||||||
const calculateDuration = () => {
|
|
||||||
if (scrollContent.value) {
|
|
||||||
const contentWidth = scrollContent.value.scrollWidth / 2;
|
|
||||||
animationDuration.value = contentWidth / props.speed;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理关闭按钮点击事件
|
|
||||||
* 触发 close 事件,并直接销毁当前组件
|
|
||||||
*/
|
|
||||||
const handleRightIconClick = () => {
|
|
||||||
emit("close");
|
|
||||||
// 获取当前组件的DOM元素
|
|
||||||
if (containerRef.value) {
|
|
||||||
// 从DOM中移除元素
|
|
||||||
containerRef.value.remove();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 启动打字机效果
|
|
||||||
* 逐字显示文本内容,完成后设置状态以开始滚动
|
|
||||||
*/
|
|
||||||
const startTypewriter = () => {
|
|
||||||
let index = 0;
|
|
||||||
currentText.value = "";
|
|
||||||
isTypewriterComplete.value = false; // 重置状态
|
|
||||||
|
|
||||||
// 递归函数,逐字添加文本
|
|
||||||
const type = () => {
|
|
||||||
if (index < props.text.length) {
|
|
||||||
// 添加一个字符
|
|
||||||
currentText.value += props.text[index];
|
|
||||||
index++;
|
|
||||||
// 设置下一个字符的延迟
|
|
||||||
typewriterTimer = setTimeout(type, props.typewriterSpeed);
|
|
||||||
} else {
|
|
||||||
// 所有字符都已添加,设置完成状态
|
|
||||||
isTypewriterComplete.value = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 开始打字过程
|
|
||||||
type();
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
// 计算初始动画持续时间
|
|
||||||
calculateDuration();
|
|
||||||
// 监听窗口大小变化,重新计算动画持续时间
|
|
||||||
window.addEventListener("resize", calculateDuration);
|
|
||||||
|
|
||||||
// 如果启用了打字机效果,开始打字
|
|
||||||
if (props.typewriter) {
|
|
||||||
startTypewriter();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
// 移除事件监听
|
|
||||||
window.removeEventListener("resize", calculateDuration);
|
|
||||||
// 清除打字机定时器
|
|
||||||
if (typewriterTimer) {
|
|
||||||
clearTimeout(typewriterTimer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 监听文本内容变化
|
|
||||||
* 当文本内容变化时,如果启用了打字机效果,重新开始打字
|
|
||||||
*/
|
|
||||||
watch(
|
|
||||||
() => props.text,
|
|
||||||
() => {
|
|
||||||
if (props.typewriter) {
|
|
||||||
// 清除现有定时器
|
|
||||||
if (typewriterTimer) {
|
|
||||||
clearTimeout(typewriterTimer);
|
|
||||||
}
|
|
||||||
// 重新开始打字效果
|
|
||||||
startTypewriter();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.text-scroll-container {
|
|
||||||
position: relative;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
padding-right: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
background-color: var(--el-color-primary-light-9) !important;
|
|
||||||
border: 1px solid var(--main-color);
|
|
||||||
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
|
|
||||||
|
|
||||||
.left-icon,
|
|
||||||
.right-icon {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 2;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 40px;
|
|
||||||
height: 100%;
|
|
||||||
text-align: center;
|
|
||||||
background-color: var(--el-color-primary-light-9) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-icon {
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-icon {
|
|
||||||
right: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-wrapper {
|
|
||||||
flex: 1;
|
|
||||||
margin-left: 34px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-scroll-content {
|
|
||||||
display: flex;
|
|
||||||
height: 34px;
|
|
||||||
line-height: 34px;
|
|
||||||
white-space: nowrap;
|
|
||||||
animation: scroll linear infinite;
|
|
||||||
animation-duration: var(--animation-duration);
|
|
||||||
animation-direction: var(--animation-direction);
|
|
||||||
animation-play-state: var(--animation-play-state);
|
|
||||||
|
|
||||||
.scroll-item {
|
|
||||||
display: inline-block;
|
|
||||||
min-width: 100%;
|
|
||||||
padding: 0 10px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--el-color-primary-light-2) !important;
|
|
||||||
text-align: left;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
:deep(a) {
|
|
||||||
color: var(--el-color-danger) !important;
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes scroll {
|
|
||||||
0% {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加类型样式
|
|
||||||
&.text-scroll--default {
|
|
||||||
background-color: var(--el-color-primary-light-9) !important;
|
|
||||||
border-color: var(--el-color-primary);
|
|
||||||
|
|
||||||
.right-icon,
|
|
||||||
.left-icon i {
|
|
||||||
color: var(--el-color-primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-item {
|
|
||||||
color: var(--el-color-primary) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.text-scroll--success {
|
|
||||||
background-color: var(--el-color-success-light-9) !important;
|
|
||||||
border-color: var(--el-color-success);
|
|
||||||
|
|
||||||
.left-icon {
|
|
||||||
background-color: var(--el-color-success-light-9) !important;
|
|
||||||
|
|
||||||
i {
|
|
||||||
color: var(--el-color-success);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-item {
|
|
||||||
color: var(--el-color-success) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.text-scroll--warning {
|
|
||||||
background-color: var(--el-color-warning-light-9) !important;
|
|
||||||
border-color: var(--el-color-warning);
|
|
||||||
|
|
||||||
.left-icon {
|
|
||||||
background-color: var(--el-color-warning-light-9) !important;
|
|
||||||
|
|
||||||
i {
|
|
||||||
color: var(--el-color-warning);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-item {
|
|
||||||
color: var(--el-color-warning) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.text-scroll--danger {
|
|
||||||
background-color: var(--el-color-danger-light-9) !important;
|
|
||||||
border-color: var(--el-color-danger);
|
|
||||||
|
|
||||||
.left-icon {
|
|
||||||
background-color: var(--el-color-danger-light-9) !important;
|
|
||||||
|
|
||||||
i {
|
|
||||||
color: var(--el-color-danger);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-item {
|
|
||||||
color: var(--el-color-danger) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.text-scroll--info {
|
|
||||||
background-color: var(--el-color-info-light-9) !important;
|
|
||||||
border-color: var(--el-color-info);
|
|
||||||
|
|
||||||
.left-icon {
|
|
||||||
background-color: var(--el-color-info-light-9) !important;
|
|
||||||
|
|
||||||
i {
|
|
||||||
color: var(--el-color-info);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-item {
|
|
||||||
color: var(--el-color-info) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加打字机效果的光标样式
|
|
||||||
.text-scroll-content .scroll-item {
|
|
||||||
&::after {
|
|
||||||
content: "";
|
|
||||||
opacity: 0;
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 仅在启用打字机效果时显示光标
|
|
||||||
.text-scroll-container[typewriter] .text-scroll-content .scroll-item::after {
|
|
||||||
content: "|";
|
|
||||||
opacity: 0;
|
|
||||||
animation: cursor 1s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes cursor {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,361 +0,0 @@
|
|||||||
<!-- 单图上传组件 -->
|
|
||||||
<template>
|
|
||||||
<div class="single-image-upload">
|
|
||||||
<el-upload
|
|
||||||
v-model:file-list="internalFileList"
|
|
||||||
class="single-upload"
|
|
||||||
list-type="picture-card"
|
|
||||||
:show-file-list="false"
|
|
||||||
:accept="props.accept"
|
|
||||||
:before-upload="handleBeforeUpload"
|
|
||||||
:http-request="handleUpload"
|
|
||||||
:on-success="onSuccess"
|
|
||||||
:on-error="onError"
|
|
||||||
:on-remove="handleDelete"
|
|
||||||
:disabled="props.disabled"
|
|
||||||
>
|
|
||||||
<template #default>
|
|
||||||
<template v-if="internalFileList && internalFileList.length > 0 && internalFileList[0].url">
|
|
||||||
<el-image
|
|
||||||
:key="internalFileList[0].url"
|
|
||||||
class="single-upload__image"
|
|
||||||
:src="internalFileList[0].url"
|
|
||||||
fit="cover"
|
|
||||||
:preview-src-list="props.enablePreview ? [internalFileList[0].url] : []"
|
|
||||||
:preview-teleported="true"
|
|
||||||
@click.stop="handleImageClick"
|
|
||||||
/>
|
|
||||||
<el-icon
|
|
||||||
v-if="!props.disabled"
|
|
||||||
class="single-upload__delete-btn"
|
|
||||||
@click.stop="handleDelete"
|
|
||||||
>
|
|
||||||
<CircleCloseFilled />
|
|
||||||
</el-icon>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<el-icon class="single-upload__add-btn">
|
|
||||||
<Plus />
|
|
||||||
</el-icon>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</el-upload>
|
|
||||||
<div v-if="props.showTip" class="el-upload__tip">
|
|
||||||
{{ props.tipText || `支持 ${props.accept} 格式,文件大小不超过 ${props.maxFileSize}MB` }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch } from "vue";
|
|
||||||
import { UploadRawFile, UploadRequestOptions, ElMessage, type UploadUserFile } from "element-plus";
|
|
||||||
import ParamsAPI from "@/api/module_system/params";
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
/**
|
|
||||||
* 请求携带的额外参数
|
|
||||||
*/
|
|
||||||
data: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 上传文件的参数名
|
|
||||||
*/
|
|
||||||
name: {
|
|
||||||
type: String,
|
|
||||||
default: "file",
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 最大文件大小(单位:M)
|
|
||||||
*/
|
|
||||||
maxFileSize: {
|
|
||||||
type: Number,
|
|
||||||
default: 10,
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传图片格式,默认支持所有图片(image/*),指定格式示例:'.png,.jpg,.jpeg,.gif,.bmp'
|
|
||||||
*/
|
|
||||||
accept: {
|
|
||||||
type: String,
|
|
||||||
default: "image/*",
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 自定义样式,用于设置组件的宽度和高度等其他样式
|
|
||||||
*/
|
|
||||||
style: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {
|
|
||||||
return {
|
|
||||||
width: "150px",
|
|
||||||
height: "150px",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否禁用
|
|
||||||
*/
|
|
||||||
disabled: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否显示提示信息
|
|
||||||
*/
|
|
||||||
showTip: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 提示文本
|
|
||||||
*/
|
|
||||||
tipText: {
|
|
||||||
type: String,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否启用图片预览功能
|
|
||||||
*/
|
|
||||||
enablePreview: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 接收字符串类型的modelValue,保持与现有代码的兼容性
|
|
||||||
const modelValue = defineModel<string>({
|
|
||||||
default: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
// 内部使用的文件列表
|
|
||||||
const internalFileList = ref<UploadUserFile[]>([]);
|
|
||||||
|
|
||||||
// 监听modelValue变化,同步到internalFileList
|
|
||||||
watch(
|
|
||||||
() => modelValue.value,
|
|
||||||
(newVal) => {
|
|
||||||
if (newVal) {
|
|
||||||
internalFileList.value = [
|
|
||||||
{
|
|
||||||
name: newVal.split("/").pop() || "image",
|
|
||||||
url: newVal,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
internalFileList.value = [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
// 监听internalFileList变化,同步到modelValue
|
|
||||||
watch(
|
|
||||||
() => internalFileList.value,
|
|
||||||
(newVal) => {
|
|
||||||
if (newVal && newVal.length > 0 && newVal[0].url) {
|
|
||||||
modelValue.value = newVal[0].url;
|
|
||||||
} else {
|
|
||||||
modelValue.value = "";
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 定义组件触发的事件
|
|
||||||
*/
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: "success", fileInfo: UploadFilePath): void;
|
|
||||||
(e: "error", error: any): void;
|
|
||||||
(e: "input", value: string): void;
|
|
||||||
(e: "update:modelValue", value: string): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 限制用户上传文件的格式和大小
|
|
||||||
*/
|
|
||||||
function handleBeforeUpload(file: UploadRawFile) {
|
|
||||||
// 校验文件类型:虽然 accept 属性限制了用户在文件选择器中可选的文件类型,但仍需在上传时再次校验文件实际类型,确保符合 accept 的规则
|
|
||||||
const acceptTypes = props.accept.split(",").map((type) => type.trim());
|
|
||||||
|
|
||||||
// 检查文件格式是否符合 accept
|
|
||||||
const isValidType = acceptTypes.some((type) => {
|
|
||||||
if (type === "image/*") {
|
|
||||||
// 如果是 image/*,检查 MIME 类型是否以 "image/" 开头
|
|
||||||
return file.type.startsWith("image/");
|
|
||||||
} else if (type.startsWith(".")) {
|
|
||||||
// 如果是扩展名 (.png, .jpg),检查文件名是否以指定扩展名结尾
|
|
||||||
return file.name.toLowerCase().endsWith(type);
|
|
||||||
} else {
|
|
||||||
// 如果是具体的 MIME 类型 (image/png, image/jpeg),检查是否完全匹配
|
|
||||||
return file.type === type;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isValidType) {
|
|
||||||
ElMessage.warning(`上传文件的格式不正确,仅支持:${props.accept}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 限制文件大小
|
|
||||||
if (file.size > props.maxFileSize * 1024 * 1024) {
|
|
||||||
ElMessage.warning(`上传图片不能大于 ${props.maxFileSize}MB`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 上传图片
|
|
||||||
*/
|
|
||||||
async function handleUpload(options: UploadRequestOptions) {
|
|
||||||
try {
|
|
||||||
const file = options.file;
|
|
||||||
const formData = new FormData();
|
|
||||||
|
|
||||||
formData.append(props.name, file);
|
|
||||||
|
|
||||||
// 处理附加参数
|
|
||||||
for (const [key, value] of Object.entries(props.data)) {
|
|
||||||
formData.append(key, String(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await ParamsAPI.uploadFile(formData);
|
|
||||||
|
|
||||||
if (response.data.code === 0 && response.data) {
|
|
||||||
const fileInfo: UploadFilePath = response.data.data;
|
|
||||||
// 调用成功回调
|
|
||||||
onSuccess(fileInfo);
|
|
||||||
return fileInfo;
|
|
||||||
} else {
|
|
||||||
const errorMsg = response.data.msg || "上传失败";
|
|
||||||
ElMessage.error(errorMsg);
|
|
||||||
throw new Error(errorMsg);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
onError(error instanceof Error ? error : new Error(String(error)));
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除图片
|
|
||||||
*/
|
|
||||||
function handleDelete() {
|
|
||||||
// 清空模型值并通知父组件
|
|
||||||
internalFileList.value = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 图片点击处理
|
|
||||||
* 阻止事件冒泡到上传组件,避免触发上传
|
|
||||||
*/
|
|
||||||
function handleImageClick(event: Event) {
|
|
||||||
// 阻止事件冒泡
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
// 如果启用了预览功能,则触发预览
|
|
||||||
if (
|
|
||||||
props.enablePreview &&
|
|
||||||
internalFileList.value &&
|
|
||||||
internalFileList.value.length > 0 &&
|
|
||||||
internalFileList.value[0].url
|
|
||||||
) {
|
|
||||||
// Element Plus的el-image组件会自动处理preview-src-list的预览功能
|
|
||||||
// 这里只需要阻止冒泡即可
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传成功回调
|
|
||||||
*
|
|
||||||
* @param fileInfo 上传成功后的文件信息
|
|
||||||
*/
|
|
||||||
const onSuccess = (fileInfo: UploadFilePath) => {
|
|
||||||
// 更新绑定的值为文件URL
|
|
||||||
const newFileList = [
|
|
||||||
{
|
|
||||||
name: fileInfo.file_name,
|
|
||||||
url: fileInfo.file_url,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
internalFileList.value = newFileList;
|
|
||||||
|
|
||||||
// 触发事件
|
|
||||||
emit("success", fileInfo);
|
|
||||||
emit("input", fileInfo.file_url);
|
|
||||||
emit("update:modelValue", fileInfo.file_url);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传失败回调
|
|
||||||
*/
|
|
||||||
const onError = (error: any) => {
|
|
||||||
console.error("图片上传失败:", error);
|
|
||||||
ElMessage.error("图片上传失败,请重试");
|
|
||||||
emit("error", error);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.single-image-upload {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.single-upload {
|
|
||||||
&__image {
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__delete-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 1px;
|
|
||||||
right: 1px;
|
|
||||||
display: none;
|
|
||||||
font-size: 16px;
|
|
||||||
color: var(--el-color-warning);
|
|
||||||
cursor: pointer;
|
|
||||||
background: var(--el-bg-color-overlay);
|
|
||||||
border-radius: 100%;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--el-color-danger);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__add-btn {
|
|
||||||
font-size: 28px;
|
|
||||||
color: var(--el-text-color-placeholder);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-upload--picture-card) {
|
|
||||||
position: relative;
|
|
||||||
width: v-bind("props.style.width ?? '150px'");
|
|
||||||
height: v-bind("props.style.height ?? '150px'");
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
.single-upload__delete-btn {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-upload__tip {
|
|
||||||
margin-top: 7px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-regular);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div style="z-index: 999; border: 1px solid var(--el-border-color)">
|
|
||||||
<!-- 工具栏 -->
|
|
||||||
<Toolbar
|
|
||||||
:editor="editorRef"
|
|
||||||
mode="mode"
|
|
||||||
:default-config="toolbarConfig"
|
|
||||||
style="border-bottom: 1px solid var(--el-border-color)"
|
|
||||||
/>
|
|
||||||
<!-- 编辑器 -->
|
|
||||||
<Editor
|
|
||||||
v-model="modelValue"
|
|
||||||
:style="{ minHeight: height, overflowY: 'hidden' }"
|
|
||||||
:default-config="editorConfig"
|
|
||||||
mode="mode"
|
|
||||||
@on-created="handleCreated"
|
|
||||||
@on-change="handleChange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import "@wangeditor-next/editor/dist/css/style.css";
|
|
||||||
import { Toolbar, Editor } from "@wangeditor-next/editor-for-vue";
|
|
||||||
import { IToolbarConfig, IEditorConfig } from "@wangeditor-next/editor";
|
|
||||||
import ResourceAPI from "@/api/module_monitor/resource";
|
|
||||||
|
|
||||||
// 上传图片回调函数类型
|
|
||||||
type InsertFnType = (_url: string, _alt: string, _href: string) => void;
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
height: {
|
|
||||||
type: String,
|
|
||||||
default: "200px",
|
|
||||||
},
|
|
||||||
readonly: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
autoFocus: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
scroll: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// 双向绑定
|
|
||||||
const modelValue = defineModel("modelValue", {
|
|
||||||
type: String,
|
|
||||||
required: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 编辑器实例,必须用 shallowRef,重要!
|
|
||||||
const editorRef = shallowRef();
|
|
||||||
|
|
||||||
// 工具栏配置
|
|
||||||
const toolbarConfig = ref<Partial<IToolbarConfig>>({});
|
|
||||||
|
|
||||||
// 编辑器配置 - 根据 props 动态生成配置
|
|
||||||
const editorConfig = ref<Partial<IEditorConfig>>({
|
|
||||||
placeholder: "请输入内容...",
|
|
||||||
readOnly: props.readonly,
|
|
||||||
autoFocus: props.autoFocus,
|
|
||||||
scroll: props.scroll,
|
|
||||||
MENU_CONF: {
|
|
||||||
uploadImage: {
|
|
||||||
async customUpload(file: File, insertFn: InsertFnType) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await ResourceAPI.uploadFile(formData);
|
|
||||||
const data = res.data.data;
|
|
||||||
|
|
||||||
insertFn(data.file_url, data.filename, data.file_url);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("图片上传失败:", error);
|
|
||||||
ElMessage.error("图片上传失败");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
} as any,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 记录 editor 实例,重要!
|
|
||||||
const handleCreated = (editor: any) => {
|
|
||||||
editorRef.value = editor;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理内容变化 - 获取纯文本而不是HTML
|
|
||||||
const handleChange = (editor: any) => {
|
|
||||||
editorRef.value = editor;
|
|
||||||
if (editorRef.value) {
|
|
||||||
const text = editorRef.value.getText();
|
|
||||||
modelValue.value = text;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 组件销毁时,也及时销毁编辑器,重要!
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
const editor = editorRef.value;
|
|
||||||
if (editor == null) return;
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
// 该文件作用是:导出所有 composable 函数,作为全局函数使用
|
|
||||||
// AI 相关
|
|
||||||
export { useAiAction } from "./ai/useAiAction";
|
|
||||||
export type { UseAiActionOptions, AiActionHandler } from "./ai/useAiAction";
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import type { App } from "vue";
|
|
||||||
|
|
||||||
import { hasPerm } from "./permission";
|
|
||||||
|
|
||||||
// 全局注册 directive
|
|
||||||
export function setupDirective(app: App<Element>) {
|
|
||||||
// 使 v-hasPerm 在所有组件中都可用
|
|
||||||
app.directive("hasPerm", hasPerm);
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
/**
|
|
||||||
* 表单类型枚举
|
|
||||||
*/
|
|
||||||
export const FormTypeEnum: Record<string, OptionType> = {
|
|
||||||
INPUT: { value: 1, label: "输入框" },
|
|
||||||
SELECT: { value: 2, label: "下拉框" },
|
|
||||||
RADIO: { value: 3, label: "单选框" },
|
|
||||||
CHECK_BOX: { value: 4, label: "复选框" },
|
|
||||||
INPUT_NUMBER: { value: 5, label: "数字输入框" },
|
|
||||||
SWITCH: { value: 6, label: "开关" },
|
|
||||||
TEXT_AREA: { value: 7, label: "文本域" },
|
|
||||||
DATE: { value: 8, label: "日期框" },
|
|
||||||
DATE_TIME: { value: 9, label: "日期时间框" },
|
|
||||||
HIDDEN: { value: 10, label: "隐藏域" },
|
|
||||||
};
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
export * from "./api/result.enum";
|
|
||||||
|
|
||||||
export * from "./codegen/form.enum";
|
|
||||||
export * from "./codegen/query.enum";
|
|
||||||
|
|
||||||
export * from "./settings/layout.enum";
|
|
||||||
export * from "./settings/theme.enum";
|
|
||||||
export * from "./settings/locale.enum";
|
|
||||||
export * from "./settings/device.enum";
|
|
||||||
export * from "./settings/setting.enum";
|
|
||||||
|
|
||||||
export * from "./system/menu.enum";
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
/**
|
|
||||||
* 页面切换动画枚举
|
|
||||||
*/
|
|
||||||
export const enum PageSwitchingAnimationEnum {
|
|
||||||
/**
|
|
||||||
* 无动画
|
|
||||||
*/
|
|
||||||
NONE = "none",
|
|
||||||
/**
|
|
||||||
* 淡入淡出
|
|
||||||
*/
|
|
||||||
FADE = "fade",
|
|
||||||
/**
|
|
||||||
* 平滑切换
|
|
||||||
*/
|
|
||||||
FADE_SLIDE = "fade-slide",
|
|
||||||
/**
|
|
||||||
* 缩放切换
|
|
||||||
*/
|
|
||||||
FADE_SCALE = "fade-scale",
|
|
||||||
/**
|
|
||||||
* 左右滑动
|
|
||||||
*/
|
|
||||||
SLIDE_LEFT_RIGHT = "slide-left-right",
|
|
||||||
/**
|
|
||||||
* 缩放进出
|
|
||||||
*/
|
|
||||||
ZOOM_IN_OUT = "zoom-in-out",
|
|
||||||
/**
|
|
||||||
* 上下滑动
|
|
||||||
*/
|
|
||||||
SLIDE_UP_DOWN = "slide-up-down",
|
|
||||||
/**
|
|
||||||
* 弹性效果
|
|
||||||
*/
|
|
||||||
BOUNCE = "bounce",
|
|
||||||
}
|
|
||||||
export const PageSwitchingAnimationOptions: Record<string, OptionType> = {
|
|
||||||
none: { value: "none", label: "无动画" },
|
|
||||||
fade: { value: "fade", label: "淡入淡出" },
|
|
||||||
"fade-slide": { value: "fade-slide", label: "平滑切换" },
|
|
||||||
"fade-scale": { value: "fade-scale", label: "缩放切换" },
|
|
||||||
"slide-left-right": { value: "slide-left-right", label: "左右滑动" },
|
|
||||||
"zoom-in-out": { value: "zoom-in-out", label: "缩放进出" },
|
|
||||||
"slide-up-down": { value: "slide-up-down", label: "上下滑动" },
|
|
||||||
bounce: { value: "bounce", label: "弹性效果" },
|
|
||||||
};
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import type { App } from "vue";
|
|
||||||
import { createI18n } from "vue-i18n";
|
|
||||||
import { useAppStoreHook } from "@/store/modules/app.store";
|
|
||||||
// 本地语言包
|
|
||||||
import enLocale from "./package/en";
|
|
||||||
import zhCnLocale from "./package/zh-cn";
|
|
||||||
|
|
||||||
const appStore = useAppStoreHook();
|
|
||||||
|
|
||||||
const messages = {
|
|
||||||
"zh-cn": {
|
|
||||||
...zhCnLocale,
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
...enLocale,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const i18n = createI18n({
|
|
||||||
legacy: false,
|
|
||||||
locale: appStore.language,
|
|
||||||
messages,
|
|
||||||
globalInjection: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 全局注册 i18n
|
|
||||||
export function setupI18n(app: App<Element>) {
|
|
||||||
app.use(i18n);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default i18n;
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
export default {
|
|
||||||
// Common section
|
|
||||||
common: {
|
|
||||||
confirm: "Confirm",
|
|
||||||
cancel: "Cancel",
|
|
||||||
reset: "Reset",
|
|
||||||
save: "Save",
|
|
||||||
edit: "Edit",
|
|
||||||
delete: "Delete",
|
|
||||||
add: "Add",
|
|
||||||
clear: "Clear",
|
|
||||||
export: "Export",
|
|
||||||
import: "Import",
|
|
||||||
query: "Query",
|
|
||||||
resetQuery: "Reset Query",
|
|
||||||
back: "Back",
|
|
||||||
close: "Close",
|
|
||||||
refresh: "Refresh",
|
|
||||||
inputText: "Please enter",
|
|
||||||
selectText: "Please select",
|
|
||||||
startTimeText: "Start Time",
|
|
||||||
endTimeText: "End Time",
|
|
||||||
login: "Login",
|
|
||||||
required: "This field is required",
|
|
||||||
loginOut: "Logout",
|
|
||||||
document: "Project Documentation",
|
|
||||||
reminder: "Reminder",
|
|
||||||
loginOutMessage: "Are you sure you want to log out?",
|
|
||||||
ok: "OK",
|
|
||||||
reload: "Reload",
|
|
||||||
closeTab: "Close Tab",
|
|
||||||
closeTheLeftTab: "Close Left Tabs",
|
|
||||||
closeTheRightTab: "Close Right Tabs",
|
|
||||||
closeOther: "Close Other Tabs",
|
|
||||||
closeAll: "Close All Tabs",
|
|
||||||
prevLabel: "Previous",
|
|
||||||
nextLabel: "Next",
|
|
||||||
skipLabel: "Skip",
|
|
||||||
doneLabel: "Done",
|
|
||||||
menu: "Menu",
|
|
||||||
menuDes: "Menu rendered based on route structure",
|
|
||||||
collapse: "Collapse/Expand",
|
|
||||||
collapseDes: "Expand and collapse the menu bar",
|
|
||||||
tagsView: "Tags View",
|
|
||||||
tagsViewDes: "Used to record route history",
|
|
||||||
tool: "Tool",
|
|
||||||
toolDes: "Used to customize system settings",
|
|
||||||
shrink: "Collapse",
|
|
||||||
expand: "Expand",
|
|
||||||
delMessage: "Delete selected data?",
|
|
||||||
delWarning: "Warning",
|
|
||||||
delOk: "OK",
|
|
||||||
delCancel: "Cancel",
|
|
||||||
delNoData: "Please select data to delete",
|
|
||||||
delSuccess: "Deleted successfully",
|
|
||||||
fullscreen: "Fullscreen",
|
|
||||||
size: "Size",
|
|
||||||
columnSetting: "Column Settings",
|
|
||||||
lengthRange: "Length must be between {min} and {max} characters",
|
|
||||||
notSpace: "Cannot contain spaces",
|
|
||||||
notSpecialCharacters: "Cannot contain special characters",
|
|
||||||
isEqual: "The two inputs are inconsistent",
|
|
||||||
setting: "Settings",
|
|
||||||
copySuccess: "Copy successfully",
|
|
||||||
copyFailed: "Copy failed",
|
|
||||||
},
|
|
||||||
// Menu internationalization
|
|
||||||
route: {
|
|
||||||
dashboard: "Dashboard",
|
|
||||||
document: "Document",
|
|
||||||
},
|
|
||||||
// 登录页面国际化
|
|
||||||
login: {
|
|
||||||
themeToggle: "Theme Switch",
|
|
||||||
languageToggle: "Language Switch",
|
|
||||||
dark: "Dark",
|
|
||||||
light: "Light",
|
|
||||||
auto: "Auto",
|
|
||||||
username: "Please enter Username",
|
|
||||||
password: "Please enter Password",
|
|
||||||
newPassword: "Please enter new Password",
|
|
||||||
login: "Login",
|
|
||||||
captchaCode: "Please enter Verify Code",
|
|
||||||
capsLock: "Caps Lock is On",
|
|
||||||
rememberMe: "Remember Me",
|
|
||||||
forgetPassword: "Forget Password?",
|
|
||||||
message: {
|
|
||||||
username: {
|
|
||||||
required: "Please enter Username",
|
|
||||||
},
|
|
||||||
password: {
|
|
||||||
required: "Please enter Password",
|
|
||||||
min: "The password cannot be less than 6 characters",
|
|
||||||
confirm: "Please confirm the password again",
|
|
||||||
inconformity: "The two password entries are inconsistent",
|
|
||||||
},
|
|
||||||
captchaCode: {
|
|
||||||
required: "Please enter Verify Code",
|
|
||||||
},
|
|
||||||
mobile: {
|
|
||||||
required: "Please enter mobile number",
|
|
||||||
invalid: "Invalid mobile number format",
|
|
||||||
exist: "Mobile number already exists",
|
|
||||||
},
|
|
||||||
email: {
|
|
||||||
required: "Please enter email",
|
|
||||||
invalid: "Invalid email format",
|
|
||||||
exist: "Email already exists",
|
|
||||||
},
|
|
||||||
name: {
|
|
||||||
required: "Please enter account",
|
|
||||||
invalid: "Invalid account format",
|
|
||||||
min: "Account cannot be less than 2 characters",
|
|
||||||
},
|
|
||||||
agree: {
|
|
||||||
required: "Please agree to the User Agreement",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
otherLoginMethods: "Other",
|
|
||||||
resetPassword: "Reset password",
|
|
||||||
thinkOfPasswd: "Remember your password?",
|
|
||||||
register: "Register account",
|
|
||||||
agree: "I have read and agree to the",
|
|
||||||
userAgreement: "User Agreement",
|
|
||||||
haveAccount: "Already have an account?",
|
|
||||||
noAccount: "Don't have an account?",
|
|
||||||
quickFill: "Quick fill",
|
|
||||||
quickLoginTip: "Click user avatar to login quickly",
|
|
||||||
selectUser: "Select a user",
|
|
||||||
reg: "Register",
|
|
||||||
},
|
|
||||||
// 导航栏国际化
|
|
||||||
navbar: {
|
|
||||||
dashboard: "Dashboard",
|
|
||||||
logout: "Logout",
|
|
||||||
document: "Document",
|
|
||||||
gitee: "Gitee",
|
|
||||||
lock: "Lock",
|
|
||||||
unlock: "Unlock",
|
|
||||||
profile: "User Profile",
|
|
||||||
config: "Config Center",
|
|
||||||
tour: "Project Tour",
|
|
||||||
refresh: "Refresh",
|
|
||||||
close: "Close",
|
|
||||||
closeLeft: "Close Left",
|
|
||||||
closeRight: "Close Right",
|
|
||||||
closeOther: "Close Other",
|
|
||||||
closeAll: "Close All",
|
|
||||||
refreshCache: "Refresh Cache",
|
|
||||||
},
|
|
||||||
lock: {
|
|
||||||
lockScreen: "Lock screen",
|
|
||||||
lockPassword: "Lock screen password",
|
|
||||||
unlock: "Click to unlock",
|
|
||||||
backToLogin: "Back to login",
|
|
||||||
entrySystem: "Entry the system",
|
|
||||||
placeholder: "Please enter the lock screen password",
|
|
||||||
message: "Lock screen password error",
|
|
||||||
},
|
|
||||||
sizeSelect: {
|
|
||||||
tooltip: "Layout Size",
|
|
||||||
default: "Default",
|
|
||||||
large: "Large",
|
|
||||||
small: "Small",
|
|
||||||
message: {
|
|
||||||
success: "Switch Layout Size Successful!",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
langSelect: {
|
|
||||||
message: {
|
|
||||||
success: "Switch Language Successful!",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
settings: {
|
|
||||||
project: "Project Settings",
|
|
||||||
theme: "Theme",
|
|
||||||
interface: "Interface",
|
|
||||||
navigation: "Navigation",
|
|
||||||
themeColor: "Theme Color",
|
|
||||||
customColor: "Custom Color",
|
|
||||||
showTagsView: "Show Tags View",
|
|
||||||
showAppLogo: "Show App Logo",
|
|
||||||
sidebarColorScheme: "Sidebar Color Scheme",
|
|
||||||
showWatermark: "Show Watermark",
|
|
||||||
grayMode: "Gray Mode",
|
|
||||||
userEnableAi: "Enable AI Assistant",
|
|
||||||
pageSwitchingAnimation: "Page Switching Animation",
|
|
||||||
none: "None",
|
|
||||||
fade: "Fade",
|
|
||||||
"fade-slide": "Fade Slide",
|
|
||||||
"fade-scale": "Fade Scale",
|
|
||||||
"slide-left-right": "Slide Left/Right",
|
|
||||||
"zoom-in-out": "Zoom In/Out",
|
|
||||||
"slide-up-down": "Slide Up/Down",
|
|
||||||
bounce: "Bounce",
|
|
||||||
showDesktopTools: "Tools Settings",
|
|
||||||
showMenuSearch: "Show Menu Search",
|
|
||||||
showFullscreen: "Show Fullscreen Toggle",
|
|
||||||
showSizeSelect: "Show Layout Size",
|
|
||||||
showLangSelect: "Show Language Select",
|
|
||||||
showNotification: "Show Notification",
|
|
||||||
classicBlue: "Classic Blue",
|
|
||||||
minimalWhite: "Minimal White",
|
|
||||||
copyConfig: "Copy Config",
|
|
||||||
resetConfig: "Reset Default",
|
|
||||||
copySuccess: "Configuration copied to clipboard",
|
|
||||||
resetSuccess: "Reset to default configuration",
|
|
||||||
copyDescription:
|
|
||||||
"Copy config will generate current settings code, reset will restore all settings to default",
|
|
||||||
confirmReset: "Are you sure to reset all settings to default? This operation cannot be undone.",
|
|
||||||
applyToFile: "Apply to File",
|
|
||||||
onlyCopy: "Only Copy",
|
|
||||||
leftLayout: "Left Mode",
|
|
||||||
topLayout: "Top Mode",
|
|
||||||
mixLayout: "Mix Mode",
|
|
||||||
configManagement: "Config Management",
|
|
||||||
copyConfigDescription:
|
|
||||||
"Generate current settings code and copy to clipboard, then overwrite src/settings.ts file",
|
|
||||||
resetConfigDescription: "Restore all settings to system default values",
|
|
||||||
systemTheme: "System Theme",
|
|
||||||
showGuide: "Show Guide",
|
|
||||||
showSettings: "Show Settings",
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
noPermission: `Sorry, you don't have permission to access this page.`,
|
|
||||||
pageError: "Sorry, the page you visited does not exist.",
|
|
||||||
networkError: "Sorry, the server reported an error.",
|
|
||||||
returnToHome: "Return to home",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
export default {
|
|
||||||
// 公共部分
|
|
||||||
common: {
|
|
||||||
confirm: "确定",
|
|
||||||
cancel: "取消",
|
|
||||||
reset: "重置",
|
|
||||||
save: "保存",
|
|
||||||
edit: "编辑",
|
|
||||||
delete: "删除",
|
|
||||||
add: "添加",
|
|
||||||
clear: "清除",
|
|
||||||
export: "导出",
|
|
||||||
import: "导入",
|
|
||||||
query: "查询",
|
|
||||||
resetQuery: "重置查询",
|
|
||||||
back: "返回",
|
|
||||||
close: "关闭",
|
|
||||||
refresh: "刷新",
|
|
||||||
inputText: "请输入",
|
|
||||||
selectText: "请选择",
|
|
||||||
startTimeText: "开始时间",
|
|
||||||
endTimeText: "结束时间",
|
|
||||||
login: "登录",
|
|
||||||
required: "该项为必填项",
|
|
||||||
loginOut: "退出系统",
|
|
||||||
document: "项目文档",
|
|
||||||
reminder: "温馨提示",
|
|
||||||
loginOutMessage: "是否退出本系统?",
|
|
||||||
ok: "确定",
|
|
||||||
reload: "重新加载",
|
|
||||||
closeTab: "关闭标签页",
|
|
||||||
closeTheLeftTab: "关闭左侧标签页",
|
|
||||||
closeTheRightTab: "关闭右侧标签页",
|
|
||||||
closeOther: "关闭其它标签页",
|
|
||||||
closeAll: "关闭全部标签页",
|
|
||||||
prevLabel: "上一步",
|
|
||||||
nextLabel: "下一步",
|
|
||||||
skipLabel: "跳过",
|
|
||||||
doneLabel: "结束",
|
|
||||||
menu: "菜单",
|
|
||||||
menuDes: "以路由的结构渲染的菜单栏",
|
|
||||||
collapse: "展开缩收",
|
|
||||||
collapseDes: "展开和缩放菜单栏",
|
|
||||||
tagsView: "标签页",
|
|
||||||
tagsViewDes: "用于记录路由历史记录",
|
|
||||||
tool: "工具",
|
|
||||||
toolDes: "用于设置定制系统",
|
|
||||||
shrink: "收起",
|
|
||||||
expand: "展开",
|
|
||||||
delMessage: "是否删除所选中数据?",
|
|
||||||
delWarning: "提示",
|
|
||||||
delOk: "确定",
|
|
||||||
delCancel: "取消",
|
|
||||||
delNoData: "请选择需要删除的数据",
|
|
||||||
delSuccess: "删除成功",
|
|
||||||
fullscreen: "全屏",
|
|
||||||
size: "尺寸",
|
|
||||||
columnSetting: "列设置",
|
|
||||||
lengthRange: "长度在 {min} 到 {max} 个字符",
|
|
||||||
notSpace: "不能包含空格",
|
|
||||||
notSpecialCharacters: "不能包含特殊字符",
|
|
||||||
isEqual: "两次输入不一致",
|
|
||||||
setting: "设置",
|
|
||||||
copySuccess: "复制成功",
|
|
||||||
copyFailed: "复制失败",
|
|
||||||
},
|
|
||||||
// 菜单国际化
|
|
||||||
route: {
|
|
||||||
dashboard: "首页",
|
|
||||||
document: "项目文档",
|
|
||||||
},
|
|
||||||
// 登录页面国际化
|
|
||||||
login: {
|
|
||||||
themeToggle: "主题切换",
|
|
||||||
languageToggle: "语言切换",
|
|
||||||
dark: "暗黑",
|
|
||||||
light: "明亮",
|
|
||||||
auto: "自动",
|
|
||||||
username: "请输入账号",
|
|
||||||
password: "请输入密码",
|
|
||||||
newPassword: "请输入新密码",
|
|
||||||
login: "登 录",
|
|
||||||
captchaCode: "请输入验证码",
|
|
||||||
capsLock: "大写锁定已打开",
|
|
||||||
rememberMe: "记住我",
|
|
||||||
forgetPassword: "忘记密码?",
|
|
||||||
message: {
|
|
||||||
username: {
|
|
||||||
required: "请输入账号",
|
|
||||||
},
|
|
||||||
password: {
|
|
||||||
required: "请输入密码",
|
|
||||||
min: "密码不能少于6位",
|
|
||||||
confirm: "请再次确认密码",
|
|
||||||
inconformity: "两次密码输入不一致",
|
|
||||||
},
|
|
||||||
captchaCode: {
|
|
||||||
required: "请输入验证码",
|
|
||||||
},
|
|
||||||
mobile: {
|
|
||||||
required: "请输入手机号",
|
|
||||||
invalid: "手机号格式不正确",
|
|
||||||
exist: "手机号已存在",
|
|
||||||
},
|
|
||||||
email: {
|
|
||||||
required: "请输入邮箱",
|
|
||||||
invalid: "邮箱格式不正确",
|
|
||||||
exist: "邮箱已存在",
|
|
||||||
},
|
|
||||||
name: {
|
|
||||||
required: "请输入名称",
|
|
||||||
invalid: "名称格式不正确",
|
|
||||||
min: "名称不能少于2位",
|
|
||||||
},
|
|
||||||
agree: {
|
|
||||||
required: "请先勾选同意用户协议",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
otherLoginMethods: "其他",
|
|
||||||
resetPassword: "重置密码",
|
|
||||||
thinkOfPasswd: "想起密码?",
|
|
||||||
register: "注册账号",
|
|
||||||
agree: "我已同意并阅读",
|
|
||||||
userAgreement: "用户协议",
|
|
||||||
haveAccount: "已有账号?",
|
|
||||||
noAccount: "您没有账号?",
|
|
||||||
quickFill: "快速填写",
|
|
||||||
quickLoginTip: "点击用户头像快速登录",
|
|
||||||
selectUser: "请选择用户",
|
|
||||||
reg: "注 册",
|
|
||||||
},
|
|
||||||
// 导航栏国际化
|
|
||||||
navbar: {
|
|
||||||
dashboard: "首页",
|
|
||||||
logout: "退出登录",
|
|
||||||
document: "项目文档",
|
|
||||||
gitee: "项目地址",
|
|
||||||
lock: "锁定屏幕",
|
|
||||||
unlock: "解锁屏幕",
|
|
||||||
profile: "个人中心",
|
|
||||||
config: "配置中心",
|
|
||||||
tour: "项目引导",
|
|
||||||
refresh: "刷新",
|
|
||||||
close: "关闭",
|
|
||||||
closeLeft: "关闭左侧",
|
|
||||||
closeRight: "关闭右侧",
|
|
||||||
closeOther: "关闭其他",
|
|
||||||
closeAll: "关闭所有",
|
|
||||||
refreshCache: "刷新缓存",
|
|
||||||
},
|
|
||||||
lock: {
|
|
||||||
lockScreen: "锁定屏幕",
|
|
||||||
lockPassword: "锁屏密码",
|
|
||||||
unlock: "点击解锁",
|
|
||||||
backToLogin: "返回登录",
|
|
||||||
entrySystem: "进入系统",
|
|
||||||
placeholder: "请输入锁屏密码",
|
|
||||||
required: "请输入必填项",
|
|
||||||
message: "锁屏密码错误",
|
|
||||||
},
|
|
||||||
sizeSelect: {
|
|
||||||
tooltip: "布局大小",
|
|
||||||
default: "默认",
|
|
||||||
large: "大型",
|
|
||||||
small: "小型",
|
|
||||||
message: {
|
|
||||||
success: "切换布局大小成功!",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
langSelect: {
|
|
||||||
message: {
|
|
||||||
success: "切换语言成功!",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
settings: {
|
|
||||||
project: "项目配置",
|
|
||||||
theme: "主题设置",
|
|
||||||
interface: "界面设置",
|
|
||||||
navigation: "导航主题",
|
|
||||||
themeColor: "主题颜色",
|
|
||||||
themeColorTip: "主题颜色",
|
|
||||||
customColor: "自定义颜色",
|
|
||||||
darkMode: "暗黑模式",
|
|
||||||
layoutSetting: "布局设置",
|
|
||||||
sidebarColorScheme: "侧边栏配色",
|
|
||||||
showTagsView: "显示页签",
|
|
||||||
showAppLogo: "显示Logo",
|
|
||||||
showWatermark: "显示水印",
|
|
||||||
grayMode: "灰色模式",
|
|
||||||
userEnableAi: "AI 助手",
|
|
||||||
pageSwitchingAnimation: "切换动画",
|
|
||||||
none: "无动画",
|
|
||||||
fade: "淡入淡出",
|
|
||||||
"fade-slide": "平滑切换",
|
|
||||||
"fade-scale": "缩放切换",
|
|
||||||
"slide-left-right": "左右滑动",
|
|
||||||
"zoom-in-out": "缩放进出",
|
|
||||||
"slide-up-down": "上下滑动",
|
|
||||||
bounce: "弹性效果",
|
|
||||||
showDesktopTools: "工具设置",
|
|
||||||
showMenuSearch: "显示菜单搜索",
|
|
||||||
showFullscreen: "显示全屏切换",
|
|
||||||
showSizeSelect: "显示布局大小",
|
|
||||||
showLangSelect: "显示语言选择",
|
|
||||||
showNotification: "显示通知",
|
|
||||||
classicBlue: "经典蓝",
|
|
||||||
minimalWhite: "极简白",
|
|
||||||
copyConfig: "复制配置",
|
|
||||||
resetConfig: "重置默认",
|
|
||||||
copySuccess: "配置已复制到剪贴板",
|
|
||||||
resetSuccess: "已重置为默认配置",
|
|
||||||
copyDescription: "复制配置将生成当前设置的代码,重置将恢复所有设置为默认值",
|
|
||||||
confirmReset: "确定要重置所有设置为默认值吗?此操作不可恢复。",
|
|
||||||
applyToFile: "应用到文件",
|
|
||||||
onlyCopy: "仅复制",
|
|
||||||
leftLayout: "左侧模式",
|
|
||||||
topLayout: "顶部模式",
|
|
||||||
mixLayout: "混合模式",
|
|
||||||
configManagement: "配置管理",
|
|
||||||
copyConfigDescription: "生成当前设置的代码并复制到剪贴板,然后覆盖 src/settings.ts 文件",
|
|
||||||
resetConfigDescription: "恢复所有设置为系统默认值",
|
|
||||||
systemTheme: "系统主题",
|
|
||||||
showGuide: "启动引导",
|
|
||||||
showSettings: "显示设置",
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
noPermission: `抱歉,您无权访问此页面。`,
|
|
||||||
pageError: "抱歉,您访问的页面不存在。",
|
|
||||||
networkError: "抱歉,服务器报告错误。",
|
|
||||||
returnToHome: "返回首页",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user