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

添加用户管理视图、API和状态管理文件
This commit is contained in:
张翔
2026-03-28 14:37:29 +08:00
commit 08ea5fbe98
1643 changed files with 255646 additions and 0 deletions
@@ -0,0 +1,310 @@
/**
* 测试数据工厂
* 生成各种测试数据,支持边界条件和异常数据
*/
import { faker } from '@faker-js/faker';
import { zh_CN } from '@faker-js/faker/locale/zh_CN';
faker.locale = zh_CN;
export interface UserData {
id?: number;
username: string;
realName: string;
email: string;
phone: string;
password: string;
confirmPassword: string;
gender: number;
status: number;
avatar?: string;
createBy?: string;
updateBy?: string;
createdAt?: string;
updatedAt?: string;
}
export interface RoleData {
id?: number;
roleName: string;
roleCode: string;
description: string;
status: number;
permissions?: string[];
createBy?: string;
updateBy?: string;
createdAt?: string;
updatedAt?: string;
}
export interface MenuData {
id?: number;
menuName: string;
menuCode: string;
path: string;
icon: string;
sortOrder: number;
status: number;
parentId: number;
menuType: number;
component?: string;
permission?: string;
createBy?: string;
updateBy?: string;
createdAt?: string;
updatedAt?: string;
}
export interface AlmanacData {
date: string;
lunarDate: string;
ganZhi: string;
zodiac: string;
yi: string[];
ji: string[];
jieQi?: string;
sha?: string;
jiShen?: string[];
xiongShen?: string[];
}
export interface CalendarData {
year: number;
month: number;
day: number;
lunarYear: number;
lunarMonth: number;
lunarDay: number;
lunarMonthName: string;
lunarDayName: string;
ganZhiYear: string;
ganZhiMonth: string;
ganZhiDay: string;
zodiac: string;
isLeapMonth: boolean;
isToday: boolean;
}
class TestDataFactory {
/**
* 生成正常用户数据
*/
generateUserData(overrides: Partial<UserData> = {}): UserData {
const timestamp = Date.now();
const password = 'Test@123456';
return {
username: `testuser_${timestamp}`,
realName: faker.person.fullName(),
email: `test_${timestamp}@example.com`,
phone: `1${faker.string.numeric(10)}`,
password,
confirmPassword: password,
gender: faker.number.int({ min: 0, max: 2 }),
status: 1,
...overrides,
};
}
/**
* 生成边界条件用户数据
*/
generateBoundaryUserData(type: 'min' | 'max' | 'empty' | 'special'): Partial<UserData> {
const timestamp = Date.now();
switch (type) {
case 'min':
return {
username: 'abc',
nickname: '测',
email: 'test@example.com',
phone: '13800138000',
password: 'A1@aaaa',
confirmPassword: 'A1@aaaa',
roleIds: [1], // 添加默认角色
};
case 'max':
return {
username: 'a'.repeat(20),
nickname: '测'.repeat(50),
email: `test${timestamp}@example.com`,
phone: '13800138000',
password: 'A1@' + 'a'.repeat(17),
confirmPassword: 'A1@' + 'a'.repeat(17),
roleIds: [1],
};
case 'empty':
return {
username: '',
nickname: '',
email: '',
phone: '',
password: '',
confirmPassword: '',
roleIds: [],
};
case 'special':
return {
username: `test_${timestamp}`,
nickname: '测试<script>alert(1)</script>',
email: `test+${timestamp}@example.com`,
phone: '13800138000',
password: 'Test@123!@#$%^&*()',
confirmPassword: 'Test@123!@#$%^&*()',
roleIds: [1],
};
default:
return {};
}
}
/**
* 生成异常用户数据
*/
generateInvalidUserData(type: 'duplicate' | 'invalid_email' | 'invalid_phone' | 'weak_password'): Partial<UserData> {
const timestamp = Date.now();
switch (type) {
case 'duplicate':
return {
username: 'admin',
email: 'admin@example.com',
};
case 'invalid_email':
return {
email: 'invalid-email',
};
case 'invalid_phone':
return {
phone: '12345678901',
};
case 'weak_password':
return {
password: '123456',
confirmPassword: '123456',
};
default:
return {};
}
}
/**
* 生成角色数据
*/
generateRoleData(overrides: Partial<RoleData> = {}): RoleData {
const timestamp = Date.now();
return {
roleName: `测试角色_${timestamp}`,
roleCode: `test_role_${timestamp}`,
description: faker.lorem.sentence(),
status: 1,
permissions: [],
...overrides,
};
}
/**
* 生成菜单数据
*/
generateMenuData(overrides: Partial<MenuData> = {}): MenuData {
const timestamp = Date.now();
return {
menuName: `测试菜单_${timestamp}`,
menuCode: `test_menu_${timestamp}`,
path: `/test-menu-${timestamp}`,
icon: 'SettingOutlined',
sortOrder: faker.number.int({ min: 1, max: 100 }),
status: 0,
parentId: 0,
menuType: 1,
...overrides,
};
}
/**
* 生成黄历数据
*/
generateAlmanacData(date: string = new Date().toISOString().split('T')[0]): AlmanacData {
return {
date,
lunarDate: '农历日期',
ganZhi: '甲子年 丙寅月 戊辰日',
zodiac: '鼠',
yi: ['嫁娶', '祭祀', '祈福', '求嗣', '开光', '出行'],
ji: ['开市', '立券', '交易', '纳财'],
jieQi: '立春',
sha: '南',
jiShen: ['天德', '月德', '天恩'],
xiongShen: ['月破', '大耗', '四击'],
};
}
/**
* 生成日历数据
*/
generateCalendarData(year: number = new Date().getFullYear(), month: number = new Date().getMonth() + 1): CalendarData {
return {
year,
month,
day: 15,
lunarYear: year,
lunarMonth: month,
lunarDay: 15,
lunarMonthName: '正月',
lunarDayName: '十五',
ganZhiYear: '甲子',
ganZhiMonth: '丙寅',
ganZhiDay: '戊辰',
zodiac: '鼠',
isLeapMonth: false,
isToday: false,
};
}
/**
* 生成批量测试数据
*/
generateBatchData<T>(
generator: () => T,
count: number,
overrides: Partial<T> = {}
): T[] {
return Array.from({ length: count }, () => ({
...generator(),
...overrides,
}));
}
/**
* 生成测试日期范围
*/
generateDateRange(days: number = 30): { start: string; end: string } {
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - days);
return {
start: start.toISOString().split('T')[0],
end: end.toISOString().split('T')[0],
};
}
/**
* 生成特殊日期
*/
generateSpecialDates(): Record<string, string> {
const year = new Date().getFullYear();
return {
newYear: `${year}-01-01`,
springFestival: `${year}-02-10`, // 示例春节日期
laborDay: `${year}-05-01`,
nationalDay: `${year}-10-01`,
leapYearFeb29: `${year % 4 === 0 ? year : year + 4}-02-29`,
};
}
}
export const testDataFactory = new TestDataFactory();
@@ -0,0 +1,218 @@
/**
* 测试日志记录器
* 提供结构化的测试日志记录
*/
export interface LogEntry {
timestamp: string;
level: 'debug' | 'info' | 'warn' | 'error';
message: string;
context?: Record<string, unknown>;
}
export interface TestStep {
name: string;
status: 'pending' | 'running' | 'passed' | 'failed' | 'skipped';
startTime?: string;
endTime?: string;
duration?: number;
logs: LogEntry[];
error?: Error;
}
export interface TestResult {
testName: string;
status: 'passed' | 'failed' | 'skipped';
startTime: string;
endTime: string;
duration: number;
steps: TestStep[];
logs: LogEntry[];
screenshots: string[];
error?: Error;
retryCount?: number;
}
class TestLogger {
private logs: LogEntry[] = [];
private steps: TestStep[] = [];
private currentStep: TestStep | null = null;
private currentTest: TestResult | null = null;
private getTimestamp(): string {
return new Date().toISOString();
}
private addLog(level: LogEntry['level'], message: string, context?: Record<string, unknown>): void {
const entry: LogEntry = {
timestamp: this.getTimestamp(),
level,
message,
context,
};
this.logs.push(entry);
if (this.currentStep) {
this.currentStep.logs.push(entry);
}
// 控制台输出
const consoleMessage = `[${entry.timestamp}] [${level.toUpperCase()}] ${message}`;
switch (level) {
case 'debug':
console.debug(consoleMessage);
break;
case 'info':
console.info(consoleMessage);
break;
case 'warn':
console.warn(consoleMessage);
break;
case 'error':
console.error(consoleMessage);
break;
}
}
debug(message: string, context?: Record<string, unknown>): void {
this.addLog('debug', message, context);
}
info(message: string, context?: Record<string, unknown>): void {
this.addLog('info', message, context);
}
warn(message: string, context?: Record<string, unknown>): void {
this.addLog('warn', message, context);
}
error(message: string, error?: Error, context?: Record<string, unknown>): void {
this.addLog('error', message, {
...context,
error: error?.message,
stack: error?.stack,
});
}
startTest(testName: string): void {
this.currentTest = {
testName,
status: 'passed',
startTime: this.getTimestamp(),
endTime: '',
duration: 0,
steps: [],
logs: [],
screenshots: [],
};
this.logs = [];
this.steps = [];
this.info(`开始测试: ${testName}`);
}
endTest(testName: string, status: 'passed' | 'failed' | 'skipped', error?: Error): void {
if (this.currentTest) {
this.currentTest.status = status;
this.currentTest.endTime = this.getTimestamp();
this.currentTest.duration = new Date(this.currentTest.endTime).getTime() -
new Date(this.currentTest.startTime).getTime();
this.currentTest.steps = this.steps;
this.currentTest.logs = this.logs;
if (error) {
this.currentTest.error = error;
}
this.info(`测试结束: ${testName} - ${status}`, {
duration: this.currentTest.duration,
stepsCount: this.steps.length,
});
}
}
startStep(stepName: string): void {
if (this.currentStep) {
this.endStep(this.currentStep.name, 'failed');
}
this.currentStep = {
name: stepName,
status: 'running',
startTime: this.getTimestamp(),
logs: [],
};
this.info(`开始步骤: ${stepName}`);
}
endStep(stepName: string, status: TestStep['status'], error?: Error): void {
if (this.currentStep && this.currentStep.name === stepName) {
this.currentStep.status = status;
this.currentStep.endTime = this.getTimestamp();
if (this.currentStep.startTime) {
this.currentStep.duration = new Date(this.currentStep.endTime).getTime() -
new Date(this.currentStep.startTime).getTime();
}
if (error) {
this.currentStep.error = error;
}
this.steps.push(this.currentStep);
this.info(`步骤结束: ${stepName} - ${status}`, {
duration: this.currentStep.duration,
});
this.currentStep = null;
}
}
addScreenshot(path: string): void {
if (this.currentTest) {
this.currentTest.screenshots.push(path);
}
this.info(`截图已保存: ${path}`);
}
getCurrentTest(): TestResult | null {
return this.currentTest;
}
getLogs(): LogEntry[] {
return this.logs;
}
getSteps(): TestStep[] {
return this.steps;
}
clear(): void {
this.logs = [];
this.steps = [];
this.currentStep = null;
this.currentTest = null;
}
/**
* 生成测试执行摘要
*/
generateSummary(): Record<string, unknown> {
const passed = this.steps.filter(s => s.status === 'passed').length;
const failed = this.steps.filter(s => s.status === 'failed').length;
const skipped = this.steps.filter(s => s.status === 'skipped').length;
return {
totalSteps: this.steps.length,
passed,
failed,
skipped,
totalLogs: this.logs.length,
errors: this.logs.filter(l => l.level === 'error').length,
warnings: this.logs.filter(l => l.level === 'warn').length,
};
}
}
export const testLogger = new TestLogger();
@@ -0,0 +1,387 @@
/**
* 测试报告生成器
* 生成多种格式的测试报告:HTML、JSON、JUnit XML、Markdown
*/
import * as fs from 'fs';
import * as path from 'path';
import { TestResult, TestStep } from './test-logger';
export interface TestSuite {
name: string;
tests: TestResult[];
startTime?: string;
endTime?: string;
}
export interface ReportSummary {
totalTests: number;
passed: number;
failed: number;
skipped: number;
totalDuration: number;
passRate: number;
startTime: string;
endTime: string;
}
class TestReporter {
private suites: TestSuite[] = [];
private startTime: string = '';
private endTime: string = '';
startReport(): void {
this.startTime = new Date().toISOString();
this.suites = [];
}
endReport(): void {
this.endTime = new Date().toISOString();
}
addTestSuite(suite: TestSuite): void {
this.suites.push(suite);
}
recordTestResult(test: TestResult): void {
// 查找或创建测试套件
let suite = this.suites.find(s => s.name === 'Default Suite');
if (!suite) {
suite = { name: 'Default Suite', tests: [] };
this.suites.push(suite);
}
suite.tests.push(test);
}
generateSummary(): ReportSummary {
const allTests = this.suites.flatMap(s => s.tests);
const passed = allTests.filter(t => t.status === 'passed').length;
const failed = allTests.filter(t => t.status === 'failed').length;
const skipped = allTests.filter(t => t.status === 'skipped').length;
const totalDuration = allTests.reduce((sum, t) => sum + t.duration, 0);
return {
totalTests: allTests.length,
passed,
failed,
skipped,
totalDuration,
passRate: allTests.length > 0 ? (passed / allTests.length) * 100 : 0,
startTime: this.startTime,
endTime: this.endTime,
};
}
/**
* 生成JSON格式报告
*/
generateJSONReport(outputPath: string): void {
const report = {
summary: this.generateSummary(),
suites: this.suites,
generatedAt: new Date().toISOString(),
};
this.ensureDirectoryExists(path.dirname(outputPath));
fs.writeFileSync(outputPath, JSON.stringify(report, null, 2), 'utf-8');
console.log(`JSON报告已生成: ${outputPath}`);
}
/**
* 生成HTML格式报告
*/
generateHTMLReport(outputPath: string): void {
const summary = this.generateSummary();
const html = this.buildHTMLReport(summary);
this.ensureDirectoryExists(path.dirname(outputPath));
fs.writeFileSync(outputPath, html, 'utf-8');
console.log(`HTML报告已生成: ${outputPath}`);
}
/**
* 生成JUnit XML格式报告
*/
generateJUnitReport(outputPath: string): void {
const summary = this.generateSummary();
const xml = this.buildJUnitReport(summary);
this.ensureDirectoryExists(path.dirname(outputPath));
fs.writeFileSync(outputPath, xml, 'utf-8');
console.log(`JUnit报告已生成: ${outputPath}`);
}
/**
* 生成Markdown格式报告
*/
generateMarkdownReport(outputPath: string): void {
const summary = this.generateSummary();
const markdown = this.buildMarkdownReport(summary);
this.ensureDirectoryExists(path.dirname(outputPath));
fs.writeFileSync(outputPath, markdown, 'utf-8');
console.log(`Markdown报告已生成: ${outputPath}`);
}
/**
* 生成所有报告
*/
generateAllReports(outputDir: string): void {
this.endReport();
this.generateJSONReport(path.join(outputDir, 'e2e-report.json'));
this.generateHTMLReport(path.join(outputDir, 'e2e-report.html'));
this.generateJUnitReport(path.join(outputDir, 'junit-report.xml'));
this.generateMarkdownReport(path.join(outputDir, 'e2e-report.md'));
// 打印摘要
this.printSummary();
}
private printSummary(): void {
const summary = this.generateSummary();
console.log('\n========== 测试执行摘要 ==========');
console.log(`总测试数: ${summary.totalTests}`);
console.log(`通过: ${summary.passed}`);
console.log(`失败: ${summary.failed}`);
console.log(`跳过: ${summary.skipped} ⏭️`);
console.log(`通过率: ${summary.passRate.toFixed(2)}%`);
console.log(`总耗时: ${(summary.totalDuration / 1000).toFixed(2)}s`);
console.log('===================================\n');
}
private ensureDirectoryExists(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
private buildHTMLReport(summary: ReportSummary): string {
const allTests = this.suites.flatMap(s => s.tests);
const statusColor = {
passed: '#28a745',
failed: '#dc3545',
skipped: '#ffc107',
};
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, sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container { max-width: 1200px; margin: 0 auto; }
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 10px;
margin-bottom: 20px;
}
.header h1 { font-size: 28px; margin-bottom: 10px; }
.summary-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.card h3 { font-size: 14px; color: #666; margin-bottom: 8px; }
.card .value { font-size: 32px; font-weight: bold; }
.card.passed .value { color: #28a745; }
.card.failed .value { color: #dc3545; }
.card.skipped .value { color: #ffc107; }
.progress-bar {
background: #e9ecef;
height: 20px;
border-radius: 10px;
overflow: hidden;
margin-top: 10px;
}
.progress-fill {
background: linear-gradient(90deg, #28a745 0%, #20c997 100%);
height: 100%;
transition: width 0.3s ease;
}
.test-list {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
overflow: hidden;
}
.test-list-header {
background: #f8f9fa;
padding: 15px 20px;
font-weight: bold;
border-bottom: 1px solid #dee2e6;
}
.test-item {
padding: 15px 20px;
border-bottom: 1px solid #dee2e6;
display: flex;
justify-content: space-between;
align-items: center;
}
.test-item:last-child { border-bottom: none; }
.test-name { font-weight: 500; }
.test-status {
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
}
.test-status.passed { background: #d4edda; color: #155724; }
.test-status.failed { background: #f8d7da; color: #721c24; }
.test-status.skipped { background: #fff3cd; color: #856404; }
.test-duration { color: #666; font-size: 14px; margin-left: 10px; }
.footer {
text-align: center;
padding: 20px;
color: #666;
font-size: 14px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>E2E测试报告</h1>
<p>生成时间: ${new Date().toLocaleString('zh-CN')}</p>
</div>
<div class="summary-cards">
<div class="card">
<h3>总测试数</h3>
<div class="value">${summary.totalTests}</div>
</div>
<div class="card passed">
<h3>通过</h3>
<div class="value">${summary.passed}</div>
</div>
<div class="card failed">
<h3>失败</h3>
<div class="value">${summary.failed}</div>
</div>
<div class="card skipped">
<h3>跳过</h3>
<div class="value">${summary.skipped}</div>
</div>
</div>
<div class="card" style="margin-bottom: 20px;">
<h3>通过率: ${summary.passRate.toFixed(2)}%</h3>
<div class="progress-bar">
<div class="progress-fill" style="width: ${summary.passRate}%"></div>
</div>
<p style="margin-top: 10px; color: #666;">
总耗时: ${(summary.totalDuration / 1000).toFixed(2)}
</p>
</div>
<div class="test-list">
<div class="test-list-header">测试详情</div>
${allTests.map(test => `
<div class="test-item">
<div>
<span class="test-name">${test.testName}</span>
<span class="test-duration">${(test.duration / 1000).toFixed(2)}s</span>
</div>
<span class="test-status ${test.status}">${test.status}</span>
</div>
`).join('')}
</div>
<div class="footer">
<p>由 Playwright E2E 测试框架生成</p>
</div>
</div>
</body>
</html>`;
}
private buildJUnitReport(summary: ReportSummary): string {
const allTests = this.suites.flatMap(s => s.tests);
const failures = allTests.filter(t => t.status === 'failed').length;
let xml = `<?xml version="1.0" encoding="UTF-8"?>\n`;
xml += `<testsuites name="E2E Tests" tests="${summary.totalTests}" failures="${failures}" skipped="${summary.skipped}" time="${summary.totalDuration / 1000}">\n`;
this.suites.forEach(suite => {
xml += ` <testsuite name="${this.escapeXml(suite.name)}" tests="${suite.tests.length}" failures="${suite.tests.filter(t => t.status === 'failed').length}">\n`;
suite.tests.forEach(test => {
xml += ` <testcase name="${this.escapeXml(test.testName)}" time="${test.duration / 1000}">\n`;
if (test.status === 'failed' && test.error) {
xml += ` <failure message="${this.escapeXml(test.error.message)}">\n`;
xml += ` ${this.escapeXml(test.error.stack || '')}\n`;
xml += ` </failure>\n`;
} else if (test.status === 'skipped') {
xml += ` <skipped/>\n`;
}
xml += ` </testcase>\n`;
});
xml += ` </testsuite>\n`;
});
xml += `</testsuites>`;
return xml;
}
private buildMarkdownReport(summary: ReportSummary): string {
const allTests = this.suites.flatMap(s => s.tests);
let md = `# E2E测试报告\n\n`;
md += `**生成时间**: ${new Date().toLocaleString('zh-CN')}\n\n`;
md += `## 执行摘要\n\n`;
md += `| 指标 | 数值 |\n`;
md += `|------|------|\n`;
md += `| 总测试数 | ${summary.totalTests} |\n`;
md += `| 通过 | ${summary.passed} ✅ |\n`;
md += `| 失败 | ${summary.failed} ❌ |\n`;
md += `| 跳过 | ${summary.skipped} ⏭️ |\n`;
md += `| 通过率 | ${summary.passRate.toFixed(2)}% |\n`;
md += `| 总耗时 | ${(summary.totalDuration / 1000).toFixed(2)}秒 |\n\n`;
md += `## 测试详情\n\n`;
md += `| 测试名称 | 状态 | 耗时 |\n`;
md += `|----------|------|------|\n`;
allTests.forEach(test => {
const statusIcon = test.status === 'passed' ? '✅' : test.status === 'failed' ? '❌' : '⏭️';
md += `| ${test.testName} | ${statusIcon} ${test.status} | ${(test.duration / 1000).toFixed(2)}s |\n`;
});
md += `\n---\n\n`;
md += `*由 Playwright E2E 测试框架生成*\n`;
return md;
}
private escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
}
export const testReporter = new TestReporter();