feat: 新增备课模块并修复全模块 P0/P1/P2 缺陷
Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled

主要变更:

- 新增 lesson-preparation 模块: 备课编辑器、节点编辑、AI 建议、知识点选择、版本历史、作业发布

- 新增 shared 通用组件: charts/question-bank-filters/schedule-list/ui (chip-nav/filter-bar/page-header/stat-card/stat-item)

- 新增 student/admin 端 loading.tsx 与 error.tsx, 优化加载与错误态体验

- 新增 teacher/lesson-plans 页面 (列表/新建/编辑)

- 新增 drizzle 迁移 0002_tiny_lionheart 及 snapshot

- 新增 textbooks/schema.ts 与 exams/utils/normalize-structure.ts

- 修复 Tiptap v3 SSR hydration 崩溃 (rich-text-block immediatelyRender: false)

- 重构多模块 data-access/actions/组件, 修复权限校验与类型规范

- 同步架构文档 004/005 反映新增模块、导出、依赖关系

- 归档 bugs/* 测试报告与 e2e 测试脚本 (admin/parent/student/teacher web_test)
This commit is contained in:
SpecialX
2026-06-22 01:06:16 +08:00
parent d8962aba96
commit 978d9a8309
327 changed files with 34070 additions and 5642 deletions

View File

@@ -0,0 +1,507 @@
"""
学生端全功能Web测试脚本
使用 Playwright 对学生端所有页面进行功能测试
"""
import json
import os
from datetime import datetime
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
BASE_URL = "http://localhost:3000"
STUDENT_EMAIL = "student_g1c1_1@xiaoxue.edu.cn"
STUDENT_PASSWORD = "123456"
# 学生端所有路由(来自 src/modules/layout/config/navigation.ts 及 src/app/(dashboard)/student/ 目录)
STUDENT_ROUTES = {
"Dashboard": ["/student/dashboard"],
"My Learning - Courses": ["/student/learning/courses"],
"My Learning - Assignments": ["/student/learning/assignments"],
"My Learning - Textbooks": ["/student/learning/textbooks"],
"Schedule": ["/student/schedule"],
"My Grades": ["/student/grades"],
"Attendance": ["/student/attendance"],
"Diagnostic": ["/student/diagnostic"],
"Electives": ["/student/elective"],
"Announcements": ["/announcements"],
"Messages": [
"/messages",
"/messages/compose",
],
"Profile": ["/profile"],
"Settings": [
"/settings",
"/settings/security",
],
"Common Dashboard": ["/dashboard"],
}
# 详情页发现规则:从列表页提取详情链接
DETAIL_DISCOVERIES = {
"Assignment Detail": ("/student/learning/assignments", "/student/learning/assignments/"),
"Textbook Detail": ("/student/learning/textbooks", "/student/learning/textbooks/"),
}
results = {
"test_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"test_target": "学生端 (Student)",
"base_url": BASE_URL,
"student_email": STUDENT_EMAIL,
"summary": {
"total": 0,
"passed": 0,
"failed": 0,
"warnings": 0,
},
"pages": {},
"console_errors": [],
"navigation_issues": [],
}
def login(page):
"""登录学生账号"""
print("\n>>> 登录学生账号...")
page.goto(f"{BASE_URL}/login", timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
# 检查是否已登录
if "/student" in page.url or page.url.rstrip("/").endswith("/dashboard"):
print(" 已登录,跳过登录步骤")
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(STUDENT_EMAIL)
password_input = page.locator('input[name="password"]')
if password_input.count() == 0:
password_input = page.locator('input[type="password"]')
password_input.fill(STUDENT_PASSWORD)
# 点击登录按钮
login_btn = page.locator('button[type="submit"]')
if login_btn.count() == 0:
login_btn = page.get_by_role("button", name="Sign In with Email")
if login_btn.count() == 0:
login_btn = page.locator("button").filter(has_text="Sign In")
if login_btn.count() == 0:
login_btn = page.locator("button").filter(has_text="登录")
login_btn.click()
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(2000)
print(f" 登录后 URL: {page.url}")
# 验证登录成功
if "/login" in page.url:
print(" ❌ 登录后仍在登录页")
return False
return True
except Exception as e:
print(f" ❌ 登录失败: {e}")
return False
def test_page(page, route, category):
"""测试单个页面"""
url = f"{BASE_URL}{route}"
page_result = {
"url": url,
"category": category,
"status": "unknown",
"http_status": None,
"redirect_url": None,
"final_url": None,
"errors": [],
"warnings": [],
"title": None,
"content_length": 0,
}
print(f"\n 测试: {category} - {route}")
# 收集控制台错误
console_errors = []
def on_console(msg):
if msg.type == "error":
console_errors.append(msg.text)
page.on("console", on_console)
# 收集页面错误
def on_page_error(err):
console_errors.append(f"[PAGE_ERROR] {err}")
page.on("pageerror", on_page_error)
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
# 获取页面标题
try:
page_result["title"] = page.title()
except Exception:
pass
# 检查是否重定向
if final_url.rstrip("/") != url.rstrip("/"):
page_result["redirect_url"] = final_url
# 检查 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"] = "warning"
page_result["warnings"].append(f"HTTP {http_status} 客户端错误")
elif final_url and "/login" in final_url:
page_result["status"] = "failed"
page_result["errors"].append("重定向到登录页 - 认证失败")
elif final_url and "/500" in final_url:
page_result["status"] = "failed"
page_result["errors"].append("重定向到 500 错误页")
elif final_url and "/404" in final_url:
page_result["status"] = "warning"
page_result["warnings"].append("重定向到 404 页面")
else:
page_result["status"] = "passed"
# 检查页面是否有错误提示
try:
error_elements = page.locator('[role="alert"], .error, .text-destructive, .text-red-500').all()
for et in error_elements[:3]:
try:
text = et.text_content()
if text and text.strip():
page_result["warnings"].append(f"页面错误提示: {text.strip()[:150]}")
except Exception:
pass
except Exception:
pass
# 检查页面是否为空(无主要内容)
body_text = page.locator("body").text_content() or ""
page_result["content_length"] = len(body_text.strip())
if len(body_text.strip()) < 50:
page_result["warnings"].append("页面内容过少,可能为空")
# 收集控制台错误
if console_errors:
page_result["errors"].extend(console_errors[:5])
# 打印状态
status_icon = "" if page_result["status"] == "passed" else "⚠️" if page_result["status"] == "warning" else ""
print(f" {status_icon} {page_result['status']} (HTTP {http_status})")
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]}")
finally:
try:
page.remove_listener("console", on_console)
page.remove_listener("pageerror", on_page_error)
except Exception:
pass
return page_result
def discover_detail_links(page, list_route, link_pattern):
"""从列表页发现详情页链接"""
try:
url = f"{BASE_URL}{list_route}"
page.goto(url, timeout=30000)
page.wait_for_load_state("networkidle", timeout=15000)
page.wait_for_timeout(1000)
# 查找匹配的链接
links = page.locator(f'a[href*="{link_pattern}"]').all()
detail_urls = []
seen = set()
for link in links[:5]: # 最多取5个
href = link.get_attribute("href")
if href and link_pattern in href and href != list_route:
# 标准化URL
if not href.startswith("/"):
href = "/" + href
# 排除列表页本身
if href.rstrip("/") == list_route.rstrip("/"):
continue
if href not in seen:
seen.add(href)
detail_urls.append(href)
return detail_urls
except Exception as e:
print(f" 发现详情页链接失败: {e}")
return []
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()
# 登录
if not login(page):
print("❌ 登录失败,终止测试")
browser.close()
return
# 测试所有路由
total = 0
for category, routes in STUDENT_ROUTES.items():
for route in routes:
page_result = test_page(page, route, category)
key = route.replace("/", "_").strip("_")
results["pages"][key] = page_result
total += 1
# 发现并测试详情页
print("\n\n>>> 发现详情页链接...")
for name, (list_url, pattern) in DETAIL_DISCOVERIES.items():
detail_urls = discover_detail_links(page, list_url, pattern)
if detail_urls:
print(f"\n {name} 详情页: {len(detail_urls)}")
for detail_url in detail_urls[:2]:
route = detail_url
if not route.startswith("/"):
route = "/" + route
page_result = test_page(page, route, f"{name}")
key = route.replace("/", "_").strip("_")
results["pages"][key] = page_result
total += 1
else:
print(f"\n {name}: 未发现详情页链接")
# 汇总
passed = sum(1 for pg in results["pages"].values() if pg["status"] == "passed")
failed = sum(1 for pg in results["pages"].values() if pg["status"] == "failed")
warnings = sum(1 for pg in results["pages"].values() if pg["status"] == "warning")
results["summary"]["total"] = total
results["summary"]["passed"] = passed
results["summary"]["failed"] = failed
results["summary"]["warnings"] = warnings
print(f"\n\n{'='*60}")
print(f"测试完成: 总计 {total}, 通过 {passed}, 失败 {failed}, 警告 {warnings}")
print(f"{'='*60}")
browser.close()
def generate_report():
"""生成Markdown测试报告"""
report_lines = []
report_lines.append("# 学生端 Web 功能测试报告")
report_lines.append("")
report_lines.append(f"> 测试日期:{results['test_date']}")
report_lines.append(f"> 测试范围:所有学生端页面功能")
report_lines.append(f"> 测试工具Playwright + Chromium (headless)")
report_lines.append(f"> 测试账号:{results['student_email']}")
report_lines.append(f"> Base URL{results['base_url']}")
report_lines.append("")
report_lines.append("---")
report_lines.append("")
report_lines.append("## 一、测试概览")
report_lines.append("")
s = results["summary"]
report_lines.append("| 指标 | 数值 |")
report_lines.append("|------|------|")
report_lines.append(f"| 总测试页面数 | {s['total']} |")
report_lines.append(f"| 通过 | {s['passed']} |")
report_lines.append(f"| 失败 | {s['failed']} |")
report_lines.append(f"| 警告 | {s['warnings']} |")
report_lines.append(f"| 通过率 | {s['passed']/s['total']*100:.1f}% |" if s['total'] > 0 else "| 通过率 | N/A |")
report_lines.append("")
report_lines.append("---")
report_lines.append("")
report_lines.append("## 二、页面测试详情")
report_lines.append("")
# 按类别分组
by_category = {}
for key, page_result in results["pages"].items():
cat = page_result.get("category", "Other")
by_category.setdefault(cat, []).append(page_result)
for category in sorted(by_category.keys()):
pages = by_category[category]
report_lines.append(f"### {category}")
report_lines.append("")
report_lines.append("| 状态 | URL | HTTP状态 | 结果 | 备注 |")
report_lines.append("|------|-----|----------|------|------|")
for pg in pages:
status_icon = "" if pg["status"] == "passed" else "⚠️" if pg["status"] == "warning" else ""
notes = []
if pg.get("redirect_url"):
notes.append(f"重定向到: `{pg['redirect_url']}`")
if pg.get("errors"):
# 截断并替换换行符,避免破坏 markdown 表格
err_short = "; ".join(pg["errors"][:2]).replace("\n", " ").replace("\r", " ")
if len(err_short) > 200:
err_short = err_short[:200] + "..."
notes.append(f"错误: {err_short}")
if pg.get("warnings"):
warn_short = "; ".join(pg["warnings"][:2]).replace("\n", " ").replace("\r", " ")
if len(warn_short) > 200:
warn_short = warn_short[:200] + "..."
notes.append(f"警告: {warn_short}")
note_str = "<br>".join(notes) if notes else "-"
# 简化URL显示
short_url = pg["url"].replace(BASE_URL, "")
report_lines.append(
f"| {status_icon} | `{short_url}` | {pg['http_status'] or '-'} | {pg['status']} | {note_str} |"
)
report_lines.append("")
# 失败页面汇总
failed_pages = [(k, v) for k, v in results["pages"].items() if v["status"] == "failed"]
if failed_pages:
report_lines.append("---")
report_lines.append("")
report_lines.append("## 三、失败页面详情")
report_lines.append("")
for key, pg in failed_pages:
report_lines.append(f"### ❌ `{pg['url']}`")
report_lines.append("")
report_lines.append(f"- **分类**: {pg['category']}")
report_lines.append(f"- **HTTP状态**: {pg['http_status']}")
if pg.get("redirect_url"):
report_lines.append(f"- **重定向**: `{pg['redirect_url']}`")
if pg.get("errors"):
report_lines.append(f"- **错误信息**:")
for err in pg["errors"]:
report_lines.append(f" - {err}")
report_lines.append("")
# 警告页面
warning_pages = [(k, v) for k, v in results["pages"].items() if v["status"] == "warning"]
next_section_num = 4
if warning_pages:
report_lines.append("---")
report_lines.append("")
report_lines.append(f"## 四、警告页面")
report_lines.append("")
for key, pg in warning_pages:
report_lines.append(f"### ⚠️ `{pg['url']}`")
report_lines.append("")
report_lines.append(f"- **分类**: {pg['category']}")
if pg.get("warnings"):
for w in pg["warnings"]:
report_lines.append(f" - {w}")
report_lines.append("")
next_section_num = 5
report_lines.append("---")
report_lines.append("")
report_lines.append(f"## {'' if next_section_num == 4 else ''}、发现的问题分析")
report_lines.append("")
report_lines.append("根据测试结果,发现以下问题:")
report_lines.append("")
# 收集所有有错误的页面(包括 passed 但有错误的)
issue_num = 1
for key, pg in results["pages"].items():
if pg.get("errors"):
report_lines.append(f"### 问题 {issue_num}: {pg['category']} - `{pg['url'].replace(BASE_URL, '')}`")
report_lines.append("")
report_lines.append(f"- **状态**: {pg['status']} (HTTP {pg['http_status']})")
report_lines.append(f"- **错误信息**:")
for err in pg["errors"]:
# 截断过长的错误信息
err_clean = err.replace("\n", " ").replace("\r", " ")
if len(err_clean) > 300:
err_clean = err_clean[:300] + "..."
report_lines.append(f" - {err_clean}")
report_lines.append("")
issue_num += 1
report_lines.append("---")
report_lines.append("")
report_lines.append(f"## {'' if next_section_num == 4 else ''}、测试覆盖范围")
report_lines.append("")
report_lines.append("本次测试覆盖学生端以下功能模块:")
report_lines.append("")
report_lines.append("| 模块 | 路由 | 说明 |")
report_lines.append("|------|------|------|")
report_lines.append("| Dashboard | `/student/dashboard` | 学生仪表盘 |")
report_lines.append("| My Learning - Courses | `/student/learning/courses` | 我的课程 |")
report_lines.append("| My Learning - Assignments | `/student/learning/assignments` | 作业列表 |")
report_lines.append("| My Learning - Assignment Detail | `/student/learning/assignments/[id]` | 作业详情/作答 |")
report_lines.append("| My Learning - Textbooks | `/student/learning/textbooks` | 教材列表 |")
report_lines.append("| My Learning - Textbook Detail | `/student/learning/textbooks/[id]` | 教材阅读 |")
report_lines.append("| Schedule | `/student/schedule` | 课表 |")
report_lines.append("| My Grades | `/student/grades` | 我的成绩 |")
report_lines.append("| Attendance | `/student/attendance` | 考勤 |")
report_lines.append("| Diagnostic | `/student/diagnostic` | 学情诊断 |")
report_lines.append("| Electives | `/student/elective` | 选课中心 |")
report_lines.append("| Announcements | `/announcements` | 公告 |")
report_lines.append("| Messages | `/messages` | 消息列表 |")
report_lines.append("| Messages - Compose | `/messages/compose` | 写消息 |")
report_lines.append("| Profile | `/profile` | 个人资料 |")
report_lines.append("| Settings | `/settings` | 设置 |")
report_lines.append("| Settings - Security | `/settings/security` | 安全设置 |")
report_lines.append("| Common Dashboard | `/dashboard` | 通用仪表盘(角色跳转) |")
report_lines.append("")
report_lines.append("---")
report_lines.append("")
report_lines.append(f"*报告自动生成于 {results['test_date']}*")
return "\n".join(report_lines)
def main():
# 运行测试
run_all_tests()
# 生成报告
report = generate_report()
# 写入文件
output_dir = os.path.join(os.path.dirname(__file__), "..", "..", "bugs")
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "student_web_test.md")
with open(output_path, "w", encoding="utf-8") as f:
f.write(report)
print(f"\n📄 报告已写入: {output_path}")
# 同时输出JSON便于调试
json_path = output_path.replace(".md", ".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()