Compare commits

..
7 Commits
Author SHA1 Message Date
insistence 8cdf58310e chore: 发布1.8.1版本 2025-12-26 15:59:50 +08:00
insistence 75c49e89c2 perf: 优化pg版本的docker compose配置文件 (#61)
* perf: 优化pg版本的docker compose配置文件

* docs: 新增更新日志
2025-12-26 15:18:37 +08:00
insistence 8405471afd fix: 修复新增和编辑部门无法使用已删除的部门名称问题 (#60) 2025-12-26 11:50:48 +08:00
insistence 1e0b38451c fix: 修复DictTag组件渲染异常的问题 (#59)
* fix: 修复DictTag组件渲染异常的问题

* docs: 新增更新日志

* docs: 修复更新日志书写错误
2025-12-26 11:37:29 +08:00
insistence b84823610c feat: 新增E2E测试 (#57)
* feat: 新增E2E测试

* fix: 修复docker compose命令书写错误

* fix: 运行测试前执行`playwright install`

* fix: 增强检查以尝试修复3.11连接拒绝的问题

* fix: 增加mysql服务检查

* fix: 修复python 3.9兼容性问题

* perf: 优化禁用验证码脚本

* feat: 新增pg测试

* fix: 修复pg测试检查服务名书写错误

* perf: 优化测试

* style: 格式化代码
2025-12-26 11:09:51 +08:00
insistence 332ddbe0e8 perf: 动态导入定时任务函数,移除eval (#56) 2025-12-22 16:33:25 +08:00
insistence 9cfbf48ce9 perf: 优化数据权限依赖 (#55) 2025-12-22 11:30:03 +08:00
54 changed files with 3430 additions and 180 deletions
+170
View File
@@ -0,0 +1,170 @@
name: Playwright Tests
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
mysql-test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Cache Docker layers
uses: actions/cache@v5
with:
path: /tmp/.docker_cache
key: ${{ runner.os }}-docker-${{ matrix.python-version }}-${{ hashFiles('**/Dockerfile*', '**/requirements.txt') }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create temporary Dockerfile with Python ${{ matrix.python-version }}
run: |
# Save original Dockerfile.my
cp ruoyi-fastapi-backend/Dockerfile.my ruoyi-fastapi-backend/Dockerfile.my.bak
# Create temporary Dockerfile with target Python version
sed "s/FROM python:3.10/FROM python:${{ matrix.python-version }}/g" ruoyi-fastapi-backend/Dockerfile.my.bak > ruoyi-fastapi-backend/Dockerfile.my
- name: Start services with docker-compose
run: |
cd ruoyi-fastapi-test
docker compose -f docker-compose.test.my.yml up -d --build
- name: Wait for services to be ready
run: |
cd ruoyi-fastapi-test
# Wait for backend to be running
timeout 180 bash -c 'until docker compose -f docker-compose.test.my.yml ps ruoyi-backend-my | grep -q "Up"; do sleep 5; done'
# Wait for frontend to be running
timeout 120 bash -c 'until docker compose -f docker-compose.test.my.yml ps ruoyi-frontend | grep -q "Up"; do sleep 5; done'
# Additional wait for services to be fully ready and listening on ports
sleep 30
# Check that backend is actually responding on the API endpoint
echo "Checking if backend service is ready..."
for i in {1..30}; do
if curl -f http://localhost:9099/captchaImage > /dev/null 2>&1; then
echo "Backend service is ready!"
break
fi
echo "Waiting for backend service to be ready... ($i/30)"
sleep 5
done
# Final check
if ! curl -f http://localhost:9099/captchaImage > /dev/null 2>&1; then
echo "Backend service failed to start properly. Checking logs..."
docker logs ruoyi-backend-my-test
exit 1
fi
- name: Run tests
run: |
cd ruoyi-fastapi-test
pip install -r requirements.txt
playwright install
pytest -v
- name: Stop services
if: always()
run: |
cd ruoyi-fastapi-test
docker compose -f docker-compose.test.my.yml down
- name: Restore original Dockerfile.my
if: always()
run: |
# Restore original Dockerfile.my after test
mv ruoyi-fastapi-backend/Dockerfile.my.bak ruoyi-fastapi-backend/Dockerfile.my
pg-test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Cache Docker layers
uses: actions/cache@v5
with:
path: /tmp/.docker_cache
key: ${{ runner.os }}-docker-${{ matrix.python-version }}-${{ hashFiles('**/Dockerfile*', '**/requirements.txt') }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create temporary Dockerfile with Python ${{ matrix.python-version }}
run: |
# Save original Dockerfile.my
cp ruoyi-fastapi-backend/Dockerfile.my ruoyi-fastapi-backend/Dockerfile.my.bak
# Create temporary Dockerfile with target Python version
sed "s/FROM python:3.10/FROM python:${{ matrix.python-version }}/g" ruoyi-fastapi-backend/Dockerfile.my.bak > ruoyi-fastapi-backend/Dockerfile.my
- name: Start services with docker-compose
run: |
cd ruoyi-fastapi-test
docker compose -f docker-compose.test.pg.yml up -d --build
- name: Wait for services to be ready
run: |
cd ruoyi-fastapi-test
# Wait for backend to be running
timeout 180 bash -c 'until docker compose -f docker-compose.test.pg.yml ps ruoyi-backend-pg | grep -q "Up"; do sleep 5; done'
# Wait for frontend to be running
timeout 120 bash -c 'until docker compose -f docker-compose.test.pg.yml ps ruoyi-frontend | grep -q "Up"; do sleep 5; done'
# Additional wait for services to be fully ready and listening on ports
sleep 30
# Check that backend is actually responding on the API endpoint
echo "Checking if backend service is ready..."
for i in {1..30}; do
if curl -f http://localhost:9099/captchaImage > /dev/null 2>&1; then
echo "Backend service is ready!"
break
fi
echo "Waiting for backend service to be ready... ($i/30)"
sleep 5
done
# Final check
if ! curl -f http://localhost:9099/captchaImage > /dev/null 2>&1; then
echo "Backend service failed to start properly. Checking logs..."
docker logs ruoyi-backend-pg-test
exit 1
fi
- name: Run tests
run: |
cd ruoyi-fastapi-test
pip install -r requirements.txt
playwright install
pytest -v
- name: Stop services
if: always()
run: |
cd ruoyi-fastapi-test
docker compose -f docker-compose.test.pg.yml down
- name: Restore original Dockerfile.my
if: always()
run: |
# Restore original Dockerfile.my after test
mv ruoyi-fastapi-backend/Dockerfile.my.bak ruoyi-fastapi-backend/Dockerfile.my
+33
View File
@@ -0,0 +1,33 @@
name: Ruff Check
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
lint-format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.9"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install ruff
- name: Run linting
run: |
ruff check ruoyi-fastapi-backend
ruff check ruoyi-fastapi-test
- name: Run format check
run: |
ruff format ruoyi-fastapi-backend --check
ruff format ruoyi-fastapi-test --check
-31
View File
@@ -1,31 +0,0 @@
name: Tests
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
lint-format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.9"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install ruff
- name: Run linting
run: |
ruff check ruoyi-fastapi-backend
- name: Run format check
run: |
ruff format ruoyi-fastapi-backend --check
+17 -1
View File
@@ -1,10 +1,26 @@
# 更新日志
## RuoYi-Vue3-FastAPI v1.8.1
### 新增功能
1.新增E2E测试 (#57)。
### BUG修复
1.修复DictTag组件渲染异常的问题 (#59)。
### 代码优化
1.优化数据权限依赖 (#55)。
2.动态导入定时任务函数,移除eval (#56)。
3.优化pg版本的docker compose配置文件 (#61)。
## RuoYi-Vue3-FastAPI v1.8.0
### 项目依赖
## 后端
#### 后端
1.后端依赖升级到最新版本,请升级依赖或重新创建环境。
+129 -41
View File
@@ -1,16 +1,33 @@
<p align="center">
<h1 align="center">
<img alt="logo" src="https://oscimg.oschina.net/oscnet/up-d3d0a9303e11d522a06cd263f3079027715.png">
</p>
<h1 align="center" style="margin: 30px 0 30px; font-weight: bold;">RuoYi-Vue3-FastAPI v1.8.0</h1>
</h1>
<h1 align="center" style="margin: 30px 0 30px; font-weight: bold;">RuoYi-Vue3-FastAPI</h1>
<h4 align="center">基于RuoYi-Vue3+FastAPI前后端分离的快速开发框架</h4>
<p align="center">
<a href="https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/stargazers"><img src="https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/badge/star.svg?theme=dark"></a>
<a href="https://github.com/insistence/RuoYi-Vue3-FastAPI"><img src="https://img.shields.io/github/stars/insistence/RuoYi-Vue3-FastAPI?style=social"></a>
<a href="https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI"><img src="https://img.shields.io/badge/RuoYiVue3FastAPI-v1.8.0-brightgreen.svg"></a>
<a href="https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/blob/master/LICENSE"><img src="https://img.shields.io/github/license/mashape/apistatus.svg"></a>
<img src="https://img.shields.io/badge/node-≥18-blue">
<img src="https://img.shields.io/badge/python-≥3.9-blue">
<img src="https://img.shields.io/badge/MySQL-≥5.7-blue">
<a href="https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/stargazers">
<img alt="Gitee" src="https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/badge/star.svg?theme=dark">
</a>
<a href="https://github.com/insistence/RuoYi-Vue3-FastAPI">
<img alt="Github" src="https://img.shields.io/github/stars/insistence/RuoYi-Vue3-FastAPI?style=social">
</a>
<a href="https://github.com/insistence/RuoYi-Vue3-FastAPI/actions?query=branch%3Amaster+event%3Apush+workflow%3A%22%22Playwright+Tests%22%22">
<img alt="Playwright Tests" src="https://github.com/insistence/RuoYi-Vue3-FastAPI/workflows/Playwright Tests/badge.svg">
</a>
<a href="https://github.com/insistence/RuoYi-Vue3-FastAPI/actions?query=branch%3Amaster+event%3Apush+workflow%3A%22%22Ruff+Check%22%22">
<img alt="Ruff Check" src="https://github.com/insistence/RuoYi-Vue3-FastAPI/workflows/Ruff Check/badge.svg">
</a>
<a href="https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI">
<img alt="project version" src="https://img.shields.io/badge/version-1.8.1-brightgreen.svg">
</a>
<a href="https://github.com/astral-sh/ruff">
<img alt="Ruff" src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json">
</a>
<a href="https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/blob/master/LICENSE">
<img alt="LICENSE" src="https://img.shields.io/github/license/mashape/apistatus.svg">
</a>
<img alt="node version" src="https://img.shields.io/badge/node-≥18-blue">
<img alt="python version" src="https://img.shields.io/badge/python-≥3.9-blue">
<img alt="mysql version" src="https://img.shields.io/badge/MySQL-≥5.7-blue">
</p>
## 平台简介
@@ -22,11 +39,11 @@ RuoYi-Vue3-FastAPI是一套全部开源的快速开发平台,毫无保留给
* 权限认证使用OAuth2 & Jwt,支持多终端认证系统。
* 支持加载动态权限菜单,多方式轻松权限控制。
* Vue2版本:
- Gitte仓库地址:https://gitee.com/insistence2022/RuoYi-Vue-FastAPI
- GitHub仓库地址:https://github.com/insistence/RuoYi-Vue-FastAPI
* Gitte仓库地址:<https://gitee.com/insistence2022/RuoYi-Vue-FastAPI>
* GitHub仓库地址:<https://github.com/insistence/RuoYi-Vue-FastAPI>
* 纯Python版本:
- Gitte仓库地址:https://gitee.com/insistence2022/dash-fastapi-admin
- GitHub仓库地址:https://github.com/insistence/Dash-FastAPI-Admin
* Gitte仓库地址:<https://gitee.com/insistence2022/dash-fastapi-admin>
* GitHub仓库地址:<https://github.com/insistence/Dash-FastAPI-Admin>
* 特别鸣谢:<u>[RuoYi-Vue3](https://github.com/yangzongzhuan/RuoYi-Vue3)</u>
## 内置功能
@@ -53,54 +70,97 @@ RuoYi-Vue3-FastAPI是一套全部开源的快速开发平台,毫无保留给
<table>
<tr>
<td><img src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/login.png"/></td>
<td><img src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/dashboard.png"/></td>
<td>
<img alt="login" src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/login.png">
</td>
<td>
<img alt="dashboard" src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/dashboard.png">
</td>
</tr>
<tr>
<td><img src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/user.png"/></td>
<td><img src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/role.png"/></td>
<td>
<img alt="user" src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/user.png">
</td>
<td>
<img alt="role" src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/role.png">
</td>
</tr>
<tr>
<td><img src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/menu.png"/></td>
<td><img src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/dept.png"/></td>
<td>
<img alt="menu" src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/menu.png">
</td>
<td>
<img alt="dept" src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/dept.png">
</td>
</tr>
<tr>
<td><img src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/post.png"/></td>
<td><img src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/dict.png"/></td>
<td>
<img alt=""post src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/post.png">
</td>
<td>
<img alt="dict" src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/dict.png">
</td>
</tr>
<tr>
<td><img src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/config.png"/></td>
<td><img src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/notice.png"/></td>
<td>
<img alt="config" src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/config.png">
</td>
<td>
<img alt="notice" src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/notice.png">
</td>
</tr>
<tr>
<td><img src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/operLog.png"/></td>
<td><img src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/loginLog.png"/></td>
<td>
<img alt="operLog" src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/operLog.png">
</td>
<td>
<img alt="loginLog" src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/loginLog.png">
</td>
</tr>
<tr>
<td><img src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/online.png"/></td>
<td><img src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/job.png"/></td>
<td>
<img alt="online" src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/online.png">
</td>
<td>
<img alt="job" src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/job.png">
</td>
</tr>
<tr>
<td><img src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/server.png"/></td>
<td><img src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/cache.png"/></td>
<td>
<img alt="server" src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/server.png">
</td>
<td>
<img alt="cache" src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/cache.png">
</td>
</tr>
<tr>
<td><img src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/cacheList.png"></td>
<td><img src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/form.png"></td>
<td>
<img alt="cacheList" src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/cacheList.png">
</td>
<td>
<img alt="form" src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/form.png">
</td>
</tr>
<tr>
<td><img src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/api.png"></td>
<td><img src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/gen.png"/></td>
<td>
<img alt="api" src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/api.png">
</td>
<td>
<img alt="gen" src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/gen.png">
</td>
</tr>
<tr>
<td><img src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/profile.png"/></td>
<td>
<img alt="profile" src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/profile.png">
</td>
</tr>
</table>
## 在线体验
- *账号:admin*
- *密码admin123*
- 演示地址:<a href="https://vfadmin.insistence.tech">vfadmin管理系统<a>
* *账号admin*
* *密码:admin123*
* 演示地址:<a href="https://vfadmin.insistence.tech">vfadmin管理系统<a>
## 项目开发及发布相关
@@ -115,6 +175,7 @@ cd RuoYi-Vue3-FastAPI
```
#### 前端
```bash
# 进入前端目录
cd ruoyi-fastapi-frontend
@@ -130,6 +191,7 @@ npm run dev 或 yarn dev
```
#### 后端
```bash
# 进入后端目录
cd ruoyi-fastapi-backend
@@ -151,6 +213,7 @@ python3 app.py --env=dev
```
#### 访问
```bash
# 默认账号密码
账号:admin
@@ -163,6 +226,7 @@ python3 app.py --env=dev
### 发布
#### 前端
```bash
# 构建测试环境
npm run build:stage 或 yarn build:stage
@@ -172,6 +236,7 @@ npm run build:prod 或 yarn build:prod
```
#### 后端
```bash
# 配置环境
在.env.prod文件中配置生产环境的数据库和redis
@@ -180,14 +245,37 @@ npm run build:prod 或 yarn build:prod
python3 app.py --env=prod
```
### Docker Compose部署方式
> ⚠️ **警告:** 默认未做数据持久化配置,请注意数据备份或自行配置持久化
#### MySQL版本
```bash
docker compose -f docker-compose.my.yml up -d --build
```
#### PostgreSQL版本
```bash
docker compose -f docker-compose.pg.yml up -d --build
```
## 交流与赞助
如果有对本项目及FastAPI感兴趣的朋友,欢迎加入知识星球一起交流学习,让我们一起变得更强。如果你觉得这个项目帮助到了你,你可以请作者喝杯咖啡表示鼓励☕。扫描下面微信二维码添加微信备注VF-Admin即可进群。
<table>
<tr>
<td><img alt="zsxq" src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/zsxq.jpg"></td>
<td><img alt="zanzhu" src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/zanzhu.jpg"></td>
<td>
<img alt="zsxq" src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/zsxq.jpg">
</td>
<td>
<img alt="zanzhu" src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/zanzhu.jpg">
</td>
</tr>
<tr>
<td><img alt="wxcode" src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/wxcode.jpg"></td>
<td>
<img alt="wxcode" src="https://gitee.com/insistence2022/RuoYi-Vue-FastAPI/raw/master/demo-pictures/wxcode.jpg">
</td>
</tr>
</table>
+9 -2
View File
@@ -25,8 +25,10 @@ services:
ports:
- "19099:9099"
depends_on:
- ruoyi-pg
- ruoyi-redis
ruoyi-pg:
condition: service_healthy
ruoyi-redis:
condition: service_healthy
networks:
- ruoyi-network
@@ -58,6 +60,11 @@ services:
- "16379:6379"
networks:
- ruoyi-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 10s
retries: 30
# 网络配置
networks:
+1 -1
View File
@@ -10,7 +10,7 @@ APP_HOST = '0.0.0.0'
# 应用端口
APP_PORT = 9099
# 应用版本
APP_VERSION= '1.8.0'
APP_VERSION= '1.8.1'
# 应用是否开启热重载
APP_RELOAD = true
# 应用是否开启IP归属区域查询
+1 -1
View File
@@ -10,7 +10,7 @@ APP_HOST = '0.0.0.0'
# 应用端口
APP_PORT = 9099
# 应用版本
APP_VERSION= '1.8.0'
APP_VERSION= '1.8.1'
# 应用是否开启热重载
APP_RELOAD = false
# 应用是否开启IP归属区域查询
+1 -1
View File
@@ -10,7 +10,7 @@ APP_HOST = '0.0.0.0'
# 应用端口
APP_PORT = 9099
# 应用版本
APP_VERSION= '1.8.0'
APP_VERSION= '1.8.1'
# 应用是否开启热重载
APP_RELOAD = false
# 应用是否开启IP归属区域查询
+1 -1
View File
@@ -10,7 +10,7 @@ APP_HOST = '0.0.0.0'
# 应用端口
APP_PORT = 9099
# 应用版本
APP_VERSION= '1.8.0'
APP_VERSION= '1.8.1'
# 应用是否开启热重载
APP_RELOAD = false
# 应用是否开启IP归属区域查询
@@ -1,8 +1,12 @@
from typing import Optional
from fastapi import Depends, Request, params
from sqlalchemy import ColumnElement, func, or_, select
from common.context import RequestContext
from config.database import Base
from module_admin.entity.do.dept_do import SysDept
from module_admin.entity.do.role_do import SysRoleDept
from utils.dependency_util import DependencyUtil
@@ -19,25 +23,22 @@ class GetDataScope:
def __init__(
self,
query_alias: Optional[str] = '',
db_alias: Optional[str] = 'db',
query_alias: Base,
user_alias: Optional[str] = 'user_id',
dept_alias: Optional[str] = 'dept_id',
) -> None:
"""
获取当前用户数据权限对应的查询sql语句
:param query_alias: 所要查询表对应的sqlalchemy模型名称,默认为''
:param db_alias: orm对象别名,默认为'db'
:param query_alias: 所要查询表对应的sqlalchemy模型类,不可为空
:param user_alias: 用户id字段别名,默认为'user_id'
:param dept_alias: 部门id字段别名,默认为'dept_id'
"""
self.query_alias = query_alias
self.db_alias = db_alias
self.user_alias = user_alias
self.dept_alias = dept_alias
def __call__(self, request: Request) -> str:
def __call__(self, request: Request) -> ColumnElement:
DependencyUtil.check_exclude_routes(request, err_msg='当前路由不在认证规则内,不可使用GetDataScope依赖项')
current_user = RequestContext.get_current_user()
user_id = current_user.user.user_id
@@ -48,50 +49,66 @@ class GetDataScope:
param_sql_list = []
for role in current_user.user.role:
if current_user.user.admin or role.data_scope == self.DATA_SCOPE_ALL:
param_sql_list = ['1 == 1']
param_sql_list = [True]
break
if role.data_scope == self.DATA_SCOPE_CUSTOM:
if len(custom_data_scope_role_id_list) > 1:
param_sql_list.append(
f"{self.query_alias}.{self.dept_alias}.in_(select(SysRoleDept.dept_id).where(SysRoleDept.role_id.in_({custom_data_scope_role_id_list}))) if hasattr({self.query_alias}, '{self.dept_alias}') else 1 == 0"
getattr(self.query_alias, self.dept_alias).in_(
select(SysRoleDept.dept_id).where(SysRoleDept.role_id.in_(custom_data_scope_role_id_list))
)
if hasattr(self.query_alias, self.dept_alias)
else False
)
else:
param_sql_list.append(
f"{self.query_alias}.{self.dept_alias}.in_(select(SysRoleDept.dept_id).where(SysRoleDept.role_id == {role.role_id})) if hasattr({self.query_alias}, '{self.dept_alias}') else 1 == 0"
getattr(self.query_alias, self.dept_alias).in_(
select(SysRoleDept.dept_id).where(SysRoleDept.role_id == role.role_id)
)
if hasattr(self.query_alias, self.dept_alias)
else False
)
elif role.data_scope == self.DATA_SCOPE_DEPT:
param_sql_list.append(
f"{self.query_alias}.{self.dept_alias} == {dept_id} if hasattr({self.query_alias}, '{self.dept_alias}') else 1 == 0"
getattr(self.query_alias, self.dept_alias) == dept_id
if hasattr(self.query_alias, self.dept_alias)
else False
)
elif role.data_scope == self.DATA_SCOPE_DEPT_AND_CHILD:
param_sql_list.append(
f"{self.query_alias}.{self.dept_alias}.in_(select(SysDept.dept_id).where(or_(SysDept.dept_id == {dept_id}, func.find_in_set({dept_id}, SysDept.ancestors)))) if hasattr({self.query_alias}, '{self.dept_alias}') else 1 == 0"
getattr(self.query_alias, self.dept_alias).in_(
select(SysDept.dept_id).where(
or_(SysDept.dept_id == dept_id, func.find_in_set(dept_id, SysDept.ancestors))
)
)
if hasattr(self.query_alias, self.dept_alias)
else False
)
elif role.data_scope == self.DATA_SCOPE_SELF:
param_sql_list.append(
f"{self.query_alias}.{self.user_alias} == {user_id} if hasattr({self.query_alias}, '{self.user_alias}') else 1 == 0"
getattr(self.query_alias, self.user_alias) == user_id
if hasattr(self.query_alias, self.user_alias)
else False
)
else:
param_sql_list.append('1 == 0')
param_sql_list.append(False)
param_sql_list = list(dict.fromkeys(param_sql_list))
param_sql = f'or_({", ".join(param_sql_list)})'
param_sql = or_(*param_sql_list)
return param_sql
def DataScopeDependency( # noqa: N802
query_alias: Optional[str] = '',
db_alias: Optional[str] = 'db',
query_alias: Base,
user_alias: Optional[str] = 'user_id',
dept_alias: Optional[str] = 'dept_id',
) -> params.Depends:
"""
当前用户数据权限依赖
:param query_alias: 所要查询表对应的sqlalchemy模型名称,默认为''
:param db_alias: orm对象别名,默认为'db'
:param query_alias: 所要查询表对应的sqlalchemy模型类,不可为空
:param user_alias: 用户id字段别名,默认为'user_id'
:param dept_alias: 部门id字段别名,默认为'dept_id'
:return: 当前用户数据权限依赖
"""
return Depends(GetDataScope(query_alias, db_alias, user_alias, dept_alias))
return Depends(GetDataScope(query_alias, user_alias, dept_alias))
+18 -5
View File
@@ -1,7 +1,8 @@
import importlib
import json
from asyncio import iscoroutinefunction
from datetime import datetime, timedelta
from typing import Optional, Union
from typing import Any, Callable, Optional, Union
from apscheduler.events import EVENT_ALL, SchedulerEvent
from apscheduler.executors.asyncio import AsyncIOExecutor
@@ -152,6 +153,18 @@ class SchedulerUtil:
scheduler.shutdown()
logger.info('✅️ 关闭定时任务成功')
@classmethod
def _import_function(cls, func_path: str) -> Callable[..., Any]:
"""
动态导入函数
:param func_path: 函数字符串,如module_task.scheduler_test.job
:return: 导入的函数对象
"""
module_path, func_name = func_path.rsplit('.', 1)
module = importlib.import_module(module_path)
return getattr(module, func_name)
@classmethod
def get_scheduler_job(cls, job_id: Union[str, int]) -> Job:
"""
@@ -172,12 +185,12 @@ class SchedulerUtil:
:param job_info: 任务对象信息
:return:
"""
job_func = eval(job_info.invoke_target)
job_func = cls._import_function(job_info.invoke_target)
job_executor = job_info.job_executor
if iscoroutinefunction(job_func):
job_executor = 'default'
scheduler.add_job(
func=eval(job_info.invoke_target),
func=job_func,
trigger=MyCronTrigger.from_crontab(job_info.cron_expression),
args=job_info.job_args.split(',') if job_info.job_args else None,
kwargs=json.loads(job_info.job_kwargs) if job_info.job_kwargs else None,
@@ -198,7 +211,7 @@ class SchedulerUtil:
:param job_info: 任务对象信息
:return:
"""
job_func = eval(job_info.invoke_target)
job_func = cls._import_function(job_info.invoke_target)
job_executor = job_info.job_executor
if iscoroutinefunction(job_func):
job_executor = 'default'
@@ -206,7 +219,7 @@ class SchedulerUtil:
if job_info.status == '0':
job_trigger = OrTrigger(triggers=[DateTrigger(), MyCronTrigger.from_crontab(job_info.cron_expression)])
scheduler.add_job(
func=eval(job_info.invoke_target),
func=job_func,
trigger=job_trigger,
args=job_info.job_args.split(',') if job_info.job_args else None,
kwargs=json.loads(job_info.job_kwargs) if job_info.job_kwargs else None,
@@ -3,6 +3,7 @@ from typing import Annotated
from fastapi import Path, Query, Request, Response
from pydantic_validation_decorator import ValidateFields
from sqlalchemy import ColumnElement
from sqlalchemy.ext.asyncio import AsyncSession
from common.annotation.log_annotation import Log
@@ -13,6 +14,7 @@ from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
from common.enums import BusinessType
from common.router import APIRouterPro
from common.vo import DataResponseModel, ResponseBaseModel
from module_admin.entity.do.dept_do import SysDept
from module_admin.entity.vo.dept_vo import DeleteDeptModel, DeptModel, DeptQueryModel
from module_admin.entity.vo.user_vo import CurrentUserModel
from module_admin.service.dept_service import DeptService
@@ -35,7 +37,7 @@ async def get_system_dept_tree_for_edit_option(
request: Request,
dept_id: Annotated[int, Path(description='部门id')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
data_scope_sql: Annotated[str, DataScopeDependency('SysDept')],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)],
) -> Response:
dept_query = DeptModel(deptId=dept_id)
dept_query_result = await DeptService.get_dept_for_edit_option_services(query_db, dept_query, data_scope_sql)
@@ -55,7 +57,7 @@ async def get_system_dept_list(
request: Request,
dept_query: Annotated[DeptQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
data_scope_sql: Annotated[str, DataScopeDependency('SysDept')],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)],
) -> Response:
dept_query_result = await DeptService.get_dept_list_services(query_db, dept_query, data_scope_sql)
logger.info('获取成功')
@@ -102,7 +104,7 @@ async def edit_system_dept(
edit_dept: DeptModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
data_scope_sql: Annotated[str, DataScopeDependency('SysDept')],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)],
) -> Response:
if not current_user.user.admin:
await DeptService.check_dept_data_scope_services(query_db, edit_dept.dept_id, data_scope_sql)
@@ -127,7 +129,7 @@ async def delete_system_dept(
dept_ids: Annotated[str, Path(description='需要删除的部门id')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
data_scope_sql: Annotated[str, DataScopeDependency('SysDept')],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)],
) -> Response:
dept_id_list = dept_ids.split(',') if dept_ids else []
if dept_id_list:
@@ -155,7 +157,7 @@ async def query_detail_system_dept(
dept_id: Annotated[int, Path(description='部门id')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
data_scope_sql: Annotated[str, DataScopeDependency('SysDept')],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)],
) -> Response:
if not current_user.user.admin:
await DeptService.check_dept_data_scope_services(query_db, dept_id, data_scope_sql)
@@ -4,6 +4,7 @@ from typing import Annotated
from fastapi import Form, Path, Query, Request, Response
from fastapi.responses import StreamingResponse
from pydantic_validation_decorator import ValidateFields
from sqlalchemy import ColumnElement
from sqlalchemy.ext.asyncio import AsyncSession
from common.annotation.log_annotation import Log
@@ -14,6 +15,8 @@ from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
from common.enums import BusinessType
from common.router import APIRouterPro
from common.vo import DataResponseModel, DynamicResponseModel, PageResponseModel, ResponseBaseModel
from module_admin.entity.do.dept_do import SysDept
from module_admin.entity.do.user_do import SysUser
from module_admin.entity.vo.dept_vo import DeptModel
from module_admin.entity.vo.role_vo import (
AddRoleModel,
@@ -46,7 +49,7 @@ async def get_system_role_dept_tree(
request: Request,
role_id: Annotated[int, Path(description='角色ID')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
data_scope_sql: Annotated[str, DataScopeDependency('SysDept')],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)],
) -> Response:
dept_query_result = await DeptService.get_dept_tree_services(query_db, DeptModel(), data_scope_sql)
role_dept_query_result = await RoleService.get_role_dept_tree_services(query_db, role_id)
@@ -67,7 +70,7 @@ async def get_system_role_list(
request: Request,
role_page_query: Annotated[RolePageQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
data_scope_sql: Annotated[str, DataScopeDependency('SysDept')],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)],
) -> Response:
role_page_query_result = await RoleService.get_role_list_services(
query_db, role_page_query, data_scope_sql, is_page=True
@@ -116,7 +119,7 @@ async def edit_system_role(
edit_role: AddRoleModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
data_scope_sql: Annotated[str, DataScopeDependency('SysDept')],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)],
) -> Response:
await RoleService.check_role_allowed_services(edit_role)
if not current_user.user.admin:
@@ -142,7 +145,7 @@ async def edit_system_role_datascope(
role_data_scope: AddRoleModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
data_scope_sql: Annotated[str, DataScopeDependency('SysDept')],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)],
) -> Response:
await RoleService.check_role_allowed_services(role_data_scope)
if not current_user.user.admin:
@@ -174,7 +177,7 @@ async def delete_system_role(
role_ids: Annotated[str, Path(description='需要删除的角色ID')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
data_scope_sql: Annotated[str, DataScopeDependency('SysDept')],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)],
) -> Response:
role_id_list = role_ids.split(',') if role_ids else []
if role_id_list:
@@ -201,7 +204,7 @@ async def query_detail_system_role(
role_id: Annotated[int, Path(description='角色ID')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
data_scope_sql: Annotated[str, DataScopeDependency('SysDept')],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)],
) -> Response:
if not current_user.user.admin:
await RoleService.check_role_data_scope_services(query_db, str(role_id), data_scope_sql)
@@ -231,7 +234,7 @@ async def export_system_role_list(
request: Request,
role_page_query: Annotated[RolePageQueryModel, Form()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
data_scope_sql: Annotated[str, DataScopeDependency('SysDept')],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)],
) -> Response:
# 获取全量数据
role_query_result = await RoleService.get_role_list_services(
@@ -256,7 +259,7 @@ async def reset_system_role_status(
change_role: AddRoleModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
data_scope_sql: Annotated[str, DataScopeDependency('SysDept')],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)],
) -> Response:
await RoleService.check_role_allowed_services(change_role)
if not current_user.user.admin:
@@ -285,7 +288,7 @@ async def get_system_allocated_user_list(
request: Request,
user_role: Annotated[UserRolePageQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
data_scope_sql: Annotated[str, DataScopeDependency('SysUser')],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysUser)],
) -> Response:
role_user_allocated_page_query_result = await RoleService.get_role_user_allocated_list_services(
query_db, user_role, data_scope_sql, is_page=True
@@ -306,7 +309,7 @@ async def get_system_unallocated_user_list(
request: Request,
user_role: Annotated[UserRolePageQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
data_scope_sql: Annotated[str, DataScopeDependency('SysUser')],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysUser)],
) -> Response:
role_user_unallocated_page_query_result = await RoleService.get_role_user_unallocated_list_services(
query_db, user_role, data_scope_sql, is_page=True
@@ -329,7 +332,7 @@ async def add_system_role_user(
add_role_user: Annotated[CrudUserRoleModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
data_scope_sql: Annotated[str, DataScopeDependency('SysDept')],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)],
) -> Response:
if not current_user.user.admin:
await RoleService.check_role_data_scope_services(query_db, str(add_role_user.role_id), data_scope_sql)
@@ -6,6 +6,7 @@ import aiofiles
from fastapi import File, Form, Path, Query, Request, Response, UploadFile
from fastapi.responses import StreamingResponse
from pydantic_validation_decorator import ValidateFields
from sqlalchemy import ColumnElement
from sqlalchemy.ext.asyncio import AsyncSession
from common.annotation.log_annotation import Log
@@ -17,6 +18,8 @@ from common.enums import BusinessType
from common.router import APIRouterPro
from common.vo import DataResponseModel, DynamicResponseModel, PageResponseModel, ResponseBaseModel
from config.env import UploadConfig
from module_admin.entity.do.dept_do import SysDept
from module_admin.entity.do.user_do import SysUser
from module_admin.entity.vo.dept_vo import DeptModel, DeptTreeModel
from module_admin.entity.vo.user_vo import (
AddUserModel,
@@ -60,7 +63,7 @@ user_controller = APIRouterPro(
async def get_system_dept_tree(
request: Request,
query_db: Annotated[AsyncSession, DBSessionDependency()],
data_scope_sql: Annotated[str, DataScopeDependency('SysDept')],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)],
) -> Response:
dept_query_result = await DeptService.get_dept_tree_services(query_db, DeptModel(), data_scope_sql)
logger.info('获取成功')
@@ -79,7 +82,7 @@ async def get_system_user_list(
request: Request,
user_page_query: Annotated[UserPageQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
data_scope_sql: Annotated[str, DataScopeDependency('SysUser')],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysUser)],
) -> Response:
# 获取分页数据
user_page_query_result = await UserService.get_user_list_services(
@@ -104,8 +107,8 @@ async def add_system_user(
add_user: AddUserModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
dept_data_scope_sql: Annotated[str, DataScopeDependency('SysDept')],
role_data_scope_sql: Annotated[str, DataScopeDependency('SysDept')],
dept_data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)],
role_data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)],
) -> Response:
if not current_user.user.admin:
await DeptService.check_dept_data_scope_services(query_db, add_user.dept_id, dept_data_scope_sql)
@@ -137,9 +140,9 @@ async def edit_system_user(
edit_user: EditUserModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
user_data_scope_sql: Annotated[str, DataScopeDependency('SysUser')],
dept_data_scope_sql: Annotated[str, DataScopeDependency('SysDept')],
role_data_scope_sql: Annotated[str, DataScopeDependency('SysDept')],
user_data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysUser)],
dept_data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)],
role_data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)],
) -> Response:
await UserService.check_user_allowed_services(edit_user)
if not current_user.user.admin:
@@ -169,7 +172,7 @@ async def delete_system_user(
user_ids: Annotated[str, Path(description='需要删除的用户ID')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
data_scope_sql: Annotated[str, DataScopeDependency('SysUser')],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysUser)],
) -> Response:
user_id_list = user_ids.split(',') if user_ids else []
if user_id_list:
@@ -201,7 +204,7 @@ async def reset_system_user_pwd(
reset_user: EditUserModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
data_scope_sql: Annotated[str, DataScopeDependency('SysUser')],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysUser)],
) -> Response:
await UserService.check_user_allowed_services(reset_user)
if not current_user.user.admin:
@@ -233,7 +236,7 @@ async def change_system_user_status(
change_user: EditUserModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
data_scope_sql: Annotated[str, DataScopeDependency('SysUser')],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysUser)],
) -> Response:
await UserService.check_user_allowed_services(change_user)
if not current_user.user.admin:
@@ -286,7 +289,7 @@ async def query_detail_system_user(
request: Request,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
data_scope_sql: Annotated[str, DataScopeDependency('SysUser')],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysUser)],
user_id: Optional[Union[int, Literal['']]] = '',
) -> Response:
if user_id and not current_user.user.admin:
@@ -407,8 +410,8 @@ async def batch_import_system_user(
update_support: Annotated[bool, Query(alias='updateSupport')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
user_data_scope_sql: Annotated[str, DataScopeDependency('SysUser')],
dept_data_scope_sql: Annotated[str, DataScopeDependency('SysDept')],
user_data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysUser)],
dept_data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)],
) -> Response:
batch_import_result = await UserService.batch_import_user_services(
request, query_db, file, update_support, current_user, user_data_scope_sql, dept_data_scope_sql
@@ -462,7 +465,7 @@ async def export_system_user_list(
request: Request,
user_page_query: Annotated[UserPageQueryModel, Form()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
data_scope_sql: Annotated[str, DataScopeDependency('SysUser')],
data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysUser)],
) -> Response:
# 获取全量数据
user_query_result = await UserService.get_user_list_services(
@@ -509,8 +512,8 @@ async def update_system_role_user(
role_ids: Annotated[str, Query(alias='roleIds')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
user_data_scope_sql: Annotated[str, DataScopeDependency('SysUser')],
role_data_scope_sql: Annotated[str, DataScopeDependency('SysDept')],
user_data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysUser)],
role_data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)],
) -> Response:
if not current_user.user.admin:
await UserService.check_user_data_scope_services(query_db, user_id, user_data_scope_sql)
@@ -1,12 +1,11 @@
from collections.abc import Sequence
from typing import Union
from sqlalchemy import bindparam, func, or_, select, update # noqa: F401
from sqlalchemy import ColumnElement, bindparam, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.util import immutabledict
from module_admin.entity.do.dept_do import SysDept
from module_admin.entity.do.role_do import SysRoleDept # noqa: F401
from module_admin.entity.do.user_do import SysUser
from module_admin.entity.vo.dept_vo import DeptModel
@@ -61,6 +60,7 @@ class DeptDao:
select(SysDept).where(
SysDept.parent_id == dept.parent_id if dept.parent_id else True,
SysDept.dept_name == dept.dept_name if dept.dept_name else True,
SysDept.del_flag == '0',
)
)
)
@@ -72,7 +72,7 @@ class DeptDao:
@classmethod
async def get_dept_info_for_edit_option(
cls, db: AsyncSession, dept_info: DeptModel, data_scope_sql: str
cls, db: AsyncSession, dept_info: DeptModel, data_scope_sql: ColumnElement
) -> Sequence[SysDept]:
"""
获取部门编辑对应的在用部门列表信息
@@ -93,7 +93,7 @@ class DeptDao:
),
SysDept.del_flag == '0',
SysDept.status == '0',
eval(data_scope_sql),
data_scope_sql,
)
.order_by(SysDept.order_num)
.distinct()
@@ -122,7 +122,7 @@ class DeptDao:
@classmethod
async def get_dept_list_for_tree(
cls, db: AsyncSession, dept_info: DeptModel, data_scope_sql: str
cls, db: AsyncSession, dept_info: DeptModel, data_scope_sql: ColumnElement
) -> Sequence[SysDept]:
"""
获取所有在用部门列表信息
@@ -140,7 +140,7 @@ class DeptDao:
SysDept.status == '0',
SysDept.del_flag == '0',
SysDept.dept_name.like(f'%{dept_info.dept_name}%') if dept_info.dept_name else True,
eval(data_scope_sql),
data_scope_sql,
)
.order_by(SysDept.order_num)
.distinct()
@@ -153,7 +153,9 @@ class DeptDao:
return dept_result
@classmethod
async def get_dept_list(cls, db: AsyncSession, page_object: DeptModel, data_scope_sql: str) -> Sequence[SysDept]:
async def get_dept_list(
cls, db: AsyncSession, page_object: DeptModel, data_scope_sql: ColumnElement
) -> Sequence[SysDept]:
"""
根据查询参数获取部门列表信息
@@ -171,7 +173,7 @@ class DeptDao:
SysDept.dept_id == page_object.dept_id if page_object.dept_id is not None else True,
SysDept.status == page_object.status if page_object.status else True,
SysDept.dept_name.like(f'%{page_object.dept_name}%') if page_object.dept_name else True,
eval(data_scope_sql),
data_scope_sql,
)
.order_by(SysDept.order_num)
.distinct()
@@ -2,7 +2,7 @@ from collections.abc import Sequence
from datetime import datetime, time
from typing import Any, Union
from sqlalchemy import and_, delete, desc, func, or_, select, update # noqa: F401
from sqlalchemy import ColumnElement, and_, delete, desc, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.vo import PageModel
@@ -131,7 +131,7 @@ class RoleDao:
@classmethod
async def get_role_list(
cls, db: AsyncSession, query_object: RolePageQueryModel, data_scope_sql: str, is_page: bool = False
cls, db: AsyncSession, query_object: RolePageQueryModel, data_scope_sql: ColumnElement, is_page: bool = False
) -> Union[PageModel, list[dict[str, Any]]]:
"""
根据查询参数获取角色列表信息
@@ -159,7 +159,7 @@ class RoleDao:
)
if query_object.begin_time and query_object.end_time
else True,
eval(data_scope_sql),
data_scope_sql,
)
.order_by(SysRole.role_sort)
.distinct()
@@ -2,14 +2,14 @@ from collections.abc import Sequence
from datetime import datetime, time
from typing import Any, Union
from sqlalchemy import and_, delete, desc, func, or_, select, update
from sqlalchemy import ColumnElement, and_, delete, desc, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.vo import PageModel
from module_admin.entity.do.dept_do import SysDept
from module_admin.entity.do.menu_do import SysMenu
from module_admin.entity.do.post_do import SysPost
from module_admin.entity.do.role_do import SysRole, SysRoleDept, SysRoleMenu # noqa: F401
from module_admin.entity.do.role_do import SysRole, SysRoleMenu
from module_admin.entity.do.user_do import SysUser, SysUserPost, SysUserRole
from module_admin.entity.vo.user_vo import (
UserModel,
@@ -280,7 +280,7 @@ class UserDao:
@classmethod
async def get_user_list(
cls, db: AsyncSession, query_object: UserPageQueryModel, data_scope_sql: str, is_page: bool = False
cls, db: AsyncSession, query_object: UserPageQueryModel, data_scope_sql: ColumnElement, is_page: bool = False
) -> Union[PageModel, list[list[dict[str, Any]]]]:
"""
根据查询参数获取用户列表信息
@@ -316,7 +316,7 @@ class UserDao:
)
if query_object.begin_time and query_object.end_time
else True,
eval(data_scope_sql),
data_scope_sql,
)
.join(
SysDept,
@@ -408,7 +408,11 @@ class UserDao:
@classmethod
async def get_user_role_allocated_list_by_role_id(
cls, db: AsyncSession, query_object: UserRolePageQueryModel, data_scope_sql: str, is_page: bool = False
cls,
db: AsyncSession,
query_object: UserRolePageQueryModel,
data_scope_sql: ColumnElement,
is_page: bool = False,
) -> Union[PageModel, list[dict[str, Any]]]:
"""
根据角色id获取已分配的用户列表信息
@@ -429,7 +433,7 @@ class UserDao:
SysUser.user_name == query_object.user_name if query_object.user_name else True,
SysUser.phonenumber == query_object.phonenumber if query_object.phonenumber else True,
SysRole.role_id == query_object.role_id,
eval(data_scope_sql),
data_scope_sql,
)
.distinct()
)
@@ -441,7 +445,11 @@ class UserDao:
@classmethod
async def get_user_role_unallocated_list_by_role_id(
cls, db: AsyncSession, query_object: UserRolePageQueryModel, data_scope_sql: str, is_page: bool = False
cls,
db: AsyncSession,
query_object: UserRolePageQueryModel,
data_scope_sql: ColumnElement,
is_page: bool = False,
) -> Union[PageModel, list[dict[str, Any]]]:
"""
根据角色id获取未分配的用户列表信息
@@ -470,7 +478,7 @@ class UserDao:
and_(SysUserRole.user_id == SysUser.user_id, SysUserRole.role_id == query_object.role_id),
)
),
eval(data_scope_sql),
data_scope_sql,
)
.distinct()
)
@@ -1,6 +1,7 @@
from collections.abc import Sequence
from typing import Any
from sqlalchemy import ColumnElement
from sqlalchemy.ext.asyncio import AsyncSession
from common.constant import CommonConstant
@@ -19,7 +20,7 @@ class DeptService:
@classmethod
async def get_dept_tree_services(
cls, query_db: AsyncSession, page_object: DeptModel, data_scope_sql: str
cls, query_db: AsyncSession, page_object: DeptModel, data_scope_sql: ColumnElement
) -> list[dict[str, Any]]:
"""
获取部门树信息service
@@ -37,7 +38,7 @@ class DeptService:
@classmethod
async def get_dept_for_edit_option_services(
cls, query_db: AsyncSession, page_object: DeptModel, data_scope_sql: str
cls, query_db: AsyncSession, page_object: DeptModel, data_scope_sql: ColumnElement
) -> list[dict[str, Any]]:
"""
获取部门编辑部门树信息service
@@ -53,7 +54,7 @@ class DeptService:
@classmethod
async def get_dept_list_services(
cls, query_db: AsyncSession, page_object: DeptModel, data_scope_sql: str
cls, query_db: AsyncSession, page_object: DeptModel, data_scope_sql: ColumnElement
) -> list[dict[str, Any]]:
"""
获取部门列表信息service
@@ -69,7 +70,7 @@ class DeptService:
@classmethod
async def check_dept_data_scope_services(
cls, query_db: AsyncSession, dept_id: int, data_scope_sql: str
cls, query_db: AsyncSession, dept_id: int, data_scope_sql: ColumnElement
) -> CrudResponseModel:
"""
校验部门是否有数据权限service
@@ -1,5 +1,6 @@
from typing import Any, Union
from sqlalchemy import ColumnElement
from sqlalchemy.ext.asyncio import AsyncSession
from common.constant import CommonConstant
@@ -56,7 +57,11 @@ class RoleService:
@classmethod
async def get_role_list_services(
cls, query_db: AsyncSession, query_object: RolePageQueryModel, data_scope_sql: str, is_page: bool = False
cls,
query_db: AsyncSession,
query_object: RolePageQueryModel,
data_scope_sql: ColumnElement,
is_page: bool = False,
) -> Union[PageModel, list[dict[str, Any]]]:
"""
获取角色列表信息service
@@ -84,7 +89,9 @@ class RoleService:
return CrudResponseModel(is_success=True, message='校验通过')
@classmethod
async def check_role_data_scope_services(cls, query_db: AsyncSession, role_ids: str, data_scope_sql: str) -> None:
async def check_role_data_scope_services(
cls, query_db: AsyncSession, role_ids: str, data_scope_sql: ColumnElement
) -> None:
"""
校验角色是否有数据权限service
@@ -304,7 +311,11 @@ class RoleService:
@classmethod
async def get_role_user_allocated_list_services(
cls, query_db: AsyncSession, page_object: UserRolePageQueryModel, data_scope_sql: str, is_page: bool = False
cls,
query_db: AsyncSession,
page_object: UserRolePageQueryModel,
data_scope_sql: ColumnElement,
is_page: bool = False,
) -> PageModel:
"""
根据角色id获取已分配用户列表
@@ -329,7 +340,11 @@ class RoleService:
@classmethod
async def get_role_user_unallocated_list_services(
cls, query_db: AsyncSession, page_object: UserRolePageQueryModel, data_scope_sql: str, is_page: bool = False
cls,
query_db: AsyncSession,
page_object: UserRolePageQueryModel,
data_scope_sql: ColumnElement,
is_page: bool = False,
) -> PageModel:
"""
根据角色id获取未分配用户列表
@@ -4,6 +4,7 @@ from typing import Any, Union
import pandas as pd
from fastapi import Request, UploadFile
from sqlalchemy import ColumnElement
from sqlalchemy.ext.asyncio import AsyncSession
from common.constant import CommonConstant
@@ -47,7 +48,11 @@ class UserService:
@classmethod
async def get_user_list_services(
cls, query_db: AsyncSession, query_object: UserPageQueryModel, data_scope_sql: str, is_page: bool = False
cls,
query_db: AsyncSession,
query_object: UserPageQueryModel,
data_scope_sql: ColumnElement,
is_page: bool = False,
) -> Union[PageModel[UserRowModel], list[dict[str, Any]]]:
"""
获取用户列表信息service
@@ -87,7 +92,7 @@ class UserService:
@classmethod
async def check_user_data_scope_services(
cls, query_db: AsyncSession, user_id: int, data_scope_sql: str
cls, query_db: AsyncSession, user_id: int, data_scope_sql: ColumnElement
) -> CrudResponseModel:
"""
校验用户数据权限service
@@ -392,8 +397,8 @@ class UserService:
file: UploadFile,
update_support: bool,
current_user: CurrentUserModel,
user_data_scope_sql: str,
dept_data_scope_sql: str,
user_data_scope_sql: ColumnElement,
dept_data_scope_sql: ColumnElement,
) -> CrudResponseModel:
"""
批量导入用户service
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "vfadmin",
"version": "1.8.0",
"version": "1.8.1",
"description": "vfadmin管理系统",
"author": "insistence",
"license": "MIT",
@@ -76,7 +76,7 @@ function handleArray(array) {
}
function isValueMatch(itemValue) {
return this.values.some(val => val == itemValue)
return values.value.some(val => val == itemValue)
}
</script>
+115
View File
@@ -0,0 +1,115 @@
# RuoYi-Vue3-FastAPI 项目测试套件
这是一个为 RuoYi-Vue3-FastAPI 项目创建的完整测试套件,使用 Playwright 进行端到端测试。测试环境已禁用验证码功能,以简化测试流程。
## 功能特性
- 使用默认方式手动启动前后端服务或`Docker Compose`自动启动项目前后端服务
- 测试环境已禁用验证码功能
- 验证登录流程和认证机制
- 测试所有受保护的页面功能
- 验证未登录用户访问受保护页面时的重定向行为
## 依赖安装
```bash
pip install -r requirements.txt
playwright install
```
## 使用方法
### 方式一:默认方法
#### 启动前端
```bash
cd ruoyi-fastapi-frontend
npm install
npm run dev
```
#### 启动后端
```bash
cd ruoyi-fastapi-backend
pip install -r requirements.txt
python app.py --env=dev
```
#### 运行测试
```bash
cd ruoyi-fastapi-test
pip install -r requirements.txt
python -m pytest -v
```
### 方式二:使用Docker
#### 进入测试目录
```bash
cd ruoyi-fastapi-test
```
#### 启动 Docker 服务
```bash
# MySQL版本
docker compose -f docker-compose.test.my.yml up -d --build
# PostgreSQL版本
docker compose -f docker-compose.test.pg.yml up -d --build
```
#### 运行测试
```bash
pip install -r requirements.txt
python -m pytest -v
```
## 测试内容
### 登录测试
- 验证登录页面正常加载
- 验证登录流程(测试环境已禁用验证码)
- 测试认证后的页面访问
### 页面访问和功能测试
- 仪表盘页面
- 用户管理页面
- 角色管理页面
- 菜单管理页面
- 部门管理页面
- 岗位管理页面
- 字典管理页面
- 参数配置页面
- 通知公告页面
- 日志管理页面(操作日志、登录日志)
- 在线用户页面
- 定时任务页面
- 服务监控页面
- 数据监控页面
- 缓存监控页面
- 缓存列表页面
- 代码生成页面
- 系统接口页面
### 认证测试
- 验证未登录用户访问受保护页面时被重定向到登录页
- 验证登录后可以访问受保护页面
## 配置说明
使用 `docker-compose.test.my.yml``docker-compose.test.pg.yml`启动服务,默认前端端口为 `80`,后端端口为 `9099`。测试环境已禁用验证码功能。
## 注意事项
1. 确保系统已安装 Docker 和 Docker Compose
2. 确保端口 `80``9099` 未被占用
3. 首次运行时 Docker 镜像构建可能需要几分钟时间
4. 测试使用默认管理员账户:用户名 `admin`,密码 `admin123`
@@ -0,0 +1,87 @@
import re
from playwright.async_api._context_manager import PlaywrightContextManager
from common.login_helper import LoginHelper
class BasePageTest:
browser = None
context = None
page = None
token = None
async def setup(self, playwright: PlaywrightContextManager) -> None:
"""初始化浏览器和登录"""
# 首先登录获取token
helper = LoginHelper()
self.token = helper.login(username='admin', password='admin123')
assert self.token is not None, '登录应该成功'
# 启动浏览器
self.browser = await playwright.chromium.launch(headless=True)
self.context = await self.browser.new_context()
# 设置认证token
await self.context.add_cookies(
[
{
'name': 'Admin-Token',
'value': self.token,
'domain': 'localhost',
'path': '/',
'httpOnly': False,
'secure': False,
}
]
)
self.page = await self.context.new_page()
async def teardown(self) -> None:
"""清理资源"""
if self.context:
await self.context.close()
if self.browser:
await self.browser.close()
async def goto_page(self, url: str) -> None:
"""访问指定页面"""
await self.page.goto(url)
async def wait_for_page_title(self, title_text: str, timeout: int = 10000) -> None:
"""等待页面标题出现"""
await self.page.wait_for_selector(f'div:has-text("{title_text}")', timeout=timeout)
page_title = await self.page.inner_text(f'div:has-text("{title_text}")')
assert title_text in page_title
async def wait_for_selector(self, selector: str, timeout: int = 10000) -> None:
"""等待选择器出现"""
await self.page.wait_for_selector(selector, timeout=timeout)
async def query_selector(self, selector: str) -> any:
"""查询选择器元素"""
return await self.page.query_selector(selector)
async def click_button(self, button_text: str) -> None:
"""点击按钮"""
button = await self.page.wait_for_selector(f'text="{button_text}"', timeout=5000)
await button.click()
async def fill_input(self, selector: str, value: str) -> None:
"""填充输入框"""
await self.page.fill(selector, value)
async def get_text_content(self, selector: str) -> str:
"""获取元素文本内容"""
element = await self.page.query_selector(selector)
if element:
return await element.text_content()
return None
async def get_table_total_rows(self) -> int:
"""获取表格总行数"""
element = self.page.locator('span.el-pagination__total').first
text = await element.text_content()
# 提取数字
number = re.search(r'\d+', text).group()
return int(number)
+3
View File
@@ -0,0 +1,3 @@
class Config:
frontend_url = 'http://localhost:80'
backend_url = 'http://localhost:9099'
+59
View File
@@ -0,0 +1,59 @@
from typing import Union
import requests
from common.config import Config
class LoginHelper:
"""
登录辅助类
"""
def __init__(self, base_url: str = Config.frontend_url) -> None:
self.base_url = base_url
self.session = requests.Session()
def login(self, username: str = 'admin', password: str = 'admin123', max_retries: int = 3) -> Union[str, None]:
"""
执行登录操作在测试环境中验证码已禁用
"""
for _attempt in range(max_retries):
# 在测试环境中,验证码已禁用,所以直接登录
login_data = {'username': username, 'password': password}
headers = {'Content-Type': 'application/x-www-form-urlencoded', 'Referer': f'{self.base_url}/login'}
response = self.session.post(f'{Config.backend_url}/login', data=login_data, headers=headers)
http_ok = 200
if response.status_code == http_ok:
result = response.json()
# 检查登录是否成功 - 可能是 code=200 或 success=true
if result.get('code') == http_ok or result.get('success'):
# 尝试从不同可能的位置获取token
token = result.get('token') # 直接在根级别
if not token:
token = result.get('data', {}).get('token') # 在data对象内
if token:
print('登录成功')
return token
print(f'登录失败: 响应中未找到token - {result}')
else:
print(f'登录失败: {result.get("msg", "未知错误")}')
else:
print(f'登录请求失败: 状态码 {response.status_code}')
print('登录失败,已达到最大重试次数')
return None
# 使用示例
if __name__ == '__main__':
helper = LoginHelper()
token = helper.login()
if token:
print(f'获取到token: {token}')
else:
print('登录失败')
+4
View File
@@ -0,0 +1,4 @@
-- 用于测试环境的SQL脚本,禁用验证码功能
-- 更新验证码配置为禁用状态
UPDATE sys_config SET config_value = 'false' WHERE config_key = 'sys.account.captchaEnabled';
@@ -0,0 +1,74 @@
services:
# 前端服务
ruoyi-frontend:
build:
context: ../ruoyi-fastapi-frontend
dockerfile: Dockerfile
image: ruoyi-frontend:latest
container_name: ruoyi-frontend-test
ports:
- "80:80"
volumes:
- ../ruoyi-fastapi-frontend/bin/nginx.dockermy.conf:/etc/nginx/conf.d/default.conf
depends_on:
- ruoyi-backend-my
networks:
- ruoyi-network-test
# 后端服务(MySQL版本)
ruoyi-backend-my:
build:
context: ../ruoyi-fastapi-backend
dockerfile: Dockerfile.my
image: ruoyi-backend-my:latest
container_name: ruoyi-backend-my-test
ports:
- "9099:9099"
depends_on:
ruoyi-mysql:
condition: service_healthy
ruoyi-redis:
condition: service_healthy
networks:
- ruoyi-network-test
# MySQL服务
ruoyi-mysql:
image: mysql:8.0
container_name: ruoyi-mysql-test
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: ruoyi-fastapi
ports:
- "13307:3306"
volumes:
- ../ruoyi-fastapi-backend/sql/ruoyi-fastapi.sql:/docker-entrypoint-initdb.d/01-ruoyi-fastapi.sql
- ./disable_captcha.sql:/docker-entrypoint-initdb.d/02-disable-captcha.sql
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci --skip-character-set-client-handshake=1
networks:
- ruoyi-network-test
healthcheck:
test: ["CMD", "bash", "-c", "mysqladmin ping -h localhost -u root -proot && mysql -h localhost -u root -proot -e 'SELECT 1'"]
interval: 5s
timeout: 10s
retries: 30
# Redis服务
ruoyi-redis:
image: redis:latest
container_name: ruoyi-redis-test
ports:
- "16380:6379"
networks:
- ruoyi-network-test
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 10s
retries: 30
# 网络配置
networks:
ruoyi-network-test:
name: ruoyi-network-test
driver: bridge
@@ -0,0 +1,74 @@
services:
# 前端服务
ruoyi-frontend:
build:
context: ../ruoyi-fastapi-frontend
dockerfile: Dockerfile
image: ruoyi-frontend:latest
container_name: ruoyi-frontend-test
ports:
- "80:80"
volumes:
- ../ruoyi-fastapi-frontend/bin/nginx.dockerpg.conf:/etc/nginx/conf.d/default.conf
depends_on:
- ruoyi-backend-pg
networks:
- ruoyi-network-test
# 后端服务(PostgreSQL版本)
ruoyi-backend-pg:
build:
context: ../ruoyi-fastapi-backend
dockerfile: Dockerfile.pg
image: ruoyi-backend-pg:latest
container_name: ruoyi-backend-pg-test
ports:
- "9099:9099"
depends_on:
ruoyi-pg:
condition: service_healthy
ruoyi-redis:
condition: service_healthy
networks:
- ruoyi-network-test
# PostgreSQL服务
ruoyi-pg:
image: postgres:14
container_name: ruoyi-pg-test
environment:
POSTGRES_PASSWORD: root
POSTGRES_DB: ruoyi-fastapi
POSTGRES_INITDB_ARGS: --encoding=UTF8 --lc-collate=C --lc-ctype=C
ports:
- "15433:5432"
volumes:
- ../ruoyi-fastapi-backend/sql/ruoyi-fastapi-pg.sql:/docker-entrypoint-initdb.d/01-ruoyi-fastapi-pg.sql
- ./disable_captcha.sql:/docker-entrypoint-initdb.d/02-disable-captcha.sql
networks:
- ruoyi-network-test
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres"]
interval: 5s
timeout: 10s
retries: 30
# Redis服务
ruoyi-redis:
image: redis:latest
container_name: ruoyi-redis-test
ports:
- "16381:6379"
networks:
- ruoyi-network-test
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 10s
retries: 30
# 网络配置
networks:
ruoyi-network-test:
name: ruoyi-network-test
driver: bridge
@@ -0,0 +1,81 @@
import pytest
from playwright.async_api import async_playwright, expect
from common.base_page_test import BasePageTest
from common.config import Config
class CacheListTest(BasePageTest):
"""缓存列表测试类"""
async def test_cache_list(self) -> None:
"""测试缓存列表页面"""
await self.page.goto(Config.frontend_url + '/monitor/cacheList')
await self.page.wait_for_load_state('networkidle')
# 1. 缓存列表 - 点击 sys_config
await self.page.wait_for_selector('text=缓存列表')
# 等待 sys_config 出现并点击
sys_config_row = self.page.locator('.el-card', has_text='缓存列表').locator('tr', has_text='sys_config')
await sys_config_row.wait_for()
await sys_config_row.click()
# 2. 键名列表 - 点击 sys.account.captchaEnabled
await self.page.wait_for_selector('text=键名列表')
# 等待 sys.account.captchaEnabled 出现并点击
captcha_row = self.page.locator('.el-card', has_text='键名列表').locator(
'tr', has_text='sys.account.captchaEnabled'
)
await captcha_row.wait_for()
await captcha_row.click()
# 3. 验证缓存内容为 false
await self.page.wait_for_selector('text=缓存内容')
# 等待数据加载
await self.page.wait_for_timeout(2000)
# 获取内容
# 缓存内容显示在一个 textarea 中
content_area = self.page.locator('div.el-form-item', has_text='缓存内容:').locator('textarea')
# 也可以尝试直接获取 .el-form-item__content 的文本,如果 textarea 不可交互
if await content_area.count() > 0:
# 尝试等待内容不为空
try:
await expect(content_area).not_to_be_empty(timeout=5000)
except TimeoutError:
pass
value = await content_area.input_value()
print(f'Cache content value: {value}')
# 如果为空,可能需要重新点击一下
if not value:
print('Value is empty, trying to click key again')
await captcha_row.click()
await self.page.wait_for_timeout(2000)
value = await content_area.input_value()
print(f'Cache content value after retry: {value}')
assert 'false' in value.lower(), f"Expected 'false' in cache content, got: {value}"
else:
# 如果不是 textarea,尝试获取文本
content_div = self.page.locator('div.el-form-item', has_text='缓存内容:').locator('.el-form-item__content')
value = await content_div.text_content()
print(f'Cache content text: {value}')
assert 'false' in value.lower(), f"Expected 'false' in cache content, got: {value}"
@pytest.mark.asyncio
async def test_cache_list_page() -> None:
"""测试缓存列表页面功能"""
async with async_playwright() as p:
test_instance = CacheListTest()
await test_instance.setup(p)
try:
await test_instance.test_cache_list()
finally:
await test_instance.teardown()
@@ -0,0 +1,48 @@
import pytest
from playwright.async_api import async_playwright
from common.base_page_test import BasePageTest
from common.config import Config
class CacheMonitorTest(BasePageTest):
"""缓存监控测试类"""
async def test_cache_monitor(self) -> None:
"""测试缓存监控页面"""
await self.page.goto(Config.frontend_url + '/monitor/cache')
await self.page.wait_for_load_state('networkidle')
# 验证基本信息
await self.page.wait_for_selector('text=基本信息')
await self.page.wait_for_selector('text=Redis版本')
await self.page.wait_for_selector('text=运行模式')
# 验证命令统计
await self.page.wait_for_selector('text=命令统计')
# 验证内存信息
await self.page.wait_for_selector('text=内存信息')
# 验证端口为 6379
port_row = self.page.locator('tr', has_text='端口')
try:
await port_row.wait_for(timeout=5000)
text = await port_row.text_content()
assert '6379' in text, f"Expected port '6379' in row, but got: {text}"
except Exception:
print("Warning: '端口' row not found, checking page content")
content = await self.page.content()
assert '6379' in content, "Port '6379' not found in page content"
@pytest.mark.asyncio
async def test_cache_monitor_page() -> None:
"""测试缓存监控页面功能"""
async with async_playwright() as p:
test_instance = CacheMonitorTest()
await test_instance.setup(p)
try:
await test_instance.test_cache_monitor()
finally:
await test_instance.teardown()
@@ -0,0 +1,185 @@
from datetime import datetime
import pytest
from playwright.async_api import async_playwright
from common.base_page_test import BasePageTest
from common.config import Config
class JobManagementTest(BasePageTest):
"""定时任务管理测试"""
def generate_job_data(self) -> dict:
"""生成测试数据"""
timestamp = datetime.now().strftime('%H%M%S')
return {
'job_name': f'测试任务_{timestamp}',
'job_group': '默认',
'job_executor': '进程池',
'invoke_target': 'module_task.scheduler_test.job', # 必须为这个值
'cron_expression': '0/2 * * * * ?', # 每2秒执行一次
'new_invoke_target': 'module_task.scheduler_test.job', # 修改时也用这个
'job_name_edit': f'测试任务_{timestamp}_edit',
}
async def create_job(
self, job_name: str, job_group: str, job_executor: str, invoke_target: str, cron_expression: str
) -> None:
"""创建定时任务"""
await self.page.get_by_role('button', name='新增').click()
# 等待对话框
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 填写表单
await dialog.get_by_role('textbox', name='任务名称').fill(job_name)
# 选择任务分组
await dialog.locator("label:has-text('任务分组') + div .el-select").click()
await self.page.get_by_role('option', name=job_group).click()
# 选择任务执行器
await dialog.locator("label:has-text('任务执行器') + div .el-select").click()
await self.page.get_by_role('option', name=job_executor).click()
# 填写调用方法
await dialog.get_by_role('textbox', name='调用方法').fill(invoke_target)
# 填写cron表达式
await dialog.get_by_role('textbox', name='cron表达式').fill(cron_expression)
# 策略:如果"失败"策略等有默认值,就不管了
await dialog.get_by_role('button', name='确 定').click()
await self.wait_for_selector("div:has-text('新增成功')", timeout=10000)
async def search_job(self, job_name: str) -> None:
"""搜索任务"""
# 我们可以限制在 form 里搜索
form = self.page.locator('form').first
await form.get_by_role('textbox', name='任务名称').fill(job_name)
await self.page.get_by_role('button', name='搜索').click()
await self.page.wait_for_timeout(1000) # 等待搜索结果
async def edit_job(self, job_name: str, new_invoke_target: str) -> None:
"""修改任务"""
await self.search_job(job_name)
# 点击修改 (在操作列)
row = self.page.locator('tbody tr').first
# 操作列按钮顺序: 修改, 删除, 执行一次, 详细, 日志
await row.locator('button').nth(0).click()
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 修改调用方法
await dialog.get_by_role('textbox', name='调用方法').fill(new_invoke_target)
await dialog.get_by_role('button', name='确 定').click()
await self.wait_for_selector("div:has-text('修改成功')", timeout=10000)
async def toggle_job_status(self, job_name: str) -> None:
"""切换任务状态"""
await self.search_job(job_name)
row = self.page.locator('tbody tr').first
# 切换开关
# 需要点击可见的 .el-switch 或 .el-switch__core
await row.locator('.el-switch').click()
# 确认切换
await self.page.get_by_role('button', name='确定').click()
await self.page.wait_for_timeout(1000)
await self.wait_for_selector("div:has-text('成功')", timeout=10000)
async def run_job_once(self, job_name: str) -> None:
"""执行一次任务"""
await self.search_job(job_name)
row = self.page.locator('tbody tr').first
# 点击执行一次 (第3个按钮)
await row.locator('button').nth(2).click()
# 确认执行
await self.page.get_by_role('button', name='确定').click()
await self.wait_for_selector("div:has-text('执行成功')", timeout=10000)
async def view_job_log(self, job_name: str) -> None:
"""查看调度日志"""
# 可以点击顶部的 "日志" 按钮,也可以点击行的 "调度日志"
# 我们点击行的 "调度日志" (icon="Operation")
await self.search_job(job_name)
row = self.page.locator('tbody tr').first
log_btn = row.locator('button').nth(4)
await log_btn.click()
# 等待跳转到日志页面 (实际上是 router push 到 /monitor/job-log)
# 检查 URL
await self.page.wait_for_url('**/monitor/job-log**')
# 验证在日志页面
await self.wait_for_selector('text=调度日志')
# 关闭返回 (点击 "关闭" 按钮)
await self.page.get_by_role('button', name='关闭').click()
# 等待返回任务列表
await self.page.wait_for_url('**/monitor/job')
async def delete_job(self, job_name: str) -> None:
"""删除任务"""
await self.search_job(job_name)
row = self.page.locator('tbody tr').first
# 点击删除 (第2个按钮)
await row.locator('button').nth(1).click()
# 确认删除
await self.page.get_by_role('button', name='确定').click()
await self.wait_for_selector("div:has-text('删除成功')", timeout=10000)
async def test_job_crud_operations(self) -> None:
"""测试定时任务管理全流程"""
data = self.generate_job_data()
# 1. 进入定时任务页面
await self.goto_page(Config.frontend_url + '/monitor/job')
await self.wait_for_selector('text=任务名称')
# 2. 创建任务
await self.create_job(
data['job_name'], data['job_group'], data['job_executor'], data['invoke_target'], data['cron_expression']
)
# 3. 修改任务 (修改调用方法)
await self.edit_job(data['job_name'], data['new_invoke_target'])
# 4. 切换状态 (开启/关闭)
await self.toggle_job_status(data['job_name']) # 开启
await self.page.wait_for_timeout(1000)
await self.toggle_job_status(data['job_name']) # 关闭
# 5. 执行一次
await self.run_job_once(data['job_name'])
# 6. 查看日志
await self.view_job_log(data['job_name'])
# 7. 删除任务
await self.delete_job(data['job_name'])
@pytest.mark.asyncio
async def test_job_management_page() -> None:
"""测试定时任务管理页面功能"""
async with async_playwright() as p:
test_instance = JobManagementTest()
await test_instance.setup(p)
try:
await test_instance.test_job_crud_operations()
finally:
await test_instance.teardown()
@@ -0,0 +1,73 @@
import pytest
from playwright.async_api import async_playwright
from common.base_page_test import BasePageTest
from common.config import Config
class OnlineUserTest(BasePageTest):
"""在线用户测试类"""
async def test_online_user_operations(self) -> None:
"""测试在线用户强退"""
# 1. 模拟 niangao 用户登录以产生在线会话
# 使用一个新的上下文来模拟另一个用户的登录
niangao_context = await self.browser.new_context()
niangao_page = await niangao_context.new_page()
# 登录 niangao
try:
await niangao_page.goto(Config.frontend_url + '/login')
await niangao_page.locator('div.el-form-item', has_text='账号').locator('input').fill('niangao')
await niangao_page.locator('div.el-form-item', has_text='密码').locator('input').fill('admin123')
await niangao_page.get_by_role('button', name='登 录').click()
# 等待登录成功
await niangao_page.wait_for_url('**/index')
except Exception as e:
print(f'Niangao login failed: {e}. Trying admin123')
try:
await niangao_page.locator('div.el-form-item', has_text='密码').locator('input').fill('123456')
await niangao_page.get_by_role('button', name='登 录').click()
await niangao_page.wait_for_url('**/index')
except Exception as e2:
print(f'Niangao login failed again: {e2}')
# 2. 切换回 admin (self.page) 进行操作
await self.page.goto(Config.frontend_url + '/monitor/online')
await self.page.wait_for_load_state('networkidle')
# 3. 搜索 niangao 用户
await self.page.locator('div.el-form-item', has_text='用户名称').locator('input').fill('niangao')
await self.page.get_by_role('button', name='搜索').click()
await self.page.wait_for_timeout(1000)
# 4. 强退操作
# 检查是否有数据
rows = self.page.locator('.el-table__body tr')
count = await rows.count()
if count > 0:
# 点击强退
await rows.first.get_by_role('button', name='强退').click()
# 确认
await self.page.get_by_role('button', name='确定').click()
# 验证
await self.page.wait_for_selector('text=删除成功', timeout=3000)
# 再次搜索确认消失
await self.page.wait_for_timeout(1000)
await self.page.get_by_role('button', name='搜索').click()
# 关闭 niangao 的上下文
await niangao_context.close()
@pytest.mark.asyncio
async def test_online_user_page() -> None:
"""测试在线用户页面功能"""
async with async_playwright() as p:
test_instance = OnlineUserTest()
await test_instance.setup(p)
try:
await test_instance.test_online_user_operations()
finally:
await test_instance.teardown()
@@ -0,0 +1,46 @@
import pytest
from playwright.async_api import async_playwright
from common.base_page_test import BasePageTest
from common.config import Config
class ServerMonitorTest(BasePageTest):
"""服务监控测试类"""
async def test_server_monitor(self) -> None:
"""测试服务监控页面"""
await self.page.goto(Config.frontend_url + '/monitor/server')
await self.page.wait_for_load_state('networkidle')
# 验证主要板块存在
await self.page.wait_for_selector('text=CPU')
await self.page.wait_for_selector('text=内存')
await self.page.wait_for_selector('text=服务器信息')
await self.page.wait_for_selector('text=Python解释器信息')
await self.page.wait_for_selector('text=磁盘状态')
# 验证项目路径为 /app
# 尝试在表格行中查找
project_path_row = self.page.locator('tr', has_text='项目路径')
try:
await project_path_row.wait_for(timeout=5000)
text = await project_path_row.text_content()
assert '/app' in text, f"Expected project path '/app' in row, but got: {text}"
except Exception:
# 如果没找到行,尝试全局搜索
print("Warning: '项目路径' row not found, checking page content")
content = await self.page.content()
assert '/app' in content, "Project path '/app' not found in page content"
@pytest.mark.asyncio
async def test_server_monitor_page() -> None:
"""测试服务监控页面功能"""
async with async_playwright() as p:
test_instance = ServerMonitorTest()
await test_instance.setup(p)
try:
await test_instance.test_server_monitor()
finally:
await test_instance.teardown()
+5
View File
@@ -0,0 +1,5 @@
playwright
pytest
pytest-asyncio
pytest-playwright
ruff
+68
View File
@@ -0,0 +1,68 @@
line-length = 120
show-fixes = true
target-version = "py39"
unsafe-fixes = true
[lint]
select = [
"FAST", # fastapi
"ANN", # flake8-annotations
"ASYNC", # flake8-async
"B", # flake8-bugbear
"A", # flake8-builtins
"COM", # flake8-commas
"C4", # flake8-comprehensions
"ISC", # flake8-implicit-str-concat
"ICN", # flake8-import-conventions
"PIE", # flake8-pie
"RSE", # flake8-raise
"RET", # flake8-return
"SIM", # flake8-simplify
"TC", # flake8-type-checking
"FLY", # flynt
"I", # isort
"N", # pep8-naming
"PERF", # perflint
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"PGH", # pygrep-hooks
"PL", # pylint
"UP", # pyupgrade
"RUF", # Ruff-specific rules
]
ignore = [
"ANN002", # missing type annotation for *{name}
"ANN003", # missing type annotation for **{name}
"ANN401", # dynamically typed expressions (typing.Any) are disallowed in {name}
"B008", # do not perform function calls in argument defaults
"COM812", # trailing comma missing
"RET504", # unnecessary assignment to {name} before return statement
"SIM105", # use contextlib.suppress({exception}) instead of try-except-pass
"C901", # too complex
"N818", # exception name {name} should be named with an Error suffix
"E501", # line too long
"W191", # indentation contains tabs
"RUF001", # string contains ambiguous
"RUF002", # docstring contains ambiguous
"RUF003", # comment contains ambiguous
"RUF012", # mutable class attributes should be annotated with typing.ClassVar
]
[lint.flake8-type-checking]
runtime-evaluated-base-classes = ["pydantic.BaseModel", "sqlalchemy.orm.DeclarativeBase"]
[lint.isort]
known-third-party = ["alembic"]
[lint.pylint]
max-args = 10
max-returns = 10
[lint.pyupgrade]
# Preserve types, even if a file imports `from __future__ import annotations`.
keep-runtime-typing = true
[format]
docstring-code-format = true
quote-style = "single"
@@ -0,0 +1,165 @@
import time
import pytest
from playwright.async_api import async_playwright
from common.base_page_test import BasePageTest
from common.config import Config
class ConfigManagementTest(BasePageTest):
"""参数设置测试类"""
def generate_config_data(self) -> dict:
"""生成测试数据"""
timestamp = int(time.time())
return {
'config_name': f'test_config_{timestamp}',
'config_key': f'test_config_key_{timestamp}',
'config_value': f'test_config_value_{timestamp}',
}
async def create_config(self, config_name: str, config_key: str, config_value: str) -> None:
"""创建参数配置"""
# 点击新增按钮
await self.page.get_by_role('button', name='新增').first.click()
# 等待对话框
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 填写参数信息
await dialog.get_by_role('textbox', name='参数名称').fill(config_name)
await dialog.get_by_role('textbox', name='参数键名').fill(config_key)
await dialog.get_by_role('textbox', name='参数键值').fill(config_value)
# 等待一段时间确保输入完成
await self.page.wait_for_timeout(500)
# 选择参数状态 - 选择系统内置选项为N(否),避免后端限制删除
# 在对话框中找到系统内置的单选按钮并选择
await self.wait_for_selector('input[type="radio"]', timeout=5000)
# 点击系统内置'否'选项
# 根据codegen脚本,使用更精确的定位器
await self.page.locator('label:nth-child(2) > .el-radio__input > .el-radio__inner').click()
# 等待一段时间确保选择完成
await self.page.wait_for_timeout(500)
# 点击确认按钮
confirm_button = self.page.get_by_role('button', name='确 定').first
await confirm_button.click()
# 等待一段时间确保请求发送
await self.page.wait_for_timeout(1000)
# 等待新增成功提示
await self.wait_for_selector('div:has-text("新增成功")', timeout=10000)
async def search_config(self, config_name: str, config_key: str = '') -> None:
"""搜索参数配置"""
# 在查询表单中输入参数信息进行查询
await self.page.get_by_role('textbox', name='参数名称').fill(config_name)
if config_key:
await self.page.get_by_role('textbox', name='参数键名').fill(config_key)
# 点击搜索按钮
await self.page.get_by_role('button', name='搜索').first.click()
# 等待搜索结果加载
await self.page.wait_for_timeout(1000)
async def edit_config(self, updated_value: str) -> None:
"""编辑参数配置"""
# 点击编辑按钮
await self.page.locator('tbody').get_by_role('button', name='修改').nth(0).click()
# 等待对话框
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 修改参数键值
await dialog.get_by_role('textbox', name='参数键值').fill(updated_value)
# 确认
await self.page.get_by_role('button', name='确 定').first.click()
# 等待编辑成功提示
await self.wait_for_selector('div:has-text("修改成功")', timeout=10000)
async def delete_config(self) -> None:
"""删除参数配置"""
# 点击删除按钮
await self.page.locator('tbody').get_by_role('button', name='删除').nth(0).click()
# 确认删除
await self.page.get_by_role('button', name='确定').first.click()
# 等待删除成功提示
await self.wait_for_selector('div:has-text("删除成功")', timeout=10000)
# 重置查询表单,确保能看到所有参数
reset_button = self.page.get_by_role('button', name='重置')
await reset_button.click()
# 等待列表刷新
await self.page.wait_for_timeout(1000)
async def test_config_crud_operations(self) -> None:
"""测试参数设置的增删查改功能"""
# 访问参数设置页面
await self.goto_page(Config.frontend_url + '/system/config')
# 等待页面加载完成
await self.wait_for_page_title('参数设置', timeout=10000)
await self.page.wait_for_timeout(1000) # 等待列表刷新
# 记录初始参数数量
initial_config_count = await self.get_table_total_rows()
# 生成测试数据
data = self.generate_config_data()
# 1.新增
await self.create_config(data['config_name'], data['config_key'], data['config_value'])
# 验证新增
await self.page.wait_for_timeout(1000) # 等待列表刷新
new_config_count = await self.get_table_total_rows()
assert new_config_count > initial_config_count, '新增参数后数量应该增加'
# 2.搜索
await self.search_config(data['config_name'], data['config_key'])
# 验证搜索
search_result_count = await self.get_table_total_rows()
assert search_result_count >= 1, '搜索结果应该至少包含一个参数'
# 3.编辑
await self.edit_config(f'updated_config_value_{time.time()}')
# 验证编辑
await self.page.wait_for_timeout(1000) # 等待列表刷新
# 重新搜索以验证修改结果
await self.search_config(data['config_name'])
# 4.删除
await self.delete_config()
# 验证删除
await self.page.wait_for_timeout(1000) # 等待列表刷新
final_config_count = await self.get_table_total_rows()
assert final_config_count == initial_config_count, '删除参数后数量应该恢复到初始值'
@pytest.mark.asyncio
async def test_config_management_page() -> None:
"""测试参数设置页面功能"""
async with async_playwright() as p:
test_instance = ConfigManagementTest()
await test_instance.setup(p)
try:
await test_instance.test_config_crud_operations()
finally:
await test_instance.teardown()
@@ -0,0 +1,135 @@
import time
import pytest
from playwright.async_api import async_playwright
from common.base_page_test import BasePageTest
from common.config import Config
class DeptManagementTest(BasePageTest):
"""部门管理页面测试类"""
def generate_dept_data(self) -> dict:
"""生成测试数据"""
timestamp = int(time.time())
return {
'dept_name': f'测试部门_{timestamp}',
'order_num': '3',
'leader_name': '年糕',
}
async def create_dept(self, dept_name: str, order_num: str) -> None:
"""创建部门"""
# 点击新增按钮
await self.page.get_by_role('button', name='新增').first.click()
# 等待对话框
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 上级部门
# 使用 label 定位父级 form-item,再点击内部的 wrapper
await dialog.locator('div.el-form-item').filter(has_text='上级部门').locator('.el-select__wrapper').click()
# 选择 "集团总公司" (根节点)
# 使用 .el-popper 定位下拉框中的内容
await self.page.locator('.el-popper:visible').get_by_text('集团总公司').click()
# 填写部门名称
await dialog.get_by_role('textbox', name='部门名称').fill(dept_name)
# 填写显示排序
# el-input-number 通常是一个 spinbutton 或者 input
await dialog.get_by_role('spinbutton', name='显示排序').fill(order_num)
# 点击确定
await self.page.get_by_role('button', name='确 定').click()
# 等待成功消息
await self.wait_for_selector("div:has-text('成功')", timeout=10000)
async def search_dept(self, dept_name: str) -> None:
"""搜索部门"""
search_form = self.page.locator('form').first
await search_form.get_by_role('textbox', name='部门名称').fill(dept_name)
# 点击搜索按钮
await self.page.get_by_role('button', name='搜索').first.click()
# 等待加载
await self.page.wait_for_timeout(1000)
async def edit_dept(self, leader_name: str) -> None:
"""编辑部门"""
# 点击修改按钮 (第一行)
# 树形表格结构可能不同,但通常也是在 tbody 的 row 中
await self.page.locator('tbody').get_by_role('button', name='修改').nth(0).click()
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 修改负责人
await dialog.get_by_role('textbox', name='负责人').fill(leader_name)
# 确定
await self.page.get_by_role('button', name='确 定').click()
# 等待成功提示
await self.wait_for_selector('div:has-text("修改成功")', timeout=10000)
async def delete_dept(self) -> None:
"""删除部门"""
# 点击删除按钮
await self.page.locator('tbody').get_by_role('button', name='删除').nth(0).click()
# 确认删除
await self.page.get_by_role('button', name='确定').click()
# 等待成功提示
await self.wait_for_selector('div:has-text("删除成功")', timeout=10000)
# 重置搜索
await self.page.get_by_role('button', name='重置').click()
await self.page.wait_for_timeout(1000)
async def test_dept_crud_operations(self) -> None:
"""测试部门管理增删查改"""
# 访问页面
await self.goto_page(Config.frontend_url + '/system/dept')
await self.wait_for_page_title('部门管理', timeout=10000)
# 生成测试数据
data = self.generate_dept_data()
# 1. 新增
await self.create_dept(data['dept_name'], data['order_num'])
# 验证新增
await self.search_dept(data['dept_name'])
# 树形表格,只要有行显示即可
rows = await self.page.query_selector_all('.el-table__row')
assert len(rows) >= 1, '新增后搜索应有结果'
# 2. 编辑
await self.edit_dept(data['leader_name'])
# 重新搜索以验证修改结果
await self.search_dept(data['dept_name'])
# 3. 删除
await self.delete_dept()
# 验证删除
await self.search_dept(data['dept_name'])
rows_after = await self.page.query_selector_all('.el-table__row')
assert len(rows_after) == 0, '删除后搜索应无结果'
@pytest.mark.asyncio
async def test_dept_management_page() -> None:
"""测试部门管理页面功能"""
async with async_playwright() as p:
test_instance = DeptManagementTest()
await test_instance.setup(p)
try:
await test_instance.test_dept_crud_operations()
finally:
await test_instance.teardown()
@@ -0,0 +1,201 @@
from datetime import datetime
import pytest
from playwright.async_api import async_playwright
from common.base_page_test import BasePageTest
from common.config import Config
class DictManagementTest(BasePageTest):
"""字典管理测试类"""
def generate_dict_data(self) -> dict:
"""生成测试数据"""
timestamp = datetime.now().strftime('%H%M%S')
return {
'dict_name': f'测试字典_{timestamp}',
'dict_type': f'test_dict_{timestamp}',
'dict_data_label_1': '正常',
'dict_data_value_1': '1',
'dict_data_label_2': '异常',
'dict_data_value_2': '2',
'remark': f'备注_{timestamp}',
}
async def create_dict_type(self, dict_name: str, dict_type: str) -> None:
"""创建字典类型"""
# 点击新增按钮
await self.page.get_by_role('button', name='新增').first.click()
# 等待对话框
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 填写表单
await dialog.get_by_role('textbox', name='字典名称').fill(dict_name)
await dialog.get_by_role('textbox', name='字典类型').fill(dict_type)
# 提交
await dialog.get_by_role('button', name='确 定').click()
# 验证成功
await self.wait_for_selector("div:has-text('新增成功')", timeout=10000)
async def search_dict_type(self, dict_name: str, dict_type: str) -> None:
"""搜索字典类型"""
form = self.page.locator('form').first
await form.get_by_role('textbox', name='字典名称').fill(dict_name)
await form.get_by_role('textbox', name='字典类型').fill(dict_type)
# 点击搜索按钮
await self.page.get_by_role('button', name='搜索').click()
await self.page.wait_for_timeout(1000)
async def edit_dict_type(self, dict_name: str, remark: str) -> None:
"""修改字典类型"""
await self.search_dict_type(dict_name, '')
# 点击修改按钮
row = self.page.locator('tbody tr').first
await row.get_by_role('button', name='修改').click()
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
await dialog.get_by_role('textbox', name='备注').fill(remark)
await dialog.get_by_role('button', name='确 定').click()
await self.wait_for_selector("div:has-text('修改成功')", timeout=10000)
async def manage_dict_data(self, dict_type: str, data: dict) -> None:
"""管理字典数据"""
# 1. 进入字典数据页面
# 点击字典类型链接
row = self.page.locator('tbody tr').first
await row.get_by_role('link', name=dict_type).click()
# 等待页面切换(Playwright在SPA中通常不需要处理多标签页,除非新开tab)
# 这里是 router-link,应该是页内跳转
# 检查是否到了字典数据页面
# 或者等待URL变化,或者等待特定元素
# data.vue 有 "关闭" 按钮
await self.wait_for_selector("button:has-text('关闭')", timeout=5000)
# 2. 新增数据1 (正常)
await self.create_dict_data(data['dict_data_label_1'], data['dict_data_value_1'], '1', '主要(primary)')
# 3. 新增数据2 (异常)
await self.create_dict_data(data['dict_data_label_2'], data['dict_data_value_2'], '2', '危险(danger)')
# 4. 修改数据
await self.edit_dict_data(data['dict_data_label_2'], '测试备注')
# 5. 删除数据
await self.delete_dict_data(data['dict_data_label_2'])
await self.delete_dict_data(data['dict_data_label_1'])
# 6. 关闭页面 (返回字典类型)
await self.page.get_by_role('button', name='关闭', exact=True).click()
# 等待返回
await self.wait_for_selector('text=字典名称') # 假设返回后能看到原来的页面元素
async def create_dict_data(self, label: str, value: str, sort: str, list_class: str) -> None:
"""创建字典数据"""
# 点击新增按钮
await self.page.get_by_role('button', name='新增').first.click()
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
await dialog.get_by_role('textbox', name='数据标签').fill(label)
await dialog.get_by_role('textbox', name='数据键值').fill(value)
await dialog.get_by_role('spinbutton', name='显示排序').fill(sort)
# 选择回显样式
# 点击下拉框触发器
# el-select 的结构通常是 wrapper -> input (readonly)
# 我们可以点击 label 为 "回显样式" 的 form-item 下的 select
select_trigger = dialog.locator("label:has-text('回显样式') + div .el-select")
if await select_trigger.count() == 0:
# 尝试直接找 .el-select,可能布局不同
select_trigger = dialog.locator('.el-select').filter(has_text='默认(default)').first
# 如果还是找不到,尝试点击那个默认值文本
if await select_trigger.count() == 0:
await dialog.get_by_text('默认(default)').click()
else:
await select_trigger.click()
# 选择选项 (选项在 body 的 el-popper 中)
# 等待选项出现
await self.page.get_by_role('option', name=list_class).click()
await dialog.get_by_role('button', name='确 定').click()
await self.wait_for_selector("div:has-text('新增成功')", timeout=10000)
async def edit_dict_data(self, label: str, remark: str) -> None:
"""修改字典数据"""
# 点击修改按钮
row = self.page.locator('tbody tr').filter(has_text=label).first
await row.get_by_role('button', name='修改').click()
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
await dialog.get_by_role('textbox', name='备注').fill(remark)
await dialog.get_by_role('button', name='确 定').click()
await self.wait_for_selector("div:has-text('修改成功')", timeout=10000)
async def delete_dict_data(self, label: str) -> None:
"""删除字典数据"""
# 点击删除按钮
row = self.page.locator('tbody tr').filter(has_text=label).first
await row.get_by_role('button', name='删除').click()
await self.page.get_by_role('button', name='确定').click()
await self.wait_for_selector("div:has-text('删除成功')", timeout=10000)
async def delete_dict_type(self, dict_name: str) -> None:
"""删除字典类型"""
await self.search_dict_type(dict_name, '')
# 点击删除按钮
row = self.page.locator('tbody tr').filter(has_text=dict_name).first
await row.get_by_role('button', name='删除').click()
await self.page.get_by_role('button', name='确定').click()
await self.wait_for_selector("div:has-text('删除成功')", timeout=10000)
async def test_dict_crud_operations(self) -> None:
"""测试字典管理全流程"""
data = self.generate_dict_data()
# 1. 进入字典管理
await self.goto_page(Config.frontend_url + '/system/dict')
# 等待页面加载
await self.wait_for_selector('text=字典名称') # 页面标题或其他标识
# 2. 创建字典类型
await self.create_dict_type(data['dict_name'], data['dict_type'])
# 3. 搜索并修改
await self.edit_dict_type(data['dict_name'], data['remark'])
# 4. 管理字典数据
await self.manage_dict_data(data['dict_type'], data)
# 5. 删除字典类型
await self.delete_dict_type(data['dict_name'])
@pytest.mark.asyncio
async def test_dict_management_page() -> None:
"""测试字典管理页面功能"""
async with async_playwright() as p:
test_instance = DictManagementTest()
await test_instance.setup(p)
try:
await test_instance.test_dict_crud_operations()
finally:
await test_instance.teardown()
@@ -0,0 +1,138 @@
import pytest
from playwright.async_api import async_playwright
from common.base_page_test import BasePageTest
from common.config import Config
class LogManagementTest(BasePageTest):
"""日志管理测试类"""
async def test_operlog_operations(self) -> None:
"""测试操作日志查询、删除和清空"""
# 1. 导航到操作日志页面
# 直接访问正确的路由
await self.page.goto(Config.frontend_url + '/system/log/operlog')
await self.page.wait_for_load_state('networkidle')
try:
await self.page.wait_for_selector('.app-container', timeout=10000)
except Exception:
# 如果首次加载失败,尝试刷新
await self.page.reload()
await self.page.wait_for_selector('.app-container', timeout=10000)
# 2. 查询操作
# 输入操作人员
await self.page.locator('div.el-form-item', has_text='操作人员').locator('input').fill('admin')
# 点击搜索按钮
await self.page.get_by_role('button', name='搜索').click()
await self.page.wait_for_timeout(1000) # 等待搜索结果
# 点击重置按钮
await self.page.get_by_role('button', name='重置').click()
await self.page.wait_for_timeout(1000) # 等待重置结果
# 3. 删除操作
# 检查是否有数据,如果有则进行删除测试
rows = self.page.locator('.el-table__body tr')
count = await rows.count()
if count > 0:
# 选中第一条
await rows.first.locator('.el-checkbox').click()
# 点击删除
await self.page.get_by_role('button', name='删除').click()
# 确认删除
await self.page.get_by_role('button', name='确定').click()
# 等待删除完成提示
try:
await self.page.wait_for_selector('text=删除成功', timeout=10000)
except Exception:
pass # 可能是 toast 提示,稍纵即逝
# 4. 清空操作
# 点击清空
await self.page.get_by_role('button', name='清空').click()
# 确认清空
await self.page.get_by_role('button', name='确定').click()
# 5. 验证清空后还有一条"清空"的操作日志
await self.page.wait_for_timeout(1000) # 等待清空操作完成并刷新
await self.page.get_by_role('button', name='搜索').click() # 刷新列表
await self.page.wait_for_timeout(1000)
# 验证列表不为空 (因为清空操作本身会产生一条日志)
rows_after_clean = self.page.locator('.el-table__body tr')
count_after_clean = await rows_after_clean.count()
assert count_after_clean > 0, '操作日志清空后应该至少有一条记录(清空操作本身)'
# 可以在这里进一步验证第一条记录的类型是否为清空,但题目只要求不是空列表
async def test_logininfor_operations(self) -> None:
"""测试登录日志查询、删除和清空"""
# 1. 导航到登录日志页面
await self.page.goto(Config.frontend_url + '/system/log/logininfor')
await self.page.wait_for_load_state('networkidle')
try:
await self.page.wait_for_selector('.app-container', timeout=10000)
except Exception:
# 如果失败,尝试刷新
await self.page.reload()
await self.page.wait_for_selector('.app-container', timeout=10000)
# 2. 查询操作
# 输入用户名称
await self.page.locator('div.el-form-item', has_text='用户名称').locator('input').fill('admin')
# 点击搜索
await self.page.get_by_role('button', name='搜索').click()
await self.page.wait_for_timeout(1000)
# 点击重置
await self.page.get_by_role('button', name='重置').click()
await self.page.wait_for_timeout(1000)
# 3. 删除操作
# 检查是否有数据
rows = self.page.locator('.el-table__body tr')
count = await rows.count()
if count > 0:
# 选中第一条
await rows.first.locator('.el-checkbox').click()
# 点击删除
await self.page.get_by_role('button', name='删除').click()
# 确认删除
await self.page.get_by_role('button', name='确定').click()
# 等待删除成功
try:
await self.page.wait_for_selector('text=删除成功', timeout=10000)
except Exception:
pass
# 4. 清空操作
# 点击清空
await self.page.get_by_role('button', name='清空').click()
# 确认清空
await self.page.get_by_role('button', name='确定').click()
# 5. 验证清空后列表为空
await self.page.wait_for_timeout(1000)
# 检查是否显示"暂无数据"
no_data = await self.page.get_by_text('暂无数据').is_visible()
if not no_data:
# 再次检查行数
count_final = await self.page.locator('.el-table__body tr').count()
assert count_final == 0, '登录日志清空后应该没有数据'
@pytest.mark.asyncio
async def test_log_management_page() -> None:
"""测试日志管理页面功能"""
async with async_playwright() as p:
test_instance = LogManagementTest()
await test_instance.setup(p)
try:
# 运行操作日志测试
await test_instance.test_operlog_operations()
# 运行登录日志测试
await test_instance.test_logininfor_operations()
finally:
await test_instance.teardown()
@@ -0,0 +1,145 @@
from datetime import datetime
import pytest
from playwright.async_api import async_playwright
from common.base_page_test import BasePageTest
from common.config import Config
class MenuManagementTest(BasePageTest):
"""菜单管理测试"""
def generate_menu_data(self) -> dict:
"""生成测试数据"""
timestamp = datetime.now().strftime('%H%M%S')
return {
'dir_name': f'测试目录_{timestamp}',
'menu_name': f'测试菜单_{timestamp}',
'path_dir': f'test_dir_{timestamp}',
'path_menu': f'test_menu_{timestamp}',
'path_menu_new': f'test_menu_new_{timestamp}',
'order_num': '10',
}
async def create_directory(self, menu_name: str, path: str, order_num: str) -> None:
"""创建目录"""
# 点击顶部新增按钮
await self.page.get_by_role('button', name='新增').first.click()
# 等待对话框出现
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 填写表单
await dialog.get_by_role('spinbutton', name='显示排序').fill(order_num)
await dialog.get_by_role('textbox', name='菜单名称').fill(menu_name)
await dialog.get_by_role('textbox', name='路由地址').fill(path)
# 提交
await dialog.get_by_role('button', name='确 定').click()
# 验证成功提示
await self.wait_for_selector("div:has-text('新增成功')", timeout=10000)
async def search_menu(self, menu_name: str) -> None:
"""搜索菜单"""
# 填写搜索条件
# 使用 first 匹配搜索表单(通常在顶部),避免匹配到隐藏对话框中的同名字段
await self.page.locator('form').first.get_by_role('textbox', name='菜单名称').fill(menu_name)
await self.page.get_by_role('button', name='搜索').click()
# 等待表格刷新(简单等待)
await self.page.wait_for_timeout(1000)
async def create_sub_menu(self, parent_name: str, menu_name: str, path: str, order_num: str) -> None:
"""在指定目录下创建子菜单"""
# 找到父级菜单行
row = self.page.locator('tbody tr').filter(has_text=parent_name).first
# 点击行内新增按钮
await row.get_by_role('button', name='新增').click()
# 等待对话框
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 选择菜单类型为"菜单"
await dialog.locator('label').filter(has_text='菜单').first.click()
# 填写表单
await dialog.get_by_role('spinbutton', name='显示排序').fill(order_num)
await dialog.get_by_role('textbox', name='菜单名称').fill(menu_name)
await dialog.get_by_role('textbox', name='路由地址').fill(path)
# 提交
await dialog.get_by_role('button', name='确 定').click()
# 验证成功
await self.wait_for_selector("div:has-text('新增成功')", timeout=10000)
async def edit_sub_menu(self, menu_name: str, new_path: str) -> None:
"""修改子菜单"""
# 搜索子菜单
await self.search_menu(menu_name)
row = self.page.locator('tbody tr').filter(has_text=menu_name).first
await row.get_by_role('button', name='修改').click()
await self.wait_for_selector('.el-dialog', timeout=5000)
dialog = self.page.locator('.el-dialog')
await dialog.get_by_role('textbox', name='路由地址').fill(new_path)
await self.page.get_by_role('button', name='确 定').click()
await self.wait_for_selector("div:has-text('修改成功')", timeout=10000)
async def delete_menus(self, menu_names: list[str]) -> None:
"""删除菜单(列表)"""
for name in menu_names:
await self.search_menu(name)
row = self.page.locator('tbody tr').filter(has_text=name).first
# 点击删除
await row.get_by_role('button', name='删除').click()
# 确认删除
await self.page.get_by_role('button', name='确定').click()
await self.wait_for_selector("div:has-text('删除成功')", timeout=10000)
async def test_menu_crud_operations(self) -> None:
"""测试菜单增删改查流程"""
data = self.generate_menu_data()
# 1. 进入菜单管理页面
await self.goto_page(Config.frontend_url + '/system/menu')
await self.wait_for_page_title('菜单管理', timeout=10000)
# 2. 新增目录
await self.create_directory(data['dir_name'], data['path_dir'], data['order_num'])
# 3. 搜索目录并新增子菜单
await self.search_menu(data['dir_name'])
await self.create_sub_menu(data['dir_name'], data['menu_name'], data['path_menu'], '1')
# 4. 修改子菜单
await self.edit_sub_menu(data['menu_name'], data['path_menu_new'])
# 5. 删除子菜单
await self.delete_menus([data['menu_name']])
# 6. 删除目录
await self.delete_menus([data['dir_name']])
@pytest.mark.asyncio
async def test_menu_management_page() -> None:
"""测试菜单管理页面功能"""
async with async_playwright() as p:
test_instance = MenuManagementTest()
await test_instance.setup(p)
try:
await test_instance.test_menu_crud_operations()
finally:
await test_instance.teardown()
@@ -0,0 +1,165 @@
import re
import time
import pytest
from playwright.async_api import async_playwright
from common.base_page_test import BasePageTest
from common.config import Config
class NoticeManagementTest(BasePageTest):
"""通知公告管理测试类"""
def generate_notice_data(self) -> dict:
"""生成测试数据"""
timestamp = int(time.time())
return {
'notice_title': f'test_notice_{timestamp}',
'updated_title': f'updated_notice_{timestamp}',
}
async def create_notice(self, notice_title: str, notice_type: int = 1, notice_content: str = '测试内容') -> None:
"""创建通知公告"""
# 点击新增按钮
await self.page.get_by_role('button', name='新增').first.click()
# 等待对话框
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 填写公告信息
await dialog.get_by_role('textbox', name='公告标题').fill(notice_title)
# 选择公告类型
# 根据codegen脚本,点击下拉框触发器
await self.page.locator('div').filter(has_text=re.compile(r'^请选择$')).nth(4).click()
# 选择对应类型选项,1为通知,2为公告
if notice_type == 1:
await self.page.get_by_role('option', name='通知').click()
else:
await self.page.get_by_role('option', name='公告').click()
# 填写公告内容
await self.page.locator('.ql-container').click()
await self.page.locator('.ql-editor').fill(notice_content)
# 等待一段时间确保输入完成
await self.page.wait_for_timeout(500)
# 点击确认按钮
await self.page.get_by_role('button', name='确 定').first.click()
# 等待一段时间确保请求发送
await self.page.wait_for_timeout(1000)
# 等待新增成功提示
await self.wait_for_selector('div:has-text("新增成功")', timeout=10000)
async def search_notice(self, notice_title: str) -> None:
"""搜索通知公告"""
# 在查询表单中输入公告标题进行查询
await self.page.get_by_role('textbox', name='公告标题').fill(notice_title)
# 点击搜索按钮
await self.page.get_by_role('button', name='搜索').first.click()
# 等待搜索结果加载
await self.page.wait_for_timeout(1000)
async def edit_notice(self, updated_title: str, updated_content: str = '更新的测试内容') -> None:
"""编辑通知公告"""
# 点击编辑按钮
await self.page.locator('tbody').get_by_role('button', name='修改').nth(0).click()
# 等待对话框
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 修改公告标题
await dialog.get_by_role('textbox', name='公告标题').fill(updated_title)
# 修改公告内容
await self.page.locator('.ql-editor').fill(updated_content)
# 点击确认按钮
await self.page.get_by_role('button', name='确 定').first.click()
# 等待编辑成功提示
await self.wait_for_selector('div:has-text("修改成功")', timeout=10000)
async def delete_notice(self) -> None:
"""删除通知公告"""
# 点击删除按钮
await self.page.locator('tbody').get_by_role('button', name='删除').nth(0).click()
# 确认删除
await self.page.get_by_role('button', name='确定').first.click()
# 等待删除成功提示
await self.wait_for_selector('div:has-text("删除成功")', timeout=10000)
# 重置查询表单,确保能看到所有公告
await self.page.get_by_role('button', name='重置').click()
# 等待列表刷新
await self.page.wait_for_timeout(1000)
async def test_notice_crud_operations(self) -> None:
"""测试通知公告的增删查改功能"""
# 访问通知公告页面
await self.goto_page(Config.frontend_url + '/system/notice')
# 等待页面加载完成
await self.wait_for_page_title('通知公告', timeout=10000)
await self.page.wait_for_timeout(1000) # 等待列表刷新
# 记录初始公告数量
initial_notice_count = await self.get_table_total_rows()
# 生成测试数据
data = self.generate_notice_data()
# 创建公告
await self.create_notice(data['notice_title'])
# 验证公告已添加到列表中
await self.page.wait_for_timeout(1000) # 等待列表刷新
new_notice_count = await self.get_table_total_rows()
assert new_notice_count > initial_notice_count, '新增公告后数量应该增加'
# 搜索公告
await self.search_notice(data['notice_title'])
# 验证搜索结果中包含新增的公告
search_result_count = await self.get_table_total_rows()
assert search_result_count >= 1, '搜索结果应该至少包含一个公告'
# 编辑公告
await self.edit_notice(data['updated_title'])
# 验证编辑成功
await self.page.wait_for_timeout(1000) # 等待列表刷新
# 重新搜索以验证修改结果
await self.search_notice(data['updated_title'])
# 删除公告
await self.delete_notice()
# 验证公告已从列表中删除
await self.page.wait_for_timeout(1000) # 等待列表刷新
final_notice_count = await self.get_table_total_rows()
assert final_notice_count == initial_notice_count, '删除公告后数量应该恢复到初始值'
@pytest.mark.asyncio
async def test_notice_management_page() -> None:
"""测试通知公告页面功能"""
async with async_playwright() as p:
test_instance = NoticeManagementTest()
await test_instance.setup(p)
try:
await test_instance.test_notice_crud_operations()
finally:
await test_instance.teardown()
@@ -0,0 +1,136 @@
from datetime import datetime
import pytest
from playwright.async_api import async_playwright
from common.base_page_test import BasePageTest
from common.config import Config
class PostManagementTest(BasePageTest):
"""岗位管理测试类"""
def generate_post_data(self) -> dict:
"""生成测试数据"""
timestamp = datetime.now().strftime('%H%M%S')
return {
'post_name': f'测试岗位_{timestamp}',
'post_code': f'test_{timestamp}',
'post_sort': '10',
'remark': f'测试备注_{timestamp}',
}
async def test_post_crud_operations(self) -> None:
"""测试岗位增删改查流程"""
# 1. 导航到岗位管理页面
await self.navigate_to_post_management()
# 生成测试数据
data = self.generate_post_data()
# 2. 新增岗位
await self.create_post(data['post_name'], data['post_code'], data['post_sort'])
# 3. 搜索岗位
await self.search_post(data['post_name'], data['post_code'])
# 4. 修改岗位
await self.edit_post(data['post_name'], data['remark'])
# 5. 删除岗位
await self.delete_post(data['post_name'])
async def navigate_to_post_management(self) -> None:
"""导航到岗位管理"""
# 直接导航到岗位管理页面
await self.goto_page(Config.frontend_url + '/system/post')
# 等待页面加载
await self.wait_for_selector('.app-container')
async def create_post(self, post_name: str, post_code: str, post_sort: str) -> None:
"""新增岗位"""
await self.page.get_by_role('button', name='新增').click()
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 填写表单
# 注意:Element Plus 表单必填项可能有 * 号,Label 匹配可能需要注意
await dialog.get_by_role('textbox', name='岗位名称').fill(post_name)
await dialog.get_by_role('textbox', name='岗位编码').fill(post_code)
# 岗位顺序是 input-number
# script.py 使用 spinbutton
await dialog.get_by_role('spinbutton', name='岗位顺序').fill(post_sort)
await dialog.get_by_role('button', name='确 定').click()
# 等待成功提示
await self.wait_for_selector("div:has-text('新增成功')", timeout=10000)
async def search_post(self, post_name: str, post_code: str) -> None:
"""搜索岗位"""
# 填写搜索表单
# 限定在搜索区域,防止定位到弹窗或其他地方(虽然此时没有弹窗)
search_form = self.page.locator('.el-form').first
await search_form.get_by_role('textbox', name='岗位编码').fill(post_code)
await search_form.get_by_role('textbox', name='岗位名称').fill(post_name)
await self.page.get_by_role('button', name='搜索').click()
# 等待表格加载结果
# 验证表格中包含刚创建的岗位
await self.wait_for_selector(f"tbody tr:has-text('{post_name}')", timeout=10000)
async def edit_post(self, post_name: str, remark: str) -> None:
"""修改岗位"""
# 确保当前显示的是我们要修改的岗位
await self.search_post(post_name, '')
row = self.page.locator('tbody tr').first
# 点击修改按钮
await row.get_by_role('button', name='修改').click()
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 修改备注
await dialog.get_by_role('textbox', name='备注').fill(remark)
await dialog.get_by_role('button', name='确 定').click()
# 等待成功提示
await self.wait_for_selector("div:has-text('修改成功')", timeout=10000)
async def delete_post(self, post_name: str) -> None:
"""删除岗位"""
# 确保当前显示的是我们要删除的岗位
search_form = self.page.locator('.el-form').first
await search_form.get_by_role('textbox', name='岗位编码').clear()
await search_form.get_by_role('textbox', name='岗位名称').fill(post_name)
await self.page.get_by_role('button', name='搜索').click()
await self.wait_for_selector(f"tbody tr:has-text('{post_name}')", timeout=10000)
row = self.page.locator('tbody tr').first
# 点击删除按钮
await row.get_by_role('button', name='删除').click()
# 确认删除
await self.page.get_by_role('button', name='确定').click()
# 等待成功提示
await self.wait_for_selector("div:has-text('删除成功')", timeout=10000)
@pytest.mark.asyncio
async def test_post_management_page() -> None:
"""测试岗位管理页面功能"""
async with async_playwright() as p:
test_instance = PostManagementTest()
await test_instance.setup(p)
try:
await test_instance.test_post_crud_operations()
finally:
await test_instance.teardown()
@@ -0,0 +1,148 @@
import time
import pytest
from playwright.async_api import async_playwright
from common.base_page_test import BasePageTest
from common.config import Config
class RoleManagementTest(BasePageTest):
"""角色管理测试类"""
def generate_role_data(self) -> dict:
"""生成测试数据"""
timestamp = int(time.time())
return {
'role_name': f'test_role_{timestamp}',
'role_key': f'test_{timestamp}',
}
async def create_role(self, role_name: str, role_key: str, role_sort: int = 3) -> None:
"""创建角色"""
# 点击新增按钮
await self.page.get_by_role('button', name='新增').click()
# 等待对话框
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 填写信息
await dialog.get_by_role('textbox', name='角色名称').fill(role_name)
await dialog.get_by_role('textbox', name='权限字符').fill(role_key)
await dialog.get_by_role('spinbutton', name='角色顺序').fill(str(role_sort))
# 选择菜单权限 (点击第一个复选框)
await self.page.locator('.el-tree-node__content .el-checkbox').first.click()
# 确定
await self.page.get_by_role('button', name='确 定').click()
# 等待成功提示
await self.wait_for_selector('div:has-text("新增成功")', timeout=10000)
async def search_role(self, role_name: str, role_key: str) -> None:
"""搜索角色"""
search_form = self.page.locator('form').first
await search_form.get_by_placeholder('请输入角色名称').fill(role_name)
await search_form.get_by_placeholder('请输入权限字符').fill(role_key)
await search_form.get_by_role('button', name='搜索').click()
# 等待加载
await self.page.wait_for_timeout(1000)
async def edit_role(self, new_remark: str) -> None:
"""编辑角色"""
# 点击修改按钮 (第一行)
# 按钮没有文字,只有图标,所以使用 nth(0)
row = self.page.locator('tbody tr').first
await row.get_by_role('button').nth(0).click()
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 修改备注
await dialog.get_by_role('textbox', name='备注').fill(new_remark)
# 确定
await self.page.get_by_role('button', name='确 定').click()
# 等待成功提示
await self.wait_for_selector('div:has-text("修改成功")', timeout=10000)
async def change_role_status(self) -> None:
"""修改角色状态"""
# 点击开关 (第一行)
# 使用 .el-switch__core 或者 role=switch
# script.py uses .el-switch__action, let's try .el-switch
switch = self.page.locator('.el-switch').first
await switch.click()
# 确认对话框
await self.page.get_by_role('button', name='确定').click()
await self.wait_for_selector('div:has-text("成功")', timeout=5000)
# 再次点击恢复
await self.page.wait_for_timeout(1000)
await switch.click()
await self.page.get_by_role('button', name='确定').click()
await self.wait_for_selector('div:has-text("成功")', timeout=5000)
async def delete_role(self) -> None:
"""删除角色"""
# 点击删除按钮 (第一行第二个按钮)
row = self.page.locator('tbody tr').first
await row.get_by_role('button').nth(1).click()
# 确认删除
await self.page.get_by_role('button', name='确定').click()
# 等待成功提示
await self.wait_for_selector('div:has-text("删除成功")', timeout=10000)
# 重置搜索
await self.page.get_by_role('button', name='重置').click()
await self.page.wait_for_timeout(1000)
async def test_role_crud_operations(self) -> None:
"""测试角色管理CRUD"""
# 访问页面
await self.goto_page(Config.frontend_url + '/system/role')
await self.wait_for_page_title('角色管理', timeout=10000)
# 生成测试数据
data = self.generate_role_data()
# 1. 新增
await self.create_role(data['role_name'], data['role_key'])
# 验证新增结果
await self.search_role(data['role_name'], data['role_key'])
rows = await self.get_table_total_rows()
assert rows >= 1, '新增后搜索应有结果'
# 2. 修改状态
await self.change_role_status()
# 3. 编辑
await self.edit_role('Updated remark')
# 4. 删除
await self.delete_role()
# 验证删除结果
await self.search_role(data['role_name'], data['role_key'])
rows_after = await self.get_table_total_rows()
assert rows_after == 0, '删除后搜索应无结果'
@pytest.mark.asyncio
async def test_role_management_page() -> None:
"""测试角色管理页面功能"""
async with async_playwright() as p:
test_instance = RoleManagementTest()
await test_instance.setup(p)
try:
await test_instance.test_role_crud_operations()
finally:
await test_instance.teardown()
@@ -0,0 +1,170 @@
import time
import pytest
from playwright.async_api import async_playwright
from common.base_page_test import BasePageTest
from common.config import Config
class UserManagementTest(BasePageTest):
"""用户管理测试类"""
def generate_user_data(self) -> dict:
"""生成测试数据"""
timestamp = int(time.time())
return {
'user_name': f'test_{timestamp}',
'nick_name': f'测试用户_{timestamp}',
'phone': '13888888888',
}
async def create_user(self, user_name: str, nick_name: str, phone: str) -> None:
"""创建用户"""
# 点击新增按钮
await self.page.get_by_role('button', name='新增').click()
# 等待对话框
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 填写基本信息
await dialog.get_by_role('textbox', name='用户昵称').fill(nick_name)
# 归属部门
# 使用 label 定位父级 form-item,再点击内部的 wrapper
await dialog.locator('div.el-form-item').filter(has_text='归属部门').locator('.el-select__wrapper').click()
# 选择 "集团总公司" (根节点)
# 使用 .el-popper 定位下拉框中的内容
await self.page.locator('.el-popper:visible').get_by_text('集团总公司').click()
await dialog.get_by_role('textbox', name='用户名称').fill(user_name)
# 手机号码在搜索框也有,所以必须限定在 dialog 内
# 注意:有些 label 可能不带 *,或者带 * 但 get_by_role 需要准确匹配
# 这里使用 get_by_placeholder 或者 filter 来定位更稳妥,或者直接 scope 到 dialog
await dialog.locator('div.el-form-item').filter(has_text='手机号码').get_by_role('textbox').fill(phone)
await dialog.locator('div.el-form-item').filter(has_text='邮箱').get_by_role('textbox').fill('test@example.com')
await dialog.locator('div.el-form-item').filter(has_text='用户密码').get_by_role('textbox').fill('123456')
# 用户性别
# 定位 label 为 "用户性别" 的父级 form-item,然后找里面的 "请选择" 文本
await dialog.locator('div.el-form-item').filter(has_text='用户性别').get_by_text('请选择').click()
await self.page.get_by_role('option', name='').click()
# 岗位
await dialog.locator('div.el-form-item').filter(has_text='岗位').get_by_text('请选择').click()
await self.page.get_by_role('option', name='董事长').click()
# 角色
await dialog.locator('div.el-form-item').filter(has_text='角色').get_by_text('请选择').click()
await self.page.get_by_role('option', name='普通角色').click()
await dialog.locator('div.el-form-item').filter(has_text='备注').get_by_role('textbox').fill('测试用户')
# 点击确定
await self.page.get_by_role('button', name='确 定').click()
# 等待成功消息
await self.wait_for_selector("div:has-text('成功')", timeout=10000)
async def search_user(self, user_name: str) -> None:
"""搜索用户"""
search_form = self.page.locator('form').first
await search_form.get_by_role('textbox', name='用户名称').fill(user_name)
await search_form.get_by_role('button', name='搜索').click()
# 等待加载
await self.page.wait_for_timeout(1000)
async def edit_user(self) -> None:
"""编辑用户"""
# 点击修改按钮 (第一行)
row = self.page.locator('tbody tr').first
await row.get_by_role('button').nth(0).click()
dialog = self.page.get_by_role('dialog')
await dialog.wait_for()
# 修改性别为女
await dialog.locator('div.el-form-item').filter(has_text='用户性别').locator('.el-select').click()
await self.page.get_by_role('option', name='').click()
# 确定
await self.page.get_by_role('button', name='确 定').click()
# 等待成功提示
await self.wait_for_selector('div:has-text("修改成功")', timeout=10000)
async def change_user_status(self) -> None:
"""修改用户状态"""
# 点击开关 (第一行)
switch = self.page.locator('.el-switch').first
await switch.click()
# 确认对话框
await self.page.get_by_role('button', name='确定').click()
await self.wait_for_selector('div:has-text("成功")', timeout=5000)
# 再次点击恢复
await self.page.wait_for_timeout(1000)
await switch.click()
await self.page.get_by_role('button', name='确定').click()
await self.wait_for_selector('div:has-text("成功")', timeout=5000)
async def delete_user(self) -> None:
"""删除用户"""
# 点击删除按钮 (第一行第二个按钮)
row = self.page.locator('tbody tr').first
await row.get_by_role('button').nth(1).click()
# 确认删除
await self.page.get_by_role('button', name='确定').click()
# 等待成功提示
await self.wait_for_selector('div:has-text("删除成功")', timeout=10000)
# 重置搜索
await self.page.get_by_role('button', name='重置').click()
await self.page.wait_for_timeout(1000)
async def test_user_crud_operations(self) -> None:
"""测试用户管理增删查改"""
# 访问页面
await self.goto_page(Config.frontend_url + '/system/user')
await self.wait_for_page_title('用户管理', timeout=10000)
# 生成测试数据
data = self.generate_user_data()
# 1. 新增
await self.create_user(data['user_name'], data['nick_name'], data['phone'])
# 验证新增结果
await self.search_user(data['user_name'])
rows = await self.get_table_total_rows()
assert rows >= 1, '新增后搜索应有结果'
# 2. 修改状态
await self.change_user_status()
# 3. 编辑
await self.edit_user()
# 4. 删除
await self.delete_user()
# 验证删除结果
await self.search_user(data['user_name'])
rows_after = await self.get_table_total_rows()
assert rows_after == 0, '删除后搜索应无结果'
@pytest.mark.asyncio
async def test_user_management_page() -> None:
"""测试用户管理页面功能"""
async with async_playwright() as p:
test_instance = UserManagementTest()
await test_instance.setup(p)
try:
await test_instance.test_user_crud_operations()
finally:
await test_instance.teardown()
+202
View File
@@ -0,0 +1,202 @@
import pytest
from playwright.async_api import async_playwright
from common.config import Config
from common.login_helper import LoginHelper
@pytest.mark.asyncio
async def test_login_page_loads() -> None:
"""测试登录页面是否能正常加载"""
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# 访问登录页面
await page.goto(Config.frontend_url + '/login')
# 检查页面标题或特定元素是否存在
title = await page.title()
assert 'vfadmin' in title
# 检查登录表单元素是否存在
username_input = await page.query_selector('input[placeholder="账号"]')
password_input = await page.query_selector('input[placeholder="密码"]')
assert username_input is not None
assert password_input is not None
await browser.close()
@pytest.mark.asyncio
async def test_captcha_generation() -> None:
"""测试验证码是否正常生成(在测试环境中,验证码已禁用,但仍应能访问登录页面)"""
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# 访问登录页面
await page.goto(Config.frontend_url + '/login')
# 在测试环境中验证码已禁用,但仍应能访问登录页面
title = await page.title()
assert 'vfadmin' in title
await browser.close()
def test_login_without_captcha() -> None:
"""测试在禁用验证码的情况下登录流程"""
helper = LoginHelper()
# 尝试登录(测试环境中验证码已禁用)
token = helper.login(username='admin', password='admin123')
# 验证登录结果
assert token is not None, '登录应该成功'
assert len(token) > 0, '应该返回有效的token'
@pytest.mark.asyncio
async def test_login_flow_with_playwright() -> None:
"""使用Playwright测试完整的登录流程(测试环境中验证码已禁用)"""
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# 访问登录页面
await page.goto(Config.frontend_url + '/login')
# 等待页面加载
await page.wait_for_selector('input[placeholder="账号"]')
# 输入用户名和密码
await page.fill('input[placeholder="账号"]', 'admin')
await page.fill('input[placeholder="密码"]', 'admin123')
# 在测试环境中验证码已禁用,所以不需要处理验证码
# 直接点击登录按钮
# 尝试多种可能的选择器
try:
await page.click('button:has-text("登录")')
except Exception:
try:
await page.click('button[type="submit"]')
except Exception:
await page.click('button.el-button')
# 等待页面跳转或检查登录结果
try:
# 等待跳转到主页或检查登录成功消息
await page.wait_for_url('**/index**', timeout=10000)
success = True
except Exception:
# 检查是否有错误消息
error_message = await page.query_selector('.el-message')
if error_message:
error_text = await error_message.text_content()
print(f'登录失败: {error_text}')
success = False
else:
success = False
assert success, '登录应该成功'
await browser.close()
@pytest.mark.asyncio
async def test_protected_routes_require_auth() -> None:
"""测试受保护的路由需要认证"""
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
# 直接访问受保护的页面(如用户管理)
await page.goto(Config.frontend_url + '/system/user')
# 检查是否被重定向到登录页面
current_url = page.url
# 检查页面是否包含登录相关的元素(如账号密码输入框)
try:
has_username_input = await page.is_visible('input[placeholder*="账号" i], input[placeholder*="username" i]')
except Exception:
has_username_input = False
try:
has_password_input = await page.is_visible('input[placeholder*="密码" i], input[placeholder*="password" i]')
except Exception:
has_password_input = False
try:
has_login_button = await page.is_visible('button:has-text("登录"), button:has-text("Login"), .login-btn')
except Exception:
has_login_button = False
# 检查URL是否包含login路径
is_redirected_to_login = '/login' in current_url.lower() or '/#/login' in current_url
# 断言:应该重定向到登录页面或页面包含登录相关元素
assert is_redirected_to_login or has_username_input or has_password_input or has_login_button, (
f'未登录用户访问受保护页面时,没有重定向到登录页。当前URL: {current_url}, 页面内容: {await page.content()}'
)
await browser.close()
@pytest.mark.asyncio
async def test_authenticated_access() -> None:
"""测试认证后的访问"""
# 首先通过API登录获取token
helper = LoginHelper()
token = helper.login(username='admin', password='admin123')
assert token is not None, '登录应该成功'
# 使用Playwright测试带认证的访问
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context(
storage_state={ # 使用storage state来保持登录状态
'cookies': [
{
'name': 'Admin-Token',
'value': token,
'domain': 'localhost',
'path': '/',
'httpOnly': False,
'secure': False,
'sameSite': 'Lax',
}
],
'origins': [],
}
)
page = await context.new_page()
# 访问受保护的页面
await page.goto(Config.frontend_url + '/system/user')
# 检查是否能够访问受保护的页面(而不是被重定向到登录页)
current_url = page.url
assert '/login' not in current_url, '已登录用户应该能够访问受保护页面'
# 检查页面是否包含用户管理相关的元素
try:
# 等待页面加载
await page.wait_for_selector('div:has-text("用户管理")', timeout=10000)
has_user_management = True
except Exception:
has_user_management = False
# 或者检查是否有表格或其他用户管理组件
table_element = await page.query_selector('el-table')
if not has_user_management and table_element:
has_user_management = True
assert has_user_management, '应该能够访问用户管理页面'
await context.close()
await browser.close()
+119
View File
@@ -0,0 +1,119 @@
import pytest
from playwright.async_api import async_playwright
from common.config import Config
from common.login_helper import LoginHelper
@pytest.mark.asyncio
async def test_dashboard_page() -> None:
"""测试仪表盘页面"""
# 首先登录获取token
helper = LoginHelper()
token = helper.login(username='admin', password='admin123')
assert token is not None, '登录应该成功'
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context()
# 设置认证token
await context.add_cookies(
[
{
'name': 'Admin-Token',
'value': token,
'domain': 'localhost',
'path': '/',
'httpOnly': False,
'secure': False,
}
]
)
page = await context.new_page()
# 访问仪表盘页面
await page.goto(Config.frontend_url + '/index')
# 检查页面是否包含仪表盘相关元素
await page.wait_for_selector('div:has-text("首页")', timeout=10000)
title = await page.inner_text('div:has-text("首页")')
assert '首页' in title
await context.close()
await browser.close()
@pytest.mark.asyncio
async def test_druid_page() -> None:
"""测试数据监控页面"""
# 首先登录获取token
helper = LoginHelper()
token = helper.login(username='admin', password='admin123')
assert token is not None, '登录应该成功'
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context()
# 设置认证token
await context.add_cookies(
[
{
'name': 'Admin-Token',
'value': token,
'domain': 'localhost',
'path': '/',
'httpOnly': False,
'secure': False,
}
]
)
page = await context.new_page()
# 访问数据库监控页面
await page.goto(Config.frontend_url + '/monitor/druid')
# 检查页面是否包含缓存监控相关元素
await page.wait_for_selector('div:has-text("数据监控")', timeout=10000)
title = await page.inner_text('div:has-text("数据监控")')
assert '数据监控' in title
await context.close()
await browser.close()
@pytest.mark.asyncio
async def test_build_page() -> None:
"""测试表单构建页面"""
# 首先登录获取token
helper = LoginHelper()
token = helper.login(username='admin', password='admin123')
assert token is not None, '登录应该成功'
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context()
# 设置认证token
await context.add_cookies(
[
{
'name': 'Admin-Token',
'value': token,
'domain': 'localhost',
'path': '/',
'httpOnly': False,
'secure': False,
}
]
)
page = await context.new_page()
# 访问数据库监控页面
await page.goto(Config.frontend_url + '/tool/build')
# 检查页面是否包含缓存监控相关元素
await page.wait_for_selector('div:has-text("Form Generator")', timeout=10000)
title = await page.inner_text('div:has-text("Form Generator")')
assert '表单构建' in title
await context.close()
await browser.close()
View File
+215
View File
@@ -0,0 +1,215 @@
import re
import pytest
from playwright.async_api import async_playwright, expect
from common.base_page_test import BasePageTest
from common.config import Config
class GenTableTest(BasePageTest):
"""代码生成业务测试"""
async def test_gen_code_flow(self) -> None:
"""测试代码生成全流程:导入 -> 编辑 -> 预览 -> 删除"""
table_name = 'sys_post'
# 1. 导航到代码生成页面
await self.navigate_to_gen_page()
# 确保环境清理 (如果存在 sys_post 则先删除)
await self.ensure_table_not_exists(table_name)
# 2. 导入表
await self.import_table(table_name)
# 3. 编辑表
await self.edit_table(table_name, '测试生成')
# 4. 预览代码
await self.preview_code(table_name)
await self.page.wait_for_timeout(1000)
# 5. 删除表 (清理环境)
await self.delete_table(table_name)
async def navigate_to_gen_page(self) -> None:
"""导航到代码生成页面"""
await self.goto_page(Config.frontend_url + '/tool/gen')
await self.wait_for_selector('.app-container')
async def ensure_table_not_exists(self, table_name: str) -> None:
"""确保表不存在 (如果存在则删除)"""
await self.delete_table(table_name, verify=False)
async def search_table(self, table_name: str) -> None:
"""搜索表"""
# 确保在主搜索表单中搜索
search_form = self.page.locator('.el-form').first
await search_form.get_by_role('textbox', name='表名称').fill(table_name)
await search_form.get_by_role('button', name='搜索').click()
# 等待加载
await self.page.wait_for_timeout(500)
async def import_table(self, table_name: str) -> None:
"""导入表"""
await self.page.get_by_role('button', name='导入').click()
dialog = self.page.locator("div[role='dialog'][aria-label='导入表']")
await dialog.wait_for()
# 搜索要导入的表
await dialog.get_by_placeholder('请输入表名称').fill(table_name)
await dialog.get_by_role('button', name='搜索').click()
# 等待搜索结果
await dialog.locator(f"tr:has-text('{table_name}')").wait_for()
# 选中行
row = dialog.locator('tr').filter(has=self.page.get_by_text(table_name, exact=True))
# 点击复选框
checkbox = row.locator('.el-checkbox')
await checkbox.click()
# 验证已选中
# Element Plus checkbox 选中时,最外层 label.el-checkbox 会有 is-checked 类
await expect(checkbox).to_have_class(re.compile(r'is-checked'))
await self.page.wait_for_timeout(500)
await dialog.get_by_role('button', name='确 定').click()
# 检查是否有"请选择要导入的表"错误
try:
await expect(self.page.get_by_text('请选择要导入的表')).to_be_visible(timeout=2000)
print("ERROR: Selection failed, '请选择要导入的表' appeared.")
except AssertionError:
pass
# 等待一会,让弹窗自动关闭
await self.page.wait_for_timeout(5000)
# 如果弹窗还在,尝试关闭它以免阻塞后续操作
if await dialog.is_visible():
print('WARNING: Import dialog still visible after timeout. Forcing close.')
# Check for error messages
if await self.page.locator('.el-message--error').count() > 0:
msg = await self.page.locator('.el-message--error').all_inner_texts()
print(f'ERROR MESSAGE: {msg}')
# 点击取消关闭弹窗
await dialog.get_by_role('button', name='取 消').click()
await expect(dialog).to_be_hidden()
# 手动刷新列表
await self.page.get_by_role('button', name='搜索').click()
# 验证导入成功 (搜索并在列表中看到)
# 使用 .app-container 限定在主页面表格,避免匹配到弹窗中的隐藏行
await self.page.locator('.app-container').locator('tbody tr', has_text=table_name).wait_for()
async def edit_table(self, table_name: str, remark: str) -> None:
"""编辑表"""
await self.search_table(table_name)
row = self.page.locator(f"tbody tr:has-text('{table_name}')")
# 点击编辑 (操作列第2个按钮,索引1)
# 按钮顺序: 预览, 编辑, 删除, 同步, 生成
await row.locator('button').nth(1).click()
# 等待编辑页面 (tab页)
await self.page.wait_for_selector("div[role='tablist']")
# 修改基本信息 -> 表描述
# 确保在基本信息 Tab
await self.page.get_by_text('基本信息').click()
await self.page.get_by_role('textbox', name='表描述').fill(remark)
# 提交
await self.page.get_by_role('button', name='提交').click()
# 验证回到列表
await self.wait_for_selector('.app-container')
# 验证描述已更新
await self.search_table(table_name)
await self.wait_for_selector(f"tbody tr:has-text('{remark}')")
async def preview_code(self, table_name: str) -> None:
"""预览代码"""
await self.search_table(table_name)
row = self.page.locator(f"tbody tr:has-text('{table_name}')")
# 点击预览 (操作列第1个按钮,索引0)
await row.locator('button').nth(0).click()
# 等待预览弹窗
dialog = self.page.locator("div[role='dialog'][aria-label='代码预览']")
await dialog.wait_for()
# 验证存在代码内容
# pre 可能有多个(多tab),只检查可见的
await dialog.locator('pre:visible').first.wait_for()
content = await dialog.locator('pre:visible').first.text_content()
assert 'class' in content or 'import' in content or 'package' in content
# 关闭预览 (点击右上角关闭按钮)
await dialog.locator('.el-dialog__headerbtn').click()
async def delete_table(self, table_name: str, verify: bool = True) -> None:
"""删除表 (支持删除多条重复数据)"""
# 循环删除直到不存在
for _i in range(5):
await self.search_table(table_name)
# 使用 strict matching
# 限制在 .app-container .el-table__body-wrapper 以避免固定列导致的重复 以及 避免匹配到弹窗中的行
row = self.page.locator('.app-container .el-table__body-wrapper tbody tr').filter(
has=self.page.get_by_text(table_name, exact=True)
)
count = await row.count()
if count == 0:
break
# 针对第一行操作
# 使用 force=True 确保点击,防止遮挡
btns = row.first.locator('button')
await btns.nth(2).click(force=True)
# 处理确认弹窗
await self.page.get_by_role('button', name='确定').click()
# 等待删除成功提示
# 使用 specific selector 避免匹配到代码预览中的文本
await expect(self.page.locator('.el-message__content').filter(has_text='删除成功')).to_be_visible(
timeout=5000
)
# 等待提示消失,防止干扰下一次操作
await expect(self.page.locator('.el-message__content').filter(has_text='删除成功')).to_be_hidden(
timeout=5000
)
if verify:
# 验证删除成功
# 重新搜索验证不存在
await self.search_table(table_name)
# 使用 strict matching
row = self.page.locator('.app-container .el-table__body-wrapper tbody tr').filter(
has=self.page.get_by_text(table_name, exact=True)
)
# 期望找不到或者 count为0
await expect(row).to_have_count(0)
@pytest.mark.asyncio
async def test_gen_table_page() -> None:
"""测试代码生成页面功能"""
async with async_playwright() as p:
test_instance = GenTableTest()
await test_instance.setup(p)
try:
await test_instance.test_gen_code_flow()
finally:
await test_instance.teardown()
+37
View File
@@ -0,0 +1,37 @@
import pytest
from playwright.async_api import async_playwright, expect
from common.base_page_test import BasePageTest
from common.config import Config
class SwaggerTest(BasePageTest):
async def check_swagger_interface(self) -> None:
"""测试系统接口页面 (Swagger UI)"""
# 1. 直接导航到系统接口页面
await self.goto_page(Config.frontend_url + '/tool/swagger')
# 2. 验证页面加载
# 等待 iframe 出现
iframe = self.page.locator('iframe')
await expect(iframe).to_be_visible()
# 获取 iframe 内容框架
frame = self.page.frame_locator('iframe')
# 验证 iframe 内部的标题包含 "RuoYi-FastAPI"
h1_locator = frame.locator('h1')
await expect(h1_locator).to_contain_text('RuoYi-FastAPI', timeout=15000)
@pytest.mark.asyncio
async def test_swagger_page() -> None:
"""测试系统接口页面功能"""
async with async_playwright() as p:
test_instance = SwaggerTest()
await test_instance.setup(p)
try:
await test_instance.check_swagger_interface()
finally:
await test_instance.teardown()