fix(dashboard): 修复普通用户落地页与待办角色过滤

- 新增 router/access.ts 统一准入与落地页(工作台→业务数据→回款),
  守卫/登录回跳/菜单/面包屑复用;普通用户不再被无限重定向
- 待办按角色映射在途审批节点(财务/领导/admin),admin 不代办但可查全部
- 预警排除已终止业务;WarningBlock.total 为全量命中数;回款趋势改日期范围
- 工作台加载请求序号防竞态;KPI 口径说明补经理分成/调整单
- 新增 role_home_flow e2e,更新 system_management 普通用户断言

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-20 09:45:03 +08:00
1 parent ebb3a92f25
commit 39a3fa7796
16 files changed
+590 -59

No files matched your search

+6 -4
View File
@@ -112,10 +112,12 @@ def expect_toast(page: Page, expected: str, step: str, timeout_ms: int = 5000) -
fail(step, f"未出现提示「{expected}」,当前提示:{text or '(无)'}")
def login(page: Page, username: str, password: str) -> None:
"""完整登录:打开登录页 → 从 Redis 取验证码答案 → 提交 → 等待进入首页。
def login(page: Page, username: str, password: str, expected_path: str = "/dashboard") -> None:
"""完整登录:打开登录页 → 从 Redis 取验证码答案 → 提交 → 等待进入落地页。
验证码 id 经响应监听捕获(前端把它内联在响应体里,不是 URL 参数)。
``expected_path`` 按角色传入:管理员/财务/领导默认工作台,普通用户
落业务数据页(工作台权限收紧后守卫按角色解析首页)。
"""
captcha_ids: list[str] = []
@@ -148,8 +150,8 @@ def login(page: Page, username: str, password: str) -> None:
page.fill('input[autocomplete="off"]', answer)
page.click("button:has-text('登 录')")
page.wait_for_timeout(2000)
if current_path(page) != "/dashboard":
detail = f"未跳转到首页,当前 {page.url}"
if current_path(page) != expected_path:
detail = f"未跳转到 {expected_path},当前 {page.url}"
if message := toast_text(page):
detail += f",提示:{message}"
fail("登录", detail)
+141
View File
@@ -0,0 +1,141 @@
"""浏览器端到端验证:普通用户落地页与路由守卫回退。
工作台收紧为 admin/finance/leader 后,登录默认页不能硬编码 `/dashboard`
(普通用户会被无限重定向)。本脚本验证:
- 普通用户登录后落业务数据页,不出现「无权访问」提示
- 侧边菜单不出现首页/系统管理/报表统计
- 刷新后仍保持登录态与落地页
- 直连 `/dashboard` 或报表页时回退业务数据页并给出权限提示
运行前置:容器、后端 8000、前端 5173 均在运行;设置 ``E2E_ADMIN_PASSWORD``。
运行::
cd frontend && E2E_ADMIN_PASSWORD=<密码> python e2e/role_home_flow.py
"""
import json
import os
import time
import urllib.error
import urllib.request
import uuid
from playwright.sync_api import Page, sync_playwright
from common import BASE_URL, E2EFailure, current_path, fail, login, toast_text
ADMIN = "admin"
USERNAME = f"e2e_role_{int(time.time())}_{uuid.uuid4().hex[:4]}"
PASSWORD = "E2eRole@2026"
HOME_PATH = "/business/businesses"
def _api(method: str, path: str, token: str, body: dict | None = None) -> dict:
"""带令牌的轻量 API 调用(urllib,避免额外依赖)。"""
request = urllib.request.Request(
f"{BASE_URL}{path}",
method=method,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
data=json.dumps(body).encode() if body is not None else None,
)
try:
with urllib.request.urlopen(request, timeout=20) as response:
payload = json.loads(response.read().decode())
except urllib.error.HTTPError as exc:
detail = exc.read().decode()[:200]
raise E2EFailure(f"API {method} {path} HTTP {exc.code}:{detail}") from exc
if payload.get("code") != 200:
raise E2EFailure(f"API {method} {path} 业务失败:{payload.get('message')}")
return payload["data"]
def _token(page: Page) -> str:
token = page.evaluate("localStorage.getItem('ys_token')")
if not token:
fail("API", "未取到登录令牌")
return token
def _create_user(page: Page) -> dict:
return _api(
"POST",
"/api/v1/system/users",
_token(page),
{
"username": USERNAME,
"password": PASSWORD,
"real_name": "E2E普通用户",
"roles": ["user"],
},
)
def run(page: Page) -> None:
admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "")
if not admin_password:
fail("环境变量", "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)")
login(page, ADMIN, admin_password)
user = _create_user(page)
try:
page.evaluate("localStorage.clear()")
login(page, USERNAME, PASSWORD, expected_path=HOME_PATH)
page.wait_for_timeout(500)
print("✓ 1. 普通用户登录 落地业务数据页")
menu_text = page.locator(".app-menu").inner_text()
for forbidden in ("首页", "系统管理", "报表统计"):
if forbidden in menu_text:
fail("菜单权限", f"普通用户看到了「{forbidden}」菜单")
print("✓ 2. 菜单权限 首页/系统管理/报表统计均不可见")
page.reload(wait_until="networkidle")
page.wait_for_timeout(600)
if current_path(page) != HOME_PATH:
fail("刷新恢复", f"刷新后未保持落地页,当前 {page.url}")
print("✓ 3. 刷新恢复 登录态与落地页保持")
for path in ("/dashboard", "/reports/personal-detail"):
page.goto(f"{BASE_URL}{path}", wait_until="networkidle")
page.wait_for_timeout(800)
if current_path(page) != HOME_PATH:
fail("路由守卫", f"直连 {path} 未回退业务数据页,当前 {page.url}")
if "无权访问该页面" not in toast_text(page):
fail("路由守卫", f"直连 {path} 未给出权限提示")
print("✓ 4. 路由守卫 受限页均回退业务数据页并提示")
finally:
# 无论用例是否失败都清理测试账号(重新以 admin 登录取令牌)
page.evaluate("localStorage.clear()")
login(page, ADMIN, admin_password)
_api("DELETE", f"/api/v1/system/users/{user['id']}", _token(page))
def main() -> int:
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1440, "height": 900})
page.set_default_timeout(15000)
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}")
return 1
finally:
browser.close()
print("\n全部通过。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+3 -3
View File
@@ -249,18 +249,18 @@ def step_permission_check(page: Page, admin_password: str) -> None:
page.click("text=退出登录")
page.wait_for_url("**/login**", timeout=10_000)
login(page, VIEWER_USERNAME, E2E_PASSWORD)
login(page, VIEWER_USERNAME, E2E_PASSWORD, expected_path="/business/businesses")
if page.locator(".app-menu").get_by_text("系统管理", exact=True).count() != 0:
fail("菜单权限", "普通用户看到了「系统管理」菜单")
print("✓ 11. 菜单权限 普通用户看不到「系统管理」分组")
page.goto(f"{BASE_URL}/system/users", wait_until="networkidle")
page.wait_for_timeout(800)
if current_path(page) != "/dashboard":
if current_path(page) != "/business/businesses":
fail("路由守卫", f"普通用户直连管理页未被弹回,当前 {page.url}")
if "需要管理员权限" not in toast_text(page):
fail("路由守卫", "直连管理页未给出权限提示")
print("✓ 12. 路由守卫 直连 /system/users 被弹回首页")
print("✓ 12. 路由守卫 直连 /system/users 被弹回业务数据页")
page.click("text=退出登录")
page.wait_for_url("**/login**", timeout=10_000)