PayrollManagementSystem/frontend/e2e/system_management.py
liuwenxin 8355e0cc7d 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>
2026-10-10 16:28:53 +08:00

402 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""系统管理端到端验证:用户 / 角色 / 日志 / 权限。
覆盖的链路(只有真实浏览器才能验证的组合):
- 侧边菜单导航与「系统管理」分组
- 用户增删改查:新建(含重名负例)、编辑、重置密码、启用/禁用、删除
- 角色保护:内置 admin 行的禁用/删除按钮禁用;角色 CRUD 全流程
- 操作日志:写操作后可按条件查到记录,展开详情懒加载出脱敏后的请求参数
- 登录日志:能查到当前会话的成功登录
- 权限:普通用户看不到「系统管理」菜单,直连管理页面被守卫弹回首页
**运行前置**(缺一不可)::
docker compose -f docker/docker-compose.dev.yml up -d # MySQL + Redis
cd backend && uv run uvicorn app.main:app --port 8000 # 后端
cd frontend && pnpm dev # 前端
管理员密码经环境变量传入(不写死在源码里)::
cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
脚本自建的测试数据(`e2e_` 前缀)在流程内清理;中途失败会残留在开发库,
可用后端集成测试的前缀清理或手工删除。公共工具见 `e2e/common.py`。
"""
import os
import time
from playwright.sync_api import Page, sync_playwright
from common import (
BASE_URL,
E2EFailure,
collect_console_errors,
current_path,
expect_toast,
fail,
login,
toast_text,
wait_toast,
)
ADMIN = "admin"
E2E_PASSWORD = "E2ePwd@2026"
RESET_PASSWORD = "E2eReset@2026"
SUFFIX = str(int(time.time()))
NEW_USERNAME = f"e2e_user_{SUFFIX}"
VIEWER_USERNAME = f"e2e_viewer_{SUFFIX}"
NEW_ROLE_CODE = f"e2e_role_{SUFFIX}"
# 控制台错误收集(失败诊断):模块级列表,main() 的失败路径也要读取
console_errors: list[str] = []
# ==================== 页面操作辅助 ====================
def open_menu(page: Page, title: str) -> None:
"""点击侧边菜单项,必要时先展开「系统管理」分组。
分组已展开时再次点击会收拢,因此先判断菜单项是否已经可见。
"""
menu = page.locator(".app-menu")
item = menu.get_by_text(title, exact=True)
if not item.is_visible():
menu.get_by_text("系统管理", exact=True).click()
page.wait_for_timeout(400)
item.click()
page.wait_for_timeout(800)
def fill_filter(page: Page, label: str, value: str) -> None:
"""在**当前可见**的筛选表单中按标签填写输入框。
选择器一律带 `:visible` —— 日志页两个 tab 各有一个表单(非激活 tab
用 v-show 隐藏),不带可见性过滤会命中隐藏表单的元素。
"""
form_item = page.locator(".filter-form:visible .el-form-item", has_text=label).first
form_item.locator("input").first.fill(value)
def select_filter_option(page: Page, label: str, option: str) -> None:
"""展开可见筛选表单中指定标签的下拉并选择选项。
标签用 `:text-is` 精确匹配 —— 子串匹配会让「操作」命中「操作人」。
"""
item = page.locator(
f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
)
item.locator(".el-select").click()
page.wait_for_timeout(300)
page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text=option).first.click()
page.wait_for_timeout(200)
def click_query(page: Page) -> None:
"""点击可见筛选表单中的查询按钮。"""
page.locator(".filter-form:visible button:has-text('查询')").first.click()
page.wait_for_timeout(800)
def table_rows(page: Page):
"""当前可见的表格行(日志页非激活 tab 的表格隐藏,需过滤)。"""
return page.locator(".el-table__body .el-table__row:visible")
def confirm_message_box(page: Page, button_text: str) -> None:
"""点击确认框(ElMessageBox)中的指定按钮。"""
page.locator(".el-message-box").locator(f"button:has-text('{button_text}')").click()
page.wait_for_timeout(800)
def visible_dialog(page: Page):
"""当前可见的对话框(同页存在多个 dialog 实例,按可见性取)。"""
return page.locator(".el-dialog:visible").last
def create_user_via_ui(
page: Page, username: str, real_name: str, expect_success: bool = True
) -> None:
"""经界面新增用户(角色固定选择「普通用户」)。"""
page.click("button:has-text('新增用户')")
page.wait_for_timeout(500)
dialog = visible_dialog(page)
dialog.locator('input[placeholder="登录用户名,创建后不可修改"]').fill(username)
dialog.locator('input[placeholder="8–72 字节(中文一个字符约 3 字节)"]').fill(E2E_PASSWORD)
dialog.locator('input[placeholder="真实姓名"]').fill(real_name)
# 角色多选:展开下拉后选择「普通用户」
dialog.locator(".el-select").click()
page.wait_for_timeout(300)
page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text="普通用户").first.click()
page.wait_for_timeout(200)
dialog.locator(".el-dialog__header").click() # 收起下拉
page.wait_for_timeout(200)
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
if expect_success:
expect_toast(page, "用户已创建", "新增用户")
page.wait_for_timeout(600)
def find_user_row(page: Page, username: str):
"""在用户列表中按用户名筛选并返回目标行。"""
fill_filter(page, "用户名", username)
click_query(page)
return table_rows(page).filter(has_text=username).first
# ==================== 流程步骤 ====================
def step_user_create(page: Page, admin_password: str) -> None:
"""登录、菜单导航、新增用户与重名负例。"""
login(page, ADMIN, admin_password)
open_menu(page, "用户管理")
if current_path(page) != "/system/users":
fail("菜单导航", f"未进入用户管理页,当前 {page.url}")
print("✓ 1. 菜单导航 经侧边菜单进入用户管理")
create_user_via_ui(page, NEW_USERNAME, "E2E测试")
row = find_user_row(page, NEW_USERNAME)
if row.count() == 0:
fail("新增用户", f"列表中未找到新建的 {NEW_USERNAME}")
print(f"✓ 2. 新增用户 创建 {NEW_USERNAME} 并可在列表查到")
create_user_via_ui(page, NEW_USERNAME, "E2E重复", expect_success=False)
# 用 wait_toast 而非直读:上一条「用户已创建」提示尚在停留窗口内时,
# 直读会命中它造成偶发误判
if "已存在" not in wait_toast(page, "已存在"):
fail("重名负例", f"重复用户名未提示已存在,当前提示:{toast_text(page) or '(无)'}")
visible_dialog(page).locator(".el-dialog__footer button:has-text('取消')").click()
page.wait_for_timeout(500)
print("✓ 3. 重名负例 重复用户名被拒并给出中文提示")
def step_user_edit_and_status(page: Page) -> None:
"""编辑用户、重置密码、启用/禁用。"""
row = find_user_row(page, NEW_USERNAME)
row.locator("button:has-text('编辑')").click()
page.wait_for_timeout(500)
dialog = visible_dialog(page)
dialog.locator('input[placeholder="真实姓名"]').fill("E2E已改名")
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
expect_toast(page, "已保存", "编辑用户")
row = find_user_row(page, NEW_USERNAME)
if "E2E已改名" not in row.inner_text():
fail("编辑用户", "列表未显示修改后的姓名")
print("✓ 4. 编辑用户 姓名修改生效")
row = find_user_row(page, NEW_USERNAME)
row.locator("button:has-text('重置密码')").click()
page.wait_for_timeout(500)
dialog = visible_dialog(page)
dialog.locator('input[placeholder="8–72 字节(中文一个字符约 3 字节)"]').fill(RESET_PASSWORD)
dialog.locator(".el-dialog__footer button:has-text('确认重置')").click()
expect_toast(page, "密码已重置", "重置密码")
page.wait_for_timeout(600)
print("✓ 5. 重置密码 提示该用户需重新登录")
row = find_user_row(page, NEW_USERNAME)
row.locator("button:has-text('禁用')").click()
page.wait_for_timeout(400)
confirm_message_box(page, "禁用")
expect_toast(page, "已禁用", "禁用用户")
row = find_user_row(page, NEW_USERNAME)
if "禁用" not in row.inner_text():
fail("禁用用户", "列表中状态未更新为禁用")
row.locator("button:has-text('启用')").click()
page.wait_for_timeout(400)
confirm_message_box(page, "启用")
expect_toast(page, "已启用", "启用用户")
print("✓ 6. 启用/禁用 状态切换与后端联动正常")
def step_role_management(page: Page) -> None:
"""角色保护与角色 CRUD。"""
open_menu(page, "角色管理")
if current_path(page) != "/system/roles":
fail("菜单导航", f"未进入角色管理页,当前 {page.url}")
admin_row = table_rows(page).filter(has_text="系统管理员").first
if not admin_row.locator("button:has-text('禁用')").is_disabled():
fail("角色保护", "内置 admin 角色的禁用按钮未禁用")
if not admin_row.locator("button:has-text('删除')").is_disabled():
fail("角色保护", "内置 admin 角色的删除按钮未禁用")
print("✓ 7. 角色保护 内置 admin 行的禁用/删除按钮均禁用")
page.click("button:has-text('新增角色')")
page.wait_for_timeout(500)
dialog = visible_dialog(page)
dialog.locator('input[placeholder="如 sales_manager,创建后不可修改"]').fill(NEW_ROLE_CODE)
dialog.locator('input[placeholder="如 销售主管"]').fill("E2E测试角色")
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
expect_toast(page, "角色已创建", "新增角色")
fill_filter(page, "关键词", NEW_ROLE_CODE)
click_query(page)
role_row = table_rows(page).filter(has_text=NEW_ROLE_CODE).first
if role_row.count() == 0:
fail("新增角色", f"列表中未找到 {NEW_ROLE_CODE}")
role_row.locator("button:has-text('编辑')").click()
page.wait_for_timeout(500)
dialog = visible_dialog(page)
dialog.locator('input[placeholder="如 销售主管"]').fill("E2E角色改名")
dialog.locator(".el-dialog__footer button:has-text('保存')").click()
expect_toast(page, "已保存", "编辑角色")
role_row = table_rows(page).filter(has_text=NEW_ROLE_CODE).first
role_row.locator("button:has-text('禁用')").click()
page.wait_for_timeout(400)
confirm_message_box(page, "禁用")
expect_toast(page, "已禁用", "禁用角色")
print("✓ 8. 角色管理 新增/编辑/禁用全流程正常")
def step_logs_query(page: Page) -> None:
"""操作日志(含详情懒加载与脱敏断言)与登录日志。"""
open_menu(page, "日志查询")
if current_path(page) != "/system/logs":
fail("菜单导航", f"未进入日志查询页,当前 {page.url}")
fill_filter(page, "操作人", ADMIN)
select_filter_option(page, "操作", "新增")
click_query(page)
log_row = table_rows(page).filter(has_text=NEW_USERNAME).first
if log_row.count() == 0:
fail("操作日志", f"未查到「新增用户 {NEW_USERNAME}」的审计记录")
log_row.locator(".el-table__expand-icon").click()
# 详情按 id 懒加载:等「加载中…」被替换为内容(含脱敏标记)
detail = ""
deadline = time.monotonic() + 8
while time.monotonic() < deadline:
detail = page.locator(".log-detail__data:visible").first.text_content() or ""
if "***" in detail:
break
page.wait_for_timeout(200)
if "***" not in detail:
fail("日志脱敏", f"展开详情未加载出已脱敏参数,当前内容:{detail[:80]}")
if E2E_PASSWORD in detail:
fail("日志脱敏", "请求参数中出现明文密码 —— 脱敏未生效")
print("✓ 9. 操作日志 写操作留痕、详情懒加载且密码已脱敏")
page.locator(".el-tabs__item:has-text('登录日志')").click()
page.wait_for_timeout(800)
fill_filter(page, "用户名", ADMIN)
click_query(page)
if table_rows(page).filter(has_text="成功").count() == 0:
fail("登录日志", "未查到当前会话的成功登录记录")
print("✓ 10. 登录日志 当前会话登录记录可查")
def step_permission_check(page: Page, admin_password: str) -> None:
"""普通用户菜单隐藏与路由守卫。"""
open_menu(page, "用户管理")
create_user_via_ui(page, VIEWER_USERNAME, "E2E访客")
page.click("text=退出登录")
page.wait_for_url("**/login**", timeout=10_000)
login(page, VIEWER_USERNAME, E2E_PASSWORD)
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":
fail("路由守卫", f"普通用户直连管理页未被弹回,当前 {page.url}")
if "需要管理员权限" not in toast_text(page):
fail("路由守卫", "直连管理页未给出权限提示")
print("✓ 12. 路由守卫 直连 /system/users 被弹回首页")
page.click("text=退出登录")
page.wait_for_url("**/login**", timeout=10_000)
login(page, ADMIN, admin_password)
def step_cleanup(page: Page) -> None:
"""删除测试用户与角色(用 expect_toast 校验,失败不假绿)。"""
open_menu(page, "用户管理")
for username in (NEW_USERNAME, VIEWER_USERNAME):
row = find_user_row(page, username)
if row.count() == 0:
continue
row.locator("button:has-text('删除')").click()
page.wait_for_timeout(400)
confirm_message_box(page, "删除")
expect_toast(page, "已删除", "数据清理")
open_menu(page, "角色管理")
fill_filter(page, "关键词", NEW_ROLE_CODE)
click_query(page)
role_row = table_rows(page).filter(has_text=NEW_ROLE_CODE).first
if role_row.count() > 0:
role_row.locator("button:has-text('删除')").click()
page.wait_for_timeout(400)
confirm_message_box(page, "删除")
expect_toast(page, "已删除", "数据清理")
print("✓ 13. 数据清理 e2e 测试用户与角色已删除")
# ==================== 入口 ====================
def run(page: Page) -> None:
collect_console_errors(page, console_errors)
admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "")
if not admin_password:
fail(
"环境变量",
"未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码),"
"示例:E2E_ADMIN_PASSWORD=xxx python e2e/system_management.py",
)
step_user_create(page, admin_password)
step_user_edit_and_status(page)
step_role_management(page)
step_logs_query(page)
step_permission_check(page, admin_password)
step_cleanup(page)
if console_errors:
print(f" (控制台错误 {len(console_errors)} 条:{console_errors[:2]})")
def main() -> int:
with sync_playwright() as playwright:
browser = playwright.chromium.launch(headless=True)
page = browser.new_page()
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}")
if console_errors:
print(f" 控制台错误:{console_errors[-5:]}")
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}")
if console_errors:
print(f" 控制台错误:{console_errors[-5:]}")
print(" 已保存截图 e2e/failure.png")
return 1
finally:
browser.close()
print("\n全部通过。")
return 0
if __name__ == "__main__":
raise SystemExit(main())