feat: 重构前端项目结构并优化代码

refactor: 迁移前端资源文件至web目录
feat: 新增多种图标资源
style: 统一代码风格和格式化配置
docs: 更新README和文档说明
chore: 更新依赖和配置文件
fix: 修复部分类型定义和枚举
perf: 优化路由和组件加载逻辑
This commit is contained in:
zhangtao
2026-04-29 00:02:09 +08:00
parent 10a35bb4b5
commit eb150bcede
887 changed files with 116514 additions and 33990 deletions
+68 -20
View File
@@ -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
+59 -42
View File
@@ -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
``` ```
> **注意**: > **注意**:
+38
View File
@@ -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,
)
+1
View File
@@ -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
+33
View File
@@ -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
+11
View File
@@ -0,0 +1,11 @@
/.idea/*
/.pytest_cache/
/venv/
.venv
.vscode/*
reports/*
logs/*
case/api/.DS_Store
case/.DS_Store
.DS_Store
+194
View File
@@ -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.
+86
View File
@@ -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
## 🎨 社区交流
| 群组二维码 | 微信支付二维码 |
| --- | --- |
| ![群组二维码](https://gitee.com/fastapiadmin/FastDocs/raw/master/docs/public/group.jpg) | ![微信支付二维码](https://gitee.com/fastapiadmin/FastDocs/raw/master/docs/public/wechatPay.jpg) |
Binary file not shown.
+142
View File
@@ -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
+66
View File
@@ -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

+75
View File
@@ -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
+81
View File
@@ -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
View File
+34
View File
@@ -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}")
+42
View File
@@ -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
View File
+76
View File
@@ -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()
+9
View File
@@ -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]
+23
View File
@@ -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)
+48
View File
@@ -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.
+119
View File
@@ -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
+63
View File
@@ -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']}"
)
+187
View File
@@ -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 ''
+25
View File
@@ -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, "退出登录失败"
View File
+101
View File
@@ -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
)
+89
View File
@@ -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()
+118
View File
@@ -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)
+56
View File
@@ -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
+182
View File
@@ -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)
+281
View File
@@ -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))
+33
View File
@@ -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
View File
@@ -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="
set "GREEN="
set "YELLOW="
set "BLUE="
set "CYAN="
set "RESET="
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="
echo %GREEN% %~nx0 stop %RESET% - 停止所有服务
set "YELLOW="
echo %GREEN% %~nx0 restart %RESET% - 重启所有服务
set "YELLOW="
echo %GREEN% %~nx0 status %RESET% - 检查服务状态
set "YELLOW="
echo %GREEN% %~nx0 help %RESET% - 显示此帮助信息
set "YELLOW="
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
+426 -183
View File
@@ -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
-291
View File
@@ -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 镜像,释放磁盘空间
## 联系与支持
如有任何问题或需要技术支持,请联系项目维护者。
-23
View File
@@ -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
-101
View File
@@ -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
View File
@@ -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
+23
View File
@@ -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
+163
View File
@@ -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
```
脚本会自动拉取最新代码,构建新镜像,重启容器。
+56
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
# 小程序静态文件目录
请将小程序 H5 构建产物放置于此目录。
构建后应包含:
- index.html
- static/ (或其他静态资源)
+7
View File
@@ -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;
} }
} }
} }
+27
View File
@@ -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-----
+62
View File
@@ -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-----
+7
View File
@@ -0,0 +1,7 @@
# 前端静态文件目录
请将前端构建产物放置于此目录。
构建后应包含:
- index.html
- static/ (或其他静态资源)
-20
View File
@@ -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
-20
View File
@@ -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
-38
View File
@@ -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
-237
View File
@@ -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",
},
},
];
-93
View File
@@ -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>
-31
View File
@@ -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,
},
],
});
}
-46
View File
@@ -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="试试说:打开角色管理页面&#10;或者:跳转到用户管理&#10;按 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>
-38
View File
@@ -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>
-344
View File
@@ -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>
-90
View File
@@ -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>
-82
View File
@@ -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>
-43
View File
@@ -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>
-134
View File
@@ -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>
-4
View File
@@ -1,4 +0,0 @@
// 该文件作用是:导出所有 composable 函数,作为全局函数使用
// AI 相关
export { useAiAction } from "./ai/useAiAction";
export type { UseAiActionOptions, AiActionHandler } from "./ai/useAiAction";
-9
View File
@@ -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);
}
-15
View File
@@ -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: "隐藏域" },
};
-12
View File
@@ -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: "弹性效果" },
};
-31
View File
@@ -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;
-230
View File
@@ -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",
},
};
-232
View File
@@ -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