08ea5fbe98
添加用户管理视图、API和状态管理文件
516 lines
15 KiB
Python
516 lines
15 KiB
Python
"""
|
|
报告生成器模块
|
|
"""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
from typing import Dict, Any, List
|
|
from dataclasses import dataclass, asdict
|
|
|
|
@dataclass
|
|
class TestResult:
|
|
"""测试结果数据类"""
|
|
test_name: str
|
|
test_type: str
|
|
url: str
|
|
method: str
|
|
status_code: int
|
|
response_time: float
|
|
success: bool
|
|
error_message: str
|
|
request_data: Dict[str, Any]
|
|
response_data: Dict[str, Any]
|
|
timestamp: str
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""转换为字典"""
|
|
return asdict(self)
|
|
|
|
|
|
@dataclass
|
|
class TestSummary:
|
|
"""测试摘要数据类"""
|
|
total: int
|
|
passed: int
|
|
failed: int
|
|
skipped: int
|
|
pass_rate: float
|
|
total_time: float
|
|
start_time: str
|
|
end_time: str
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""转换为字典"""
|
|
return asdict(self)
|
|
|
|
|
|
class ReportGenerator:
|
|
"""测试报告生成器"""
|
|
|
|
def __init__(self, output_dir: str = "reports"):
|
|
"""
|
|
初始化报告生成器
|
|
|
|
Args:
|
|
output_dir: 报告输出目录
|
|
"""
|
|
self.output_dir = Path(output_dir)
|
|
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
def generate_json_report(self, results: List[TestResult], summary: TestSummary, filename: str = None) -> str:
|
|
"""
|
|
生成JSON格式报告
|
|
|
|
Args:
|
|
results: 测试结果列表
|
|
summary: 测试摘要
|
|
filename: 报告文件名
|
|
|
|
Returns:
|
|
报告文件路径
|
|
"""
|
|
if filename is None:
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
filename = f"test_report_{timestamp}.json"
|
|
|
|
report_path = self.output_dir / filename
|
|
|
|
report_data = {
|
|
"summary": summary.to_dict(),
|
|
"results": [result.to_dict() for result in results],
|
|
"generated_at": datetime.now().isoformat()
|
|
}
|
|
|
|
with open(report_path, 'w', encoding='utf-8') as f:
|
|
json.dump(report_data, f, ensure_ascii=False, indent=2)
|
|
|
|
return str(report_path)
|
|
|
|
def generate_html_report(self, results: List[TestResult], summary: TestSummary, filename: str = None) -> str:
|
|
"""
|
|
生成HTML格式报告
|
|
|
|
Args:
|
|
results: 测试结果列表
|
|
summary: 测试摘要
|
|
filename: 报告文件名
|
|
|
|
Returns:
|
|
报告文件路径
|
|
"""
|
|
if filename is None:
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
filename = f"test_report_{timestamp}.html"
|
|
|
|
report_path = self.output_dir / filename
|
|
|
|
html_content = self._generate_html_content(results, summary)
|
|
|
|
with open(report_path, 'w', encoding='utf-8') as f:
|
|
f.write(html_content)
|
|
|
|
return str(report_path)
|
|
|
|
def _generate_html_content(self, results: List[TestResult], summary: TestSummary) -> str:
|
|
"""
|
|
生成HTML内容
|
|
|
|
Args:
|
|
results: 测试结果列表
|
|
summary: 测试摘要
|
|
|
|
Returns:
|
|
HTML内容
|
|
"""
|
|
passed_results = [r for r in results if r.success]
|
|
failed_results = [r for r in results if not r.success]
|
|
|
|
html = f"""<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>测试报告 - {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</title>
|
|
<style>
|
|
* {{
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}}
|
|
|
|
body {{
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
padding: 20px;
|
|
color: #333;
|
|
}}
|
|
|
|
.container {{
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
background: white;
|
|
border-radius: 10px;
|
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
|
overflow: hidden;
|
|
}}
|
|
|
|
.header {{
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
padding: 30px;
|
|
text-align: center;
|
|
}}
|
|
|
|
.header h1 {{
|
|
font-size: 28px;
|
|
margin-bottom: 10px;
|
|
}}
|
|
|
|
.header p {{
|
|
font-size: 14px;
|
|
opacity: 0.9;
|
|
}}
|
|
|
|
.summary {{
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 20px;
|
|
padding: 30px;
|
|
background: #f8f9fa;
|
|
}}
|
|
|
|
.summary-card {{
|
|
background: white;
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
text-align: center;
|
|
}}
|
|
|
|
.summary-card h3 {{
|
|
font-size: 14px;
|
|
color: #666;
|
|
margin-bottom: 10px;
|
|
}}
|
|
|
|
.summary-card .value {{
|
|
font-size: 32px;
|
|
font-weight: bold;
|
|
color: #333;
|
|
}}
|
|
|
|
.summary-card.passed .value {{
|
|
color: #52c41a;
|
|
}}
|
|
|
|
.summary-card.failed .value {{
|
|
color: #ff4d4f;
|
|
}}
|
|
|
|
.progress-bar {{
|
|
grid-column: 1 / -1;
|
|
height: 30px;
|
|
background: #e8e8e8;
|
|
border-radius: 15px;
|
|
overflow: hidden;
|
|
margin-top: 20px;
|
|
}}
|
|
|
|
.progress-fill {{
|
|
height: 100%;
|
|
background: linear-gradient(90deg, #52c41a 0%, #73d13d 100%);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: white;
|
|
font-weight: bold;
|
|
transition: width 0.3s ease;
|
|
}}
|
|
|
|
.results {{
|
|
padding: 30px;
|
|
}}
|
|
|
|
.results h2 {{
|
|
font-size: 24px;
|
|
margin-bottom: 20px;
|
|
color: #333;
|
|
}}
|
|
|
|
.test-item {{
|
|
background: white;
|
|
border: 1px solid #e8e8e8;
|
|
border-radius: 8px;
|
|
margin-bottom: 15px;
|
|
overflow: hidden;
|
|
}}
|
|
|
|
.test-item.passed {{
|
|
border-left: 4px solid #52c41a;
|
|
}}
|
|
|
|
.test-item.failed {{
|
|
border-left: 4px solid #ff4d4f;
|
|
}}
|
|
|
|
.test-header {{
|
|
padding: 15px 20px;
|
|
background: #fafafa;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
cursor: pointer;
|
|
}}
|
|
|
|
.test-header:hover {{
|
|
background: #f0f0f0;
|
|
}}
|
|
|
|
.test-name {{
|
|
font-weight: bold;
|
|
color: #333;
|
|
}}
|
|
|
|
.test-status {{
|
|
padding: 4px 12px;
|
|
border-radius: 12px;
|
|
font-size: 12px;
|
|
font-weight: bold;
|
|
}}
|
|
|
|
.test-status.passed {{
|
|
background: #f6ffed;
|
|
color: #52c41a;
|
|
}}
|
|
|
|
.test-status.failed {{
|
|
background: #fff1f0;
|
|
color: #ff4d4f;
|
|
}}
|
|
|
|
.test-details {{
|
|
padding: 20px;
|
|
display: none;
|
|
border-top: 1px solid #e8e8e8;
|
|
}}
|
|
|
|
.test-item.expanded .test-details {{
|
|
display: block;
|
|
}}
|
|
|
|
.detail-row {{
|
|
margin-bottom: 10px;
|
|
}}
|
|
|
|
.detail-label {{
|
|
font-weight: bold;
|
|
color: #666;
|
|
margin-right: 10px;
|
|
}}
|
|
|
|
.detail-value {{
|
|
color: #333;
|
|
}}
|
|
|
|
.error-message {{
|
|
background: #fff1f0;
|
|
color: #ff4d4f;
|
|
padding: 10px;
|
|
border-radius: 4px;
|
|
margin-top: 10px;
|
|
}}
|
|
|
|
.code-block {{
|
|
background: #f5f5f5;
|
|
padding: 15px;
|
|
border-radius: 4px;
|
|
overflow-x: auto;
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 13px;
|
|
margin-top: 10px;
|
|
}}
|
|
|
|
.footer {{
|
|
text-align: center;
|
|
padding: 20px;
|
|
background: #f8f9fa;
|
|
color: #666;
|
|
font-size: 14px;
|
|
}}
|
|
|
|
@media (max-width: 768px) {{
|
|
.summary {{
|
|
grid-template-columns: 1fr;
|
|
}}
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>API测试报告</h1>
|
|
<p>生成时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</p>
|
|
</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">
|
|
<h3>跳过</h3>
|
|
<div class="value">{summary.skipped}</div>
|
|
</div>
|
|
<div class="summary-card">
|
|
<h3>通过率</h3>
|
|
<div class="value">{summary.pass_rate:.1f}%</div>
|
|
</div>
|
|
<div class="summary-card">
|
|
<h3>总耗时</h3>
|
|
<div class="value">{summary.total_time:.0f}ms</div>
|
|
</div>
|
|
|
|
<div class="progress-bar">
|
|
<div class="progress-fill" style="width: {summary.pass_rate}%">
|
|
{summary.pass_rate:.1f}%
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="results">
|
|
<h2>测试结果</h2>
|
|
<div id="test-results">
|
|
"""
|
|
|
|
for result in results:
|
|
status_class = "passed" if result.success else "failed"
|
|
status_text = "通过" if result.success else "失败"
|
|
|
|
html += f"""
|
|
<div class="test-item {status_class}" onclick="toggleDetails(this)">
|
|
<div class="test-header">
|
|
<span class="test-name">{result.test_name}</span>
|
|
<span class="test-status {status_class}">{status_text}</span>
|
|
</div>
|
|
<div class="test-details">
|
|
<div class="detail-row">
|
|
<span class="detail-label">测试类型:</span>
|
|
<span class="detail-value">{result.test_type}</span>
|
|
</div>
|
|
<div class="detail-row">
|
|
<span class="detail-label">请求方法:</span>
|
|
<span class="detail-value">{result.method}</span>
|
|
</div>
|
|
<div class="detail-row">
|
|
<span class="detail-label">请求URL:</span>
|
|
<span class="detail-value">{result.url}</span>
|
|
</div>
|
|
<div class="detail-row">
|
|
<span class="detail-label">状态码:</span>
|
|
<span class="detail-value">{result.status_code}</span>
|
|
</div>
|
|
<div class="detail-row">
|
|
<span class="detail-label">响应时间:</span>
|
|
<span class="detail-value">{result.response_time:.2f}ms</span>
|
|
</div>
|
|
<div class="detail-row">
|
|
<span class="detail-label">时间戳:</span>
|
|
<span class="detail-value">{result.timestamp}</span>
|
|
</div>
|
|
{self._generate_request_details(result)}
|
|
{self._generate_response_details(result)}
|
|
{self._generate_error_details(result)}
|
|
</div>
|
|
</div>
|
|
"""
|
|
|
|
html += """
|
|
</div>
|
|
</div>
|
|
|
|
<div class="footer">
|
|
<p>Everything is Suitable - API测试工具</p>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
function toggleDetails(element) {
|
|
element.classList.toggle('expanded');
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
return html
|
|
|
|
def _generate_request_details(self, result: TestResult) -> str:
|
|
"""生成请求详情"""
|
|
if not result.request_data:
|
|
return ""
|
|
|
|
import json
|
|
|
|
return f"""
|
|
<div class="detail-row">
|
|
<span class="detail-label">请求数据:</span>
|
|
</div>
|
|
<div class="code-block">{json.dumps(result.request_data, ensure_ascii=False, indent=2)}</div>
|
|
"""
|
|
|
|
def _generate_response_details(self, result: TestResult) -> str:
|
|
"""生成响应详情"""
|
|
if not result.response_data:
|
|
return ""
|
|
|
|
import json
|
|
|
|
return f"""
|
|
<div class="detail-row">
|
|
<span class="detail-label">响应数据:</span>
|
|
</div>
|
|
<div class="code-block">{json.dumps(result.response_data, ensure_ascii=False, indent=2)}</div>
|
|
"""
|
|
|
|
def _generate_error_details(self, result: TestResult) -> str:
|
|
"""生成错误详情"""
|
|
if result.success or not result.error_message:
|
|
return ""
|
|
|
|
return f"""
|
|
<div class="error-message">
|
|
<strong>错误信息:</strong> {result.error_message}
|
|
</div>
|
|
"""
|
|
|
|
def generate_all_reports(self, results: List[TestResult], summary: TestSummary) -> Dict[str, str]:
|
|
"""
|
|
生成所有格式的报告
|
|
|
|
Args:
|
|
results: 测试结果列表
|
|
summary: 测试摘要
|
|
|
|
Returns:
|
|
报告文件路径字典
|
|
"""
|
|
reports = {}
|
|
|
|
# 生成JSON报告
|
|
json_path = self.generate_json_report(results, summary)
|
|
reports['json'] = json_path
|
|
|
|
# 生成HTML报告
|
|
html_path = self.generate_html_report(results, summary)
|
|
reports['html'] = html_path
|
|
|
|
return reports |