feat(system): 完成前端系统管理(布局框架、三个管理页面、e2e)
- BasicLayout:侧边栏/顶栏/面包屑/移动端抽屉;菜单从路由表派生; 路由守卫新增 requiresAdmin 层(菜单隐藏 + 直连 URL 弹回) - 用户/角色/日志三个页面:分页请求序号防竞态、确认动作流抽 useConfirmAction、 时间范围配 default-time、日志详情按需懒加载并缓存 - 空 catch 统一改 handledError 显式声明;词表/选项常量集中(与后端对齐), 「停用」文案全站统一为「禁用」 - 品牌色提升为全局 token,Element Plus 主色指向赭金 - e2e:公共工具抽 common.py;系统管理 13 项(含详情懒加载脱敏断言), 管理员密码改 E2E_ADMIN_PASSWORD 环境变量;login_flow 8 项回归通过 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
1 parent
c83287819a
commit
8355e0cc7d
26 files changed
+2785
-180
No files matched your search
+33
-69
@@ -1,4 +1,4 @@
|
||||
"""浏览器端端到端验证:登录 + 图形验证码。
|
||||
"""浏览器端到端验证:登录 + 图形验证码。
|
||||
|
||||
验证的是**只有真实浏览器才能暴露**的问题:
|
||||
|
||||
@@ -20,53 +20,28 @@
|
||||
cd frontend && python e2e/login_flow.py
|
||||
|
||||
依赖:Python `playwright` 包与 chromium(`python -m playwright install chromium`)。
|
||||
公共工具见 `e2e/common.py`。
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from playwright.sync_api import Page, sync_playwright
|
||||
|
||||
BASE_URL = "http://127.0.0.1:5173"
|
||||
from common import (
|
||||
BASE_URL,
|
||||
E2EFailure,
|
||||
collect_console_errors,
|
||||
current_path,
|
||||
fail,
|
||||
redis_get,
|
||||
toast_text,
|
||||
)
|
||||
|
||||
USERNAME = "admin"
|
||||
PASSWORD = "Admin@2026"
|
||||
REDIS_CONTAINER = "ys-redis-dev"
|
||||
|
||||
|
||||
def _redis_password() -> str:
|
||||
"""从 docker/.env 读取 Redis 密码。
|
||||
|
||||
测试脚本无法用应用配置(那需要后端 venv),故直接读环境文件。
|
||||
"""
|
||||
if value := os.environ.get("REDIS_PASSWORD"):
|
||||
return value
|
||||
env_file = os.path.join(os.path.dirname(__file__), "..", "..", "docker", ".env")
|
||||
with open(env_file, encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
if line.startswith("REDIS_PASSWORD="):
|
||||
return line.split("=", 1)[1].strip()
|
||||
raise RuntimeError("未能从 docker/.env 读取 REDIS_PASSWORD")
|
||||
|
||||
|
||||
def redis_get(key: str) -> str | None:
|
||||
"""读取 Redis 键值。仅用于自动化测试 —— 生产当然不能这样取验证码答案。"""
|
||||
result = subprocess.run(
|
||||
["docker", "exec", REDIS_CONTAINER, "redis-cli", "-a", _redis_password(),
|
||||
"--no-auth-warning", "get", key],
|
||||
capture_output=True, text=True, encoding="utf-8", check=False,
|
||||
)
|
||||
return result.stdout.strip() or None
|
||||
|
||||
|
||||
def _fail(step: str, detail: str) -> None:
|
||||
print(f" ✗ {step}:{detail}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run(page: Page) -> None:
|
||||
captured_captcha_ids: list[str] = []
|
||||
console_errors: list[str] = []
|
||||
console_errors = collect_console_errors(page)
|
||||
|
||||
def on_response(response) -> None:
|
||||
if "/api/v1/auth/captcha" not in response.url or response.status != 200:
|
||||
@@ -79,27 +54,11 @@ def run(page: Page) -> None:
|
||||
pass
|
||||
|
||||
page.on("response", on_response)
|
||||
page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None)
|
||||
page.on("pageerror", lambda exc: console_errors.append(str(exc)))
|
||||
|
||||
def at_dashboard() -> bool:
|
||||
"""判断是否真的在首页。
|
||||
|
||||
不能用 `**/dashboard` 这类 glob —— 它会把
|
||||
`/login?redirect=/dashboard` 也匹配上,导致"登录失败"被误判为成功。
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
return urlparse(page.url).path == "/dashboard"
|
||||
|
||||
def toast_text() -> str:
|
||||
locator = page.locator(".el-message")
|
||||
return locator.first.text_content().strip() if locator.count() else ""
|
||||
|
||||
# ---- 1. 路由守卫:未登录访问首页应被拦回登录页 ----
|
||||
page.goto(BASE_URL, wait_until="networkidle")
|
||||
if "/login" not in page.url:
|
||||
_fail("路由守卫", f"未登录访问首页未被拦截,当前 URL={page.url}")
|
||||
fail("路由守卫", f"未登录访问首页未被拦截,当前 URL={page.url}")
|
||||
print(f"✓ 1. 路由守卫 未登录访问 / → 重定向到 {page.url}")
|
||||
|
||||
# ---- 2. 验证码渲染(证明 浏览器→Vite代理→uvicorn→Redis 全链路通)----
|
||||
@@ -107,9 +66,9 @@ def run(page: Page) -> None:
|
||||
image.wait_for(state="visible", timeout=10_000)
|
||||
src = image.get_attribute("src") or ""
|
||||
if not src.startswith("data:image/png;base64,"):
|
||||
_fail("验证码渲染", f"图片 src 前缀异常:{src[:40]}")
|
||||
fail("验证码渲染", f"图片 src 前缀异常:{src[:40]}")
|
||||
if len(src) < 1000:
|
||||
_fail("验证码渲染", f"图片数据过小({len(src)}),可能未真正渲染")
|
||||
fail("验证码渲染", f"图片数据过小({len(src)}),可能未真正渲染")
|
||||
print(f"✓ 2. 验证码渲染 PNG base64 长度 {len(src)}(代理链路已通)")
|
||||
|
||||
# ---- 3. 输错验证码 → 应提示且验证码自动刷新 ----
|
||||
@@ -121,38 +80,38 @@ def run(page: Page) -> None:
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
if "/login" not in page.url:
|
||||
_fail("失败处理", f"登录失败后不应离开登录页,当前 URL={page.url}")
|
||||
fail("失败处理", f"登录失败后不应离开登录页,当前 URL={page.url}")
|
||||
second_id = captured_captcha_ids[-1]
|
||||
if second_id == first_id:
|
||||
_fail("验证码刷新", "登录失败后验证码未刷新 —— 第二次提交必然报「验证码错误」")
|
||||
fail("验证码刷新", "登录失败后验证码未刷新 —— 第二次提交必然报「验证码错误」")
|
||||
print("✓ 3. 失败自动刷新 验证码已换新(GETDEL 消费后的必要处理)")
|
||||
|
||||
# ---- 4. 正确登录 ----
|
||||
answer = redis_get(f"ys:captcha:{second_id}")
|
||||
if not answer:
|
||||
_fail("取验证码", f"Redis 中未找到 ys:captcha:{second_id}")
|
||||
fail("取验证码", f"Redis 中未找到 ys:captcha:{second_id}")
|
||||
page.fill('input[autocomplete="off"]', answer)
|
||||
page.click("button:has-text('登 录')")
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
if not at_dashboard():
|
||||
if current_path(page) != "/dashboard":
|
||||
detail = f"仍停留在 {page.url}"
|
||||
if message := toast_text():
|
||||
if message := toast_text(page):
|
||||
detail += f",提示:{message}"
|
||||
if console_errors:
|
||||
detail += f",控制台错误:{console_errors[:3]}"
|
||||
_fail("登录", detail)
|
||||
fail("登录", detail)
|
||||
page.wait_for_load_state("networkidle")
|
||||
print(f"✓ 4. 登录成功 跳转到 {page.url}")
|
||||
|
||||
if USERNAME not in page.content():
|
||||
_fail("用户信息", "首页未显示当前用户名")
|
||||
fail("用户信息", "首页未显示当前用户名")
|
||||
print("✓ 5. 用户信息 首页已展示当前账号")
|
||||
|
||||
# ---- 6. 刷新页面 → 守卫应调用 /me 恢复会话 ----
|
||||
page.reload(wait_until="networkidle")
|
||||
if "/dashboard" not in page.url:
|
||||
_fail("会话恢复", f"刷新后被踢回登录页,当前 URL={page.url}")
|
||||
if current_path(page) != "/dashboard":
|
||||
fail("会话恢复", f"刷新后被踢回登录页,当前 URL={page.url}")
|
||||
print("✓ 6. 会话恢复 刷新页面后仍保持登录(/me 恢复成功)")
|
||||
|
||||
# ---- 7. 登出 → 令牌立即失效 ----
|
||||
@@ -162,8 +121,8 @@ def run(page: Page) -> None:
|
||||
|
||||
page.goto(f"{BASE_URL}/dashboard", wait_until="networkidle")
|
||||
page.wait_for_timeout(1000)
|
||||
if at_dashboard():
|
||||
_fail("登出失效", "登出后仍能访问受保护页面")
|
||||
if current_path(page) == "/dashboard":
|
||||
fail("登出失效", "登出后仍能访问受保护页面")
|
||||
print("✓ 8. 令牌失效 登出后直接访问 /dashboard 被拦回登录页")
|
||||
|
||||
|
||||
@@ -174,9 +133,14 @@ def main() -> int:
|
||||
page.set_default_timeout(10_000)
|
||||
try:
|
||||
run(page)
|
||||
except E2EFailure as exc:
|
||||
page.screenshot(path="e2e/failure.png", full_page=True)
|
||||
print(f"\n ✗ {exc}")
|
||||
print(" 已保存截图 e2e/failure.png")
|
||||
return 1
|
||||
except Exception as exc: # noqa: BLE001 — 测试脚本需要打印完整失败原因
|
||||
page.screenshot(path="e2e/failure.png", full_page=True)
|
||||
print(f"\n✗ 执行中断:{type(exc).__name__}: {exc}")
|
||||
print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}")
|
||||
print(" 已保存截图 e2e/failure.png")
|
||||
return 1
|
||||
finally:
|
||||
|
||||
Reference in new issue
Block a user