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:
1 parent
ebb3a92f25
commit
39a3fa7796
16 files changed
+590
-59
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user