""" 考勤模块全功能 Web 测试脚本 使用 Playwright 对所有角色的考勤页面与核心交互进行功能测试 覆盖:admin / teacher / student / parent 四种角色 结果输出:webtest/attendance_0.1.0.md 与 webtest/attendance_0.1.0.json """ import json import re from datetime import datetime from pathlib import Path from urllib.parse import urlparse, parse_qs from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout BASE_URL = "http://localhost:3000" VERSION = "0.1.0" # 测试账号(来自 scripts/seed.ts) TEST_ACCOUNTS = { "admin": {"email": "admin@xiaoxue.edu.cn", "password": "123456", "expected_path": "/admin/dashboard"}, "teacher": {"email": "t_chinese_1@xiaoxue.edu.cn", "password": "123456", "expected_path": "/teacher/dashboard"}, "student": {"email": "student_g1c1_1@xiaoxue.edu.cn", "password": "123456", "expected_path": "/student/dashboard"}, "parent": {"email": "parent_g1c1_1@xiaoxue.edu.cn", "password": "123456", "expected_path": "/parent/dashboard"}, } # 各角色考勤页面路由(基于 src/modules/layout/config/navigation.ts 与源码目录) ATTENDANCE_ROUTES = { "admin": [ {"route": "/admin/attendance", "category": "考勤总览", "expected_keys": ["考勤总览"]}, ], "teacher": [ {"route": "/teacher/attendance", "category": "考勤记录", "expected_keys": ["考勤记录"]}, {"route": "/teacher/attendance/sheet", "category": "录入考勤", "expected_keys": ["录入考勤"]}, {"route": "/teacher/attendance/stats", "category": "考勤统计", "expected_keys": ["考勤统计"]}, ], "student": [ {"route": "/student/attendance", "category": "我的考勤", "expected_keys": ["我的考勤"]}, ], "parent": [ {"route": "/parent/attendance", "category": "子女考勤", "expected_keys": ["子女考勤"]}, ], } # 跨角色访问保护测试:每个角色不应能访问其他角色的考勤页 # 注意:admin 拥有所有权限,可以访问所有路由,因此不测试 admin 的跨角色访问 CROSS_ROLE_FORBIDDEN = { "teacher": ["/admin/attendance", "/student/attendance", "/parent/attendance"], "student": ["/admin/attendance", "/teacher/attendance", "/parent/attendance"], "parent": ["/admin/attendance", "/teacher/attendance", "/student/attendance"], } PROJECT_ROOT = Path(__file__).resolve().parents[1] WEBTEST_DIR = PROJECT_ROOT / "webtest" SCREENSHOT_DIR = WEBTEST_DIR / "screenshots" / "attendance" WEBTEST_DIR.mkdir(parents=True, exist_ok=True) SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True) results = { "test_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "module": "考勤 (Attendance)", "version": VERSION, "base_url": BASE_URL, "summary": { "total": 0, "passed": 0, "failed": 0, "warnings": 0, }, "roles": {}, "cross_role_tests": [], "interactions": [], "console_errors_global": [], } def _is_login_redirect(url: str) -> bool: """精确判断 URL 是否为登录页重定向""" parsed = urlparse(url) path = parsed.path.rstrip("/") if path.endswith("/login"): return True query = parse_qs(parsed.query) callback = query.get("callbackUrl", [""])[0] if callback and "/login" in callback: return True return False def login(page, role: str) -> bool: """登录指定角色账号""" account = TEST_ACCOUNTS[role] print(f"\n>>> 登录 {role} 账号 ({account['email']})...") # 先清除 cookies 确保干净状态 page.context.clear_cookies() page.goto(f"{BASE_URL}/login", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(800) current_path = urlparse(page.url).path if current_path.startswith(account["expected_path"]) or current_path.endswith("/dashboard"): print(f" 已登录,跳过登录步骤 (URL: {page.url})") return True try: # 填写邮箱 email_input = page.locator('input[name="email"]') if email_input.count() == 0: email_input = page.locator('input[type="email"]') email_input.fill(account["email"]) # 填写密码 password_input = page.locator('input[name="password"]') if password_input.count() == 0: password_input = page.locator('input[type="password"]') password_input.fill(account["password"]) # 点击登录按钮 - shadcn Button 不显式设置 type="submit" # 按钮文本是 "Sign In with Email" login_btn = page.get_by_role("button", name="Sign In with Email") if login_btn.count() == 0: login_btn = page.locator("form button").filter(has_text="Sign In") if login_btn.count() == 0: # 最后兜底:表单内第一个 button login_btn = page.locator("form button").first print(f" 找到登录按钮: {login_btn.count()} 个") login_btn.first.click() # 登录表单使用 signIn(redirect: false) + router.push # 需要等待 URL 变化,而不只是 networkidle try: page.wait_for_url(lambda url: "/login" not in url, timeout=20000) except PlaywrightTimeout: # 如果 URL 没变,可能登录失败 pass page.wait_for_timeout(1500) print(f" 登录后 URL: {page.url}") if "/login" in page.url: # 重试一次 print(f" ⚠️ 首次登录失败,重试...") page.context.clear_cookies() page.goto(f"{BASE_URL}/login", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(800) email_input = page.locator('input[name="email"]') if email_input.count() == 0: email_input = page.locator('input[type="email"]') email_input.fill(account["email"]) password_input = page.locator('input[name="password"]') if password_input.count() == 0: password_input = page.locator('input[type="password"]') password_input.fill(account["password"]) login_btn = page.get_by_role("button", name="Sign In with Email") if login_btn.count() == 0: login_btn = page.locator("form button").filter(has_text="Sign In") if login_btn.count() == 0: login_btn = page.locator("form button").first login_btn.first.click() try: page.wait_for_url(lambda url: "/login" not in url, timeout=20000) except PlaywrightTimeout: pass page.wait_for_timeout(1500) print(f" 重试后 URL: {page.url}") if "/login" in page.url: print(f" ❌ {role} 登录失败") return False return True except Exception as e: print(f" ❌ {role} 登录异常: {e}") return False def logout(page): """退出当前登录状态""" page.context.clear_cookies() print(" 已清除 cookies 退出登录") def collect_console_errors(page): """附加控制台错误收集器""" errors = [] def on_console(msg): if msg.type == "error": text = msg.text if "favicon" in text.lower() or "Download the React DevTools" in text: return errors.append(text) page.on("console", on_console) return errors, on_console def test_role_attendance(page, role: str) -> dict: """测试单个角色的考勤页面""" routes = ATTENDANCE_ROUTES[role] role_result = { "role": role, "login_success": False, "pages": [], "interactions": [], "errors": [], "warnings": [], } print(f"\n=== 测试 {role} 考勤模块 ===") if not login(page, role): role_result["errors"].append(f"{role} 登录失败") return role_result role_result["login_success"] = True console_errors, on_console = collect_console_errors(page) # 测试每个页面 for route_info in routes: route = route_info["route"] category = route_info["category"] expected_keys = route_info["expected_keys"] url = f"{BASE_URL}{route}" page_result = { "url": url, "route": route, "category": category, "status": "unknown", "http_status": None, "final_url": None, "checks": [], "errors": [], "warnings": [], "console_errors": [], "screenshot": None, } print(f"\n 测试: {category} - {route}") try: response = page.goto(url, timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1000) http_status = response.status if response else None final_url = page.url page_result["http_status"] = http_status page_result["final_url"] = final_url # 截图 safe_name = re.sub(r"[^\w\-]", "_", route.strip("/")) shot_path = SCREENSHOT_DIR / f"{role}_{safe_name}.png" try: page.screenshot(path=str(shot_path), full_page=True) page_result["screenshot"] = str(shot_path.relative_to(PROJECT_ROOT)) except Exception as e: page_result["warnings"].append(f"截图失败: {e}") body_text = page.locator("body").text_content() or "" # HTTP 状态检查 if http_status and http_status >= 500: page_result["status"] = "failed" page_result["errors"].append(f"HTTP {http_status} 服务器错误") elif http_status and http_status >= 400: page_result["status"] = "failed" page_result["errors"].append(f"HTTP {http_status} 客户端错误") elif _is_login_redirect(final_url): page_result["status"] = "failed" page_result["errors"].append("重定向到登录页 - 认证失败") elif urlparse(final_url).path != route: page_result["status"] = "warning" page_result["warnings"].append(f"重定向到 {final_url}(预期 {route})") elif len(body_text.strip()) < 50: page_result["status"] = "warning" page_result["warnings"].append(f"页面内容过少({len(body_text.strip())} 字符)") else: page_result["status"] = "passed" # 检查页面标题关键词 title_matched = any(key in body_text for key in expected_keys) page_result["checks"].append({ "name": f"标题关键词匹配 ({'/'.join(expected_keys)})", "passed": title_matched, "detail": f"实际匹配: {title_matched}" }) if not title_matched: page_result["warnings"].append(f"未找到标题关键词: {expected_keys}") # 检查页面错误提示 error_alerts = page.locator('[role="alert"], .text-destructive, .text-red-500, .text-red-600').all() for alert in error_alerts[:3]: try: text = (alert.text_content() or "").strip()[:150] if text: page_result["warnings"].append(f"页面告警文本: {text}") except Exception: pass # 收集控制台错误 if console_errors: page_result["console_errors"] = console_errors[:5] if page_result["status"] == "passed": page_result["status"] = "warning" page_result["warnings"].append(f"控制台错误 {len(console_errors)} 条") status_icon = {"passed": "✅", "warning": "⚠️", "failed": "❌"}.get(page_result["status"], "❓") print(f" {status_icon} {page_result['status']} (HTTP {http_status}) -> {final_url}") except PlaywrightTimeout: page_result["status"] = "failed" page_result["errors"].append("页面加载超时 (30s)") print(f" ❌ TIMEOUT") except Exception as e: page_result["status"] = "failed" page_result["errors"].append(str(e)[:200]) print(f" ❌ ERROR: {str(e)[:100]}") role_result["pages"].append(page_result) # 测试角色特定交互 role_result["interactions"] = test_role_interactions(page, role) # 收集全局控制台错误 if console_errors: for err in console_errors[:10]: results["console_errors_global"].append({"role": role, "error": err}) try: page.remove_listener("console", on_console) except Exception: pass return role_result def test_role_interactions(page, role: str) -> list: """测试角色特定的交互功能""" interactions = [] if role == "teacher": # 测试录入考勤页面交互 print("\n >>> 测试交互: 录入考勤页面") try: page.goto(f"{BASE_URL}/teacher/attendance/sheet", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) # 检查班级选择器 class_select = page.locator('select, button[role="combobox"]').first class_select_exists = class_select.count() > 0 interactions.append({ "name": "录入考勤 - 班级选择器存在", "passed": class_select_exists, "detail": "" }) # 检查日期选择器 date_input = page.locator('input[type="date"], input[name="date"]').first date_input_exists = date_input.count() > 0 interactions.append({ "name": "录入考勤 - 日期选择器存在", "passed": date_input_exists, "detail": "" }) # 检查"全部标记到场"按钮或类似快捷操作 body_text = page.locator("body").text_content() or "" has_mark_all = "全部标记到场" in body_text or "Mark All" in body_text interactions.append({ "name": "录入考勤 - 全部标记到场按钮", "passed": has_mark_all, "detail": "" }) except Exception as e: interactions.append({ "name": "录入考勤交互测试", "passed": False, "detail": f"异常: {str(e)[:200]}" }) # 测试考勤统计页面 print("\n >>> 测试交互: 考勤统计页面") try: page.goto(f"{BASE_URL}/teacher/attendance/stats", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) body_text = page.locator("body").text_content() or "" # 检查统计卡片 has_stats = "出勤率" in body_text or "总记录数" in body_text or "Attendance" in body_text interactions.append({ "name": "考勤统计 - 统计卡片显示", "passed": has_stats, "detail": "" }) # 检查班级切换器(ChipNav 渲染为 Link,不是 button/tab) # 班级切换器链接指向 /teacher/attendance/stats?classId= class_selector = page.locator('a[href*="/teacher/attendance/stats?classId="]') class_selector_exists = class_selector.count() > 0 interactions.append({ "name": "考勤统计 - 班级切换器存在", "passed": class_selector_exists, "detail": f"找到 {class_selector.count()} 个班级切换链接" }) except Exception as e: interactions.append({ "name": "考勤统计交互测试", "passed": False, "detail": f"异常: {str(e)[:200]}" }) elif role == "admin": # 测试管理员考勤总览 print("\n >>> 测试交互: 管理员考勤总览") try: page.goto(f"{BASE_URL}/admin/attendance", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) body_text = page.locator("body").text_content() or "" # 检查统计卡片 has_stats = "出勤" in body_text or "缺勤" in body_text or "Attendance" in body_text interactions.append({ "name": "管理员考勤总览 - 统计卡片显示", "passed": has_stats, "detail": "" }) # 检查筛选器 filter_exists = page.locator('select, [role="combobox"]').count() > 0 interactions.append({ "name": "管理员考勤总览 - 筛选器存在", "passed": filter_exists, "detail": "" }) # 检查"统计分析"快捷链接 stats_link = page.locator('a[href="/teacher/attendance/stats"]').count() > 0 interactions.append({ "name": "管理员考勤总览 - 统计分析快捷链接", "passed": stats_link, "detail": "" }) except Exception as e: interactions.append({ "name": "管理员考勤总览交互测试", "passed": False, "detail": f"异常: {str(e)[:200]}" }) elif role == "student": # 测试学生考勤视图 print("\n >>> 测试交互: 学生考勤视图") try: page.goto(f"{BASE_URL}/student/attendance", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) body_text = page.locator("body").text_content() or "" # 检查统计信息 has_stats = "出勤" in body_text or "缺勤" in body_text or "迟到" in body_text interactions.append({ "name": "学生考勤 - 统计信息显示", "passed": has_stats, "detail": "" }) # 检查最近记录 has_records = "最近记录" in body_text or "Recent" in body_text or "记录" in body_text interactions.append({ "name": "学生考勤 - 最近记录显示", "passed": has_records, "detail": "" }) except Exception as e: interactions.append({ "name": "学生考勤交互测试", "passed": False, "detail": f"异常: {str(e)[:200]}" }) elif role == "parent": # 测试家长考勤视图 print("\n >>> 测试交互: 家长考勤视图") try: page.goto(f"{BASE_URL}/parent/attendance", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(1500) body_text = page.locator("body").text_content() or "" # 检查子女考勤信息 has_child_info = "子女" in body_text or "Child" in body_text or "考勤" in body_text interactions.append({ "name": "家长考勤 - 子女信息显示", "passed": has_child_info, "detail": "" }) # 检查出勤率卡片 has_rate_card = "出勤率" in body_text or "Attendance Rate" in body_text interactions.append({ "name": "家长考勤 - 出勤率卡片", "passed": has_rate_card, "detail": "" }) # 检查月历视图 has_calendar = "月历" in body_text or "Calendar" in body_text interactions.append({ "name": "家长考勤 - 月历视图", "passed": has_calendar, "detail": "" }) except Exception as e: interactions.append({ "name": "家长考勤交互测试", "passed": False, "detail": f"异常: {str(e)[:200]}" }) return interactions def test_cross_role_access(page, role: str) -> list: """测试跨角色访问保护""" forbidden_routes = CROSS_ROLE_FORBIDDEN[role] test_results = [] print(f"\n=== 测试 {role} 跨角色访问保护 ===") for route in forbidden_routes: result = { "role": role, "forbidden_route": route, "final_url": None, "passed": False, "error": None, } try: page.goto(f"{BASE_URL}{route}", timeout=30000) page.wait_for_load_state("networkidle", timeout=15000) page.wait_for_timeout(800) final_url = page.url final_path = urlparse(final_url).path result["final_url"] = final_url if _is_login_redirect(final_url): result["passed"] = True result["error"] = "重定向到登录页(拒绝访问)" print(f" ✅ {route} -> 登录页(拒绝)") elif final_path.startswith(TEST_ACCOUNTS[role]["expected_path"]): result["passed"] = True result["error"] = f"重定向回 {final_path}(拒绝访问)" print(f" ✅ {route} -> {final_path}(拒绝)") elif final_path == route: result["passed"] = False result["error"] = "成功访问禁止路由(权限漏洞)" print(f" ❌ {route} -> 直接访问(权限漏洞)") elif "/403" in final_url or "/401" in final_url or "/unauthorized" in final_url: result["passed"] = True result["error"] = "重定向到未授权页" print(f" ✅ {route} -> 未授权页(拒绝)") else: result["passed"] = True result["error"] = f"重定向到 {final_path}(拒绝访问)" print(f" ✅ {route} -> {final_path}(拒绝)") except PlaywrightTimeout: result["error"] = "页面加载超时" result["passed"] = True print(f" ⚠️ {route} -> 超时") except Exception as e: result["error"] = f"异常: {str(e)[:200]}" result["passed"] = True print(f" ⚠️ {route} -> 异常") test_results.append(result) return test_results def run_all_tests(): """运行所有测试""" global results with sync_playwright() as p: browser = p.chromium.launch(headless=True) context = browser.new_context( viewport={"width": 1440, "height": 900}, locale="zh-CN", ignore_https_errors=True, ) page = context.new_page() for role in ["admin", "teacher", "student", "parent"]: logout(page) role_result = test_role_attendance(page, role) results["roles"][role] = role_result # admin 拥有所有权限,可以访问所有路由,跳过跨角色测试 if role in CROSS_ROLE_FORBIDDEN: cross_role_results = test_cross_role_access(page, role) results["cross_role_tests"].extend(cross_role_results) # 汇总 total = 0 passed = 0 failed = 0 warnings = 0 for role, r in results["roles"].items(): for page_result in r.get("pages", []): total += 1 if page_result["status"] == "passed": passed += 1 elif page_result["status"] == "warning": warnings += 1 else: failed += 1 for interaction in r.get("interactions", []): total += 1 if interaction["passed"]: passed += 1 else: failed += 1 for r in results["cross_role_tests"]: total += 1 if r["passed"]: passed += 1 else: failed += 1 results["summary"]["total"] = total results["summary"]["passed"] = passed results["summary"]["failed"] = failed results["summary"]["warnings"] = warnings print(f"\n{'='*60}") print(f"测试完成: 总计 {total}, 通过 {passed}, 失败 {failed}, 警告 {warnings}") print(f"{'='*60}") browser.close() def generate_report() -> str: """生成 Markdown 测试报告""" lines = [] lines.append("# 考勤模块 Web 功能测试报告") lines.append("") lines.append(f"> 模块:考勤 (Attendance)") lines.append(f"> 版本:{results['version']}") lines.append(f"> 测试日期:{results['test_date']}") lines.append(f"> 测试工具:Playwright + Chromium (headless)") lines.append(f"> Base URL:{results['base_url']}") lines.append(f"> 测试范围:admin / teacher / student / parent 四角色考勤页面 + 跨角色访问保护 + 关键交互") lines.append("") lines.append("---") lines.append("") # 测试概览 lines.append("## 一、测试概览") lines.append("") s = results["summary"] pass_rate = (s["passed"] / s["total"] * 100) if s["total"] > 0 else 0 lines.append("| 指标 | 数值 |") lines.append("|------|------|") lines.append(f"| 总测试项 | {s['total']} |") lines.append(f"| 通过 | {s['passed']} |") lines.append(f"| 失败 | {s['failed']} |") lines.append(f"| 警告 | {s['warnings']} |") lines.append(f"| 通过率 | {pass_rate:.1f}% |") lines.append("") lines.append("---") lines.append("") # 测试账号 lines.append("## 二、测试账号") lines.append("") lines.append("| 角色 | 邮箱 | 预期仪表盘路径 |") lines.append("|------|------|----------------|") for role, acc in TEST_ACCOUNTS.items(): lines.append(f"| {role} | `{acc['email']}` | `{acc['expected_path']}` |") lines.append("") lines.append("---") lines.append("") # 各角色测试详情 lines.append("## 三、各角色测试详情") lines.append("") for role in ["admin", "teacher", "student", "parent"]: r = results["roles"].get(role, {}) if not r: continue lines.append(f"### {role.upper()} 考勤模块") lines.append("") lines.append(f"- **登录**: {'✅ 成功' if r.get('login_success') else '❌ 失败'}") if r.get("errors"): lines.append(f"- **错误**:") for err in r["errors"]: lines.append(f" - {err}") if r.get("warnings"): lines.append(f"- **警告**:") for w in r["warnings"]: lines.append(f" - {w}") lines.append("") # 页面测试详情 if r.get("pages"): lines.append("#### 页面测试") lines.append("") lines.append("| 状态 | 类别 | 路由 | HTTP | 最终 URL | 错误 | 警告 |") lines.append("|------|------|------|------|----------|------|------|") for p in r["pages"]: icon = {"passed": "✅", "warning": "⚠️", "failed": "❌"}.get(p["status"], "❓") errs = "; ".join(p.get("errors", []))[:80] or "-" warns = "; ".join(p.get("warnings", []))[:80] or "-" lines.append(f"| {icon} | {p['category']} | `{p['route']}` | {p.get('http_status', '-')} | `{p.get('final_url', '-')}` | {errs} | {warns} |") lines.append("") # 检查项明细 for p in r["pages"]: if p.get("checks"): lines.append(f"##### 检查项明细 - {p['category']}") lines.append("") lines.append("| 状态 | 检查项 | 详情 |") lines.append("|------|--------|------|") for c in p["checks"]: icon = "✅" if c["passed"] else "❌" lines.append(f"| {icon} | {c['name']} | {c.get('detail', '')} |") lines.append("") # 交互测试 if r.get("interactions"): lines.append("#### 交互测试") lines.append("") lines.append("| 状态 | 交互项 | 详情 |") lines.append("|------|--------|------|") for it in r["interactions"]: icon = "✅" if it["passed"] else "❌" lines.append(f"| {icon} | {it['name']} | {it.get('detail', '')} |") lines.append("") lines.append("---") lines.append("") # 跨角色访问保护测试 lines.append("## 四、跨角色访问保护测试") lines.append("") lines.append("| 角色 | 禁止路由 | 实际 URL | 结果 | 说明 |") lines.append("|------|----------|----------|------|------|") for r in results["cross_role_tests"]: icon = "✅" if r["passed"] else "❌" actual_path = urlparse(r.get("final_url", "")).path or "-" lines.append(f"| {r['role']} | `{r['forbidden_route']}` | `{actual_path}` | {icon} {'拒绝' if r['passed'] else '通过'} | {r.get('error', '-') or '-'} |") lines.append("") lines.append("---") lines.append("") # 失败项汇总 failed_items = [] for role, r in results["roles"].items(): for p in r.get("pages", []): if p["status"] == "failed": failed_items.append(("页面测试", role, p.get("route", ""), p.get("errors", []))) for it in r.get("interactions", []): if not it["passed"]: failed_items.append(("交互测试", role, it["name"], [it.get("detail", "")])) for r in results["cross_role_tests"]: if not r["passed"]: failed_items.append(("跨角色保护", r["role"], r.get("forbidden_route", ""), [r.get("error", "")])) if failed_items: lines.append("## 五、失败项汇总") lines.append("") lines.append("| 类别 | 角色 | 路径/项 | 错误 |") lines.append("|------|------|---------|------|") for cat, role, item, errs in failed_items: err_str = "; ".join(errs[:2]) if errs else "-" lines.append(f"| {cat} | {role} | `{item}` | {err_str} |") lines.append("") lines.append("---") lines.append("") # 结论与建议 lines.append("## 六、测试结论与改进建议") lines.append("") if s["failed"] == 0: lines.append("✅ **考勤模块所有测试通过**,各角色考勤页面功能正常,权限保护有效。") else: lines.append(f"❌ **{s['failed']} 项测试失败**,需修复以下问题:") lines.append("") for cat, role, item, errs in failed_items: lines.append(f"- **{cat} - {role}** (`{item}`): {'; '.join(errs[:2]) if errs else '未知错误'}") lines.append("") lines.append("### 改进建议") lines.append("") lines.append("1. **认证与权限**:失败页面中若出现重定向至 /login,需检查会话过期策略与权限校验逻辑。") lines.append("2. **HTTP 5xx 错误**:服务端错误需检查 Server Action 数据访问层与数据库连接。") lines.append("3. **页面内容为空**:检查数据查询条件与渲染逻辑,确认数据源是否返回预期结果。") lines.append("4. **控制台错误**:浏览器控制台报错需检查前端组件渲染与 API 调用。") lines.append("5. **跨角色访问**:若出现权限漏洞,需检查 `requirePermission()` 调用与角色-权限映射。") lines.append("6. **i18n 缺失**:若页面出现英文硬编码,需补充对应 i18n key。") lines.append("") lines.append("---") lines.append("") lines.append(f"*报告自动生成于 {results['test_date']}*") return "\n".join(lines) def main(): run_all_tests() report = generate_report() output_path = WEBTEST_DIR / f"attendance_{VERSION}.md" with open(output_path, "w", encoding="utf-8") as f: f.write(report) print(f"\n📄 报告已写入: {output_path}") json_path = output_path.parent / f"attendance_{VERSION}.json" with open(json_path, "w", encoding="utf-8") as f: json.dump(results, f, ensure_ascii=False, indent=2, default=str) print(f"📄 JSON 数据已写入: {json_path}") if __name__ == "__main__": main()