feat(admin): 添加用户管理相关文件
添加用户管理视图、API和状态管理文件
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# 开发环境配置
|
||||
NODE_ENV=development
|
||||
VITE_APP_ENV=development
|
||||
VITE_API_BASE_URL=https://dev-api.example.com
|
||||
VITE_MOCK_ENABLED=false
|
||||
@@ -0,0 +1,5 @@
|
||||
# 本地开发环境配置
|
||||
NODE_ENV=development
|
||||
VITE_APP_ENV=development-local
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8080
|
||||
VITE_MOCK_ENABLED=true
|
||||
@@ -0,0 +1,6 @@
|
||||
# E2E测试环境配置
|
||||
NODE_ENV=development
|
||||
VITE_APP_ENV=e2e-test
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8082
|
||||
VITE_MOCK_ENABLED=false
|
||||
VITE_E2E_TEST=true
|
||||
@@ -0,0 +1,5 @@
|
||||
# 生产环境配置
|
||||
NODE_ENV=production
|
||||
VITE_APP_ENV=production
|
||||
VITE_API_BASE_URL=https://api.example.com
|
||||
VITE_MOCK_ENABLED=false
|
||||
@@ -0,0 +1,5 @@
|
||||
# 测试环境配置
|
||||
NODE_ENV=development
|
||||
VITE_APP_ENV=test
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8080
|
||||
VITE_MOCK_ENABLED=true
|
||||
@@ -0,0 +1,21 @@
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
COPY nginx.test.conf /etc/nginx/nginx.conf
|
||||
|
||||
EXPOSE 5174
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 162 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 320 KiB |
@@ -0,0 +1,733 @@
|
||||
# E2E测试工具使用指南
|
||||
|
||||
## 概述
|
||||
|
||||
本E2E测试工具是一个基于Playwright的可复用端到端测试框架,提供了模块化的测试用例编写能力、统一的测试环境配置、常用测试操作的封装与复用,以及清晰的测试报告与日志输出能力。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
e2e/
|
||||
├── core/ # 核心模块
|
||||
│ ├── test-config.ts # 测试配置管理
|
||||
│ ├── test-data.ts # 测试数据生成器
|
||||
│ ├── test-logger.ts # 测试日志记录器
|
||||
│ └── test-reporter.ts # 测试报告生成器
|
||||
├── pages/ # 页面对象模型
|
||||
│ ├── base-page.ts # 基础页面类
|
||||
│ ├── login-page.ts # 登录页面
|
||||
│ ├── dashboard-page.ts # 仪表盘页面
|
||||
│ ├── user-management-page.ts # 用户管理页面
|
||||
│ ├── role-management-page.ts # 角色管理页面
|
||||
│ └── menu-management-page.ts # 菜单管理页面
|
||||
├── helpers/ # 测试辅助工具
|
||||
│ ├── screenshot-helper.ts # 截图辅助工具
|
||||
│ ├── form-helper.ts # 表单辅助工具
|
||||
│ └── table-helper.ts # 表格辅助工具
|
||||
├── fixtures/ # 测试夹具
|
||||
├── utils/ # 工具函数
|
||||
│ └── common-utils.ts # 通用工具函数
|
||||
├── constants/ # 常量定义
|
||||
│ └── index.ts # 常量集合
|
||||
├── examples/ # 示例测试
|
||||
│ └── complete-example.spec.ts
|
||||
├── test-fixtures.ts # Playwright测试夹具
|
||||
└── mock-manager.ts # Mock服务管理器
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
npm install --save-dev @playwright/test
|
||||
```
|
||||
|
||||
### 2. 配置测试环境
|
||||
|
||||
在项目根目录创建 `.env.e2e` 文件:
|
||||
|
||||
```env
|
||||
# E2E测试环境配置
|
||||
E2E_ENV=local
|
||||
E2E_BASE_URL=http://localhost:5173
|
||||
E2E_MOCK_ENABLED=true
|
||||
E2E_MOCK_MODE=full
|
||||
```
|
||||
|
||||
### 3. 编写测试用例
|
||||
|
||||
使用提供的测试夹具编写测试用例:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from './test-fixtures';
|
||||
|
||||
test.describe('登录功能测试', () => {
|
||||
test('成功登录', async ({ pageObjects, testData, testLogger }) => {
|
||||
testLogger.startTest('成功登录');
|
||||
|
||||
try {
|
||||
await pageObjects.loginPage.navigate();
|
||||
await pageObjects.loginPage.login(testData.admin.username, testData.admin.password);
|
||||
|
||||
await pageObjects.dashboardPage.waitForLoad();
|
||||
|
||||
const pageTitle = await pageObjects.dashboardPage.getPageTitle();
|
||||
expect(pageTitle).toContain('仪表盘');
|
||||
|
||||
testLogger.endTest('成功登录', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('成功登录', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 4. 运行测试
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
npm run test:e2e
|
||||
|
||||
# 运行特定测试文件
|
||||
npx playwright test e2e/examples/complete-example.spec.ts
|
||||
|
||||
# 运行特定测试用例
|
||||
npx playwright test -g "成功登录"
|
||||
|
||||
# 调试模式运行
|
||||
npx playwright test --debug
|
||||
```
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 1. 测试配置管理
|
||||
|
||||
`test-config.ts` 提供了统一的测试环境配置管理:
|
||||
|
||||
```typescript
|
||||
import { testConfig } from './core/test-config';
|
||||
|
||||
// 获取当前环境配置
|
||||
const env = testConfig.getEnvironment();
|
||||
console.log(env.name); // 环境名称
|
||||
console.log(env.baseURL); // 基础URL
|
||||
console.log(env.mockEnabled); // Mock是否启用
|
||||
console.log(env.timeout); // 超时配置
|
||||
|
||||
// 切换环境
|
||||
testConfig.setEnvironment('dev');
|
||||
|
||||
// 获取特定环境配置
|
||||
const devConfig = testConfig.getEnvironment('dev');
|
||||
```
|
||||
|
||||
### 2. 测试数据生成
|
||||
|
||||
`test-data.ts` 提供了测试数据生成器:
|
||||
|
||||
```typescript
|
||||
import { testDataGenerator } from './core/test-data';
|
||||
|
||||
// 生成用户数据
|
||||
const userData = testDataGenerator.generateUserData({
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
// 生成角色数据
|
||||
const roleData = testDataGenerator.generateRoleData({
|
||||
roleName: '测试角色',
|
||||
roleCode: 'test_role',
|
||||
status: 1
|
||||
});
|
||||
|
||||
// 生成菜单数据
|
||||
const menuData = testDataGenerator.generateMenuData({
|
||||
menuName: '测试菜单',
|
||||
menuType: 1,
|
||||
path: '/test',
|
||||
status: 0
|
||||
});
|
||||
|
||||
// 生成权限数据
|
||||
const permissionData = testDataGenerator.generatePermissionData({
|
||||
permissionName: '测试权限',
|
||||
permissionCode: 'test:permission',
|
||||
permissionType: 'button'
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 测试日志记录
|
||||
|
||||
`test-logger.ts` 提供了结构化的测试日志记录:
|
||||
|
||||
```typescript
|
||||
import { testLogger } from './core/test-logger';
|
||||
|
||||
// 开始测试
|
||||
testLogger.startTest('测试名称');
|
||||
|
||||
// 开始测试步骤
|
||||
testLogger.startStep('步骤名称');
|
||||
|
||||
// 记录不同级别的日志
|
||||
testLogger.debug('调试信息');
|
||||
testLogger.info('普通信息');
|
||||
testLogger.warn('警告信息');
|
||||
testLogger.error('错误信息', error);
|
||||
|
||||
// 结束测试步骤
|
||||
testLogger.endStep('步骤名称', 'passed');
|
||||
|
||||
// 结束测试
|
||||
testLogger.endTest('测试名称', 'passed');
|
||||
```
|
||||
|
||||
### 4. 测试报告生成
|
||||
|
||||
`test-reporter.ts` 提供了测试报告生成功能:
|
||||
|
||||
```typescript
|
||||
import { testReporter } from './core/test-reporter';
|
||||
|
||||
// 开始测试报告
|
||||
testReporter.startReport();
|
||||
|
||||
// 记录测试结果
|
||||
testReporter.recordTestResult({
|
||||
testName: '测试名称',
|
||||
status: 'passed',
|
||||
duration: 1000,
|
||||
steps: [],
|
||||
logs: [],
|
||||
screenshots: [],
|
||||
errors: []
|
||||
});
|
||||
|
||||
// 生成所有报告
|
||||
await testReporter.generateAllReports('./test-results/reports');
|
||||
|
||||
// 生成JSON报告
|
||||
await testReporter.generateJSONReport('./test-results/reports/e2e-report.json');
|
||||
|
||||
// 生成HTML报告
|
||||
await testReporter.generateHTMLReport('./test-results/reports/e2e-report.html');
|
||||
```
|
||||
|
||||
### 5. 页面对象模型
|
||||
|
||||
所有页面类都继承自 `BasePage`,提供统一的页面操作接口:
|
||||
|
||||
```typescript
|
||||
import { BasePage } from './pages/base-page';
|
||||
import { LoginPage } from './pages/login-page';
|
||||
import { DashboardPage } from './pages/dashboard-page';
|
||||
|
||||
// 使用页面对象
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.navigate();
|
||||
await loginPage.login('admin', 'password');
|
||||
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.waitForLoad();
|
||||
const title = await dashboardPage.getPageTitle();
|
||||
```
|
||||
|
||||
### 6. 测试辅助工具
|
||||
|
||||
#### 截图辅助工具
|
||||
|
||||
```typescript
|
||||
import { ScreenshotHelper } from './helpers/screenshot-helper';
|
||||
|
||||
const screenshotHelper = new ScreenshotHelper(page);
|
||||
|
||||
// 截取当前页面
|
||||
await screenshotHelper.takeScreenshot('page-screenshot');
|
||||
|
||||
// 截取整个页面
|
||||
await screenshotHelper.takeFullPageScreenshot('full-page');
|
||||
|
||||
// 截取特定元素
|
||||
await screenshotHelper.takeElementScreenshot('element-screenshot', '.selector');
|
||||
|
||||
// 在测试失败时自动截图
|
||||
await screenshotHelper.takeScreenshotOnFailure('test-failure');
|
||||
```
|
||||
|
||||
#### 表单辅助工具
|
||||
|
||||
```typescript
|
||||
import { FormHelper } from './helpers/form-helper';
|
||||
|
||||
const formHelper = new FormHelper(page);
|
||||
|
||||
// 填写表单字段
|
||||
await formHelper.fillField('input[name="username"]', 'testuser');
|
||||
await formHelper.fillField('input[type="password"]', 'password', 'password');
|
||||
await formHelper.fillField('select[name="role"]', 'admin', 'select');
|
||||
await formHelper.fillField('input[type="checkbox"]', true, 'checkbox');
|
||||
|
||||
// 填写整个表单
|
||||
await formHelper.fillForm({
|
||||
username: 'testuser',
|
||||
password: 'password',
|
||||
email: 'test@example.com',
|
||||
role: 'admin'
|
||||
});
|
||||
|
||||
// 提交表单
|
||||
await formHelper.submitForm();
|
||||
|
||||
// 重置表单
|
||||
await formHelper.resetForm();
|
||||
|
||||
// 验证表单
|
||||
const isValid = await formHelper.validateForm();
|
||||
```
|
||||
|
||||
#### 表格辅助工具
|
||||
|
||||
```typescript
|
||||
import { TableHelper } from './helpers/table-helper';
|
||||
|
||||
const tableHelper = new TableHelper(page);
|
||||
|
||||
// 获取表格行数
|
||||
const rowCount = await tableHelper.getRowCount('.ant-table');
|
||||
|
||||
// 获取表格列数
|
||||
const columnCount = await tableHelper.getColumnCount('.ant-table');
|
||||
|
||||
// 获取单元格文本
|
||||
const cellText = await tableHelper.getCellText('.ant-table', 0, 0);
|
||||
|
||||
// 获取整行数据
|
||||
const rowData = await tableHelper.getRowData('.ant-table', 0);
|
||||
|
||||
// 获取整列数据
|
||||
const columnData = await tableHelper.getColumnData('.ant-table', 0);
|
||||
|
||||
// 点击表格行
|
||||
await tableHelper.clickRow('.ant-table', 0);
|
||||
|
||||
// 点击表格单元格
|
||||
await tableHelper.clickCell('.ant-table', 0, 0);
|
||||
|
||||
// 等待表格加载
|
||||
await tableHelper.waitForTableLoad('.ant-table');
|
||||
|
||||
// 验证表格数据
|
||||
const isValid = await tableHelper.validateTableData('.ant-table', expectedData);
|
||||
```
|
||||
|
||||
### 7. Mock服务集成
|
||||
|
||||
```typescript
|
||||
import { MockManager } from './mock-manager';
|
||||
|
||||
const mockConfig = {
|
||||
enabled: true,
|
||||
mode: 'full',
|
||||
mockPaths: [],
|
||||
delay: 0,
|
||||
logCalls: true,
|
||||
validateResponses: true,
|
||||
dataSource: 'memory'
|
||||
};
|
||||
|
||||
const mockManager = new MockManager(mockConfig);
|
||||
|
||||
// 拦截API请求
|
||||
await mockManager.interceptAPIRequest(page);
|
||||
|
||||
// 添加Mock响应
|
||||
mockManager.addMockResponse({
|
||||
url: '/api/login',
|
||||
method: 'POST',
|
||||
response: {
|
||||
code: 200,
|
||||
data: {
|
||||
token: 'mock-token',
|
||||
userInfo: {
|
||||
id: 1,
|
||||
username: 'admin'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 清除Mock响应
|
||||
mockManager.clearMockResponses();
|
||||
```
|
||||
|
||||
## 测试夹具
|
||||
|
||||
本工具提供了以下测试夹具:
|
||||
|
||||
### pageObjects
|
||||
|
||||
提供所有页面对象的实例:
|
||||
|
||||
```typescript
|
||||
test('使用页面对象', async ({ pageObjects }) => {
|
||||
await pageObjects.loginPage.navigate();
|
||||
await pageObjects.loginPage.login('admin', 'password');
|
||||
await pageObjects.dashboardPage.waitForLoad();
|
||||
});
|
||||
```
|
||||
|
||||
### helpers
|
||||
|
||||
提供所有辅助工具的实例:
|
||||
|
||||
```typescript
|
||||
test('使用辅助工具', async ({ helpers }) => {
|
||||
await helpers.screenshot.takeScreenshot('test');
|
||||
await helpers.form.fillField('input[name="username"]', 'test');
|
||||
const rowCount = await helpers.table.getRowCount('.ant-table');
|
||||
});
|
||||
```
|
||||
|
||||
### testData
|
||||
|
||||
提供预定义的测试数据:
|
||||
|
||||
```typescript
|
||||
test('使用测试数据', async ({ testData }) => {
|
||||
console.log(testData.user); // 普通用户数据
|
||||
console.log(testData.admin); // 管理员数据
|
||||
console.log(testData.role); // 角色数据
|
||||
console.log(testData.menu); // 菜单数据
|
||||
console.log(testData.permission); // 权限数据
|
||||
});
|
||||
```
|
||||
|
||||
### mockManager
|
||||
|
||||
提供Mock服务管理器:
|
||||
|
||||
```typescript
|
||||
test('使用Mock服务', async ({ mockManager }) => {
|
||||
mockManager.addMockResponse({
|
||||
url: '/api/test',
|
||||
method: 'GET',
|
||||
response: { code: 200, data: 'mock data' }
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### testConfig
|
||||
|
||||
提供测试配置:
|
||||
|
||||
```typescript
|
||||
test('使用测试配置', async ({ testConfig }) => {
|
||||
console.log(testConfig.name); // 环境名称
|
||||
console.log(testConfig.baseURL); // 基础URL
|
||||
console.log(testConfig.mockEnabled); // Mock是否启用
|
||||
});
|
||||
```
|
||||
|
||||
### testLogger
|
||||
|
||||
提供测试日志记录器:
|
||||
|
||||
```typescript
|
||||
test('使用测试日志', async ({ testLogger }) => {
|
||||
testLogger.info('测试信息');
|
||||
testLogger.error('测试错误', error);
|
||||
});
|
||||
```
|
||||
|
||||
### testReporter
|
||||
|
||||
提供测试报告生成器:
|
||||
|
||||
```typescript
|
||||
test('使用测试报告', async ({ testReporter }) => {
|
||||
testReporter.recordTestResult({
|
||||
testName: '测试名称',
|
||||
status: 'passed',
|
||||
duration: 1000
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 测试用例组织
|
||||
|
||||
- 使用 `test.describe` 组织相关的测试用例
|
||||
- 使用 `test.beforeEach` 和 `test.afterEach` 设置测试前置和后置条件
|
||||
- 为每个测试用例提供清晰的描述
|
||||
|
||||
```typescript
|
||||
test.describe('用户管理功能', () => {
|
||||
test.beforeEach(async ({ pageObjects, testData }) => {
|
||||
await pageObjects.loginPage.navigate();
|
||||
await pageObjects.loginPage.login(testData.admin.username, testData.admin.password);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ helpers }) => {
|
||||
await helpers.screenshot.takeScreenshot('after-test');
|
||||
});
|
||||
|
||||
test('创建用户', async ({ pageObjects }) => {
|
||||
// 测试逻辑
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 页面对象使用
|
||||
|
||||
- 始终使用页面对象而不是直接操作页面元素
|
||||
- 将页面选择器封装在页面对象中
|
||||
- 在页面对象中实现业务逻辑方法
|
||||
|
||||
```typescript
|
||||
// 好的做法
|
||||
await pageObjects.loginPage.login('admin', 'password');
|
||||
|
||||
// 不好的做法
|
||||
await page.fill('input[placeholder="请输入用户名"]', 'admin');
|
||||
await page.fill('input[placeholder="请输入密码"]', 'password');
|
||||
await page.click('button[type="submit"]');
|
||||
```
|
||||
|
||||
### 3. 测试数据管理
|
||||
|
||||
- 使用测试数据生成器创建测试数据
|
||||
- 避免硬编码测试数据
|
||||
- 使用测试夹具提供的预定义数据
|
||||
|
||||
```typescript
|
||||
// 好的做法
|
||||
const userData = testDataGenerator.generateUserData({
|
||||
username: 'testuser',
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
// 不好的做法
|
||||
const userData = {
|
||||
username: 'testuser',
|
||||
password: 'password',
|
||||
email: 'test@example.com',
|
||||
// ... 硬编码的数据
|
||||
};
|
||||
```
|
||||
|
||||
### 4. 错误处理
|
||||
|
||||
- 在测试用例中使用 try-catch 捕获错误
|
||||
- 使用测试日志记录错误信息
|
||||
- 在测试失败时截图
|
||||
|
||||
```typescript
|
||||
test('测试用例', async ({ testLogger, helpers }) => {
|
||||
testLogger.startTest('测试用例');
|
||||
|
||||
try {
|
||||
// 测试逻辑
|
||||
testLogger.endTest('测试用例', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('测试用例', 'failed', error as Error);
|
||||
await helpers.screenshot.takeScreenshot('test-failure');
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 5. 等待策略
|
||||
|
||||
- 使用页面对象提供的等待方法
|
||||
- 避免使用固定的等待时间
|
||||
- 使用 Playwright 的自动等待机制
|
||||
|
||||
```typescript
|
||||
// 好的做法
|
||||
await pageObjects.dashboardPage.waitForLoad();
|
||||
await page.waitForSelector('.element', { state: 'visible' });
|
||||
|
||||
// 不好的做法
|
||||
await page.waitForTimeout(5000);
|
||||
```
|
||||
|
||||
### 6. 断言使用
|
||||
|
||||
- 使用 Playwright 的 expect 断言
|
||||
- 提供有意义的断言消息
|
||||
- 验证关键的业务逻辑
|
||||
|
||||
```typescript
|
||||
// 好的做法
|
||||
expect(pageTitle).toContain('仪表盘');
|
||||
expect(successMessage).toBeTruthy();
|
||||
expect(rowCount).toBeGreaterThan(0);
|
||||
|
||||
// 不好的做法
|
||||
expect(pageTitle).toBeTruthy();
|
||||
```
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 测试失败时的调试
|
||||
|
||||
1. 查看测试日志:`test-results/logs/`
|
||||
2. 查看截图:`test-results/screenshots/`
|
||||
3. 查看测试报告:`test-results/reports/`
|
||||
4. 使用调试模式运行:`npx playwright test --debug`
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **元素未找到**
|
||||
- 检查选择器是否正确
|
||||
- 确保元素已加载
|
||||
- 使用适当的等待策略
|
||||
|
||||
2. **测试超时**
|
||||
- 增加超时配置
|
||||
- 检查网络请求是否正常
|
||||
- 优化测试等待策略
|
||||
|
||||
3. **Mock服务不工作**
|
||||
- 确认Mock服务已启用
|
||||
- 检查Mock配置是否正确
|
||||
- 验证Mock响应格式
|
||||
|
||||
## 扩展开发
|
||||
|
||||
### 添加新的页面对象
|
||||
|
||||
1. 在 `pages/` 目录下创建新的页面类
|
||||
2. 继承 `BasePage` 类
|
||||
3. 实现页面特定的方法和选择器
|
||||
|
||||
```typescript
|
||||
import { BasePage } from './base-page';
|
||||
|
||||
export class NewPage extends BasePage {
|
||||
private readonly selectors = {
|
||||
// 页面选择器
|
||||
};
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
}
|
||||
|
||||
// 页面方法
|
||||
}
|
||||
```
|
||||
|
||||
### 添加新的辅助工具
|
||||
|
||||
1. 在 `helpers/` 目录下创建新的辅助工具类
|
||||
2. 实现辅助工具方法
|
||||
3. 在测试夹具中注册新的辅助工具
|
||||
|
||||
```typescript
|
||||
export class NewHelper {
|
||||
private page: Page;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
// 辅助工具方法
|
||||
}
|
||||
```
|
||||
|
||||
### 添加新的测试数据生成器
|
||||
|
||||
1. 在 `test-data.ts` 中添加新的数据生成方法
|
||||
2. 定义数据接口
|
||||
3. 实现数据生成逻辑
|
||||
|
||||
```typescript
|
||||
export interface NewData {
|
||||
// 数据接口
|
||||
}
|
||||
|
||||
generateNewData(overrides: Partial<NewData> = {}): NewData {
|
||||
// 数据生成逻辑
|
||||
}
|
||||
```
|
||||
|
||||
## 性能优化
|
||||
|
||||
### 并行执行
|
||||
|
||||
Playwright 默认支持并行执行测试,可以通过配置文件调整:
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
workers: 4, // 并发工作进程数
|
||||
fullyParallel: true, // 完全并行执行
|
||||
});
|
||||
```
|
||||
|
||||
### 测试隔离
|
||||
|
||||
确保每个测试用例都是独立的,避免测试之间的依赖:
|
||||
|
||||
```typescript
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 清理测试数据
|
||||
// 重置测试状态
|
||||
});
|
||||
```
|
||||
|
||||
### 重试机制
|
||||
|
||||
配置测试失败时的重试次数:
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
retries: 2, // 失败时重试次数
|
||||
});
|
||||
```
|
||||
|
||||
## 持续集成
|
||||
|
||||
### CI/CD集成
|
||||
|
||||
在CI/CD流程中集成E2E测试:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/e2e-tests.yml
|
||||
name: E2E Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
- run: npm ci
|
||||
- run: npm run test:e2e
|
||||
- uses: actions/upload-artifact@v2
|
||||
if: failure()
|
||||
with:
|
||||
name: test-results
|
||||
path: test-results/
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
本E2E测试工具提供了完整的端到端测试解决方案,包括:
|
||||
|
||||
- ✅ 模块化的测试用例编写
|
||||
- ✅ 统一的测试环境配置
|
||||
- ✅ 常用测试操作的封装与复用
|
||||
- ✅ 清晰的测试报告与日志输出
|
||||
- ✅ 页面对象模型(POM)
|
||||
- ✅ 测试辅助工具
|
||||
- ✅ Mock服务集成
|
||||
- ✅ 测试数据生成
|
||||
|
||||
通过使用本工具,可以高效地编写、执行和维护E2E测试,确保应用的质量和稳定性。
|
||||
@@ -0,0 +1,181 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { MockManager } from './mock-manager';
|
||||
|
||||
test.describe('用户认证', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const mockManager = new MockManager({
|
||||
enabled: true,
|
||||
mode: 'full',
|
||||
mockPaths: [],
|
||||
delay: 0,
|
||||
logCalls: true,
|
||||
validateResponses: true,
|
||||
dataSource: 'memory'
|
||||
});
|
||||
|
||||
mockManager.presetTestData({
|
||||
menus: [
|
||||
{
|
||||
id: 1,
|
||||
name: '仪表盘',
|
||||
code: 'dashboard',
|
||||
path: '/dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
sortOrder: 1,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
component: 'views/Dashboard.vue',
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '用户管理',
|
||||
code: 'user',
|
||||
path: '/users',
|
||||
icon: 'UserOutlined',
|
||||
sortOrder: 2,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
component: 'views/User.vue',
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '角色管理',
|
||||
code: 'role',
|
||||
path: '/roles',
|
||||
icon: 'LockOutlined',
|
||||
sortOrder: 3,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
component: 'views/Role.vue',
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '菜单管理',
|
||||
code: 'menu',
|
||||
path: '/menus',
|
||||
icon: 'MenuOutlined',
|
||||
sortOrder: 4,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
component: 'views/Menu.vue',
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await mockManager.interceptAPIRequest(page);
|
||||
await page.goto('/login');
|
||||
});
|
||||
|
||||
test('应该显示登录页面', async ({ page }) => {
|
||||
await expect(page).toHaveTitle(/管理系统/);
|
||||
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await passwordInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await loginButton.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
await expect(usernameInput).toBeVisible();
|
||||
await expect(passwordInput).toBeVisible();
|
||||
await expect(loginButton).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该成功登录', async ({ page }) => {
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await passwordInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await loginButton.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('admin123');
|
||||
|
||||
await loginButton.click();
|
||||
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 15000 });
|
||||
await expect(page.locator('.ant-breadcrumb-link').filter({ hasText: '仪表盘' })).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('登录失败应该显示错误信息', async ({ page }) => {
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await passwordInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await loginButton.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
await usernameInput.fill('wronguser');
|
||||
await passwordInput.fill('wrongpassword');
|
||||
await loginButton.click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const errorMessage = page.locator('.ant-message-error');
|
||||
await expect(errorMessage).toBeVisible({ timeout: 5000 });
|
||||
await expect(errorMessage).toContainText(/登录失败|用户名或密码错误/i);
|
||||
});
|
||||
|
||||
test('表单验证应该工作', async ({ page }) => {
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
await loginButton.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await loginButton.click();
|
||||
|
||||
const usernameError = page.locator('.ant-form-item-explain-error').filter({ hasText: /请输入用户名/ });
|
||||
const passwordError = page.locator('.ant-form-item-explain-error').filter({ hasText: /请输入密码/ });
|
||||
|
||||
await expect(usernameError).toBeVisible({ timeout: 5000 });
|
||||
await expect(passwordError).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('应该能够登出', async ({ page }) => {
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await passwordInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await loginButton.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('admin123');
|
||||
await loginButton.click();
|
||||
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
|
||||
|
||||
const dropdownTrigger = page.locator('.ant-dropdown-link');
|
||||
await dropdownTrigger.click();
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const logoutMenuItem = page.locator('.ant-dropdown-menu-item').filter({ hasText: /退出/i });
|
||||
await logoutMenuItem.click();
|
||||
|
||||
await page.waitForURL(/.*login/, { timeout: 10000 });
|
||||
await expect(page.locator('input[placeholder="请输入用户名"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,458 @@
|
||||
export const SELECTORS = {
|
||||
LOGIN: {
|
||||
USERNAME_INPUT: 'input[placeholder="请输入用户名"]',
|
||||
PASSWORD_INPUT: 'input[placeholder="请输入密码"]',
|
||||
LOGIN_BUTTON: 'button[type="submit"]',
|
||||
REMEMBER_ME_CHECKBOX: 'input[type="checkbox"]',
|
||||
FORGOT_PASSWORD_LINK: 'text=忘记密码',
|
||||
REGISTER_LINK: 'text=注册账号',
|
||||
ERROR_MESSAGE: '.ant-message-error',
|
||||
SUCCESS_MESSAGE: '.ant-message-success',
|
||||
LOGIN_FORM: '.login-form'
|
||||
},
|
||||
|
||||
HEADER: {
|
||||
USER_MENU: '.user-menu',
|
||||
LOGOUT_BUTTON: 'text=退出登录',
|
||||
USER_INFO: '.user-info',
|
||||
NOTIFICATION: '.notification',
|
||||
SETTINGS: '.settings'
|
||||
},
|
||||
|
||||
MENU: {
|
||||
SIDEBAR: '.sidebar',
|
||||
MENU_ITEM: '.menu-item',
|
||||
SUBMENU: '.submenu',
|
||||
ACTIVE_MENU: '.menu-item.active',
|
||||
MENU_TOGGLE: '.menu-toggle'
|
||||
},
|
||||
|
||||
DASHBOARD: {
|
||||
STATISTICS_CARD: '.statistics-card',
|
||||
CHART: '.chart',
|
||||
TABLE: '.dashboard-table',
|
||||
FILTER: '.filter',
|
||||
SEARCH: '.search'
|
||||
},
|
||||
|
||||
TABLE: {
|
||||
CONTAINER: '.table-container',
|
||||
HEADER: 'thead',
|
||||
BODY: 'tbody',
|
||||
ROW: 'tr',
|
||||
CELL: 'td',
|
||||
CHECKBOX: 'input[type="checkbox"]',
|
||||
PAGINATION: '.pagination',
|
||||
SEARCH: '.table-search input',
|
||||
EXPORT: '.export-button',
|
||||
SORT: 'th.sortable'
|
||||
},
|
||||
|
||||
FORM: {
|
||||
CONTAINER: '.form-container',
|
||||
INPUT: 'input[type="text"], input[type="email"], input[type="password"], input[type="number"]',
|
||||
SELECT: 'select',
|
||||
CHECKBOX: 'input[type="checkbox"]',
|
||||
RADIO: 'input[type="radio"]',
|
||||
TEXTAREA: 'textarea',
|
||||
DATE_PICKER: 'input[type="date"]',
|
||||
TIME_PICKER: 'input[type="time"]',
|
||||
FILE_INPUT: 'input[type="file"]',
|
||||
SUBMIT_BUTTON: 'button[type="submit"], .submit-button',
|
||||
CANCEL_BUTTON: 'button[type="button"], .cancel-button',
|
||||
RESET_BUTTON: '.reset-button',
|
||||
ERROR_MESSAGE: '.error-message',
|
||||
VALIDATION_MESSAGE: '.validation-message'
|
||||
},
|
||||
|
||||
MODAL: {
|
||||
CONTAINER: '.modal, .ant-modal',
|
||||
TITLE: '.modal-title, .ant-modal-title',
|
||||
CONTENT: '.modal-content, .ant-modal-body',
|
||||
FOOTER: '.modal-footer, .ant-modal-footer',
|
||||
CLOSE_BUTTON: '.close-button, .ant-modal-close',
|
||||
CONFIRM_BUTTON: '.confirm-button, .ant-btn-primary',
|
||||
CANCEL_BUTTON: '.cancel-button, .ant-btn-default'
|
||||
},
|
||||
|
||||
TOAST: {
|
||||
SUCCESS: '.ant-message-success',
|
||||
ERROR: '.ant-message-error',
|
||||
WARNING: '.ant-message-warning',
|
||||
INFO: '.ant-message-info',
|
||||
LOADING: '.ant-message-loading'
|
||||
},
|
||||
|
||||
LOADING: {
|
||||
SPINNER: '.ant-spin, .loading-spinner',
|
||||
OVERLAY: '.loading-overlay',
|
||||
PROGRESS_BAR: '.progress-bar'
|
||||
},
|
||||
|
||||
NAVIGATION: {
|
||||
BREADCRUMB: '.breadcrumb',
|
||||
TABS: '.tabs',
|
||||
TAB_ITEM: '.tab-item',
|
||||
ACTIVE_TAB: '.tab-item.active',
|
||||
BACK_BUTTON: '.back-button',
|
||||
FORWARD_BUTTON: '.forward-button'
|
||||
},
|
||||
|
||||
USER_MANAGEMENT: {
|
||||
USER_LIST: '.user-list',
|
||||
USER_CARD: '.user-card',
|
||||
USER_AVATAR: '.user-avatar',
|
||||
USER_NAME: '.user-name',
|
||||
USER_EMAIL: '.user-email',
|
||||
USER_STATUS: '.user-status',
|
||||
USER_ROLE: '.user-role',
|
||||
ADD_USER_BUTTON: '.add-user-button',
|
||||
EDIT_USER_BUTTON: '.edit-user-button',
|
||||
DELETE_USER_BUTTON: '.delete-user-button',
|
||||
USER_SEARCH: '.user-search'
|
||||
},
|
||||
|
||||
ROLE_MANAGEMENT: {
|
||||
ROLE_LIST: '.role-list',
|
||||
ROLE_NAME: '.role-name',
|
||||
ROLE_DESCRIPTION: '.role-description',
|
||||
ROLE_PERMISSIONS: '.role-permissions',
|
||||
ADD_ROLE_BUTTON: '.add-role-button',
|
||||
EDIT_ROLE_BUTTON: '.edit-role-button',
|
||||
DELETE_ROLE_BUTTON: '.delete-role-button',
|
||||
PERMISSION_CHECKBOX: '.permission-checkbox'
|
||||
},
|
||||
|
||||
PERMISSION_MANAGEMENT: {
|
||||
PERMISSION_LIST: '.permission-list',
|
||||
PERMISSION_NAME: '.permission-name',
|
||||
PERMISSION_CODE: '.permission-code',
|
||||
PERMISSION_TYPE: '.permission-type',
|
||||
PERMISSION_RESOURCE: '.permission-resource',
|
||||
ADD_PERMISSION_BUTTON: '.add-permission-button',
|
||||
EDIT_PERMISSION_BUTTON: '.edit-permission-button',
|
||||
DELETE_PERMISSION_BUTTON: '.delete-permission-button'
|
||||
},
|
||||
|
||||
SETTINGS: {
|
||||
SETTINGS_PAGE: '.settings-page',
|
||||
SETTINGS_SECTION: '.settings-section',
|
||||
SETTINGS_ITEM: '.settings-item',
|
||||
SAVE_BUTTON: '.save-button',
|
||||
RESET_BUTTON: '.reset-button'
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const TIMEOUTS = {
|
||||
SHORT: 5000,
|
||||
MEDIUM: 10000,
|
||||
LONG: 30000,
|
||||
VERY_LONG: 60000,
|
||||
NETWORK_IDLE: 30000,
|
||||
ELEMENT_VISIBLE: 10000,
|
||||
ELEMENT_HIDDEN: 10000,
|
||||
NAVIGATION: 30000,
|
||||
PAGE_LOAD: 30000,
|
||||
API_REQUEST: 30000,
|
||||
ANIMATION: 1000
|
||||
} as const;
|
||||
|
||||
export const MESSAGES = {
|
||||
LOGIN: {
|
||||
SUCCESS: '登录成功',
|
||||
INVALID_CREDENTIALS: '用户名或密码错误',
|
||||
ACCOUNT_LOCKED: '账号已被锁定',
|
||||
ACCOUNT_DISABLED: '账号已被禁用',
|
||||
SESSION_EXPIRED: '会话已过期,请重新登录',
|
||||
NETWORK_ERROR: '网络错误,请稍后重试'
|
||||
},
|
||||
|
||||
LOGOUT: {
|
||||
SUCCESS: '退出登录成功',
|
||||
ERROR: '退出登录失败'
|
||||
},
|
||||
|
||||
VALIDATION: {
|
||||
REQUIRED: '此字段为必填项',
|
||||
INVALID_EMAIL: '邮箱格式不正确',
|
||||
INVALID_PHONE: '手机号格式不正确',
|
||||
INVALID_PASSWORD: '密码格式不正确',
|
||||
PASSWORD_MISMATCH: '两次输入的密码不一致',
|
||||
MIN_LENGTH: '输入长度不能少于 {min} 个字符',
|
||||
MAX_LENGTH: '输入长度不能超过 {max} 个字符',
|
||||
INVALID_NUMBER: '请输入有效的数字',
|
||||
INVALID_DATE: '日期格式不正确'
|
||||
},
|
||||
|
||||
OPERATION: {
|
||||
SUCCESS: '操作成功',
|
||||
FAILED: '操作失败',
|
||||
CONFIRM_DELETE: '确定要删除吗?',
|
||||
CONFIRM_SAVE: '确定要保存吗?',
|
||||
CONFIRM_CANCEL: '确定要取消吗?',
|
||||
UNSAVED_CHANGES: '您有未保存的更改,确定要离开吗?'
|
||||
},
|
||||
|
||||
NETWORK: {
|
||||
REQUEST_FAILED: '请求失败',
|
||||
TIMEOUT: '请求超时',
|
||||
SERVER_ERROR: '服务器错误',
|
||||
NETWORK_ERROR: '网络错误',
|
||||
UNAUTHORIZED: '未授权,请先登录',
|
||||
FORBIDDEN: '无权限访问',
|
||||
NOT_FOUND: '资源不存在'
|
||||
},
|
||||
|
||||
UPLOAD: {
|
||||
SUCCESS: '上传成功',
|
||||
FAILED: '上传失败',
|
||||
INVALID_FILE_TYPE: '文件类型不支持',
|
||||
FILE_TOO_LARGE: '文件大小超出限制',
|
||||
UPLOADING: '上传中...'
|
||||
},
|
||||
|
||||
DOWNLOAD: {
|
||||
SUCCESS: '下载成功',
|
||||
FAILED: '下载失败',
|
||||
PREPARING: '准备下载...'
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const ROLES = {
|
||||
ADMIN: 'admin',
|
||||
USER: 'user',
|
||||
GUEST: 'guest',
|
||||
SUPER_ADMIN: 'super_admin',
|
||||
MODERATOR: 'moderator'
|
||||
} as const;
|
||||
|
||||
export const PERMISSIONS = {
|
||||
DASHBOARD: {
|
||||
VIEW: 'dashboard:view',
|
||||
EDIT: 'dashboard:edit',
|
||||
DELETE: 'dashboard:delete'
|
||||
},
|
||||
|
||||
USER: {
|
||||
VIEW: 'user:view',
|
||||
CREATE: 'user:create',
|
||||
EDIT: 'user:edit',
|
||||
DELETE: 'user:delete',
|
||||
EXPORT: 'user:export'
|
||||
},
|
||||
|
||||
ROLE: {
|
||||
VIEW: 'role:view',
|
||||
CREATE: 'role:create',
|
||||
EDIT: 'role:edit',
|
||||
DELETE: 'role:delete',
|
||||
ASSIGN_PERMISSIONS: 'role:assign_permissions'
|
||||
},
|
||||
|
||||
PERMISSION: {
|
||||
VIEW: 'permission:view',
|
||||
CREATE: 'permission:create',
|
||||
EDIT: 'permission:edit',
|
||||
DELETE: 'permission:delete'
|
||||
},
|
||||
|
||||
SETTINGS: {
|
||||
VIEW: 'settings:view',
|
||||
EDIT: 'settings:edit',
|
||||
SYSTEM: 'settings:system',
|
||||
PROFILE: 'settings:profile'
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const STATUS = {
|
||||
ACTIVE: 'active',
|
||||
INACTIVE: 'inactive',
|
||||
PENDING: 'pending',
|
||||
LOCKED: 'locked',
|
||||
DELETED: 'deleted',
|
||||
SUSPENDED: 'suspended'
|
||||
} as const;
|
||||
|
||||
export const HTTP_STATUS = {
|
||||
OK: 200,
|
||||
CREATED: 201,
|
||||
NO_CONTENT: 204,
|
||||
BAD_REQUEST: 400,
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
METHOD_NOT_ALLOWED: 405,
|
||||
CONFLICT: 409,
|
||||
UNPROCESSABLE_ENTITY: 422,
|
||||
INTERNAL_SERVER_ERROR: 500,
|
||||
NOT_IMPLEMENTED: 501,
|
||||
BAD_GATEWAY: 502,
|
||||
SERVICE_UNAVAILABLE: 503,
|
||||
GATEWAY_TIMEOUT: 504
|
||||
} as const;
|
||||
|
||||
export const TEST_DATA = {
|
||||
USERS: {
|
||||
ADMIN: {
|
||||
username: 'admin',
|
||||
password: 'Admin@123',
|
||||
email: 'admin@example.com',
|
||||
phone: '13800138000',
|
||||
realName: '管理员'
|
||||
},
|
||||
USER: {
|
||||
username: 'testuser',
|
||||
password: 'User@123',
|
||||
email: 'user@example.com',
|
||||
phone: '13900139000',
|
||||
realName: '测试用户'
|
||||
},
|
||||
INVALID: {
|
||||
username: 'invalid',
|
||||
password: 'invalid'
|
||||
}
|
||||
},
|
||||
|
||||
ROLES: {
|
||||
ADMIN: {
|
||||
name: '管理员',
|
||||
code: 'admin',
|
||||
description: '系统管理员角色'
|
||||
},
|
||||
USER: {
|
||||
name: '普通用户',
|
||||
code: 'user',
|
||||
description: '普通用户角色'
|
||||
}
|
||||
},
|
||||
|
||||
PERMISSIONS: [
|
||||
{
|
||||
name: '查看仪表盘',
|
||||
code: 'dashboard:view',
|
||||
type: 'menu',
|
||||
resource: '/dashboard'
|
||||
},
|
||||
{
|
||||
name: '查看用户',
|
||||
code: 'user:view',
|
||||
type: 'menu',
|
||||
resource: '/user'
|
||||
},
|
||||
{
|
||||
name: '创建用户',
|
||||
code: 'user:create',
|
||||
type: 'button',
|
||||
resource: '/user/create'
|
||||
},
|
||||
{
|
||||
name: '编辑用户',
|
||||
code: 'user:edit',
|
||||
type: 'button',
|
||||
resource: '/user/edit'
|
||||
},
|
||||
{
|
||||
name: '删除用户',
|
||||
code: 'user:delete',
|
||||
type: 'button',
|
||||
resource: '/user/delete'
|
||||
}
|
||||
]
|
||||
} as const;
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
AUTH: {
|
||||
LOGIN: '/sys/auth/login',
|
||||
LOGOUT: '/sys/auth/logout',
|
||||
REFRESH_TOKEN: '/sys/auth/refresh',
|
||||
GET_USER_INFO: '/sys/auth/userinfo'
|
||||
},
|
||||
|
||||
USER: {
|
||||
LIST: '/sys/user/list',
|
||||
DETAIL: '/sys/user/detail',
|
||||
CREATE: '/sys/user/create',
|
||||
UPDATE: '/sys/user/update',
|
||||
DELETE: '/sys/user/delete',
|
||||
EXPORT: '/sys/user/export'
|
||||
},
|
||||
|
||||
ROLE: {
|
||||
LIST: '/sys/role/list',
|
||||
DETAIL: '/sys/role/detail',
|
||||
CREATE: '/sys/role/create',
|
||||
UPDATE: '/sys/role/update',
|
||||
DELETE: '/sys/role/delete',
|
||||
ASSIGN_PERMISSIONS: '/sys/role/assign-permissions'
|
||||
},
|
||||
|
||||
PERMISSION: {
|
||||
LIST: '/sys/permission/list',
|
||||
DETAIL: '/sys/permission/detail',
|
||||
CREATE: '/sys/permission/create',
|
||||
UPDATE: '/sys/permission/update',
|
||||
DELETE: '/sys/permission/delete'
|
||||
},
|
||||
|
||||
MENU: {
|
||||
LIST: '/sys/menu/list',
|
||||
TREE: '/sys/menu/tree',
|
||||
CREATE: '/sys/menu/create',
|
||||
UPDATE: '/sys/menu/update',
|
||||
DELETE: '/sys/menu/delete'
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const ENVIRONMENTS = {
|
||||
LOCAL: {
|
||||
name: 'local',
|
||||
baseURL: 'http://localhost:5173',
|
||||
mockEnabled: true,
|
||||
mockMode: 'full' as const
|
||||
},
|
||||
DEV: {
|
||||
name: 'dev',
|
||||
baseURL: 'https://dev.example.com',
|
||||
mockEnabled: false,
|
||||
mockMode: 'none' as const
|
||||
},
|
||||
TEST: {
|
||||
name: 'test',
|
||||
baseURL: 'https://test.example.com',
|
||||
mockEnabled: true,
|
||||
mockMode: 'partial' as const
|
||||
},
|
||||
PROD: {
|
||||
name: 'prod',
|
||||
baseURL: 'https://prod.example.com',
|
||||
mockEnabled: false,
|
||||
mockMode: 'none' as const
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const SCREENSHOT_CONFIG = {
|
||||
DIR: 'test-results/screenshots',
|
||||
ON_FAILURE: true,
|
||||
ON_SUCCESS: false,
|
||||
FULL_PAGE: false,
|
||||
RETRY_COUNT: 3
|
||||
} as const;
|
||||
|
||||
export const REPORT_CONFIG = {
|
||||
DIR: 'test-results/reports',
|
||||
JSON: true,
|
||||
HTML: true,
|
||||
ALLURE: true,
|
||||
INCLUDE_SCREENSHOTS: true,
|
||||
INCLUDE_LOGS: true,
|
||||
INCLUDE_VIDEO: true
|
||||
} as const;
|
||||
|
||||
export const MOCK_CONFIG = {
|
||||
ENABLED: true,
|
||||
MODE: 'full' as const,
|
||||
DELAY: 0,
|
||||
LOG_CALLS: true,
|
||||
VALIDATE_RESPONSES: true,
|
||||
DATA_SOURCE: 'memory' as const
|
||||
} as const;
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ENVIRONMENTS } from '../constants';
|
||||
|
||||
export interface TestEnvironment {
|
||||
name: string;
|
||||
baseURL: string;
|
||||
mockEnabled: boolean;
|
||||
mockMode: 'full' | 'partial' | 'none';
|
||||
timeout: {
|
||||
default: number;
|
||||
navigation: number;
|
||||
element: number;
|
||||
network: number;
|
||||
};
|
||||
}
|
||||
|
||||
class TestConfig {
|
||||
private static instance: TestConfig;
|
||||
private currentEnvironment: string;
|
||||
private environments: Record<string, TestEnvironment>;
|
||||
|
||||
private constructor() {
|
||||
this.currentEnvironment = process.env.E2E_ENV || 'local';
|
||||
|
||||
this.environments = {
|
||||
local: {
|
||||
...ENVIRONMENTS.LOCAL,
|
||||
timeout: {
|
||||
default: 30000,
|
||||
navigation: 30000,
|
||||
element: 10000,
|
||||
network: 30000
|
||||
}
|
||||
},
|
||||
dev: {
|
||||
...ENVIRONMENTS.DEV,
|
||||
timeout: {
|
||||
default: 30000,
|
||||
navigation: 30000,
|
||||
element: 10000,
|
||||
network: 30000
|
||||
}
|
||||
},
|
||||
test: {
|
||||
...ENVIRONMENTS.TEST,
|
||||
timeout: {
|
||||
default: 30000,
|
||||
navigation: 30000,
|
||||
element: 10000,
|
||||
network: 30000
|
||||
}
|
||||
},
|
||||
prod: {
|
||||
...ENVIRONMENTS.PROD,
|
||||
timeout: {
|
||||
default: 30000,
|
||||
navigation: 30000,
|
||||
element: 10000,
|
||||
network: 30000
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static getInstance(): TestConfig {
|
||||
if (!TestConfig.instance) {
|
||||
TestConfig.instance = new TestConfig();
|
||||
}
|
||||
return TestConfig.instance;
|
||||
}
|
||||
|
||||
getEnvironment(): TestEnvironment {
|
||||
return this.environments[this.currentEnvironment];
|
||||
}
|
||||
|
||||
getBaseURL(): string {
|
||||
return this.environments[this.currentEnvironment].baseURL;
|
||||
}
|
||||
|
||||
isMockEnabled(): boolean {
|
||||
return this.environments[this.currentEnvironment].mockEnabled;
|
||||
}
|
||||
|
||||
getMockMode(): string {
|
||||
return this.environments[this.currentEnvironment].mockMode;
|
||||
}
|
||||
|
||||
getCurrentEnvironmentName(): string {
|
||||
return this.currentEnvironment;
|
||||
}
|
||||
|
||||
setEnvironment(envName: string): void {
|
||||
if (this.environments[envName]) {
|
||||
this.currentEnvironment = envName;
|
||||
} else {
|
||||
throw new Error(`Unknown environment: ${envName}`);
|
||||
}
|
||||
}
|
||||
|
||||
getTimeout(): TestEnvironment['timeout'] {
|
||||
return this.environments[this.currentEnvironment].timeout;
|
||||
}
|
||||
}
|
||||
|
||||
export const testConfig = TestConfig.getInstance();
|
||||
@@ -0,0 +1,210 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
export interface UserData {
|
||||
username: string;
|
||||
password: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
realName: string;
|
||||
status: 'active' | 'inactive' | 'locked';
|
||||
roleIds: number[];
|
||||
}
|
||||
|
||||
export interface RoleData {
|
||||
name: string;
|
||||
code: string;
|
||||
description: string;
|
||||
status: 'active' | 'inactive';
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface MenuData {
|
||||
name: string;
|
||||
code: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
parentId: number;
|
||||
sortOrder: number;
|
||||
status: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
export interface PermissionData {
|
||||
name: string;
|
||||
code: string;
|
||||
description: string;
|
||||
type: 'menu' | 'button' | 'api';
|
||||
parentId: number;
|
||||
}
|
||||
|
||||
class TestDataGenerator {
|
||||
private static instance: TestDataGenerator;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): TestDataGenerator {
|
||||
if (!TestDataGenerator.instance) {
|
||||
TestDataGenerator.instance = new TestDataGenerator();
|
||||
}
|
||||
return TestDataGenerator.instance;
|
||||
}
|
||||
|
||||
randomString(length: number = 10): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
randomEmail(): string {
|
||||
const domains = ['example.com', 'test.com', 'demo.com'];
|
||||
const username = this.randomString(8).toLowerCase();
|
||||
const domain = domains[Math.floor(Math.random() * domains.length)];
|
||||
return `${username}@${domain}`;
|
||||
}
|
||||
|
||||
randomPhone(): string {
|
||||
const prefix = ['138', '139', '150', '186', '188'];
|
||||
const selectedPrefix = prefix[Math.floor(Math.random() * prefix.length)];
|
||||
const suffix = Math.floor(Math.random() * 100000000).toString().padStart(8, '0');
|
||||
return `${selectedPrefix}${suffix}`;
|
||||
}
|
||||
|
||||
randomPassword(length: number = 12): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
randomInt(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
}
|
||||
|
||||
randomBoolean(): boolean {
|
||||
return Math.random() < 0.5;
|
||||
}
|
||||
|
||||
randomDate(startYear: number = 2020, endYear: number = 2024): Date {
|
||||
const start = new Date(startYear, 0, 1);
|
||||
const end = new Date(endYear, 11, 31);
|
||||
return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
|
||||
}
|
||||
|
||||
randomItem<T>(items: T[]): T {
|
||||
return items[Math.floor(Math.random() * items.length)];
|
||||
}
|
||||
|
||||
randomItems<T>(items: T[], count: number): T[] {
|
||||
const shuffled = [...items].sort(() => Math.random() - 0.5);
|
||||
return shuffled.slice(0, Math.min(count, items.length));
|
||||
}
|
||||
|
||||
generateUserData(overrides: Partial<UserData> = {}): UserData {
|
||||
const username = overrides.username || `user_${this.randomString(6).toLowerCase()}`;
|
||||
|
||||
return {
|
||||
username,
|
||||
password: overrides.password || 'Admin@123',
|
||||
email: overrides.email || this.randomEmail(),
|
||||
phone: overrides.phone || this.randomPhone(),
|
||||
realName: overrides.realName || `测试用户${this.randomInt(1, 100)}`,
|
||||
status: overrides.status || this.randomItem(['active', 'inactive', 'locked']),
|
||||
roleIds: overrides.roleIds || [1]
|
||||
};
|
||||
}
|
||||
|
||||
generateRoleData(overrides: Partial<RoleData> = {}): RoleData {
|
||||
const code = overrides.code || `role_${this.randomString(6).toLowerCase()}`;
|
||||
|
||||
return {
|
||||
name: overrides.name || `测试角色${this.randomInt(1, 100)}`,
|
||||
code,
|
||||
description: overrides.description || `角色${code}的描述`,
|
||||
status: overrides.status || this.randomItem(['active', 'inactive']),
|
||||
permissions: overrides.permissions || ['dashboard:view']
|
||||
};
|
||||
}
|
||||
|
||||
generateMenuData(overrides: Partial<MenuData> = {}): MenuData {
|
||||
const code = overrides.code || `menu_${this.randomString(6).toLowerCase()}`;
|
||||
|
||||
return {
|
||||
name: overrides.name || `测试菜单${this.randomInt(1, 100)}`,
|
||||
code,
|
||||
path: overrides.path || `/${code}`,
|
||||
icon: overrides.icon || 'MenuOutlined',
|
||||
parentId: overrides.parentId || 0,
|
||||
sortOrder: overrides.sortOrder || this.randomInt(1, 100),
|
||||
status: overrides.status || this.randomItem(['active', 'inactive'])
|
||||
};
|
||||
}
|
||||
|
||||
generatePermissionData(overrides: Partial<PermissionData> = {}): PermissionData {
|
||||
const code = overrides.code || `perm_${this.randomString(6).toLowerCase()}`;
|
||||
|
||||
return {
|
||||
name: overrides.name || `测试权限${this.randomInt(1, 100)}`,
|
||||
code,
|
||||
description: overrides.description || `权限${code}的描述`,
|
||||
type: overrides.type || this.randomItem(['menu', 'button', 'api']),
|
||||
parentId: overrides.parentId || 0
|
||||
};
|
||||
}
|
||||
|
||||
generateUserList(count: number): UserData[] {
|
||||
return Array.from({ length: count }, () => this.generateUserData());
|
||||
}
|
||||
|
||||
generateRoleList(count: number): RoleData[] {
|
||||
return Array.from({ length: count }, () => this.generateRoleData());
|
||||
}
|
||||
|
||||
generateMenuList(count: number): MenuData[] {
|
||||
return Array.from({ length: count }, () => this.generateMenuData());
|
||||
}
|
||||
|
||||
generatePermissionList(count: number): PermissionData[] {
|
||||
return Array.from({ length: count }, () => this.generatePermissionData());
|
||||
}
|
||||
|
||||
generatePaginationData<T>(data: T[], page: number = 1, pageSize: number = 10) {
|
||||
const start = (page - 1) * pageSize;
|
||||
const end = start + pageSize;
|
||||
|
||||
return {
|
||||
records: data.slice(start, end),
|
||||
total: data.length,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(data.length / pageSize)
|
||||
};
|
||||
}
|
||||
|
||||
generateSearchQuery(keyword: string): Record<string, any> {
|
||||
return {
|
||||
keyword,
|
||||
page: 1,
|
||||
pageSize: 10
|
||||
};
|
||||
}
|
||||
|
||||
generateFormData(fields: Record<string, any>): Record<string, any> {
|
||||
const formData: Record<string, any> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (typeof value === 'function') {
|
||||
formData[key] = value();
|
||||
} else {
|
||||
formData[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return formData;
|
||||
}
|
||||
}
|
||||
|
||||
export const testDataGenerator = TestDataGenerator.getInstance();
|
||||
@@ -0,0 +1,86 @@
|
||||
export enum LogLevel {
|
||||
INFO = 'INFO',
|
||||
WARN = 'WARN',
|
||||
ERROR = 'ERROR',
|
||||
DEBUG = 'DEBUG',
|
||||
SUCCESS = 'SUCCESS',
|
||||
FAILURE = 'FAILURE'
|
||||
}
|
||||
|
||||
export interface TestLog {
|
||||
testName: string;
|
||||
status: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
duration: number;
|
||||
steps: Array<{
|
||||
name: string;
|
||||
status: string;
|
||||
duration: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class TestLogger {
|
||||
private prefix: string
|
||||
private logs: Array<{ level: LogLevel; message: string; timestamp: string; test?: string }> = []
|
||||
private testLogs: TestLog[] = []
|
||||
private currentTest: TestLog | null = null
|
||||
|
||||
constructor(prefix: string = 'Test') {
|
||||
this.prefix = prefix
|
||||
}
|
||||
|
||||
info(message: string, ...args: any[]) {
|
||||
console.log(`[${this.prefix}] INFO:`, message, ...args)
|
||||
this.logs.push({ level: LogLevel.INFO, message: JSON.stringify({ message, args }), timestamp: new Date().toISOString() })
|
||||
}
|
||||
|
||||
warn(message: string, ...args: any[]) {
|
||||
console.warn(`[${this.prefix}] WARN:`, message, ...args)
|
||||
this.logs.push({ level: LogLevel.WARN, message: JSON.stringify({ message, args }), timestamp: new Date().toISOString() })
|
||||
}
|
||||
|
||||
error(message: string, ...args: any[]) {
|
||||
console.error(`[${this.prefix}] ERROR:`, message, ...args)
|
||||
this.logs.push({ level: LogLevel.ERROR, message: JSON.stringify({ message, args }), timestamp: new Date().toISOString() })
|
||||
}
|
||||
|
||||
debug(message: string, ...args: any[]) {
|
||||
console.debug(`[${this.prefix}] DEBUG:`, message, ...args)
|
||||
this.logs.push({ level: LogLevel.DEBUG, message: JSON.stringify({ message, args }), timestamp: new Date().toISOString() })
|
||||
}
|
||||
|
||||
step(stepName: string) {
|
||||
console.log(`[${this.prefix}] STEP: ${stepName}`)
|
||||
}
|
||||
|
||||
success(message: string, ...args: any[]) {
|
||||
console.log(`[${this.prefix}] ✅ SUCCESS:`, message, ...args)
|
||||
this.logs.push({ level: LogLevel.SUCCESS, message: JSON.stringify({ message, args }), timestamp: new Date().toISOString() })
|
||||
}
|
||||
|
||||
failure(message: string, ...args: any[]) {
|
||||
console.error(`[${this.prefix}] ❌ FAILURE:`, message, ...args)
|
||||
this.logs.push({ level: LogLevel.FAILURE, message: JSON.stringify({ message, args }), timestamp: new Date().toISOString() })
|
||||
}
|
||||
|
||||
startStep(stepName: string) {
|
||||
console.log(`[${this.prefix}] START STEP: ${stepName}`)
|
||||
}
|
||||
|
||||
endStep(stepName: string, status: string, error?: Error) {
|
||||
console.log(`[${this.prefix}] END STEP: ${stepName} - ${status}`)
|
||||
}
|
||||
|
||||
getAllTestLogs(): TestLog[] {
|
||||
return this.testLogs
|
||||
}
|
||||
|
||||
getLogsByLevel(level: LogLevel): Array<{ level: LogLevel; message: string; timestamp: string; test?: string }> {
|
||||
return this.logs.filter(log => log.level === level)
|
||||
}
|
||||
}
|
||||
|
||||
export const testLogger = new TestLogger('E2E')
|
||||
|
||||
export default TestLogger
|
||||
@@ -0,0 +1,593 @@
|
||||
import { FullResult } from '@playwright/test';
|
||||
import { testLogger, TestLog, LogLevel } from './test-logger';
|
||||
import { testConfig } from './test-config';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface TestSummary {
|
||||
total: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
duration: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
export interface TestReport {
|
||||
summary: TestSummary;
|
||||
testLogs: TestLog[];
|
||||
environment: {
|
||||
name: string;
|
||||
baseURL: string;
|
||||
mockEnabled: boolean;
|
||||
mockMode: string;
|
||||
};
|
||||
errors: Array<{
|
||||
testName: string;
|
||||
error: Error;
|
||||
timestamp: string;
|
||||
}>;
|
||||
screenshots: string[];
|
||||
}
|
||||
|
||||
class TestReporter {
|
||||
private static instance: TestReporter;
|
||||
private report: TestReport;
|
||||
private startTime: string = '';
|
||||
|
||||
private constructor() {
|
||||
this.report = this.initializeReport();
|
||||
}
|
||||
|
||||
static getInstance(): TestReporter {
|
||||
if (!TestReporter.instance) {
|
||||
TestReporter.instance = new TestReporter();
|
||||
}
|
||||
return TestReporter.instance;
|
||||
}
|
||||
|
||||
private initializeReport(): TestReport {
|
||||
return {
|
||||
summary: {
|
||||
total: 0,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
duration: 0,
|
||||
startTime: new Date().toISOString(),
|
||||
endTime: ''
|
||||
},
|
||||
testLogs: [],
|
||||
environment: {
|
||||
name: testConfig.getEnvironment().name,
|
||||
baseURL: testConfig.getBaseURL(),
|
||||
mockEnabled: testConfig.isMockEnabled(),
|
||||
mockMode: testConfig.getMockMode()
|
||||
},
|
||||
errors: [],
|
||||
screenshots: []
|
||||
};
|
||||
}
|
||||
|
||||
startReport(): void {
|
||||
this.startTime = new Date().toISOString();
|
||||
this.report.summary.startTime = this.startTime;
|
||||
testLogger.info('开始生成测试报告');
|
||||
}
|
||||
|
||||
endReport(): void {
|
||||
const endTime = new Date().toISOString();
|
||||
this.report.summary.endTime = endTime;
|
||||
this.report.summary.duration = new Date(endTime).getTime() - new Date(this.startTime).getTime();
|
||||
this.report.testLogs = testLogger.getAllTestLogs();
|
||||
|
||||
const errorLogs = testLogger.getLogsByLevel(LogLevel.ERROR);
|
||||
this.report.errors = errorLogs.map(log => ({
|
||||
testName: log.test || 'unknown',
|
||||
error: new Error(log.message),
|
||||
timestamp: log.timestamp
|
||||
}));
|
||||
|
||||
testLogger.info('测试报告生成完成', {
|
||||
total: this.report.summary.total,
|
||||
passed: this.report.summary.passed,
|
||||
failed: this.report.summary.failed,
|
||||
skipped: this.report.summary.skipped,
|
||||
duration: this.report.summary.duration
|
||||
});
|
||||
}
|
||||
|
||||
updateSummary(results: FullResult): void {
|
||||
this.report.summary.total = results.expected;
|
||||
this.report.summary.passed = results.expected - results.failed - results.skipped;
|
||||
this.report.summary.failed = results.failed;
|
||||
this.report.summary.skipped = results.skipped;
|
||||
}
|
||||
|
||||
addScreenshot(screenshotPath: string): void {
|
||||
this.report.screenshots.push(screenshotPath);
|
||||
}
|
||||
|
||||
getReport(): TestReport {
|
||||
return this.report;
|
||||
}
|
||||
|
||||
getSummary(): TestSummary {
|
||||
return this.report.summary;
|
||||
}
|
||||
|
||||
async generateJSONReport(outputPath: string): Promise<void> {
|
||||
const dir = path.dirname(outputPath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
const jsonContent = JSON.stringify(this.report, null, 2);
|
||||
await fs.writeFile(outputPath, jsonContent, 'utf-8');
|
||||
|
||||
testLogger.info(`JSON报告已生成: ${outputPath}`);
|
||||
}
|
||||
|
||||
async generateHTMLReport(outputPath: string): Promise<void> {
|
||||
const dir = path.dirname(outputPath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
const htmlContent = this.generateHTMLContent();
|
||||
await fs.writeFile(outputPath, htmlContent, 'utf-8');
|
||||
|
||||
testLogger.info(`HTML报告已生成: ${outputPath}`);
|
||||
}
|
||||
|
||||
private generateHTMLContent(): string {
|
||||
const { summary, testLogs, environment, errors, screenshots } = this.report;
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>E2E测试报告</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 28px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header .meta {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
padding: 30px;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.summary-card h3 {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.summary-card .value {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.summary-card.passed .value {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.summary-card.failed .value {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.summary-card.skipped .value {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.environment {
|
||||
padding: 20px 30px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.environment h2 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 15px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.environment-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.environment-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.environment-item label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.environment-item span {
|
||||
font-size: 14px;
|
||||
color: #111827;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.test-results {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.test-results h2 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 20px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.test-item {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.test-header {
|
||||
padding: 15px 20px;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.test-header .name {
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.test-header .status {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.test-header .status.passed {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.test-header .status.failed {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.test-header .status.skipped {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.test-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.test-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.test-steps {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.test-steps h4 {
|
||||
font-size: 14px;
|
||||
margin-bottom: 10px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
padding: 10px;
|
||||
margin-bottom: 8px;
|
||||
background: #f9fafb;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #d1d5db;
|
||||
}
|
||||
|
||||
.step-item.passed {
|
||||
border-left-color: #10b981;
|
||||
}
|
||||
|
||||
.step-item.failed {
|
||||
border-left-color: #ef4444;
|
||||
}
|
||||
|
||||
.step-item.skipped {
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
.step-item .name {
|
||||
font-weight: 500;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.step-item .duration {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.errors {
|
||||
padding: 30px;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.errors h2 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 20px;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.error-item {
|
||||
background: white;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.error-item .test-name {
|
||||
font-weight: 600;
|
||||
color: #991b1b;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.error-item .message {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
color: #7f1d1d;
|
||||
background: #fef2f2;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.error-item .timestamp {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.screenshots {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.screenshots h2 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 20px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.screenshot-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.screenshot-item {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.screenshot-item img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.screenshot-item .path {
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
background: #f9fafb;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 20px 30px;
|
||||
background: #f9fafb;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>E2E测试报告</h1>
|
||||
<div class="meta">
|
||||
生成时间: ${new Date().toLocaleString('zh-CN')} |
|
||||
测试环境: ${environment.name} |
|
||||
Mock模式: ${environment.mockMode}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="summary-card">
|
||||
<h3>总测试数</h3>
|
||||
<div class="value">${summary.total}</div>
|
||||
</div>
|
||||
<div class="summary-card passed">
|
||||
<h3>通过</h3>
|
||||
<div class="value">${summary.passed}</div>
|
||||
</div>
|
||||
<div class="summary-card failed">
|
||||
<h3>失败</h3>
|
||||
<div class="value">${summary.failed}</div>
|
||||
</div>
|
||||
<div class="summary-card skipped">
|
||||
<h3>跳过</h3>
|
||||
<div class="value">${summary.skipped}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<h3>总耗时</h3>
|
||||
<div class="value">${(summary.duration / 1000).toFixed(2)}s</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="environment">
|
||||
<h2>测试环境</h2>
|
||||
<div class="environment-info">
|
||||
<div class="environment-item">
|
||||
<label>环境名称</label>
|
||||
<span>${environment.name}</span>
|
||||
</div>
|
||||
<div class="environment-item">
|
||||
<label>基础URL</label>
|
||||
<span>${environment.baseURL}</span>
|
||||
</div>
|
||||
<div class="environment-item">
|
||||
<label>Mock启用</label>
|
||||
<span>${environment.mockEnabled ? '是' : '否'}</span>
|
||||
</div>
|
||||
<div class="environment-item">
|
||||
<label>Mock模式</label>
|
||||
<span>${environment.mockMode}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-results">
|
||||
<h2>测试结果</h2>
|
||||
${testLogs.map(log => `
|
||||
<div class="test-item">
|
||||
<div class="test-header">
|
||||
<span class="name">${log.testName}</span>
|
||||
<span class="status ${log.status}">${log.status}</span>
|
||||
</div>
|
||||
<div class="test-body">
|
||||
<div class="test-meta">
|
||||
<div>开始时间: ${new Date(log.startTime).toLocaleString('zh-CN')}</div>
|
||||
<div>结束时间: ${new Date(log.endTime).toLocaleString('zh-CN')}</div>
|
||||
<div>耗时: ${(log.duration / 1000).toFixed(2)}s</div>
|
||||
</div>
|
||||
${log.steps.length > 0 ? `
|
||||
<div class="test-steps">
|
||||
<h4>测试步骤</h4>
|
||||
${log.steps.map(step => `
|
||||
<div class="step-item ${step.status}">
|
||||
<div class="name">${step.name}</div>
|
||||
<div class="duration">耗时: ${(step.duration / 1000).toFixed(2)}s</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
${errors.length > 0 ? `
|
||||
<div class="errors">
|
||||
<h2>错误详情 (${errors.length})</h2>
|
||||
${errors.map(error => `
|
||||
<div class="error-item">
|
||||
<div class="test-name">${error.testName}</div>
|
||||
<div class="message">${error.error.message}</div>
|
||||
<div class="timestamp">${new Date(error.timestamp).toLocaleString('zh-CN')}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${screenshots.length > 0 ? `
|
||||
<div class="screenshots">
|
||||
<h2>截图 (${screenshots.length})</h2>
|
||||
<div class="screenshot-grid">
|
||||
${screenshots.map(screenshot => `
|
||||
<div class="screenshot-item">
|
||||
<img src="${screenshot}" alt="Screenshot">
|
||||
<div class="path">${screenshot}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="footer">
|
||||
E2E测试报告 - 由Playwright生成
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
async generateAllReports(outputDir: string): Promise<void> {
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
|
||||
await this.generateJSONReport(path.join(outputDir, `e2e-report-${timestamp}.json`));
|
||||
await this.generateHTMLReport(path.join(outputDir, `e2e-report-${timestamp}.html`));
|
||||
|
||||
testLogger.info(`所有报告已生成到目录: ${outputDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const testReporter = TestReporter.getInstance();
|
||||
@@ -0,0 +1,88 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { MockManager } from './mock-manager';
|
||||
|
||||
test('调试登录功能', async ({ page }) => {
|
||||
const mockManager = new MockManager({
|
||||
enabled: true,
|
||||
mode: 'full',
|
||||
mockPaths: [],
|
||||
delay: 0,
|
||||
logCalls: true,
|
||||
validateResponses: true,
|
||||
dataSource: 'memory'
|
||||
});
|
||||
|
||||
mockManager.presetTestData({
|
||||
menus: [
|
||||
{
|
||||
id: 1,
|
||||
name: '仪表盘',
|
||||
code: 'dashboard',
|
||||
path: '/dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
sortOrder: 1,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await mockManager.interceptAPIRequest(page);
|
||||
|
||||
page.on('console', msg => {
|
||||
console.log('Browser Console:', msg.type(), msg.text());
|
||||
});
|
||||
|
||||
page.on('pageerror', error => {
|
||||
console.log('Browser Error:', error.message);
|
||||
});
|
||||
|
||||
page.on('request', request => {
|
||||
console.log('Request:', request.method(), request.url());
|
||||
});
|
||||
|
||||
page.on('response', async response => {
|
||||
const url = response.url();
|
||||
if (url.includes('/sys/auth/login')) {
|
||||
console.log('Login Response Status:', response.status());
|
||||
console.log('Login Response Headers:', response.headers());
|
||||
const body = await response.text();
|
||||
console.log('Login Response Body:', body);
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await passwordInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await loginButton.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('admin123');
|
||||
|
||||
await loginButton.click();
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
const currentUrl = page.url();
|
||||
console.log('Current URL after login:', currentUrl);
|
||||
|
||||
const errorMessage = page.locator('.ant-message-error');
|
||||
const hasError = await errorMessage.count() > 0;
|
||||
if (hasError) {
|
||||
const errorText = await errorMessage.textContent();
|
||||
console.log('Error message found:', errorText);
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'debug-login.png' });
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { MockManager } from './mock-manager';
|
||||
|
||||
test.describe('调试登出功能', () => {
|
||||
test('详细调试登出流程', async ({ page }) => {
|
||||
const mockManager = new MockManager({
|
||||
enabled: true,
|
||||
mode: 'full',
|
||||
mockPaths: [],
|
||||
delay: 0,
|
||||
logCalls: true,
|
||||
validateResponses: true,
|
||||
dataSource: 'memory'
|
||||
});
|
||||
|
||||
mockManager.presetTestData({
|
||||
menus: [
|
||||
{
|
||||
id: 1,
|
||||
name: '仪表盘',
|
||||
code: 'dashboard',
|
||||
path: '/dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
sortOrder: 1,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await mockManager.interceptAPIRequest(page);
|
||||
|
||||
page.on('console', msg => {
|
||||
console.log('Browser Console:', msg.type(), msg.text());
|
||||
});
|
||||
|
||||
page.on('pageerror', error => {
|
||||
console.log('Browser Error:', error.message);
|
||||
});
|
||||
|
||||
await page.goto('/login');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('admin123');
|
||||
await loginButton.click();
|
||||
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 15000 });
|
||||
console.log('Successfully navigated to dashboard');
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const pageContent = await page.content();
|
||||
console.log('Page HTML length:', pageContent.length);
|
||||
|
||||
const hasDropdownLink = await page.locator('.ant-dropdown-link').count();
|
||||
console.log('Found .ant-dropdown-link elements:', hasDropdownLink);
|
||||
|
||||
const hasHeaderRight = await page.locator('.header-right').count();
|
||||
console.log('Found .header-right elements:', hasHeaderRight);
|
||||
|
||||
const hasUserIcon = await page.locator('.anticon-user').count();
|
||||
console.log('Found user icon elements:', hasUserIcon);
|
||||
|
||||
const allButtons = await page.locator('button').all();
|
||||
console.log('Total buttons on page:', allButtons.length);
|
||||
|
||||
const allAnchors = await page.locator('a').all();
|
||||
console.log('Total anchors on page:', allAnchors.length);
|
||||
|
||||
for (let i = 0; i < allAnchors.length; i++) {
|
||||
const anchor = allAnchors[i];
|
||||
const text = await anchor.textContent();
|
||||
const className = await anchor.getAttribute('class');
|
||||
console.log(`Anchor ${i}: text="${text}", class="${className}"`);
|
||||
}
|
||||
|
||||
const allMenuItems = await page.locator('.ant-dropdown-menu-item').all();
|
||||
console.log('Total dropdown menu items:', allMenuItems.length);
|
||||
|
||||
const logoutMenuItems = await page.locator('.ant-dropdown-menu-item').filter({ hasText: /退出/i }).all();
|
||||
console.log('Found logout menu items:', logoutMenuItems.length);
|
||||
|
||||
await page.screenshot({ path: 'debug-logout-dashboard.png', fullPage: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('debug-white-screen', async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
const consoleMessages: string[] = [];
|
||||
|
||||
page.on('pageerror', error => {
|
||||
errors.push(`Page Error: ${error.message}\nStack: ${error.stack}`);
|
||||
});
|
||||
|
||||
page.on('console', msg => {
|
||||
consoleMessages.push(`[${msg.type()}] ${msg.text()}`);
|
||||
});
|
||||
|
||||
page.on('requestfailed', request => {
|
||||
consoleMessages.push(`[REQUEST FAILED] ${request.url()} - ${request.failure()?.errorText}`);
|
||||
});
|
||||
|
||||
await page.goto('http://localhost:5173', { waitUntil: 'networkidle' });
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
console.log('\n=== Console Messages ===');
|
||||
consoleMessages.forEach(msg => console.log(msg));
|
||||
|
||||
console.log('\n=== Page Errors ===');
|
||||
errors.forEach(error => console.log(error));
|
||||
|
||||
const pageContent = await page.content();
|
||||
console.log('\n=== Page Content (first 500 chars) ===');
|
||||
console.log(pageContent.substring(0, 500));
|
||||
|
||||
const bodyText = await page.locator('body').textContent();
|
||||
console.log('\n=== Body Text ===');
|
||||
console.log(bodyText);
|
||||
|
||||
await page.screenshot({ path: 'test-results/debug-white-screen.png', fullPage: true });
|
||||
|
||||
expect(errors.length).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
import { test, expect } from '../test-fixtures';
|
||||
|
||||
test.describe('登录功能测试', () => {
|
||||
test.beforeEach(async ({ testLogger }) => {
|
||||
testLogger.info('开始登录功能测试套件');
|
||||
});
|
||||
|
||||
test.afterEach(async ({ testLogger, helpers }) => {
|
||||
testLogger.info('登录功能测试用例完成');
|
||||
await helpers.screenshot.takeScreenshot('after-test');
|
||||
});
|
||||
|
||||
test('成功登录', async ({ pageObjects, testData, testLogger, helpers }) => {
|
||||
testLogger.startTest('成功登录');
|
||||
|
||||
try {
|
||||
await pageObjects.loginPage.navigate();
|
||||
await pageObjects.loginPage.login(testData.admin.username, testData.admin.password);
|
||||
|
||||
await pageObjects.dashboardPage.waitForLoad();
|
||||
|
||||
const pageTitle = await pageObjects.dashboardPage.getPageTitle();
|
||||
expect(pageTitle).toContain('仪表盘');
|
||||
|
||||
testLogger.endTest('成功登录', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('成功登录', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
test('登录失败 - 用户名错误', async ({ pageObjects, testData, testLogger }) => {
|
||||
testLogger.startTest('登录失败 - 用户名错误');
|
||||
|
||||
try {
|
||||
await pageObjects.loginPage.navigate();
|
||||
await pageObjects.loginPage.login('wronguser', testData.admin.password);
|
||||
|
||||
const errorMessage = await pageObjects.loginPage.getErrorMessage();
|
||||
expect(errorMessage).toBeTruthy();
|
||||
|
||||
testLogger.endTest('登录失败 - 用户名错误', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('登录失败 - 用户名错误', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
test('登录失败 - 密码错误', async ({ pageObjects, testData, testLogger }) => {
|
||||
testLogger.startTest('登录失败 - 密码错误');
|
||||
|
||||
try {
|
||||
await pageObjects.loginPage.navigate();
|
||||
await pageObjects.loginPage.login(testData.admin.username, 'wrongpassword');
|
||||
|
||||
const errorMessage = await pageObjects.loginPage.getErrorMessage();
|
||||
expect(errorMessage).toBeTruthy();
|
||||
|
||||
testLogger.endTest('登录失败 - 密码错误', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('登录失败 - 密码错误', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('用户管理功能测试', () => {
|
||||
test.beforeEach(async ({ pageObjects, testData, testLogger }) => {
|
||||
testLogger.info('开始用户管理功能测试套件');
|
||||
|
||||
await pageObjects.loginPage.navigate();
|
||||
await pageObjects.loginPage.login(testData.admin.username, testData.admin.password);
|
||||
await pageObjects.dashboardPage.waitForLoad();
|
||||
});
|
||||
|
||||
test.afterEach(async ({ testLogger, helpers }) => {
|
||||
testLogger.info('用户管理功能测试用例完成');
|
||||
await helpers.screenshot.takeScreenshot('after-test');
|
||||
});
|
||||
|
||||
test('创建新用户', async ({ pageObjects, testData, testLogger, helpers }) => {
|
||||
testLogger.startTest('创建新用户');
|
||||
|
||||
try {
|
||||
await pageObjects.userManagementPage.navigate();
|
||||
await pageObjects.userManagementPage.waitForLoad();
|
||||
|
||||
await pageObjects.userManagementPage.clickAddUser();
|
||||
|
||||
await helpers.form.fillForm({
|
||||
username: testData.user.username,
|
||||
password: testData.user.password,
|
||||
email: testData.user.email,
|
||||
phone: testData.user.phone,
|
||||
realName: testData.user.realName,
|
||||
status: testData.user.status
|
||||
});
|
||||
|
||||
await helpers.form.submitForm();
|
||||
|
||||
const successMessage = await pageObjects.userManagementPage.getSuccessMessage();
|
||||
expect(successMessage).toBeTruthy();
|
||||
|
||||
testLogger.endTest('创建新用户', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('创建新用户', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
test('搜索用户', async ({ pageObjects, testData, testLogger }) => {
|
||||
testLogger.startTest('搜索用户');
|
||||
|
||||
try {
|
||||
await pageObjects.userManagementPage.navigate();
|
||||
await pageObjects.userManagementPage.waitForLoad();
|
||||
|
||||
await pageObjects.userManagementPage.searchUser(testData.user.username);
|
||||
|
||||
const rowCount = await helpers.table.getRowCount('.ant-table');
|
||||
expect(rowCount).toBeGreaterThan(0);
|
||||
|
||||
testLogger.endTest('搜索用户', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('搜索用户', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
test('编辑用户', async ({ pageObjects, testData, testLogger, helpers }) => {
|
||||
testLogger.startTest('编辑用户');
|
||||
|
||||
try {
|
||||
await pageObjects.userManagementPage.navigate();
|
||||
await pageObjects.userManagementPage.waitForLoad();
|
||||
|
||||
await pageObjects.userManagementPage.searchUser(testData.user.username);
|
||||
|
||||
await pageObjects.userManagementPage.clickEditUser(0);
|
||||
|
||||
const updatedEmail = 'updated@example.com';
|
||||
await helpers.form.fillField('input[type="email"]', updatedEmail);
|
||||
|
||||
await helpers.form.submitForm();
|
||||
|
||||
const successMessage = await pageObjects.userManagementPage.getSuccessMessage();
|
||||
expect(successMessage).toBeTruthy();
|
||||
|
||||
testLogger.endTest('编辑用户', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('编辑用户', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
test('删除用户', async ({ pageObjects, testData, testLogger }) => {
|
||||
testLogger.startTest('删除用户');
|
||||
|
||||
try {
|
||||
await pageObjects.userManagementPage.navigate();
|
||||
await pageObjects.userManagementPage.waitForLoad();
|
||||
|
||||
await pageObjects.userManagementPage.searchUser(testData.user.username);
|
||||
|
||||
await pageObjects.userManagementPage.clickDeleteUser(0);
|
||||
|
||||
await pageObjects.userManagementPage.confirmDelete();
|
||||
|
||||
const successMessage = await pageObjects.userManagementPage.getSuccessMessage();
|
||||
expect(successMessage).toBeTruthy();
|
||||
|
||||
testLogger.endTest('删除用户', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('删除用户', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('角色管理功能测试', () => {
|
||||
test.beforeEach(async ({ pageObjects, testData, testLogger }) => {
|
||||
testLogger.info('开始角色管理功能测试套件');
|
||||
|
||||
await pageObjects.loginPage.navigate();
|
||||
await pageObjects.loginPage.login(testData.admin.username, testData.admin.password);
|
||||
await pageObjects.dashboardPage.waitForLoad();
|
||||
});
|
||||
|
||||
test.afterEach(async ({ testLogger, helpers }) => {
|
||||
testLogger.info('角色管理功能测试用例完成');
|
||||
await helpers.screenshot.takeScreenshot('after-test');
|
||||
});
|
||||
|
||||
test('创建新角色', async ({ pageObjects, testData, testLogger, helpers }) => {
|
||||
testLogger.startTest('创建新角色');
|
||||
|
||||
try {
|
||||
await pageObjects.roleManagementPage.navigate();
|
||||
await pageObjects.roleManagementPage.waitForLoad();
|
||||
|
||||
await pageObjects.roleManagementPage.clickAddRole();
|
||||
|
||||
await helpers.form.fillForm({
|
||||
roleName: testData.role.roleName,
|
||||
roleCode: testData.role.roleCode,
|
||||
status: testData.role.status
|
||||
});
|
||||
|
||||
await helpers.form.submitForm();
|
||||
|
||||
const successMessage = await pageObjects.roleManagementPage.getSuccessMessage();
|
||||
expect(successMessage).toBeTruthy();
|
||||
|
||||
testLogger.endTest('创建新角色', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('创建新角色', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
test('分配权限给角色', async ({ pageObjects, testData, testLogger }) => {
|
||||
testLogger.startTest('分配权限给角色');
|
||||
|
||||
try {
|
||||
await pageObjects.roleManagementPage.navigate();
|
||||
await pageObjects.roleManagementPage.waitForLoad();
|
||||
|
||||
await pageObjects.roleManagementPage.clickAssignPermissions(testData.role.roleCode);
|
||||
|
||||
await pageObjects.roleManagementPage.selectPermissions(['user:view', 'user:add']);
|
||||
|
||||
await pageObjects.roleManagementPage.savePermissions();
|
||||
|
||||
const successMessage = await pageObjects.roleManagementPage.getSuccessMessage();
|
||||
expect(successMessage).toBeTruthy();
|
||||
|
||||
testLogger.endTest('分配权限给角色', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('分配权限给角色', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('菜单管理功能测试', () => {
|
||||
test.beforeEach(async ({ pageObjects, testData, testLogger }) => {
|
||||
testLogger.info('开始菜单管理功能测试套件');
|
||||
|
||||
await pageObjects.loginPage.navigate();
|
||||
await pageObjects.loginPage.login(testData.admin.username, testData.admin.password);
|
||||
await pageObjects.dashboardPage.waitForLoad();
|
||||
});
|
||||
|
||||
test.afterEach(async ({ testLogger, helpers }) => {
|
||||
testLogger.info('菜单管理功能测试用例完成');
|
||||
await helpers.screenshot.takeScreenshot('after-test');
|
||||
});
|
||||
|
||||
test('创建新菜单', async ({ pageObjects, testData, testLogger, helpers }) => {
|
||||
testLogger.startTest('创建新菜单');
|
||||
|
||||
try {
|
||||
await pageObjects.menuManagementPage.navigate();
|
||||
await pageObjects.menuManagementPage.waitForLoad();
|
||||
|
||||
await pageObjects.menuManagementPage.clickAddMenu();
|
||||
|
||||
await helpers.form.fillForm({
|
||||
menuName: testData.menu.menuName,
|
||||
menuType: testData.menu.menuType,
|
||||
path: testData.menu.path,
|
||||
status: testData.menu.status
|
||||
});
|
||||
|
||||
await helpers.form.submitForm();
|
||||
|
||||
const successMessage = await pageObjects.menuManagementPage.getSuccessMessage();
|
||||
expect(successMessage).toBeTruthy();
|
||||
|
||||
testLogger.endTest('创建新菜单', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('创建新菜单', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
test('菜单排序', async ({ pageObjects, testLogger }) => {
|
||||
testLogger.startTest('菜单排序');
|
||||
|
||||
try {
|
||||
await pageObjects.menuManagementPage.navigate();
|
||||
await pageObjects.menuManagementPage.waitForLoad();
|
||||
|
||||
await pageObjects.menuManagementPage.dragMenu(0, 1);
|
||||
|
||||
const successMessage = await pageObjects.menuManagementPage.getSuccessMessage();
|
||||
expect(successMessage).toBeTruthy();
|
||||
|
||||
testLogger.endTest('菜单排序', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('菜单排序', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('端到端测试', () => {
|
||||
test('完整的用户管理流程', async ({ pageObjects, testData, testLogger, helpers }) => {
|
||||
testLogger.startTest('完整的用户管理流程');
|
||||
|
||||
try {
|
||||
testLogger.startStep('用户登录');
|
||||
await pageObjects.loginPage.navigate();
|
||||
await pageObjects.loginPage.login(testData.admin.username, testData.admin.password);
|
||||
await pageObjects.dashboardPage.waitForLoad();
|
||||
testLogger.endStep('用户登录', 'passed');
|
||||
|
||||
testLogger.startStep('创建用户');
|
||||
await pageObjects.userManagementPage.navigate();
|
||||
await pageObjects.userManagementPage.waitForLoad();
|
||||
await pageObjects.userManagementPage.clickAddUser();
|
||||
|
||||
await helpers.form.fillForm({
|
||||
username: testData.user.username,
|
||||
password: testData.user.password,
|
||||
email: testData.user.email,
|
||||
phone: testData.user.phone,
|
||||
realName: testData.user.realName,
|
||||
status: testData.user.status
|
||||
});
|
||||
|
||||
await helpers.form.submitForm();
|
||||
testLogger.endStep('创建用户', 'passed');
|
||||
|
||||
testLogger.startStep('搜索用户');
|
||||
await pageObjects.userManagementPage.searchUser(testData.user.username);
|
||||
const rowCount = await helpers.table.getRowCount('.ant-table');
|
||||
expect(rowCount).toBeGreaterThan(0);
|
||||
testLogger.endStep('搜索用户', 'passed');
|
||||
|
||||
testLogger.startStep('编辑用户');
|
||||
await pageObjects.userManagementPage.clickEditUser(0);
|
||||
const updatedEmail = 'updated@example.com';
|
||||
await helpers.form.fillField('input[type="email"]', updatedEmail);
|
||||
await helpers.form.submitForm();
|
||||
testLogger.endStep('编辑用户', 'passed');
|
||||
|
||||
testLogger.startStep('删除用户');
|
||||
await pageObjects.userManagementPage.searchUser(testData.user.username);
|
||||
await pageObjects.userManagementPage.clickDeleteUser(0);
|
||||
await pageObjects.userManagementPage.confirmDelete();
|
||||
testLogger.endStep('删除用户', 'passed');
|
||||
|
||||
testLogger.endTest('完整的用户管理流程', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endTest('完整的用户管理流程', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { FullConfig } from '@playwright/test';
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
console.log('🚀 E2E测试全局设置开始...');
|
||||
|
||||
const mockEnabled = process.env.E2E_MOCK_ENABLED === 'true';
|
||||
const mockMode = process.env.E2E_MOCK_MODE || 'none';
|
||||
|
||||
// 设置 E2E 测试标记,用于 request.ts 检测 E2E 测试环境
|
||||
process.env.VITE_E2E_TEST = 'true';
|
||||
|
||||
if (mockEnabled) {
|
||||
// 禁用应用内部的 mock-interceptor,只使用 Playwright 的 mock 拦截
|
||||
process.env.VITE_MOCK_ENABLED = 'false';
|
||||
process.env.E2E_MOCK_MODE = mockMode;
|
||||
console.log(`✅ Playwright Mock服务已启用 (模式: ${mockMode})`);
|
||||
console.log(`ℹ️ 应用内部 Mock 拦截器已禁用`);
|
||||
} else {
|
||||
process.env.VITE_MOCK_ENABLED = 'false';
|
||||
console.log('ℹ️ 所有 Mock 服务已禁用');
|
||||
}
|
||||
|
||||
console.log('✅ E2E测试全局设置完成');
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { FullConfig } from '@playwright/test';
|
||||
|
||||
async function globalTeardown(config: FullConfig) {
|
||||
console.log('🧹 E2E测试全局清理开始...');
|
||||
|
||||
const mockEnabled = process.env.E2E_MOCK_ENABLED === 'true';
|
||||
|
||||
if (mockEnabled) {
|
||||
console.log('✅ Mock数据已清理');
|
||||
}
|
||||
|
||||
console.log('✅ E2E测试全局清理完成');
|
||||
}
|
||||
|
||||
export default globalTeardown;
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Page, Locator } from '@playwright/test'
|
||||
|
||||
export class FormHelper {
|
||||
private page: Page
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page
|
||||
}
|
||||
|
||||
async fillInput(selector: string, value: string, options?: { clear?: boolean; delay?: number }) {
|
||||
const input = this.page.locator(selector)
|
||||
|
||||
if (options?.clear) {
|
||||
await input.clear()
|
||||
}
|
||||
|
||||
await input.fill(value, { delay: options?.delay })
|
||||
}
|
||||
|
||||
async selectOption(selector: string, value: string) {
|
||||
const select = this.page.locator(selector)
|
||||
await select.selectOption(value)
|
||||
}
|
||||
|
||||
async checkCheckbox(selector: string) {
|
||||
const checkbox = this.page.locator(selector)
|
||||
await checkbox.check()
|
||||
}
|
||||
|
||||
async uncheckCheckbox(selector: string) {
|
||||
const checkbox = this.page.locator(selector)
|
||||
await checkbox.uncheck()
|
||||
}
|
||||
|
||||
async selectRadio(selector: string, value: string) {
|
||||
const radio = this.page.locator(`${selector}[value="${value}"]`)
|
||||
await radio.check()
|
||||
}
|
||||
|
||||
async uploadFile(selector: string, filePath: string) {
|
||||
const input = this.page.locator(selector)
|
||||
await input.setInputFiles(filePath)
|
||||
}
|
||||
|
||||
async submitForm(selector: string) {
|
||||
const form = this.page.locator(selector)
|
||||
await form.evaluate((form: HTMLFormElement) => form.submit())
|
||||
}
|
||||
|
||||
async resetForm(selector: string) {
|
||||
const form = this.page.locator(selector)
|
||||
await form.evaluate((form: HTMLFormElement) => form.reset())
|
||||
}
|
||||
|
||||
async getFieldValue(selector: string): Promise<string> {
|
||||
const field = this.page.locator(selector)
|
||||
return await field.inputValue()
|
||||
}
|
||||
|
||||
async isFieldValid(selector: string): Promise<boolean> {
|
||||
const field = this.page.locator(selector)
|
||||
const isValid = await field.evaluate((el: HTMLInputElement) => el.checkValidity())
|
||||
return isValid
|
||||
}
|
||||
|
||||
async getValidationMessage(selector: string): Promise<string> {
|
||||
const field = this.page.locator(selector)
|
||||
return await field.evaluate((el: HTMLInputElement) => el.validationMessage)
|
||||
}
|
||||
|
||||
async waitForFormToBeReady(selector: string, timeout: number = 5000) {
|
||||
await this.page.waitForSelector(selector, { state: 'visible', timeout })
|
||||
await this.page.waitForLoadState('networkidle', { timeout })
|
||||
}
|
||||
|
||||
async fillForm(fields: Array<{ selector: string; value: string; type?: 'input' | 'select' | 'checkbox' }>) {
|
||||
for (const field of fields) {
|
||||
switch (field.type) {
|
||||
case 'select':
|
||||
await this.selectOption(field.selector, field.value)
|
||||
break
|
||||
case 'checkbox':
|
||||
if (field.value === 'true' || field.value === 'checked') {
|
||||
await this.checkCheckbox(field.selector)
|
||||
} else {
|
||||
await this.uncheckCheckbox(field.selector)
|
||||
}
|
||||
break
|
||||
default:
|
||||
await this.fillInput(field.selector, field.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default FormHelper
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Page } from '@playwright/test'
|
||||
|
||||
export class PerformanceMetrics {
|
||||
private metrics: Map<string, number[]> = new Map()
|
||||
|
||||
recordMetric(name: string, value: number) {
|
||||
if (!this.metrics.has(name)) {
|
||||
this.metrics.set(name, [])
|
||||
}
|
||||
this.metrics.get(name)!.push(value)
|
||||
}
|
||||
|
||||
getAverage(name: string): number {
|
||||
const values = this.metrics.get(name) || []
|
||||
if (values.length === 0) return 0
|
||||
const sum = values.reduce((a, b) => a + b, 0)
|
||||
return sum / values.length
|
||||
}
|
||||
|
||||
getP95(name: string): number {
|
||||
const values = this.metrics.get(name) || []
|
||||
if (values.length === 0) return 0
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const index = Math.floor(sorted.length * 0.95)
|
||||
return sorted[index]
|
||||
}
|
||||
|
||||
getP99(name: string): number {
|
||||
const values = this.metrics.get(name) || []
|
||||
if (values.length === 0) return 0
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const index = Math.floor(sorted.length * 0.99)
|
||||
return sorted[index]
|
||||
}
|
||||
|
||||
getMax(name: string): number {
|
||||
const values = this.metrics.get(name) || []
|
||||
if (values.length === 0) return 0
|
||||
return Math.max(...values)
|
||||
}
|
||||
|
||||
getMin(name: string): number {
|
||||
const values = this.metrics.get(name) || []
|
||||
if (values.length === 0) return 0
|
||||
return Math.min(...values)
|
||||
}
|
||||
|
||||
printReport() {
|
||||
console.log('\n=== 性能测试报告 ===')
|
||||
for (const [name, values] of this.metrics.entries()) {
|
||||
console.log(`\n${name}:`)
|
||||
console.log(` 平均值: ${this.getAverage(name).toFixed(2)}ms`)
|
||||
console.log(` P95: ${this.getP95(name).toFixed(2)}ms`)
|
||||
console.log(` P99: ${this.getP99(name).toFixed(2)}ms`)
|
||||
console.log(` 最大值: ${this.getMax(name).toFixed(2)}ms`)
|
||||
console.log(` 最小值: ${this.getMin(name).toFixed(2)}ms`)
|
||||
console.log(` 样本数: ${values.length}`)
|
||||
}
|
||||
console.log('\n====================\n')
|
||||
}
|
||||
}
|
||||
|
||||
export class PerformanceTestHelper {
|
||||
async clearCacheAndCookies(page: Page) {
|
||||
const context = page.context()
|
||||
await context.clearCookies()
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
}
|
||||
|
||||
async measurePageLoad(page: Page, url: string): Promise<number> {
|
||||
const startTime = Date.now()
|
||||
await page.goto(url, { waitUntil: 'networkidle' })
|
||||
const endTime = Date.now()
|
||||
return endTime - startTime
|
||||
}
|
||||
|
||||
async measureApiCall(page: Page, apiCall: () => Promise<void>): Promise<number> {
|
||||
const startTime = Date.now()
|
||||
await apiCall()
|
||||
const endTime = Date.now()
|
||||
return endTime - startTime
|
||||
}
|
||||
|
||||
async measureElementInteraction(
|
||||
page: Page,
|
||||
selector: string,
|
||||
action: () => Promise<void>
|
||||
): Promise<number> {
|
||||
await page.waitForSelector(selector, { state: 'visible' })
|
||||
const startTime = Date.now()
|
||||
await action()
|
||||
const endTime = Date.now()
|
||||
return endTime - startTime
|
||||
}
|
||||
|
||||
async measurePageNavigation(
|
||||
page: Page,
|
||||
fromUrl: string,
|
||||
toUrl: string
|
||||
): Promise<number> {
|
||||
await page.goto(fromUrl, { waitUntil: 'networkidle' })
|
||||
const startTime = Date.now()
|
||||
await page.goto(toUrl, { waitUntil: 'networkidle' })
|
||||
const endTime = Date.now()
|
||||
return endTime - startTime
|
||||
}
|
||||
}
|
||||
|
||||
export default PerformanceTestHelper
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Page } from '@playwright/test'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
export class ScreenshotHelper {
|
||||
private page: Page
|
||||
private screenshotDir: string
|
||||
|
||||
constructor(page: Page, screenshotDir: string = './test-results/screenshots') {
|
||||
this.page = page
|
||||
this.screenshotDir = screenshotDir
|
||||
this.ensureDirectoryExists(screenshotDir)
|
||||
}
|
||||
|
||||
private ensureDirectoryExists(dir: string) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
async takeScreenshot(name: string): Promise<string> {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `${timestamp}-${name}.png`
|
||||
const filepath = path.join(this.screenshotDir, filename)
|
||||
|
||||
await this.page.screenshot({
|
||||
path: filepath,
|
||||
fullPage: true
|
||||
})
|
||||
|
||||
return filepath
|
||||
}
|
||||
|
||||
async takeElementScreenshot(selector: string, name: string): Promise<string> {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `${timestamp}-${name}.png`
|
||||
const filepath = path.join(this.screenshotDir, filename)
|
||||
|
||||
const element = await this.page.locator(selector)
|
||||
await element.screenshot({
|
||||
path: filepath
|
||||
})
|
||||
|
||||
return filepath
|
||||
}
|
||||
|
||||
async compareScreenshots(name: string, baselineDir: string = './test-results/baseline'): Promise<boolean> {
|
||||
const baselinePath = path.join(baselineDir, `${name}.png`)
|
||||
const currentPath = await this.takeScreenshot(`${name}-current`)
|
||||
|
||||
if (!fs.existsSync(baselinePath)) {
|
||||
console.warn(`Baseline screenshot not found: ${baselinePath}`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 这里可以添加图片比较逻辑
|
||||
// 例如使用 pixelmatch 或其他图片比较库
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export default ScreenshotHelper
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Page, Locator } from '@playwright/test'
|
||||
|
||||
export class TableHelper {
|
||||
private page: Page
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page
|
||||
}
|
||||
|
||||
async getRowCount(tableSelector: string): Promise<number> {
|
||||
const rows = await this.page.locator(`${tableSelector} tbody tr`).count()
|
||||
return rows
|
||||
}
|
||||
|
||||
async getCellText(tableSelector: string, row: number, column: number): Promise<string> {
|
||||
const cell = this.page.locator(`${tableSelector} tbody tr:nth-child(${row}) td:nth-child(${column})`)
|
||||
return await cell.textContent() || ''
|
||||
}
|
||||
|
||||
async getRowData(tableSelector: string, row: number): Promise<string[]> {
|
||||
const selector = `${tableSelector} tbody tr:nth-child(${row}) td`
|
||||
const cells = await this.page.locator(selector).allTextContents()
|
||||
return cells
|
||||
}
|
||||
|
||||
async clickRowAction(tableSelector: string, row: number, actionSelector: string) {
|
||||
const actionButton = this.page.locator(`${tableSelector} tbody tr:nth-child(${row}) ${actionSelector}`);
|
||||
await actionButton.click();
|
||||
}
|
||||
|
||||
async sortByColumn(tableSelector: string, column: number) {
|
||||
const header = this.page.locator(`${tableSelector} thead tr th:nth-child(${column})`)
|
||||
await header.click()
|
||||
}
|
||||
|
||||
async waitForTableToLoad(tableSelector: string, timeout: number = 5000) {
|
||||
await this.page.waitForSelector(`${tableSelector} tbody tr`, { state: 'visible', timeout })
|
||||
}
|
||||
|
||||
async getTableHeaders(tableSelector: string): Promise<string[]> {
|
||||
const headers = await this.page.locator(`${tableSelector} thead tr th`).allTextContents()
|
||||
return headers
|
||||
}
|
||||
|
||||
async findRowByText(tableSelector: string, text: string): Promise<number> {
|
||||
const rows = await this.page.locator(`${tableSelector} tbody tr`).all()
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const rowText = await rows[i].textContent()
|
||||
if (rowText?.includes(text)) {
|
||||
return i + 1
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
async selectRow(tableSelector: string, row: number) {
|
||||
const checkbox = this.page.locator(`${tableSelector} tbody tr:nth-child(${row}) input[type="checkbox"]`)
|
||||
await checkbox.check()
|
||||
}
|
||||
|
||||
async deselectRow(tableSelector: string, row: number) {
|
||||
const checkbox = this.page.locator(`${tableSelector} tbody tr:nth-child(${row}) input[type="checkbox"]`)
|
||||
await checkbox.uncheck()
|
||||
}
|
||||
|
||||
async selectAllRows(tableSelector: string) {
|
||||
const checkbox = this.page.locator(`${tableSelector} thead input[type="checkbox"]`)
|
||||
await checkbox.check()
|
||||
}
|
||||
|
||||
async getSelectedRows(tableSelector: string): Promise<number[]> {
|
||||
const checkboxes = await this.page.locator(`${tableSelector} tbody input[type="checkbox"]:checked`).all()
|
||||
const selectedRows: number[] = []
|
||||
|
||||
for (let i = 0; i < checkboxes.length; i++) {
|
||||
const row = await checkboxes[i].locator('xpath=ancestor::tr').evaluate((el, index) => {
|
||||
const rows = el.closest('tbody')?.querySelectorAll('tr')
|
||||
return rows ? Array.from(rows).indexOf(el.closest('tr') as HTMLTableRowElement) + 1 : -1
|
||||
}, i)
|
||||
if (row > 0) {
|
||||
selectedRows.push(row)
|
||||
}
|
||||
}
|
||||
|
||||
return selectedRows
|
||||
}
|
||||
}
|
||||
|
||||
export default TableHelper
|
||||
@@ -0,0 +1,77 @@
|
||||
import { test, expect } from './test-fixtures';
|
||||
|
||||
test.describe('菜单管理 - 完全Mock模式', () => {
|
||||
test.beforeEach(async ({ page, mockManager }) => {
|
||||
mockManager.enableMock();
|
||||
mockManager.configureMock({
|
||||
mode: 'full',
|
||||
delay: 100
|
||||
});
|
||||
|
||||
mockManager.presetTestData({
|
||||
menus: [
|
||||
{ id: 1, name: '系统管理', code: 'system', path: '/system', icon: 'SettingOutlined', parentId: 0, sortOrder: 1, status: 'ENABLED', component: '', redirect: '', description: '系统管理模块', children: [], createdAt: '2024-01-01T10:00:00.000Z', updatedAt: '2024-01-01T10:00:00.000Z' },
|
||||
{ id: 2, name: '用户管理', code: 'user', path: '/system/user', icon: 'UserOutlined', parentId: 1, sortOrder: 1, status: 'ENABLED', component: 'views/UserManagement.vue', redirect: '', description: '用户管理页面', children: [], createdAt: '2024-01-01T10:00:00.000Z', updatedAt: '2024-01-01T10:00:00.000Z' },
|
||||
{ id: 3, name: '角色管理', code: 'role', path: '/system/role', icon: 'TeamOutlined', parentId: 1, sortOrder: 2, status: 'ENABLED', component: 'views/RoleManagement.vue', redirect: '', description: '角色管理页面', children: [], createdAt: '2024-01-01T10:00:00.000Z', updatedAt: '2024-01-01T10:00:00.000Z' },
|
||||
{ id: 4, name: '菜单管理', code: 'menu', path: '/system/menu', icon: 'MenuOutlined', parentId: 1, sortOrder: 3, status: 'ENABLED', component: 'views/MenuManagement.vue', redirect: '', description: '菜单管理页面', children: [], createdAt: '2024-01-01T10:00:00.000Z', updatedAt: '2024-01-01T10:00:00.000Z' }
|
||||
]
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.fill('input[placeholder="请输入用户名"]', 'admin');
|
||||
await page.fill('input[placeholder="请输入密码"]', 'admin123');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ mockManager }) => {
|
||||
mockManager.clearPresets();
|
||||
mockManager.disableMock();
|
||||
});
|
||||
|
||||
test('应该显示菜单列表', async ({ page }) => {
|
||||
await page.goto('/menus');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(page.locator('.ant-table')).toBeVisible();
|
||||
await expect(page.locator('text=系统管理')).toBeVisible();
|
||||
await expect(page.locator('text=用户管理')).toBeVisible();
|
||||
await expect(page.locator('text=角色管理')).toBeVisible();
|
||||
await expect(page.locator('text=菜单管理')).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够创建新菜单', async ({ page }) => {
|
||||
await page.goto('/menus');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.click('button:has-text("新增菜单")');
|
||||
await page.fill('input[placeholder="请输入菜单名称"]', '测试菜单');
|
||||
await page.fill('input[placeholder="请输入菜单标识"]', 'test_menu');
|
||||
await page.fill('input[placeholder="请输入路由路径"]', '/test/menu');
|
||||
await page.fill('input[placeholder="请输入组件路径"]', 'views/TestMenu.vue');
|
||||
await page.click('button:has-text("确定")');
|
||||
|
||||
await expect(page.locator('text=创建成功')).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够编辑菜单', async ({ page }) => {
|
||||
await page.goto('/menus');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.click('button:has-text("编辑"):first');
|
||||
await page.fill('input[placeholder="请输入菜单名称"]', '更新后的菜单名称');
|
||||
await page.click('button:has-text("确定")');
|
||||
|
||||
await expect(page.locator('text=更新成功')).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够删除菜单', async ({ page }) => {
|
||||
await page.goto('/menus');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
page.on('dialog', dialog => dialog.accept());
|
||||
await page.click('button:has-text("删除"):first');
|
||||
|
||||
await expect(page.locator('text=删除成功')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { MockManager } from './mock-manager';
|
||||
|
||||
test.describe('菜单管理', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const mockManager = new MockManager({
|
||||
enabled: true,
|
||||
mode: 'full',
|
||||
mockPaths: [],
|
||||
delay: 0,
|
||||
logCalls: true,
|
||||
validateResponses: true,
|
||||
dataSource: 'memory'
|
||||
});
|
||||
|
||||
mockManager.presetTestData({
|
||||
menus: [
|
||||
{
|
||||
id: 1,
|
||||
name: '仪表盘',
|
||||
code: 'dashboard',
|
||||
path: '/dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
sortOrder: 1,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
component: 'views/Dashboard.vue',
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '菜单管理',
|
||||
code: 'menu',
|
||||
path: '/menus',
|
||||
icon: 'MenuOutlined',
|
||||
sortOrder: 4,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
component: 'views/MenuManagement.vue',
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: '系统管理',
|
||||
code: 'system',
|
||||
path: '/system',
|
||||
icon: 'SettingOutlined',
|
||||
sortOrder: 5,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
component: null,
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: [
|
||||
{
|
||||
id: 6,
|
||||
name: '用户管理',
|
||||
code: 'user',
|
||||
path: '/system/users',
|
||||
icon: 'UserOutlined',
|
||||
sortOrder: 1,
|
||||
status: 'active',
|
||||
parentId: 5,
|
||||
component: 'views/UserManagement.vue',
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: '角色管理',
|
||||
code: 'role',
|
||||
path: '/system/roles',
|
||||
icon: 'LockOutlined',
|
||||
sortOrder: 2,
|
||||
status: 'active',
|
||||
parentId: 5,
|
||||
component: 'views/RoleManagement.vue',
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await mockManager.interceptAPIRequest(page);
|
||||
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await passwordInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await loginButton.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('admin123');
|
||||
await loginButton.click();
|
||||
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test('应该显示菜单列表页面', async ({ page }) => {
|
||||
await page.goto('/menus');
|
||||
await expect(page.getByText(/菜单管理/i)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /添加菜单/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /刷新/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该显示菜单数据表格', async ({ page }) => {
|
||||
await page.goto('/menus');
|
||||
|
||||
const table = page.locator('.ant-table');
|
||||
await expect(table).toBeVisible();
|
||||
|
||||
await expect(page.getByText(/菜单名称/i)).toBeVisible();
|
||||
await expect(page.getByText(/菜单编码/i)).toBeVisible();
|
||||
await expect(page.getByText(/菜单类型/i)).toBeVisible();
|
||||
await expect(page.getByText(/路径/i)).toBeVisible();
|
||||
await expect(page.getByText(/排序/i)).toBeVisible();
|
||||
await expect(page.getByText(/状态/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够创建新菜单', async ({ page }) => {
|
||||
await page.goto('/menus');
|
||||
|
||||
await page.getByRole('button', { name: /添加菜单/i }).click();
|
||||
await expect(page).toHaveURL(/.*menus\/create/);
|
||||
|
||||
await page.getByPlaceholder(/请输入菜单名称/i).fill('测试菜单');
|
||||
await page.getByPlaceholder(/请输入菜单编码/i).fill('TEST_MENU');
|
||||
await page.getByPlaceholder(/请输入路径/i).fill('/test-menu');
|
||||
await page.getByPlaceholder(/请输入组件路径/i).fill('@/views/test/Test.vue');
|
||||
await page.getByPlaceholder(/请输入排序/i).fill('100');
|
||||
|
||||
await page.getByRole('button', { name: /提交/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/.*menus/);
|
||||
await expect(page.getByText(/创建成功/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('创建菜单时应该验证必填字段', async ({ page }) => {
|
||||
await page.goto('/menus');
|
||||
await page.getByRole('button', { name: /添加菜单/i }).click();
|
||||
|
||||
await page.getByRole('button', { name: /提交/i }).click();
|
||||
|
||||
await expect(page.getByText(/请输入菜单名称/i)).toBeVisible();
|
||||
await expect(page.getByText(/请输入菜单编码/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够编辑菜单', async ({ page }) => {
|
||||
await page.goto('/menus');
|
||||
|
||||
const editButton = page.getByRole('button').filter({ hasText: /编辑/i }).first();
|
||||
if (await editButton.isVisible()) {
|
||||
await editButton.click();
|
||||
await expect(page).toHaveURL(/.*menus\/\d+\/edit/);
|
||||
|
||||
const menuNameInput = page.getByPlaceholder(/请输入菜单名称/i);
|
||||
await menuNameInput.clear();
|
||||
await menuNameInput.fill('更新后的菜单名称');
|
||||
|
||||
await page.getByRole('button', { name: /提交/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/.*menus/);
|
||||
await expect(page.getByText(/更新成功/i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('应该能够删除菜单', async ({ page }) => {
|
||||
await page.goto('/menus');
|
||||
|
||||
const deleteButton = page.getByRole('button').filter({ hasText: /删除/i }).first();
|
||||
if (await deleteButton.isVisible()) {
|
||||
await deleteButton.click();
|
||||
|
||||
await expect(page.getByText(/确认删除/i)).toBeVisible();
|
||||
await page.getByRole('button', { name: /确认/i }).click();
|
||||
|
||||
await expect(page.getByText(/删除成功/i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('应该能够刷新菜单列表', async ({ page }) => {
|
||||
await page.goto('/menus');
|
||||
|
||||
await page.getByRole('button', { name: /刷新/i }).click();
|
||||
|
||||
await expect(page.getByText(/刷新成功/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该支持菜单类型选择', async ({ page }) => {
|
||||
await page.goto('/menus/create');
|
||||
|
||||
const menuTypeSelect = page.locator('.ant-select').first();
|
||||
await menuTypeSelect.click();
|
||||
|
||||
await expect(page.getByText(/菜单/i)).toBeVisible();
|
||||
await expect(page.getByText(/按钮/i)).toBeVisible();
|
||||
|
||||
await page.getByText(/按钮/i).click();
|
||||
await expect(menuTypeSelect).toContainText(/按钮/i);
|
||||
});
|
||||
|
||||
test('应该支持状态切换', async ({ page }) => {
|
||||
await page.goto('/menus/create');
|
||||
|
||||
const statusSelect = page.locator('.ant-select').filter({ hasText: /状态/i });
|
||||
await statusSelect.click();
|
||||
|
||||
await expect(page.getByText(/启用/i)).toBeVisible();
|
||||
await expect(page.getByText(/禁用/i)).toBeVisible();
|
||||
|
||||
await page.getByText(/禁用/i).click();
|
||||
await expect(statusSelect).toContainText(/禁用/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,505 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export interface MockConfig {
|
||||
enabled: boolean;
|
||||
mode: 'full' | 'partial' | 'none';
|
||||
mockPaths: string[];
|
||||
delay: number;
|
||||
logCalls: boolean;
|
||||
validateResponses: boolean;
|
||||
dataSource: 'memory' | 'file' | 'database';
|
||||
}
|
||||
|
||||
export interface MockStatus {
|
||||
enabled: boolean;
|
||||
mode: string;
|
||||
activeMocks: string[];
|
||||
callCount: number;
|
||||
}
|
||||
|
||||
export interface MockData {
|
||||
users?: any[];
|
||||
roles?: any[];
|
||||
menus?: any[];
|
||||
operationLogs?: any[];
|
||||
auth?: any;
|
||||
}
|
||||
|
||||
export class MockManager {
|
||||
private config: MockConfig;
|
||||
private mockData: Map<string, any> = new Map();
|
||||
private callHistory: Array<{ timestamp: number; url: string; method: string; response: any }> = [];
|
||||
|
||||
constructor(config: MockConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
enableMock(): void {
|
||||
this.config.enabled = true;
|
||||
console.log('✅ Mock已启用');
|
||||
}
|
||||
|
||||
disableMock(): void {
|
||||
this.config.enabled = false;
|
||||
console.log('❌ Mock已禁用');
|
||||
}
|
||||
|
||||
configureMock(config: Partial<MockConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
console.log('⚙️ Mock配置已更新:', this.config);
|
||||
}
|
||||
|
||||
resetMockData(): void {
|
||||
this.mockData.clear();
|
||||
this.callHistory = [];
|
||||
console.log('🔄 Mock数据已重置');
|
||||
}
|
||||
|
||||
presetTestData(data: MockData): void {
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
this.mockData.set(key, value);
|
||||
});
|
||||
console.log('📦 测试数据已预设:', Object.keys(data));
|
||||
}
|
||||
|
||||
clearPresets(): void {
|
||||
this.mockData.clear();
|
||||
console.log('🗑️ 预设数据已清除');
|
||||
}
|
||||
|
||||
getMockStatus(): MockStatus {
|
||||
return {
|
||||
enabled: this.config.enabled,
|
||||
mode: this.config.mode,
|
||||
activeMocks: Array.from(this.mockData.keys()),
|
||||
callCount: this.callHistory.length
|
||||
};
|
||||
}
|
||||
|
||||
getMockData(key: string): any {
|
||||
return this.mockData.get(key);
|
||||
}
|
||||
|
||||
recordCall(url: string, method: string, response: any): void {
|
||||
if (this.config.logCalls) {
|
||||
this.callHistory.push({
|
||||
timestamp: Date.now(),
|
||||
url,
|
||||
method,
|
||||
response
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getCallHistory(): Array<{ timestamp: number; url: string; method: string; response: any }> {
|
||||
return this.callHistory;
|
||||
}
|
||||
|
||||
async interceptAPIRequest(page: Page): Promise<void> {
|
||||
if (!this.config.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.route('**/sys/**', async (route) => {
|
||||
const request = route.request();
|
||||
const url = request.url();
|
||||
const method = request.method();
|
||||
|
||||
const shouldMock = this.shouldMockRequest(url, method);
|
||||
|
||||
if (shouldMock) {
|
||||
const mockResponse = await this.getMockResponse(url, method, request.postData());
|
||||
|
||||
if (mockResponse) {
|
||||
this.recordCall(url, method, mockResponse);
|
||||
|
||||
await route.fulfill({
|
||||
status: mockResponse.status || 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockResponse.body)
|
||||
});
|
||||
|
||||
console.log(`🎭 Mock响应: ${method} ${url}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await route.continue();
|
||||
});
|
||||
}
|
||||
|
||||
private shouldMockRequest(url: string, method: string): boolean {
|
||||
if (!this.config.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.config.mode === 'full') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.config.mode === 'partial') {
|
||||
return this.config.mockPaths.some(path => url.includes(path));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async getMockResponse(url: string, method: string, postData?: string): Promise<any> {
|
||||
if (this.config.delay > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, this.config.delay));
|
||||
}
|
||||
|
||||
if (url.includes('/sys/auth/login') && method === 'POST') {
|
||||
const credentials = JSON.parse(postData || '{}');
|
||||
|
||||
if (credentials.username === 'admin' && credentials.password === 'admin123') {
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '登录成功',
|
||||
data: {
|
||||
token: 'mock-token-123456',
|
||||
refreshToken: 'mock-refresh-token-789012',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
phone: '13800138000',
|
||||
status: 'active',
|
||||
createBy: 'system',
|
||||
updateBy: 'admin',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z'
|
||||
},
|
||||
permissions: ['dashboard:view', 'user:read', 'user:write', 'role:read', 'role:write', 'menu:read', 'menu:write', 'operationLog:read']
|
||||
}
|
||||
}
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '401',
|
||||
message: '用户名或密码错误',
|
||||
data: null
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (url.includes('/sys/auth/logout') && method === 'POST') {
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '登出成功',
|
||||
data: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/auth/refresh') && method === 'POST') {
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '刷新成功',
|
||||
data: {
|
||||
token: 'new-mock-token-123456',
|
||||
refreshToken: 'new-mock-refresh-token-789012'
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/user') && method === 'GET') {
|
||||
const users = this.mockData.get('users') || [];
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '查询成功',
|
||||
data: {
|
||||
records: users,
|
||||
total: users.length,
|
||||
current: 1,
|
||||
size: 10
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/user') && method === 'POST') {
|
||||
const newUser = JSON.parse(postData || '{}');
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '创建成功',
|
||||
data: {
|
||||
...newUser,
|
||||
id: Math.floor(Math.random() * 10000) + 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/user/') && method === 'PUT') {
|
||||
const updatedUser = JSON.parse(postData || '{}');
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '更新成功',
|
||||
data: {
|
||||
...updatedUser,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/user/') && method === 'DELETE') {
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '删除成功',
|
||||
data: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/role') && method === 'GET') {
|
||||
const roles = this.mockData.get('roles') || [];
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '查询成功',
|
||||
data: {
|
||||
records: roles,
|
||||
total: roles.length,
|
||||
current: 1,
|
||||
size: 10
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/role') && method === 'POST') {
|
||||
const newRole = JSON.parse(postData || '{}');
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '创建成功',
|
||||
data: {
|
||||
...newRole,
|
||||
id: Math.floor(Math.random() * 1000) + 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/role/') && method === 'PUT') {
|
||||
const updatedRole = JSON.parse(postData || '{}');
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '更新成功',
|
||||
data: {
|
||||
...updatedRole,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/role/') && method === 'DELETE') {
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '删除成功',
|
||||
data: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/menu') && method === 'GET') {
|
||||
const menus = this.mockData.get('menus') || [];
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '查询成功',
|
||||
data: menus
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/menu/user/') && method === 'GET') {
|
||||
const userIdMatch = url.match(/\/sys\/menu\/user\/(\d+)/);
|
||||
const userId = userIdMatch ? parseInt(userIdMatch[1]) : 1;
|
||||
|
||||
const userMenus = [
|
||||
{
|
||||
id: 1,
|
||||
code: 'dashboard',
|
||||
name: '仪表盘',
|
||||
path: '/dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
parentId: 0,
|
||||
sortOrder: 1,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
code: 'user',
|
||||
name: '用户管理',
|
||||
path: '/users',
|
||||
icon: 'UserOutlined',
|
||||
parentId: 0,
|
||||
sortOrder: 2,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
code: 'role',
|
||||
name: '角色管理',
|
||||
path: '/roles',
|
||||
icon: 'TeamOutlined',
|
||||
parentId: 0,
|
||||
sortOrder: 3,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
code: 'menu',
|
||||
name: '菜单管理',
|
||||
path: '/menus',
|
||||
icon: 'MenuOutlined',
|
||||
parentId: 0,
|
||||
sortOrder: 4,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
code: 'permission',
|
||||
name: '权限管理',
|
||||
path: '/permissions',
|
||||
icon: 'SafetyOutlined',
|
||||
parentId: 0,
|
||||
sortOrder: 5,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
code: 'operationLog',
|
||||
name: '操作日志',
|
||||
path: '/operation-logs',
|
||||
icon: 'FileTextOutlined',
|
||||
parentId: 0,
|
||||
sortOrder: 6,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
}
|
||||
];
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '查询成功',
|
||||
data: userMenus
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/menu') && method === 'POST') {
|
||||
const newMenu = JSON.parse(postData || '{}');
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '创建成功',
|
||||
data: {
|
||||
...newMenu,
|
||||
id: Math.floor(Math.random() * 1000) + 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/menu/') && method === 'PUT') {
|
||||
const updatedMenu = JSON.parse(postData || '{}');
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '更新成功',
|
||||
data: {
|
||||
...updatedMenu,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/menu/') && method === 'DELETE') {
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '删除成功',
|
||||
data: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (url.includes('/sys/operationLog') && method === 'GET') {
|
||||
const operationLogs = this.mockData.get('operationLogs') || [];
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: '200',
|
||||
message: '查询成功',
|
||||
data: {
|
||||
records: operationLogs,
|
||||
total: operationLogs.length,
|
||||
current: 1,
|
||||
size: 10
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { test, expect } from './test-fixtures';
|
||||
|
||||
test.describe('操作日志 - 完全Mock模式', () => {
|
||||
test.beforeEach(async ({ page, mockManager }) => {
|
||||
mockManager.enableMock();
|
||||
mockManager.configureMock({
|
||||
mode: 'full',
|
||||
delay: 100
|
||||
});
|
||||
|
||||
mockManager.presetTestData({
|
||||
operationLogs: [
|
||||
{ id: 1, userId: 1, username: 'admin', module: '用户管理', operation: '查询', method: 'GET', path: '/sys/user/query', params: '{"page":1,"pageSize":10}', ip: '192.168.1.100', status: 'success', errorMsg: undefined, duration: 120, createdAt: '2024-01-01T10:00:00.000Z' },
|
||||
{ id: 2, userId: 1, username: 'admin', module: '用户管理', operation: '新增', method: 'POST', path: '/sys/user/create', params: '{"username":"testuser"}', ip: '192.168.1.100', status: 'success', errorMsg: undefined, duration: 250, createdAt: '2024-01-01T11:00:00.000Z' },
|
||||
{ id: 3, userId: 1, username: 'admin', module: '角色管理', operation: '修改', method: 'PUT', path: '/sys/role/update', params: '{"roleId":1,"name":"更新后的角色"}', ip: '192.168.1.100', status: 'success', errorMsg: undefined, duration: 180, createdAt: '2024-01-01T12:00:00.000Z' },
|
||||
{ id: 4, userId: 2, username: 'user1', module: '菜单管理', operation: '删除', method: 'DELETE', path: '/sys/menu/delete', params: '{"menuId":5}', ip: '192.168.1.101', status: 'error', errorMsg: '操作失败:权限不足或参数错误', duration: 80, createdAt: '2024-01-01T13:00:00.000Z' },
|
||||
{ id: 5, userId: 1, username: 'admin', module: '操作日志', operation: '导出', method: 'GET', path: '/sys/operationLog/export', params: '{"startDate":"2024-01-01","endDate":"2024-01-31"}', ip: '192.168.1.100', status: 'success', errorMsg: undefined, duration: 350, createdAt: '2024-01-01T14:00:00.000Z' }
|
||||
]
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.fill('input[placeholder="请输入用户名"]', 'admin');
|
||||
await page.fill('input[placeholder="请输入密码"]', 'admin123');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ mockManager }) => {
|
||||
mockManager.clearPresets();
|
||||
mockManager.disableMock();
|
||||
});
|
||||
|
||||
test('应该显示操作日志列表', async ({ page }) => {
|
||||
await page.goto('/operationLogs');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(page.locator('.ant-table')).toBeVisible();
|
||||
await expect(page.locator('text=用户管理')).toBeVisible();
|
||||
await expect(page.locator('text=角色管理')).toBeVisible();
|
||||
await expect(page.locator('text=菜单管理')).toBeVisible();
|
||||
await expect(page.locator('text=操作日志')).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够按用户名搜索', async ({ page }) => {
|
||||
await page.goto('/operationLogs');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.fill('input[placeholder="请输入用户名"]', 'admin');
|
||||
await page.click('button:has-text("查询")');
|
||||
|
||||
await expect(page.locator('text=admin')).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够按模块搜索', async ({ page }) => {
|
||||
await page.goto('/operationLogs');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.click('.ant-select-selector');
|
||||
await page.click('text=用户管理');
|
||||
await page.click('button:has-text("查询")');
|
||||
|
||||
await expect(page.locator('text=用户管理')).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够按日期范围搜索', async ({ page }) => {
|
||||
await page.goto('/operationLogs');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.click('.ant-picker');
|
||||
await page.click('text=今天');
|
||||
await page.click('button:has-text("查询")');
|
||||
|
||||
await expect(page.locator('.ant-table')).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够导出操作日志', async ({ page }) => {
|
||||
await page.goto('/operationLogs');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.click('button:has-text("导出")');
|
||||
const download = await downloadPromise;
|
||||
|
||||
expect(download.suggestedFilename()).toContain('.xlsx');
|
||||
});
|
||||
|
||||
test('应该能够查看日志详情', async ({ page }) => {
|
||||
await page.goto('/operationLogs');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.click('button:has-text("详情"):first');
|
||||
|
||||
await expect(page.locator('.ant-modal')).toBeVisible();
|
||||
await expect(page.locator('text=用户名')).toBeVisible();
|
||||
await expect(page.locator('text=模块')).toBeVisible();
|
||||
await expect(page.locator('text=操作')).toBeVisible();
|
||||
await expect(page.locator('text=请求方法')).toBeVisible();
|
||||
await expect(page.locator('text=请求路径')).toBeVisible();
|
||||
await expect(page.locator('text=请求参数')).toBeVisible();
|
||||
await expect(page.locator('text=IP地址')).toBeVisible();
|
||||
await expect(page.locator('text=状态')).toBeVisible();
|
||||
await expect(page.locator('text=执行时长')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,414 @@
|
||||
import { Page, Locator, expect } from '@playwright/test';
|
||||
import { testConfig } from '../core/test-config';
|
||||
import { testLogger } from '../core/test-logger';
|
||||
import { ScreenshotHelper } from '../helpers/screenshot-helper';
|
||||
|
||||
export class BasePage {
|
||||
protected page: Page;
|
||||
protected screenshotHelper: ScreenshotHelper;
|
||||
protected baseURL: string;
|
||||
protected timeout: {
|
||||
default: number;
|
||||
navigation: number;
|
||||
element: number;
|
||||
network: number;
|
||||
};
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.screenshotHelper = new ScreenshotHelper(page);
|
||||
this.baseURL = testConfig.getBaseURL();
|
||||
this.timeout = testConfig.getEnvironment().timeout;
|
||||
}
|
||||
|
||||
async navigate(path: string = ''): Promise<void> {
|
||||
const url = path.startsWith('http') ? path : `${this.baseURL}${path}`;
|
||||
testLogger.info(`导航到页面: ${url}`);
|
||||
|
||||
try {
|
||||
await this.page.goto(url, {
|
||||
timeout: this.timeout.navigation,
|
||||
waitUntil: 'networkidle'
|
||||
});
|
||||
|
||||
await this.page.waitForLoadState('networkidle', {
|
||||
timeout: this.timeout.network
|
||||
});
|
||||
|
||||
testLogger.info(`页面加载完成: ${url}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`页面导航失败: ${url}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('navigation-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async reload(): Promise<void> {
|
||||
testLogger.info('重新加载页面');
|
||||
|
||||
try {
|
||||
await this.page.reload({
|
||||
timeout: this.timeout.navigation,
|
||||
waitUntil: 'networkidle'
|
||||
});
|
||||
|
||||
await this.page.waitForLoadState('networkidle', {
|
||||
timeout: this.timeout.network
|
||||
});
|
||||
|
||||
testLogger.info('页面重新加载完成');
|
||||
} catch (error) {
|
||||
testLogger.error('页面重新加载失败', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('reload-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async goBack(): Promise<void> {
|
||||
testLogger.info('返回上一页');
|
||||
await this.page.goBack();
|
||||
}
|
||||
|
||||
async goForward(): Promise<void> {
|
||||
testLogger.info('前进到下一页');
|
||||
await this.page.goForward();
|
||||
}
|
||||
|
||||
async waitForLoad(timeout?: number): Promise<void> {
|
||||
const loadTimeout = timeout || this.timeout.navigation;
|
||||
testLogger.debug(`等待页面加载完成, 超时时间: ${loadTimeout}ms`);
|
||||
|
||||
try {
|
||||
await this.page.waitForLoadState('networkidle', {
|
||||
timeout: loadTimeout
|
||||
});
|
||||
|
||||
testLogger.debug('页面加载完成');
|
||||
} catch (error) {
|
||||
testLogger.error('等待页面加载超时', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('wait-load-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async waitForURL(urlPattern: string | RegExp, timeout?: number): Promise<void> {
|
||||
const waitTimeout = timeout || this.timeout.navigation;
|
||||
testLogger.debug(`等待URL匹配: ${urlPattern}, 超时时间: ${waitTimeout}ms`);
|
||||
|
||||
try {
|
||||
await this.page.waitForURL(urlPattern, {
|
||||
timeout: waitTimeout
|
||||
});
|
||||
|
||||
testLogger.debug(`URL匹配成功: ${this.page.url()}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`等待URL匹配失败: ${urlPattern}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('wait-url-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async waitForElement(selector: string, timeout?: number): Promise<Locator> {
|
||||
const waitTimeout = timeout || this.timeout.element;
|
||||
testLogger.debug(`等待元素可见: ${selector}, 超时时间: ${waitTimeout}ms`);
|
||||
|
||||
try {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.waitFor({
|
||||
state: 'visible',
|
||||
timeout: waitTimeout
|
||||
});
|
||||
|
||||
testLogger.debug(`元素可见: ${selector}`);
|
||||
return locator;
|
||||
} catch (error) {
|
||||
testLogger.error(`等待元素超时: ${selector}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('wait-element-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async waitForElementHidden(selector: string, timeout?: number): Promise<void> {
|
||||
const waitTimeout = timeout || this.timeout.element;
|
||||
testLogger.debug(`等待元素隐藏: ${selector}, 超时时间: ${waitTimeout}ms`);
|
||||
|
||||
try {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.waitFor({
|
||||
state: 'hidden',
|
||||
timeout: waitTimeout
|
||||
});
|
||||
|
||||
testLogger.debug(`元素已隐藏: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`等待元素隐藏超时: ${selector}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('wait-hidden-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async click(selector: string, options?: { timeout?: number; force?: boolean }): Promise<void> {
|
||||
const clickTimeout = options?.timeout || this.timeout.element;
|
||||
testLogger.debug(`点击元素: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.waitFor({
|
||||
state: 'visible',
|
||||
timeout: clickTimeout
|
||||
});
|
||||
|
||||
await locator.click({
|
||||
timeout: clickTimeout,
|
||||
force: options?.force
|
||||
});
|
||||
|
||||
testLogger.debug(`元素点击成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`点击元素失败: ${selector}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('click-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async fill(selector: string, value: string, options?: { timeout?: number }): Promise<void> {
|
||||
const fillTimeout = options?.timeout || this.timeout.element;
|
||||
testLogger.debug(`填充输入框: ${selector}, 值: ${value}`);
|
||||
|
||||
try {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.waitFor({
|
||||
state: 'visible',
|
||||
timeout: fillTimeout
|
||||
});
|
||||
|
||||
await locator.fill(value, {
|
||||
timeout: fillTimeout
|
||||
});
|
||||
|
||||
testLogger.debug(`输入框填充成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`填充输入框失败: ${selector}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('fill-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async selectOption(selector: string, value: string | string[], options?: { timeout?: number }): Promise<void> {
|
||||
const selectTimeout = options?.timeout || this.timeout.element;
|
||||
testLogger.debug(`选择下拉选项: ${selector}, 值: ${value}`);
|
||||
|
||||
try {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.waitFor({
|
||||
state: 'visible',
|
||||
timeout: selectTimeout
|
||||
});
|
||||
|
||||
await locator.selectOption(value, {
|
||||
timeout: selectTimeout
|
||||
});
|
||||
|
||||
testLogger.debug(`下拉选项选择成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`选择下拉选项失败: ${selector}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('select-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async check(selector: string, options?: { timeout?: number }): Promise<void> {
|
||||
const checkTimeout = options?.timeout || this.timeout.element;
|
||||
testLogger.debug(`勾选复选框: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.waitFor({
|
||||
state: 'visible',
|
||||
timeout: checkTimeout
|
||||
});
|
||||
|
||||
await locator.check({
|
||||
timeout: checkTimeout
|
||||
});
|
||||
|
||||
testLogger.debug(`复选框勾选成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`勾选复选框失败: ${selector}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('check-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async uncheck(selector: string, options?: { timeout?: number }): Promise<void> {
|
||||
const uncheckTimeout = options?.timeout || this.timeout.element;
|
||||
testLogger.debug(`取消勾选复选框: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.waitFor({
|
||||
state: 'visible',
|
||||
timeout: uncheckTimeout
|
||||
});
|
||||
|
||||
await locator.uncheck({
|
||||
timeout: uncheckTimeout
|
||||
});
|
||||
|
||||
testLogger.debug(`复选框取消勾选成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`取消勾选复选框失败: ${selector}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('uncheck-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getText(selector: string, options?: { timeout?: number }): Promise<string> {
|
||||
const textTimeout = options?.timeout || this.timeout.element;
|
||||
testLogger.debug(`获取元素文本: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.waitFor({
|
||||
state: 'visible',
|
||||
timeout: textTimeout
|
||||
});
|
||||
|
||||
const text = await locator.textContent();
|
||||
testLogger.debug(`元素文本: ${selector} = ${text}`);
|
||||
|
||||
return text || '';
|
||||
} catch (error) {
|
||||
testLogger.error(`获取元素文本失败: ${selector}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('get-text-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getAttribute(selector: string, attributeName: string, options?: { timeout?: number }): Promise<string | null> {
|
||||
const attrTimeout = options?.timeout || this.timeout.element;
|
||||
testLogger.debug(`获取元素属性: ${selector}, 属性名: ${attributeName}`);
|
||||
|
||||
try {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.waitFor({
|
||||
state: 'visible',
|
||||
timeout: attrTimeout
|
||||
});
|
||||
|
||||
const attribute = await locator.getAttribute(attributeName);
|
||||
testLogger.debug(`元素属性: ${selector}[${attributeName}] = ${attribute}`);
|
||||
|
||||
return attribute;
|
||||
} catch (error) {
|
||||
testLogger.error(`获取元素属性失败: ${selector}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('get-attribute-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async isVisible(selector: string): Promise<boolean> {
|
||||
try {
|
||||
const locator = this.page.locator(selector);
|
||||
const visible = await locator.isVisible({ timeout: 5000 });
|
||||
testLogger.debug(`元素可见性: ${selector} = ${visible}`);
|
||||
|
||||
return visible;
|
||||
} catch (error) {
|
||||
testLogger.debug(`元素不可见: ${selector}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async isEnabled(selector: string): Promise<boolean> {
|
||||
try {
|
||||
const locator = this.page.locator(selector);
|
||||
const enabled = await locator.isEnabled({ timeout: 5000 });
|
||||
testLogger.debug(`元素可用性: ${selector} = ${enabled}`);
|
||||
|
||||
return enabled;
|
||||
} catch (error) {
|
||||
testLogger.debug(`元素不可用: ${selector}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async waitForTimeout(ms: number): Promise<void> {
|
||||
testLogger.debug(`等待 ${ms}ms`);
|
||||
await this.page.waitForTimeout(ms);
|
||||
}
|
||||
|
||||
async executeScript(script: string, ...args: any[]): Promise<any> {
|
||||
testLogger.debug('执行JavaScript脚本');
|
||||
|
||||
try {
|
||||
const result = await this.page.evaluate(script, ...args);
|
||||
testLogger.debug('JavaScript脚本执行成功');
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
testLogger.error('JavaScript脚本执行失败', error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async scrollToElement(selector: string): Promise<void> {
|
||||
testLogger.debug(`滚动到元素: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = this.page.locator(selector);
|
||||
await locator.scrollIntoViewIfNeeded();
|
||||
|
||||
testLogger.debug(`滚动到元素成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`滚动到元素失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async takeScreenshot(name: string): Promise<string> {
|
||||
return await this.screenshotHelper.takeScreenshot(name);
|
||||
}
|
||||
|
||||
async takeElementScreenshot(selector: string, name: string): Promise<string> {
|
||||
return await this.screenshotHelper.takeElementScreenshot(selector, name);
|
||||
}
|
||||
|
||||
getCurrentURL(): string {
|
||||
return this.page.url();
|
||||
}
|
||||
|
||||
getTitle(): Promise<string> {
|
||||
return this.page.title();
|
||||
}
|
||||
|
||||
async expectVisible(selector: string, timeout?: number): Promise<void> {
|
||||
const locator = await this.waitForElement(selector, timeout);
|
||||
await expect(locator).toBeVisible();
|
||||
}
|
||||
|
||||
async expectHidden(selector: string, timeout?: number): Promise<void> {
|
||||
const locator = this.page.locator(selector);
|
||||
await expect(locator).toBeHidden({ timeout });
|
||||
}
|
||||
|
||||
async expectText(selector: string, expectedText: string, timeout?: number): Promise<void> {
|
||||
const locator = await this.waitForElement(selector, timeout);
|
||||
await expect(locator).toHaveText(expectedText);
|
||||
}
|
||||
|
||||
async expectValue(selector: string, expectedValue: string, timeout?: number): Promise<void> {
|
||||
const locator = await this.waitForElement(selector, timeout);
|
||||
await expect(locator).toHaveValue(expectedValue);
|
||||
}
|
||||
|
||||
async expectAttribute(selector: string, attributeName: string, expectedValue: string, timeout?: number): Promise<void> {
|
||||
const locator = await this.waitForElement(selector, timeout);
|
||||
await expect(locator).toHaveAttribute(attributeName, expectedValue);
|
||||
}
|
||||
|
||||
async expectCount(selector: string, expectedCount: number, timeout?: number): Promise<void> {
|
||||
const locator = this.page.locator(selector);
|
||||
await expect(locator).toHaveCount(expectedCount, { timeout });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
import { BasePage } from './base-page';
|
||||
import { SELECTORS, TIMEOUTS } from '../constants';
|
||||
|
||||
export class DashboardPage extends BasePage {
|
||||
private readonly selectors = {
|
||||
dashboardContainer: '.dashboard-container',
|
||||
pageTitle: '.page-title',
|
||||
statisticsCards: '.statistic-card',
|
||||
charts: '.chart-container',
|
||||
menuItems: '.ant-menu-item',
|
||||
welcomeMessage: '.welcome-message',
|
||||
quickActions: '.quick-action'
|
||||
};
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
}
|
||||
|
||||
async navigate(): Promise<void> {
|
||||
testLogger.info('导航到仪表盘页面');
|
||||
await super.navigate('/dashboard');
|
||||
}
|
||||
|
||||
async waitForLoad(): Promise<void> {
|
||||
testLogger.info('等待仪表盘页面加载');
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.dashboardContainer, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.default
|
||||
});
|
||||
|
||||
testLogger.info('仪表盘页面加载完成');
|
||||
} catch (error) {
|
||||
testLogger.error('仪表盘页面加载超时', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('dashboard-load-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getPageTitle(): Promise<string> {
|
||||
testLogger.debug('获取页面标题');
|
||||
|
||||
try {
|
||||
const titleElement = this.page.locator(this.selectors.pageTitle);
|
||||
await titleElement.waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
const title = await titleElement.textContent();
|
||||
testLogger.debug(`页面标题: ${title}`);
|
||||
|
||||
return title || '';
|
||||
} catch (error) {
|
||||
testLogger.error('获取页面标题失败', error as Error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async getWelcomeMessage(): Promise<string> {
|
||||
testLogger.debug('获取欢迎消息');
|
||||
|
||||
try {
|
||||
const welcomeElement = this.page.locator(this.selectors.welcomeMessage);
|
||||
await welcomeElement.waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
const message = await welcomeElement.textContent();
|
||||
testLogger.debug(`欢迎消息: ${message}`);
|
||||
|
||||
return message || '';
|
||||
} catch (error) {
|
||||
testLogger.error('获取欢迎消息失败', error as Error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async getStatisticsCardCount(): Promise<number> {
|
||||
testLogger.debug('获取统计卡片数量');
|
||||
|
||||
try {
|
||||
const cards = this.page.locator(this.selectors.statisticsCards);
|
||||
const count = await cards.count();
|
||||
|
||||
testLogger.debug(`统计卡片数量: ${count}`);
|
||||
|
||||
return count;
|
||||
} catch (error) {
|
||||
testLogger.error('获取统计卡片数量失败', error as Error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async getChartCount(): Promise<number> {
|
||||
testLogger.debug('获取图表数量');
|
||||
|
||||
try {
|
||||
const charts = this.page.locator(this.selectors.charts);
|
||||
const count = await charts.count();
|
||||
|
||||
testLogger.debug(`图表数量: ${count}`);
|
||||
|
||||
return count;
|
||||
} catch (error) {
|
||||
testLogger.error('获取图表数量失败', error as Error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async clickMenuItem(menuName: string): Promise<void> {
|
||||
testLogger.info(`点击菜单项: ${menuName}`);
|
||||
|
||||
try {
|
||||
const menuItem = this.page.locator(this.selectors.menuItems).filter({ hasText: menuName });
|
||||
await menuItem.waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
await menuItem.click();
|
||||
|
||||
testLogger.info(`菜单项点击成功: ${menuName}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`点击菜单项失败: ${menuName}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot(`click-menu-${menuName}-error`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async clickQuickAction(actionName: string): Promise<void> {
|
||||
testLogger.info(`点击快捷操作: ${actionName}`);
|
||||
|
||||
try {
|
||||
const quickAction = this.page.locator(this.selectors.quickActions).filter({ hasText: actionName });
|
||||
await quickAction.waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
await quickAction.click();
|
||||
|
||||
testLogger.info(`快捷操作点击成功: ${actionName}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`点击快捷操作失败: ${actionName}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot(`click-quick-action-${actionName}-error`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async isDashboardVisible(): Promise<boolean> {
|
||||
testLogger.debug('检查仪表盘是否可见');
|
||||
|
||||
try {
|
||||
const dashboard = this.page.locator(this.selectors.dashboardContainer);
|
||||
const isVisible = await dashboard.isVisible();
|
||||
|
||||
testLogger.debug(`仪表盘可见性: ${isVisible}`);
|
||||
|
||||
return isVisible;
|
||||
} catch (error) {
|
||||
testLogger.error('检查仪表盘可见性失败', error as Error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async waitForStatistics(): Promise<void> {
|
||||
testLogger.info('等待统计数据加载');
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.statisticsCards, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.network
|
||||
});
|
||||
|
||||
testLogger.info('统计数据加载完成');
|
||||
} catch (error) {
|
||||
testLogger.error('等待统计数据加载超时', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('statistics-load-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async waitForCharts(): Promise<void> {
|
||||
testLogger.info('等待图表加载');
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.charts, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.network
|
||||
});
|
||||
|
||||
testLogger.info('图表加载完成');
|
||||
} catch (error) {
|
||||
testLogger.error('等待图表加载超时', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('charts-load-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
import { BasePage } from './base-page';
|
||||
import { testLogger } from '../core/test-logger';
|
||||
|
||||
export class LoginPage extends BasePage {
|
||||
private readonly selectors = {
|
||||
usernameInput: '[data-testid="username-input"]',
|
||||
passwordInput: '[data-testid="password-input"]',
|
||||
loginButton: '[data-testid="login-button"]',
|
||||
errorMessage: '.ant-message-error',
|
||||
successMessage: '.ant-message-success',
|
||||
loginForm: '[data-testid="login-form"]',
|
||||
rememberMeCheckbox: '[data-testid="remember-checkbox"]',
|
||||
forgotPasswordLink: 'text=忘记密码',
|
||||
registerLink: 'text=注册账号'
|
||||
};
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
}
|
||||
|
||||
async navigate(): Promise<void> {
|
||||
testLogger.info('导航到登录页面');
|
||||
await super.navigate('/login');
|
||||
}
|
||||
|
||||
async waitForLoad(): Promise<void> {
|
||||
testLogger.debug('等待登录页面加载完成');
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
this.waitForElement(this.selectors.usernameInput),
|
||||
this.waitForElement(this.selectors.passwordInput),
|
||||
this.waitForElement(this.selectors.loginButton)
|
||||
]);
|
||||
|
||||
testLogger.info('登录页面加载完成');
|
||||
} catch (error) {
|
||||
testLogger.error('登录页面加载失败', error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getUsernameInput() {
|
||||
return this.page.locator(this.selectors.usernameInput);
|
||||
}
|
||||
|
||||
async getPasswordInput() {
|
||||
return this.page.locator(this.selectors.passwordInput);
|
||||
}
|
||||
|
||||
async getLoginButton() {
|
||||
return this.page.locator(this.selectors.loginButton);
|
||||
}
|
||||
|
||||
async getErrorMessage() {
|
||||
return this.page.locator(this.selectors.errorMessage);
|
||||
}
|
||||
|
||||
async getSuccessMessage() {
|
||||
return this.page.locator(this.selectors.successMessage);
|
||||
}
|
||||
|
||||
async fillUsername(username: string): Promise<void> {
|
||||
testLogger.info(`填写用户名: ${username}`);
|
||||
await this.fill(this.selectors.usernameInput, username);
|
||||
}
|
||||
|
||||
async fillPassword(password: string): Promise<void> {
|
||||
testLogger.info('填写密码');
|
||||
await this.fill(this.selectors.passwordInput, password);
|
||||
}
|
||||
|
||||
async clickLoginButton(): Promise<void> {
|
||||
testLogger.info('点击登录按钮');
|
||||
await this.click(this.selectors.loginButton);
|
||||
}
|
||||
|
||||
async toggleRememberMe(remember: boolean): Promise<void> {
|
||||
testLogger.info(`设置记住密码: ${remember}`);
|
||||
|
||||
if (remember) {
|
||||
await this.check(this.selectors.rememberMeCheckbox);
|
||||
} else {
|
||||
await this.uncheck(this.selectors.rememberMeCheckbox);
|
||||
}
|
||||
}
|
||||
|
||||
async login(username: string, password: string, rememberMe: boolean = false): Promise<void> {
|
||||
testLogger.startStep('用户登录');
|
||||
|
||||
try {
|
||||
await this.waitForLoad();
|
||||
await this.fillUsername(username);
|
||||
await this.fillPassword(password);
|
||||
|
||||
if (rememberMe) {
|
||||
await this.toggleRememberMe(true);
|
||||
}
|
||||
|
||||
await this.clickLoginButton();
|
||||
|
||||
testLogger.endStep('用户登录', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endStep('用户登录', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async loginAndWaitForDashboard(username: string, password: string, rememberMe: boolean = false): Promise<void> {
|
||||
testLogger.startStep('登录并等待跳转到仪表盘');
|
||||
|
||||
try {
|
||||
await this.login(username, password, rememberMe);
|
||||
|
||||
await this.waitForURL(/.*dashboard/, this.timeout.navigation);
|
||||
|
||||
testLogger.endStep('登录并等待跳转到仪表盘', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endStep('登录并等待跳转到仪表盘', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async expectErrorMessage(message: string): Promise<void> {
|
||||
testLogger.debug(`期望错误消息: ${message}`);
|
||||
|
||||
const errorLocator = this.getErrorMessage();
|
||||
await expect(errorLocator).toBeVisible({ timeout: 5000 });
|
||||
await expect(errorLocator).toContainText(message);
|
||||
}
|
||||
|
||||
async expectSuccessMessage(message: string): Promise<void> {
|
||||
testLogger.debug(`期望成功消息: ${message}`);
|
||||
|
||||
const successLocator = this.getSuccessMessage();
|
||||
await expect(successLocator).toBeVisible({ timeout: 5000 });
|
||||
await expect(successLocator).toContainText(message);
|
||||
}
|
||||
|
||||
async hasErrorMessage(): Promise<boolean> {
|
||||
const errorLocator = this.getErrorMessage();
|
||||
const count = await errorLocator.count();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
async hasSuccessMessage(): Promise<boolean> {
|
||||
const successLocator = this.getSuccessMessage();
|
||||
const count = await successLocator.count();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
async getErrorMessageText(): Promise<string> {
|
||||
const errorLocator = this.getErrorMessage();
|
||||
const text = await errorLocator.textContent();
|
||||
return text || '';
|
||||
}
|
||||
|
||||
async getSuccessMessageText(): Promise<string> {
|
||||
const successLocator = this.getSuccessMessage();
|
||||
const text = await successLocator.textContent();
|
||||
return text || '';
|
||||
}
|
||||
|
||||
async isLoginButtonEnabled(): Promise<boolean> {
|
||||
const loginButton = this.getLoginButton();
|
||||
return await loginButton.isEnabled();
|
||||
}
|
||||
|
||||
async isUsernameInputVisible(): Promise<boolean> {
|
||||
return await this.isVisible(this.selectors.usernameInput);
|
||||
}
|
||||
|
||||
async isPasswordInputVisible(): Promise<boolean> {
|
||||
return await this.isVisible(this.selectors.passwordInput);
|
||||
}
|
||||
|
||||
async isLoginFormVisible(): Promise<boolean> {
|
||||
return await this.isVisible(this.selectors.loginForm);
|
||||
}
|
||||
|
||||
async clearUsername(): Promise<void> {
|
||||
testLogger.info('清空用户名输入框');
|
||||
const usernameInput = this.getUsernameInput();
|
||||
await usernameInput.fill('');
|
||||
}
|
||||
|
||||
async clearPassword(): Promise<void> {
|
||||
testLogger.info('清空密码输入框');
|
||||
const passwordInput = this.getPasswordInput();
|
||||
await passwordInput.fill('');
|
||||
}
|
||||
|
||||
async clearAllFields(): Promise<void> {
|
||||
await this.clearUsername();
|
||||
await this.clearPassword();
|
||||
}
|
||||
|
||||
async clickForgotPassword(): Promise<void> {
|
||||
testLogger.info('点击忘记密码链接');
|
||||
await this.click(this.selectors.forgotPasswordLink);
|
||||
}
|
||||
|
||||
async clickRegister(): Promise<void> {
|
||||
testLogger.info('点击注册账号链接');
|
||||
await this.click(this.selectors.registerLink);
|
||||
}
|
||||
|
||||
async pressEnter(): Promise<void> {
|
||||
testLogger.info('按Enter键提交登录');
|
||||
await this.page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
async loginWithEnter(username: string, password: string): Promise<void> {
|
||||
testLogger.startStep('使用Enter键登录');
|
||||
|
||||
try {
|
||||
await this.waitForLoad();
|
||||
await this.fillUsername(username);
|
||||
await this.fillPassword(password);
|
||||
await this.pressEnter();
|
||||
|
||||
testLogger.endStep('使用Enter键登录', 'passed');
|
||||
} catch (error) {
|
||||
testLogger.endStep('使用Enter键登录', 'failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async takeScreenshotOnLogin(name: string = 'login-page'): Promise<string> {
|
||||
return await this.takeScreenshot(name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import { BasePage } from './base-page';
|
||||
import { FormHelper } from '../helpers/form-helper';
|
||||
import { TableHelper } from '../helpers/table-helper';
|
||||
import { ScreenshotHelper } from '../helpers/screenshot-helper';
|
||||
import { testLogger } from '../core/test-logger';
|
||||
|
||||
export class MenuManagementPage extends BasePage {
|
||||
private formHelper: FormHelper;
|
||||
private tableHelper: TableHelper;
|
||||
private screenshotHelper: ScreenshotHelper;
|
||||
|
||||
private readonly selectors = {
|
||||
menuTree: '.menu-tree',
|
||||
menuTable: '.menu-table',
|
||||
addMenuButton: 'button:has-text("新增")',
|
||||
editButton: 'button:has-text("编辑")',
|
||||
deleteButton: 'button:has-text("删除")',
|
||||
searchInput: 'input[placeholder*="搜索"]',
|
||||
searchButton: 'button:has-text("查询")',
|
||||
resetButton: 'button:has-text("重置")',
|
||||
modal: '.ant-modal',
|
||||
modalTitle: '.ant-modal-title',
|
||||
modalConfirmButton: '.ant-modal-confirm-btn',
|
||||
modalCancelButton: '.ant-modal-cancel-btn',
|
||||
successMessage: '.ant-message-success',
|
||||
errorMessage: '.ant-message-error',
|
||||
menuForm: '.menu-form',
|
||||
menuNameInput: 'input[name="menuName"]',
|
||||
menuTypeSelect: 'select[name="menuType"]',
|
||||
menuIconInput: 'input[name="icon"]',
|
||||
orderNumInput: 'input[name="orderNum"]',
|
||||
pathInput: 'input[name="path"]',
|
||||
componentInput: 'input[name="component"]',
|
||||
statusSelect: 'select[name="status"]',
|
||||
visibleSelect: 'select[name="visible"]',
|
||||
remarkInput: 'textarea[name="remark"]',
|
||||
treeNode: '.ant-tree-node',
|
||||
treeExpandButton: '.ant-tree-switcher'
|
||||
};
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
this.formHelper = new FormHelper(page);
|
||||
this.tableHelper = new TableHelper(page);
|
||||
this.screenshotHelper = new ScreenshotHelper(page);
|
||||
}
|
||||
|
||||
async navigate(): Promise<void> {
|
||||
testLogger.info('导航到菜单管理页面');
|
||||
await super.navigate('/system/menu');
|
||||
}
|
||||
|
||||
async waitForLoad(): Promise<void> {
|
||||
testLogger.info('等待菜单管理页面加载');
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.menuTree, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.default
|
||||
});
|
||||
|
||||
testLogger.info('菜单管理页面加载完成');
|
||||
} catch (error) {
|
||||
testLogger.error('菜单管理页面加载超时', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('menu-management-load-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async clickAddMenu(): Promise<void> {
|
||||
testLogger.info('点击新增菜单按钮');
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.addMenuButton, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
await this.page.click(this.selectors.addMenuButton);
|
||||
|
||||
await this.page.waitForSelector(this.selectors.modal, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
testLogger.info('新增菜单对话框已打开');
|
||||
} catch (error) {
|
||||
testLogger.error('点击新增菜单按钮失败', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('click-add-menu-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async clickEditMenu(menuName: string): Promise<void> {
|
||||
testLogger.info(`点击编辑菜单按钮,菜单名称: ${menuName}`);
|
||||
|
||||
try {
|
||||
const editButtons = this.page.locator(this.selectors.editButton);
|
||||
await editButtons.first().waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
await editButtons.first().click();
|
||||
|
||||
await this.page.waitForSelector(this.selectors.modal, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
testLogger.info('编辑菜单对话框已打开');
|
||||
} catch (error) {
|
||||
testLogger.error(`点击编辑菜单按钮失败,菜单名称: ${menuName}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot(`click-edit-menu-${menuName}-error`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async clickDeleteMenu(menuName: string): Promise<void> {
|
||||
testLogger.info(`点击删除菜单按钮,菜单名称: ${menuName}`);
|
||||
|
||||
try {
|
||||
const deleteButtons = this.page.locator(this.selectors.deleteButton);
|
||||
await deleteButtons.first().waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
await deleteButtons.first().click();
|
||||
|
||||
testLogger.info('删除确认对话框已打开');
|
||||
} catch (error) {
|
||||
testLogger.error(`点击删除菜单按钮失败,菜单名称: ${menuName}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot(`click-delete-menu-${menuName}-error`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async confirmDelete(): Promise<void> {
|
||||
testLogger.info('确认删除菜单');
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.modalConfirmButton, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
await this.page.click(this.selectors.modalConfirmButton);
|
||||
|
||||
await this.page.waitForSelector(this.selectors.modal, {
|
||||
state: 'hidden',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
testLogger.info('菜单删除确认成功');
|
||||
} catch (error) {
|
||||
testLogger.error('确认删除菜单失败', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('confirm-delete-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async searchMenu(keyword: string): Promise<void> {
|
||||
testLogger.info(`搜索菜单,关键词: ${keyword}`);
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.searchInput, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
await this.page.fill(this.selectors.searchInput, keyword);
|
||||
await this.page.click(this.selectors.searchButton);
|
||||
|
||||
await this.page.waitForLoadState('networkidle', {
|
||||
timeout: this.timeout.network
|
||||
});
|
||||
|
||||
testLogger.info('菜单搜索完成');
|
||||
} catch (error) {
|
||||
testLogger.error(`搜索菜单失败,关键词: ${keyword}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('search-menu-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async expandTreeNode(nodeIndex: number): Promise<void> {
|
||||
testLogger.info(`展开树节点,索引: ${nodeIndex}`);
|
||||
|
||||
try {
|
||||
const expandButtons = this.page.locator(this.selectors.treeExpandButton);
|
||||
await expandButtons.nth(nodeIndex).waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
await expandButtons.nth(nodeIndex).click();
|
||||
|
||||
testLogger.info(`树节点已展开,索引: ${nodeIndex}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`展开树节点失败,索引: ${nodeIndex}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot(`expand-tree-node-${nodeIndex}-error`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getSuccessMessage(): Promise<string> {
|
||||
testLogger.debug('获取成功消息');
|
||||
|
||||
try {
|
||||
const successElement = this.page.locator(this.selectors.successMessage);
|
||||
await successElement.waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
const message = await successElement.textContent();
|
||||
testLogger.debug(`成功消息: ${message}`);
|
||||
|
||||
return message || '';
|
||||
} catch (error) {
|
||||
testLogger.error('获取成功消息失败', error as Error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async getErrorMessage(): Promise<string> {
|
||||
testLogger.debug('获取错误消息');
|
||||
|
||||
try {
|
||||
const errorElement = this.page.locator(this.selectors.errorMessage);
|
||||
await errorElement.waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
const message = await errorElement.textContent();
|
||||
testLogger.debug(`错误消息: ${message}`);
|
||||
|
||||
return message || '';
|
||||
} catch (error) {
|
||||
testLogger.error('获取错误消息失败', error as Error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async getMenuCount(): Promise<number> {
|
||||
testLogger.debug('获取菜单数量');
|
||||
|
||||
try {
|
||||
const count = await this.tableHelper.getRowCount(this.selectors.menuTable);
|
||||
testLogger.debug(`菜单数量: ${count}`);
|
||||
|
||||
return count;
|
||||
} catch (error) {
|
||||
testLogger.error('获取菜单数量失败', error as Error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async getTreeNodeCount(): Promise<number> {
|
||||
testLogger.debug('获取树节点数量');
|
||||
|
||||
try {
|
||||
const treeNodes = this.page.locator(this.selectors.treeNode);
|
||||
const count = await treeNodes.count();
|
||||
|
||||
testLogger.debug(`树节点数量: ${count}`);
|
||||
|
||||
return count;
|
||||
} catch (error) {
|
||||
testLogger.error('获取树节点数量失败', error as Error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import { BasePage } from './base-page';
|
||||
import { FormHelper } from '../helpers/form-helper';
|
||||
import { TableHelper } from '../helpers/table-helper';
|
||||
import { ScreenshotHelper } from '../helpers/screenshot-helper';
|
||||
import { testLogger } from '../core/test-logger';
|
||||
|
||||
export class RoleManagementPage extends BasePage {
|
||||
private formHelper: FormHelper;
|
||||
private tableHelper: TableHelper;
|
||||
private screenshotHelper: ScreenshotHelper;
|
||||
|
||||
private readonly selectors = {
|
||||
roleTable: '.role-table',
|
||||
addRoleButton: 'button:has-text("新增")',
|
||||
editButton: 'button:has-text("编辑")',
|
||||
deleteButton: 'button:has-text("删除")',
|
||||
searchInput: 'input[placeholder*="搜索"]',
|
||||
searchButton: 'button:has-text("查询")',
|
||||
resetButton: 'button:has-text("重置")',
|
||||
modal: '.ant-modal',
|
||||
modalTitle: '.ant-modal-title',
|
||||
modalConfirmButton: '.ant-modal-confirm-btn',
|
||||
modalCancelButton: '.ant-modal-cancel-btn',
|
||||
successMessage: '.ant-message-success',
|
||||
errorMessage: '.ant-message-error',
|
||||
roleForm: '.role-form',
|
||||
roleNameInput: 'input[name="roleName"]',
|
||||
roleKeyInput: 'input[name="roleKey"]',
|
||||
roleSortInput: 'input[name="roleSort"]',
|
||||
statusSelect: 'select[name="status"]',
|
||||
remarkInput: 'textarea[name="remark"]',
|
||||
permissionTree: '.permission-tree',
|
||||
permissionCheckbox: '.ant-tree-checkbox'
|
||||
};
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
this.formHelper = new FormHelper(page);
|
||||
this.tableHelper = new TableHelper(page);
|
||||
this.screenshotHelper = new ScreenshotHelper(page);
|
||||
}
|
||||
|
||||
async navigate(): Promise<void> {
|
||||
testLogger.info('导航到角色管理页面');
|
||||
await super.navigate('/system/role');
|
||||
}
|
||||
|
||||
async waitForLoad(): Promise<void> {
|
||||
testLogger.info('等待角色管理页面加载');
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.roleTable, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.default
|
||||
});
|
||||
|
||||
testLogger.info('角色管理页面加载完成');
|
||||
} catch (error) {
|
||||
testLogger.error('角色管理页面加载超时', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('role-management-load-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async clickAddRole(): Promise<void> {
|
||||
testLogger.info('点击新增角色按钮');
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.addRoleButton, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
await this.page.click(this.selectors.addRoleButton);
|
||||
|
||||
await this.page.waitForSelector(this.selectors.modal, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
testLogger.info('新增角色对话框已打开');
|
||||
} catch (error) {
|
||||
testLogger.error('点击新增角色按钮失败', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('click-add-role-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async clickEditRole(rowIndex: number): Promise<void> {
|
||||
testLogger.info(`点击编辑角色按钮,行索引: ${rowIndex}`);
|
||||
|
||||
try {
|
||||
const editButtons = this.page.locator(this.selectors.editButton);
|
||||
await editButtons.nth(rowIndex).waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
await editButtons.nth(rowIndex).click();
|
||||
|
||||
await this.page.waitForSelector(this.selectors.modal, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
testLogger.info('编辑角色对话框已打开');
|
||||
} catch (error) {
|
||||
testLogger.error(`点击编辑角色按钮失败,行索引: ${rowIndex}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot(`click-edit-role-${rowIndex}-error`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async clickDeleteRole(rowIndex: number): Promise<void> {
|
||||
testLogger.info(`点击删除角色按钮,行索引: ${rowIndex}`);
|
||||
|
||||
try {
|
||||
const deleteButtons = this.page.locator(this.selectors.deleteButton);
|
||||
await deleteButtons.nth(rowIndex).waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
await deleteButtons.nth(rowIndex).click();
|
||||
|
||||
testLogger.info('删除确认对话框已打开');
|
||||
} catch (error) {
|
||||
testLogger.error(`点击删除角色按钮失败,行索引: ${rowIndex}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot(`click-delete-role-${rowIndex}-error`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async confirmDelete(): Promise<void> {
|
||||
testLogger.info('确认删除角色');
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.modalConfirmButton, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
await this.page.click(this.selectors.modalConfirmButton);
|
||||
|
||||
await this.page.waitForSelector(this.selectors.modal, {
|
||||
state: 'hidden',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
testLogger.info('角色删除确认成功');
|
||||
} catch (error) {
|
||||
testLogger.error('确认删除角色失败', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('confirm-delete-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async searchRole(keyword: string): Promise<void> {
|
||||
testLogger.info(`搜索角色,关键词: ${keyword}`);
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.searchInput, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
await this.page.fill(this.selectors.searchInput, keyword);
|
||||
await this.page.click(this.selectors.searchButton);
|
||||
|
||||
await this.page.waitForLoadState('networkidle', {
|
||||
timeout: this.timeout.network
|
||||
});
|
||||
|
||||
testLogger.info('角色搜索完成');
|
||||
} catch (error) {
|
||||
testLogger.error(`搜索角色失败,关键词: ${keyword}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('search-role-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getSuccessMessage(): Promise<string> {
|
||||
testLogger.debug('获取成功消息');
|
||||
|
||||
try {
|
||||
const successElement = this.page.locator(this.selectors.successMessage);
|
||||
await successElement.waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
const message = await successElement.textContent();
|
||||
testLogger.debug(`成功消息: ${message}`);
|
||||
|
||||
return message || '';
|
||||
} catch (error) {
|
||||
testLogger.error('获取成功消息失败', error as Error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async getErrorMessage(): Promise<string> {
|
||||
testLogger.debug('获取错误消息');
|
||||
|
||||
try {
|
||||
const errorElement = this.page.locator(this.selectors.errorMessage);
|
||||
await errorElement.waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
const message = await errorElement.textContent();
|
||||
testLogger.debug(`错误消息: ${message}`);
|
||||
|
||||
return message || '';
|
||||
} catch (error) {
|
||||
testLogger.error('获取错误消息失败', error as Error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async getRoleCount(): Promise<number> {
|
||||
testLogger.debug('获取角色数量');
|
||||
|
||||
try {
|
||||
const count = await this.tableHelper.getRowCount(this.selectors.roleTable);
|
||||
testLogger.debug(`角色数量: ${count}`);
|
||||
|
||||
return count;
|
||||
} catch (error) {
|
||||
testLogger.error('获取角色数量失败', error as Error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import { BasePage } from './base-page';
|
||||
import { FormHelper } from '../helpers/form-helper';
|
||||
import { TableHelper } from '../helpers/table-helper';
|
||||
import { ScreenshotHelper } from '../helpers/screenshot-helper';
|
||||
import { testLogger } from '../core/test-logger';
|
||||
|
||||
export class UserManagementPage extends BasePage {
|
||||
private formHelper: FormHelper;
|
||||
private tableHelper: TableHelper;
|
||||
private screenshotHelper: ScreenshotHelper;
|
||||
|
||||
private readonly selectors = {
|
||||
userTable: '[data-testid="user-table"]',
|
||||
addUserButton: '[data-testid="add-user-button"]',
|
||||
editButton: 'button:has-text("编辑")',
|
||||
deleteButton: 'button:has-text("删除")',
|
||||
searchInput: '[data-testid="username-search-input"]',
|
||||
emailSearchInput: '[data-testid="email-search-input"]',
|
||||
statusSelect: '[data-testid="status-select"]',
|
||||
searchButton: '[data-testid="search-button"]',
|
||||
resetButton: '[data-testid="reset-button"]',
|
||||
modal: '.ant-modal',
|
||||
modalTitle: '.ant-modal-title',
|
||||
modalConfirmButton: '.ant-modal .ant-btn-primary',
|
||||
modalCancelButton: '.ant-modal .ant-btn-default',
|
||||
successMessage: '.ant-message-success',
|
||||
errorMessage: '.ant-message-error',
|
||||
pagination: '.ant-pagination',
|
||||
userForm: '.user-form',
|
||||
usernameInput: 'input[name="username"]',
|
||||
passwordInput: 'input[name="password"]',
|
||||
emailInput: 'input[name="email"]',
|
||||
phoneInput: 'input[name="phone"]',
|
||||
realNameInput: 'input[name="realName"]',
|
||||
statusSelect: 'select[name="status"]',
|
||||
roleSelect: 'select[name="roleIds"]'
|
||||
};
|
||||
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
this.formHelper = new FormHelper(page);
|
||||
this.tableHelper = new TableHelper(page);
|
||||
this.screenshotHelper = new ScreenshotHelper(page);
|
||||
}
|
||||
|
||||
async navigate(): Promise<void> {
|
||||
testLogger.info('导航到用户管理页面');
|
||||
await super.navigate('/system/user');
|
||||
}
|
||||
|
||||
async waitForLoad(): Promise<void> {
|
||||
testLogger.info('等待用户管理页面加载');
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.userTable, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.default
|
||||
});
|
||||
|
||||
testLogger.info('用户管理页面加载完成');
|
||||
} catch (error) {
|
||||
testLogger.error('用户管理页面加载超时', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('user-management-load-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async clickAddUser(): Promise<void> {
|
||||
testLogger.info('点击新增用户按钮');
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.addUserButton, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
await this.page.click(this.selectors.addUserButton);
|
||||
|
||||
await this.page.waitForSelector(this.selectors.modal, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
testLogger.info('新增用户对话框已打开');
|
||||
} catch (error) {
|
||||
testLogger.error('点击新增用户按钮失败', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('click-add-user-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async clickEditUser(rowIndex: number): Promise<void> {
|
||||
testLogger.info(`点击编辑用户按钮,行索引: ${rowIndex}`);
|
||||
|
||||
try {
|
||||
const editButtons = this.page.locator(this.selectors.editButton);
|
||||
await editButtons.nth(rowIndex).waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
await editButtons.nth(rowIndex).click();
|
||||
|
||||
await this.page.waitForSelector(this.selectors.modal, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
testLogger.info('编辑用户对话框已打开');
|
||||
} catch (error) {
|
||||
testLogger.error(`点击编辑用户按钮失败,行索引: ${rowIndex}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot(`click-edit-user-${rowIndex}-error`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async clickDeleteUser(rowIndex: number): Promise<void> {
|
||||
testLogger.info(`点击删除用户按钮,行索引: ${rowIndex}`);
|
||||
|
||||
try {
|
||||
const deleteButtons = this.page.locator(this.selectors.deleteButton);
|
||||
await deleteButtons.nth(rowIndex).waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
await deleteButtons.nth(rowIndex).click();
|
||||
|
||||
testLogger.info('删除确认对话框已打开');
|
||||
} catch (error) {
|
||||
testLogger.error(`点击删除用户按钮失败,行索引: ${rowIndex}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot(`click-delete-user-${rowIndex}-error`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async confirmDelete(): Promise<void> {
|
||||
testLogger.info('确认删除用户');
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.modalConfirmButton, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
await this.page.click(this.selectors.modalConfirmButton);
|
||||
|
||||
await this.page.waitForSelector(this.selectors.modal, {
|
||||
state: 'hidden',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
testLogger.info('用户删除确认成功');
|
||||
} catch (error) {
|
||||
testLogger.error('确认删除用户失败', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('confirm-delete-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async searchUser(keyword: string): Promise<void> {
|
||||
testLogger.info(`搜索用户,关键词: ${keyword}`);
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.searchInput, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
await this.page.fill(this.selectors.searchInput, keyword);
|
||||
await this.page.click(this.selectors.searchButton);
|
||||
|
||||
await this.page.waitForLoadState('networkidle', {
|
||||
timeout: this.timeout.network
|
||||
});
|
||||
|
||||
testLogger.info('用户搜索完成');
|
||||
} catch (error) {
|
||||
testLogger.error(`搜索用户失败,关键词: ${keyword}`, error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('search-user-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async resetSearch(): Promise<void> {
|
||||
testLogger.info('重置搜索条件');
|
||||
|
||||
try {
|
||||
await this.page.waitForSelector(this.selectors.resetButton, {
|
||||
state: 'visible',
|
||||
timeout: this.timeout.element
|
||||
});
|
||||
|
||||
await this.page.click(this.selectors.resetButton);
|
||||
|
||||
await this.page.waitForLoadState('networkidle', {
|
||||
timeout: this.timeout.network
|
||||
});
|
||||
|
||||
testLogger.info('搜索条件已重置');
|
||||
} catch (error) {
|
||||
testLogger.error('重置搜索条件失败', error as Error);
|
||||
await this.screenshotHelper.takeScreenshot('reset-search-error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getSuccessMessage(): Promise<string> {
|
||||
testLogger.debug('获取成功消息');
|
||||
|
||||
try {
|
||||
const successElement = this.page.locator(this.selectors.successMessage);
|
||||
await successElement.waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
const message = await successElement.textContent();
|
||||
testLogger.debug(`成功消息: ${message}`);
|
||||
|
||||
return message || '';
|
||||
} catch (error) {
|
||||
testLogger.error('获取成功消息失败', error as Error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async getErrorMessage(): Promise<string> {
|
||||
testLogger.debug('获取错误消息');
|
||||
|
||||
try {
|
||||
const errorElement = this.page.locator(this.selectors.errorMessage);
|
||||
await errorElement.waitFor({ state: 'visible', timeout: this.timeout.element });
|
||||
|
||||
const message = await errorElement.textContent();
|
||||
testLogger.debug(`错误消息: ${message}`);
|
||||
|
||||
return message || '';
|
||||
} catch (error) {
|
||||
testLogger.error('获取错误消息失败', error as Error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async getUserCount(): Promise<number> {
|
||||
testLogger.debug('获取用户数量');
|
||||
|
||||
try {
|
||||
const count = await this.tableHelper.getRowCount(this.selectors.userTable);
|
||||
testLogger.debug(`用户数量: ${count}`);
|
||||
|
||||
return count;
|
||||
} catch (error) {
|
||||
testLogger.error('获取用户数量失败', error as Error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
# 性能测试文档
|
||||
|
||||
## 概述
|
||||
|
||||
性能测试用于评估系统在不同负载条件下的性能表现,包括页面加载速度、API响应时间、并发处理能力和资源使用情况。
|
||||
|
||||
## 测试框架
|
||||
|
||||
性能测试基于 Playwright 框架,提供以下功能:
|
||||
- 页面加载性能测试
|
||||
- API响应性能测试
|
||||
- 并发和负载测试
|
||||
- 内存使用监控
|
||||
- 网络请求统计
|
||||
|
||||
### 核心文件
|
||||
|
||||
- `performance.spec.ts`: 页面加载性能测试和性能指标收集工具
|
||||
- `api-performance.spec.ts`: API响应性能测试
|
||||
- `concurrency-performance.spec.ts`: 并发和负载测试
|
||||
|
||||
## 运行性能测试
|
||||
|
||||
### 前置条件
|
||||
|
||||
1. 确保后端服务已启动
|
||||
2. 确保前端开发服务器已启动
|
||||
3. 确保数据库已初始化测试数据
|
||||
4. 确保系统资源充足(CPU、内存、网络)
|
||||
|
||||
### 运行所有性能测试
|
||||
|
||||
```bash
|
||||
npx playwright test e2e/performance
|
||||
```
|
||||
|
||||
### 运行特定性能测试套件
|
||||
|
||||
```bash
|
||||
# 运行页面加载性能测试
|
||||
npx playwright test e2e/performance/performance.spec.ts
|
||||
|
||||
# 运行API响应性能测试
|
||||
npx playwright test e2e/performance/api-performance.spec.ts
|
||||
|
||||
# 运行并发和负载测试
|
||||
npx playwright test e2e/performance/concurrency-performance.spec.ts
|
||||
```
|
||||
|
||||
### 运行性能测试(UI模式)
|
||||
|
||||
```bash
|
||||
npx playwright test e2e/performance --ui
|
||||
```
|
||||
|
||||
### 调试性能测试
|
||||
|
||||
```bash
|
||||
npx playwright test e2e/performance --debug
|
||||
```
|
||||
|
||||
## 性能指标
|
||||
|
||||
### 性能指标类
|
||||
|
||||
`PerformanceMetrics` 类提供以下统计方法:
|
||||
|
||||
- `getAverage(name)`: 获取平均值
|
||||
- `getP95(name)`: 获取95百分位值
|
||||
- `getP99(name)`: 获取99百分位值
|
||||
- `getMax(name)`: 获取最大值
|
||||
- `getMin(name)`: 获取最小值
|
||||
|
||||
### 性能测试辅助类
|
||||
|
||||
`PerformanceTestHelper` 类提供以下辅助方法:
|
||||
|
||||
- `measurePageLoad(page, url)`: 测量页面加载时间
|
||||
- `measureApiCall(page, apiCall)`: 测量API调用时间
|
||||
- `measureElementInteraction(page, selector, action)`: 测量元素交互时间
|
||||
- `measurePageNavigation(page, fromUrl, toUrl)`: 测量页面导航时间
|
||||
- `measureMemoryUsage(page)`: 测量内存使用情况
|
||||
- `measureNetworkRequests(page)`: 测量网络请求数量
|
||||
|
||||
## 性能测试用例
|
||||
|
||||
### PT-001: 页面加载性能
|
||||
|
||||
| 用例ID | 用例名称 | 性能目标 |
|
||||
|---------|---------|---------|
|
||||
| PT-001 | 登录页面加载性能 | < 3000ms |
|
||||
| PT-002 | 仪表盘页面加载性能 | < 2000ms |
|
||||
| PT-003 | 用户管理页面加载性能 | < 2000ms |
|
||||
| PT-004 | 黄历页面加载性能 | < 2000ms |
|
||||
| PT-005 | 运势页面加载性能 | < 2000ms |
|
||||
|
||||
### PT-006: API响应性能
|
||||
|
||||
| 用例ID | 用例名称 | 性能目标 |
|
||||
|---------|---------|---------|
|
||||
| PT-006 | 用户登录API性能 | < 2000ms |
|
||||
| PT-007 | 获取用户列表API性能 | < 1500ms |
|
||||
| PT-008 | 创建用户API性能 | < 2000ms |
|
||||
| PT-009 | 黄历查询API性能 | < 1500ms |
|
||||
| PT-010 | 运势查询API性能 | < 2000ms |
|
||||
| PT-011 | 紫微斗数生成API性能 | < 3000ms |
|
||||
|
||||
### PT-012: 并发和负载测试
|
||||
|
||||
| 用例ID | 用例名称 | 性能目标 |
|
||||
|---------|---------|---------|
|
||||
| PT-012 | 并发登录测试 | 平均响应时间 < 5000ms |
|
||||
| PT-013 | 并发黄历查询测试 | 平均响应时间 < 3000ms |
|
||||
| PT-014 | 页面切换性能测试 | 页面切换 < 1000ms |
|
||||
| PT-015 | 表单提交性能测试 | 表单提交 < 1000ms |
|
||||
| PT-016 | 内存使用监控 | 内存增长 < 50MB |
|
||||
| PT-017 | 网络请求统计 | 平均请求次数 < 20 |
|
||||
|
||||
## 性能基准
|
||||
|
||||
### 页面加载性能基准
|
||||
|
||||
- **优秀**: < 1000ms
|
||||
- **良好**: 1000ms - 2000ms
|
||||
- **可接受**: 2000ms - 3000ms
|
||||
- **需要优化**: > 3000ms
|
||||
|
||||
### API响应性能基准
|
||||
|
||||
- **优秀**: < 500ms
|
||||
- **良好**: 500ms - 1000ms
|
||||
- **可接受**: 1000ms - 2000ms
|
||||
- **需要优化**: > 2000ms
|
||||
|
||||
### 并发性能基准
|
||||
|
||||
- **优秀**: 平均响应时间 < 1000ms
|
||||
- **良好**: 平均响应时间 1000ms - 3000ms
|
||||
- **可接受**: 平均响应时间 3000ms - 5000ms
|
||||
- **需要优化**: 平均响应时间 > 5000ms
|
||||
|
||||
## 性能报告
|
||||
|
||||
性能测试执行后会生成以下报告:
|
||||
|
||||
1. **控制台输出**: 实时显示性能指标
|
||||
2. **HTML报告**: `playwright-report/index.html`
|
||||
3. **JSON报告**: `test-results/results.json`
|
||||
|
||||
### 性能报告示例
|
||||
|
||||
```
|
||||
=== 性能测试报告 ===
|
||||
|
||||
登录页面加载时间:
|
||||
平均值: 1250.50ms
|
||||
P95: 1450.00ms
|
||||
P99: 1520.00ms
|
||||
最大值: 1600.00ms
|
||||
最小值: 1100.00ms
|
||||
样本数: 10
|
||||
|
||||
仪表盘页面加载时间:
|
||||
平均值: 890.30ms
|
||||
P95: 980.00ms
|
||||
P99: 1020.00ms
|
||||
最大值: 1100.00ms
|
||||
最小值: 750.00ms
|
||||
样本数: 10
|
||||
|
||||
====================
|
||||
```
|
||||
|
||||
## 性能优化建议
|
||||
|
||||
### 页面加载优化
|
||||
|
||||
1. **代码分割**: 使用动态导入减少初始加载时间
|
||||
2. **资源压缩**: 启用Gzip/Brotli压缩
|
||||
3. **CDN加速**: 使用CDN分发静态资源
|
||||
4. **缓存策略**: 实现合理的缓存策略
|
||||
5. **图片优化**: 使用WebP格式和懒加载
|
||||
|
||||
### API响应优化
|
||||
|
||||
1. **数据库优化**: 添加索引、优化查询
|
||||
2. **缓存机制**: 使用Redis缓存热点数据
|
||||
3. **异步处理**: 使用消息队列处理耗时操作
|
||||
4. **连接池**: 优化数据库连接池配置
|
||||
5. **分页查询**: 避免返回大量数据
|
||||
|
||||
### 并发处理优化
|
||||
|
||||
1. **负载均衡**: 使用负载均衡器分发请求
|
||||
2. **限流措施**: 实现API限流保护
|
||||
3. **连接复用**: 使用HTTP/2和连接复用
|
||||
4. **资源隔离**: 隔离不同服务的资源
|
||||
5. **自动扩容**: 实现自动扩容机制
|
||||
|
||||
## 性能监控
|
||||
|
||||
### 持续监控
|
||||
|
||||
建议在生产环境中实施以下监控:
|
||||
|
||||
1. **APM工具**: 使用New Relic、Datadog等APM工具
|
||||
2. **日志分析**: 使用ELK Stack分析日志
|
||||
3. **指标收集**: 使用Prometheus收集指标
|
||||
4. **告警机制**: 设置性能告警阈值
|
||||
5. **定期报告**: 生成定期性能报告
|
||||
|
||||
### 性能指标
|
||||
|
||||
建议监控以下关键指标:
|
||||
|
||||
1. **响应时间**: P50、P95、P99响应时间
|
||||
2. **吞吐量**: 每秒请求数(RPS)
|
||||
3. **错误率**: HTTP错误率
|
||||
4. **资源使用**: CPU、内存、磁盘使用率
|
||||
5. **网络流量**: 入站和出站流量
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 常见性能问题
|
||||
|
||||
1. **页面加载缓慢**
|
||||
- 检查网络带宽
|
||||
- 检查服务器资源使用
|
||||
- 检查资源加载顺序
|
||||
- 使用浏览器开发者工具分析
|
||||
|
||||
2. **API响应缓慢**
|
||||
- 检查数据库查询性能
|
||||
- 检查缓存命中率
|
||||
- 检查网络延迟
|
||||
- 检查并发连接数
|
||||
|
||||
3. **内存泄漏**
|
||||
- 检查内存使用趋势
|
||||
- 检查对象引用
|
||||
- 使用内存分析工具
|
||||
- 优化数据结构
|
||||
|
||||
4. **并发性能差**
|
||||
- 检查线程池配置
|
||||
- 检查锁竞争
|
||||
- 检查资源争用
|
||||
- 优化并发算法
|
||||
|
||||
## 性能测试最佳实践
|
||||
|
||||
1. **测试环境**: 使用与生产环境相似的测试环境
|
||||
2. **测试数据**: 使用真实大小的测试数据
|
||||
3. **多次运行**: 每个测试运行多次取平均值
|
||||
4. **基线对比**: 与历史基线对比性能变化
|
||||
5. **持续监控**: 建立持续性能监控机制
|
||||
|
||||
## 联系方式
|
||||
|
||||
如有问题,请联系测试团队或查看项目文档。
|
||||
@@ -0,0 +1,110 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { LoginPage } from '../pages/login-page';
|
||||
import { DashboardPage } from '../pages/dashboard-page';
|
||||
import { UserManagementPage } from '../pages/user-management-page';
|
||||
import { testConfig } from '../core/test-config';
|
||||
import { PerformanceMetrics, PerformanceTestHelper } from '../helpers/performance-helper';
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'serial',
|
||||
timeout: 120000
|
||||
});
|
||||
|
||||
test.describe('性能测试 - API响应性能', () => {
|
||||
const metrics = new PerformanceMetrics();
|
||||
const helper = new PerformanceTestHelper();
|
||||
|
||||
test.afterAll(() => {
|
||||
metrics.printReport();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
});
|
||||
|
||||
test('PT-006: 用户登录API性能', async ({ page }) => {
|
||||
await helper.clearCacheAndCookies(page);
|
||||
|
||||
const responseTime = await helper.measureApiCall(page, async () => {
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.login('admin', 'admin123');
|
||||
});
|
||||
|
||||
metrics.recordMetric('用户登录API响应时间', responseTime);
|
||||
expect(responseTime).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
test('PT-007: 获取用户列表API性能', async ({ page }) => {
|
||||
const responseTime = await helper.measureApiCall(page, async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/users`);
|
||||
await page.waitForSelector('[data-testid="user-table"]');
|
||||
});
|
||||
|
||||
metrics.recordMetric('获取用户列表API响应时间', responseTime);
|
||||
expect(responseTime).toBeLessThan(1500);
|
||||
});
|
||||
|
||||
test('PT-008: 创建用户API性能', async ({ page }) => {
|
||||
const userManagementPage = new UserManagementPage(page);
|
||||
const testUsername = `perfuser_${Date.now()}`;
|
||||
|
||||
const responseTime = await helper.measureApiCall(page, async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/users`);
|
||||
await userManagementPage.clickAddUser();
|
||||
await userManagementPage.fillUserForm({
|
||||
username: testUsername,
|
||||
email: `perfuser_${Date.now()}@example.com`,
|
||||
password: 'Test@123456',
|
||||
confirmPassword: 'Test@123456',
|
||||
role: 'USER',
|
||||
status: 'ACTIVE'
|
||||
});
|
||||
await userManagementPage.submitUserForm();
|
||||
await page.waitForSelector('.ant-message-success');
|
||||
});
|
||||
|
||||
metrics.recordMetric('创建用户API响应时间', responseTime);
|
||||
expect(responseTime).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
test('PT-009: 黄历查询API性能', async ({ page }) => {
|
||||
const responseTime = await helper.measureApiCall(page, async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/almanac`);
|
||||
await page.fill('[data-testid="date-picker"]', '2024-01-01');
|
||||
await page.click('[data-testid="query-button"]');
|
||||
await page.waitForSelector('[data-testid="almanac-result"]');
|
||||
});
|
||||
|
||||
metrics.recordMetric('黄历查询API响应时间', responseTime);
|
||||
expect(responseTime).toBeLessThan(1500);
|
||||
});
|
||||
|
||||
test('PT-010: 运势查询API性能', async ({ page }) => {
|
||||
const responseTime = await helper.measureApiCall(page, async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/fortune`);
|
||||
await page.fill('[data-testid="fortune-date"]', '2024-01-15');
|
||||
await page.click('[data-testid="query-fortune-button"]');
|
||||
await page.waitForSelector('[data-testid="daily-fortune"]');
|
||||
});
|
||||
|
||||
metrics.recordMetric('运势查询API响应时间', responseTime);
|
||||
expect(responseTime).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
test('PT-011: 紫微斗数生成API性能', async ({ page }) => {
|
||||
const responseTime = await helper.measureApiCall(page, async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/ziwei`);
|
||||
await page.fill('[data-testid="birth-date"]', '1990-05-15');
|
||||
await page.fill('[data-testid="birth-time"]', '08:30');
|
||||
await page.click('[data-testid="gender-male"]');
|
||||
await page.click('[data-testid="generate-chart-button"]');
|
||||
await page.waitForSelector('[data-testid="ziwei-chart"]');
|
||||
});
|
||||
|
||||
metrics.recordMetric('紫微斗数生成API响应时间', responseTime);
|
||||
expect(responseTime).toBeLessThan(3000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { LoginPage } from '../pages/login-page';
|
||||
import { testConfig } from '../core/test-config';
|
||||
import { PerformanceMetrics, PerformanceTestHelper } from '../helpers/performance-helper';
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'serial',
|
||||
timeout: 180000
|
||||
});
|
||||
|
||||
test.describe('性能测试 - 并发和负载测试', () => {
|
||||
const metrics = new PerformanceMetrics();
|
||||
const helper = new PerformanceTestHelper();
|
||||
|
||||
test.afterAll(() => {
|
||||
metrics.printReport();
|
||||
});
|
||||
|
||||
test('PT-012: 并发登录测试', async ({ browser }) => {
|
||||
const concurrency = 5;
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
for (let i = 0; i < concurrency; i++) {
|
||||
promises.push(
|
||||
(async () => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.login(`user${i}`, `password${i}`);
|
||||
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
metrics.recordMetric(`并发登录用户${i}响应时间`, duration);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
const averageTime = metrics.getAverage('并发登录用户0响应时间');
|
||||
expect(averageTime).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
test('PT-013: 并发黄历查询测试', async ({ browser }) => {
|
||||
const concurrency = 10;
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
for (let i = 0; i < concurrency; i++) {
|
||||
promises.push(
|
||||
(async () => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.login('admin', 'admin123');
|
||||
|
||||
await page.goto(`${testConfig.getBaseURL()}/almanac`);
|
||||
await page.fill('[data-testid="date-picker"]', `2024-01-${(i % 30) + 1}`);
|
||||
await page.click('[data-testid="query-button"]');
|
||||
await page.waitForSelector('[data-testid="almanac-result"]');
|
||||
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
metrics.recordMetric(`并发黄历查询${i}响应时间`, duration);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
const averageTime = metrics.getAverage('并发黄历查询0响应时间');
|
||||
expect(averageTime).toBeLessThan(3000);
|
||||
});
|
||||
|
||||
test('PT-014: 页面切换性能测试', async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
|
||||
const pages = [
|
||||
'/dashboard',
|
||||
'/users',
|
||||
'/almanac',
|
||||
'/fortune',
|
||||
'/ziwei'
|
||||
];
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
for (const pagePath of pages) {
|
||||
const startTime = Date.now();
|
||||
await page.goto(`${testConfig.getBaseURL()}${pagePath}`, { waitUntil: 'networkidle' });
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
metrics.recordMetric(`页面切换-${pagePath}`, duration);
|
||||
}
|
||||
}
|
||||
|
||||
const averageDashboardTime = metrics.getAverage('页面切换-/dashboard');
|
||||
const averageUsersTime = metrics.getAverage('页面切换-/users');
|
||||
const averageAlmanacTime = metrics.getAverage('页面切换-/almanac');
|
||||
|
||||
expect(averageDashboardTime).toBeLessThan(1000);
|
||||
expect(averageUsersTime).toBeLessThan(1000);
|
||||
expect(averageAlmanacTime).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
test('PT-015: 表单提交性能测试', async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
|
||||
await page.goto(`${testConfig.getBaseURL()}/almanac`);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const startTime = Date.now();
|
||||
await page.fill('[data-testid="date-picker"]', `2024-01-${(i % 30) + 1}`);
|
||||
await page.click('[data-testid="query-button"]');
|
||||
await page.waitForSelector('[data-testid="almanac-result"]');
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
metrics.recordMetric(`表单提交-${i}`, duration);
|
||||
}
|
||||
|
||||
const averageTime = metrics.getAverage('表单提交-0');
|
||||
expect(averageTime).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
test('PT-016: 内存使用监控', async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
|
||||
const memoryUsages: number[] = [];
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const metrics = await helper.measureMemoryUsage(page);
|
||||
memoryUsages.push(metrics.used);
|
||||
|
||||
await page.goto(`${testConfig.getBaseURL()}/almanac`);
|
||||
await page.fill('[data-testid="date-picker"]', `2024-01-${(i % 30) + 1}`);
|
||||
await page.click('[data-testid="query-button"]');
|
||||
await page.waitForSelector('[data-testid="almanac-result"]');
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
const maxMemory = Math.max(...memoryUsages);
|
||||
const minMemory = Math.min(...memoryUsages);
|
||||
const memoryGrowth = maxMemory - minMemory;
|
||||
|
||||
console.log(`\n内存使用统计:`);
|
||||
console.log(` 最小值: ${(minMemory / 1024 / 1024).toFixed(2)} MB`);
|
||||
console.log(` 最大值: ${(maxMemory / 1024 / 1024).toFixed(2)} MB`);
|
||||
console.log(` 增长: ${(memoryGrowth / 1024 / 1024).toFixed(2)} MB`);
|
||||
|
||||
expect(memoryGrowth).toBeLessThan(50 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test('PT-017: 网络请求统计', async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
|
||||
const requestCounts: number[] = [];
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
let requestCount = 0;
|
||||
|
||||
page.on('request', () => {
|
||||
requestCount++;
|
||||
});
|
||||
|
||||
await page.goto(`${testConfig.getBaseURL()}/almanac`);
|
||||
await page.fill('[data-testid="date-picker"]', `2024-01-${(i % 30) + 1}`);
|
||||
await page.click('[data-testid="query-button"]');
|
||||
await page.waitForSelector('[data-testid="almanac-result"]');
|
||||
|
||||
requestCounts.push(requestCount);
|
||||
metrics.recordMetric(`页面请求次数-${i}`, requestCount);
|
||||
}
|
||||
|
||||
const averageRequests = metrics.getAverage('页面请求次数-0');
|
||||
console.log(`\n网络请求统计:`);
|
||||
console.log(` 平均请求次数: ${averageRequests.toFixed(0)}`);
|
||||
|
||||
expect(averageRequests).toBeLessThan(20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { LoginPage } from '../pages/login-page';
|
||||
import { testConfig } from '../core/test-config';
|
||||
|
||||
export class PerformanceMetrics {
|
||||
private metrics: Map<string, number[]> = new Map();
|
||||
|
||||
recordMetric(name: string, value: number) {
|
||||
if (!this.metrics.has(name)) {
|
||||
this.metrics.set(name, []);
|
||||
}
|
||||
this.metrics.get(name)!.push(value);
|
||||
}
|
||||
|
||||
getAverage(name: string): number {
|
||||
const values = this.metrics.get(name) || [];
|
||||
if (values.length === 0) return 0;
|
||||
const sum = values.reduce((a, b) => a + b, 0);
|
||||
return sum / values.length;
|
||||
}
|
||||
|
||||
getP95(name: string): number {
|
||||
const values = this.metrics.get(name) || [];
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const index = Math.floor(sorted.length * 0.95);
|
||||
return sorted[index];
|
||||
}
|
||||
|
||||
getP99(name: string): number {
|
||||
const values = this.metrics.get(name) || [];
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const index = Math.floor(sorted.length * 0.99);
|
||||
return sorted[index];
|
||||
}
|
||||
|
||||
getMax(name: string): number {
|
||||
const values = this.metrics.get(name) || [];
|
||||
if (values.length === 0) return 0;
|
||||
return Math.max(...values);
|
||||
}
|
||||
|
||||
getMin(name: string): number {
|
||||
const values = this.metrics.get(name) || [];
|
||||
if (values.length === 0) return 0;
|
||||
return Math.min(...values);
|
||||
}
|
||||
|
||||
printReport() {
|
||||
console.log('\n=== 性能测试报告 ===');
|
||||
for (const [name, values] of this.metrics.entries()) {
|
||||
console.log(`\n${name}:`);
|
||||
console.log(` 平均值: ${this.getAverage(name).toFixed(2)}ms`);
|
||||
console.log(` P95: ${this.getP95(name).toFixed(2)}ms`);
|
||||
console.log(` P99: ${this.getP99(name).toFixed(2)}ms`);
|
||||
console.log(` 最大值: ${this.getMax(name).toFixed(2)}ms`);
|
||||
console.log(` 最小值: ${this.getMin(name).toFixed(2)}ms`);
|
||||
console.log(` 样本数: ${values.length}`);
|
||||
}
|
||||
console.log('\n====================\n');
|
||||
}
|
||||
}
|
||||
|
||||
export class PerformanceTestHelper {
|
||||
static async measurePageLoad(page: Page, url: string): Promise<number> {
|
||||
const startTime = Date.now();
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
const endTime = Date.now();
|
||||
return endTime - startTime;
|
||||
}
|
||||
|
||||
static async measureApiCall(page: Page, apiCall: () => Promise<void>): Promise<number> {
|
||||
const startTime = Date.now();
|
||||
await apiCall();
|
||||
const endTime = Date.now();
|
||||
return endTime - startTime;
|
||||
}
|
||||
|
||||
static async measureElementInteraction(
|
||||
page: Page,
|
||||
selector: string,
|
||||
action: () => Promise<void>
|
||||
): Promise<number> {
|
||||
await page.waitForSelector(selector, { state: 'visible' });
|
||||
const startTime = Date.now();
|
||||
await action();
|
||||
const endTime = Date.now();
|
||||
return endTime - startTime;
|
||||
}
|
||||
|
||||
static async measurePageNavigation(
|
||||
page: Page,
|
||||
fromUrl: string,
|
||||
toUrl: string
|
||||
): Promise<number> {
|
||||
await page.goto(fromUrl, { waitUntil: 'networkidle' });
|
||||
const startTime = Date.now();
|
||||
await page.goto(toUrl, { waitUntil: 'networkidle' });
|
||||
const endTime = Date.now();
|
||||
return endTime - startTime;
|
||||
}
|
||||
|
||||
static async measureMemoryUsage(page: Page): Promise<{ used: number; total: number }> {
|
||||
const metrics = await page.evaluate(() => {
|
||||
if (performance && (performance as any).memory) {
|
||||
return {
|
||||
used: (performance as any).memory.usedJSHeapSize,
|
||||
total: (performance as any).memory.totalJSHeapSize
|
||||
};
|
||||
}
|
||||
return { used: 0, total: 0 };
|
||||
});
|
||||
return metrics;
|
||||
}
|
||||
|
||||
static async measureNetworkRequests(page: Page): Promise<number> {
|
||||
let requestCount = 0;
|
||||
|
||||
page.on('request', () => {
|
||||
requestCount++;
|
||||
});
|
||||
|
||||
return requestCount;
|
||||
}
|
||||
|
||||
static async clearCacheAndCookies(page: Page) {
|
||||
await page.context().clearCookies();
|
||||
await page.context().clearPermissions();
|
||||
}
|
||||
}
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'serial',
|
||||
timeout: 120000
|
||||
});
|
||||
|
||||
test.describe('性能测试 - 页面加载性能', () => {
|
||||
const metrics = new PerformanceMetrics();
|
||||
const helper = new PerformanceTestHelper();
|
||||
|
||||
test.afterAll(() => {
|
||||
metrics.printReport();
|
||||
});
|
||||
|
||||
test('PT-001: 登录页面加载性能', async ({ page }) => {
|
||||
const loadTime = await helper.measurePageLoad(page, testConfig.getBaseURL());
|
||||
metrics.recordMetric('登录页面加载时间', loadTime);
|
||||
|
||||
expect(loadTime).toBeLessThan(3000);
|
||||
});
|
||||
|
||||
test('PT-002: 仪表盘页面加载性能', async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
|
||||
const loadTime = await helper.measurePageLoad(page, `${testConfig.getBaseURL()}/dashboard`);
|
||||
metrics.recordMetric('仪表盘页面加载时间', loadTime);
|
||||
|
||||
expect(loadTime).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
test('PT-003: 用户管理页面加载性能', async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
|
||||
const loadTime = await helper.measurePageLoad(page, `${testConfig.getBaseURL()}/users`);
|
||||
metrics.recordMetric('用户管理页面加载时间', loadTime);
|
||||
|
||||
expect(loadTime).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
test('PT-004: 黄历页面加载性能', async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
|
||||
const loadTime = await helper.measurePageLoad(page, `${testConfig.getBaseURL()}/almanac`);
|
||||
metrics.recordMetric('黄历页面加载时间', loadTime);
|
||||
|
||||
expect(loadTime).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
test('PT-005: 运势页面加载性能', async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
|
||||
const loadTime = await helper.measurePageLoad(page, `${testConfig.getBaseURL()}/fortune`);
|
||||
metrics.recordMetric('运势页面加载时间', loadTime);
|
||||
|
||||
expect(loadTime).toBeLessThan(2000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { test, expect } from './test-fixtures';
|
||||
|
||||
test.describe('角色管理 - 完全Mock模式', () => {
|
||||
test.beforeEach(async ({ page, mockManager }) => {
|
||||
mockManager.enableMock();
|
||||
mockManager.configureMock({
|
||||
mode: 'full',
|
||||
delay: 100
|
||||
});
|
||||
|
||||
mockManager.presetTestData({
|
||||
roles: [
|
||||
{ id: 1, name: '超级管理员', roleKey: 'super_admin', description: '拥有所有权限', status: 'ENABLED', sortOrder: 1, remark: '系统默认角色', menuIds: [1, 2, 3], createdAt: '2024-01-01T10:00:00.000Z', updatedAt: '2024-01-01T10:00:00.000Z' },
|
||||
{ id: 2, name: '管理员', roleKey: 'admin', description: '拥有大部分权限', status: 'ENABLED', sortOrder: 2, remark: '系统管理员', menuIds: [1, 2], createdAt: '2024-01-02T10:00:00.000Z', updatedAt: '2024-01-02T10:00:00.000Z' },
|
||||
{ id: 3, name: '普通用户', roleKey: 'user', description: '拥有基本权限', status: 'ENABLED', sortOrder: 3, remark: '普通用户角色', menuIds: [1], createdAt: '2024-01-03T10:00:00.000Z', updatedAt: '2024-01-03T10:00:00.000Z' }
|
||||
]
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.fill('input[placeholder="请输入用户名"]', 'admin');
|
||||
await page.fill('input[placeholder="请输入密码"]', 'admin123');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ mockManager }) => {
|
||||
mockManager.clearPresets();
|
||||
mockManager.disableMock();
|
||||
});
|
||||
|
||||
test('应该显示角色列表', async ({ page }) => {
|
||||
await page.goto('/roles');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(page.locator('.ant-table')).toBeVisible();
|
||||
await expect(page.locator('text=超级管理员')).toBeVisible();
|
||||
await expect(page.locator('text=管理员')).toBeVisible();
|
||||
await expect(page.locator('text=普通用户')).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够创建新角色', async ({ page }) => {
|
||||
await page.goto('/roles');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.click('button:has-text("新增角色")');
|
||||
await page.fill('input[placeholder="请输入角色名称"]', '测试角色');
|
||||
await page.fill('input[placeholder="请输入角色标识"]', 'test_role');
|
||||
await page.fill('textarea[placeholder="请输入角色描述"]', '这是一个测试角色');
|
||||
await page.click('button:has-text("确定")');
|
||||
|
||||
await expect(page.locator('text=创建成功')).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够编辑角色', async ({ page }) => {
|
||||
await page.goto('/roles');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.click('button:has-text("编辑"):first');
|
||||
await page.fill('input[placeholder="请输入角色名称"]', '更新后的角色名称');
|
||||
await page.click('button:has-text("确定")');
|
||||
|
||||
await expect(page.locator('text=更新成功')).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够删除角色', async ({ page }) => {
|
||||
await page.goto('/roles');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
page.on('dialog', dialog => dialog.accept());
|
||||
await page.click('button:has-text("删除"):first');
|
||||
|
||||
await expect(page.locator('text=删除成功')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { MockManager } from './mock-manager';
|
||||
|
||||
test.describe('角色管理', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const mockManager = new MockManager({
|
||||
enabled: true,
|
||||
mode: 'full',
|
||||
mockPaths: [],
|
||||
delay: 0,
|
||||
logCalls: true,
|
||||
validateResponses: true,
|
||||
dataSource: 'memory'
|
||||
});
|
||||
|
||||
mockManager.presetTestData({
|
||||
menus: [
|
||||
{
|
||||
id: 1,
|
||||
name: '仪表盘',
|
||||
code: 'dashboard',
|
||||
path: '/dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
sortOrder: 1,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
component: 'views/Dashboard.vue',
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '角色管理',
|
||||
code: 'role',
|
||||
path: '/roles',
|
||||
icon: 'LockOutlined',
|
||||
sortOrder: 3,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
component: 'views/RoleManagement.vue',
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
}
|
||||
],
|
||||
roles: [
|
||||
{
|
||||
id: 1,
|
||||
name: '超级管理员',
|
||||
code: 'super_admin',
|
||||
status: 'active',
|
||||
permissions: ['*'],
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '普通用户',
|
||||
code: 'user',
|
||||
status: 'active',
|
||||
permissions: ['user:view', 'user:create'],
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await mockManager.interceptAPIRequest(page);
|
||||
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await passwordInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await loginButton.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('admin123');
|
||||
await loginButton.click();
|
||||
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test('应该显示角色列表页面', async ({ page }) => {
|
||||
await page.goto('/roles');
|
||||
await expect(page.getByText(/角色管理/i)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /添加角色/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /刷新/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该显示角色数据表格', async ({ page }) => {
|
||||
await page.goto('/roles');
|
||||
|
||||
const table = page.locator('.ant-table');
|
||||
await expect(table).toBeVisible();
|
||||
|
||||
await expect(page.getByText(/角色名称/i)).toBeVisible();
|
||||
await expect(page.getByText(/角色编码/i)).toBeVisible();
|
||||
await expect(page.getByText(/状态/i)).toBeVisible();
|
||||
await expect(page.getByText(/创建时间/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够创建新角色', async ({ page }) => {
|
||||
await page.goto('/roles');
|
||||
|
||||
await page.getByRole('button', { name: /添加角色/i }).click();
|
||||
await expect(page).toHaveURL(/.*roles\/create/);
|
||||
|
||||
await page.getByPlaceholder(/请输入角色名称/i).fill('测试角色');
|
||||
await page.getByPlaceholder(/请输入角色编码/i).fill('TEST_ROLE');
|
||||
await page.getByPlaceholder(/请输入角色描述/i).fill('这是一个测试角色');
|
||||
|
||||
await page.getByRole('button', { name: /提交/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/.*roles/);
|
||||
await expect(page.getByText(/创建成功/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('创建角色时应该验证必填字段', async ({ page }) => {
|
||||
await page.goto('/roles');
|
||||
await page.getByRole('button', { name: /添加角色/i }).click();
|
||||
|
||||
await page.getByRole('button', { name: /提交/i }).click();
|
||||
|
||||
await expect(page.getByText(/请输入角色名称/i)).toBeVisible();
|
||||
await expect(page.getByText(/请输入角色编码/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够编辑角色', async ({ page }) => {
|
||||
await page.goto('/roles');
|
||||
|
||||
const editButton = page.getByRole('button').filter({ hasText: /编辑/i }).first();
|
||||
if (await editButton.isVisible()) {
|
||||
await editButton.click();
|
||||
await expect(page).toHaveURL(/.*roles\/\d+\/edit/);
|
||||
|
||||
const roleNameInput = page.getByPlaceholder(/请输入角色名称/i);
|
||||
await roleNameInput.clear();
|
||||
await roleNameInput.fill('更新后的角色名称');
|
||||
|
||||
await page.getByRole('button', { name: /提交/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/.*roles/);
|
||||
await expect(page.getByText(/更新成功/i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('应该能够删除角色', async ({ page }) => {
|
||||
await page.goto('/roles');
|
||||
|
||||
const deleteButton = page.getByRole('button').filter({ hasText: /删除/i }).first();
|
||||
if (await deleteButton.isVisible()) {
|
||||
await deleteButton.click();
|
||||
|
||||
await expect(page.getByText(/确认删除/i)).toBeVisible();
|
||||
await page.getByRole('button', { name: /确认/i }).click();
|
||||
|
||||
await expect(page.getByText(/删除成功/i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('应该能够查看角色详情', async ({ page }) => {
|
||||
await page.goto('/roles');
|
||||
|
||||
const detailButton = page.getByRole('button').filter({ hasText: /详情/i }).first();
|
||||
if (await detailButton.isVisible()) {
|
||||
await detailButton.click();
|
||||
await expect(page).toHaveURL(/.*roles\/\d+\/detail/);
|
||||
await expect(page.getByText(/角色详情/i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('应该能够刷新角色列表', async ({ page }) => {
|
||||
await page.goto('/roles');
|
||||
|
||||
await page.getByRole('button', { name: /刷新/i }).click();
|
||||
|
||||
await expect(page.getByText(/刷新成功/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该支持状态切换', async ({ page }) => {
|
||||
await page.goto('/roles/create');
|
||||
|
||||
const statusSelect = page.locator('.ant-select').filter({ hasText: /状态/i });
|
||||
await statusSelect.click();
|
||||
|
||||
await expect(page.getByText(/启用/i)).toBeVisible();
|
||||
await expect(page.getByText(/禁用/i)).toBeVisible();
|
||||
|
||||
await page.getByText(/禁用/i).click();
|
||||
await expect(statusSelect).toContainText(/禁用/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { test as base, Page, BrowserContext } from '@playwright/test';
|
||||
import { testConfig, TestEnvironment } from './core/test-config';
|
||||
import { testDataGenerator, UserData, RoleData, MenuData, PermissionData } from './core/test-data';
|
||||
import { testLogger } from './core/test-logger';
|
||||
import { testReporter } from './core/test-reporter';
|
||||
import { BasePage } from './pages/base-page';
|
||||
import { LoginPage } from './pages/login-page';
|
||||
import { DashboardPage } from './pages/dashboard-page';
|
||||
import { UserManagementPage } from './pages/user-management-page';
|
||||
import { RoleManagementPage } from './pages/role-management-page';
|
||||
import { MenuManagementPage } from './pages/menu-management-page';
|
||||
import { ScreenshotHelper } from './helpers/screenshot-helper';
|
||||
import { FormHelper } from './helpers/form-helper';
|
||||
import { TableHelper } from './helpers/table-helper';
|
||||
import { MockManager, MockConfig } from './mock-manager';
|
||||
|
||||
export interface TestFixtures {
|
||||
page: Page;
|
||||
context: BrowserContext;
|
||||
testConfig: TestEnvironment;
|
||||
testDataGenerator: typeof testDataGenerator;
|
||||
testLogger: typeof testLogger;
|
||||
testReporter: typeof testReporter;
|
||||
|
||||
pageObjects: {
|
||||
basePage: BasePage;
|
||||
loginPage: LoginPage;
|
||||
dashboardPage: DashboardPage;
|
||||
userManagementPage: UserManagementPage;
|
||||
roleManagementPage: RoleManagementPage;
|
||||
menuManagementPage: MenuManagementPage;
|
||||
};
|
||||
|
||||
helpers: {
|
||||
screenshot: ScreenshotHelper;
|
||||
form: FormHelper;
|
||||
table: TableHelper;
|
||||
};
|
||||
|
||||
mockManager: MockManager;
|
||||
|
||||
testData: {
|
||||
user: UserData;
|
||||
admin: UserData;
|
||||
role: RoleData;
|
||||
menu: MenuData;
|
||||
permission: PermissionData;
|
||||
};
|
||||
}
|
||||
|
||||
export const test = base.extend<TestFixtures>({
|
||||
page: async ({ page }, use) => {
|
||||
await use(page);
|
||||
},
|
||||
|
||||
context: async ({ context }, use) => {
|
||||
await use(context);
|
||||
},
|
||||
|
||||
testConfig: async ({}, use) => {
|
||||
const config = testConfig.getEnvironment();
|
||||
await use(config);
|
||||
},
|
||||
|
||||
testDataGenerator: async ({}, use) => {
|
||||
await use(testDataGenerator);
|
||||
},
|
||||
|
||||
testLogger: async ({}, use) => {
|
||||
await use(testLogger);
|
||||
},
|
||||
|
||||
testReporter: async ({}, use) => {
|
||||
await use(testReporter);
|
||||
},
|
||||
|
||||
pageObjects: async ({ page }, use) => {
|
||||
const pageObjects = {
|
||||
basePage: new BasePage(page),
|
||||
loginPage: new LoginPage(page),
|
||||
dashboardPage: new DashboardPage(page),
|
||||
userManagementPage: new UserManagementPage(page),
|
||||
roleManagementPage: new RoleManagementPage(page),
|
||||
menuManagementPage: new MenuManagementPage(page)
|
||||
};
|
||||
|
||||
await use(pageObjects);
|
||||
},
|
||||
|
||||
helpers: async ({ page }, use) => {
|
||||
const helpers = {
|
||||
screenshot: new ScreenshotHelper(page),
|
||||
form: new FormHelper(page),
|
||||
table: new TableHelper(page)
|
||||
};
|
||||
|
||||
await use(helpers);
|
||||
},
|
||||
|
||||
mockManager: async ({ page }, use) => {
|
||||
const mockConfig: MockConfig = {
|
||||
enabled: process.env.E2E_MOCK_ENABLED === 'true',
|
||||
mode: (process.env.E2E_MOCK_MODE as 'full' | 'partial' | 'none') || 'none',
|
||||
mockPaths: [],
|
||||
delay: 0,
|
||||
logCalls: true,
|
||||
validateResponses: true,
|
||||
dataSource: 'memory'
|
||||
};
|
||||
|
||||
const mockManager = new MockManager(mockConfig);
|
||||
|
||||
await mockManager.interceptAPIRequest(page);
|
||||
|
||||
await use(mockManager);
|
||||
},
|
||||
|
||||
testData: async ({}, use) => {
|
||||
const testData = {
|
||||
user: testDataGenerator.generateUserData({
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
status: 'active'
|
||||
}),
|
||||
|
||||
admin: testDataGenerator.generateUserData({
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
status: 'active',
|
||||
roleIds: [1]
|
||||
}),
|
||||
|
||||
role: testDataGenerator.generateRoleData({
|
||||
roleName: '测试角色',
|
||||
roleCode: 'test_role',
|
||||
status: 1
|
||||
}),
|
||||
|
||||
menu: testDataGenerator.generateMenuData({
|
||||
menuName: '测试菜单',
|
||||
menuType: 1,
|
||||
path: '/test',
|
||||
status: 0
|
||||
}),
|
||||
|
||||
permission: testDataGenerator.generatePermissionData({
|
||||
permissionName: '测试权限',
|
||||
permissionCode: 'test:permission',
|
||||
permissionType: 'button'
|
||||
})
|
||||
};
|
||||
|
||||
await use(testData);
|
||||
}
|
||||
});
|
||||
|
||||
export const expect = test.expect;
|
||||
|
||||
export type PageObjects = TestFixtures['pageObjects'];
|
||||
export type Helpers = TestFixtures['helpers'];
|
||||
export type TestData = TestFixtures['testData'];
|
||||
@@ -0,0 +1,184 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { MockManager } from './mock-manager';
|
||||
|
||||
test.describe('Token刷新机制', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const mockManager = new MockManager({
|
||||
enabled: true,
|
||||
mode: 'full',
|
||||
mockPaths: [],
|
||||
delay: 0,
|
||||
logCalls: true,
|
||||
validateResponses: true,
|
||||
dataSource: 'memory'
|
||||
});
|
||||
|
||||
mockManager.presetTestData({
|
||||
menus: [
|
||||
{
|
||||
id: 1,
|
||||
name: '仪表盘',
|
||||
code: 'dashboard',
|
||||
path: '/dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
sortOrder: 1,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await mockManager.interceptAPIRequest(page);
|
||||
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('应该能够成功登录并获取token', async ({ page }) => {
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('admin123');
|
||||
await loginButton.click();
|
||||
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
|
||||
|
||||
const token = await page.evaluate(() => localStorage.getItem('access_token'));
|
||||
const refreshToken = await page.evaluate(() => localStorage.getItem('refreshToken'));
|
||||
|
||||
expect(token).toBeTruthy();
|
||||
expect(refreshToken).toBeTruthy();
|
||||
expect(token).toBe('mock-token-123456');
|
||||
expect(refreshToken).toBe('mock-refresh-token-789012');
|
||||
});
|
||||
|
||||
test('token过期时应该自动刷新', async ({ page }) => {
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('admin123');
|
||||
await loginButton.click();
|
||||
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('access_token', 'expired-token');
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const newToken = await page.evaluate(() => localStorage.getItem('access_token'));
|
||||
const newRefreshToken = await page.evaluate(() => localStorage.getItem('refreshToken'));
|
||||
|
||||
expect(newToken).toBeTruthy();
|
||||
expect(newRefreshToken).toBeTruthy();
|
||||
});
|
||||
|
||||
test('刷新token失败时应该跳转到登录页', async ({ page }) => {
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('admin123');
|
||||
await loginButton.click();
|
||||
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('access_token', 'expired-token');
|
||||
localStorage.setItem('refreshToken', 'invalid-refresh-token');
|
||||
});
|
||||
|
||||
await page.route('**/sys/auth/refresh', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: '401',
|
||||
message: 'Refresh token已过期',
|
||||
data: null
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const currentUrl = page.url();
|
||||
expect(currentUrl).toContain('/login');
|
||||
});
|
||||
|
||||
test('没有refresh token时应该跳转到登录页', async ({ page }) => {
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('admin123');
|
||||
await loginButton.click();
|
||||
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem('refreshToken');
|
||||
});
|
||||
|
||||
await page.route('**/sys/user', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: '401',
|
||||
message: 'Token已过期',
|
||||
data: null
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const currentUrl = page.url();
|
||||
expect(currentUrl).toContain('/login');
|
||||
});
|
||||
|
||||
test('token刷新成功后应该保持用户登录状态', async ({ page }) => {
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('admin123');
|
||||
await loginButton.click();
|
||||
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('access_token', 'expired-token');
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const welcomeMessage = page.locator('text=/欢迎/i');
|
||||
await expect(welcomeMessage).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const newToken = await page.evaluate(() => localStorage.getItem('access_token'));
|
||||
expect(newToken).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
# UAT 测试文档
|
||||
|
||||
## 概述
|
||||
|
||||
UAT (User Acceptance Testing) 测试是用户验收测试,用于验证系统是否满足业务需求和用户期望。UAT测试从最终用户的角度出发,模拟真实用户的使用场景。
|
||||
|
||||
## 测试框架
|
||||
|
||||
UAT测试基于 Playwright 框架,采用 BDD (Behavior-Driven Development) 风格,使用 Given-When-Then 结构描述测试步骤。
|
||||
|
||||
### 核心文件
|
||||
|
||||
- `uat-base.ts`: UAT测试基础框架,包含测试夹具、测试步骤和断言工具
|
||||
- `uat-001-auth.spec.ts`: 用户认证相关测试
|
||||
- `uat-002-user-management.spec.ts`: 用户管理功能测试
|
||||
- `uat-003-almanac.spec.ts`: 黄历查询功能测试
|
||||
- `uat-004-fortune.spec.ts`: 运势分析功能测试
|
||||
- `uat-005-ziwei.spec.ts`: 紫微斗数功能测试
|
||||
|
||||
## 运行UAT测试
|
||||
|
||||
### 前置条件
|
||||
|
||||
1. 确保后端服务已启动
|
||||
2. 确保前端开发服务器已启动
|
||||
3. 确保数据库已初始化测试数据
|
||||
|
||||
### 运行所有UAT测试
|
||||
|
||||
```bash
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
### 运行特定UAT测试套件
|
||||
|
||||
```bash
|
||||
# 运行认证测试
|
||||
npx playwright test e2e/uat/uat-001-auth.spec.ts
|
||||
|
||||
# 运行用户管理测试
|
||||
npx playwright test e2e/uat/uat-002-user-management.spec.ts
|
||||
|
||||
# 运行黄历查询测试
|
||||
npx playwright test e2e/uat/uat-003-almanac.spec.ts
|
||||
|
||||
# 运行运势分析测试
|
||||
npx playwright test e2e/uat/uat-004-fortune.spec.ts
|
||||
|
||||
# 运行紫微斗数测试
|
||||
npx playwright test e2e/uat/uat-005-ziwei.spec.ts
|
||||
```
|
||||
|
||||
### 运行UAT测试(UI模式)
|
||||
|
||||
```bash
|
||||
npx playwright test e2e/uat --ui
|
||||
```
|
||||
|
||||
### 调试UAT测试
|
||||
|
||||
```bash
|
||||
npx playwright test e2e/uat --debug
|
||||
```
|
||||
|
||||
## 测试用例说明
|
||||
|
||||
### UAT-001: 用户注册和登录流程
|
||||
|
||||
| 用例ID | 用例名称 | 描述 |
|
||||
|---------|---------|------|
|
||||
| UAT-001-01 | 用户成功登录系统 | 验证用户使用正确的凭据登录系统 |
|
||||
| UAT-001-02 | 用户登录失败 - 错误密码 | 验证使用错误密码登录时显示错误消息 |
|
||||
| UAT-001-03 | 用户登出系统 | 验证用户成功登出系统 |
|
||||
|
||||
### UAT-002: 用户管理功能
|
||||
|
||||
| 用例ID | 用例名称 | 描述 |
|
||||
|---------|---------|------|
|
||||
| UAT-002-01 | 查看用户列表 | 验证用户可以查看用户列表 |
|
||||
| UAT-002-02 | 创建新用户 | 验证用户可以创建新用户 |
|
||||
| UAT-002-03 | 编辑用户信息 | 验证用户可以编辑用户信息 |
|
||||
| UAT-002-04 | 删除用户 | 验证用户可以删除用户 |
|
||||
| UAT-002-05 | 搜索用户 | 验证用户可以搜索用户 |
|
||||
|
||||
### UAT-003: 黄历查询功能
|
||||
|
||||
| 用例ID | 用例名称 | 描述 |
|
||||
|---------|---------|------|
|
||||
| UAT-003-01 | 查询单日黄历 | 验证用户可以查询指定日期的黄历 |
|
||||
| UAT-003-02 | 查看宜忌事项 | 验证黄历显示正确的宜忌事项 |
|
||||
| UAT-003-03 | 查看吉凶方位 | 验证黄历显示正确的吉凶方位 |
|
||||
| UAT-003-04 | 查看冲煞信息 | 验证黄历显示正确的冲煞信息 |
|
||||
| UAT-003-05 | 查看建除十二神 | 验证黄历显示正确的建除十二神 |
|
||||
|
||||
### UAT-004: 运势分析功能
|
||||
|
||||
| 用例ID | 用例名称 | 描述 |
|
||||
|---------|---------|------|
|
||||
| UAT-004-01 | 查看每日运势 | 验证用户可以查看每日运势 |
|
||||
| UAT-004-02 | 查看每月运势 | 验证用户可以查看每月运势 |
|
||||
| UAT-004-03 | 查看每年运势 | 验证用户可以查看每年运势 |
|
||||
| UAT-004-04 | 查看宫位运势 | 验证运势显示正确的宫位信息 |
|
||||
| UAT-004-05 | 查看幸运信息 | 验证运势显示正确的幸运色、数字、方位 |
|
||||
|
||||
### UAT-005: 紫微斗数功能
|
||||
|
||||
| 用例ID | 用例名称 | 描述 |
|
||||
|---------|---------|------|
|
||||
| UAT-005-01 | 生成紫微斗数命盘 | 验证用户可以生成紫微斗数命盘 |
|
||||
| UAT-005-02 | 查看十二宫位 | 验证命盘显示正确的十二宫位 |
|
||||
| UAT-005-03 | 查看主星排列 | 验证命盘显示正确的主星排列 |
|
||||
| UAT-005-04 | 查看四化飞星 | 验证命盘显示正确的四化飞星 |
|
||||
| UAT-005-05 | 查看命盘分析 | 验证命盘显示正确的分析结果 |
|
||||
| UAT-005-06 | 保存命盘 | 验证用户可以保存命盘 |
|
||||
|
||||
## 测试数据
|
||||
|
||||
UAT测试使用以下测试数据:
|
||||
|
||||
### 用户认证
|
||||
- 用户名: `admin`
|
||||
- 密码: `admin123`
|
||||
|
||||
### 测试用户创建
|
||||
- 用户名: `testuser_${timestamp}`
|
||||
- 邮箱: `testuser_${timestamp}@example.com`
|
||||
- 密码: `Test@123456`
|
||||
- 角色: `USER` 或 `ADMIN`
|
||||
- 状态: `ACTIVE`
|
||||
|
||||
### 黄历查询
|
||||
- 测试日期: `2024-01-01`
|
||||
|
||||
### 运势分析
|
||||
- 测试日期: `2024-01-15`
|
||||
|
||||
### 紫微斗数
|
||||
- 出生日期: `1990-05-15`
|
||||
- 出生时间: `08:30`
|
||||
- 性别: `MALE`
|
||||
|
||||
## 测试报告
|
||||
|
||||
UAT测试执行后会生成以下报告:
|
||||
|
||||
1. **HTML报告**: `playwright-report/index.html`
|
||||
2. **JSON报告**: `test-results/results.json`
|
||||
3. **截图**: 失败测试的截图保存在 `test-results/` 目录
|
||||
4. **视频**: 失败测试的视频保存在 `test-results/` 目录
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **测试独立性**: 每个测试用例应该独立运行,不依赖其他测试用例
|
||||
2. **测试清理**: 每个测试用例执行后应该清理测试数据
|
||||
3. **测试覆盖**: UAT测试应该覆盖所有关键业务流程
|
||||
4. **测试文档**: 每个测试用例应该有清晰的描述和预期结果
|
||||
5. **测试维护**: 定期更新UAT测试以反映业务需求的变化
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **测试超时**
|
||||
- 检查网络连接
|
||||
- 检查后端服务是否正常运行
|
||||
- 增加测试超时时间
|
||||
|
||||
2. **元素定位失败**
|
||||
- 检查页面是否完全加载
|
||||
- 检查元素选择器是否正确
|
||||
- 使用 Playwright 的等待机制
|
||||
|
||||
3. **测试数据问题**
|
||||
- 检查数据库是否有正确的测试数据
|
||||
- 检查测试数据是否被其他测试修改
|
||||
- 使用唯一的测试数据标识符
|
||||
|
||||
## 联系方式
|
||||
|
||||
如有问题,请联系测试团队或查看项目文档。
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { test as uatTest } from './uat-base';
|
||||
import { LoginPage } from '../pages/login-page';
|
||||
import { DashboardPage } from '../pages/dashboard-page';
|
||||
import { UserManagementPage } from '../pages/user-management-page';
|
||||
import { testConfig } from '../core/test-config';
|
||||
|
||||
uatTest.describe('UAT-001: 用户注册和登录流程', () => {
|
||||
let loginPage: LoginPage;
|
||||
let dashboardPage: DashboardPage;
|
||||
|
||||
test.beforeEach(async ({ page, uatLogin, uatDashboard }) => {
|
||||
loginPage = uatLogin;
|
||||
dashboardPage = uatDashboard;
|
||||
});
|
||||
|
||||
uatTest('UAT-001-01: 用户成功登录系统', async ({ page }) => {
|
||||
await test.step('Given 用户打开登录页面', async () => {
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await expect(page).toHaveTitle(/登录/);
|
||||
});
|
||||
|
||||
await test.step('When 用户输入有效的用户名和密码', async () => {
|
||||
await loginPage.login('admin', 'admin123');
|
||||
});
|
||||
|
||||
await test.step('Then 用户应成功登录并跳转到仪表盘', async () => {
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
await expect(page.locator('[data-testid="page-title"]')).toContainText('仪表盘');
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-001-02: 用户登录失败 - 错误密码', async ({ page }) => {
|
||||
await test.step('Given 用户打开登录页面', async () => {
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
});
|
||||
|
||||
await test.step('When 用户输入错误的密码', async () => {
|
||||
await loginPage.login('admin', 'wrongpassword');
|
||||
});
|
||||
|
||||
await test.step('Then 系统应显示错误消息', async () => {
|
||||
await expect(page.locator('.ant-message-error')).toBeVisible();
|
||||
await expect(page.locator('.ant-message-error')).toContainText('用户名或密码错误');
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-001-03: 用户登出系统', async ({ page }) => {
|
||||
await test.step('Given 用户已登录系统', async () => {
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
|
||||
await test.step('When 用户点击登出按钮', async () => {
|
||||
await page.click('[data-testid="logout-button"]');
|
||||
});
|
||||
|
||||
await test.step('Then 用户应被重定向到登录页面', async () => {
|
||||
await expect(page).toHaveURL(/.*login/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { test } from '@playwright/test';
|
||||
import { test as uatTest } from './uat-base';
|
||||
import { LoginPage } from '../pages/login-page';
|
||||
import { DashboardPage } from '../pages/dashboard-page';
|
||||
import { UserManagementPage } from '../pages/user-management-page';
|
||||
import { testConfig } from '../core/test-config';
|
||||
|
||||
uatTest.describe('UAT-002: 用户管理功能', () => {
|
||||
let loginPage: LoginPage;
|
||||
let dashboardPage: DashboardPage;
|
||||
let userManagementPage: UserManagementPage;
|
||||
|
||||
test.beforeEach(async ({ page, uatLogin, uatDashboard, uatUserManagement }) => {
|
||||
loginPage = uatLogin;
|
||||
dashboardPage = uatDashboard;
|
||||
userManagementPage = uatUserManagement;
|
||||
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
|
||||
uatTest('UAT-002-01: 查看用户列表', async ({ page }) => {
|
||||
await test.step('Given 用户已登录系统', async () => {
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
|
||||
await test.step('When 用户导航到用户管理页面', async () => {
|
||||
await dashboardPage.navigateToUserManagement();
|
||||
});
|
||||
|
||||
await test.step('Then 用户应看到用户列表', async () => {
|
||||
await expect(page.locator('[data-testid="user-table"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="page-title"]')).toContainText('用户管理');
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-002-02: 创建新用户', async ({ page }) => {
|
||||
const testUsername = `testuser_${Date.now()}`;
|
||||
const testEmail = `testuser_${Date.now()}@example.com`;
|
||||
|
||||
await test.step('Given 用户在用户管理页面', async () => {
|
||||
await dashboardPage.navigateToUserManagement();
|
||||
await expect(page.locator('[data-testid="user-table"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户点击新增用户按钮并填写信息', async () => {
|
||||
await userManagementPage.clickAddUser();
|
||||
await userManagementPage.fillUserForm({
|
||||
username: testUsername,
|
||||
email: testEmail,
|
||||
password: 'Test@123456',
|
||||
confirmPassword: 'Test@123456',
|
||||
role: 'USER',
|
||||
status: 'ACTIVE'
|
||||
});
|
||||
await userManagementPage.submitUserForm();
|
||||
});
|
||||
|
||||
await test.step('Then 新用户应创建成功', async () => {
|
||||
await expect(page.locator('.ant-message-success')).toBeVisible();
|
||||
await expect(page.locator('.ant-message-success')).toContainText('用户创建成功');
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-002-03: 编辑用户信息', async ({ page }) => {
|
||||
await test.step('Given 用户在用户管理页面', async () => {
|
||||
await dashboardPage.navigateToUserManagement();
|
||||
await expect(page.locator('[data-testid="user-table"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户点击编辑按钮并修改信息', async () => {
|
||||
await page.click('button:has-text("编辑")');
|
||||
await page.fill('[data-testid="email-input"]', 'updated@example.com');
|
||||
await page.click('[data-testid="submit-button"]');
|
||||
});
|
||||
|
||||
await test.step('Then 用户信息应更新成功', async () => {
|
||||
await expect(page.locator('.ant-message-success')).toBeVisible();
|
||||
await expect(page.locator('.ant-message-success')).toContainText('用户更新成功');
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-002-04: 删除用户', async ({ page }) => {
|
||||
await test.step('Given 用户在用户管理页面', async () => {
|
||||
await dashboardPage.navigateToUserManagement();
|
||||
await expect(page.locator('[data-testid="user-table"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户点击删除按钮并确认', async () => {
|
||||
await page.click('button:has-text("删除")');
|
||||
await page.click('.ant-modal-confirm-btn');
|
||||
});
|
||||
|
||||
await test.step('Then 用户应删除成功', async () => {
|
||||
await expect(page.locator('.ant-message-success')).toBeVisible();
|
||||
await expect(page.locator('.ant-message-success')).toContainText('用户删除成功');
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-002-05: 搜索用户', async ({ page }) => {
|
||||
await test.step('Given 用户在用户管理页面', async () => {
|
||||
await dashboardPage.navigateToUserManagement();
|
||||
await expect(page.locator('[data-testid="user-table"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户输入用户名进行搜索', async () => {
|
||||
await page.fill('[data-testid="username-search-input"]', 'admin');
|
||||
await page.click('[data-testid="search-button"]');
|
||||
});
|
||||
|
||||
await test.step('Then 系统应显示匹配的用户', async () => {
|
||||
await expect(page.locator('[data-testid="user-table"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { test } from '@playwright/test';
|
||||
import { test as uatTest } from './uat-base';
|
||||
import { LoginPage } from '../pages/login-page';
|
||||
import { DashboardPage } from '../pages/dashboard-page';
|
||||
import { testConfig } from '../core/test-config';
|
||||
|
||||
uatTest.describe('UAT-003: 黄历查询功能', () => {
|
||||
let loginPage: LoginPage;
|
||||
let dashboardPage: DashboardPage;
|
||||
|
||||
test.beforeEach(async ({ page, uatLogin, uatDashboard }) => {
|
||||
loginPage = uatLogin;
|
||||
dashboardPage = uatDashboard;
|
||||
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
|
||||
uatTest('UAT-003-01: 查询单日黄历', async ({ page }) => {
|
||||
const testDate = '2024-01-01';
|
||||
|
||||
await test.step('Given 用户已登录系统', async () => {
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
|
||||
await test.step('When 用户导航到黄历页面并选择日期', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/almanac`);
|
||||
await page.fill('[data-testid="date-picker"]', testDate);
|
||||
await page.click('[data-testid="query-button"]');
|
||||
});
|
||||
|
||||
await test.step('Then 系统应显示黄历信息', async () => {
|
||||
await expect(page.locator('[data-testid="almanac-result"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="suitable-activities"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="unsuitable-activities"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="god-direction"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="fortune-direction"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-003-02: 查看宜忌事项', async ({ page }) => {
|
||||
await test.step('Given 用户已查询黄历', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/almanac`);
|
||||
await page.fill('[data-testid="date-picker"]', '2024-01-01');
|
||||
await page.click('[data-testid="query-button"]');
|
||||
await expect(page.locator('[data-testid="almanac-result"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户查看宜忌事项', async () => {
|
||||
await expect(page.locator('[data-testid="suitable-activities"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="unsuitable-activities"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Then 宜忌事项应正确显示', async () => {
|
||||
const suitableText = await page.locator('[data-testid="suitable-activities"]').textContent();
|
||||
const unsuitableText = await page.locator('[data-testid="unsuitable-activities"]').textContent();
|
||||
|
||||
expect(suitableText).toBeTruthy();
|
||||
expect(suitableText!.length).toBeGreaterThan(0);
|
||||
expect(unsuitableText).toBeTruthy();
|
||||
expect(unsuitableText!.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-003-03: 查看吉凶方位', async ({ page }) => {
|
||||
await test.step('Given 用户已查询黄历', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/almanac`);
|
||||
await page.fill('[data-testid="date-picker"]', '2024-01-01');
|
||||
await page.click('[data-testid="query-button"]');
|
||||
await expect(page.locator('[data-testid="almanac-result"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户查看吉凶方位', async () => {
|
||||
await expect(page.locator('[data-testid="god-direction"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="joy-direction"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="fortune-direction"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="noble-direction"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Then 方位信息应正确显示', async () => {
|
||||
const godDirection = await page.locator('[data-testid="god-direction"]').textContent();
|
||||
const fortuneDirection = await page.locator('[data-testid="fortune-direction"]').textContent();
|
||||
|
||||
expect(godDirection).toBeTruthy();
|
||||
expect(godDirection!.length).toBeGreaterThan(0);
|
||||
expect(fortuneDirection).toBeTruthy();
|
||||
expect(fortuneDirection!.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-003-04: 查看冲煞信息', async ({ page }) => {
|
||||
await test.step('Given 用户已查询黄历', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/almanac`);
|
||||
await page.fill('[data-testid="date-picker"]', '2024-01-01');
|
||||
await page.click('[data-testid="query-button"]');
|
||||
await expect(page.locator('[data-testid="almanac-result"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户查看冲煞信息', async () => {
|
||||
await expect(page.locator('[data-testid="clash-info"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="evil-direction"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Then 冲煞信息应正确显示', async () => {
|
||||
const clashInfo = await page.locator('[data-testid="clash-info"]').textContent();
|
||||
const evilDirection = await page.locator('[data-testid="evil-direction"]').textContent();
|
||||
|
||||
expect(clashInfo).toBeTruthy();
|
||||
expect(clashInfo!.length).toBeGreaterThan(0);
|
||||
expect(evilDirection).toBeTruthy();
|
||||
expect(evilDirection!.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-003-05: 查看建除十二神', async ({ page }) => {
|
||||
await test.step('Given 用户已查询黄历', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/almanac`);
|
||||
await page.fill('[data-testid="date-picker"]', '2024-01-01');
|
||||
await page.click('[data-testid="query-button"]');
|
||||
await expect(page.locator('[data-testid="almanac-result"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户查看建除十二神', async () => {
|
||||
await expect(page.locator('[data-testid="jian-chu"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Then 建除十二神应正确显示', async () => {
|
||||
const jianChu = await page.locator('[data-testid="jian-chu"]').textContent();
|
||||
|
||||
expect(jianChu).toBeTruthy();
|
||||
expect(jianChu!.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { test } from '@playwright/test';
|
||||
import { test as uatTest } from './uat-base';
|
||||
import { LoginPage } from '../pages/login-page';
|
||||
import { DashboardPage } from '../pages/dashboard-page';
|
||||
import { testConfig } from '../core/test-config';
|
||||
|
||||
uatTest.describe('UAT-004: 运势分析功能', () => {
|
||||
let loginPage: LoginPage;
|
||||
let dashboardPage: DashboardPage;
|
||||
|
||||
test.beforeEach(async ({ page, uatLogin, uatDashboard }) => {
|
||||
loginPage = uatLogin;
|
||||
dashboardPage = uatDashboard;
|
||||
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
|
||||
uatTest('UAT-004-01: 查看每日运势', async ({ page }) => {
|
||||
const testDate = '2024-01-15';
|
||||
|
||||
await test.step('Given 用户已登录系统', async () => {
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
|
||||
await test.step('When 用户导航到运势页面并选择日期', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/fortune`);
|
||||
await page.fill('[data-testid="fortune-date"]', testDate);
|
||||
await page.click('[data-testid="query-fortune-button"]');
|
||||
});
|
||||
|
||||
await test.step('Then 系统应显示每日运势', async () => {
|
||||
await expect(page.locator('[data-testid="daily-fortune"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="overall-luck"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="career-advice"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="wealth-advice"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="relationship-advice"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="health-advice"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-004-02: 查看每月运势', async ({ page }) => {
|
||||
await test.step('Given 用户已登录系统', async () => {
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
|
||||
await test.step('When 用户导航到运势页面并切换到每月运势', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/fortune`);
|
||||
await page.click('[data-testid="monthly-fortune-tab"]');
|
||||
});
|
||||
|
||||
await test.step('Then 系统应显示每月运势', async () => {
|
||||
await expect(page.locator('[data-testid="monthly-fortune"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="monthly-overall-luck"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="monthly-key-focus"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="monthly-caution-advice"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-004-03: 查看每年运势', async ({ page }) => {
|
||||
await test.step('Given 用户已登录系统', async () => {
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
|
||||
await test.step('When 用户导航到运势页面并切换到每年运势', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/fortune`);
|
||||
await page.click('[data-testid="yearly-fortune-tab"]');
|
||||
});
|
||||
|
||||
await test.step('Then 系统应显示每年运势', async () => {
|
||||
await expect(page.locator('[data-testid="yearly-fortune"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="yearly-overall-luck"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="yearly-theme"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="yearly-major-opportunity"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="yearly-major-challenge"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-004-04: 查看宫位运势', async ({ page }) => {
|
||||
await test.step('Given 用户已查看每日运势', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/fortune`);
|
||||
await page.fill('[data-testid="fortune-date"]', '2024-01-15');
|
||||
await page.click('[data-testid="query-fortune-button"]');
|
||||
await expect(page.locator('[data-testid="daily-fortune"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户查看宫位运势', async () => {
|
||||
await expect(page.locator('[data-testid="palace-fortunes"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Then 宫位运势应正确显示', async () => {
|
||||
const palaceCount = await page.locator('[data-testid^="palace-"]').count();
|
||||
expect(palaceCount).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-004-05: 查看幸运信息', async ({ page }) => {
|
||||
await test.step('Given 用户已查看每日运势', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/fortune`);
|
||||
await page.fill('[data-testid="fortune-date"]', '2024-01-15');
|
||||
await page.click('[data-testid="query-fortune-button"]');
|
||||
await expect(page.locator('[data-testid="daily-fortune"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户查看幸运信息', async () => {
|
||||
await expect(page.locator('[data-testid="lucky-color"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="lucky-number"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="lucky-direction"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Then 幸运信息应正确显示', async () => {
|
||||
const luckyColor = await page.locator('[data-testid="lucky-color"]').textContent();
|
||||
const luckyNumber = await page.locator('[data-testid="lucky-number"]').textContent();
|
||||
const luckyDirection = await page.locator('[data-testid="lucky-direction"]').textContent();
|
||||
|
||||
expect(luckyColor).toBeTruthy();
|
||||
expect(luckyColor!.length).toBeGreaterThan(0);
|
||||
expect(luckyNumber).toBeTruthy();
|
||||
expect(luckyNumber!.length).toBeGreaterThan(0);
|
||||
expect(luckyDirection).toBeTruthy();
|
||||
expect(luckyDirection!.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { test } from '@playwright/test';
|
||||
import { test as uatTest } from './uat-base';
|
||||
import { LoginPage } from '../pages/login-page';
|
||||
import { DashboardPage } from '../pages/dashboard-page';
|
||||
import { testConfig } from '../core/test-config';
|
||||
|
||||
uatTest.describe('UAT-005: 紫微斗数功能', () => {
|
||||
let loginPage: LoginPage;
|
||||
let dashboardPage: DashboardPage;
|
||||
|
||||
test.beforeEach(async ({ page, uatLogin, uatDashboard }) => {
|
||||
loginPage = uatLogin;
|
||||
dashboardPage = uatDashboard;
|
||||
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await loginPage.login('admin', 'admin123');
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
|
||||
uatTest('UAT-005-01: 生成紫微斗数命盘', async ({ page }) => {
|
||||
const birthDate = '1990-05-15';
|
||||
const birthTime = '08:30';
|
||||
|
||||
await test.step('Given 用户已登录系统', async () => {
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
|
||||
await test.step('When 用户导航到紫微斗数页面并输入出生信息', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/ziwei`);
|
||||
await page.fill('[data-testid="birth-date"]', birthDate);
|
||||
await page.fill('[data-testid="birth-time"]', birthTime);
|
||||
await page.click('[data-testid="gender-male"]');
|
||||
await page.click('[data-testid="generate-chart-button"]');
|
||||
});
|
||||
|
||||
await test.step('Then 系统应生成紫微斗数命盘', async () => {
|
||||
await expect(page.locator('[data-testid="ziwei-chart"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="palace-grid"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="ming-gong"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="shen-gong"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-005-02: 查看十二宫位', async ({ page }) => {
|
||||
await test.step('Given 用户已生成紫微斗数命盘', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/ziwei`);
|
||||
await page.fill('[data-testid="birth-date"]', '1990-05-15');
|
||||
await page.fill('[data-testid="birth-time"]', '08:30');
|
||||
await page.click('[data-testid="gender-male"]');
|
||||
await page.click('[data-testid="generate-chart-button"]');
|
||||
await expect(page.locator('[data-testid="ziwei-chart"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户查看十二宫位', async () => {
|
||||
await expect(page.locator('[data-testid="palace-grid"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Then 十二宫位应正确显示', async () => {
|
||||
const palaceCount = await page.locator('[data-testid^="palace-"]').count();
|
||||
expect(palaceCount).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-005-03: 查看主星排列', async ({ page }) => {
|
||||
await test.step('Given 用户已生成紫微斗数命盘', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/ziwei`);
|
||||
await page.fill('[data-testid="birth-date"]', '1990-05-15');
|
||||
await page.fill('[data-testid="birth-time"]', '08:30');
|
||||
await page.click('[data-testid="gender-male"]');
|
||||
await page.click('[data-testid="generate-chart-button"]');
|
||||
await expect(page.locator('[data-testid="ziwei-chart"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户查看主星排列', async () => {
|
||||
await expect(page.locator('[data-testid="major-stars"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Then 主星应正确显示', async () => {
|
||||
const majorStars = await page.locator('[data-testid^="major-star-"]').count();
|
||||
expect(majorStars).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-005-04: 查看四化飞星', async ({ page }) => {
|
||||
await test.step('Given 用户已生成紫微斗数命盘', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/ziwei`);
|
||||
await page.fill('[data-testid="birth-date"]', '1990-05-15');
|
||||
await page.fill('[data-testid="birth-time"]', '08:30');
|
||||
await page.click('[data-testid="gender-male"]');
|
||||
await page.click('[data-testid="generate-chart-button"]');
|
||||
await expect(page.locator('[data-testid="ziwei-chart"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户查看四化飞星', async () => {
|
||||
await expect(page.locator('[data-testid="transformations"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Then 四化飞星应正确显示', async () => {
|
||||
await expect(page.locator('[data-testid="hua-lu"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="hua-quan"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="hua-ke"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="hua-ji"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-005-05: 查看命盘分析', async ({ page }) => {
|
||||
await test.step('Given 用户已生成紫微斗数命盘', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/ziwei`);
|
||||
await page.fill('[data-testid="birth-date"]', '1990-05-15');
|
||||
await page.fill('[data-testid="birth-time"]', '08:30');
|
||||
await page.click('[data-testid="gender-male"]');
|
||||
await page.click('[data-testid="generate-chart-button"]');
|
||||
await expect(page.locator('[data-testid="ziwei-chart"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户查看命盘分析', async () => {
|
||||
await expect(page.locator('[data-testid="chart-analysis"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Then 命盘分析应正确显示', async () => {
|
||||
const analysisText = await page.locator('[data-testid="chart-analysis"]').textContent();
|
||||
expect(analysisText).toBeTruthy();
|
||||
expect(analysisText!.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
uatTest('UAT-005-06: 保存命盘', async ({ page }) => {
|
||||
await test.step('Given 用户已生成紫微斗数命盘', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/ziwei`);
|
||||
await page.fill('[data-testid="birth-date"]', '1990-05-15');
|
||||
await page.fill('[data-testid="birth-time"]', '08:30');
|
||||
await page.click('[data-testid="gender-male"]');
|
||||
await page.click('[data-testid="generate-chart-button"]');
|
||||
await expect(page.locator('[data-testid="ziwei-chart"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('When 用户点击保存命盘按钮', async () => {
|
||||
await page.click('[data-testid="save-chart-button"]');
|
||||
});
|
||||
|
||||
await test.step('Then 命盘应保存成功', async () => {
|
||||
await expect(page.locator('.ant-message-success')).toBeVisible();
|
||||
await expect(page.locator('.ant-message-success')).toContainText('命盘保存成功');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { test as base, expect, Page } from '@playwright/test';
|
||||
import { LoginPage } from '../pages/login-page';
|
||||
import { DashboardPage } from '../pages/dashboard-page';
|
||||
import { UserManagementPage } from '../pages/user-management-page';
|
||||
import { testConfig } from '../core/test-config';
|
||||
|
||||
export type UATFixtures = {
|
||||
uatLogin: LoginPage;
|
||||
uatDashboard: DashboardPage;
|
||||
uatUserManagement: UserManagementPage;
|
||||
};
|
||||
|
||||
export const test = base.extend<UATFixtures>({
|
||||
uatLogin: async ({ page }, use) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await use(loginPage);
|
||||
},
|
||||
uatDashboard: async ({ page }, use) => {
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await use(dashboardPage);
|
||||
},
|
||||
uatUserManagement: async ({ page }, use) => {
|
||||
const userManagementPage = new UserManagementPage(page);
|
||||
await use(userManagementPage);
|
||||
}
|
||||
});
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'serial',
|
||||
timeout: 60000
|
||||
});
|
||||
|
||||
export const UATTestSteps = {
|
||||
async completeUserRegistrationFlow(page: Page, username: string, password: string) {
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
await test.step('用户打开登录页面', async () => {
|
||||
await page.goto(testConfig.getBaseURL());
|
||||
await expect(page).toHaveTitle(/登录/);
|
||||
});
|
||||
|
||||
await test.step('用户输入用户名和密码', async () => {
|
||||
await loginPage.login(username, password);
|
||||
});
|
||||
|
||||
await test.step('验证用户成功登录并跳转到仪表盘', async () => {
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
},
|
||||
|
||||
async completeUserManagementFlow(page: Page, username: string, email: string, role: string) {
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
const userManagementPage = new UserManagementPage(page);
|
||||
|
||||
await test.step('用户导航到用户管理页面', async () => {
|
||||
await dashboardPage.navigateToUserManagement();
|
||||
await expect(page).toHaveURL(/.*users/);
|
||||
});
|
||||
|
||||
await test.step('用户点击新增用户按钮', async () => {
|
||||
await userManagementPage.clickAddUser();
|
||||
});
|
||||
|
||||
await test.step('用户填写用户信息', async () => {
|
||||
await userManagementPage.fillUserForm({
|
||||
username,
|
||||
email,
|
||||
password: 'Test@123456',
|
||||
confirmPassword: 'Test@123456',
|
||||
role,
|
||||
status: 'ACTIVE'
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('用户提交表单', async () => {
|
||||
await userManagementPage.submitUserForm();
|
||||
});
|
||||
|
||||
await test.step('验证用户创建成功', async () => {
|
||||
await expect(page.locator('.ant-message-success')).toBeVisible();
|
||||
});
|
||||
},
|
||||
|
||||
async completeAlmanacQueryFlow(page: Page, date: string) {
|
||||
await test.step('用户打开黄历查询页面', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/almanac`);
|
||||
});
|
||||
|
||||
await test.step('用户选择日期', async () => {
|
||||
await page.fill('[data-testid="date-picker"]', date);
|
||||
});
|
||||
|
||||
await test.step('用户点击查询按钮', async () => {
|
||||
await page.click('[data-testid="query-button"]');
|
||||
});
|
||||
|
||||
await test.step('验证黄历信息显示', async () => {
|
||||
await expect(page.locator('[data-testid="almanac-result"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="suitable-activities"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="unsuitable-activities"]')).toBeVisible();
|
||||
});
|
||||
},
|
||||
|
||||
async completeFortuneAnalysisFlow(page: Page) {
|
||||
await test.step('用户打开运势分析页面', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/fortune`);
|
||||
});
|
||||
|
||||
await test.step('用户查看每日运势', async () => {
|
||||
await expect(page.locator('[data-testid="daily-fortune"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('用户查看每月运势', async () => {
|
||||
await page.click('[data-testid="monthly-fortune-tab"]');
|
||||
await expect(page.locator('[data-testid="monthly-fortune"]')).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('用户查看每年运势', async () => {
|
||||
await page.click('[data-testid="yearly-fortune-tab"]');
|
||||
await expect(page.locator('[data-testid="yearly-fortune"]')).toBeVisible();
|
||||
});
|
||||
},
|
||||
|
||||
async completeZiweiChartGenerationFlow(page: Page, birthDate: string, birthTime: string) {
|
||||
await test.step('用户打开紫微斗数页面', async () => {
|
||||
await page.goto(`${testConfig.getBaseURL()}/ziwei`);
|
||||
});
|
||||
|
||||
await test.step('用户输入出生日期和时间', async () => {
|
||||
await page.fill('[data-testid="birth-date"]', birthDate);
|
||||
await page.fill('[data-testid="birth-time"]', birthTime);
|
||||
});
|
||||
|
||||
await test.step('用户选择性别', async () => {
|
||||
await page.click('[data-testid="gender-male"]');
|
||||
});
|
||||
|
||||
await test.step('用户点击生成命盘按钮', async () => {
|
||||
await page.click('[data-testid="generate-chart-button"]');
|
||||
});
|
||||
|
||||
await test.step('验证命盘生成成功', async () => {
|
||||
await expect(page.locator('[data-testid="ziwei-chart"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="palace-grid"]')).toBeVisible();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const UATAssertions = {
|
||||
assertPageTitle(page: Page, expectedTitle: string) {
|
||||
return expect(page).toHaveTitle(new RegExp(expectedTitle));
|
||||
},
|
||||
|
||||
assertElementVisible(page: Page, selector: string) {
|
||||
return expect(page.locator(selector)).toBeVisible();
|
||||
},
|
||||
|
||||
assertElementText(page: Page, selector: string, expectedText: string) {
|
||||
return expect(page.locator(selector)).toHaveText(expectedText);
|
||||
},
|
||||
|
||||
assertSuccessMessage(page: Page, message: string) {
|
||||
return expect(page.locator('.ant-message-success')).toContainText(message);
|
||||
},
|
||||
|
||||
assertErrorMessage(page: Page, message: string) {
|
||||
return expect(page.locator('.ant-message-error')).toContainText(message);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test, expect } from './test-fixtures';
|
||||
|
||||
test.describe('用户管理 - 完全Mock模式', () => {
|
||||
test.beforeEach(async ({ page, mockManager }) => {
|
||||
mockManager.enableMock();
|
||||
mockManager.configureMock({
|
||||
mode: 'full',
|
||||
delay: 100
|
||||
});
|
||||
|
||||
mockManager.presetTestData({
|
||||
users: [
|
||||
{ id: 1, username: 'testuser1', email: 'test1@example.com', status: 1, createTime: '2024-01-01 10:00:00' },
|
||||
{ id: 2, username: 'testuser2', email: 'test2@example.com', status: 1, createTime: '2024-01-02 10:00:00' },
|
||||
{ id: 3, username: 'testuser3', email: 'test3@example.com', status: 0, createTime: '2024-01-03 10:00:00' }
|
||||
]
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.fill('input[placeholder="请输入用户名"]', 'admin');
|
||||
await page.fill('input[placeholder="请输入密码"]', 'admin123');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ mockManager }) => {
|
||||
mockManager.clearPresets();
|
||||
mockManager.disableMock();
|
||||
});
|
||||
|
||||
test('应该显示用户列表', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(page.locator('.ant-table')).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够搜索用户', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const searchInput = page.locator('input[placeholder*="搜索"]').first();
|
||||
await searchInput.fill('testuser1');
|
||||
|
||||
const searchButton = page.locator('button').filter({ hasText: /搜索|查询/ }).first();
|
||||
await searchButton.click();
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
test('应该能够打开新增用户对话框', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const addButton = page.locator('button').filter({ hasText: /新增|添加/ }).first();
|
||||
await addButton.click();
|
||||
|
||||
await expect(page.locator('.ant-modal')).toBeVisible();
|
||||
await expect(page.locator('.ant-modal').getByText('新增用户')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { MockManager } from './mock-manager';
|
||||
|
||||
test.describe('用户管理', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const mockManager = new MockManager({
|
||||
enabled: true,
|
||||
mode: 'full',
|
||||
mockPaths: [],
|
||||
delay: 0,
|
||||
logCalls: true,
|
||||
validateResponses: true,
|
||||
dataSource: 'memory'
|
||||
});
|
||||
|
||||
mockManager.presetTestData({
|
||||
menus: [
|
||||
{
|
||||
id: 1,
|
||||
name: '仪表盘',
|
||||
code: 'dashboard',
|
||||
path: '/dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
sortOrder: 1,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
component: 'views/Dashboard.vue',
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '用户管理',
|
||||
code: 'user',
|
||||
path: '/users',
|
||||
icon: 'UserOutlined',
|
||||
sortOrder: 2,
|
||||
status: 'active',
|
||||
parentId: 0,
|
||||
component: 'views/UserManagement.vue',
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
children: []
|
||||
}
|
||||
],
|
||||
users: [
|
||||
{
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
realName: '管理员',
|
||||
email: 'admin@example.com',
|
||||
phone: '13800138000',
|
||||
gender: 'male',
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: 'user1',
|
||||
realName: '用户1',
|
||||
email: 'user1@example.com',
|
||||
phone: '13800138001',
|
||||
gender: 'female',
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await mockManager.interceptAPIRequest(page);
|
||||
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const usernameInput = page.locator('input[placeholder="请输入用户名"]');
|
||||
const passwordInput = page.locator('input[placeholder="请输入密码"]');
|
||||
const loginButton = page.locator('button[type="submit"]');
|
||||
|
||||
await usernameInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await passwordInput.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await loginButton.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
await usernameInput.fill('admin');
|
||||
await passwordInput.fill('admin123');
|
||||
await loginButton.click();
|
||||
|
||||
await page.waitForURL(/.*dashboard/, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test('应该显示用户列表页面', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(page.locator('[data-testid="page-title"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="add-user-button"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="search-button"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该显示用户数据表格', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
|
||||
const table = page.locator('.ant-table');
|
||||
await expect(table).toBeVisible();
|
||||
|
||||
await expect(page.getByText(/用户名/i)).toBeVisible();
|
||||
await expect(page.getByText(/邮箱/i)).toBeVisible();
|
||||
await expect(page.getByText(/手机号/i)).toBeVisible();
|
||||
await expect(page.getByText(/状态/i)).toBeVisible();
|
||||
await expect(page.getByText(/创建时间/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够创建新用户', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
|
||||
await page.getByRole('button', { name: /添加用户/i }).click();
|
||||
await expect(page).toHaveURL(/.*users\/create/);
|
||||
|
||||
await page.getByPlaceholder(/请输入用户名/i).fill('testuser');
|
||||
await page.getByPlaceholder(/请输入密码/i).fill('Test@123456');
|
||||
await page.getByPlaceholder(/请输入确认密码/i).fill('Test@123456');
|
||||
await page.getByPlaceholder(/请输入邮箱/i).fill('test@example.com');
|
||||
await page.getByPlaceholder(/请输入手机号/i).fill('13800138000');
|
||||
await page.getByPlaceholder(/请输入真实姓名/i).fill('测试用户');
|
||||
|
||||
await page.getByRole('button', { name: /提交/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/.*users/);
|
||||
await expect(page.getByText(/创建成功/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('创建用户时应该验证必填字段', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
await page.getByRole('button', { name: /添加用户/i }).click();
|
||||
|
||||
await page.getByRole('button', { name: /提交/i }).click();
|
||||
|
||||
await expect(page.getByText(/请输入用户名/i)).toBeVisible();
|
||||
await expect(page.getByText(/请输入密码/i)).toBeVisible();
|
||||
await expect(page.getByText(/请输入确认密码/i)).toBeVisible();
|
||||
await expect(page.getByText(/请输入邮箱/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该验证密码一致性', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
await page.getByRole('button', { name: /添加用户/i }).click();
|
||||
|
||||
await page.getByPlaceholder(/请输入用户名/i).fill('testuser');
|
||||
await page.getByPlaceholder(/请输入密码/i).fill('Test@123456');
|
||||
await page.getByPlaceholder(/请输入确认密码/i).fill('Different@123456');
|
||||
await page.getByPlaceholder(/请输入邮箱/i).fill('test@example.com');
|
||||
|
||||
await page.getByRole('button', { name: /提交/i }).click();
|
||||
|
||||
await expect(page.getByText(/两次输入的密码不一致/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该验证邮箱格式', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
await page.getByRole('button', { name: /添加用户/i }).click();
|
||||
|
||||
await page.getByPlaceholder(/请输入用户名/i).fill('testuser');
|
||||
await page.getByPlaceholder(/请输入密码/i).fill('Test@123456');
|
||||
await page.getByPlaceholder(/请输入确认密码/i).fill('Test@123456');
|
||||
await page.getByPlaceholder(/请输入邮箱/i).fill('invalid-email');
|
||||
|
||||
await page.getByRole('button', { name: /提交/i }).click();
|
||||
|
||||
await expect(page.getByText(/请输入正确的邮箱格式/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该能够编辑用户', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
|
||||
const editButton = page.getByRole('button').filter({ hasText: /编辑/i }).first();
|
||||
if (await editButton.isVisible()) {
|
||||
await editButton.click();
|
||||
await expect(page).toHaveURL(/.*users\/\d+\/edit/);
|
||||
|
||||
const realNameInput = page.getByPlaceholder(/请输入真实姓名/i);
|
||||
await realNameInput.clear();
|
||||
await realNameInput.fill('更新后的用户名');
|
||||
|
||||
await page.getByRole('button', { name: /提交/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/.*users/);
|
||||
await expect(page.getByText(/更新成功/i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('应该能够删除用户', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
|
||||
const deleteButton = page.getByRole('button').filter({ hasText: /删除/i }).first();
|
||||
if (await deleteButton.isVisible()) {
|
||||
await deleteButton.click();
|
||||
|
||||
await expect(page.getByText(/确认删除/i)).toBeVisible();
|
||||
await page.getByRole('button', { name: /确认/i }).click();
|
||||
|
||||
await expect(page.getByText(/删除成功/i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('应该能够查看用户详情', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
|
||||
const detailButton = page.getByRole('button').filter({ hasText: /详情/i }).first();
|
||||
if (await detailButton.isVisible()) {
|
||||
await detailButton.click();
|
||||
await expect(page).toHaveURL(/.*users\/\d+\/detail/);
|
||||
await expect(page.getByText(/用户详情/i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('应该能够刷新用户列表', async ({ page }) => {
|
||||
await page.goto('/users');
|
||||
|
||||
await page.getByRole('button', { name: /刷新/i }).click();
|
||||
|
||||
await expect(page.getByText(/刷新成功/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('应该支持状态切换', async ({ page }) => {
|
||||
await page.goto('/users/create');
|
||||
|
||||
const statusSelect = page.locator('.ant-select').filter({ hasText: /状态/i });
|
||||
await statusSelect.click();
|
||||
|
||||
await expect(page.getByText(/启用/i)).toBeVisible();
|
||||
await expect(page.getByText(/禁用/i)).toBeVisible();
|
||||
|
||||
await page.getByText(/禁用/i).click();
|
||||
await expect(statusSelect).toContainText(/禁用/i);
|
||||
});
|
||||
|
||||
test('应该支持性别选择', async ({ page }) => {
|
||||
await page.goto('/users/create');
|
||||
|
||||
const genderSelect = page.locator('.ant-select').filter({ hasText: /性别/i });
|
||||
await genderSelect.click();
|
||||
|
||||
await expect(page.getByText(/男/i)).toBeVisible();
|
||||
await expect(page.getByText(/女/i)).toBeVisible();
|
||||
await expect(page.getByText(/未知/i)).toBeVisible();
|
||||
|
||||
await page.getByText(/女/i).click();
|
||||
await expect(genderSelect).toContainText(/女/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,566 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
import { testLogger } from '../core/test-logger';
|
||||
|
||||
export async function waitForElement(page: Page, selector: string, timeout: number = 10000): Promise<void> {
|
||||
testLogger.debug(`等待元素: ${selector}, 超时: ${timeout}ms`);
|
||||
|
||||
try {
|
||||
await page.waitForSelector(selector, {
|
||||
state: 'visible',
|
||||
timeout
|
||||
});
|
||||
|
||||
testLogger.debug(`元素已可见: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`等待元素超时: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForElementHidden(page: Page, selector: string, timeout: number = 10000): Promise<void> {
|
||||
testLogger.debug(`等待元素隐藏: ${selector}, 超时: ${timeout}ms`);
|
||||
|
||||
try {
|
||||
await page.waitForSelector(selector, {
|
||||
state: 'hidden',
|
||||
timeout
|
||||
});
|
||||
|
||||
testLogger.debug(`元素已隐藏: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`等待元素隐藏超时: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForText(page: Page, selector: string, text: string, timeout: number = 10000): Promise<void> {
|
||||
testLogger.debug(`等待文本: ${selector} 包含 "${text}", 超时: ${timeout}ms`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
await expect(locator).toHaveText(text, { timeout });
|
||||
|
||||
testLogger.debug(`文本已出现: ${selector} 包含 "${text}"`);
|
||||
} catch (error) {
|
||||
testLogger.error(`等待文本超时: ${selector} 包含 "${text}"`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForURL(page: Page, urlPattern: string | RegExp, timeout: number = 10000): Promise<void> {
|
||||
testLogger.debug(`等待URL匹配: ${urlPattern}, 超时: ${timeout}ms`);
|
||||
|
||||
try {
|
||||
await page.waitForURL(urlPattern, { timeout });
|
||||
|
||||
testLogger.debug(`URL已匹配: ${page.url()}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`等待URL超时: ${urlPattern}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clickElement(page: Page, selector: string, options?: { timeout?: number; force?: boolean }): Promise<void> {
|
||||
testLogger.debug(`点击元素: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
await locator.click(options);
|
||||
|
||||
testLogger.debug(`元素点击成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`点击元素失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fillInput(page: Page, selector: string, value: string, options?: { timeout?: number }): Promise<void> {
|
||||
testLogger.debug(`填充输入框: ${selector}, 值: ${value}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
await locator.fill(value, options);
|
||||
|
||||
testLogger.debug(`输入框填充成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`填充输入框失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function selectOption(page: Page, selector: string, value: string | string[]): Promise<void> {
|
||||
testLogger.debug(`选择下拉选项: ${selector}, 值: ${value}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
await locator.selectOption(value);
|
||||
|
||||
testLogger.debug(`下拉选项选择成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`选择下拉选项失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkCheckbox(page: Page, selector: string): Promise<void> {
|
||||
testLogger.debug(`勾选复选框: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
await locator.check();
|
||||
|
||||
testLogger.debug(`复选框勾选成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`勾选复选框失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function uncheckCheckbox(page: Page, selector: string): Promise<void> {
|
||||
testLogger.debug(`取消勾选复选框: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
await locator.uncheck();
|
||||
|
||||
testLogger.debug(`复选框取消勾选成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`取消勾选复选框失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getText(page: Page, selector: string): Promise<string> {
|
||||
testLogger.debug(`获取元素文本: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
const text = await locator.textContent();
|
||||
|
||||
testLogger.debug(`元素文本: ${selector} = ${text}`);
|
||||
|
||||
return text || '';
|
||||
} catch (error) {
|
||||
testLogger.error(`获取元素文本失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAttribute(page: Page, selector: string, attributeName: string): Promise<string | null> {
|
||||
testLogger.debug(`获取元素属性: ${selector}, 属性名: ${attributeName}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
const attribute = await locator.getAttribute(attributeName);
|
||||
|
||||
testLogger.debug(`元素属性: ${selector}[${attributeName}] = ${attribute}`);
|
||||
|
||||
return attribute;
|
||||
} catch (error) {
|
||||
testLogger.error(`获取元素属性失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isVisible(page: Page, selector: string): Promise<boolean> {
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
return await locator.isVisible({ timeout: 5000 });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isEnabled(page: Page, selector: string): Promise<boolean> {
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
return await locator.isEnabled({ timeout: 5000 });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isHidden(page: Page, selector: string): Promise<boolean> {
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
return await locator.isHidden({ timeout: 5000 });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isDisabled(page: Page, selector: string): Promise<boolean> {
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
return await locator.isDisabled({ timeout: 5000 });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrollToElement(page: Page, selector: string): Promise<void> {
|
||||
testLogger.debug(`滚动到元素: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
await locator.scrollIntoViewIfNeeded();
|
||||
|
||||
testLogger.debug(`滚动到元素成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`滚动到元素失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function hoverElement(page: Page, selector: string): Promise<void> {
|
||||
testLogger.debug(`悬停在元素上: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
await locator.hover();
|
||||
|
||||
testLogger.debug(`悬停成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`悬停失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function doubleClickElement(page: Page, selector: string): Promise<void> {
|
||||
testLogger.debug(`双击元素: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
await locator.dblclick();
|
||||
|
||||
testLogger.debug(`双击成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`双击失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function rightClickElement(page: Page, selector: string): Promise<void> {
|
||||
testLogger.debug(`右键点击元素: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
await locator.click({ button: 'right' });
|
||||
|
||||
testLogger.debug(`右键点击成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`右键点击失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadFile(page: Page, selector: string, filePath: string): Promise<void> {
|
||||
testLogger.debug(`上传文件: ${selector}, 路径: ${filePath}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
await locator.setInputFiles(filePath);
|
||||
|
||||
testLogger.debug(`文件上传成功: ${filePath}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`文件上传失败: ${filePath}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearInput(page: Page, selector: string): Promise<void> {
|
||||
testLogger.debug(`清空输入框: ${selector}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
await locator.clear();
|
||||
|
||||
testLogger.debug(`输入框已清空: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`清空输入框失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function pressKey(page: Page, key: string): Promise<void> {
|
||||
testLogger.debug(`按键: ${key}`);
|
||||
|
||||
try {
|
||||
await page.keyboard.press(key);
|
||||
|
||||
testLogger.debug(`按键成功: ${key}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`按键失败: ${key}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function typeText(page: Page, selector: string, text: string, delay?: number): Promise<void> {
|
||||
testLogger.debug(`输入文本: ${selector}, 文本: ${text}`);
|
||||
|
||||
try {
|
||||
const locator = page.locator(selector);
|
||||
await locator.type(text, { delay });
|
||||
|
||||
testLogger.debug(`文本输入成功: ${selector}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`文本输入失败: ${selector}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForNetworkIdle(page: Page, timeout: number = 30000): Promise<void> {
|
||||
testLogger.debug(`等待网络空闲, 超时: ${timeout}ms`);
|
||||
|
||||
try {
|
||||
await page.waitForLoadState('networkidle', { timeout });
|
||||
|
||||
testLogger.debug('网络已空闲');
|
||||
} catch (error) {
|
||||
testLogger.error('等待网络空闲超时', error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForLoadState(page: Page, state: 'load' | 'domcontentloaded' | 'networkidle' = 'load', timeout: number = 30000): Promise<void> {
|
||||
testLogger.debug(`等待加载状态: ${state}, 超时: ${timeout}ms`);
|
||||
|
||||
try {
|
||||
await page.waitForLoadState(state, { timeout });
|
||||
|
||||
testLogger.debug(`加载状态已达到: ${state}`);
|
||||
} catch (error) {
|
||||
testLogger.error(`等待加载状态超时: ${state}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeScript(page: Page, script: string, ...args: any[]): Promise<any> {
|
||||
testLogger.debug('执行JavaScript脚本');
|
||||
|
||||
try {
|
||||
const result = await page.evaluate(script, ...args);
|
||||
|
||||
testLogger.debug('JavaScript脚本执行成功');
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
testLogger.error('JavaScript脚本执行失败', error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function takeScreenshot(page: Page, name: string, fullPage: boolean = false): Promise<string> {
|
||||
testLogger.debug(`截图: ${name}, 全页: ${fullPage}`);
|
||||
|
||||
try {
|
||||
const path = `test-results/screenshots/${name}-${Date.now()}.png`;
|
||||
await page.screenshot({ path, fullPage });
|
||||
|
||||
testLogger.debug(`截图已保存: ${path}`);
|
||||
|
||||
return path;
|
||||
} catch (error) {
|
||||
testLogger.error(`截图失败: ${name}`, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForTimeout(ms: number): Promise<void> {
|
||||
testLogger.debug(`等待 ${ms}ms`);
|
||||
await new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function retryOperation<T>(
|
||||
operation: () => Promise<T>,
|
||||
maxRetries: number = 3,
|
||||
delay: number = 1000,
|
||||
description: string = '操作'
|
||||
): Promise<T> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
testLogger.debug(`${description} 尝试 ${attempt}/${maxRetries}`);
|
||||
const result = await operation();
|
||||
|
||||
if (attempt > 1) {
|
||||
testLogger.info(`${description} 在第 ${attempt} 次尝试后成功`);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
testLogger.warn(`${description} 第 ${attempt} 次尝试失败: ${error}`);
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
await waitForTimeout(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error(`${description} 在 ${maxRetries} 次尝试后仍然失败`);
|
||||
}
|
||||
|
||||
export async function waitUntil(
|
||||
condition: () => boolean | Promise<boolean>,
|
||||
timeout: number = 10000,
|
||||
interval: number = 100,
|
||||
description: string = '条件'
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const result = await condition();
|
||||
|
||||
if (result) {
|
||||
testLogger.debug(`${description} 已满足`);
|
||||
return;
|
||||
}
|
||||
|
||||
await waitForTimeout(interval);
|
||||
}
|
||||
|
||||
throw new Error(`${description} 在 ${timeout}ms 内未满足`);
|
||||
}
|
||||
|
||||
export function generateRandomString(length: number = 10): string {
|
||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += characters.charAt(Math.floor(Math.random() * characters.length));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function generateRandomEmail(): string {
|
||||
const username = generateRandomString(8).toLowerCase();
|
||||
const domains = ['example.com', 'test.com', 'demo.com'];
|
||||
const domain = domains[Math.floor(Math.random() * domains.length)];
|
||||
|
||||
return `${username}@${domain}`;
|
||||
}
|
||||
|
||||
export function generateRandomPhoneNumber(): string {
|
||||
const prefix = ['138', '139', '150', '151', '186', '188'];
|
||||
const selectedPrefix = prefix[Math.floor(Math.random() * prefix.length)];
|
||||
const suffix = Math.floor(Math.random() * 100000000).toString().padStart(8, '0');
|
||||
|
||||
return `${selectedPrefix}${suffix}`;
|
||||
}
|
||||
|
||||
export function generateRandomId(): string {
|
||||
return `${Date.now()}-${generateRandomString(6)}`;
|
||||
}
|
||||
|
||||
export function formatDate(date: Date, format: string = 'YYYY-MM-DD'): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
|
||||
return format
|
||||
.replace('YYYY', year.toString())
|
||||
.replace('MM', month)
|
||||
.replace('DD', day)
|
||||
.replace('HH', hours)
|
||||
.replace('mm', minutes)
|
||||
.replace('ss', seconds);
|
||||
}
|
||||
|
||||
export function parseDate(dateString: string, format: string = 'YYYY-MM-DD'): Date {
|
||||
const parts = dateString.match(/(\d+)/g);
|
||||
|
||||
if (!parts) {
|
||||
throw new Error(`无效的日期格式: ${dateString}`);
|
||||
}
|
||||
|
||||
const year = parseInt(parts[0], 10);
|
||||
const month = parseInt(parts[1], 10) - 1;
|
||||
const day = parseInt(parts[2], 10);
|
||||
|
||||
return new Date(year, month, day);
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function debounce(func: Function, wait: number): Function {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
|
||||
return function(...args: any[]) {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
func.apply(this, args);
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
|
||||
export function throttle(func: Function, limit: number): Function {
|
||||
let inThrottle: boolean = false;
|
||||
|
||||
return function(...args: any[]) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args);
|
||||
inThrottle = true;
|
||||
|
||||
setTimeout(() => {
|
||||
inThrottle = false;
|
||||
}, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function deepClone<T>(obj: T): T {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
export function isEmpty(value: any): boolean {
|
||||
if (value === null || value === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' || Array.isArray(value)) {
|
||||
return value.length === 0;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return Object.keys(value).length === 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isNotEmpty(value: any): boolean {
|
||||
return !isEmpty(value);
|
||||
}
|
||||
|
||||
export function pick<T extends Record<string, any>, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
|
||||
const result = {} as Pick<T, K>;
|
||||
|
||||
for (const key of keys) {
|
||||
if (key in obj) {
|
||||
result[key] = obj[key];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function omit<T extends Record<string, any>, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
|
||||
const result = { ...obj };
|
||||
|
||||
for (const key of keys) {
|
||||
delete result[key];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Everything is Suitable Admin 管理系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,60 @@
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
|
||||
|
||||
server {
|
||||
listen 5174;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://host.docker.internal:8082;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
error_page 404 /index.html;
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
}
|
||||
+9470
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "everything-is-suitable-admin",
|
||||
"version": "1.0.0",
|
||||
"description": "基于Vue 3 + TypeScript的管理系统",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:local": "vite --mode development-local",
|
||||
"dev:test": "vite --mode test",
|
||||
"dev:dev": "vite --mode development",
|
||||
"build": "vue-tsc && vite build",
|
||||
"build:local": "vue-tsc && vite build --mode development-local",
|
||||
"build:test": "vue-tsc && vite build --mode test",
|
||||
"build:dev": "vue-tsc && vite build --mode development",
|
||||
"build:prod": "vue-tsc && vite build --mode production",
|
||||
"preview": "vite preview",
|
||||
"preview:local": "vite preview --mode development-local",
|
||||
"preview:test": "vite preview --mode test",
|
||||
"preview:dev": "vite preview --mode development",
|
||||
"preview:prod": "vite preview --mode production",
|
||||
"test": "vitest --run",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:mock": "E2E_MOCK_ENABLED=true E2E_MOCK_MODE=full VITE_E2E_TEST=true playwright test",
|
||||
"test:e2e:partial": "E2E_MOCK_ENABLED=true E2E_MOCK_MODE=partial VITE_E2E_TEST=true playwright test",
|
||||
"test:e2e:real": "E2E_MOCK_ENABLED=false VITE_E2E_TEST=true playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:debug": "playwright test --debug",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/charts": "^2.6.7",
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.6.2",
|
||||
"dayjs": "^1.11.10",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.26",
|
||||
"vue-i18n": "^9.8.0",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.40.1",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/vue": "^8.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.18.1",
|
||||
"@typescript-eslint/parser": "^6.18.1",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vitest/ui": "^4.0.16",
|
||||
"@vue/test-utils": "^2.4.3",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-vue": "^9.19.2",
|
||||
"jsdom": "^27.4.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.1.1",
|
||||
"sass": "^1.69.5",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.16",
|
||||
"vue-tsc": "^3.2.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: [
|
||||
['html'],
|
||||
['json', { outputFile: 'test-results/results.json' }]
|
||||
],
|
||||
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL || 'http://localhost:5173',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
actionTimeout: 10000,
|
||||
navigationTimeout: 30000,
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<a-config-provider :locale="antLocale">
|
||||
<router-view />
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import zhCN from 'ant-design-vue/es/locale/zh_CN';
|
||||
import enUS from 'ant-design-vue/es/locale/en_US';
|
||||
|
||||
const { locale } = useI18n();
|
||||
|
||||
const antLocale = computed(() => {
|
||||
return locale.value === 'zh-CN' ? zhCN : enUS;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
status: string;
|
||||
createBy: string;
|
||||
updateBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export interface SysLoginResponse {
|
||||
token: string;
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
status: string;
|
||||
createBy: string;
|
||||
updateBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
login: (data: LoginRequest) => {
|
||||
return request.post<SysLoginResponse>('/sys/auth/login', data);
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
return request.post('/sys/auth/logout');
|
||||
},
|
||||
|
||||
register: (data: RegisterRequest) => {
|
||||
return request.post('/sys/auth/register', data);
|
||||
},
|
||||
|
||||
refreshToken: (token: string) => {
|
||||
return request.post<SysLoginResponse>(`/sys/auth/refresh/${token}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './auth';
|
||||
export * from './menu';
|
||||
export * from './role';
|
||||
export * from './user';
|
||||
@@ -0,0 +1,85 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
export interface Menu {
|
||||
id?: number;
|
||||
parentId?: number;
|
||||
name: string;
|
||||
code: string;
|
||||
type?: 'MENU' | 'BUTTON';
|
||||
path?: string;
|
||||
component?: string;
|
||||
icon?: string;
|
||||
sort?: number;
|
||||
sortOrder?: number;
|
||||
status?: string;
|
||||
description?: string;
|
||||
children?: Menu[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface MenuTree extends Menu {
|
||||
children?: MenuTree[];
|
||||
}
|
||||
|
||||
export interface MenuQueryParams {
|
||||
name?: string;
|
||||
code?: string;
|
||||
type?: 'MENU' | 'BUTTON';
|
||||
status?: 'ENABLED' | 'DISABLED';
|
||||
current?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface MenuPageResponse {
|
||||
records: Menu[];
|
||||
total: number;
|
||||
current: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export const menuApi = {
|
||||
getMenus: (params?: MenuQueryParams) => {
|
||||
return request.get<MenuPageResponse>('/sys/menu', { params });
|
||||
},
|
||||
|
||||
getMenuTree: () => {
|
||||
return request.get<MenuTree[]>('/sys/menu/tree');
|
||||
},
|
||||
|
||||
getMenuById: (id: number) => {
|
||||
return request.get<Menu>(`/sys/menu/${id}`);
|
||||
},
|
||||
|
||||
createMenu: (data: Menu) => {
|
||||
return request.post<Menu>('/sys/menu', data);
|
||||
},
|
||||
|
||||
updateMenu: (id: number, data: Menu) => {
|
||||
return request.put<Menu>(`/sys/menu/${id}`, data);
|
||||
},
|
||||
|
||||
deleteMenu: (id: number) => {
|
||||
return request.delete(`/sys/menu/${id}`);
|
||||
},
|
||||
|
||||
getMenusByRole: (roleId: number) => {
|
||||
return request.get<Menu[]>(`/sys/role/${roleId}/menus`);
|
||||
},
|
||||
|
||||
assignMenusToRole: (roleId: number, menuIds: number[]) => {
|
||||
return request.post(`/sys/role/${roleId}/menus`, { menuIds });
|
||||
},
|
||||
|
||||
assignMenuToRole: (data: { roleId: number; menuIds: number[] }) => {
|
||||
return request.post('/sys/menu/assign', data);
|
||||
},
|
||||
|
||||
getMenusByUserId: (userId: number) => {
|
||||
return request.get<Menu[]>(`/sys/menu/user/${userId}`);
|
||||
},
|
||||
|
||||
removeMenuFromRole: (roleId: number, menuId: number) => {
|
||||
return request.delete(`/sys/menu/role/${roleId}/menu/${menuId}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import request from '@/utils/request';
|
||||
import type { OperationLog, OperationLogQueryParams, PageResult, Result } from '@/types';
|
||||
|
||||
export const operationLogApi = {
|
||||
query: (params: OperationLogQueryParams) => {
|
||||
return request.post<Result<PageResult<OperationLog>>>('/sys/operationLog/query', params);
|
||||
},
|
||||
|
||||
getById: (id: number) => {
|
||||
return request.get<Result<OperationLog>>(`/sys/operationLog/${id}`);
|
||||
},
|
||||
|
||||
delete: (id: number) => {
|
||||
return request.delete<Result<void>>(`/sys/operationLog/${id}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
export interface Role {
|
||||
id?: number;
|
||||
roleName: string;
|
||||
roleCode: string;
|
||||
description?: string;
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
export interface RoleQuery {
|
||||
roleName?: string;
|
||||
roleCode?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface RoleResponse {
|
||||
code: string;
|
||||
message: string;
|
||||
data: Role[];
|
||||
}
|
||||
|
||||
export interface RoleDetailResponse {
|
||||
code: string;
|
||||
message: string;
|
||||
data: Role;
|
||||
}
|
||||
|
||||
export const roleApi = {
|
||||
getRoles: (params?: RoleQuery) => {
|
||||
return request.get<RoleResponse>('/sys/role', { params });
|
||||
},
|
||||
|
||||
getRoleById: (id: number) => {
|
||||
return request.get<RoleDetailResponse>(`/sys/role/${id}`);
|
||||
},
|
||||
|
||||
createRole: (data: Role) => {
|
||||
return request.post<RoleDetailResponse>('/sys/role', data);
|
||||
},
|
||||
|
||||
updateRole: (data: Role) => {
|
||||
return request.put<RoleDetailResponse>('/sys/role', data);
|
||||
},
|
||||
|
||||
deleteRole: (id: number) => {
|
||||
return request.delete<RoleDetailResponse>(`/sys/role/${id}`);
|
||||
},
|
||||
|
||||
getRoleByRoleKey: (roleKey: string) => {
|
||||
return request.get<RoleDetailResponse>(`/sys/role/roleKey/${roleKey}`);
|
||||
},
|
||||
|
||||
assignRoleToUser: (data: { userId: number; roleIds: number[] }) => {
|
||||
return request.post('/sys/role/assign', data);
|
||||
},
|
||||
|
||||
getUserRoles: (userId: number) => {
|
||||
return request.get<RoleResponse>(`/sys/role/user/${userId}`);
|
||||
},
|
||||
|
||||
removeUserRole: (userId: number, roleId: number) => {
|
||||
return request.delete(`/sys/role/user/${userId}/role/${roleId}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface Role {
|
||||
id?: number
|
||||
roleName: string
|
||||
roleCode: string
|
||||
description?: string
|
||||
createTime?: string
|
||||
updateTime?: string
|
||||
}
|
||||
|
||||
export interface RoleQuery {
|
||||
roleName?: string
|
||||
roleCode?: string
|
||||
page?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
export interface RoleResponse {
|
||||
code: string
|
||||
message: string
|
||||
data: Role[]
|
||||
}
|
||||
|
||||
export interface RoleDetailResponse {
|
||||
code: string
|
||||
message: string
|
||||
data: Role
|
||||
}
|
||||
|
||||
export const getRoles = (params?: RoleQuery) => {
|
||||
return request.get<Role[]>('/sys/role', { params })
|
||||
}
|
||||
|
||||
export const getRoleById = (id: number) => {
|
||||
return request.get<Role>(`/sys/role/${id}`)
|
||||
}
|
||||
|
||||
export const createRole = (data: Role) => {
|
||||
return request.post<Role>('/sys/role', data)
|
||||
}
|
||||
|
||||
export const updateRole = (data: Role) => {
|
||||
return request.put<Role>('/sys/role', data)
|
||||
}
|
||||
|
||||
export const deleteRole = (id: number) => {
|
||||
return request.delete<boolean>(`/sys/role/${id}`)
|
||||
}
|
||||
|
||||
export const getRoleByRoleKey = (roleKey: string) => {
|
||||
return request.get<Role>(`/sys/role/roleKey/${roleKey}`)
|
||||
}
|
||||
|
||||
export const assignRoleToUser = (userId: number, roleIds: number[]) => {
|
||||
return request.post('/sys/role/assign', { userId, roleIds })
|
||||
}
|
||||
|
||||
export const getUserRoles = (userId: number) => {
|
||||
return request.get<Role[]>(`/sys/role/user/${userId}`)
|
||||
}
|
||||
|
||||
export const removeUserRole = (userId: number, roleId: number) => {
|
||||
return request.delete(`/sys/role/user/${userId}/role/${roleId}`)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
export interface User {
|
||||
id?: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
status?: 'ENABLED' | 'DISABLED';
|
||||
createBy?: string;
|
||||
updateBy?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface UserQueryParams {
|
||||
username?: string;
|
||||
email?: string;
|
||||
status?: 'ENABLED' | 'DISABLED';
|
||||
current?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface UserPageResponse {
|
||||
records: User[];
|
||||
total: number;
|
||||
current: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export const userApi = {
|
||||
getUsers: (params?: UserQueryParams) => {
|
||||
return request.get<UserPageResponse>('/sys/user', { params });
|
||||
},
|
||||
|
||||
getUserById: (id: number) => {
|
||||
return request.get<User>(`/sys/user/${id}`);
|
||||
},
|
||||
|
||||
getUserByUsername: (username: string) => {
|
||||
return request.get<User>(`/sys/user/username/${username}`);
|
||||
},
|
||||
|
||||
createUser: (data: User) => {
|
||||
return request.post<User>('/sys/user', data);
|
||||
},
|
||||
|
||||
updateUser: (id: number, data: User) => {
|
||||
return request.put<User>(`/sys/user/${id}`, data);
|
||||
},
|
||||
|
||||
deleteUser: (id: number) => {
|
||||
return request.delete(`/sys/user/${id}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<a-layout-header class="header">
|
||||
<div class="header-left">
|
||||
<a-button
|
||||
type="text"
|
||||
class="collapse-btn"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<template #icon>
|
||||
<MenuUnfoldOutlined v-if="collapsed" />
|
||||
<MenuFoldOutlined v-else />
|
||||
</template>
|
||||
</a-button>
|
||||
|
||||
<a-breadcrumb class="breadcrumb">
|
||||
<a-breadcrumb-item>
|
||||
<HomeOutlined />
|
||||
{{ t('layout.home') }}
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item v-for="item in breadcrumbItems" :key="item.path">
|
||||
{{ item.title }}
|
||||
</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<a-space :size="16">
|
||||
<a-button
|
||||
type="text"
|
||||
class="action-btn"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<template #icon>
|
||||
<SunOutlined v-if="!isDarkMode" />
|
||||
<MoonOutlined v-else />
|
||||
</template>
|
||||
</a-button>
|
||||
|
||||
<a-button
|
||||
type="text"
|
||||
class="action-btn"
|
||||
@click="toggleLanguage"
|
||||
>
|
||||
<template #icon>
|
||||
<GlobalOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
|
||||
<a-badge :count="notificationCount" :offset="[-5, 5]">
|
||||
<a-button type="text" class="action-btn">
|
||||
<template #icon>
|
||||
<BellOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-badge>
|
||||
|
||||
<a-dropdown placement="bottomRight">
|
||||
<div class="user-info">
|
||||
<a-avatar :size="32" class="user-avatar">
|
||||
<template #icon>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-avatar>
|
||||
<span class="user-name">{{ user?.username || t('layout.userCenter') }}</span>
|
||||
<DownOutlined class="dropdown-icon" />
|
||||
</div>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item key="profile" @click="handleProfile">
|
||||
<UserOutlined />
|
||||
{{ t('layout.userCenter') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="settings" @click="handleSettings">
|
||||
<SettingOutlined />
|
||||
{{ t('menu.settings') }}
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="logout" @click="handleLogout">
|
||||
<LogoutOutlined />
|
||||
{{ t('layout.logout') }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAppStore } from '@/stores/app.store';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import {
|
||||
MenuUnfoldOutlined,
|
||||
MenuFoldOutlined,
|
||||
HomeOutlined,
|
||||
BellOutlined,
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
LogoutOutlined,
|
||||
DownOutlined,
|
||||
SunOutlined,
|
||||
MoonOutlined,
|
||||
GlobalOutlined
|
||||
} from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleSidebar: [];
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { t, locale } = useI18n();
|
||||
const appStore = useAppStore();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const collapsed = computed(() => appStore.sidebarCollapsed);
|
||||
const isDarkMode = computed(() => appStore.isDarkMode);
|
||||
const user = computed(() => authStore.user);
|
||||
const notificationCount = ref(3);
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
const routeMap: Record<string, { title: string; path: string }> = {
|
||||
'/dashboard': { title: t('menu.dashboard'), path: '/dashboard' },
|
||||
'/users': { title: t('menu.userManagement'), path: '/users' },
|
||||
'/roles': { title: t('menu.roleManagement'), path: '/roles' },
|
||||
'/menus': { title: t('menu.menuManagement'), path: '/menus' },
|
||||
'/permissions': { title: t('menu.permissionManagement'), path: '/permissions' },
|
||||
'/charts': { title: t('menu.chartDisplay'), path: '/charts' },
|
||||
'/reports': { title: t('menu.reportStatistics'), path: '/reports' }
|
||||
};
|
||||
|
||||
const currentRoute = routeMap[route.path];
|
||||
return currentRoute ? [currentRoute] : [];
|
||||
});
|
||||
|
||||
function toggleSidebar() {
|
||||
emit('toggleSidebar');
|
||||
appStore.toggleSidebar();
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const newTheme = isDarkMode.value ? 'light' : 'dark';
|
||||
appStore.setTheme(newTheme);
|
||||
message.success(newTheme === 'dark' ? t('settings.darkTheme') : t('settings.lightTheme'));
|
||||
}
|
||||
|
||||
function toggleLanguage() {
|
||||
const newLocale = locale.value === 'zh-CN' ? 'en-US' : 'zh-CN';
|
||||
locale.value = newLocale;
|
||||
localStorage.setItem('locale', newLocale);
|
||||
message.success(newLocale === 'zh-CN' ? '已切换到中文' : 'Switched to English');
|
||||
}
|
||||
|
||||
function handleProfile() {
|
||||
router.push('/profile');
|
||||
}
|
||||
|
||||
function handleSettings() {
|
||||
router.push('/settings');
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await authStore.logout();
|
||||
message.success(t('auth.logoutSuccess'));
|
||||
router.push('/login');
|
||||
} catch (error) {
|
||||
message.error(t('auth.logoutFailed'));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
font-size: 18px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.collapse-btn:hover {
|
||||
color: #1890ff;
|
||||
background: rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
font-size: 18px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
color: #1890ff;
|
||||
background: rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.user-info:hover {
|
||||
background: rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
background: #1890ff;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
:deep(.ant-breadcrumb-link) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
:deep(.ant-breadcrumb-link:hover) {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
:deep(.ant-breadcrumb-separator) {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,289 @@
|
||||
<template>
|
||||
<a-layout-sider
|
||||
v-model:collapsed="collapsed"
|
||||
:trigger="null"
|
||||
collapsible
|
||||
:width="240"
|
||||
:collapsed-width="64"
|
||||
class="sidebar"
|
||||
:class="{ 'sidebar-collapsed': collapsed }"
|
||||
>
|
||||
<div class="sidebar-logo">
|
||||
<template v-if="!collapsed">
|
||||
<span class="logo-text">管理系统</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="logo-icon">M</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<a-menu
|
||||
v-model:selectedKeys="selectedKeys"
|
||||
v-model:openKeys="openKeys"
|
||||
:mode="mode"
|
||||
:theme="theme"
|
||||
:inline-collapsed="collapsed"
|
||||
:items="menuItems"
|
||||
class="sidebar-menu"
|
||||
@select="handleMenuSelect"
|
||||
/>
|
||||
</a-layout-sider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, h, onMounted } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { useAppStore } from "@/stores/app.store";
|
||||
import { useAuthStore } from "@/stores/auth.store";
|
||||
import type { ItemType } from "ant-design-vue";
|
||||
import {
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
LockOutlined,
|
||||
MenuOutlined,
|
||||
SettingOutlined,
|
||||
TeamOutlined,
|
||||
SafetyOutlined,
|
||||
ApartmentOutlined,
|
||||
FileTextOutlined,
|
||||
BarChartOutlined,
|
||||
PieChartOutlined,
|
||||
LineChartOutlined,
|
||||
RightOutlined,
|
||||
} from "@ant-design/icons-vue";
|
||||
|
||||
interface Props {
|
||||
mode?: "vertical" | "inline";
|
||||
theme?: "light" | "dark";
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
mode: "inline",
|
||||
theme: "dark",
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
menuSelect: [key: string];
|
||||
}>();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const collapsed = computed(() => appStore.sidebarCollapsed);
|
||||
const selectedKeys = ref<string[]>([]);
|
||||
const openKeys = ref<string[]>([]);
|
||||
|
||||
const iconMap: Record<string, any> = {
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
LockOutlined,
|
||||
MenuOutlined,
|
||||
SettingOutlined,
|
||||
TeamOutlined,
|
||||
SafetyOutlined,
|
||||
ApartmentOutlined,
|
||||
FileTextOutlined,
|
||||
BarChartOutlined,
|
||||
PieChartOutlined,
|
||||
LineChartOutlined,
|
||||
RightOutlined,
|
||||
};
|
||||
|
||||
function getIconComponent(iconName: string) {
|
||||
return iconMap[iconName] || MenuOutlined;
|
||||
}
|
||||
|
||||
function hasPermission(permission: string | undefined): boolean {
|
||||
if (!permission) return true;
|
||||
const userPermissions = authStore.user?.permissions || [];
|
||||
return userPermissions.includes(permission);
|
||||
}
|
||||
|
||||
function convertRouteToMenuItem(route: any): ItemType | null {
|
||||
if (route.meta?.hidden) return null;
|
||||
|
||||
if (route.meta?.permission && !hasPermission(route.meta.permission)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item: any = {
|
||||
key: route.name as string,
|
||||
label: route.meta?.title || route.name,
|
||||
icon: route.meta?.icon ? h(getIconComponent(route.meta.icon)) : undefined,
|
||||
};
|
||||
|
||||
if (route.children && route.children.length > 0) {
|
||||
const children = route.children
|
||||
.map((child: any) => convertRouteToMenuItem(child))
|
||||
.filter((child: ItemType | null) => child !== null);
|
||||
|
||||
if (children.length > 0) {
|
||||
item.children = children;
|
||||
}
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
const menuItems = computed<ItemType[]>(() => {
|
||||
const routes = router.getRoutes();
|
||||
const mainLayout = routes.find((r) => r.path === "/");
|
||||
|
||||
if (!mainLayout || !mainLayout.children) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const menuRoutes = mainLayout.children.filter((r) => !r.meta?.hidden);
|
||||
|
||||
const items = menuRoutes
|
||||
.map((route) => convertRouteToMenuItem(route))
|
||||
.filter((item): item is ItemType => item !== null);
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
function handleMenuSelect({ key }: { key: string }) {
|
||||
emit("menuSelect", key);
|
||||
|
||||
const targetRoute = router.getRoutes().find((r) => r.name === key);
|
||||
if (targetRoute) {
|
||||
router.push(targetRoute.path);
|
||||
}
|
||||
}
|
||||
|
||||
function updateSelectedKeys() {
|
||||
const currentRoute = router.getRoutes().find((r) => r.path === route.path);
|
||||
|
||||
if (currentRoute) {
|
||||
selectedKeys.value = [currentRoute.name as string];
|
||||
|
||||
if (currentRoute.path !== "/" && currentRoute.path !== "/dashboard") {
|
||||
const parentRoute = router
|
||||
.getRoutes()
|
||||
.find(
|
||||
(r) =>
|
||||
r.children &&
|
||||
r.children.some((child: any) => child.path === currentRoute.path)
|
||||
);
|
||||
|
||||
if (parentRoute) {
|
||||
if (!openKeys.value.includes(parentRoute.name as string)) {
|
||||
openKeys.value = [...openKeys.value, parentRoute.name as string];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
updateSelectedKeys();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
updateSelectedKeys();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
background: #001529;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-collapsed {
|
||||
width: 64px !important;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
margin: 16px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
border-right: none;
|
||||
height: calc(100vh - 96px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.sidebar-menu::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.sidebar-menu::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.sidebar-menu::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
:deep(.ant-menu-item) {
|
||||
margin: 4px 8px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
:deep(.ant-menu-submenu) {
|
||||
margin: 4px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
:deep(.ant-menu-item-selected) {
|
||||
background: #1890ff !important;
|
||||
}
|
||||
|
||||
:deep(.ant-menu-item:hover) {
|
||||
background: rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
|
||||
:deep(.ant-menu-submenu-title:hover) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
:deep(.ant-menu-submenu-open > .ant-menu-submenu-title) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
:deep(.ant-menu-item-icon) {
|
||||
font-size: 16px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
:deep(.ant-menu-submenu-arrow) {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
:deep(
|
||||
.ant-menu-submenu-open > .ant-menu-submenu-title .ant-menu-submenu-arrow
|
||||
) {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import zhCN from './locales/zh-CN';
|
||||
import enUS from './locales/en-US';
|
||||
|
||||
const messages = {
|
||||
'zh-CN': zhCN,
|
||||
'en-US': enUS
|
||||
};
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: localStorage.getItem('locale') || 'zh-CN',
|
||||
fallbackLocale: 'zh-CN',
|
||||
messages
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,267 @@
|
||||
export default {
|
||||
common: {
|
||||
confirm: 'Confirm',
|
||||
cancel: 'Cancel',
|
||||
save: 'Save',
|
||||
delete: 'Delete',
|
||||
edit: 'Edit',
|
||||
add: 'Add',
|
||||
search: 'Search',
|
||||
reset: 'Reset',
|
||||
view: 'View',
|
||||
submit: 'Submit',
|
||||
close: 'Close',
|
||||
back: 'Back',
|
||||
loading: 'Loading...',
|
||||
success: 'Operation successful',
|
||||
error: 'Operation failed',
|
||||
warning: 'Warning',
|
||||
info: 'Info',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
status: 'Status',
|
||||
action: 'Action',
|
||||
createTime: 'Create Time',
|
||||
updateTime: 'Update Time',
|
||||
creator: 'Creator',
|
||||
updater: 'Updater',
|
||||
remark: 'Remark',
|
||||
description: 'Description',
|
||||
name: 'Name',
|
||||
code: 'Code',
|
||||
sort: 'Sort',
|
||||
total: 'Total {count} items',
|
||||
pleaseSelect: 'Please select',
|
||||
pleaseInput: 'Please input',
|
||||
required: 'Required',
|
||||
optional: 'Optional'
|
||||
},
|
||||
auth: {
|
||||
login: 'Login',
|
||||
logout: 'Logout',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
rememberMe: 'Remember me',
|
||||
forgotPassword: 'Forgot password',
|
||||
loginSuccess: 'Login successful',
|
||||
loginFailed: 'Login failed',
|
||||
logoutSuccess: 'Logout successful',
|
||||
logoutFailed: 'Logout failed',
|
||||
usernamePlaceholder: 'Please enter username',
|
||||
passwordPlaceholder: 'Please enter password',
|
||||
usernameRequired: 'Please enter username',
|
||||
passwordRequired: 'Please enter password',
|
||||
invalidCredentials: 'Invalid username or password',
|
||||
tokenExpired: 'Session expired, please login again',
|
||||
notLoggedIn: 'Please login first',
|
||||
permissionDenied: 'You do not have permission to access this page'
|
||||
},
|
||||
menu: {
|
||||
dashboard: 'Dashboard',
|
||||
systemManagement: 'System Management',
|
||||
userManagement: 'User Management',
|
||||
roleManagement: 'Role Management',
|
||||
menuManagement: 'Menu Management',
|
||||
permissionManagement: 'Permission Management',
|
||||
dataAnalysis: 'Data Analysis',
|
||||
chartDisplay: 'Chart Display',
|
||||
reportStatistics: 'Report Statistics',
|
||||
settings: 'Settings',
|
||||
profile: 'Profile'
|
||||
},
|
||||
user: {
|
||||
userList: 'User List',
|
||||
addUser: 'Add User',
|
||||
editUser: 'Edit User',
|
||||
deleteUser: 'Delete User',
|
||||
viewUser: 'View User',
|
||||
userInfo: 'User Info',
|
||||
username: 'Username',
|
||||
email: 'Email',
|
||||
phone: 'Phone',
|
||||
avatar: 'Avatar',
|
||||
roles: 'Roles',
|
||||
assignRoles: 'Assign Roles',
|
||||
deleteConfirm: 'Are you sure you want to delete this user?',
|
||||
deleteSuccess: 'User deleted successfully',
|
||||
deleteFailed: 'Failed to delete user',
|
||||
addSuccess: 'User added successfully',
|
||||
addFailed: 'Failed to add user',
|
||||
updateSuccess: 'User updated successfully',
|
||||
updateFailed: 'Failed to update user',
|
||||
fetchSuccess: 'User list fetched successfully',
|
||||
fetchFailed: 'Failed to fetch user list',
|
||||
status: 'Status',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled'
|
||||
},
|
||||
role: {
|
||||
roleList: 'Role List',
|
||||
addRole: 'Add Role',
|
||||
editRole: 'Edit Role',
|
||||
deleteRole: 'Delete Role',
|
||||
viewRole: 'View Role',
|
||||
roleInfo: 'Role Info',
|
||||
roleName: 'Role Name',
|
||||
roleKey: 'Role Key',
|
||||
roleDescription: 'Role Description',
|
||||
assignPermissions: 'Assign Permissions',
|
||||
deleteConfirm: 'Are you sure you want to delete this role?',
|
||||
deleteSuccess: 'Role deleted successfully',
|
||||
deleteFailed: 'Failed to delete role',
|
||||
addSuccess: 'Role added successfully',
|
||||
addFailed: 'Failed to add role',
|
||||
updateSuccess: 'Role updated successfully',
|
||||
updateFailed: 'Failed to update role',
|
||||
fetchSuccess: 'Role list fetched successfully',
|
||||
fetchFailed: 'Failed to fetch role list',
|
||||
status: 'Status',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled'
|
||||
},
|
||||
menuManagement: {
|
||||
menuList: 'Menu List',
|
||||
addMenu: 'Add Menu',
|
||||
editMenu: 'Edit Menu',
|
||||
deleteMenu: 'Delete Menu',
|
||||
viewMenu: 'View Menu',
|
||||
menuInfo: 'Menu Info',
|
||||
menuName: 'Menu Name',
|
||||
menuCode: 'Menu Code',
|
||||
menuPath: 'Menu Path',
|
||||
menuIcon: 'Menu Icon',
|
||||
parentMenu: 'Parent Menu',
|
||||
sortOrder: 'Sort Order',
|
||||
deleteConfirm: 'Are you sure you want to delete this menu?',
|
||||
deleteSuccess: 'Menu deleted successfully',
|
||||
deleteFailed: 'Failed to delete menu',
|
||||
addSuccess: 'Menu added successfully',
|
||||
addFailed: 'Failed to add menu',
|
||||
updateSuccess: 'Menu updated successfully',
|
||||
updateFailed: 'Failed to update menu',
|
||||
fetchSuccess: 'Menu list fetched successfully',
|
||||
fetchFailed: 'Failed to fetch menu list',
|
||||
status: 'Status',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
addChildMenu: 'Add Child Menu'
|
||||
},
|
||||
permission: {
|
||||
permissionList: 'Permission List',
|
||||
assignPermissions: 'Assign Permissions',
|
||||
savePermissions: 'Save Permissions',
|
||||
permissionInfo: 'Permission Info',
|
||||
roleName: 'Role Name',
|
||||
roleList: 'Role List',
|
||||
permissionAssignment: 'Permission Assignment',
|
||||
selectRole: 'Please select role',
|
||||
saveSuccess: 'Permissions saved successfully',
|
||||
saveFailed: 'Failed to save permissions',
|
||||
fetchSuccess: 'Permission list fetched successfully',
|
||||
fetchFailed: 'Failed to fetch permission list',
|
||||
selectRoleHint: 'Please select a role to view and assign permissions',
|
||||
permissionHint: 'Check menu items to assign permissions. Child menus will automatically inherit parent menu permissions'
|
||||
},
|
||||
dashboard: {
|
||||
welcome: 'Welcome back',
|
||||
overview: 'Overview',
|
||||
statistics: 'Statistics',
|
||||
recentUsers: 'Recent Users',
|
||||
systemStatus: 'System Status',
|
||||
totalUsers: 'Total Users',
|
||||
totalRoles: 'Total Roles',
|
||||
totalMenus: 'Total Menus',
|
||||
activeUsers: 'Active Users',
|
||||
systemHealth: 'System Health',
|
||||
cpuUsage: 'CPU Usage',
|
||||
memoryUsage: 'Memory Usage',
|
||||
diskUsage: 'Disk Usage',
|
||||
networkTraffic: 'Network Traffic',
|
||||
lastLoginTime: 'Last Login Time',
|
||||
lastLoginIp: 'Last Login IP',
|
||||
accountStatus: 'Account Status',
|
||||
createTime: 'Create Time'
|
||||
},
|
||||
settings: {
|
||||
systemSettings: 'System Settings',
|
||||
basicSettings: 'Basic Settings',
|
||||
securitySettings: 'Security Settings',
|
||||
notificationSettings: 'Notification Settings',
|
||||
languageSettings: 'Language Settings',
|
||||
themeSettings: 'Theme Settings',
|
||||
systemName: 'System Name',
|
||||
systemLogo: 'System Logo',
|
||||
systemDescription: 'System Description',
|
||||
changePassword: 'Change Password',
|
||||
oldPassword: 'Old Password',
|
||||
newPassword: 'New Password',
|
||||
confirmPassword: 'Confirm Password',
|
||||
passwordNotMatch: 'Passwords do not match',
|
||||
passwordChanged: 'Password changed successfully',
|
||||
passwordChangeFailed: 'Failed to change password',
|
||||
enableNotification: 'Enable Notification',
|
||||
notificationEmail: 'Notification Email',
|
||||
notificationPhone: 'Notification Phone',
|
||||
language: 'Language',
|
||||
theme: 'Theme',
|
||||
lightTheme: 'Light Theme',
|
||||
darkTheme: 'Dark Theme',
|
||||
autoTheme: 'Auto Theme',
|
||||
saveSuccess: 'Settings saved successfully',
|
||||
saveFailed: 'Failed to save settings'
|
||||
},
|
||||
layout: {
|
||||
collapseSidebar: 'Collapse Sidebar',
|
||||
expandSidebar: 'Expand Sidebar',
|
||||
toggleTheme: 'Toggle Theme',
|
||||
notifications: 'Notifications',
|
||||
userCenter: 'User Center',
|
||||
logout: 'Logout',
|
||||
home: 'Home',
|
||||
searchPlaceholder: 'Search...'
|
||||
},
|
||||
validation: {
|
||||
required: '{field} is required',
|
||||
minLength: '{field} must be at least {min} characters',
|
||||
maxLength: '{field} must not exceed {max} characters',
|
||||
pattern: '{field} format is incorrect',
|
||||
email: 'Please enter a valid email address',
|
||||
phone: 'Please enter a valid phone number',
|
||||
url: 'Please enter a valid URL',
|
||||
number: 'Please enter a valid number',
|
||||
integer: 'Please enter a valid integer',
|
||||
positive: 'Please enter a positive number',
|
||||
range: '{field} must be between {min} and {max}'
|
||||
},
|
||||
error: {
|
||||
networkError: 'Network error, please check your connection',
|
||||
serverError: 'Server error, please try again later',
|
||||
requestTimeout: 'Request timeout, please try again later',
|
||||
unknownError: 'Unknown error',
|
||||
notFound: 'Requested resource not found',
|
||||
forbidden: 'You do not have permission to access this resource',
|
||||
unauthorized: 'Unauthorized, please login first'
|
||||
},
|
||||
message: {
|
||||
operationSuccess: 'Operation successful',
|
||||
operationFailed: 'Operation failed',
|
||||
saveSuccess: 'Saved successfully',
|
||||
saveFailed: 'Failed to save',
|
||||
deleteSuccess: 'Deleted successfully',
|
||||
deleteFailed: 'Failed to delete',
|
||||
updateSuccess: 'Updated successfully',
|
||||
updateFailed: 'Failed to update',
|
||||
fetchSuccess: 'Data fetched successfully',
|
||||
fetchFailed: 'Failed to fetch data',
|
||||
uploadSuccess: 'Uploaded successfully',
|
||||
uploadFailed: 'Failed to upload',
|
||||
downloadSuccess: 'Downloaded successfully',
|
||||
downloadFailed: 'Failed to download',
|
||||
importSuccess: 'Imported successfully',
|
||||
importFailed: 'Failed to import',
|
||||
exportSuccess: 'Exported successfully',
|
||||
exportFailed: 'Failed to export'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,267 @@
|
||||
export default {
|
||||
common: {
|
||||
confirm: '确认',
|
||||
cancel: '取消',
|
||||
save: '保存',
|
||||
delete: '删除',
|
||||
edit: '编辑',
|
||||
add: '新增',
|
||||
search: '搜索',
|
||||
reset: '重置',
|
||||
view: '查看',
|
||||
submit: '提交',
|
||||
close: '关闭',
|
||||
back: '返回',
|
||||
loading: '加载中...',
|
||||
success: '操作成功',
|
||||
error: '操作失败',
|
||||
warning: '警告',
|
||||
info: '提示',
|
||||
yes: '是',
|
||||
no: '否',
|
||||
enable: '启用',
|
||||
disable: '禁用',
|
||||
status: '状态',
|
||||
action: '操作',
|
||||
createTime: '创建时间',
|
||||
updateTime: '更新时间',
|
||||
creator: '创建人',
|
||||
updater: '更新人',
|
||||
remark: '备注',
|
||||
description: '描述',
|
||||
name: '名称',
|
||||
code: '编码',
|
||||
sort: '排序',
|
||||
total: '共 {count} 条',
|
||||
pleaseSelect: '请选择',
|
||||
pleaseInput: '请输入',
|
||||
required: '必填项',
|
||||
optional: '选填项'
|
||||
},
|
||||
auth: {
|
||||
login: '登录',
|
||||
logout: '退出登录',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
rememberMe: '记住我',
|
||||
forgotPassword: '忘记密码',
|
||||
loginSuccess: '登录成功',
|
||||
loginFailed: '登录失败',
|
||||
logoutSuccess: '退出登录成功',
|
||||
logoutFailed: '退出登录失败',
|
||||
usernamePlaceholder: '请输入用户名',
|
||||
passwordPlaceholder: '请输入密码',
|
||||
usernameRequired: '请输入用户名',
|
||||
passwordRequired: '请输入密码',
|
||||
invalidCredentials: '用户名或密码错误',
|
||||
tokenExpired: '登录已过期,请重新登录',
|
||||
notLoggedIn: '请先登录',
|
||||
permissionDenied: '您没有权限访问该页面'
|
||||
},
|
||||
menu: {
|
||||
dashboard: '仪表盘',
|
||||
systemManagement: '系统管理',
|
||||
userManagement: '用户管理',
|
||||
roleManagement: '角色管理',
|
||||
menuManagement: '菜单管理',
|
||||
permissionManagement: '权限管理',
|
||||
dataAnalysis: '数据分析',
|
||||
chartDisplay: '图表展示',
|
||||
reportStatistics: '报表统计',
|
||||
settings: '设置',
|
||||
profile: '个人中心'
|
||||
},
|
||||
user: {
|
||||
userList: '用户列表',
|
||||
addUser: '新增用户',
|
||||
editUser: '编辑用户',
|
||||
deleteUser: '删除用户',
|
||||
viewUser: '查看用户',
|
||||
userInfo: '用户信息',
|
||||
username: '用户名',
|
||||
email: '邮箱',
|
||||
phone: '手机号',
|
||||
avatar: '头像',
|
||||
roles: '角色',
|
||||
assignRoles: '分配角色',
|
||||
deleteConfirm: '确定要删除该用户吗?',
|
||||
deleteSuccess: '删除用户成功',
|
||||
deleteFailed: '删除用户失败',
|
||||
addSuccess: '新增用户成功',
|
||||
addFailed: '新增用户失败',
|
||||
updateSuccess: '更新用户成功',
|
||||
updateFailed: '更新用户失败',
|
||||
fetchSuccess: '获取用户列表成功',
|
||||
fetchFailed: '获取用户列表失败',
|
||||
status: '状态',
|
||||
enabled: '启用',
|
||||
disabled: '禁用'
|
||||
},
|
||||
role: {
|
||||
roleList: '角色列表',
|
||||
addRole: '新增角色',
|
||||
editRole: '编辑角色',
|
||||
deleteRole: '删除角色',
|
||||
viewRole: '查看角色',
|
||||
roleInfo: '角色信息',
|
||||
roleName: '角色名称',
|
||||
roleKey: '角色标识',
|
||||
roleDescription: '角色描述',
|
||||
assignPermissions: '分配权限',
|
||||
deleteConfirm: '确定要删除该角色吗?',
|
||||
deleteSuccess: '删除角色成功',
|
||||
deleteFailed: '删除角色失败',
|
||||
addSuccess: '新增角色成功',
|
||||
addFailed: '新增角色失败',
|
||||
updateSuccess: '更新角色成功',
|
||||
updateFailed: '更新角色失败',
|
||||
fetchSuccess: '获取角色列表成功',
|
||||
fetchFailed: '获取角色列表失败',
|
||||
status: '状态',
|
||||
enabled: '启用',
|
||||
disabled: '禁用'
|
||||
},
|
||||
menuManagement: {
|
||||
menuList: '菜单列表',
|
||||
addMenu: '新增菜单',
|
||||
editMenu: '编辑菜单',
|
||||
deleteMenu: '删除菜单',
|
||||
viewMenu: '查看菜单',
|
||||
menuInfo: '菜单信息',
|
||||
menuName: '菜单名称',
|
||||
menuCode: '菜单标识',
|
||||
menuPath: '菜单路径',
|
||||
menuIcon: '菜单图标',
|
||||
parentMenu: '父菜单',
|
||||
sortOrder: '排序',
|
||||
deleteConfirm: '确定要删除该菜单吗?',
|
||||
deleteSuccess: '删除菜单成功',
|
||||
deleteFailed: '删除菜单失败',
|
||||
addSuccess: '新增菜单成功',
|
||||
addFailed: '新增菜单失败',
|
||||
updateSuccess: '更新菜单成功',
|
||||
updateFailed: '更新菜单失败',
|
||||
fetchSuccess: '获取菜单列表成功',
|
||||
fetchFailed: '获取菜单列表失败',
|
||||
status: '状态',
|
||||
enabled: '启用',
|
||||
disabled: '禁用',
|
||||
addChildMenu: '新增子菜单'
|
||||
},
|
||||
permission: {
|
||||
permissionList: '权限列表',
|
||||
assignPermissions: '分配权限',
|
||||
savePermissions: '保存权限',
|
||||
permissionInfo: '权限信息',
|
||||
roleName: '角色名称',
|
||||
roleList: '角色列表',
|
||||
permissionAssignment: '权限分配',
|
||||
selectRole: '请选择角色',
|
||||
saveSuccess: '保存权限成功',
|
||||
saveFailed: '保存权限失败',
|
||||
fetchSuccess: '获取权限列表成功',
|
||||
fetchFailed: '获取权限列表失败',
|
||||
selectRoleHint: '请选择角色以查看和分配权限',
|
||||
permissionHint: '勾选菜单项即为分配该权限,子菜单会自动继承父菜单的权限'
|
||||
},
|
||||
dashboard: {
|
||||
welcome: '欢迎回来',
|
||||
overview: '概览',
|
||||
statistics: '统计',
|
||||
recentUsers: '最近用户',
|
||||
systemStatus: '系统状态',
|
||||
totalUsers: '总用户数',
|
||||
totalRoles: '总角色数',
|
||||
totalMenus: '总菜单数',
|
||||
activeUsers: '活跃用户',
|
||||
systemHealth: '系统健康度',
|
||||
cpuUsage: 'CPU 使用率',
|
||||
memoryUsage: '内存使用率',
|
||||
diskUsage: '磁盘使用率',
|
||||
networkTraffic: '网络流量',
|
||||
lastLoginTime: '最后登录时间',
|
||||
lastLoginIp: '最后登录IP',
|
||||
accountStatus: '账号状态',
|
||||
createTime: '创建时间'
|
||||
},
|
||||
settings: {
|
||||
systemSettings: '系统设置',
|
||||
basicSettings: '基本设置',
|
||||
securitySettings: '安全设置',
|
||||
notificationSettings: '通知设置',
|
||||
languageSettings: '语言设置',
|
||||
themeSettings: '主题设置',
|
||||
systemName: '系统名称',
|
||||
systemLogo: '系统Logo',
|
||||
systemDescription: '系统描述',
|
||||
changePassword: '修改密码',
|
||||
oldPassword: '旧密码',
|
||||
newPassword: '新密码',
|
||||
confirmPassword: '确认密码',
|
||||
passwordNotMatch: '两次输入的密码不一致',
|
||||
passwordChanged: '密码修改成功',
|
||||
passwordChangeFailed: '密码修改失败',
|
||||
enableNotification: '启用通知',
|
||||
notificationEmail: '通知邮箱',
|
||||
notificationPhone: '通知手机号',
|
||||
language: '语言',
|
||||
theme: '主题',
|
||||
lightTheme: '浅色主题',
|
||||
darkTheme: '深色主题',
|
||||
autoTheme: '自动主题',
|
||||
saveSuccess: '保存设置成功',
|
||||
saveFailed: '保存设置失败'
|
||||
},
|
||||
layout: {
|
||||
collapseSidebar: '收起侧边栏',
|
||||
expandSidebar: '展开侧边栏',
|
||||
toggleTheme: '切换主题',
|
||||
notifications: '通知',
|
||||
userCenter: '个人中心',
|
||||
logout: '退出登录',
|
||||
home: '首页',
|
||||
searchPlaceholder: '搜索...'
|
||||
},
|
||||
validation: {
|
||||
required: '{field}不能为空',
|
||||
minLength: '{field}长度不能少于{min}个字符',
|
||||
maxLength: '{field}长度不能超过{max}个字符',
|
||||
pattern: '{field}格式不正确',
|
||||
email: '请输入正确的邮箱地址',
|
||||
phone: '请输入正确的手机号码',
|
||||
url: '请输入正确的URL地址',
|
||||
number: '请输入有效的数字',
|
||||
integer: '请输入有效的整数',
|
||||
positive: '请输入正数',
|
||||
range: '{field}必须在{min}到{max}之间'
|
||||
},
|
||||
error: {
|
||||
networkError: '网络错误,请检查网络连接',
|
||||
serverError: '服务器错误,请稍后重试',
|
||||
requestTimeout: '请求超时,请稍后重试',
|
||||
unknownError: '未知错误',
|
||||
notFound: '请求的资源不存在',
|
||||
forbidden: '没有权限访问该资源',
|
||||
unauthorized: '未授权,请先登录'
|
||||
},
|
||||
message: {
|
||||
operationSuccess: '操作成功',
|
||||
operationFailed: '操作失败',
|
||||
saveSuccess: '保存成功',
|
||||
saveFailed: '保存失败',
|
||||
deleteSuccess: '删除成功',
|
||||
deleteFailed: '删除失败',
|
||||
updateSuccess: '更新成功',
|
||||
updateFailed: '更新失败',
|
||||
fetchSuccess: '获取数据成功',
|
||||
fetchFailed: '获取数据失败',
|
||||
uploadSuccess: '上传成功',
|
||||
uploadFailed: '上传失败',
|
||||
downloadSuccess: '下载成功',
|
||||
downloadFailed: '下载失败',
|
||||
importSuccess: '导入成功',
|
||||
importFailed: '导入失败',
|
||||
exportSuccess: '导出成功',
|
||||
exportFailed: '导出失败'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<a-layout class="layout">
|
||||
<a-layout-sider
|
||||
v-model:collapsed="collapsed"
|
||||
:trigger="null"
|
||||
collapsible
|
||||
:width="240"
|
||||
:collapsed-width="64"
|
||||
class="layout-sider"
|
||||
>
|
||||
<div class="logo">
|
||||
<template v-if="!collapsed">
|
||||
<span>管理系统</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="logo-icon">M</span>
|
||||
</template>
|
||||
</div>
|
||||
<a-menu
|
||||
v-model:selectedKeys="selectedKeys"
|
||||
v-model:openKeys="openKeys"
|
||||
mode="inline"
|
||||
theme="dark"
|
||||
:inline-collapsed="collapsed"
|
||||
>
|
||||
<a-menu-item key="dashboard" @click="handleMenuClick('/dashboard')">
|
||||
<template #icon>
|
||||
<DashboardOutlined />
|
||||
</template>
|
||||
<span>仪表盘</span>
|
||||
</a-menu-item>
|
||||
<a-sub-menu key="system">
|
||||
<template #icon>
|
||||
<SettingOutlined />
|
||||
</template>
|
||||
<template #title>系统管理</template>
|
||||
<a-menu-item key="users" @click="handleMenuClick('/users')">
|
||||
<template #icon>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
<span>用户管理</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="roles" @click="handleMenuClick('/roles')">
|
||||
<template #icon>
|
||||
<LockOutlined />
|
||||
</template>
|
||||
<span>角色管理</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="menus" @click="handleMenuClick('/menus')">
|
||||
<template #icon>
|
||||
<MenuOutlined />
|
||||
</template>
|
||||
<span>菜单管理</span>
|
||||
</a-menu-item>
|
||||
</a-sub-menu>
|
||||
</a-menu>
|
||||
</a-layout-sider>
|
||||
<a-layout>
|
||||
<a-layout-header class="header">
|
||||
<div class="header-left">
|
||||
<MenuUnfoldOutlined
|
||||
v-if="collapsed"
|
||||
class="trigger"
|
||||
@click="toggleSidebar"
|
||||
/>
|
||||
<MenuFoldOutlined
|
||||
v-else
|
||||
class="trigger"
|
||||
@click="toggleSidebar"
|
||||
/>
|
||||
<a-breadcrumb class="breadcrumb">
|
||||
<a-breadcrumb-item>首页</a-breadcrumb-item>
|
||||
<a-breadcrumb-item v-for="item in breadcrumbItems" :key="item">
|
||||
{{ item }}
|
||||
</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<a-space>
|
||||
<a-button type="text" :icon="h(BellOutlined)" />
|
||||
<a-dropdown>
|
||||
<a class="ant-dropdown-link" @click.prevent>
|
||||
<UserOutlined />
|
||||
<span class="user-name">{{ user?.username || '用户' }}</span>
|
||||
<DownOutlined />
|
||||
</a>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item key="profile">
|
||||
<UserOutlined />
|
||||
个人中心
|
||||
</a-menu-item>
|
||||
<a-menu-item key="settings">
|
||||
<SettingOutlined />
|
||||
设置
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="logout" @click="handleLogout">
|
||||
<LogoutOutlined />
|
||||
退出登录
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
<a-layout-content class="content">
|
||||
<div class="content-wrapper">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useAuthStore, useAppStore } from '../stores'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
SettingOutlined,
|
||||
UserOutlined,
|
||||
LockOutlined,
|
||||
MenuOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
MenuFoldOutlined,
|
||||
DownOutlined,
|
||||
LogoutOutlined,
|
||||
BellOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const collapsed = computed(() => appStore.sidebarCollapsed)
|
||||
const selectedKeys = ref<string[]>(['dashboard'])
|
||||
const openKeys = ref<string[]>(['system'])
|
||||
const user = computed(() => authStore.user)
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
const pathMap: Record<string, string> = {
|
||||
'/dashboard': '仪表盘',
|
||||
'/users': '用户管理',
|
||||
'/roles': '角色管理',
|
||||
'/menus': '菜单管理'
|
||||
}
|
||||
const items = []
|
||||
const currentPath = route.path
|
||||
if (pathMap[currentPath]) {
|
||||
items.push(pathMap[currentPath])
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
function toggleSidebar() {
|
||||
appStore.toggleSidebar()
|
||||
}
|
||||
|
||||
function handleMenuClick(path: string) {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await authStore.logout()
|
||||
message.success('退出登录成功')
|
||||
router.push('/login')
|
||||
} catch (error) {
|
||||
message.error('退出登录失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.layout {
|
||||
min-height: 100vh;
|
||||
|
||||
.logo {
|
||||
height: 32px;
|
||||
margin: 16px;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: white;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.header-left {
|
||||
.trigger {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
.ant-dropdown-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: 16px;
|
||||
padding: 24px;
|
||||
background: white;
|
||||
min-height: 280px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import Antd from 'ant-design-vue'
|
||||
import 'ant-design-vue/dist/reset.css'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import './styles/index.scss'
|
||||
import i18n from './i18n'
|
||||
import { PerformanceMonitor } from './utils/performance'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 初始化性能监控
|
||||
const perfMonitor = PerformanceMonitor.getInstance();
|
||||
perfMonitor.init();
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(Antd)
|
||||
app.use(i18n)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
// 页面加载完成后记录性能数据
|
||||
window.addEventListener('load', () => {
|
||||
// 可以在这里发送性能数据到监控服务
|
||||
setTimeout(() => {
|
||||
const metrics = perfMonitor.getMetrics();
|
||||
console.log('应用性能指标:', metrics);
|
||||
|
||||
// 如果需要,可以发送到监控服务
|
||||
// sendPerformanceMetrics(metrics);
|
||||
}, 0);
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
# Mock API 服务系统
|
||||
|
||||
基于 OpenAPI 规范生成模拟响应数据,用于前端开发过程中的数据模拟。
|
||||
|
||||
## 概述
|
||||
|
||||
Mock API 服务系统旨在为前端开发提供一个完整的模拟后端环境,使前端开发可以独立于后端进行,提高开发效率。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 1. Mock 数据生成器
|
||||
|
||||
- `generateMockUser()`: 生成符合规范的用户模拟数据
|
||||
- `generateMockRole()`: 生成符合规范的角色模拟数据
|
||||
- `generateMockMenu()`: 生成符合规范的菜单模拟数据
|
||||
- `generatePageResult()`: 生成分页结果
|
||||
- `successResponse()`: 生成成功响应
|
||||
- `errorResponse()`: 生成错误响应
|
||||
- `randomDelay()`: 生成随机网络延迟
|
||||
|
||||
### 2. Mock 服务器
|
||||
|
||||
- 模拟完整的 CRUD 操作
|
||||
- 支持所有主要的 API 端点
|
||||
- 模拟认证流程
|
||||
- 支持查询参数和分页
|
||||
|
||||
### 3. Mock 拦截器
|
||||
|
||||
- 自动拦截 API 请求
|
||||
- 在开发环境中自动启用
|
||||
- 可随时切换到真实 API
|
||||
|
||||
## API 端点支持
|
||||
|
||||
### 用户管理
|
||||
|
||||
- `GET /sys/user` - 获取用户列表
|
||||
- `POST /sys/user` - 创建用户
|
||||
- `PUT /sys/user` - 更新用户
|
||||
- `GET /sys/user/{id}` - 获取用户详情
|
||||
- `DELETE /sys/user/{id}` - 删除用户
|
||||
- `GET /sys/user/username/{username}` - 根据用户名获取用户
|
||||
|
||||
### 角色管理
|
||||
|
||||
- `GET /sys/role` - 获取角色列表
|
||||
- `POST /sys/role` - 创建角色
|
||||
- `PUT /sys/role` - 更新角色
|
||||
- `GET /sys/role/{id}` - 获取角色详情
|
||||
- `DELETE /sys/role/{id}` - 删除角色
|
||||
- `GET /sys/role/roleKey/{roleKey}` - 根据角色键获取角色
|
||||
- `GET /sys/role/user/{userId}` - 获取用户的角色
|
||||
|
||||
### 菜单管理
|
||||
|
||||
- `GET /sys/menu` - 获取菜单列表
|
||||
- `POST /sys/menu` - 创建菜单
|
||||
- `PUT /sys/menu` - 更新菜单
|
||||
- `GET /sys/menu/{id}` - 获取菜单详情
|
||||
- `DELETE /sys/menu/{id}` - 删除菜单
|
||||
- `GET /sys/menu/user/{userId}` - 获取用户的菜单
|
||||
|
||||
### 认证
|
||||
|
||||
- `POST /sys/auth/login` - 用户登录
|
||||
- `POST /sys/auth/logout` - 用户登出
|
||||
- `POST /sys/auth/refresh/{token}` - 刷新令牌
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 自动启用
|
||||
|
||||
在开发环境中,Mock 系统会自动启用,无需任何配置。
|
||||
|
||||
### 手动控制
|
||||
|
||||
可以通过环境变量控制 Mock 系统:
|
||||
|
||||
```bash
|
||||
# 启用 Mock(开发环境默认)
|
||||
VITE_MOCK_ENABLED=true
|
||||
|
||||
# 禁用 Mock
|
||||
VITE_MOCK_ENABLED=false
|
||||
```
|
||||
|
||||
### 模拟数据定制
|
||||
|
||||
如果需要特定的模拟数据,可以直接使用 Mock 数据生成器:
|
||||
|
||||
```typescript
|
||||
import { generateMockUser, successResponse } from "@/mocks/mock-data";
|
||||
|
||||
// 生成特定用户
|
||||
const user = generateMockUser(123);
|
||||
|
||||
// 生成成功响应
|
||||
const response = successResponse(user, "User retrieved successfully");
|
||||
```
|
||||
|
||||
## 测试覆盖
|
||||
|
||||
Mock 系统已全面测试,覆盖以下场景:
|
||||
|
||||
1. **正常请求** - 所有 API 端点的正常操作
|
||||
2. **异常处理** - 错误响应、无效输入等
|
||||
3. **边界情况** - 空结果、大页码、特殊参数等
|
||||
4. **数据一致性** - 响应格式与真实 API 保持一致
|
||||
|
||||
## 无缝切换
|
||||
|
||||
当后端 API 准备就绪时,只需:
|
||||
|
||||
1. 设置 `VITE_MOCK_ENABLED=false`
|
||||
2. 确保后端服务运行在正确的地址
|
||||
3. 前端代码无需任何修改即可连接真实 API
|
||||
|
||||
## 开发注意事项
|
||||
|
||||
- Mock 数据遵循与真实 API 相同的数据结构
|
||||
- 响应格式与真实 API 完全一致
|
||||
- HTTP 状态码模拟真实行为
|
||||
- 网络延迟模拟真实用户体验
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Mock API服务系统
|
||||
* 基于OpenAPI规范生成模拟响应数据
|
||||
* 用于前端开发过程中的数据模拟
|
||||
*/
|
||||
|
||||
export * from './mock-data';
|
||||
export * from './mock-server';
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Mock数据生成器
|
||||
* 基于API文档定义生成符合规范的模拟数据
|
||||
*/
|
||||
|
||||
import type { Result, User, Role, Menu, OperationLog } from '@/types';
|
||||
|
||||
// 生成模拟用户数据
|
||||
export const generateMockUser = (id?: number): User => {
|
||||
const userId = id || Math.floor(Math.random() * 10000) + 1;
|
||||
return {
|
||||
id: userId,
|
||||
username: `user${userId}`,
|
||||
email: `user${userId}@example.com`,
|
||||
phone: `138${Math.random().toString().substring(2, 11).padEnd(11, '0').substring(0, 9)}`,
|
||||
nickname: `昵称${userId}`,
|
||||
status: Math.random() > 0.5 ? 'ENABLED' : 'DISABLED',
|
||||
remark: `用户${userId}的备注`,
|
||||
createBy: 'admin',
|
||||
updateBy: 'admin',
|
||||
createdAt: new Date(Date.now() - Math.random() * 10000000000).toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
permissions: ['user:view', 'user:edit'],
|
||||
roles: []
|
||||
};
|
||||
};
|
||||
|
||||
// 生成模拟角色数据
|
||||
export const generateMockRole = (id?: number): Role => {
|
||||
const roleId = id || Math.floor(Math.random() * 1000) + 1;
|
||||
return {
|
||||
id: roleId,
|
||||
name: `角色${roleId}`,
|
||||
roleKey: `role${roleId}`,
|
||||
description: `角色${roleId}的描述`,
|
||||
status: Math.random() > 0.5 ? 'ENABLED' : 'DISABLED',
|
||||
sortOrder: Math.floor(Math.random() * 100),
|
||||
remark: `角色${roleId}的备注`,
|
||||
menuIds: [],
|
||||
createdAt: new Date(Date.now() - Math.random() * 10000000000).toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
};
|
||||
|
||||
// 生成模拟菜单数据
|
||||
export const generateMockMenu = (id?: number, parentId?: number): Menu => {
|
||||
const menuId = id || Math.floor(Math.random() * 1000) + 1;
|
||||
return {
|
||||
id: menuId,
|
||||
name: `菜单${menuId}`,
|
||||
code: `menu${menuId}`,
|
||||
path: `/menu${menuId}`,
|
||||
icon: 'MenuOutlined',
|
||||
parentId: parentId || 0,
|
||||
sortOrder: Math.floor(Math.random() * 100),
|
||||
status: Math.random() > 0.5 ? 'ENABLED' : 'DISABLED',
|
||||
component: `views/Menu${menuId}.vue`,
|
||||
redirect: '',
|
||||
description: `菜单${menuId}的描述`,
|
||||
children: [],
|
||||
createdAt: new Date(Date.now() - Math.random() * 10000000000).toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
};
|
||||
|
||||
// 生成模拟操作日志数据
|
||||
export const generateMockOperationLog = (id?: number): OperationLog => {
|
||||
const logId = id || Math.floor(Math.random() * 10000) + 1;
|
||||
const modules = ['用户管理', '角色管理', '菜单管理', '权限管理', '操作日志', '系统设置'];
|
||||
const operations = ['查询', '新增', '修改', '删除', '导出', '导入', '登录', '登出'];
|
||||
const methods = ['GET', 'POST', 'PUT', 'DELETE'];
|
||||
const paths = ['/sys/user/query', '/sys/user/create', '/sys/user/update', '/sys/user/delete', '/sys/role/query', '/sys/role/create', '/sys/menu/query', '/sys/operationLog/query'];
|
||||
const statuses = ['success', 'error'];
|
||||
const status = statuses[Math.floor(Math.random() * statuses.length)];
|
||||
const isError = status === 'error';
|
||||
|
||||
return {
|
||||
id: logId,
|
||||
userId: Math.floor(Math.random() * 10) + 1,
|
||||
username: `user${Math.floor(Math.random() * 10) + 1}`,
|
||||
module: modules[Math.floor(Math.random() * modules.length)],
|
||||
operation: operations[Math.floor(Math.random() * operations.length)],
|
||||
method: methods[Math.floor(Math.random() * methods.length)],
|
||||
path: paths[Math.floor(Math.random() * paths.length)],
|
||||
params: JSON.stringify({
|
||||
page: Math.floor(Math.random() * 10) + 1,
|
||||
pageSize: 10,
|
||||
username: `user${Math.floor(Math.random() * 10) + 1}`
|
||||
}),
|
||||
ip: `192.168.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`,
|
||||
status,
|
||||
errorMsg: isError ? '操作失败:权限不足或参数错误' : undefined,
|
||||
duration: Math.floor(Math.random() * 1000) + 10,
|
||||
createdAt: new Date(Date.now() - Math.random() * 10000000000).toISOString()
|
||||
};
|
||||
};
|
||||
|
||||
// 生成分页结果
|
||||
export const generatePageResult = <T>(data: T[], current: number = 1, pageSize: number = 10) => {
|
||||
const start = (current - 1) * pageSize;
|
||||
const end = start + pageSize;
|
||||
const records = data.slice(start, end);
|
||||
|
||||
return {
|
||||
records,
|
||||
total: data.length,
|
||||
current,
|
||||
size: pageSize
|
||||
};
|
||||
};
|
||||
|
||||
// 模拟成功响应
|
||||
export const successResponse = <T>(data: T, message: string = 'Success'): Result<T> => {
|
||||
return {
|
||||
code: '200',
|
||||
message,
|
||||
data
|
||||
};
|
||||
};
|
||||
|
||||
// 模拟错误响应
|
||||
export const errorResponse = <T = any>(code: string = '500', message: string = 'Error'): Result<T> => {
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
data: null as unknown as T
|
||||
};
|
||||
};
|
||||
|
||||
// 生成随机延迟
|
||||
export const randomDelay = (min: number = 200, max: number = 800) => {
|
||||
const delay = Math.random() * (max - min) + min;
|
||||
return new Promise(resolve => setTimeout(resolve, delay));
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Mock拦截器
|
||||
* 用于拦截API请求并返回Mock数据
|
||||
* 可以在开发环境中启用或禁用
|
||||
*/
|
||||
|
||||
import { mockServer } from './mock-server';
|
||||
|
||||
// 存储原始的axios实例方法
|
||||
let originalGet: any;
|
||||
let originalPost: any;
|
||||
let originalPut: any;
|
||||
let originalDelete: any;
|
||||
|
||||
// 检查是否启用Mock模式
|
||||
const isMockEnabled = () => {
|
||||
const env = import.meta.env.MODE;
|
||||
// development-local 和 test 环境使用 Mock,development 和 prod 环境使用真实API
|
||||
const useMock = env === 'development-local' || env === 'test';
|
||||
const forceMock = import.meta.env.VITE_MOCK_ENABLED === 'true';
|
||||
|
||||
return useMock || forceMock;
|
||||
};
|
||||
|
||||
// 拦截请求方法
|
||||
const mockRequest = async (method: string, url: string, data?: any, config?: any) => {
|
||||
if (!isMockEnabled()) {
|
||||
// 如果未启用Mock模式,调用原始方法
|
||||
throw new Error('Mock not enabled');
|
||||
}
|
||||
|
||||
// 提取URL参数
|
||||
const urlParams = new URLSearchParams(config?.params);
|
||||
const params = Object.fromEntries(urlParams.entries());
|
||||
|
||||
// 处理请求并返回axios响应格式
|
||||
const mockData = await mockServer.handleRequest(url, method, data, params);
|
||||
return {
|
||||
data: mockData,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
config: {}
|
||||
};
|
||||
};
|
||||
|
||||
// 安装Mock拦截器
|
||||
export const installMockInterceptor = (requestInstance: any) => {
|
||||
if (!isMockEnabled()) {
|
||||
console.log('Mock is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Installing mock interceptor...');
|
||||
|
||||
// 保存原始方法
|
||||
originalGet = requestInstance.get;
|
||||
originalPost = requestInstance.post;
|
||||
originalPut = requestInstance.put;
|
||||
originalDelete = requestInstance.delete;
|
||||
|
||||
// 替换为Mock方法
|
||||
requestInstance.get = async (url: string, config?: any) => {
|
||||
try {
|
||||
return await mockRequest('GET', url, undefined, config);
|
||||
} catch (error) {
|
||||
// 如果Mock处理失败,调用原始方法
|
||||
return originalGet.call(requestInstance, url, config);
|
||||
}
|
||||
};
|
||||
|
||||
requestInstance.post = async (url: string, data?: any, config?: any) => {
|
||||
try {
|
||||
return await mockRequest('POST', url, data, config);
|
||||
} catch (error) {
|
||||
// 如果Mock处理失败,调用原始方法
|
||||
return originalPost.call(requestInstance, url, data, config);
|
||||
}
|
||||
};
|
||||
|
||||
requestInstance.put = async (url: string, data?: any, config?: any) => {
|
||||
try {
|
||||
return await mockRequest('PUT', url, data, config);
|
||||
} catch (error) {
|
||||
// 如果Mock处理失败,调用原始方法
|
||||
return originalPut.call(requestInstance, url, data, config);
|
||||
}
|
||||
};
|
||||
|
||||
requestInstance.delete = async (url: string, config?: any) => {
|
||||
try {
|
||||
return await mockRequest('DELETE', url, undefined, config);
|
||||
} catch (error) {
|
||||
// 如果Mock处理失败,调用原始方法
|
||||
return originalDelete.call(requestInstance, url, config);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// 卸载Mock拦截器
|
||||
export const uninstallMockInterceptor = (requestInstance: any) => {
|
||||
if (originalGet) {
|
||||
requestInstance.get = originalGet;
|
||||
}
|
||||
if (originalPost) {
|
||||
requestInstance.post = originalPost;
|
||||
}
|
||||
if (originalPut) {
|
||||
requestInstance.put = originalPut;
|
||||
}
|
||||
if (originalDelete) {
|
||||
requestInstance.delete = originalDelete;
|
||||
}
|
||||
};
|
||||
|
||||
// 切换Mock模式
|
||||
export const toggleMock = (requestInstance: any, enabled: boolean) => {
|
||||
if (enabled) {
|
||||
installMockInterceptor(requestInstance);
|
||||
} else {
|
||||
uninstallMockInterceptor(requestInstance);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,605 @@
|
||||
/**
|
||||
* Mock服务器
|
||||
* 拦截API请求并返回模拟数据
|
||||
* 支持正常请求、异常处理和边界情况
|
||||
*/
|
||||
|
||||
import { generateMockUser, generateMockRole, generateMockMenu, generateMockOperationLog, successResponse, errorResponse, randomDelay, generatePageResult } from './mock-data';
|
||||
import type { User, Role, Menu, LoginResponse, OperationLog } from '@/types';
|
||||
|
||||
// 模拟数据存储
|
||||
class MockDB {
|
||||
private users: User[] = [];
|
||||
private roles: Role[] = [];
|
||||
private menus: Menu[] = [];
|
||||
private operationLogs: OperationLog[] = [];
|
||||
private tokens: Map<string, { userId: number; exp: number }> = new Map();
|
||||
|
||||
constructor() {
|
||||
// 初始化一些默认数据
|
||||
this.initDefaultData();
|
||||
}
|
||||
|
||||
private initDefaultData() {
|
||||
// 创建默认用户
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
this.users.push(generateMockUser(i));
|
||||
}
|
||||
|
||||
// 创建默认角色
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
this.roles.push(generateMockRole(i));
|
||||
}
|
||||
|
||||
// 创建默认菜单 - 包含dashboard菜单
|
||||
this.menus.push({
|
||||
id: 1,
|
||||
name: 'Dashboard',
|
||||
code: 'dashboard',
|
||||
path: '/dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
parentId: 0,
|
||||
sortOrder: 1,
|
||||
status: 'ENABLED',
|
||||
component: 'views/dashboard.vue',
|
||||
redirect: '',
|
||||
description: 'Dashboard页面',
|
||||
children: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 创建其他默认菜单
|
||||
for (let i = 2; i <= 8; i++) {
|
||||
this.menus.push(generateMockMenu(i, i > 2 ? Math.floor(i / 2) : 0));
|
||||
}
|
||||
|
||||
// 创建默认操作日志
|
||||
for (let i = 1; i <= 50; i++) {
|
||||
this.operationLogs.push(generateMockOperationLog(i));
|
||||
}
|
||||
}
|
||||
|
||||
// 用户相关操作
|
||||
getUsers(page?: number, size?: number, username?: string, email?: string, status?: string) {
|
||||
let result = [...this.users];
|
||||
|
||||
// 应用过滤条件
|
||||
if (username) {
|
||||
result = result.filter(user => user.username.includes(username));
|
||||
}
|
||||
if (email) {
|
||||
result = result.filter(user => user.email.includes(email));
|
||||
}
|
||||
if (status) {
|
||||
result = result.filter(user => user.status === status);
|
||||
}
|
||||
|
||||
// 应用分页
|
||||
const currentPage = page || 1;
|
||||
const pageSize = size || 10;
|
||||
|
||||
return generatePageResult(result, currentPage, pageSize);
|
||||
}
|
||||
|
||||
getUserById(id: number) {
|
||||
return this.users.find(user => user.id === id);
|
||||
}
|
||||
|
||||
getUserByUsername(username: string) {
|
||||
return this.users.find(user => user.username === username);
|
||||
}
|
||||
|
||||
createUser(user: Omit<User, 'id'>) {
|
||||
const newUser = {
|
||||
...user,
|
||||
id: Math.max(...this.users.map(u => u.id || 0), 0) + 1,
|
||||
createBy: 'admin',
|
||||
updateBy: 'admin',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
permissions: user.permissions || [],
|
||||
roles: user.roles || []
|
||||
} as User;
|
||||
|
||||
this.users.push(newUser);
|
||||
return newUser;
|
||||
}
|
||||
|
||||
updateUser(id: number, userData: Partial<User>) {
|
||||
const index = this.users.findIndex(user => user.id === id);
|
||||
if (index !== -1) {
|
||||
this.users[index] = {
|
||||
...this.users[index],
|
||||
...userData,
|
||||
updateBy: 'admin',
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
return this.users[index];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
deleteUser(id: number) {
|
||||
const index = this.users.findIndex(user => user.id === id);
|
||||
if (index !== -1) {
|
||||
this.users.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 角色相关操作
|
||||
getRoles() {
|
||||
return [...this.roles];
|
||||
}
|
||||
|
||||
getRoleById(id: number) {
|
||||
return this.roles.find(role => role.id === id);
|
||||
}
|
||||
|
||||
createRole(role: Omit<Role, 'id'>) {
|
||||
const newRole = {
|
||||
...role,
|
||||
id: Math.max(...this.roles.map(r => r.id || 0), 0) + 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
} as Role;
|
||||
|
||||
this.roles.push(newRole);
|
||||
return newRole;
|
||||
}
|
||||
|
||||
updateRole(id: number, roleData: Partial<Role>) {
|
||||
const index = this.roles.findIndex(role => role.id === id);
|
||||
if (index !== -1) {
|
||||
this.roles[index] = {
|
||||
...this.roles[index],
|
||||
...roleData,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
return this.roles[index];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
deleteRole(id: number) {
|
||||
const index = this.roles.findIndex(role => role.id === id);
|
||||
if (index !== -1) {
|
||||
this.roles.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 菜单相关操作
|
||||
getMenus() {
|
||||
return [...this.menus];
|
||||
}
|
||||
|
||||
getMenuById(id: number) {
|
||||
return this.menus.find(menu => menu.id === id);
|
||||
}
|
||||
|
||||
createMenu(menu: Omit<Menu, 'id'>) {
|
||||
const newMenu = {
|
||||
...menu,
|
||||
id: Math.max(...this.menus.map(m => m.id || 0), 0) + 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
} as Menu;
|
||||
|
||||
this.menus.push(newMenu);
|
||||
return newMenu;
|
||||
}
|
||||
|
||||
updateMenu(id: number, menuData: Partial<Menu>) {
|
||||
const index = this.menus.findIndex(menu => menu.id === id);
|
||||
if (index !== -1) {
|
||||
this.menus[index] = {
|
||||
...this.menus[index],
|
||||
...menuData,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
return this.menus[index];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
deleteMenu(id: number) {
|
||||
const index = this.menus.findIndex(menu => menu.id === id);
|
||||
if (index !== -1) {
|
||||
this.menus.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 认证相关操作
|
||||
login(username: string, password: string) {
|
||||
let user = this.getUserByUsername(username);
|
||||
|
||||
if (!user) {
|
||||
user = generateMockUser();
|
||||
user.username = username;
|
||||
this.users.push(user);
|
||||
}
|
||||
|
||||
if (username && password) {
|
||||
const token = `mock_token_${Date.now()}_${Math.random().toString(36).substr(2)}`;
|
||||
const refreshToken = `mock_refresh_token_${Date.now()}_${Math.random().toString(36).substr(2)}`;
|
||||
|
||||
this.tokens.set(token, { userId: user.id!, exp: Date.now() + 3600000 });
|
||||
|
||||
const userWithPermissions = {
|
||||
...user,
|
||||
permissions: user.permissions || ['user:view', 'user:edit', 'dashboard:view']
|
||||
};
|
||||
|
||||
return {
|
||||
token,
|
||||
refreshToken,
|
||||
user: userWithPermissions
|
||||
} as LoginResponse;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
logout(token: string) {
|
||||
this.tokens.delete(token);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 获取菜单通过用户ID
|
||||
getMenusByUserId(userId: number) {
|
||||
// 模拟返回用户可访问的菜单
|
||||
return this.menus.filter(menu => menu.status === 'ENABLED');
|
||||
}
|
||||
|
||||
// 获取角色通过用户ID
|
||||
getRolesByUserId(userId: number) {
|
||||
// 模拟返回用户的角色
|
||||
return this.roles.slice(0, 2); // 返回前两个角色作为示例
|
||||
}
|
||||
|
||||
// 操作日志相关操作
|
||||
getOperationLogs(page?: number, size?: number, username?: string, module?: string, operation?: string, status?: string, startTime?: string, endTime?: string) {
|
||||
let result = [...this.operationLogs];
|
||||
|
||||
// 应用过滤条件
|
||||
if (username) {
|
||||
result = result.filter(log => log.username.includes(username));
|
||||
}
|
||||
if (module) {
|
||||
result = result.filter(log => log.module.includes(module));
|
||||
}
|
||||
if (operation) {
|
||||
result = result.filter(log => log.operation.includes(operation));
|
||||
}
|
||||
if (status) {
|
||||
result = result.filter(log => log.status === status);
|
||||
}
|
||||
if (startTime) {
|
||||
const start = new Date(startTime).getTime();
|
||||
result = result.filter(log => new Date(log.createdAt).getTime() >= start);
|
||||
}
|
||||
if (endTime) {
|
||||
const end = new Date(endTime).getTime();
|
||||
result = result.filter(log => new Date(log.createdAt).getTime() <= end);
|
||||
}
|
||||
|
||||
// 按创建时间倒序排序
|
||||
result.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
// 应用分页
|
||||
const currentPage = page || 1;
|
||||
const pageSize = size || 10;
|
||||
|
||||
return generatePageResult(result, currentPage, pageSize);
|
||||
}
|
||||
|
||||
getOperationLogById(id: number) {
|
||||
return this.operationLogs.find(log => log.id === id);
|
||||
}
|
||||
|
||||
deleteOperationLog(id: number) {
|
||||
const index = this.operationLogs.findIndex(log => log.id === id);
|
||||
if (index !== -1) {
|
||||
this.operationLogs.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建Mock数据库实例
|
||||
const mockDB = new MockDB();
|
||||
|
||||
// Mock API路由处理器
|
||||
export class MockServer {
|
||||
private db = mockDB;
|
||||
|
||||
async handleRequest(url: string, method: string, data?: any, params?: any) {
|
||||
// 模拟网络延迟
|
||||
await randomDelay();
|
||||
|
||||
try {
|
||||
// 用户相关API
|
||||
if (url.startsWith('/sys/user')) {
|
||||
return this.handleUserRequest(url, method, data, params);
|
||||
}
|
||||
|
||||
// 角色相关API
|
||||
if (url.startsWith('/sys/role')) {
|
||||
return this.handleRoleRequest(url, method, data, params);
|
||||
}
|
||||
|
||||
// 菜单相关API
|
||||
if (url.startsWith('/sys/menu')) {
|
||||
return this.handleMenuRequest(url, method, data, params);
|
||||
}
|
||||
|
||||
// 操作日志相关API
|
||||
if (url.startsWith('/sys/operationLog')) {
|
||||
return this.handleOperationLogRequest(url, method, data, params);
|
||||
}
|
||||
|
||||
// 认证相关API
|
||||
if (url.startsWith('/sys/auth')) {
|
||||
return this.handleAuthRequest(url, method, data, params);
|
||||
}
|
||||
|
||||
// 未匹配的路由返回错误
|
||||
return errorResponse('404', 'API endpoint not found');
|
||||
} catch (error) {
|
||||
console.error('Mock server error:', error);
|
||||
return errorResponse('500', 'Internal server error');
|
||||
}
|
||||
}
|
||||
|
||||
private handleUserRequest(url: string, method: string, data?: any, params?: any) {
|
||||
const segments = url.split('/');
|
||||
const id = segments[3] ? parseInt(segments[3]) : null;
|
||||
const username = segments[4] === 'username' ? segments[5] : null;
|
||||
|
||||
switch (method.toUpperCase()) {
|
||||
case 'GET':
|
||||
if (username) {
|
||||
// GET /sys/user/username/{username}
|
||||
const user = this.db.getUserByUsername(username);
|
||||
return user ? successResponse(user) : errorResponse('404', 'User not found');
|
||||
} else if (id) {
|
||||
// GET /sys/user/{id}
|
||||
const user = this.db.getUserById(id);
|
||||
return user ? successResponse(user) : errorResponse('404', 'User not found');
|
||||
} else {
|
||||
// GET /sys/user
|
||||
const page = params?.current ? parseInt(params.current) : 1;
|
||||
const size = params?.size ? parseInt(params.size) : 10;
|
||||
const result = this.db.getUsers(page, size, params?.username, params?.email, params?.status);
|
||||
return successResponse(result);
|
||||
}
|
||||
|
||||
case 'POST':
|
||||
// POST /sys/user
|
||||
if (!id) {
|
||||
const user = this.db.createUser(data);
|
||||
return successResponse(user.id, 'User created successfully');
|
||||
} else {
|
||||
return errorResponse('400', 'Invalid request');
|
||||
}
|
||||
|
||||
case 'PUT':
|
||||
// PUT /sys/user
|
||||
if (id) {
|
||||
const user = this.db.updateUser(id, data);
|
||||
return user ? successResponse(user) : errorResponse('404', 'User not found');
|
||||
} else {
|
||||
return errorResponse('400', 'User ID is required');
|
||||
}
|
||||
|
||||
case 'DELETE':
|
||||
// DELETE /sys/user/{id}
|
||||
if (id) {
|
||||
const success = this.db.deleteUser(id);
|
||||
return success ? successResponse(true, 'User deleted successfully') : errorResponse('404', 'User not found');
|
||||
} else {
|
||||
return errorResponse('400', 'User ID is required');
|
||||
}
|
||||
|
||||
default:
|
||||
return errorResponse('405', 'Method not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
private handleRoleRequest(url: string, method: string, data?: any, params?: any) {
|
||||
const segments = url.split('/');
|
||||
const id = segments[3] ? parseInt(segments[3]) : null;
|
||||
const roleKey = segments[4] === 'roleKey' ? segments[5] : null;
|
||||
const userId = segments[4] === 'user' ? parseInt(segments[5]) : null;
|
||||
|
||||
switch (method.toUpperCase()) {
|
||||
case 'GET':
|
||||
if (roleKey) {
|
||||
// GET /sys/role/roleKey/{roleKey}
|
||||
const role = this.db.getRoles().find(r => r.roleKey === roleKey);
|
||||
return role ? successResponse(role) : errorResponse('404', 'Role not found');
|
||||
} else if (userId) {
|
||||
// GET /sys/role/user/{userId}
|
||||
const roles = this.db.getRolesByUserId(userId);
|
||||
return successResponse(roles);
|
||||
} else if (id) {
|
||||
// GET /sys/role/{id}
|
||||
const role = this.db.getRoleById(id);
|
||||
return role ? successResponse(role) : errorResponse('404', 'Role not found');
|
||||
} else {
|
||||
// GET /sys/role
|
||||
const roles = this.db.getRoles();
|
||||
return successResponse(roles);
|
||||
}
|
||||
|
||||
case 'POST':
|
||||
if (!id) {
|
||||
// POST /sys/role
|
||||
const role = this.db.createRole(data);
|
||||
return successResponse(role);
|
||||
} else {
|
||||
return errorResponse('400', 'Invalid request');
|
||||
}
|
||||
|
||||
case 'PUT':
|
||||
// PUT /sys/role
|
||||
if (id) {
|
||||
const role = this.db.updateRole(id, data);
|
||||
return role ? successResponse(role) : errorResponse('404', 'Role not found');
|
||||
} else {
|
||||
return errorResponse('400', 'Role ID is required');
|
||||
}
|
||||
|
||||
case 'DELETE':
|
||||
// DELETE /sys/role/{id}
|
||||
if (id) {
|
||||
const success = this.db.deleteRole(id);
|
||||
return success ? successResponse(true, 'Role deleted successfully') : errorResponse('404', 'Role not found');
|
||||
} else {
|
||||
return errorResponse('400', 'Role ID is required');
|
||||
}
|
||||
|
||||
default:
|
||||
return errorResponse('405', 'Method not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
private handleMenuRequest(url: string, method: string, data?: any, params?: any) {
|
||||
const segments = url.split('/');
|
||||
const id = segments[3] ? parseInt(segments[3]) : null;
|
||||
const userId = segments[4] === 'user' ? parseInt(segments[5]) : null;
|
||||
|
||||
switch (method.toUpperCase()) {
|
||||
case 'GET':
|
||||
if (userId) {
|
||||
// GET /sys/menu/user/{userId}
|
||||
const menus = this.db.getMenusByUserId(userId);
|
||||
return successResponse(menus);
|
||||
} else if (id) {
|
||||
// GET /sys/menu/{id}
|
||||
const menu = this.db.getMenuById(id);
|
||||
return menu ? successResponse(menu) : errorResponse('404', 'Menu not found');
|
||||
} else {
|
||||
// GET /sys/menu
|
||||
const menus = this.db.getMenus();
|
||||
return successResponse(menus);
|
||||
}
|
||||
|
||||
case 'POST':
|
||||
if (!id) {
|
||||
// POST /sys/menu
|
||||
const menu = this.db.createMenu(data);
|
||||
return successResponse(menu);
|
||||
} else {
|
||||
return errorResponse('400', 'Invalid request');
|
||||
}
|
||||
|
||||
case 'PUT':
|
||||
// PUT /sys/menu
|
||||
if (id) {
|
||||
const menu = this.db.updateMenu(id, data);
|
||||
return menu ? successResponse(menu) : errorResponse('404', 'Menu not found');
|
||||
} else {
|
||||
return errorResponse('400', 'Menu ID is required');
|
||||
}
|
||||
|
||||
case 'DELETE':
|
||||
// DELETE /sys/menu/{id}
|
||||
if (id) {
|
||||
const success = this.db.deleteMenu(id);
|
||||
return success ? successResponse(true, 'Menu deleted successfully') : errorResponse('404', 'Menu not found');
|
||||
} else {
|
||||
return errorResponse('400', 'Menu ID is required');
|
||||
}
|
||||
|
||||
default:
|
||||
return errorResponse('405', 'Method not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
private handleOperationLogRequest(url: string, method: string, data?: any, params?: any) {
|
||||
const segments = url.split('/');
|
||||
const id = segments[3] ? parseInt(segments[3]) : null;
|
||||
|
||||
switch (method.toUpperCase()) {
|
||||
case 'POST':
|
||||
if (segments[3] === 'query') {
|
||||
// POST /sys/operationLog/query
|
||||
const page = data?.current || data?.page || 1;
|
||||
const size = data?.size || data?.pageSize || 10;
|
||||
const result = this.db.getOperationLogs(
|
||||
page,
|
||||
size,
|
||||
data?.username,
|
||||
data?.module,
|
||||
data?.operation,
|
||||
data?.status,
|
||||
data?.startTime,
|
||||
data?.endTime
|
||||
);
|
||||
return successResponse(result);
|
||||
} else {
|
||||
return errorResponse('400', 'Invalid request');
|
||||
}
|
||||
|
||||
case 'GET':
|
||||
if (id) {
|
||||
// GET /sys/operationLog/{id}
|
||||
const log = this.db.getOperationLogById(id);
|
||||
return log ? successResponse(log) : errorResponse('404', 'Operation log not found');
|
||||
} else {
|
||||
return errorResponse('400', 'Log ID is required');
|
||||
}
|
||||
|
||||
case 'DELETE':
|
||||
if (id) {
|
||||
// DELETE /sys/operationLog/{id}
|
||||
const success = this.db.deleteOperationLog(id);
|
||||
return success ? successResponse(true, 'Operation log deleted successfully') : errorResponse('404', 'Operation log not found');
|
||||
} else {
|
||||
return errorResponse('400', 'Log ID is required');
|
||||
}
|
||||
|
||||
default:
|
||||
return errorResponse('405', 'Method not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
private handleAuthRequest(url: string, method: string, data?: any, params?: any) {
|
||||
const segments = url.split('/');
|
||||
const action = segments[3];
|
||||
|
||||
switch (method.toUpperCase()) {
|
||||
case 'POST':
|
||||
if (action === 'login') {
|
||||
// POST /sys/auth/login
|
||||
const result = this.db.login(data.username, data.password);
|
||||
return result ? successResponse(result) : errorResponse('401', 'Invalid username or password');
|
||||
} else if (action === 'logout') {
|
||||
// POST /sys/auth/logout
|
||||
// 这里简化处理,实际应该从请求头获取token
|
||||
return successResponse(null, 'Logged out successfully');
|
||||
} else if (action === 'refresh') {
|
||||
// POST /sys/auth/refresh/{token}
|
||||
return successResponse({
|
||||
token: `refreshed_token_${Date.now()}`,
|
||||
user: this.db.getUserById(1) // 返回默认用户
|
||||
});
|
||||
} else {
|
||||
return errorResponse('400', 'Invalid auth action');
|
||||
}
|
||||
|
||||
default:
|
||||
return errorResponse('405', 'Method not allowed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局Mock服务器实例
|
||||
export const mockServer = new MockServer();
|
||||
@@ -0,0 +1,219 @@
|
||||
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMenuStore } from '@/stores/menu.store'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { Menu } from '@/types'
|
||||
|
||||
const constantRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
meta: { requiresAuth: false, title: '登录', hidden: true }
|
||||
},
|
||||
{
|
||||
path: '/404',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/NotFound.vue'),
|
||||
meta: { title: '页面不存在', hidden: true }
|
||||
}
|
||||
]
|
||||
|
||||
const asyncRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/DefaultLayout.vue'),
|
||||
redirect: '/dashboard',
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/dashboard.vue'),
|
||||
meta: { title: '仪表盘', icon: 'DashboardOutlined', permission: 'dashboard:view' }
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'UserManagement',
|
||||
component: () => import('@/views/UserManagement.vue'),
|
||||
meta: { title: '用户管理', icon: 'UserOutlined', permission: 'user:view' }
|
||||
},
|
||||
{
|
||||
path: 'roles',
|
||||
name: 'RoleManagement',
|
||||
component: () => import('@/views/RoleManagement.vue'),
|
||||
meta: { title: '角色管理', icon: 'TeamOutlined', permission: 'role:view' }
|
||||
},
|
||||
{
|
||||
path: 'menus',
|
||||
name: 'MenuManagement',
|
||||
component: () => import('@/views/MenuManagement.vue'),
|
||||
meta: { title: '菜单管理', icon: 'MenuOutlined', permission: 'menu:view' }
|
||||
},
|
||||
{
|
||||
path: 'permissions',
|
||||
name: 'PermissionManagement',
|
||||
component: () => import('@/views/PermissionManagement.vue'),
|
||||
meta: { title: '权限管理', icon: 'SafetyOutlined', permission: 'permission:view' }
|
||||
},
|
||||
{
|
||||
path: 'operation-logs',
|
||||
name: 'OperationLogManagement',
|
||||
component: () => import('@/views/OperationLogManagement.vue'),
|
||||
meta: { title: '操作日志', icon: 'FileTextOutlined', permission: 'operationLog:view' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/404',
|
||||
meta: { hidden: true }
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: constantRoutes
|
||||
})
|
||||
|
||||
let isDynamicRouteLoaded = false
|
||||
|
||||
function hasPermission(permission: string | undefined, userPermissions: string[]): boolean {
|
||||
if (!permission) return true
|
||||
if (!userPermissions || userPermissions.length === 0) return false
|
||||
return userPermissions.includes(permission)
|
||||
}
|
||||
|
||||
function filterAsyncRoutes(routes: RouteRecordRaw[], permissions: string[]): RouteRecordRaw[] {
|
||||
const filteredRoutes: RouteRecordRaw[] = []
|
||||
|
||||
routes.forEach(route => {
|
||||
const routeCopy = { ...route }
|
||||
|
||||
if (route.meta?.permission) {
|
||||
if (hasPermission(route.meta.permission as string, permissions)) {
|
||||
if (routeCopy.children) {
|
||||
routeCopy.children = filterAsyncRoutes(routeCopy.children, permissions)
|
||||
}
|
||||
filteredRoutes.push(routeCopy)
|
||||
}
|
||||
} else {
|
||||
if (routeCopy.children) {
|
||||
routeCopy.children = filterAsyncRoutes(routeCopy.children, permissions)
|
||||
}
|
||||
filteredRoutes.push(routeCopy)
|
||||
}
|
||||
})
|
||||
|
||||
return filteredRoutes
|
||||
}
|
||||
|
||||
function convertMenusToRoutes(menus: Menu[]): RouteRecordRaw[] {
|
||||
const routes: RouteRecordRaw[] = []
|
||||
|
||||
menus.forEach(menu => {
|
||||
if (menu.status !== 'active' && menu.status !== 'ENABLED') return
|
||||
|
||||
const route: RouteRecordRaw = {
|
||||
path: menu.path,
|
||||
name: menu.code,
|
||||
component: menu.component ? () => import(`../views/${menu.component.replace('views/', '')}`) : undefined,
|
||||
meta: {
|
||||
title: menu.name,
|
||||
icon: menu.icon,
|
||||
permission: `menu:${menu.code}`,
|
||||
hidden: false
|
||||
}
|
||||
}
|
||||
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
const childRoutes = convertMenusToRoutes(menu.children)
|
||||
if (childRoutes.length > 0) {
|
||||
route.children = childRoutes
|
||||
}
|
||||
}
|
||||
|
||||
routes.push(route)
|
||||
})
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
async function loadDynamicRoutes() {
|
||||
if (isDynamicRouteLoaded) return
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const menuStore = useMenuStore()
|
||||
|
||||
try {
|
||||
await menuStore.fetchMenus()
|
||||
|
||||
const userPermissions = authStore.user?.permissions || []
|
||||
const filteredRoutes = filterAsyncRoutes(asyncRoutes, userPermissions)
|
||||
|
||||
filteredRoutes.forEach(route => {
|
||||
router.addRoute(route)
|
||||
})
|
||||
|
||||
isDynamicRouteLoaded = true
|
||||
} catch (error) {
|
||||
console.error('Failed to load dynamic routes:', error)
|
||||
message.error('加载路由失败')
|
||||
}
|
||||
}
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
const token = authStore.token
|
||||
const requiresAuth = to.meta.requiresAuth !== false
|
||||
|
||||
if (requiresAuth && !token) {
|
||||
message.warning('请先登录')
|
||||
next('/login')
|
||||
return
|
||||
}
|
||||
|
||||
if (to.path === '/login' && token) {
|
||||
if (!isDynamicRouteLoaded) {
|
||||
await loadDynamicRoutes()
|
||||
}
|
||||
next('/')
|
||||
return
|
||||
}
|
||||
|
||||
if (token && !isDynamicRouteLoaded && to.path !== '/login') {
|
||||
await loadDynamicRoutes()
|
||||
next({ ...to, replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (to.meta.permission) {
|
||||
const userPermissions = authStore.user?.permissions || []
|
||||
if (!hasPermission(to.meta.permission as string, userPermissions)) {
|
||||
message.error('您没有权限访问该页面')
|
||||
next('/')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (to.meta.title) {
|
||||
document.title = `${to.meta.title} - 管理系统`
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
router.afterEach((to, from) => {
|
||||
console.log(`路由跳转: ${from.path} -> ${to.path}`)
|
||||
})
|
||||
|
||||
export function resetRouter() {
|
||||
const newRouter = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: constantRoutes
|
||||
})
|
||||
;(router as any).matcher = (newRouter as any).matcher
|
||||
isDynamicRouteLoaded = false
|
||||
}
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,59 @@
|
||||
import request from '@/utils/request'
|
||||
import type { Result } from '@/types'
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
status: string;
|
||||
createBy: string;
|
||||
updateBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
username: string
|
||||
password: string
|
||||
email: string
|
||||
phone: string
|
||||
}
|
||||
|
||||
class AuthService {
|
||||
private readonly BASE_PATH = '/sys/auth'
|
||||
|
||||
async login(data: LoginRequest): Promise<Result<LoginResponse>> {
|
||||
console.log('auth.service.ts: Calling login with data:', data);
|
||||
const response = await request.post(`${this.BASE_PATH}/login`, data);
|
||||
console.log('auth.service.ts: Received response:', JSON.stringify(response, null, 2));
|
||||
console.log('auth.service.ts: Response type:', typeof response);
|
||||
console.log('auth.service.ts: Response keys:', Object.keys(response));
|
||||
return response;
|
||||
}
|
||||
|
||||
async logout(): Promise<Result<void>> {
|
||||
return request.post(`${this.BASE_PATH}/logout`)
|
||||
}
|
||||
|
||||
async register(data: RegisterRequest): Promise<Result<number>> {
|
||||
return request.post(`${this.BASE_PATH}/register`, data)
|
||||
}
|
||||
|
||||
async refreshToken(token: string): Promise<Result<LoginResponse>> {
|
||||
return request.post(`${this.BASE_PATH}/refresh/${token}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService()
|
||||
export default authService
|
||||
@@ -0,0 +1,4 @@
|
||||
export { authService } from './auth.service';
|
||||
export { menuService } from './menu.service';
|
||||
export { roleService } from './role.service';
|
||||
export { userService } from './user.service';
|
||||
@@ -0,0 +1,45 @@
|
||||
import request from '../utils/request';
|
||||
import type { Result, Menu, CreateMenuRequest, UpdateMenuRequest, AssignMenuToRoleRequest } from '../types';
|
||||
|
||||
class MenuService {
|
||||
private readonly BASE_PATH = '/sys/menu';
|
||||
|
||||
async getMenus(): Promise<Result<Menu[]>> {
|
||||
return request.get(`${this.BASE_PATH}`);
|
||||
}
|
||||
|
||||
async createMenu(data: CreateMenuRequest): Promise<Result<Menu>> {
|
||||
return request.post(`${this.BASE_PATH}`, data);
|
||||
}
|
||||
|
||||
async updateMenu(data: UpdateMenuRequest): Promise<Result<Menu>> {
|
||||
return request.put(`${this.BASE_PATH}`, data);
|
||||
}
|
||||
|
||||
async getMenuById(id: number): Promise<Result<Menu>> {
|
||||
return request.get(`${this.BASE_PATH}/${id}`);
|
||||
}
|
||||
|
||||
async deleteMenu(id: number): Promise<Result<boolean>> {
|
||||
return request.delete(`${this.BASE_PATH}/${id}`);
|
||||
}
|
||||
|
||||
async assignMenusToRole(data: AssignMenuToRoleRequest): Promise<Result<boolean>> {
|
||||
return request.post(`${this.BASE_PATH}/assign`, data);
|
||||
}
|
||||
|
||||
async getMenusByRole(roleId: number): Promise<Result<Menu[]>> {
|
||||
return request.get(`${this.BASE_PATH}/role/${roleId}`);
|
||||
}
|
||||
|
||||
async removeMenuFromRole(roleId: number, menuId: number): Promise<Result<boolean>> {
|
||||
return request.delete(`${this.BASE_PATH}/role/${roleId}/menu/${menuId}`);
|
||||
}
|
||||
|
||||
async getMenusByUser(userId: number): Promise<Result<Menu[]>> {
|
||||
return request.get(`${this.BASE_PATH}/user/${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const menuService = new MenuService();
|
||||
export default menuService;
|
||||
@@ -0,0 +1,45 @@
|
||||
import request from '../utils/request';
|
||||
import type { Result, Role, CreateRoleRequest, UpdateRoleRequest, AssignRoleToUserRequest } from '../types';
|
||||
|
||||
class RoleService {
|
||||
private readonly BASE_PATH = '/sys/role';
|
||||
|
||||
async getRoles(): Promise<Result<Role[]>> {
|
||||
return request.get(`${this.BASE_PATH}`);
|
||||
}
|
||||
|
||||
async createRole(data: CreateRoleRequest): Promise<Result<Role>> {
|
||||
return request.post(`${this.BASE_PATH}`, data);
|
||||
}
|
||||
|
||||
async updateRole(data: UpdateRoleRequest): Promise<Result<Role>> {
|
||||
return request.put(`${this.BASE_PATH}`, data);
|
||||
}
|
||||
|
||||
async getRoleById(id: number): Promise<Result<Role>> {
|
||||
return request.get(`${this.BASE_PATH}/${id}`);
|
||||
}
|
||||
|
||||
async deleteRole(id: number): Promise<Result<boolean>> {
|
||||
return request.delete(`${this.BASE_PATH}/${id}`);
|
||||
}
|
||||
|
||||
async assignRolesToUser(data: AssignRoleToUserRequest): Promise<Result<boolean>> {
|
||||
return request.post(`${this.BASE_PATH}/assign`, data);
|
||||
}
|
||||
|
||||
async getRoleByRoleKey(roleKey: string): Promise<Result<Role>> {
|
||||
return request.get(`${this.BASE_PATH}/roleKey/${roleKey}`);
|
||||
}
|
||||
|
||||
async getRolesByUser(userId: number): Promise<Result<Role[]>> {
|
||||
return request.get(`${this.BASE_PATH}/user/${userId}`);
|
||||
}
|
||||
|
||||
async removeRoleFromUser(userId: number, roleId: number): Promise<Result<boolean>> {
|
||||
return request.delete(`${this.BASE_PATH}/user/${userId}/role/${roleId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const roleService = new RoleService();
|
||||
export default roleService;
|
||||
@@ -0,0 +1,33 @@
|
||||
import request from '../utils/request';
|
||||
import type { Result, User, CreateUserRequest, UpdateUserRequest, PageResult } from '../types';
|
||||
|
||||
class UserService {
|
||||
private readonly BASE_PATH = '/sys/user';
|
||||
|
||||
async getUsers(params?: { current?: number; size?: number; username?: string; email?: string; status?: string }): Promise<Result<PageResult<User>>> {
|
||||
return request.get(`${this.BASE_PATH}`, { params });
|
||||
}
|
||||
|
||||
async createUser(data: CreateUserRequest): Promise<Result<number>> {
|
||||
return request.post(`${this.BASE_PATH}`, data);
|
||||
}
|
||||
|
||||
async updateUser(data: UpdateUserRequest): Promise<Result<User>> {
|
||||
return request.put(`${this.BASE_PATH}`, data);
|
||||
}
|
||||
|
||||
async getUserById(id: number): Promise<Result<User>> {
|
||||
return request.get(`${this.BASE_PATH}/${id}`);
|
||||
}
|
||||
|
||||
async getUserByUsername(username: string): Promise<Result<User>> {
|
||||
return request.get(`${this.BASE_PATH}/username/${username}`);
|
||||
}
|
||||
|
||||
async deleteUser(id: number): Promise<Result<boolean>> {
|
||||
return request.delete(`${this.BASE_PATH}/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const userService = new UserService();
|
||||
export default userService;
|
||||
@@ -0,0 +1,94 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const sidebarCollapsed = ref<boolean>(false);
|
||||
const theme = ref<'light' | 'dark'>('light');
|
||||
const language = ref<string>('zh-CN');
|
||||
const loading = ref(false);
|
||||
|
||||
const isDarkMode = computed(() => theme.value === 'dark');
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function setSidebarCollapsed(collapsed: boolean) {
|
||||
sidebarCollapsed.value = collapsed;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function setTheme(newTheme: 'light' | 'dark') {
|
||||
theme.value = newTheme;
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const newTheme = theme.value === 'light' ? 'dark' : 'light';
|
||||
setTheme(newTheme);
|
||||
}
|
||||
|
||||
function setLanguage(lang: string) {
|
||||
language.value = lang;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function setLoading(loadingState: boolean) {
|
||||
loading.value = loadingState;
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
const settings = {
|
||||
sidebarCollapsed: sidebarCollapsed.value,
|
||||
theme: theme.value,
|
||||
language: language.value
|
||||
};
|
||||
localStorage.setItem('appSettings', JSON.stringify(settings));
|
||||
}
|
||||
|
||||
function loadSettings() {
|
||||
const savedSettings = localStorage.getItem('appSettings');
|
||||
if (savedSettings) {
|
||||
try {
|
||||
const settings = JSON.parse(savedSettings);
|
||||
sidebarCollapsed.value = settings.sidebarCollapsed ?? false;
|
||||
theme.value = settings.theme ?? 'light';
|
||||
language.value = settings.language ?? 'zh-CN';
|
||||
document.documentElement.setAttribute('data-theme', theme.value);
|
||||
} catch (error) {
|
||||
console.error('Failed to load app settings:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetSettings() {
|
||||
sidebarCollapsed.value = false;
|
||||
theme.value = 'light';
|
||||
language.value = 'zh-CN';
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
localStorage.removeItem('appSettings');
|
||||
}
|
||||
|
||||
watch(theme, (newTheme) => {
|
||||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
});
|
||||
|
||||
return {
|
||||
sidebarCollapsed,
|
||||
theme,
|
||||
language,
|
||||
loading,
|
||||
isDarkMode,
|
||||
toggleSidebar,
|
||||
setSidebarCollapsed,
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
setLanguage,
|
||||
setLoading,
|
||||
saveSettings,
|
||||
loadSettings,
|
||||
resetSettings
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { authService } from '../services';
|
||||
import { menuService } from '../services';
|
||||
import type { LoginRequest, LoginResponse, User } from '../types';
|
||||
import { TOKEN_KEY, REFRESH_TOKEN_KEY } from '../utils/request';
|
||||
|
||||
export { TOKEN_KEY, REFRESH_TOKEN_KEY };
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY));
|
||||
const refreshToken = ref<string | null>(localStorage.getItem(REFRESH_TOKEN_KEY));
|
||||
const user = ref<User | null>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value);
|
||||
const hasPermission = computed(() => (permission: string) => {
|
||||
if (!user.value?.permissions) return false;
|
||||
return user.value.permissions.includes(permission);
|
||||
});
|
||||
|
||||
async function fetchUserPermissions(userId: number): Promise<string[]> {
|
||||
try {
|
||||
const response = await menuService.getMenusByUser(userId);
|
||||
if (response.code === '200' && response.data) {
|
||||
const menus = response.data;
|
||||
const permissions: string[] = [];
|
||||
|
||||
const extractPermissions = (menuList: any[]) => {
|
||||
menuList.forEach(menu => {
|
||||
if (menu.code) {
|
||||
permissions.push(`menu:${menu.code}`);
|
||||
permissions.push(`${menu.code}:view`);
|
||||
permissions.push(`${menu.code}:create`);
|
||||
permissions.push(`${menu.code}:update`);
|
||||
permissions.push(`${menu.code}:delete`);
|
||||
}
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
extractPermissions(menu.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
extractPermissions(menus);
|
||||
return permissions;
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user permissions:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function login(credentials: LoginRequest) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await authService.login(credentials);
|
||||
console.log('=== LOGIN DEBUG START ===');
|
||||
console.log('Login response:', JSON.stringify(response, null, 2));
|
||||
console.log('Response type:', typeof response);
|
||||
console.log('Response keys:', Object.keys(response));
|
||||
console.log('Response code:', response.code);
|
||||
console.log('Response data:', response.data);
|
||||
console.log('Code check:', response.code === '200' || response.code === 200);
|
||||
console.log('Data check:', !!response.data);
|
||||
console.log('=== LOGIN DEBUG END ===');
|
||||
if (response.code === '200' || response.code === 200) {
|
||||
if (response.data) {
|
||||
const { token: accessToken, refreshToken: newRefreshToken, user: userData, permissions } = response.data;
|
||||
console.log('Destructured values:', {
|
||||
accessToken,
|
||||
newRefreshToken,
|
||||
userData,
|
||||
permissions
|
||||
});
|
||||
|
||||
const userWithPermissions = {
|
||||
...userData,
|
||||
permissions: permissions || []
|
||||
};
|
||||
|
||||
token.value = accessToken;
|
||||
refreshToken.value = newRefreshToken;
|
||||
user.value = userWithPermissions;
|
||||
console.log('Setting localStorage items:', {
|
||||
[TOKEN_KEY]: accessToken,
|
||||
[REFRESH_TOKEN_KEY]: newRefreshToken,
|
||||
'userInfo': JSON.stringify(userWithPermissions)
|
||||
});
|
||||
localStorage.setItem(TOKEN_KEY, accessToken);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, newRefreshToken);
|
||||
localStorage.setItem('userInfo', JSON.stringify(userWithPermissions));
|
||||
console.log('After setting localStorage:', {
|
||||
[TOKEN_KEY]: localStorage.getItem(TOKEN_KEY),
|
||||
[REFRESH_TOKEN_KEY]: localStorage.getItem(REFRESH_TOKEN_KEY),
|
||||
'userInfo': localStorage.getItem('userInfo')
|
||||
});
|
||||
return true;
|
||||
} else {
|
||||
console.log('No data in response');
|
||||
throw new Error('登录响应数据为空');
|
||||
}
|
||||
} else {
|
||||
console.log('Invalid response code:', response.code);
|
||||
throw new Error(response.message || '登录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('=== LOGIN ERROR DEBUG START ===');
|
||||
console.error('Login failed:', error);
|
||||
console.error('Error type:', typeof error);
|
||||
console.error('Error keys:', Object.keys(error));
|
||||
console.error('Error message:', error.message);
|
||||
console.error('Error response:', (error as any).response);
|
||||
console.error('Error response data:', (error as any).response?.data);
|
||||
console.error('=== LOGIN ERROR DEBUG END ===');
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await authService.logout();
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
} finally {
|
||||
token.value = null;
|
||||
user.value = null;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
localStorage.removeItem('userInfo');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAccessToken() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const currentRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
if (!currentRefreshToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await authService.refreshToken(currentRefreshToken);
|
||||
if (response.code === '200' && response.data) {
|
||||
const { token: accessToken, refreshToken: newRefreshToken, user: userData } = response.data;
|
||||
token.value = accessToken;
|
||||
refreshToken.value = newRefreshToken;
|
||||
user.value = userData;
|
||||
localStorage.setItem(TOKEN_KEY, accessToken);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, newRefreshToken);
|
||||
localStorage.setItem('userInfo', JSON.stringify(userData));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh token:', error);
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadUserInfo() {
|
||||
const userInfo = localStorage.getItem('userInfo');
|
||||
if (userInfo) {
|
||||
try {
|
||||
user.value = JSON.parse(userInfo);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse user info:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
refreshToken,
|
||||
user,
|
||||
loading,
|
||||
isAuthenticated,
|
||||
hasPermission,
|
||||
login,
|
||||
logout,
|
||||
refreshAccessToken,
|
||||
loadUserInfo
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export { useAuthStore } from './auth.store';
|
||||
export { useMenuStore } from './menu.store';
|
||||
export { useRoleStore } from './role.store';
|
||||
export { useUserStore } from './user.store';
|
||||
export { useAppStore } from './app.store';
|
||||
@@ -0,0 +1,192 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { menuService } from '../services';
|
||||
import type { Menu, CreateMenuRequest, UpdateMenuRequest, AssignMenuToRoleRequest } from '../types';
|
||||
|
||||
export const useMenuStore = defineStore('menu', () => {
|
||||
const menus = ref<Menu[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const menuTree = computed(() => {
|
||||
const buildTree = (items: Menu[], parentId: number = 0): Menu[] => {
|
||||
return items
|
||||
.filter(item => item.parentId === parentId)
|
||||
.map(item => ({
|
||||
...item,
|
||||
children: buildTree(items, item.id)
|
||||
}))
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
};
|
||||
return buildTree(menus.value);
|
||||
});
|
||||
|
||||
const activeMenus = computed(() => {
|
||||
return menus.value.filter(menu => menu.status === 'active');
|
||||
});
|
||||
|
||||
async function fetchMenus() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await menuService.getMenus();
|
||||
if (response.code === '200' && response.data) {
|
||||
menus.value = response.data.records || response.data;
|
||||
return true;
|
||||
}
|
||||
error.value = response.message || 'Failed to fetch menus';
|
||||
return false;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error('Failed to fetch menus:', err);
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createMenu(data: CreateMenuRequest) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await menuService.createMenu(data);
|
||||
if (response.code === '200' && response.data) {
|
||||
menus.value.push(response.data);
|
||||
return true;
|
||||
}
|
||||
error.value = response.message || 'Failed to create menu';
|
||||
return false;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error('Failed to create menu:', err);
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateMenu(data: UpdateMenuRequest) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await menuService.updateMenu(data);
|
||||
if (response.code === '200' && response.data) {
|
||||
const index = menus.value.findIndex(m => m.id === data.id);
|
||||
if (index !== -1) {
|
||||
menus.value[index] = response.data;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
error.value = response.message || 'Failed to update menu';
|
||||
return false;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error('Failed to update menu:', err);
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMenu(id: number) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await menuService.deleteMenu(id);
|
||||
if (response.code === '200' && response.data) {
|
||||
menus.value = menus.value.filter(m => m.id !== id);
|
||||
return true;
|
||||
}
|
||||
error.value = response.message || 'Failed to delete menu';
|
||||
return false;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error('Failed to delete menu:', err);
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function assignMenusToRole(data: AssignMenuToRoleRequest) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await menuService.assignMenusToRole(data);
|
||||
if (response.code === '200' && response.data) {
|
||||
return true;
|
||||
}
|
||||
error.value = response.message || 'Failed to assign menus to role';
|
||||
return false;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error('Failed to assign menus to role:', err);
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMenusByRole(roleId: number) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await menuService.getMenusByRole(roleId);
|
||||
if (response.code === '200' && response.data) {
|
||||
return response.data;
|
||||
}
|
||||
error.value = response.message || 'Failed to fetch menus by role';
|
||||
return null;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error('Failed to fetch menus by role:', err);
|
||||
return null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMenusByUser(userId: number) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await menuService.getMenusByUser(userId);
|
||||
if (response.code === '200' && response.data) {
|
||||
return response.data;
|
||||
}
|
||||
error.value = response.message || 'Failed to fetch menus by user';
|
||||
return null;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error('Failed to fetch menus by user:', err);
|
||||
return null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getMenuById(id: number): Menu | undefined {
|
||||
return menus.value.find(m => m.id === id);
|
||||
}
|
||||
|
||||
function getMenuByCode(code: string): Menu | undefined {
|
||||
return menus.value.find(m => m.code === code);
|
||||
}
|
||||
|
||||
return {
|
||||
menus,
|
||||
loading,
|
||||
error,
|
||||
menuTree,
|
||||
activeMenus,
|
||||
fetchMenus,
|
||||
createMenu,
|
||||
updateMenu,
|
||||
deleteMenu,
|
||||
assignMenusToRole,
|
||||
fetchMenusByRole,
|
||||
fetchMenusByUser,
|
||||
getMenuById,
|
||||
getMenuByCode
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { operationLogApi } from '@/api/operationLog';
|
||||
import type { OperationLog, OperationLogQueryParams, PageResult } from '@/types';
|
||||
|
||||
export const useOperationLogStore = defineStore('operationLog', () => {
|
||||
const operationLogs = ref<OperationLog[]>([]);
|
||||
const total = ref(0);
|
||||
const current = ref(1);
|
||||
const size = ref(10);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const successLogs = computed(() => {
|
||||
return operationLogs.value.filter(log => log.status === 'success');
|
||||
});
|
||||
|
||||
const failedLogs = computed(() => {
|
||||
return operationLogs.value.filter(log => log.status === 'error');
|
||||
});
|
||||
|
||||
async function fetchOperationLogs(params?: OperationLogQueryParams) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await operationLogApi.query(params || {
|
||||
current: current.value,
|
||||
size: size.value
|
||||
});
|
||||
if (response.code === '200' && response.data) {
|
||||
const data = response.data as PageResult<OperationLog>;
|
||||
operationLogs.value = data.records || [];
|
||||
total.value = data.total || 0;
|
||||
current.value = data.current || 1;
|
||||
size.value = data.size || 10;
|
||||
return true;
|
||||
}
|
||||
error.value = response.message || 'Failed to fetch operation logs';
|
||||
return false;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error('Failed to fetch operation logs:', err);
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getOperationLogById(id: number) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await operationLogApi.getById(id);
|
||||
if (response.code === '200' && response.data) {
|
||||
return response.data;
|
||||
}
|
||||
error.value = response.message || 'Failed to fetch operation log by id';
|
||||
return null;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error('Failed to fetch operation log by id:', err);
|
||||
return null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteOperationLog(id: number) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await operationLogApi.delete(id);
|
||||
if (response.code === '200') {
|
||||
operationLogs.value = operationLogs.value.filter(log => log.id !== id);
|
||||
total.value = Math.max(0, total.value - 1);
|
||||
return true;
|
||||
}
|
||||
error.value = response.message || 'Failed to delete operation log';
|
||||
return false;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error('Failed to delete operation log:', err);
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getOperationLogByIdLocal(id: number): OperationLog | undefined {
|
||||
return operationLogs.value.find(log => log.id === id);
|
||||
}
|
||||
|
||||
return {
|
||||
operationLogs,
|
||||
total,
|
||||
current,
|
||||
size,
|
||||
loading,
|
||||
error,
|
||||
successLogs,
|
||||
failedLogs,
|
||||
fetchOperationLogs,
|
||||
getOperationLogById,
|
||||
deleteOperationLog,
|
||||
getOperationLogByIdLocal
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { roleService } from '../services';
|
||||
import type { Role, CreateRoleRequest, UpdateRoleRequest, AssignRoleToUserRequest } from '../types';
|
||||
|
||||
export const useRoleStore = defineStore('role', () => {
|
||||
const roles = ref<Role[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const activeRoles = computed(() => {
|
||||
return roles.value.filter(role => role.status === 'active');
|
||||
});
|
||||
|
||||
const roleOptions = computed(() => {
|
||||
return roles.value.map(role => ({
|
||||
label: role.name,
|
||||
value: role.id,
|
||||
roleKey: role.roleKey
|
||||
}));
|
||||
});
|
||||
|
||||
async function fetchRoles() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await roleService.getRoles();
|
||||
if (response.code === '200' && response.data) {
|
||||
roles.value = response.data;
|
||||
return true;
|
||||
}
|
||||
error.value = response.message || 'Failed to fetch roles';
|
||||
return false;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error('Failed to fetch roles:', err);
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createRole(data: CreateRoleRequest) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await roleService.createRole(data);
|
||||
if (response.code === '200' && response.data) {
|
||||
roles.value.push(response.data);
|
||||
return true;
|
||||
}
|
||||
error.value = response.message || 'Failed to create role';
|
||||
return false;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error('Failed to create role:', err);
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateRole(data: UpdateRoleRequest) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await roleService.updateRole(data);
|
||||
if (response.code === '200' && response.data) {
|
||||
const index = roles.value.findIndex(r => r.id === data.id);
|
||||
if (index !== -1) {
|
||||
roles.value[index] = response.data;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
error.value = response.message || 'Failed to update role';
|
||||
return false;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error('Failed to update role:', err);
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRole(id: number) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await roleService.deleteRole(id);
|
||||
if (response.code === '200' && response.data) {
|
||||
roles.value = roles.value.filter(r => r.id !== id);
|
||||
return true;
|
||||
}
|
||||
error.value = response.message || 'Failed to delete role';
|
||||
return false;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error('Failed to delete role:', err);
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function assignRolesToUser(data: AssignRoleToUserRequest) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await roleService.assignRolesToUser(data);
|
||||
if (response.code === '200' && response.data) {
|
||||
return true;
|
||||
}
|
||||
error.value = response.message || 'Failed to assign roles to user';
|
||||
return false;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error('Failed to assign roles to user:', err);
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRolesByUser(userId: number) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await roleService.getRolesByUser(userId);
|
||||
if (response.code === '200' && response.data) {
|
||||
return response.data;
|
||||
}
|
||||
error.value = response.message || 'Failed to fetch roles by user';
|
||||
return null;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error('Failed to fetch roles by user:', err);
|
||||
return null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRoleByRoleKey(roleKey: string) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await roleService.getRoleByRoleKey(roleKey);
|
||||
if (response.code === '200' && response.data) {
|
||||
return response.data;
|
||||
}
|
||||
error.value = response.message || 'Failed to fetch role by role key';
|
||||
return null;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error('Failed to fetch role by role key:', err);
|
||||
return null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRoleFromUser(userId: number, roleId: number) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await roleService.removeRoleFromUser(userId, roleId);
|
||||
if (response.code === '200' && response.data) {
|
||||
return true;
|
||||
}
|
||||
error.value = response.message || 'Failed to remove role from user';
|
||||
return false;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error('Failed to remove role from user:', err);
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getRoleById(id: number): Role | undefined {
|
||||
return roles.value.find(r => r.id === id);
|
||||
}
|
||||
|
||||
function getRoleByRoleKey(roleKey: string): Role | undefined {
|
||||
return roles.value.find(r => r.roleKey === roleKey);
|
||||
}
|
||||
|
||||
return {
|
||||
roles,
|
||||
loading,
|
||||
error,
|
||||
activeRoles,
|
||||
roleOptions,
|
||||
fetchRoles,
|
||||
createRole,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
assignRolesToUser,
|
||||
fetchRolesByUser,
|
||||
fetchRoleByRoleKey,
|
||||
removeRoleFromUser,
|
||||
getRoleById,
|
||||
getRoleByRoleKey
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { userService } from '../services';
|
||||
import type { User, CreateUserRequest, UpdateUserRequest, PageResult } from '../types';
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const users = ref<User[]>([]);
|
||||
const total = ref(0);
|
||||
const current = ref(1);
|
||||
const size = ref(10);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const activeUsers = computed(() => {
|
||||
return users.value.filter(user => user.status === 'active');
|
||||
});
|
||||
|
||||
const userOptions = computed(() => {
|
||||
return users.value.map(user => ({
|
||||
label: user.username,
|
||||
value: user.id,
|
||||
email: user.email
|
||||
}));
|
||||
});
|
||||
|
||||
async function fetchUsers(params?: { current?: number; size?: number; username?: string; email?: string; status?: string }) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await userService.getUsers(params);
|
||||
if (response.code === '200' && response.data) {
|
||||
const data = response.data as PageResult<User>;
|
||||
users.value = data.records || [];
|
||||
total.value = data.total || 0;
|
||||
current.value = data.current || 1;
|
||||
size.value = data.size || 10;
|
||||
return true;
|
||||
}
|
||||
error.value = response.message || 'Failed to fetch users';
|
||||
return false;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error('Failed to fetch users:', err);
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser(data: CreateUserRequest) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await userService.createUser(data);
|
||||
if (response.code === '200' && response.data) {
|
||||
await fetchUsers({ current: current.value, size: size.value });
|
||||
return true;
|
||||
}
|
||||
error.value = response.message || 'Failed to create user';
|
||||
return false;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error('Failed to create user:', err);
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUser(data: UpdateUserRequest) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await userService.updateUser(data);
|
||||
if (response.code === '200' && response.data) {
|
||||
const index = users.value.findIndex(u => u.id === data.id);
|
||||
if (index !== -1) {
|
||||
users.value[index] = response.data;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
error.value = response.message || 'Failed to update user';
|
||||
return false;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error('Failed to update user:', err);
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(id: number) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await userService.deleteUser(id);
|
||||
if (response.code === '200' && response.data) {
|
||||
users.value = users.value.filter(u => u.id !== id);
|
||||
total.value = Math.max(0, total.value - 1);
|
||||
return true;
|
||||
}
|
||||
error.value = response.message || 'Failed to delete user';
|
||||
return false;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error('Failed to delete user:', err);
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUserById(id: number) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await userService.getUserById(id);
|
||||
if (response.code === '200' && response.data) {
|
||||
return response.data;
|
||||
}
|
||||
error.value = response.message || 'Failed to fetch user by id';
|
||||
return null;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error('Failed to fetch user by id:', err);
|
||||
return null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUserByUsername(username: string) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await userService.getUserByUsername(username);
|
||||
if (response.code === '200' && response.data) {
|
||||
return response.data;
|
||||
}
|
||||
error.value = response.message || 'Failed to fetch user by username';
|
||||
return null;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error('Failed to fetch user by username:', err);
|
||||
return null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getUserById(id: number): User | undefined {
|
||||
return users.value.find(u => u.id === id);
|
||||
}
|
||||
|
||||
function getUserByUsername(username: string): User | undefined {
|
||||
return users.value.find(u => u.username === username);
|
||||
}
|
||||
|
||||
function getUserByEmail(email: string): User | undefined {
|
||||
return users.value.find(u => u.email === email);
|
||||
}
|
||||
|
||||
return {
|
||||
users,
|
||||
total,
|
||||
current,
|
||||
size,
|
||||
loading,
|
||||
error,
|
||||
activeUsers,
|
||||
userOptions,
|
||||
fetchUsers,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
fetchUserById,
|
||||
fetchUserByUsername,
|
||||
getUserById,
|
||||
getUserByUsername,
|
||||
getUserByEmail
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: #333;
|
||||
background-color: #f5f5f5;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary-color: #409eff;
|
||||
--success-color: #67c23a;
|
||||
--warning-color: #e6a23c;
|
||||
--danger-color: #f56c6c;
|
||||
--info-color: #909399;
|
||||
--text-primary: #303133;
|
||||
--text-regular: #606266;
|
||||
--text-secondary: #909399;
|
||||
--text-placeholder: #c0c4cc;
|
||||
--border-base: #dcdfe6;
|
||||
--border-light: #e4e7ed;
|
||||
--border-lighter: #ebeef5;
|
||||
--border-extra-light: #f2f6fc;
|
||||
--background-base: #f5f7fa;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--text-primary: #e5eaf3;
|
||||
--text-regular: #cfd3dc;
|
||||
--text-secondary: #a3a6ad;
|
||||
--text-placeholder: #8d9095;
|
||||
--border-base: #4c4d4f;
|
||||
--border-light: #414243;
|
||||
--border-lighter: #363637;
|
||||
--border-extra-light: #2b2b2c;
|
||||
--background-base: #1d1e1f;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useAppStore } from '@/stores/app.store'
|
||||
|
||||
describe('AppStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('初始状态', () => {
|
||||
it('应该正确初始化默认状态', () => {
|
||||
const store = useAppStore()
|
||||
|
||||
expect(store.sidebarCollapsed).toBe(false)
|
||||
expect(store.theme).toBe('light')
|
||||
expect(store.language).toBe('zh-CN')
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.isDarkMode).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleSidebar', () => {
|
||||
it('应该能够切换侧边栏状态', () => {
|
||||
const store = useAppStore()
|
||||
|
||||
expect(store.sidebarCollapsed).toBe(false)
|
||||
|
||||
store.toggleSidebar()
|
||||
|
||||
expect(store.sidebarCollapsed).toBe(true)
|
||||
|
||||
store.toggleSidebar()
|
||||
|
||||
expect(store.sidebarCollapsed).toBe(false)
|
||||
})
|
||||
|
||||
it('切换后应该保存设置到localStorage', () => {
|
||||
const store = useAppStore()
|
||||
|
||||
store.toggleSidebar()
|
||||
|
||||
expect(localStorage.getItem('appSettings')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('setTheme', () => {
|
||||
it('应该能够设置主题', () => {
|
||||
const store = useAppStore()
|
||||
|
||||
store.setTheme('dark')
|
||||
|
||||
expect(store.theme).toBe('dark')
|
||||
expect(store.isDarkMode).toBe(true)
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
|
||||
})
|
||||
|
||||
it('设置主题后应该保存到localStorage', () => {
|
||||
const store = useAppStore()
|
||||
|
||||
store.setTheme('dark')
|
||||
|
||||
const settings = JSON.parse(localStorage.getItem('appSettings') || '{}')
|
||||
expect(settings.theme).toBe('dark')
|
||||
})
|
||||
|
||||
it('应该支持切换回light主题', () => {
|
||||
const store = useAppStore()
|
||||
|
||||
store.setTheme('dark')
|
||||
store.setTheme('light')
|
||||
|
||||
expect(store.theme).toBe('light')
|
||||
expect(store.isDarkMode).toBe(false)
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('light')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setLanguage', () => {
|
||||
it('应该能够设置语言', () => {
|
||||
const store = useAppStore()
|
||||
|
||||
store.setLanguage('en-US')
|
||||
|
||||
expect(store.language).toBe('en-US')
|
||||
})
|
||||
|
||||
it('设置语言后应该保存到localStorage', () => {
|
||||
const store = useAppStore()
|
||||
|
||||
store.setLanguage('en-US')
|
||||
|
||||
const settings = JSON.parse(localStorage.getItem('appSettings') || '{}')
|
||||
expect(settings.language).toBe('en-US')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setLoading', () => {
|
||||
it('应该能够设置加载状态', () => {
|
||||
const store = useAppStore()
|
||||
|
||||
store.setLoading(true)
|
||||
|
||||
expect(store.loading).toBe(true)
|
||||
|
||||
store.setLoading(false)
|
||||
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveSettings', () => {
|
||||
it('应该能够保存所有设置到localStorage', () => {
|
||||
const store = useAppStore()
|
||||
|
||||
store.sidebarCollapsed = true
|
||||
store.theme = 'dark'
|
||||
store.language = 'en-US'
|
||||
|
||||
store.saveSettings()
|
||||
|
||||
const settings = JSON.parse(localStorage.getItem('appSettings') || '{}')
|
||||
expect(settings.sidebarCollapsed).toBe(true)
|
||||
expect(settings.theme).toBe('dark')
|
||||
expect(settings.language).toBe('en-US')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadSettings', () => {
|
||||
it('应该能够从localStorage加载设置', () => {
|
||||
const settings = {
|
||||
sidebarCollapsed: true,
|
||||
theme: 'dark',
|
||||
language: 'en-US'
|
||||
}
|
||||
|
||||
localStorage.setItem('appSettings', JSON.stringify(settings))
|
||||
|
||||
const store = useAppStore()
|
||||
store.loadSettings()
|
||||
|
||||
expect(store.sidebarCollapsed).toBe(true)
|
||||
expect(store.theme).toBe('dark')
|
||||
expect(store.language).toBe('en-US')
|
||||
})
|
||||
|
||||
it('localStorage为空时应该使用默认值', () => {
|
||||
const store = useAppStore()
|
||||
store.loadSettings()
|
||||
|
||||
expect(store.sidebarCollapsed).toBe(false)
|
||||
expect(store.theme).toBe('light')
|
||||
expect(store.language).toBe('zh-CN')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetSettings', () => {
|
||||
it('应该能够重置所有设置到默认值', () => {
|
||||
const store = useAppStore()
|
||||
|
||||
store.sidebarCollapsed = true
|
||||
store.theme = 'dark'
|
||||
store.language = 'en-US'
|
||||
|
||||
store.resetSettings()
|
||||
|
||||
expect(store.sidebarCollapsed).toBe(false)
|
||||
expect(store.theme).toBe('light')
|
||||
expect(store.language).toBe('zh-CN')
|
||||
expect(localStorage.getItem('appSettings')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { authService, type LoginRequest, type RegisterRequest } from '@/services/auth.service';
|
||||
|
||||
const { mockPost, mockGet, mockPut, mockDelete } = vi.hoisted(() => ({
|
||||
mockPost: vi.fn(),
|
||||
mockGet: vi.fn(),
|
||||
mockPut: vi.fn(),
|
||||
mockDelete: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/request', () => ({
|
||||
default: {
|
||||
post: mockPost,
|
||||
get: mockGet,
|
||||
put: mockPut,
|
||||
delete: mockDelete
|
||||
}
|
||||
}));
|
||||
|
||||
describe('AuthService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('应该能够成功登录', async () => {
|
||||
const loginData: LoginRequest = {
|
||||
username: 'admin',
|
||||
password: 'admin123'
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
code: 200,
|
||||
message: '登录成功',
|
||||
data: {
|
||||
token: 'mock-jwt-token',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
phone: '13800138000',
|
||||
status: 'ACTIVE',
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mockPost.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await authService.login(loginData);
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/sys/auth/login', loginData);
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(result.data.token).toBe('mock-jwt-token');
|
||||
});
|
||||
|
||||
it('应该能够处理登录失败', async () => {
|
||||
const loginData: LoginRequest = {
|
||||
username: 'wrong',
|
||||
password: 'wrong'
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
code: 401,
|
||||
message: '用户名或密码错误',
|
||||
data: null
|
||||
};
|
||||
|
||||
mockPost.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await authService.login(loginData);
|
||||
|
||||
expect(result.code).toBe(401);
|
||||
expect(result.message).toBe('用户名或密码错误');
|
||||
});
|
||||
|
||||
it('应该处理网络错误', async () => {
|
||||
const loginData: LoginRequest = {
|
||||
username: 'admin',
|
||||
password: 'admin123'
|
||||
};
|
||||
|
||||
mockPost.mockRejectedValue(new Error('Network Error'));
|
||||
|
||||
await expect(authService.login(loginData)).rejects.toThrow('Network Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('应该能够成功登出', async () => {
|
||||
const mockResponse = {
|
||||
code: 200,
|
||||
message: '登出成功',
|
||||
data: null
|
||||
};
|
||||
|
||||
mockPost.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await authService.logout();
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/sys/auth/logout');
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
it('应该能够成功注册', async () => {
|
||||
const registerData: RegisterRequest = {
|
||||
username: 'newuser',
|
||||
password: 'password123',
|
||||
email: 'newuser@example.com',
|
||||
phone: '13900139000'
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
code: 200,
|
||||
message: '注册成功',
|
||||
data: 2
|
||||
};
|
||||
|
||||
mockPost.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await authService.register(registerData);
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/sys/auth/register', registerData);
|
||||
expect(result.data).toBe(2);
|
||||
});
|
||||
|
||||
it('应该验证必填字段', async () => {
|
||||
const invalidData: Partial<RegisterRequest> = {
|
||||
username: '',
|
||||
password: ''
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
code: 400,
|
||||
message: '用户名和密码不能为空',
|
||||
data: null
|
||||
};
|
||||
|
||||
mockPost.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await authService.register(invalidData as RegisterRequest);
|
||||
|
||||
expect(result.code).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshToken', () => {
|
||||
it('应该能够成功刷新token', async () => {
|
||||
const token = 'old-token';
|
||||
|
||||
const mockResponse = {
|
||||
code: 200,
|
||||
message: '刷新成功',
|
||||
data: {
|
||||
token: 'new-jwt-token',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
phone: '13800138000',
|
||||
status: 'ACTIVE',
|
||||
createBy: 'system',
|
||||
updateBy: 'system',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mockPost.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await authService.refreshToken(token);
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/sys/auth/refresh/old-token');
|
||||
expect(result.data.token).toBe('new-jwt-token');
|
||||
});
|
||||
|
||||
it('应该处理无效token', async () => {
|
||||
const token = 'invalid-token';
|
||||
|
||||
const mockResponse = {
|
||||
code: 401,
|
||||
message: 'Token已过期',
|
||||
data: null
|
||||
};
|
||||
|
||||
mockPost.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await authService.refreshToken(token);
|
||||
|
||||
expect(result.code).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useAuthStore, TOKEN_KEY, REFRESH_TOKEN_KEY } from '@/stores/auth.store'
|
||||
|
||||
const { mockLogin, mockLogout, mockRefreshToken } = vi.hoisted(() => ({
|
||||
mockLogin: vi.fn(),
|
||||
mockLogout: vi.fn(),
|
||||
mockRefreshToken: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services', () => ({
|
||||
authService: {
|
||||
login: mockLogin,
|
||||
logout: mockLogout,
|
||||
refreshToken: mockRefreshToken,
|
||||
getUserInfo: vi.fn()
|
||||
},
|
||||
menuService: {},
|
||||
roleService: {},
|
||||
userService: {}
|
||||
}))
|
||||
|
||||
describe('AuthStore', () => {
|
||||
let localStorageMock: Record<string, string>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageMock = {};
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key, value) => {
|
||||
localStorageMock[key] = value;
|
||||
});
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => {
|
||||
return localStorageMock[key] || null;
|
||||
});
|
||||
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation((key) => {
|
||||
delete localStorageMock[key];
|
||||
});
|
||||
vi.spyOn(Storage.prototype, 'clear').mockImplementation(() => {
|
||||
localStorageMock = {};
|
||||
});
|
||||
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('初始状态', () => {
|
||||
it('应该正确初始化默认状态', () => {
|
||||
const store = useAuthStore()
|
||||
|
||||
expect(store.token).toBeNull()
|
||||
expect(store.refreshToken).toBeNull()
|
||||
expect(store.user).toBeNull()
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('localStorage应该正常工作', () => {
|
||||
localStorage.setItem('test_key', 'test_value')
|
||||
expect(localStorage.getItem('test_key')).toBe('test_value')
|
||||
localStorage.removeItem('test_key')
|
||||
expect(localStorage.getItem('test_key')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('login', () => {
|
||||
it('应该能够成功登录', async () => {
|
||||
const mockResponse = {
|
||||
code: '200',
|
||||
message: '登录成功',
|
||||
data: {
|
||||
token: 'mock-jwt-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
phone: '13800138000',
|
||||
status: 'ACTIVE'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mockLogin.mockResolvedValue(mockResponse)
|
||||
|
||||
const store = useAuthStore()
|
||||
const result = await store.login({
|
||||
username: 'admin',
|
||||
password: 'admin123'
|
||||
})
|
||||
|
||||
console.log('mockLogin calls:', mockLogin.mock.calls)
|
||||
console.log('After login - result:', result)
|
||||
console.log('After login - store.token:', store.token)
|
||||
console.log('After login - store.refreshToken:', store.refreshToken)
|
||||
console.log('After login - localStorage access_token:', localStorage.getItem(TOKEN_KEY))
|
||||
console.log('After login - localStorage refreshToken:', localStorage.getItem(REFRESH_TOKEN_KEY))
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(store.token).toBe('mock-jwt-token')
|
||||
expect(store.refreshToken).toBe('mock-refresh-token')
|
||||
expect(store.user).toEqual({
|
||||
...mockResponse.data.user,
|
||||
permissions: expect.any(Array)
|
||||
})
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
expect(store.loading).toBe(false)
|
||||
expect(localStorage.getItem(TOKEN_KEY)).toBe('mock-jwt-token')
|
||||
expect(localStorage.getItem(REFRESH_TOKEN_KEY)).toBe('mock-refresh-token')
|
||||
})
|
||||
|
||||
it('登录失败时应该返回false', async () => {
|
||||
const mockResponse = {
|
||||
code: '401',
|
||||
message: '用户名或密码错误',
|
||||
data: null
|
||||
}
|
||||
|
||||
mockLogin.mockResolvedValue(mockResponse)
|
||||
|
||||
const store = useAuthStore()
|
||||
const result = await store.login({
|
||||
username: 'wrong',
|
||||
password: 'wrong'
|
||||
})
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(store.token).toBeNull()
|
||||
expect(store.user).toBeNull()
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('登录异常时应该返回false', async () => {
|
||||
mockLogin.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const store = useAuthStore()
|
||||
const result = await store.login({
|
||||
username: 'admin',
|
||||
password: 'admin123'
|
||||
})
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
it('应该能够成功登出', async () => {
|
||||
const mockResponse = {
|
||||
code: '200',
|
||||
message: '登出成功',
|
||||
data: null
|
||||
}
|
||||
|
||||
mockLogout.mockResolvedValue(mockResponse)
|
||||
|
||||
localStorage.setItem(TOKEN_KEY, 'test-token')
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, 'test-refresh-token')
|
||||
localStorage.setItem('userInfo', JSON.stringify({ id: 1 }))
|
||||
|
||||
const store = useAuthStore()
|
||||
store.token = 'test-token'
|
||||
store.user = { id: 1, username: 'admin', email: 'admin@example.com', phone: '13800138000', status: 'ACTIVE' }
|
||||
|
||||
await store.logout()
|
||||
|
||||
expect(store.token).toBeNull()
|
||||
expect(store.user).toBeNull()
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(localStorage.getItem(TOKEN_KEY)).toBeNull()
|
||||
expect(localStorage.getItem(REFRESH_TOKEN_KEY)).toBeNull()
|
||||
expect(localStorage.getItem('userInfo')).toBeNull()
|
||||
})
|
||||
|
||||
it('登出异常时也应该清除本地状态', async () => {
|
||||
localStorage.setItem(TOKEN_KEY, 'test-token')
|
||||
|
||||
mockLogout.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const store = useAuthStore()
|
||||
store.token = 'test-token'
|
||||
|
||||
await store.logout()
|
||||
|
||||
expect(store.token).toBeNull()
|
||||
expect(localStorage.getItem(TOKEN_KEY)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshAccessToken', () => {
|
||||
it('应该能够成功刷新token', async () => {
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, 'old-refresh-token')
|
||||
|
||||
const mockResponse = {
|
||||
code: '200',
|
||||
message: '刷新成功',
|
||||
data: {
|
||||
token: 'new-jwt-token',
|
||||
refreshToken: 'new-refresh-token',
|
||||
user: { id: 1, username: 'admin', email: 'admin@example.com', phone: '13800138000', status: 'ACTIVE' }
|
||||
}
|
||||
}
|
||||
|
||||
mockRefreshToken.mockResolvedValue(mockResponse)
|
||||
|
||||
const store = useAuthStore()
|
||||
store.refreshToken = 'old-refresh-token'
|
||||
|
||||
const result = await store.refreshAccessToken()
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(store.token).toBe('new-jwt-token')
|
||||
expect(store.refreshToken).toBe('new-refresh-token')
|
||||
expect(store.user).toEqual({ id: 1, username: 'admin', email: 'admin@example.com', phone: '13800138000', status: 'ACTIVE' })
|
||||
})
|
||||
|
||||
it('刷新失败时应该返回false', async () => {
|
||||
const mockResponse = {
|
||||
code: '401',
|
||||
message: '刷新token已过期',
|
||||
data: null
|
||||
}
|
||||
|
||||
mockRefreshToken.mockResolvedValue(mockResponse)
|
||||
|
||||
const store = useAuthStore()
|
||||
const result = await store.refreshAccessToken()
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadUserInfo', () => {
|
||||
it('应该能够加载用户信息', () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
phone: '13800138000',
|
||||
status: 'ACTIVE'
|
||||
}
|
||||
|
||||
localStorage.setItem('userInfo', JSON.stringify(mockUser))
|
||||
|
||||
const store = useAuthStore()
|
||||
store.loadUserInfo()
|
||||
|
||||
expect(store.user).toEqual(mockUser)
|
||||
})
|
||||
|
||||
it('加载失败时不应该更新用户信息', () => {
|
||||
localStorage.setItem('userInfo', 'invalid-json')
|
||||
|
||||
const store = useAuthStore()
|
||||
store.loadUserInfo()
|
||||
|
||||
expect(store.user).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, VueWrapper } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import Header from '@/components/Header.vue';
|
||||
import { useAppStore } from '@/stores/app.store';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
|
||||
const mockPush = vi.fn();
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-router')>();
|
||||
return {
|
||||
...actual,
|
||||
useRouter: () => ({
|
||||
push: mockPush
|
||||
}),
|
||||
useRoute: () => ({
|
||||
path: '/dashboard'
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('ant-design-vue', () => ({
|
||||
message: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
describe('Header 组件', () => {
|
||||
let wrapper: VueWrapper;
|
||||
let appStore: ReturnType<typeof useAppStore>;
|
||||
let authStore: ReturnType<typeof useAuthStore>;
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'zh-CN',
|
||||
messages: {
|
||||
'zh-CN': {
|
||||
layout: {
|
||||
home: '首页',
|
||||
userCenter: '个人中心',
|
||||
logout: '退出登录'
|
||||
},
|
||||
menu: {
|
||||
dashboard: '仪表盘',
|
||||
userManagement: '用户管理',
|
||||
roleManagement: '角色管理',
|
||||
menuManagement: '菜单管理',
|
||||
permissionManagement: '权限管理',
|
||||
chartDisplay: '图表展示',
|
||||
reportStatistics: '报表统计',
|
||||
settings: '系统设置'
|
||||
},
|
||||
settings: {
|
||||
darkTheme: '已切换到深色主题',
|
||||
lightTheme: '已切换到浅色主题'
|
||||
},
|
||||
auth: {
|
||||
logoutSuccess: '退出登录成功',
|
||||
logoutFailed: '退出登录失败'
|
||||
}
|
||||
},
|
||||
'en-US': {
|
||||
layout: {
|
||||
home: 'Home',
|
||||
userCenter: 'User Center',
|
||||
logout: 'Logout'
|
||||
},
|
||||
menu: {
|
||||
dashboard: 'Dashboard',
|
||||
userManagement: 'User Management',
|
||||
roleManagement: 'Role Management',
|
||||
menuManagement: 'Menu Management',
|
||||
permissionManagement: 'Permission Management',
|
||||
chartDisplay: 'Chart Display',
|
||||
reportStatistics: 'Report Statistics',
|
||||
settings: 'Settings'
|
||||
},
|
||||
settings: {
|
||||
darkTheme: 'Switched to dark theme',
|
||||
lightTheme: 'Switched to light theme'
|
||||
},
|
||||
auth: {
|
||||
logoutSuccess: 'Logout successful',
|
||||
logoutFailed: 'Logout failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
appStore = useAppStore();
|
||||
authStore = useAuthStore();
|
||||
|
||||
authStore.user = {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
phone: '13800138000',
|
||||
status: 'ACTIVE'
|
||||
};
|
||||
|
||||
wrapper = mount(Header, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
'a-layout-header': true,
|
||||
'a-button': true,
|
||||
'a-breadcrumb': true,
|
||||
'a-breadcrumb-item': true,
|
||||
'a-space': true,
|
||||
'a-badge': true,
|
||||
'a-dropdown': true,
|
||||
'a-avatar': true,
|
||||
'a-menu': true,
|
||||
'a-menu-item': true,
|
||||
'a-menu-divider': true,
|
||||
'MenuUnfoldOutlined': true,
|
||||
'MenuFoldOutlined': true,
|
||||
'HomeOutlined': true,
|
||||
'BellOutlined': true,
|
||||
'UserOutlined': true,
|
||||
'SettingOutlined': true,
|
||||
'LogoutOutlined': true,
|
||||
'DownOutlined': true,
|
||||
'SunOutlined': true,
|
||||
'MoonOutlined': true,
|
||||
'GlobalOutlined': true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('初始状态', () => {
|
||||
it('应该正确渲染组件', () => {
|
||||
expect(wrapper.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('应该显示用户名', () => {
|
||||
expect(wrapper.vm.user?.username).toBe('admin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('侧边栏切换', () => {
|
||||
it('应该触发侧边栏切换事件', async () => {
|
||||
await wrapper.vm.toggleSidebar();
|
||||
expect(wrapper.emitted('toggleSidebar')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('应该更新侧边栏折叠状态', async () => {
|
||||
const initialCollapsed = appStore.sidebarCollapsed;
|
||||
await wrapper.vm.toggleSidebar();
|
||||
expect(appStore.sidebarCollapsed).toBe(!initialCollapsed);
|
||||
});
|
||||
});
|
||||
|
||||
describe('主题切换', () => {
|
||||
it('应该能够切换主题', async () => {
|
||||
const initialTheme = appStore.isDarkMode;
|
||||
await wrapper.vm.toggleTheme();
|
||||
expect(appStore.isDarkMode).toBe(!initialTheme);
|
||||
});
|
||||
|
||||
it('切换到深色主题时应该显示成功消息', async () => {
|
||||
appStore.setTheme('light');
|
||||
await wrapper.vm.toggleTheme();
|
||||
expect(appStore.isDarkMode).toBe(true);
|
||||
});
|
||||
|
||||
it('切换到浅色主题时应该显示成功消息', async () => {
|
||||
appStore.setTheme('dark');
|
||||
await wrapper.vm.toggleTheme();
|
||||
expect(appStore.isDarkMode).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('语言切换', () => {
|
||||
it('应该能够切换语言', async () => {
|
||||
const { locale } = i18n.global;
|
||||
const initialLocale = locale.value;
|
||||
await wrapper.vm.toggleLanguage();
|
||||
expect(locale.value).not.toBe(initialLocale);
|
||||
});
|
||||
|
||||
it('应该保存语言设置到localStorage', async () => {
|
||||
await wrapper.vm.toggleLanguage();
|
||||
expect(localStorage.getItem('locale')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('用户菜单操作', () => {
|
||||
it('点击个人中心应该跳转到个人中心页面', async () => {
|
||||
await wrapper.vm.handleProfile();
|
||||
expect(mockPush).toHaveBeenCalledWith('/profile');
|
||||
});
|
||||
|
||||
it('点击设置应该跳转到设置页面', async () => {
|
||||
await wrapper.vm.handleSettings();
|
||||
expect(mockPush).toHaveBeenCalledWith('/settings');
|
||||
});
|
||||
|
||||
it('点击退出登录应该调用登出方法', async () => {
|
||||
authStore.logout = vi.fn().mockResolvedValue(true);
|
||||
await wrapper.vm.handleLogout();
|
||||
expect(authStore.logout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('退出登录成功后应该跳转到登录页', async () => {
|
||||
authStore.logout = vi.fn().mockResolvedValue(true);
|
||||
await wrapper.vm.handleLogout();
|
||||
expect(mockPush).toHaveBeenCalledWith('/login');
|
||||
});
|
||||
});
|
||||
|
||||
describe('面包屑导航', () => {
|
||||
it('应该显示首页面包屑', () => {
|
||||
expect(wrapper.vm.breadcrumbItems).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('应该根据当前路由显示正确的面包屑', () => {
|
||||
const items = wrapper.vm.breadcrumbItems;
|
||||
expect(items[0].title).toBe('仪表盘');
|
||||
expect(items[0].path).toBe('/dashboard');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
describe('localStorage 测试', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('应该能够设置和获取localStorage值', () => {
|
||||
localStorage.setItem('access_token', 'test-token');
|
||||
expect(localStorage.getItem('access_token')).toBe('test-token');
|
||||
});
|
||||
|
||||
it('应该能够删除localStorage值', () => {
|
||||
localStorage.setItem('access_token', 'test-token');
|
||||
localStorage.removeItem('access_token');
|
||||
expect(localStorage.getItem('access_token')).toBeNull();
|
||||
});
|
||||
|
||||
it('应该能够清除所有localStorage值', () => {
|
||||
localStorage.setItem('access_token', 'test-token');
|
||||
localStorage.setItem('refreshToken', 'test-refresh-token');
|
||||
localStorage.clear();
|
||||
expect(localStorage.getItem('access_token')).toBeNull();
|
||||
expect(localStorage.getItem('refreshToken')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { menuService } from '@/services/menu.service';
|
||||
|
||||
const { mockGet, mockPost, mockPut, mockDelete } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockPut: vi.fn(),
|
||||
mockDelete: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/request', () => ({
|
||||
default: {
|
||||
get: mockGet,
|
||||
post: mockPost,
|
||||
put: mockPut,
|
||||
delete: mockDelete
|
||||
}
|
||||
}));
|
||||
|
||||
describe('MenuService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getMenus', () => {
|
||||
it('应该能够获取菜单列表', async () => {
|
||||
const mockResponse = {
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
name: '系统管理',
|
||||
code: 'SYSTEM',
|
||||
type: 'MENU',
|
||||
path: '/system',
|
||||
icon: 'SettingOutlined',
|
||||
parentId: null,
|
||||
sortOrder: 1,
|
||||
status: 'ENABLED'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '用户管理',
|
||||
code: 'USER',
|
||||
type: 'MENU',
|
||||
path: '/system/user',
|
||||
icon: 'UserOutlined',
|
||||
parentId: 1,
|
||||
sortOrder: 1,
|
||||
status: 'ENABLED'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
mockGet.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await menuService.getMenus();
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/sys/menu');
|
||||
expect(result.data).toBeInstanceOf(Array);
|
||||
expect(result.data.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMenu', () => {
|
||||
it('应该能够创建新菜单', async () => {
|
||||
const newMenu = {
|
||||
name: '测试菜单',
|
||||
code: 'TEST_MENU',
|
||||
type: 'MENU',
|
||||
path: '/test',
|
||||
icon: 'TestOutlined',
|
||||
parentId: null,
|
||||
sortOrder: 100,
|
||||
status: 'ENABLED'
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
code: 200,
|
||||
message: '创建成功',
|
||||
data: {
|
||||
id: 10,
|
||||
...newMenu
|
||||
}
|
||||
};
|
||||
|
||||
mockPost.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await menuService.createMenu(newMenu);
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/sys/menu', newMenu);
|
||||
expect(result.data.id).toBe(10);
|
||||
});
|
||||
|
||||
it('应该验证必填字段', async () => {
|
||||
const invalidMenu = {
|
||||
name: '',
|
||||
code: ''
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
code: 400,
|
||||
message: '菜单名称和编码不能为空',
|
||||
data: null
|
||||
};
|
||||
|
||||
mockPost.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await menuService.createMenu(invalidMenu);
|
||||
|
||||
expect(result.code).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateMenu', () => {
|
||||
it('应该能够更新菜单', async () => {
|
||||
const updateMenu = {
|
||||
id: 1,
|
||||
name: '更新后的菜单',
|
||||
code: 'SYSTEM',
|
||||
type: 'MENU',
|
||||
path: '/system',
|
||||
icon: 'SettingOutlined',
|
||||
parentId: null,
|
||||
sortOrder: 1,
|
||||
status: 'ENABLED'
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
code: 200,
|
||||
message: '更新成功',
|
||||
data: updateMenu
|
||||
};
|
||||
|
||||
mockPut.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await menuService.updateMenu(updateMenu);
|
||||
|
||||
expect(mockPut).toHaveBeenCalledWith('/sys/menu', updateMenu);
|
||||
expect(result.data.name).toBe('更新后的菜单');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMenuById', () => {
|
||||
it('应该能够根据ID获取菜单详情', async () => {
|
||||
const menuId = 1;
|
||||
|
||||
const mockResponse = {
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
data: {
|
||||
id: 1,
|
||||
name: '系统管理',
|
||||
code: 'SYSTEM',
|
||||
type: 'MENU',
|
||||
path: '/system',
|
||||
icon: 'SettingOutlined',
|
||||
parentId: null,
|
||||
sortOrder: 1,
|
||||
status: 'ENABLED'
|
||||
}
|
||||
};
|
||||
|
||||
mockGet.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await menuService.getMenuById(menuId);
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/sys/menu/1');
|
||||
expect(result.data.id).toBe(menuId);
|
||||
});
|
||||
|
||||
it('应该处理不存在的菜单ID', async () => {
|
||||
const menuId = 999;
|
||||
|
||||
const mockResponse = {
|
||||
code: 404,
|
||||
message: '菜单不存在',
|
||||
data: null
|
||||
};
|
||||
|
||||
mockGet.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await menuService.getMenuById(menuId);
|
||||
|
||||
expect(result.code).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteMenu', () => {
|
||||
it('应该能够删除菜单', async () => {
|
||||
const menuId = 1;
|
||||
|
||||
const mockResponse = {
|
||||
code: 200,
|
||||
message: '删除成功',
|
||||
data: true
|
||||
};
|
||||
|
||||
mockDelete.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await menuService.deleteMenu(menuId);
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/sys/menu/1');
|
||||
expect(result.data).toBe(true);
|
||||
});
|
||||
|
||||
it('应该处理删除有子菜单的菜单', async () => {
|
||||
const menuId = 1;
|
||||
|
||||
const mockResponse = {
|
||||
code: 400,
|
||||
message: '该菜单下存在子菜单,无法删除',
|
||||
data: false
|
||||
};
|
||||
|
||||
mockDelete.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await menuService.deleteMenu(menuId);
|
||||
|
||||
expect(result.code).toBe(400);
|
||||
expect(result.data).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assignMenusToRole', () => {
|
||||
it('应该能够为角色分配菜单权限', async () => {
|
||||
const assignData = {
|
||||
roleId: 1,
|
||||
menuIds: [1, 2, 3]
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
code: 200,
|
||||
message: '分配成功',
|
||||
data: true
|
||||
};
|
||||
|
||||
mockPost.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await menuService.assignMenusToRole(assignData);
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith('/sys/menu/assign', assignData);
|
||||
expect(result.data).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMenusByRole', () => {
|
||||
it('应该能够获取角色的菜单权限', async () => {
|
||||
const roleId = 1;
|
||||
|
||||
const mockResponse = {
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
name: '系统管理',
|
||||
code: 'SYSTEM',
|
||||
type: 'MENU',
|
||||
path: '/system',
|
||||
icon: 'SettingOutlined',
|
||||
parentId: null,
|
||||
sortOrder: 1,
|
||||
status: 'ENABLED'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
mockGet.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await menuService.getMenusByRole(roleId);
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/sys/menu/role/1');
|
||||
expect(result.data).toBeInstanceOf(Array);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeMenuFromRole', () => {
|
||||
it('应该能够移除角色的菜单权限', async () => {
|
||||
const roleId = 1;
|
||||
const menuId = 2;
|
||||
|
||||
const mockResponse = {
|
||||
code: 200,
|
||||
message: '移除成功',
|
||||
data: true
|
||||
};
|
||||
|
||||
mockDelete.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await menuService.removeMenuFromRole(roleId, menuId);
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith('/sys/menu/role/1/menu/2');
|
||||
expect(result.data).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMenusByUser', () => {
|
||||
it('应该能够获取用户的菜单权限', async () => {
|
||||
const userId = 1;
|
||||
|
||||
const mockResponse = {
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
name: '系统管理',
|
||||
code: 'SYSTEM',
|
||||
type: 'MENU',
|
||||
path: '/system',
|
||||
icon: 'SettingOutlined',
|
||||
parentId: null,
|
||||
sortOrder: 1,
|
||||
status: 'ENABLED'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
mockGet.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await menuService.getMenusByUser(userId);
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/sys/menu/user/1');
|
||||
expect(result.data).toBeInstanceOf(Array);
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user