chore: 清理旧测试基础设施与废弃文件
- 移除 everything-is-suitable-test/ 旧测试框架 - 移除根目录 test-automation/、test-data-manager/、test-monitoring/ - 移除根目录 scripts/ 旧部署/测试脚本 - 移除 everything-is-suitable-uniapp/src/utils/httpClient.ts、tokenManager.ts - 移除根目录旧配置文件(jest.config.js、package.json、tsconfig.json 等) - 移除临时测试文件(test-*.ts、test-*.html、playwright-diagnose-*.js)
This commit is contained in:
@@ -1,49 +0,0 @@
|
|||||||
# 测试环境配置文件
|
|
||||||
|
|
||||||
NODE_ENV=test
|
|
||||||
TEST_ENV=ci
|
|
||||||
|
|
||||||
# 测试环境URL
|
|
||||||
API_BASE_URL=http://localhost:8083
|
|
||||||
FRONTEND_BASE_URL=http://localhost:5174
|
|
||||||
|
|
||||||
# 数据库配置(复用postgresql_dev容器)
|
|
||||||
DB_HOST=localhost
|
|
||||||
DB_PORT=55432
|
|
||||||
DB_NAME=everything_suitable_test
|
|
||||||
DB_USERNAME=postgres
|
|
||||||
DB_PASSWORD=postgres
|
|
||||||
|
|
||||||
# 企业微信配置
|
|
||||||
WECOM_WEBHOOK_URL=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY
|
|
||||||
WECOM_TABLE_ID=YOUR_TABLE_ID
|
|
||||||
WECOM_BOT_WEBHOOK=https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_BOT_KEY
|
|
||||||
|
|
||||||
# Redis配置
|
|
||||||
TEST_REDIS_PORT=6380
|
|
||||||
|
|
||||||
# 监控配置
|
|
||||||
TEST_PROMETHEUS_PORT=9091
|
|
||||||
TEST_GRAFANA_PORT=3001
|
|
||||||
|
|
||||||
# Grafana配置
|
|
||||||
GRAFANA_ADMIN_USER=admin
|
|
||||||
GRAFANA_ADMIN_PASSWORD=admin
|
|
||||||
|
|
||||||
# 测试数据配置
|
|
||||||
TEST_DATA_DIR=./test-data
|
|
||||||
TEST_DATA_SCRIPTS_DIR=./test-data/scripts
|
|
||||||
|
|
||||||
# 时区
|
|
||||||
TZ=Asia/Shanghai
|
|
||||||
|
|
||||||
# 日志配置
|
|
||||||
LOG_LEVEL=DEBUG
|
|
||||||
LOG_FORMAT=JSON
|
|
||||||
|
|
||||||
# 测试超时配置
|
|
||||||
TEST_TIMEOUT=30000
|
|
||||||
TEST_RETRY_COUNT=3
|
|
||||||
|
|
||||||
# 测试环境标识
|
|
||||||
ENVIRONMENT=test
|
|
||||||
+2
-20
@@ -1,14 +1,3 @@
|
|||||||
# ==================== Java / Spring Boot ====================
|
|
||||||
target/
|
|
||||||
*.class
|
|
||||||
*.jar
|
|
||||||
*.war
|
|
||||||
*.ear
|
|
||||||
*.log
|
|
||||||
|
|
||||||
.mvn/
|
|
||||||
mvnw.cmd
|
|
||||||
|
|
||||||
# ==================== Node.js / Vue / UniApp ====================
|
# ==================== Node.js / Vue / UniApp ====================
|
||||||
node_modules/
|
node_modules/
|
||||||
.pnp
|
.pnp
|
||||||
@@ -215,12 +204,5 @@ config/local.js
|
|||||||
.lingma/
|
.lingma/
|
||||||
.trae/
|
.trae/
|
||||||
|
|
||||||
# ==================== Project Specific ====================
|
# agents
|
||||||
everything-is-suitable-api/api-test-tool/data/*.json
|
AGENTS.md
|
||||||
everything-is-suitable-api/api-test-tool/data/*.csv
|
|
||||||
everything-is-suitable-api/api-test-tool/test_cases/*.json
|
|
||||||
everything-is-suitable-api/api-test-tool/test_cases/*.yaml
|
|
||||||
everything-is-suitable-api/api-test-tool/reports/*
|
|
||||||
!everything-is-suitable-api/api-test-tool/reports/.gitkeep
|
|
||||||
everything-is-suitable-api/api-test-tool/logs/*
|
|
||||||
!everything-is-suitable-api/api-test-tool/logs/.gitkeep
|
|
||||||
|
|||||||
-399
@@ -1,399 +0,0 @@
|
|||||||
clone:
|
|
||||||
git:
|
|
||||||
image: woodpeckerci/plugin-git
|
|
||||||
settings:
|
|
||||||
depth: 1
|
|
||||||
|
|
||||||
pipeline:
|
|
||||||
lint-api:
|
|
||||||
image: maven:3.9.9-eclipse-temurin-21
|
|
||||||
commands:
|
|
||||||
- cd everything-is-suitable-api
|
|
||||||
- mvn checkstyle:check
|
|
||||||
when:
|
|
||||||
event: [push, pull_request]
|
|
||||||
|
|
||||||
test-api:
|
|
||||||
image: maven:3.9.9-eclipse-temurin-21
|
|
||||||
commands:
|
|
||||||
- cd everything-is-suitable-api
|
|
||||||
- mvn test
|
|
||||||
when:
|
|
||||||
event: [push, pull_request]
|
|
||||||
|
|
||||||
build-api:
|
|
||||||
image: woodpeckerci/plugin-docker
|
|
||||||
settings:
|
|
||||||
registry: ${DOCKER_REGISTRY}
|
|
||||||
username: ${DOCKER_USERNAME}
|
|
||||||
password: ${DOCKER_PASSWORD}
|
|
||||||
repo: ${DOCKER_REGISTRY}/everything-is-suitable-api
|
|
||||||
tags: latest, ${CI_COMMIT_SHA:0:8}
|
|
||||||
dockerfile: everything-is-suitable-api/Dockerfile
|
|
||||||
context: .
|
|
||||||
when:
|
|
||||||
event: [push, tag]
|
|
||||||
branch: [main, develop]
|
|
||||||
|
|
||||||
lint-admin:
|
|
||||||
image: node:20-alpine
|
|
||||||
commands:
|
|
||||||
- cd everything-is-suitable-admin
|
|
||||||
- npm ci
|
|
||||||
- npm run lint
|
|
||||||
when:
|
|
||||||
event: [push, pull_request]
|
|
||||||
|
|
||||||
test-admin:
|
|
||||||
image: node:20-alpine
|
|
||||||
commands:
|
|
||||||
- cd everything-is-suitable-admin
|
|
||||||
- npm ci
|
|
||||||
- npm run test
|
|
||||||
when:
|
|
||||||
event: [push, pull_request]
|
|
||||||
|
|
||||||
smart-test-selection:
|
|
||||||
image: node:20-alpine
|
|
||||||
commands:
|
|
||||||
- npm ci
|
|
||||||
- |
|
|
||||||
# 获取变更文件
|
|
||||||
if [ "$CI_BUILD_EVENT" = "cron" ]; then
|
|
||||||
echo "[]" > changed-files.txt
|
|
||||||
else
|
|
||||||
git diff --name-only origin/main...HEAD > changed-files.txt || echo "[]" > changed-files.txt
|
|
||||||
fi
|
|
||||||
- |
|
|
||||||
# 智能选择测试用例
|
|
||||||
node dist/scripts/scripts/cli/smart-test-selector-cli.js \
|
|
||||||
--input changed-files.txt \
|
|
||||||
--output selected-tests.json \
|
|
||||||
--report test-selection-report.md
|
|
||||||
when:
|
|
||||||
event: [push, pull_request]
|
|
||||||
|
|
||||||
smart-test-execution:
|
|
||||||
image: node:20-alpine
|
|
||||||
environment:
|
|
||||||
- TEST_ENV=ci
|
|
||||||
- API_BASE_URL=http://localhost:8083
|
|
||||||
- FRONTEND_BASE_URL=http://localhost:5174
|
|
||||||
commands:
|
|
||||||
- npm ci
|
|
||||||
- npx playwright install --with-deps chromium
|
|
||||||
- |
|
|
||||||
if [ -f selected-tests.json ]; then
|
|
||||||
npm run test:smart selected-tests.json
|
|
||||||
else
|
|
||||||
npm run test:all
|
|
||||||
fi
|
|
||||||
when:
|
|
||||||
event: [push, pull_request]
|
|
||||||
depends_on:
|
|
||||||
- smart-test-selection
|
|
||||||
|
|
||||||
e2e-test-admin:
|
|
||||||
image: node:20-alpine
|
|
||||||
commands:
|
|
||||||
- cd everything-is-suitable-admin
|
|
||||||
- npm ci
|
|
||||||
- npx playwright install --with-deps
|
|
||||||
- npm run test:e2e
|
|
||||||
when:
|
|
||||||
event: [push, pull_request]
|
|
||||||
|
|
||||||
build-admin:
|
|
||||||
image: woodpeckerci/plugin-docker
|
|
||||||
settings:
|
|
||||||
registry: ${DOCKER_REGISTRY}
|
|
||||||
username: ${DOCKER_USERNAME}
|
|
||||||
password: ${DOCKER_PASSWORD}
|
|
||||||
repo: ${DOCKER_REGISTRY}/everything-is-suitable-admin
|
|
||||||
tags: latest, ${CI_COMMIT_SHA:0:8}
|
|
||||||
dockerfile: everything-is-suitable-admin/Dockerfile
|
|
||||||
context: .
|
|
||||||
when:
|
|
||||||
event: [push, tag]
|
|
||||||
branch: [main, develop]
|
|
||||||
|
|
||||||
lint-uniapp:
|
|
||||||
image: node:20-alpine
|
|
||||||
commands:
|
|
||||||
- cd everything-is-suitable-uniapp
|
|
||||||
- npm ci
|
|
||||||
- npm run lint
|
|
||||||
when:
|
|
||||||
event: [push, pull_request]
|
|
||||||
|
|
||||||
test-uniapp:
|
|
||||||
image: node:20-alpine
|
|
||||||
commands:
|
|
||||||
- cd everything-is-suitable-uniapp
|
|
||||||
- npm ci
|
|
||||||
- npm run test
|
|
||||||
when:
|
|
||||||
event: [push, pull_request]
|
|
||||||
|
|
||||||
e2e-test-uniapp:
|
|
||||||
image: node:20-alpine
|
|
||||||
commands:
|
|
||||||
- cd everything-is-suitable-uniapp
|
|
||||||
- npm ci
|
|
||||||
- npx playwright install --with-deps
|
|
||||||
- npm run test:e2e
|
|
||||||
when:
|
|
||||||
event: [push, pull_request]
|
|
||||||
|
|
||||||
build-uniapp:
|
|
||||||
image: woodpeckerci/plugin-docker
|
|
||||||
settings:
|
|
||||||
registry: ${DOCKER_REGISTRY}
|
|
||||||
username: ${DOCKER_USERNAME}
|
|
||||||
password: ${DOCKER_PASSWORD}
|
|
||||||
repo: ${DOCKER_REGISTRY}/everything-is-suitable-uniapp
|
|
||||||
tags: latest, ${CI_COMMIT_SHA:0:8}
|
|
||||||
dockerfile: everything-is-suitable-uniapp/Dockerfile
|
|
||||||
context: .
|
|
||||||
when:
|
|
||||||
event: [push, tag]
|
|
||||||
branch: [main, develop]
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
image: alpine:3.19
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache openssh-client
|
|
||||||
- mkdir -p ~/.ssh
|
|
||||||
- echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
|
|
||||||
- chmod 600 ~/.ssh/id_rsa
|
|
||||||
- ssh-keyscan -H ${DEPLOY_HOST} >> ~/.ssh/known_hosts
|
|
||||||
- ssh ${DEPLOY_USER}@${DEPLOY_HOST} "cd ${DEPLOY_PATH} && docker-compose pull && docker-compose up -d"
|
|
||||||
environment:
|
|
||||||
SSH_PRIVATE_KEY:
|
|
||||||
from_secret: ssh_private_key
|
|
||||||
DEPLOY_HOST:
|
|
||||||
from_secret: deploy_host
|
|
||||||
DEPLOY_USER:
|
|
||||||
from_secret: deploy_user
|
|
||||||
DEPLOY_PATH:
|
|
||||||
from_secret: deploy_path
|
|
||||||
when:
|
|
||||||
event: [push, tag]
|
|
||||||
branch: [main]
|
|
||||||
|
|
||||||
test-env-setup:
|
|
||||||
image: docker:24-cli
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache docker-compose
|
|
||||||
- docker-compose -f docker-compose.test-new.yml --env-file .env.test up -d
|
|
||||||
- sleep 30
|
|
||||||
- docker-compose -f docker-compose.test-new.yml --env-file .env.test ps
|
|
||||||
when:
|
|
||||||
event: [push, pull_request]
|
|
||||||
branch: [main, develop]
|
|
||||||
|
|
||||||
integration-test:
|
|
||||||
image: node:20-alpine
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache python3 py3-pip
|
|
||||||
- pip3 install psycopg2-binary requests
|
|
||||||
- cd everything-is-suitable-admin
|
|
||||||
- npm ci
|
|
||||||
- npm run test:integration
|
|
||||||
environment:
|
|
||||||
TEST_API_URL: http://test-api-gateway:8080
|
|
||||||
TEST_ADMIN_URL: http://test-admin-backend:8081
|
|
||||||
when:
|
|
||||||
event: [push, pull_request]
|
|
||||||
branch: [main, develop]
|
|
||||||
|
|
||||||
test-env-cleanup:
|
|
||||||
image: docker:24-cli
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache docker-compose
|
|
||||||
- docker-compose -f docker-compose.test-new.yml --env-file .env.test down -v
|
|
||||||
when:
|
|
||||||
event: [push, pull_request]
|
|
||||||
branch: [main, develop]
|
|
||||||
status: [success, failure]
|
|
||||||
|
|
||||||
lint-e2e-test:
|
|
||||||
image: node:20-alpine
|
|
||||||
commands:
|
|
||||||
- cd everything-is-suitable-test
|
|
||||||
- npm ci
|
|
||||||
- npm run lint
|
|
||||||
when:
|
|
||||||
event: [push, pull_request]
|
|
||||||
|
|
||||||
test-e2e-smoke:
|
|
||||||
image: node:20-alpine
|
|
||||||
commands:
|
|
||||||
- cd everything-is-suitable-test
|
|
||||||
- npm ci
|
|
||||||
- npx playwright install --with-deps
|
|
||||||
- npm run test:e2e:smoke
|
|
||||||
environment:
|
|
||||||
E2E_BASE_URL: http://test-admin-backend:8081
|
|
||||||
E2E_API_URL: http://test-api-gateway:8080
|
|
||||||
CI: true
|
|
||||||
when:
|
|
||||||
event: [push, pull_request]
|
|
||||||
branch: [main, develop]
|
|
||||||
|
|
||||||
test-e2e-regression:
|
|
||||||
image: node:20-alpine
|
|
||||||
commands:
|
|
||||||
- cd everything-is-suitable-test
|
|
||||||
- npm ci
|
|
||||||
- npx playwright install --with-deps
|
|
||||||
- npm run test:e2e:regression
|
|
||||||
environment:
|
|
||||||
E2E_BASE_URL: http://test-admin-backend:8081
|
|
||||||
E2E_API_URL: http://test-api-gateway:8080
|
|
||||||
CI: true
|
|
||||||
when:
|
|
||||||
event: [push, pull_request]
|
|
||||||
branch: [main, develop]
|
|
||||||
|
|
||||||
test-e2e-full:
|
|
||||||
image: node:20-alpine
|
|
||||||
commands:
|
|
||||||
- cd everything-is-suitable-test
|
|
||||||
- npm ci
|
|
||||||
- npx playwright install --with-deps
|
|
||||||
- npm run test:e2e:full
|
|
||||||
environment:
|
|
||||||
E2E_BASE_URL: http://test-admin-backend:8081
|
|
||||||
E2E_API_URL: http://test-api-gateway:8080
|
|
||||||
CI: true
|
|
||||||
when:
|
|
||||||
event: [push]
|
|
||||||
branch: [main, develop]
|
|
||||||
|
|
||||||
test-report-upload:
|
|
||||||
image: alpine:3.19
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache curl
|
|
||||||
- cd everything-is-suitable-test
|
|
||||||
- |
|
|
||||||
if [ -d "test-results" ]; then
|
|
||||||
echo "Uploading test reports..."
|
|
||||||
curl -X POST -H "Authorization: Bearer $REPORT_UPLOAD_TOKEN" \
|
|
||||||
-F "report=@test-results/results.json" \
|
|
||||||
-F "junit=@test-results/junit.xml" \
|
|
||||||
$REPORT_UPLOAD_URL || echo "Report upload failed"
|
|
||||||
fi
|
|
||||||
environment:
|
|
||||||
REPORT_UPLOAD_TOKEN:
|
|
||||||
from_secret: report_upload_token
|
|
||||||
REPORT_UPLOAD_URL:
|
|
||||||
from_secret: report_upload_url
|
|
||||||
when:
|
|
||||||
event: [push, pull_request]
|
|
||||||
branch: [main, develop]
|
|
||||||
status: [success, failure]
|
|
||||||
|
|
||||||
test-failure-notification:
|
|
||||||
image: alpine:3.19
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache curl
|
|
||||||
- |
|
|
||||||
echo "Checking test results..."
|
|
||||||
if [ -f "everything-is-suitable-test/test-results/results.json" ]; then
|
|
||||||
FAILED_COUNT=$(grep -o '"status":"failed"' everything-is-suitable-test/test-results/results.json | wc -l)
|
|
||||||
if [ "$FAILED_COUNT" -gt 0 ]; then
|
|
||||||
echo "E2E tests failed: $FAILED_COUNT tests failed"
|
|
||||||
curl -X POST -H "Authorization: Bearer $NOTIFICATION_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"title\":\"E2E测试失败\",\"message\":\"$FAILED_COUNT个测试失败\",\"priority\":\"high\"}" \
|
|
||||||
$NOTIFICATION_URL || echo "Notification failed"
|
|
||||||
exit 1
|
|
||||||
else
|
|
||||||
echo "All E2E tests passed"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
environment:
|
|
||||||
NOTIFICATION_TOKEN:
|
|
||||||
from_secret: notification_token
|
|
||||||
NOTIFICATION_URL:
|
|
||||||
from_secret: notification_url
|
|
||||||
when:
|
|
||||||
event: [push, pull_request]
|
|
||||||
branch: [main, develop]
|
|
||||||
status: [failure]
|
|
||||||
|
|
||||||
test-success-notification:
|
|
||||||
image: alpine:3.19
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache curl
|
|
||||||
- |
|
|
||||||
echo "E2E tests passed successfully"
|
|
||||||
curl -X POST -H "Authorization: Bearer $NOTIFICATION_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"title\":\"E2E测试通过\",\"message\":\"所有E2E测试通过\",\"priority\":\"low\"}" \
|
|
||||||
$NOTIFICATION_URL || echo "Notification failed"
|
|
||||||
environment:
|
|
||||||
NOTIFICATION_TOKEN:
|
|
||||||
from_secret: notification_token
|
|
||||||
NOTIFICATION_URL:
|
|
||||||
from_secret: notification_url
|
|
||||||
when:
|
|
||||||
event: [push, pull_request]
|
|
||||||
branch: [main, develop]
|
|
||||||
status: [success]
|
|
||||||
|
|
||||||
test-coverage-report:
|
|
||||||
image: alpine:3.19
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache nodejs npm
|
|
||||||
- cd everything-is-suitable-test
|
|
||||||
- |
|
|
||||||
echo "Generating test coverage report..."
|
|
||||||
|
|
||||||
# Create coverage report directory
|
|
||||||
mkdir -p test-results/coverage
|
|
||||||
|
|
||||||
# Generate coverage report
|
|
||||||
node -e "
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const coverageData = {
|
|
||||||
totalTests: 0,
|
|
||||||
passedTests: 0,
|
|
||||||
failedTests: 0,
|
|
||||||
skippedTests: 0,
|
|
||||||
passRate: 0,
|
|
||||||
testSuites: [],
|
|
||||||
executionTime: 0,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Read test results
|
|
||||||
if (fs.existsSync('test-results/results.json')) {
|
|
||||||
const testResults = JSON.parse(fs.readFileSync('test-results/results.json', 'utf-8'));
|
|
||||||
coverageData.totalTests = testResults.stats?.expected || 0;
|
|
||||||
coverageData.passedTests = testResults.stats?.passed || 0;
|
|
||||||
coverageData.failedTests = testResults.stats?.failed || 0;
|
|
||||||
coverageData.skippedTests = testResults.stats?.skipped || 0;
|
|
||||||
coverageData.passRate = coverageData.totalTests > 0 ? (coverageData.passedTests / coverageData.totalTests) * 100 : 0;
|
|
||||||
coverageData.executionTime = testResults.stats?.duration || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write coverage report
|
|
||||||
fs.writeFileSync('test-results/coverage/coverage.json', JSON.stringify(coverageData, null, 2));
|
|
||||||
console.log('Coverage report generated successfully');
|
|
||||||
console.log('Total tests:', coverageData.totalTests);
|
|
||||||
console.log('Passed tests:', coverageData.passedTests);
|
|
||||||
console.log('Failed tests:', coverageData.failedTests);
|
|
||||||
console.log('Skipped tests:', coverageData.skippedTests);
|
|
||||||
console.log('Pass rate:', coverageData.passRate.toFixed(2) + '%');
|
|
||||||
"
|
|
||||||
environment:
|
|
||||||
COVERAGE_THRESHOLD:
|
|
||||||
from_secret: coverage_threshold
|
|
||||||
when:
|
|
||||||
event: [push, pull_request]
|
|
||||||
branch: [main, develop]
|
|
||||||
status: [success, failure]
|
|
||||||
-349
@@ -1,349 +0,0 @@
|
|||||||
# CI/CD部署文档
|
|
||||||
|
|
||||||
## 架构概览
|
|
||||||
|
|
||||||
本项目采用Forgejo + Woodpecker CI + Docker Registry的CI/CD架构,通过docker-compose进行容器化部署。
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Forgejo (代码仓库) │
|
|
||||||
│ Woodpecker CI (CI/CD) │
|
|
||||||
│ Docker Registry (镜像仓库) │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
构建Docker镜像并推送
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ docker-compose 部署 │
|
|
||||||
├─────────────────────────────────────────────────────────────┤
|
|
||||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
|
||||||
│ │ API容器 │ │ Admin容器 │ │ UniApp容器 │ │
|
|
||||||
│ │ (Spring Boot) │ │ (Nginx) │ │ (Nginx) │ │
|
|
||||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
|
||||||
│ ┌──────────────┐ │
|
|
||||||
│ │ PostgreSQL │ │
|
|
||||||
│ └──────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
everything-is-suitable/
|
|
||||||
├── everything-is-suitable-api/ # Spring Boot API
|
|
||||||
│ ├── Dockerfile # API镜像构建文件
|
|
||||||
│ └── pom.xml # Maven配置
|
|
||||||
├── everything-is-suitable-admin/ # Vue 3 Admin
|
|
||||||
│ ├── Dockerfile # Admin镜像构建文件
|
|
||||||
│ ├── nginx.conf # Nginx配置
|
|
||||||
│ └── package.json # npm配置
|
|
||||||
├── everything-is-suitable-uniapp/ # UniApp移动端
|
|
||||||
│ ├── Dockerfile # UniApp镜像构建文件
|
|
||||||
│ ├── nginx.conf # Nginx配置
|
|
||||||
│ └── package.json # npm配置
|
|
||||||
├── docker-compose.yml # 生产环境部署编排
|
|
||||||
└── .woodpecker.yml # Woodpecker CI配置
|
|
||||||
```
|
|
||||||
|
|
||||||
## 本地开发
|
|
||||||
|
|
||||||
### 前置要求
|
|
||||||
|
|
||||||
- Docker 20.10+
|
|
||||||
- Docker Compose 2.0+
|
|
||||||
- Node.js 20+
|
|
||||||
- Java 21+
|
|
||||||
- Maven 3.9+
|
|
||||||
|
|
||||||
### 启动开发环境
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 启动数据库
|
|
||||||
docker-compose up -d postgres
|
|
||||||
|
|
||||||
# 启动API
|
|
||||||
cd everything-is-suitable-api
|
|
||||||
mvn spring-boot:run -Dspring-boot.run.profiles=local
|
|
||||||
|
|
||||||
# 启动Admin
|
|
||||||
cd ../everything-is-suitable-admin
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# 启动UniApp
|
|
||||||
cd ../everything-is-suitable-uniapp
|
|
||||||
npm run dev:h5
|
|
||||||
```
|
|
||||||
|
|
||||||
## CI/CD流程
|
|
||||||
|
|
||||||
### Woodpecker CI流水线
|
|
||||||
|
|
||||||
Woodpecker CI在每次代码提交时自动执行以下步骤:
|
|
||||||
|
|
||||||
1. **代码检查**
|
|
||||||
- API: `mvn spotless:check`
|
|
||||||
- Admin: `npm run lint`
|
|
||||||
- UniApp: `npm run lint`
|
|
||||||
|
|
||||||
2. **单元测试**
|
|
||||||
- API: `mvn test`
|
|
||||||
- Admin: `npm run test`
|
|
||||||
- UniApp: `npm run test`
|
|
||||||
|
|
||||||
3. **E2E测试**
|
|
||||||
- Admin: `npm run test:e2e`
|
|
||||||
- UniApp: `npm run test:e2e`
|
|
||||||
|
|
||||||
4. **构建Docker镜像**
|
|
||||||
- 推送到Docker Registry
|
|
||||||
- 标签: `latest` 和 `${CI_COMMIT_SHA:0:8}`
|
|
||||||
|
|
||||||
5. **部署**
|
|
||||||
- SSH连接到部署服务器
|
|
||||||
- 拉取最新镜像
|
|
||||||
- 重启容器
|
|
||||||
|
|
||||||
### Woodpecker Secrets配置
|
|
||||||
|
|
||||||
在Forgejo仓库设置中配置以下Secrets:
|
|
||||||
|
|
||||||
| Secret名称 | 说明 | 示例 |
|
|
||||||
|-----------|------|------|
|
|
||||||
| `DOCKER_REGISTRY` | Docker Registry地址 | `registry.example.com` |
|
|
||||||
| `DOCKER_USERNAME` | Docker用户名 | `your-username` |
|
|
||||||
| `DOCKER_PASSWORD` | Docker密码 | `your-password` |
|
|
||||||
| `SSH_PRIVATE_KEY` | SSH私钥 | `-----BEGIN RSA PRIVATE KEY-----...` |
|
|
||||||
| `DEPLOY_HOST` | 部署服务器地址 | `deploy.example.com` |
|
|
||||||
| `DEPLOY_USER` | 部署服务器用户名 | `deploy` |
|
|
||||||
| `DEPLOY_PATH` | 部署路径 | `/opt/everything-is-suitable` |
|
|
||||||
|
|
||||||
## 生产部署
|
|
||||||
|
|
||||||
### 1. 准备部署环境
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 安装Docker和Docker Compose
|
|
||||||
curl -fsSL https://get.docker.com | sh
|
|
||||||
sudo usermod -aG docker $USER
|
|
||||||
|
|
||||||
# 创建部署目录
|
|
||||||
mkdir -p /opt/everything-is-suitable
|
|
||||||
cd /opt/everything-is-suitable
|
|
||||||
|
|
||||||
# 复制配置文件
|
|
||||||
cp docker-compose.yml .
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 配置环境变量
|
|
||||||
|
|
||||||
编辑 `.env` 文件:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 数据库密码
|
|
||||||
POSTGRES_PASSWORD=your_secure_password
|
|
||||||
|
|
||||||
# Docker Registry配置
|
|
||||||
DOCKER_REGISTRY=registry.example.com
|
|
||||||
DOCKER_USERNAME=your-username
|
|
||||||
DOCKER_PASSWORD=your-password
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 启动服务
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 拉取镜像
|
|
||||||
docker-compose pull
|
|
||||||
|
|
||||||
# 启动所有服务
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# 查看服务状态
|
|
||||||
docker-compose ps
|
|
||||||
|
|
||||||
# 查看日志
|
|
||||||
docker-compose logs -f
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 验证部署
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 检查API健康状态
|
|
||||||
curl http://localhost:8080/actuator/health
|
|
||||||
|
|
||||||
# 检查Admin页面
|
|
||||||
curl http://localhost/
|
|
||||||
|
|
||||||
# 检查UniApp页面
|
|
||||||
curl http://localhost:8081/
|
|
||||||
```
|
|
||||||
|
|
||||||
## 服务端口
|
|
||||||
|
|
||||||
| 服务 | 端口 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| Admin | 80 | 管理后台 |
|
|
||||||
| API | 8080 | 后端API |
|
|
||||||
| UniApp | 8081 | 移动端H5 |
|
|
||||||
| PostgreSQL | 5432 | 数据库 |
|
|
||||||
|
|
||||||
## 常用命令
|
|
||||||
|
|
||||||
### Docker Compose
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 启动所有服务
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# 停止所有服务
|
|
||||||
docker-compose down
|
|
||||||
|
|
||||||
# 重启服务
|
|
||||||
docker-compose restart
|
|
||||||
|
|
||||||
# 查看日志
|
|
||||||
docker-compose logs -f [service-name]
|
|
||||||
|
|
||||||
# 查看服务状态
|
|
||||||
docker-compose ps
|
|
||||||
|
|
||||||
# 更新镜像并重启
|
|
||||||
docker-compose pull && docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### 容器管理
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查看运行中的容器
|
|
||||||
docker ps
|
|
||||||
|
|
||||||
# 查看容器日志
|
|
||||||
docker logs -f [container-id]
|
|
||||||
|
|
||||||
# 进入容器
|
|
||||||
docker exec -it [container-id] sh
|
|
||||||
|
|
||||||
# 重启容器
|
|
||||||
docker restart [container-id]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 监控和日志
|
|
||||||
|
|
||||||
### 日志查看
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# API日志
|
|
||||||
docker-compose logs -f api
|
|
||||||
|
|
||||||
# Admin日志
|
|
||||||
docker-compose logs -f admin
|
|
||||||
|
|
||||||
# UniApp日志
|
|
||||||
docker-compose logs -f uniapp
|
|
||||||
|
|
||||||
# 数据库日志
|
|
||||||
docker-compose logs -f postgres
|
|
||||||
|
|
||||||
# 所有服务日志
|
|
||||||
docker-compose logs -f
|
|
||||||
```
|
|
||||||
|
|
||||||
### 健康检查
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 检查所有服务健康状态
|
|
||||||
docker-compose ps
|
|
||||||
|
|
||||||
# 检查API健康端点
|
|
||||||
curl http://localhost:8080/actuator/health
|
|
||||||
|
|
||||||
# 检查数据库连接
|
|
||||||
docker-compose exec postgres pg_isready -U postgres
|
|
||||||
```
|
|
||||||
|
|
||||||
## 备份和恢复
|
|
||||||
|
|
||||||
### 数据库备份
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 备份数据库
|
|
||||||
docker-compose exec postgres pg_dump -U postgres everything_is_suitable > backup.sql
|
|
||||||
|
|
||||||
# 恢复数据库
|
|
||||||
docker-compose exec -T postgres psql -U postgres everything_is_suitable < backup.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### 数据卷备份
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 备份PostgreSQL数据卷
|
|
||||||
docker run --rm -v everything-is-suitable_postgres_data:/data -v $(pwd):/backup alpine tar czf /backup/postgres_data_backup.tar.gz -C /data .
|
|
||||||
|
|
||||||
# 恢复PostgreSQL数据卷
|
|
||||||
docker run --rm -v everything-is-suitable_postgres_data:/data -v $(pwd):/backup alpine tar xzf /backup/postgres_data_backup.tar.gz -C /data
|
|
||||||
```
|
|
||||||
|
|
||||||
## 故障排查
|
|
||||||
|
|
||||||
### 容器无法启动
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查看容器日志
|
|
||||||
docker-compose logs [service-name]
|
|
||||||
|
|
||||||
# 检查容器状态
|
|
||||||
docker-compose ps
|
|
||||||
|
|
||||||
# 检查资源使用
|
|
||||||
docker stats
|
|
||||||
```
|
|
||||||
|
|
||||||
### 网络问题
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 检查网络连接
|
|
||||||
docker network ls
|
|
||||||
docker network inspect everything-is-suitable_app-network
|
|
||||||
|
|
||||||
# 测试容器间连接
|
|
||||||
docker-compose exec api ping postgres
|
|
||||||
```
|
|
||||||
|
|
||||||
### 性能问题
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查看资源使用
|
|
||||||
docker stats
|
|
||||||
|
|
||||||
# 查看容器资源限制
|
|
||||||
docker inspect [container-id] | grep -A 10 Memory
|
|
||||||
```
|
|
||||||
|
|
||||||
## 安全建议
|
|
||||||
|
|
||||||
1. **修改默认密码**
|
|
||||||
- 修改PostgreSQL密码
|
|
||||||
|
|
||||||
2. **网络隔离**
|
|
||||||
- 使用Docker网络隔离服务
|
|
||||||
- 只暴露必要的端口
|
|
||||||
|
|
||||||
3. **定期更新**
|
|
||||||
- 定期更新Docker镜像
|
|
||||||
- 更新依赖包
|
|
||||||
|
|
||||||
4. **备份策略**
|
|
||||||
- 定期备份数据库
|
|
||||||
- 备份配置文件
|
|
||||||
|
|
||||||
5. **监控告警**
|
|
||||||
- 配置Prometheus监控
|
|
||||||
- 配置Grafana仪表盘
|
|
||||||
- 设置告警规则
|
|
||||||
|
|
||||||
## 扩展阅读
|
|
||||||
|
|
||||||
- [Docker Compose文档](https://docs.docker.com/compose/)
|
|
||||||
- [Woodpecker CI文档](https://woodpecker-ci.org/docs/)
|
|
||||||
- [Spring Boot Docker部署](https://spring.io/guides/topicals/spring-boot-docker/)
|
|
||||||
- [Nginx配置指南](https://nginx.org/en/docs/)
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
FROM python:3.11-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
postgresql-client \
|
|
||||||
curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY requirements-test-data.txt .
|
|
||||||
RUN pip install --no-cache-dir -r requirements-test-data.txt
|
|
||||||
|
|
||||||
COPY test-data-manager/ ./test-data-manager/
|
|
||||||
|
|
||||||
CMD ["python", "-m", "test_data_manager.main"]
|
|
||||||
@@ -1,406 +0,0 @@
|
|||||||
# Everything is Suitable - 应用启动指南
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
本指南提供了启动Everything is Suitable项目所有应用的详细步骤,包括Web管理前端、Uniapp小程序、后端API和测试环境。
|
|
||||||
|
|
||||||
## 前置要求
|
|
||||||
|
|
||||||
### 1. 系统要求
|
|
||||||
|
|
||||||
- **操作系统**:macOS、Linux或Windows
|
|
||||||
- **Node.js**:18.0.0或更高版本
|
|
||||||
- **Python**:3.10或更高版本
|
|
||||||
- **Java**:21或更高版本
|
|
||||||
- **Maven**:3.8或更高版本
|
|
||||||
- **PostgreSQL**:14或更高版本
|
|
||||||
|
|
||||||
### 2. 开发工具
|
|
||||||
|
|
||||||
- **IDE**:VS Code、IntelliJ IDEA或WebStorm
|
|
||||||
- **Git**:用于版本控制
|
|
||||||
- **浏览器**:Chrome、Firefox或Safari(用于E2E测试)
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
everything-is-suitable/
|
|
||||||
├── everything-is-suitable-admin/ # Web管理前端(Vue 3 + TypeScript)
|
|
||||||
├── everything-is-suitable-uniapp/ # Uniapp小程序
|
|
||||||
├── everything-is-suitable-api/ # 后端API(Spring Boot)
|
|
||||||
└── everything-is-suitable-test/ # 统一测试平台
|
|
||||||
├── e2e/ # TypeScript E2E测试
|
|
||||||
├── python_e2e/ # Python E2E测试
|
|
||||||
└── api/ # Python API测试
|
|
||||||
```
|
|
||||||
|
|
||||||
## 启动步骤
|
|
||||||
|
|
||||||
### 1. 启动后端API(everything-is-suitable-api)
|
|
||||||
|
|
||||||
#### 1.1 配置数据库
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 启动PostgreSQL
|
|
||||||
brew services start postgresql
|
|
||||||
|
|
||||||
# 创建数据库
|
|
||||||
psql -U postgres
|
|
||||||
CREATE DATABASE everything_is_suitable;
|
|
||||||
CREATE USER everything_user WITH PASSWORD 'your_password';
|
|
||||||
GRANT ALL PRIVILEGES ON DATABASE everything_is_suitable TO everything_user;
|
|
||||||
\q
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 1.2 配置环境变量
|
|
||||||
|
|
||||||
创建 `everything-is-suitable-api/src/main/resources/application-local.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
spring:
|
|
||||||
datasource:
|
|
||||||
url: jdbc:postgresql://localhost:5432/everything_is_suitable
|
|
||||||
username: everything_user
|
|
||||||
password: your_password
|
|
||||||
driver-class-name: org.postgresql.Driver
|
|
||||||
jpa:
|
|
||||||
hibernate:
|
|
||||||
ddl-auto: update
|
|
||||||
show-sql: true
|
|
||||||
flyway:
|
|
||||||
enabled: true
|
|
||||||
locations: classpath:db/migration
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 1.3 启动应用
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd everything-is-suitable-api
|
|
||||||
|
|
||||||
# 方式1:使用Maven启动
|
|
||||||
mvn spring-boot:run -Dspring-boot.run.profiles=local
|
|
||||||
|
|
||||||
# 方式2:使用IDE启动
|
|
||||||
# 在IntelliJ IDEA中打开项目
|
|
||||||
# 运行 EverythingIsSuitableApplication.java
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 1.4 验证启动
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 检查API是否启动成功
|
|
||||||
curl http://localhost:8080/actuator/health
|
|
||||||
|
|
||||||
# 预期输出
|
|
||||||
{"status":"UP"}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 启动Web管理前端(everything-is-suitable-admin)
|
|
||||||
|
|
||||||
#### 2.1 安装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd everything-is-suitable-admin
|
|
||||||
|
|
||||||
# 安装Node.js依赖
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# 安装Playwright浏览器
|
|
||||||
npm run test:install:e2e
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2.2 配置环境变量
|
|
||||||
|
|
||||||
创建 `.env.development-local`:
|
|
||||||
|
|
||||||
```env
|
|
||||||
NODE_ENV=development
|
|
||||||
VITE_APP_ENV=development-local
|
|
||||||
VITE_API_BASE_URL=http://localhost:8080
|
|
||||||
VITE_MOCK_ENABLED=false
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2.3 启动应用
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 方式1:使用npm启动
|
|
||||||
npm run dev:local
|
|
||||||
|
|
||||||
# 方式2:使用Vite直接启动
|
|
||||||
npx vite --mode development-local
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2.4 验证启动
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 检查Web应用是否启动成功
|
|
||||||
curl http://localhost:5174
|
|
||||||
|
|
||||||
# 预期输出:HTML页面内容
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 启动Uniapp小程序(everything-is-suitable-uniapp)
|
|
||||||
|
|
||||||
#### 3.1 安装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd everything-is-suitable-uniapp
|
|
||||||
|
|
||||||
# 安装Node.js依赖
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# 安装Playwright浏览器(用于H5测试)
|
|
||||||
npm install -D @playwright/test
|
|
||||||
npx playwright install --with-deps
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.2 配置环境变量
|
|
||||||
|
|
||||||
创建 `.env.development`:
|
|
||||||
|
|
||||||
```env
|
|
||||||
NODE_ENV=development
|
|
||||||
VITE_APP_ENV=development
|
|
||||||
VITE_API_BASE_URL=http://localhost:8080
|
|
||||||
VITE_MOCK_ENABLED=false
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.3 启动应用
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 方式1:使用npm启动(H5模式)
|
|
||||||
npm run dev:h5
|
|
||||||
|
|
||||||
# 方式2:使用HBuilderX启动(小程序模式)
|
|
||||||
# 在HBuilderX中打开项目
|
|
||||||
# 点击"运行" -> "运行到小程序模拟器"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.4 验证启动
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 检查Uniapp H5是否启动成功
|
|
||||||
curl http://localhost:5173
|
|
||||||
|
|
||||||
# 预期输出:HTML页面内容
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 启动测试环境(everything-is-suitable-test)
|
|
||||||
|
|
||||||
#### 4.1 安装TypeScript E2E测试依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd everything-is-suitable-test
|
|
||||||
|
|
||||||
# 安装Node.js依赖
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# 安装Playwright浏览器
|
|
||||||
npm run test:install:e2e
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4.2 安装Python E2E测试依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd python_e2e
|
|
||||||
|
|
||||||
# 安装Python依赖
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# 安装Playwright浏览器
|
|
||||||
python -m playwright install --with-deps
|
|
||||||
|
|
||||||
# 配置环境变量
|
|
||||||
cp .env.example .env
|
|
||||||
|
|
||||||
# 编辑.env文件,设置正确的URL
|
|
||||||
# WEB_BASE_URL=http://localhost:5174
|
|
||||||
# UNIAPP_URL=http://localhost:5173
|
|
||||||
# API_URL=http://localhost:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4.3 安装API测试依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd everything-is-suitable-test
|
|
||||||
|
|
||||||
# 安装Python依赖(使用Poetry)
|
|
||||||
cd api
|
|
||||||
poetry install
|
|
||||||
```
|
|
||||||
|
|
||||||
## 完整启动流程
|
|
||||||
|
|
||||||
### 开发环境启动
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 终端1:启动后端API
|
|
||||||
cd everything-is-suitable-api
|
|
||||||
mvn spring-boot:run -Dspring-boot.run.profiles=local
|
|
||||||
|
|
||||||
# 终端2:启动Web管理前端
|
|
||||||
cd everything-is-suitable-admin
|
|
||||||
npm run dev:local
|
|
||||||
|
|
||||||
# 终端3:启动Uniapp小程序
|
|
||||||
cd everything-is-suitable-uniapp
|
|
||||||
npm run dev:h5
|
|
||||||
|
|
||||||
# 终端4:启动E2E测试(包含Spring Boot Actuator监控)
|
|
||||||
cd everything-is-suitable-test
|
|
||||||
npm run test:e2e:smoke
|
|
||||||
```
|
|
||||||
|
|
||||||
### 测试环境启动
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 终端1:运行TypeScript E2E测试
|
|
||||||
cd everything-is-suitable-test
|
|
||||||
npm run test:e2e:smoke
|
|
||||||
|
|
||||||
# 终端2:运行完整E2E测试(包含Actuator监控)
|
|
||||||
npm run test:e2e:full
|
|
||||||
```
|
|
||||||
|
|
||||||
# 终端2:运行Python E2E测试
|
|
||||||
cd everything-is-suitable-test/python_e2e
|
|
||||||
pytest
|
|
||||||
|
|
||||||
# 终端3:运行API测试
|
|
||||||
cd everything-is-suitable-test/api
|
|
||||||
poetry run pytest
|
|
||||||
```
|
|
||||||
|
|
||||||
## 端口说明
|
|
||||||
|
|
||||||
| 应用 | 端口 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| 后端API | 8080 | Spring Boot应用 |
|
|
||||||
| Web管理前端 | 5174 | Vite开发服务器 |
|
|
||||||
| Uniapp H5 | 5173 | Vite开发服务器 |
|
|
||||||
| Prometheus | 9090 | 监控服务 |
|
|
||||||
| Grafana | 3000 | 监控面板 |
|
|
||||||
|
|
||||||
## 环境变量说明
|
|
||||||
|
|
||||||
### 后端API环境变量
|
|
||||||
|
|
||||||
| 变量名 | 说明 | 默认值 |
|
|
||||||
|--------|------|--------|
|
|
||||||
| SPRING_PROFILES_ACTIVE | Spring配置文件 | local |
|
|
||||||
| SPRING_DATASOURCE_URL | 数据库URL | jdbc:postgresql://localhost:5432/everything_is_suitable |
|
|
||||||
| SPRING_DATASOURCE_USERNAME | 数据库用户名 | everything_user |
|
|
||||||
| SPRING_DATASOURCE_PASSWORD | 数据库密码 | your_password |
|
|
||||||
|
|
||||||
### Web管理前端环境变量
|
|
||||||
|
|
||||||
| 变量名 | 说明 | 默认值 |
|
|
||||||
|--------|------|--------|
|
|
||||||
| NODE_ENV | Node环境 | development |
|
|
||||||
| VITE_APP_ENV | 应用环境 | development-local |
|
|
||||||
| VITE_API_BASE_URL | API基础URL | http://localhost:8080 |
|
|
||||||
| VITE_MOCK_ENABLED | 是否启用Mock | false |
|
|
||||||
|
|
||||||
### Uniapp环境变量
|
|
||||||
|
|
||||||
| 变量名 | 说明 | 默认值 |
|
|
||||||
|--------|------|--------|
|
|
||||||
| NODE_ENV | Node环境 | development |
|
|
||||||
| VITE_APP_ENV | 应用环境 | development |
|
|
||||||
| VITE_API_BASE_URL | API基础URL | http://localhost:8080 |
|
|
||||||
| VITE_MOCK_ENABLED | 是否启用Mock | false |
|
|
||||||
|
|
||||||
### Python E2E测试环境变量
|
|
||||||
|
|
||||||
| 变量名 | 说明 | 默认值 |
|
|
||||||
|--------|------|--------|
|
|
||||||
| TEST_ENV | 测试环境 | dev |
|
|
||||||
| BASE_URL | Web应用URL | http://localhost:5174 |
|
|
||||||
| UNIAPP_URL | Uniapp应用URL | http://localhost:5173 |
|
|
||||||
| API_URL | API服务URL | http://localhost:8080 |
|
|
||||||
| HEADLESS | 是否无头模式 | true |
|
|
||||||
| BROWSER | 浏览器类型 | chromium |
|
|
||||||
| TIMEOUT | 超时时间(毫秒) | 30000 |
|
|
||||||
| RETRY_COUNT | 重试次数 | 2 |
|
|
||||||
| PARALLEL_WORKERS | 并行工作数 | 4 |
|
|
||||||
| LOG_LEVEL | 日志级别 | INFO |
|
|
||||||
| SCREENSHOT_ON_FAILURE | 失败时截图 | true |
|
|
||||||
| VIDEO_ON_FAILURE | 失败时录制视频 | false |
|
|
||||||
| TRACE_ON_FAILURE | 失败时追踪 | false |
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
### 1. 端口被占用
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 查看端口占用情况
|
|
||||||
lsof -i :8080
|
|
||||||
lsof -i :5174
|
|
||||||
lsof -i :5173
|
|
||||||
|
|
||||||
# 杀死占用端口的进程
|
|
||||||
kill -9 <PID>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 数据库连接失败
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 检查PostgreSQL是否运行
|
|
||||||
brew services list | grep postgresql
|
|
||||||
|
|
||||||
# 启动PostgreSQL
|
|
||||||
brew services start postgresql
|
|
||||||
|
|
||||||
# 检查数据库是否存在
|
|
||||||
psql -U postgres -l
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 依赖安装失败
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 清除Node.js缓存
|
|
||||||
npm cache clean --force
|
|
||||||
|
|
||||||
# 重新安装依赖
|
|
||||||
rm -rf node_modules package-lock.json
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# 清除Python缓存
|
|
||||||
pip cache purge
|
|
||||||
|
|
||||||
# 重新安装依赖
|
|
||||||
pip install -r requirements.txt --no-cache-dir
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Playwright浏览器未安装
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 重新安装Playwright浏览器
|
|
||||||
npx playwright install --with-deps
|
|
||||||
|
|
||||||
# 或使用Python安装
|
|
||||||
python -m playwright install --with-deps
|
|
||||||
```
|
|
||||||
|
|
||||||
## 下一步
|
|
||||||
|
|
||||||
启动所有应用后,您可以:
|
|
||||||
|
|
||||||
1. 访问Web管理前端:http://localhost:5174
|
|
||||||
2. 访问Uniapp H5:http://localhost:5173
|
|
||||||
3. 访问API文档:http://localhost:8080/swagger-ui.html
|
|
||||||
4. 运行E2E测试验证功能
|
|
||||||
5. 运行API测试验证接口
|
|
||||||
|
|
||||||
## 参考文档
|
|
||||||
|
|
||||||
- [everything-is-suitable-admin/README.md](file:///Users/zhangxiang/Codes/Gitee/everything-is-suitable/everything-is-suitable-admin/README.md)
|
|
||||||
- [everything-is-suitable-uniapp/README.md](file:///Users/zhangxiang/Codes/Gitee/everything-is-suitable/everything-is-suitable-uniapp/README.md)
|
|
||||||
- [everything-is-suitable-test/README.md](file:///Users/zhangxiang/Codes/Gitee/everything-is-suitable/everything-is-suitable-test/README.md)
|
|
||||||
- [python_e2e/README.md](file:///Users/zhangxiang/Codes/Gitee/everything-is-suitable/everything-is-suitable-test/python_e2e/README.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**文档版本**: 1.0.0
|
|
||||||
**更新日期**: 2026-01-26
|
|
||||||
**作者**: Zhang Xiang
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
# Woodpecker CI配置说明
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
本项目使用Woodpecker CI作为持续集成/持续部署(CI/CD)工具。Woodpecker CI是一个轻量级、可扩展的CI/CD平台,支持Docker容器化部署。
|
|
||||||
|
|
||||||
## 配置文件
|
|
||||||
|
|
||||||
Woodpecker CI配置文件位于项目根目录:`.woodpecker.yml`
|
|
||||||
|
|
||||||
## Pipeline结构
|
|
||||||
|
|
||||||
### 主要Pipeline
|
|
||||||
|
|
||||||
#### 1. Setup Pipeline
|
|
||||||
- **作用**:初始化Docker环境
|
|
||||||
- **镜像**:`docker:dind`
|
|
||||||
- **触发条件**:推送到main/develop分支、PR、标签
|
|
||||||
|
|
||||||
#### 2. E2E Tests Pipeline
|
|
||||||
- **作用**:执行端到端测试
|
|
||||||
- **镜像**:`node:18`
|
|
||||||
- **并行执行**:4个分片
|
|
||||||
- **触发条件**:推送到main/develop分支、PR、标签
|
|
||||||
- **依赖**:setup
|
|
||||||
|
|
||||||
#### 3. API Tests Pipeline
|
|
||||||
- **作用**:执行API集成测试
|
|
||||||
- **镜像**:`node:18`
|
|
||||||
- **触发条件**:推送到main/develop分支、PR、标签
|
|
||||||
- **依赖**:setup
|
|
||||||
|
|
||||||
#### 4. Test Report Pipeline
|
|
||||||
- **作用**:合并测试报告
|
|
||||||
- **镜像**:`node:18`
|
|
||||||
- **触发条件**:测试完成后(成功或失败)
|
|
||||||
- **依赖**:e2e-tests, api-tests
|
|
||||||
|
|
||||||
#### 5. Notify Failure Pipeline
|
|
||||||
- **作用**:测试失败时发送Slack通知
|
|
||||||
- **镜像**:`plugins/slack`
|
|
||||||
- **触发条件**:测试失败
|
|
||||||
- **依赖**:e2e-tests, api-tests
|
|
||||||
|
|
||||||
#### 6. Nightly Tests Pipeline
|
|
||||||
- **作用**:每日定时执行完整测试
|
|
||||||
- **镜像**:`node:18`
|
|
||||||
- **触发条件**:每天凌晨2点(cron: "0 2 * * *")
|
|
||||||
- **依赖**:setup
|
|
||||||
|
|
||||||
## 环境变量配置
|
|
||||||
|
|
||||||
在Woodpecker CI管理界面配置以下环境变量:
|
|
||||||
|
|
||||||
### 必需变量
|
|
||||||
|
|
||||||
| 变量名 | 描述 | 示例 |
|
|
||||||
|--------|------|------|
|
|
||||||
| `ADMIN_BASE_URL` | Admin模块URL | `http://localhost:5174` |
|
|
||||||
| `UNIAPP_BASE_URL` | Uniapp模块URL | `http://localhost:8081` |
|
|
||||||
| `API_BASE_URL` | 后端API URL | `http://localhost:8080` |
|
|
||||||
| `TEST_USERNAME` | 测试账号用户名 | `admin` |
|
|
||||||
| `TEST_PASSWORD` | 测试账号密码 | `admin123` |
|
|
||||||
|
|
||||||
### 可选变量
|
|
||||||
|
|
||||||
| 变量名 | 描述 | 示例 |
|
|
||||||
|--------|------|------|
|
|
||||||
| `SLACK_WEBHOOK` | Slack通知Webhook URL | `https://hooks.slack.com/...` |
|
|
||||||
| `MOCK_ENABLED` | 是否启用Mock | `false` |
|
|
||||||
| `TEST_TIMEOUT` | 测试超时时间(ms) | `30000` |
|
|
||||||
|
|
||||||
## 触发条件
|
|
||||||
|
|
||||||
### 自动触发
|
|
||||||
|
|
||||||
- **Push事件**:代码推送到main或develop分支
|
|
||||||
- **Pull Request事件**:创建或更新PR
|
|
||||||
- **Tag事件**:创建新的标签
|
|
||||||
- **定时任务**:每天凌晨2点执行完整测试
|
|
||||||
|
|
||||||
### 手动触发
|
|
||||||
|
|
||||||
在Woodpecker CI界面中,可以手动触发任何Pipeline。
|
|
||||||
|
|
||||||
## 测试执行流程
|
|
||||||
|
|
||||||
### E2E测试流程
|
|
||||||
|
|
||||||
1. **Setup阶段**:初始化Docker环境
|
|
||||||
2. **安装依赖**:安装npm依赖和Playwright浏览器
|
|
||||||
3. **启动服务**:使用docker-compose启动测试环境
|
|
||||||
4. **执行测试**:并行运行E2E测试(4个分片)
|
|
||||||
5. **清理环境**:停止并清理Docker容器
|
|
||||||
6. **生成报告**:合并测试报告并生成HTML报告
|
|
||||||
|
|
||||||
### API测试流程
|
|
||||||
|
|
||||||
1. **Setup阶段**:初始化Docker环境
|
|
||||||
2. **安装依赖**:安装npm依赖
|
|
||||||
3. **启动服务**:仅启动后端服务
|
|
||||||
4. **执行测试**:运行API集成测试
|
|
||||||
5. **清理环境**:停止并清理Docker容器
|
|
||||||
|
|
||||||
## 并行执行
|
|
||||||
|
|
||||||
Woodpecker CI支持并行执行测试以提高效率:
|
|
||||||
|
|
||||||
- **E2E测试**:4个并行分片(CI_NODE_INDEX: 1-4)
|
|
||||||
- **API测试**:独立执行
|
|
||||||
|
|
||||||
## 测试报告
|
|
||||||
|
|
||||||
### 报告生成
|
|
||||||
|
|
||||||
测试完成后会自动生成以下报告:
|
|
||||||
|
|
||||||
- **HTML报告**:交互式HTML报告
|
|
||||||
- **JSON报告**:机器可读的JSON格式
|
|
||||||
- **JUnit报告**:兼容JUnit的XML格式
|
|
||||||
|
|
||||||
### 报告位置
|
|
||||||
|
|
||||||
- `playwright-report/`:HTML报告
|
|
||||||
- `test-results/results.json`:JSON报告
|
|
||||||
- `test-results/junit.xml`:JUnit报告
|
|
||||||
|
|
||||||
### 查看报告
|
|
||||||
|
|
||||||
在Woodpecker CI界面中,可以:
|
|
||||||
1. 查看Pipeline执行日志
|
|
||||||
2. 下载测试报告artifacts
|
|
||||||
3. 查看测试截图和录屏
|
|
||||||
|
|
||||||
## 通知配置
|
|
||||||
|
|
||||||
### Slack通知
|
|
||||||
|
|
||||||
当测试失败时,会自动发送Slack通知:
|
|
||||||
|
|
||||||
1. 在Woodpecker CI中配置`SLACK_WEBHOOK`环境变量
|
|
||||||
2. 通知会发送到指定的Slack频道
|
|
||||||
3. 通知内容包括:
|
|
||||||
- 失败的Pipeline名称
|
|
||||||
- 失败的原因
|
|
||||||
- 相关的commit信息
|
|
||||||
|
|
||||||
### 其他通知方式
|
|
||||||
|
|
||||||
Woodpecker CI支持多种通知插件:
|
|
||||||
- Email
|
|
||||||
- Discord
|
|
||||||
- Rocket.Chat
|
|
||||||
- Matrix
|
|
||||||
- Telegram
|
|
||||||
|
|
||||||
## 故障排查
|
|
||||||
|
|
||||||
### 常见问题
|
|
||||||
|
|
||||||
#### 1. Docker权限问题
|
|
||||||
|
|
||||||
**错误信息**:`permission denied while trying to connect to the Docker daemon socket`
|
|
||||||
|
|
||||||
**解决方案**:
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
docker:
|
|
||||||
image: docker:dind
|
|
||||||
privileged: true
|
|
||||||
volumes:
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. 测试超时
|
|
||||||
|
|
||||||
**错误信息**:`Test timeout of 30000ms exceeded`
|
|
||||||
|
|
||||||
**解决方案**:
|
|
||||||
在`.woodpecker.yml`中增加超时时间:
|
|
||||||
```yaml
|
|
||||||
environment:
|
|
||||||
- TEST_TIMEOUT=60000
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. 依赖安装失败
|
|
||||||
|
|
||||||
**错误信息**:`npm install failed`
|
|
||||||
|
|
||||||
**解决方案**:
|
|
||||||
```yaml
|
|
||||||
commands:
|
|
||||||
- npm ci --prefer-offline --no-audit
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. 服务启动失败
|
|
||||||
|
|
||||||
**错误信息**:`Service failed to start`
|
|
||||||
|
|
||||||
**解决方案**:
|
|
||||||
```yaml
|
|
||||||
commands:
|
|
||||||
- docker-compose -f ../docker-compose.test.yml up -d
|
|
||||||
- sleep 60 # 增加等待时间
|
|
||||||
- docker-compose -f ../docker-compose.test.yml ps # 检查服务状态
|
|
||||||
```
|
|
||||||
|
|
||||||
### 调试技巧
|
|
||||||
|
|
||||||
#### 1. 查看详细日志
|
|
||||||
|
|
||||||
在Woodpecker CI界面中:
|
|
||||||
1. 点击失败的Pipeline
|
|
||||||
2. 查看详细的执行日志
|
|
||||||
3. 下载完整的日志文件
|
|
||||||
|
|
||||||
#### 2. 本地复现
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 使用相同的命令在本地执行
|
|
||||||
cd everything-is-suitable-test
|
|
||||||
npm ci
|
|
||||||
docker-compose -f ../docker-compose.test.yml up -d
|
|
||||||
npx playwright test
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. 启用调试模式
|
|
||||||
|
|
||||||
在`.woodpecker.yml`中添加:
|
|
||||||
```yaml
|
|
||||||
environment:
|
|
||||||
- DEBUG=*
|
|
||||||
- PWDEBUG=1
|
|
||||||
```
|
|
||||||
|
|
||||||
## 最佳实践
|
|
||||||
|
|
||||||
### 1. Pipeline设计
|
|
||||||
|
|
||||||
- **模块化**:将不同类型的测试分离到独立的Pipeline
|
|
||||||
- **并行化**:充分利用并行执行提高效率
|
|
||||||
- **依赖管理**:明确Pipeline之间的依赖关系
|
|
||||||
|
|
||||||
### 2. 资源优化
|
|
||||||
|
|
||||||
- **缓存依赖**:使用npm缓存加速依赖安装
|
|
||||||
- **重用容器**:在可能的情况下重用Docker容器
|
|
||||||
- **清理资源**:测试完成后及时清理资源
|
|
||||||
|
|
||||||
### 3. 安全性
|
|
||||||
|
|
||||||
- **敏感信息**:使用环境变量存储敏感信息
|
|
||||||
- **最小权限**:仅授予必要的权限
|
|
||||||
- **定期更新**:及时更新镜像和依赖
|
|
||||||
|
|
||||||
### 4. 监控和告警
|
|
||||||
|
|
||||||
- **实时监控**:监控Pipeline执行状态
|
|
||||||
- **及时通知**:配置适当的通知机制
|
|
||||||
- **定期审查**:定期审查Pipeline执行日志
|
|
||||||
|
|
||||||
## 扩展和定制
|
|
||||||
|
|
||||||
### 添加新的Pipeline
|
|
||||||
|
|
||||||
在`.woodpecker.yml`中添加新的Pipeline:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
new-pipeline:
|
|
||||||
image: node:18
|
|
||||||
commands:
|
|
||||||
- echo "执行新的测试"
|
|
||||||
when:
|
|
||||||
event: [push]
|
|
||||||
branch: [main]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 使用自定义镜像
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
custom-test:
|
|
||||||
image: your-registry/custom-image:latest
|
|
||||||
commands:
|
|
||||||
- echo "使用自定义镜像"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 集成其他工具
|
|
||||||
|
|
||||||
Woodpecker CI支持丰富的插件生态:
|
|
||||||
|
|
||||||
- **代码质量**:SonarQube, CodeClimate
|
|
||||||
- **部署**:Kubernetes, AWS, GCP
|
|
||||||
- **通知**:Slack, Email, Discord
|
|
||||||
- **安全扫描**:Trivy, Snyk
|
|
||||||
|
|
||||||
## 相关资源
|
|
||||||
|
|
||||||
- [Woodpecker CI官方文档](https://woodpecker-ci.org/docs/)
|
|
||||||
- [Woodpecker CI插件市场](https://woodpecker-ci.org/plugins/)
|
|
||||||
- [Playwright CI/CD集成](https://playwright.dev/docs/ci)
|
|
||||||
- [Docker Compose文档](https://docs.docker.com/compose/)
|
|
||||||
|
|
||||||
## 联系方式
|
|
||||||
|
|
||||||
如有问题或建议,请联系DevOps团队。
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
export interface TestMapping {
|
|
||||||
[sourceFile: string]: {
|
|
||||||
tests: string[];
|
|
||||||
priority: 'high' | 'medium' | 'low';
|
|
||||||
modules: string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const testMapping: TestMapping = {
|
|
||||||
// 用户管理模块
|
|
||||||
'everything-is-suitable-admin/src/views/UserManagement.vue': {
|
|
||||||
tests: [
|
|
||||||
'e2e/user-management/*.spec.ts',
|
|
||||||
],
|
|
||||||
priority: 'high',
|
|
||||||
modules: ['user-management'],
|
|
||||||
},
|
|
||||||
'everything-is-suitable-admin/src/api/user.ts': {
|
|
||||||
tests: [
|
|
||||||
'e2e/user-management/*.spec.ts',
|
|
||||||
'e2e/api/user-api.spec.ts',
|
|
||||||
],
|
|
||||||
priority: 'high',
|
|
||||||
modules: ['user-management', 'api'],
|
|
||||||
},
|
|
||||||
'everything-is-suitable-admin/src/stores/user.ts': {
|
|
||||||
tests: [
|
|
||||||
'e2e/user-management/*.spec.ts',
|
|
||||||
],
|
|
||||||
priority: 'medium',
|
|
||||||
modules: ['user-management'],
|
|
||||||
},
|
|
||||||
|
|
||||||
// 角色管理模块
|
|
||||||
'everything-is-suitable-admin/src/views/RoleManagement.vue': {
|
|
||||||
tests: [
|
|
||||||
'e2e/role-management/*.spec.ts',
|
|
||||||
],
|
|
||||||
priority: 'high',
|
|
||||||
modules: ['role-management'],
|
|
||||||
},
|
|
||||||
'everything-is-suitable-admin/src/api/role.ts': {
|
|
||||||
tests: [
|
|
||||||
'e2e/role-management/*.spec.ts',
|
|
||||||
'e2e/api/role-api.spec.ts',
|
|
||||||
],
|
|
||||||
priority: 'high',
|
|
||||||
modules: ['role-management', 'api'],
|
|
||||||
},
|
|
||||||
|
|
||||||
// 菜单管理模块
|
|
||||||
'everything-is-suitable-admin/src/views/MenuManagement.vue': {
|
|
||||||
tests: [
|
|
||||||
'e2e/menu-management/*.spec.ts',
|
|
||||||
],
|
|
||||||
priority: 'high',
|
|
||||||
modules: ['menu-management'],
|
|
||||||
},
|
|
||||||
'everything-is-suitable-admin/src/api/menu.ts': {
|
|
||||||
tests: [
|
|
||||||
'e2e/menu-management/*.spec.ts',
|
|
||||||
'e2e/api/menu-api.spec.ts',
|
|
||||||
],
|
|
||||||
priority: 'high',
|
|
||||||
modules: ['menu-management', 'api'],
|
|
||||||
},
|
|
||||||
|
|
||||||
// 黄历功能模块
|
|
||||||
'everything-is-suitable-uniapp/src/pages/almanac/index.vue': {
|
|
||||||
tests: [
|
|
||||||
'e2e/almanac-functionality/*.spec.ts',
|
|
||||||
],
|
|
||||||
priority: 'high',
|
|
||||||
modules: ['almanac-functionality'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// 反向映射:模块 -> 测试文件
|
|
||||||
export const moduleToTests: Record<string, string[]> = {
|
|
||||||
'user-management': ['e2e/user-management/*.spec.ts'],
|
|
||||||
'role-management': ['e2e/role-management/*.spec.ts'],
|
|
||||||
'menu-management': ['e2e/menu-management/*.spec.ts'],
|
|
||||||
'almanac-functionality': ['e2e/almanac-functionality/*.spec.ts'],
|
|
||||||
'api': ['e2e/api/*.spec.ts'],
|
|
||||||
};
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
prometheus:
|
|
||||||
image: prom/prometheus:v2.45.0
|
|
||||||
container_name: prometheus
|
|
||||||
ports:
|
|
||||||
- "9090:9090"
|
|
||||||
volumes:
|
|
||||||
- ./.trae/monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
|
|
||||||
- ./.trae/monitoring/prometheus/alerting_rules.yml:/etc/prometheus/alerting_rules.yml
|
|
||||||
- prometheus_data:/prometheus
|
|
||||||
command:
|
|
||||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
|
||||||
- '--storage.tsdb.path=/prometheus'
|
|
||||||
- '--web.console.libraries=/etc/prometheus/console_libraries'
|
|
||||||
- '--web.console.templates=/etc/prometheus/consoles'
|
|
||||||
- '--storage.tsdb.retention.time=15d'
|
|
||||||
networks:
|
|
||||||
- monitoring
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
grafana:
|
|
||||||
image: grafana/grafana:10.1.0
|
|
||||||
container_name: grafana
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
environment:
|
|
||||||
- GF_SECURITY_ADMIN_USER=admin
|
|
||||||
- GF_SECURITY_ADMIN_PASSWORD=admin
|
|
||||||
- GF_INSTALL_PLUGINS=
|
|
||||||
- GF_SERVER_ROOT_URL=http://localhost:3000
|
|
||||||
- GF_USERS_ALLOW_SIGN_UP=false
|
|
||||||
- GF_PROVISIONING_ENABLED=true
|
|
||||||
- GF_PROVISIONING_PATHS=/etc/grafana/provisioning
|
|
||||||
volumes:
|
|
||||||
- grafana_data:/var/lib/grafana
|
|
||||||
- ./.trae/monitoring/grafana/provisioning:/etc/grafana/provisioning
|
|
||||||
- ./.trae/monitoring/grafana/dashboards:/var/lib/grafana/dashboards
|
|
||||||
networks:
|
|
||||||
- monitoring
|
|
||||||
depends_on:
|
|
||||||
- prometheus
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
prometheus_data:
|
|
||||||
driver: local
|
|
||||||
grafana_data:
|
|
||||||
driver: local
|
|
||||||
|
|
||||||
networks:
|
|
||||||
monitoring:
|
|
||||||
driver: bridge
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
# 测试数据库 - PostgreSQL
|
|
||||||
test-postgres:
|
|
||||||
image: postgres:15-alpine
|
|
||||||
container_name: test-postgres
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: ${TEST_DB_NAME:-everything_test}
|
|
||||||
POSTGRES_USER: ${TEST_DB_USER:-test_user}
|
|
||||||
POSTGRES_PASSWORD: ${TEST_DB_PASSWORD:-test_password}
|
|
||||||
POSTGRES_INITDB_ARGS: "-E UTF8"
|
|
||||||
TZ: Asia/Shanghai
|
|
||||||
ports:
|
|
||||||
- "${TEST_DB_PORT:-5433}:5432"
|
|
||||||
volumes:
|
|
||||||
- test-postgres-data:/var/lib/postgresql/data
|
|
||||||
- ./scripts/init-test-db.sql:/docker-entrypoint-initdb.d/init-test-db.sql:ro
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${TEST_DB_USER:-test_user}"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 20s
|
|
||||||
networks:
|
|
||||||
- test-network
|
|
||||||
|
|
||||||
# 测试缓存 - Redis
|
|
||||||
test-redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
container_name: test-redis
|
|
||||||
ports:
|
|
||||||
- "${TEST_REDIS_PORT:-6380}:6379"
|
|
||||||
volumes:
|
|
||||||
- test-redis-data:/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 10s
|
|
||||||
networks:
|
|
||||||
- test-network
|
|
||||||
|
|
||||||
# 测试应用 - API Gateway
|
|
||||||
test-api-gateway:
|
|
||||||
build:
|
|
||||||
context: ./everything-is-suitable-api/everything-is-suitable-app
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: test-api-gateway
|
|
||||||
environment:
|
|
||||||
SPRING_PROFILES_ACTIVE: test
|
|
||||||
SPRING_DATASOURCE_URL: jdbc:postgresql://test-postgres:5432/${TEST_DB_NAME:-everything_test}
|
|
||||||
SPRING_DATASOURCE_USERNAME: ${TEST_DB_USER:-test_user}
|
|
||||||
SPRING_DATASOURCE_PASSWORD: ${TEST_DB_PASSWORD:-test_password}
|
|
||||||
SPRING_REDIS_HOST: test-redis
|
|
||||||
SPRING_REDIS_PORT: 6379
|
|
||||||
SERVER_PORT: 8080
|
|
||||||
TZ: Asia/Shanghai
|
|
||||||
ports:
|
|
||||||
- "${TEST_API_PORT:-8081}:8080"
|
|
||||||
depends_on:
|
|
||||||
test-postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
test-redis:
|
|
||||||
condition: service_healthy
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 40s
|
|
||||||
networks:
|
|
||||||
- test-network
|
|
||||||
|
|
||||||
# 测试应用 - Admin Backend
|
|
||||||
test-admin-backend:
|
|
||||||
build:
|
|
||||||
context: ./everything-is-suitable-api/everything-is-suitable-admin-app
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: test-admin-backend
|
|
||||||
environment:
|
|
||||||
SPRING_PROFILES_ACTIVE: test
|
|
||||||
SPRING_DATASOURCE_URL: jdbc:postgresql://test-postgres:5432/${TEST_DB_NAME:-everything_test}
|
|
||||||
SPRING_DATASOURCE_USERNAME: ${TEST_DB_USER:-test_user}
|
|
||||||
SPRING_DATASOURCE_PASSWORD: ${TEST_DB_PASSWORD:-test_password}
|
|
||||||
SPRING_REDIS_HOST: test-redis
|
|
||||||
SPRING_REDIS_PORT: 6379
|
|
||||||
SERVER_PORT: 8081
|
|
||||||
TZ: Asia/Shanghai
|
|
||||||
ports:
|
|
||||||
- "${TEST_ADMIN_PORT:-8082}:8081"
|
|
||||||
depends_on:
|
|
||||||
test-postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
test-redis:
|
|
||||||
condition: service_healthy
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8081/actuator/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 40s
|
|
||||||
networks:
|
|
||||||
- test-network
|
|
||||||
|
|
||||||
# 测试数据管理工具
|
|
||||||
test-data-manager:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.test-data-manager
|
|
||||||
container_name: test-data-manager
|
|
||||||
environment:
|
|
||||||
DB_HOST: test-postgres
|
|
||||||
DB_PORT: 5432
|
|
||||||
DB_NAME: ${TEST_DB_NAME:-everything_test}
|
|
||||||
DB_USER: ${TEST_DB_USER:-test_user}
|
|
||||||
DB_PASSWORD: ${TEST_DB_PASSWORD:-test_password}
|
|
||||||
API_URL: http://test-api-gateway:8080
|
|
||||||
ADMIN_API_URL: http://test-admin-backend:8081
|
|
||||||
TZ: Asia/Shanghai
|
|
||||||
volumes:
|
|
||||||
- ./test-data:/app/test-data
|
|
||||||
- ./test-data/scripts:/app/scripts:ro
|
|
||||||
depends_on:
|
|
||||||
test-postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
test-api-gateway:
|
|
||||||
condition: service_healthy
|
|
||||||
test-admin-backend:
|
|
||||||
condition: service_healthy
|
|
||||||
networks:
|
|
||||||
- test-network
|
|
||||||
profiles:
|
|
||||||
- data-manager
|
|
||||||
|
|
||||||
# 测试监控 - Prometheus
|
|
||||||
test-prometheus:
|
|
||||||
image: prom/prometheus:latest
|
|
||||||
container_name: test-prometheus
|
|
||||||
command:
|
|
||||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
|
||||||
- '--storage.tsdb.path=/prometheus'
|
|
||||||
- '--web.console.libraries=/usr/share/prometheus/console_libraries'
|
|
||||||
- '--web.console.templates=/usr/share/prometheus/consoles'
|
|
||||||
- '--web.enable-lifecycle'
|
|
||||||
ports:
|
|
||||||
- "${TEST_PROMETHEUS_PORT:-9091}:9090"
|
|
||||||
volumes:
|
|
||||||
- ./test-monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
|
||||||
- test-prometheus-data:/prometheus
|
|
||||||
depends_on:
|
|
||||||
- test-api-gateway
|
|
||||||
- test-admin-backend
|
|
||||||
networks:
|
|
||||||
- test-network
|
|
||||||
profiles:
|
|
||||||
- monitoring
|
|
||||||
|
|
||||||
# 测试监控 - Grafana
|
|
||||||
test-grafana:
|
|
||||||
image: grafana/grafana:latest
|
|
||||||
container_name: test-grafana
|
|
||||||
environment:
|
|
||||||
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
|
|
||||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin}
|
|
||||||
GF_USERS_ALLOW_SIGN_UP: "false"
|
|
||||||
GF_INSTALL_PLUGINS: "grafana-piechart-panel"
|
|
||||||
ports:
|
|
||||||
- "${TEST_GRAFANA_PORT:-3001}:3000"
|
|
||||||
volumes:
|
|
||||||
- test-grafana-data:/var/lib/grafana
|
|
||||||
- ./test-monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
|
|
||||||
- ./test-monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
|
||||||
depends_on:
|
|
||||||
- test-prometheus
|
|
||||||
networks:
|
|
||||||
- test-network
|
|
||||||
profiles:
|
|
||||||
- monitoring
|
|
||||||
|
|
||||||
networks:
|
|
||||||
test-network:
|
|
||||||
driver: bridge
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
test-postgres-data:
|
|
||||||
driver: local
|
|
||||||
test-redis-data:
|
|
||||||
driver: local
|
|
||||||
test-prometheus-data:
|
|
||||||
driver: local
|
|
||||||
test-grafana-data:
|
|
||||||
driver: local
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
services:
|
|
||||||
admin-frontend-test:
|
|
||||||
build:
|
|
||||||
context: ./everything-is-suitable-admin
|
|
||||||
dockerfile: Dockerfile.test
|
|
||||||
container_name: admin-frontend-test
|
|
||||||
ports:
|
|
||||||
- "5174:5174"
|
|
||||||
environment:
|
|
||||||
- NODE_ENV=test
|
|
||||||
- VITE_API_BASE_URL=http://host.docker.internal:8082
|
|
||||||
- VITE_MOCK_ENABLED=false
|
|
||||||
networks:
|
|
||||||
- test-network
|
|
||||||
extra_hosts:
|
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:5174"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
start_period: 30s
|
|
||||||
|
|
||||||
admin-api-test:
|
|
||||||
build:
|
|
||||||
context: ./everything-is-suitable-api/everything-is-suitable-admin-app
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: admin-api-test
|
|
||||||
ports:
|
|
||||||
- "8083:8082"
|
|
||||||
environment:
|
|
||||||
- SPRING_PROFILES_ACTIVE=test
|
|
||||||
- SPRING_R2DBC_URL=r2dbc:postgresql://host.docker.internal:55432/everything_suitable_test
|
|
||||||
- SPRING_R2DBC_USERNAME=postgres
|
|
||||||
- SPRING_R2DBC_PASSWORD=postgres
|
|
||||||
networks:
|
|
||||||
- test-network
|
|
||||||
extra_hosts:
|
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8082/actuator/health"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 40s
|
|
||||||
|
|
||||||
networks:
|
|
||||||
test-network:
|
|
||||||
driver: bridge
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
container_name: postgres
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: everything_is_suitable
|
|
||||||
POSTGRES_USER: postgres
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres123}
|
|
||||||
TZ: Asia/Shanghai
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
networks:
|
|
||||||
- app-network
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
api:
|
|
||||||
build:
|
|
||||||
context: ./everything-is-suitable-api
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: api
|
|
||||||
environment:
|
|
||||||
SPRING_PROFILES_ACTIVE: production
|
|
||||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/everything_is_suitable
|
|
||||||
SPRING_DATASOURCE_USERNAME: postgres
|
|
||||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-postgres123}
|
|
||||||
TZ: Asia/Shanghai
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
networks:
|
|
||||||
- app-network
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
admin:
|
|
||||||
build:
|
|
||||||
context: ./everything-is-suitable-admin
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: admin
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
depends_on:
|
|
||||||
- api
|
|
||||||
networks:
|
|
||||||
- app-network
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
uniapp:
|
|
||||||
build:
|
|
||||||
context: ./everything-is-suitable-uniapp
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: uniapp
|
|
||||||
ports:
|
|
||||||
- "8081:80"
|
|
||||||
depends_on:
|
|
||||||
- api
|
|
||||||
networks:
|
|
||||||
- app-network
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
networks:
|
|
||||||
app-network:
|
|
||||||
driver: bridge
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
driver: local
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# 开发环境配置
|
|
||||||
NODE_ENV=development
|
|
||||||
VITE_APP_ENV=development
|
|
||||||
VITE_API_BASE_URL=https://dev-api.example.com
|
|
||||||
VITE_MOCK_ENABLED=false
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# 本地开发环境配置
|
|
||||||
NODE_ENV=development
|
|
||||||
VITE_APP_ENV=development-local
|
|
||||||
VITE_API_BASE_URL=http://127.0.0.1:8080
|
|
||||||
VITE_MOCK_ENABLED=true
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# 生产环境配置
|
|
||||||
NODE_ENV=production
|
|
||||||
VITE_APP_ENV=production
|
|
||||||
VITE_API_BASE_URL=https://api.example.com
|
|
||||||
VITE_MOCK_ENABLED=false
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# 测试环境配置
|
|
||||||
NODE_ENV=development
|
|
||||||
VITE_APP_ENV=test
|
|
||||||
VITE_API_BASE_URL=http://127.0.0.1:8080
|
|
||||||
VITE_MOCK_ENABLED=true
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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.
|
Before Width: | Height: | Size: 162 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 320 KiB |
@@ -1,733 +0,0 @@
|
|||||||
# 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测试,确保应用的质量和稳定性。
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,458 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,593 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
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' });
|
|
||||||
});
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
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 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
@@ -1,360 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,505 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,414 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
# 性能测试文档
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
性能测试用于评估系统在不同负载条件下的性能表现,包括页面加载速度、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. **持续监控**: 建立持续性能监控机制
|
|
||||||
|
|
||||||
## 联系方式
|
|
||||||
|
|
||||||
如有问题,请联系测试团队或查看项目文档。
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
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'];
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
# 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. **测试数据问题**
|
|
||||||
- 检查数据库是否有正确的测试数据
|
|
||||||
- 检查测试数据是否被其他测试修改
|
|
||||||
- 使用唯一的测试数据标识符
|
|
||||||
|
|
||||||
## 联系方式
|
|
||||||
|
|
||||||
如有问题,请联系测试团队或查看项目文档。
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
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/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
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('命盘保存成功');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,566 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
user nginx;
|
|
||||||
worker_processes auto;
|
|
||||||
error_log /var/log/nginx/error.log warn;
|
|
||||||
pid /var/run/nginx.pid;
|
|
||||||
|
|
||||||
events {
|
|
||||||
worker_connections 1024;
|
|
||||||
}
|
|
||||||
|
|
||||||
http {
|
|
||||||
include /etc/nginx/mime.types;
|
|
||||||
default_type application/octet-stream;
|
|
||||||
|
|
||||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
|
||||||
'$status $body_bytes_sent "$http_referer" '
|
|
||||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
|
||||||
|
|
||||||
access_log /var/log/nginx/access.log main;
|
|
||||||
|
|
||||||
sendfile on;
|
|
||||||
tcp_nopush on;
|
|
||||||
tcp_nodelay on;
|
|
||||||
keepalive_timeout 65;
|
|
||||||
types_hash_max_size 2048;
|
|
||||||
|
|
||||||
gzip on;
|
|
||||||
gzip_vary on;
|
|
||||||
gzip_min_length 1024;
|
|
||||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 5174;
|
|
||||||
server_name localhost;
|
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /api {
|
|
||||||
proxy_pass http://host.docker.internal:8082;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection 'upgrade';
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
error_page 404 /index.html;
|
|
||||||
error_page 500 502 503 504 /50x.html;
|
|
||||||
location = /50x.html {
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-9470
File diff suppressed because it is too large
Load Diff
@@ -1,66 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
'@tailwindcss/postcss': {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export * from './auth';
|
|
||||||
export * from './menu';
|
|
||||||
export * from './role';
|
|
||||||
export * from './user';
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
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}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
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}`)
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
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'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
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: '导出失败'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
# 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 状态码模拟真实行为
|
|
||||||
- 网络延迟模拟真实用户体验
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/**
|
|
||||||
* Mock API服务系统
|
|
||||||
* 基于OpenAPI规范生成模拟响应数据
|
|
||||||
* 用于前端开发过程中的数据模拟
|
|
||||||
*/
|
|
||||||
|
|
||||||
export * from './mock-data';
|
|
||||||
export * from './mock-server';
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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));
|
|
||||||
};
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
/**
|
|
||||||
* Mock拦截器
|
|
||||||
* 用于拦截API请求并返回Mock数据
|
|
||||||
* 可以在开发环境中启用或禁用
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { mockServer } from './mock-server';
|
|
||||||
|
|
||||||
// 存储原始的axios实例方法
|
|
||||||
let originalGet: any;
|
|
||||||
let originalPost: any;
|
|
||||||
let originalPut: any;
|
|
||||||
let originalDelete: any;
|
|
||||||
|
|
||||||
// 检查是否启用Mock模式
|
|
||||||
const isMockEnabled = () => {
|
|
||||||
const env = import.meta.env.MODE;
|
|
||||||
// development-local 和 test 环境使用 Mock,development 和 prod 环境使用真实API
|
|
||||||
const useMock = env === 'development-local' || env === 'test';
|
|
||||||
const forceMock = import.meta.env.VITE_MOCK_ENABLED === 'true';
|
|
||||||
|
|
||||||
return useMock || forceMock;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 拦截请求方法
|
|
||||||
const mockRequest = async (method: string, url: string, data?: any, config?: any) => {
|
|
||||||
if (!isMockEnabled()) {
|
|
||||||
// 如果未启用Mock模式,调用原始方法
|
|
||||||
throw new Error('Mock not enabled');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提取URL参数
|
|
||||||
const urlParams = new URLSearchParams(config?.params);
|
|
||||||
const params = Object.fromEntries(urlParams.entries());
|
|
||||||
|
|
||||||
// 处理请求并返回axios响应格式
|
|
||||||
const mockData = await mockServer.handleRequest(url, method, data, params);
|
|
||||||
return {
|
|
||||||
data: mockData,
|
|
||||||
status: 200,
|
|
||||||
statusText: 'OK',
|
|
||||||
headers: {},
|
|
||||||
config: {}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// 安装Mock拦截器
|
|
||||||
export const installMockInterceptor = (requestInstance: any) => {
|
|
||||||
if (!isMockEnabled()) {
|
|
||||||
console.log('Mock is disabled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Installing mock interceptor...');
|
|
||||||
|
|
||||||
// 保存原始方法
|
|
||||||
originalGet = requestInstance.get;
|
|
||||||
originalPost = requestInstance.post;
|
|
||||||
originalPut = requestInstance.put;
|
|
||||||
originalDelete = requestInstance.delete;
|
|
||||||
|
|
||||||
// 替换为Mock方法
|
|
||||||
requestInstance.get = async (url: string, config?: any) => {
|
|
||||||
try {
|
|
||||||
return await mockRequest('GET', url, undefined, config);
|
|
||||||
} catch (error) {
|
|
||||||
// 如果Mock处理失败,调用原始方法
|
|
||||||
return originalGet.call(requestInstance, url, config);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
requestInstance.post = async (url: string, data?: any, config?: any) => {
|
|
||||||
try {
|
|
||||||
return await mockRequest('POST', url, data, config);
|
|
||||||
} catch (error) {
|
|
||||||
// 如果Mock处理失败,调用原始方法
|
|
||||||
return originalPost.call(requestInstance, url, data, config);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
requestInstance.put = async (url: string, data?: any, config?: any) => {
|
|
||||||
try {
|
|
||||||
return await mockRequest('PUT', url, data, config);
|
|
||||||
} catch (error) {
|
|
||||||
// 如果Mock处理失败,调用原始方法
|
|
||||||
return originalPut.call(requestInstance, url, data, config);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
requestInstance.delete = async (url: string, config?: any) => {
|
|
||||||
try {
|
|
||||||
return await mockRequest('DELETE', url, undefined, config);
|
|
||||||
} catch (error) {
|
|
||||||
// 如果Mock处理失败,调用原始方法
|
|
||||||
return originalDelete.call(requestInstance, url, config);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// 卸载Mock拦截器
|
|
||||||
export const uninstallMockInterceptor = (requestInstance: any) => {
|
|
||||||
if (originalGet) {
|
|
||||||
requestInstance.get = originalGet;
|
|
||||||
}
|
|
||||||
if (originalPost) {
|
|
||||||
requestInstance.post = originalPost;
|
|
||||||
}
|
|
||||||
if (originalPut) {
|
|
||||||
requestInstance.put = originalPut;
|
|
||||||
}
|
|
||||||
if (originalDelete) {
|
|
||||||
requestInstance.delete = originalDelete;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 切换Mock模式
|
|
||||||
export const toggleMock = (requestInstance: any, enabled: boolean) => {
|
|
||||||
if (enabled) {
|
|
||||||
installMockInterceptor(requestInstance);
|
|
||||||
} else {
|
|
||||||
uninstallMockInterceptor(requestInstance);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,605 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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();
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export { authService } from './auth.service';
|
|
||||||
export { menuService } from './menu.service';
|
|
||||||
export { roleService } from './role.service';
|
|
||||||
export { userService } from './user.service';
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
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
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
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
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
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';
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user