feat(admin): 添加用户管理相关文件

添加用户管理视图、API和状态管理文件
This commit is contained in:
张翔
2026-03-28 14:37:29 +08:00
commit 08ea5fbe98
1643 changed files with 255646 additions and 0 deletions
@@ -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
+5
View File
@@ -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

+733
View File
@@ -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;
}
+13
View File
@@ -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;
}
}
}
File diff suppressed because it is too large Load Diff
+66
View File
@@ -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: {},
},
}
+21
View File
@@ -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>
+34
View File
@@ -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 环境使用 Mockdevelopment 和 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