From 8355e0cc7dd6db05319db9ff4b016f1fd2e89afb Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Sat, 10 Oct 2026 16:28:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(system):=20=E5=AE=8C=E6=88=90=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E7=B3=BB=E7=BB=9F=E7=AE=A1=E7=90=86=EF=BC=88=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E6=A1=86=E6=9E=B6=E3=80=81=E4=B8=89=E4=B8=AA=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=A1=B5=E9=9D=A2=E3=80=81e2e=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/README.md | 34 +- frontend/e2e/common.py | 153 ++++++ frontend/e2e/login_flow.py | 102 ++-- frontend/e2e/system_management.py | 401 +++++++++++++++ frontend/src/api/logs.ts | 78 +++ frontend/src/api/roles.ts | 54 ++ frontend/src/api/types.ts | 25 + frontend/src/api/users.ts | 86 ++++ frontend/src/assets/styles/index.css | 41 +- frontend/src/components/layout/AppMenu.vue | 49 ++ .../src/components/layout/BasicLayout.vue | 219 ++++++++ .../src/components/table/AppPagination.vue | 36 ++ frontend/src/components/table/StatusTag.vue | 29 ++ frontend/src/composables/useAppMenu.ts | 65 +++ frontend/src/composables/useConfirmAction.ts | 52 ++ frontend/src/composables/usePagedList.ts | 86 ++++ frontend/src/router/index.ts | 65 ++- frontend/src/utils/constants.ts | 23 + frontend/src/utils/error.ts | 13 + frontend/src/utils/format.ts | 12 + frontend/src/utils/validate.ts | 38 ++ frontend/src/views/dashboard/index.vue | 117 ++--- frontend/src/views/login/index.vue | 22 +- frontend/src/views/system/logs/index.vue | 393 +++++++++++++++ frontend/src/views/system/roles/index.vue | 305 ++++++++++++ frontend/src/views/system/users/index.vue | 467 ++++++++++++++++++ 26 files changed, 2785 insertions(+), 180 deletions(-) create mode 100644 frontend/e2e/common.py create mode 100644 frontend/e2e/system_management.py create mode 100644 frontend/src/api/logs.ts create mode 100644 frontend/src/api/roles.ts create mode 100644 frontend/src/api/types.ts create mode 100644 frontend/src/api/users.ts create mode 100644 frontend/src/components/layout/AppMenu.vue create mode 100644 frontend/src/components/layout/BasicLayout.vue create mode 100644 frontend/src/components/table/AppPagination.vue create mode 100644 frontend/src/components/table/StatusTag.vue create mode 100644 frontend/src/composables/useAppMenu.ts create mode 100644 frontend/src/composables/useConfirmAction.ts create mode 100644 frontend/src/composables/usePagedList.ts create mode 100644 frontend/src/utils/constants.ts create mode 100644 frontend/src/utils/error.ts create mode 100644 frontend/src/utils/format.ts create mode 100644 frontend/src/utils/validate.ts create mode 100644 frontend/src/views/system/logs/index.vue create mode 100644 frontend/src/views/system/roles/index.vue create mode 100644 frontend/src/views/system/users/index.vue diff --git a/frontend/README.md b/frontend/README.md index c86e097..17ad2d4 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,6 +1,6 @@ # 前端 -> AI摘要:衍水集团营销人员工资管理系统的前端。Vue 3 + TypeScript + Vite + Element Plus + Pinia。关键文件 `src/api/request.ts`(统一请求封装)、`src/router/index.ts`(路由守卫)、`src/store/user.ts`(会话状态)、`src/views/login/index.vue`(登录页)。当前完成登录链路,业务模块页面待接入。 +> AI摘要:衍水集团营销人员工资管理系统的前端。Vue 3 + TypeScript + Vite + Element Plus + Pinia。关键文件 `src/api/request.ts`(统一请求封装)、`src/components/layout/BasicLayout.vue`(应用布局与导航)、`src/router/index.ts`(路由守卫)、`src/store/user.ts`(会话状态)。当前完成登录链路与系统管理页面(用户/角色/日志查询),业务模块页面待接入。 ## 职责 @@ -26,18 +26,26 @@ src/ ├── api/ │ ├── request.ts # axios 实例、拦截器、统一信封解包 -│ └── auth.ts # 认证接口封装与类型 -├── assets/styles/ # 全局样式 -├── components/ # 通用组件(待补充) -├── composables/ # 组合式函数(待补充) -├── router/index.ts # 路由表与全局守卫 +│ ├── auth.ts # 认证接口封装与类型 +│ ├── users.ts / roles.ts / logs.ts # 系统管理接口封装 +│ └── types.ts # 分页等公共类型 +├── assets/styles/ # 全局样式与品牌 token(:root 的 --ys-*) +├── components/ +│ ├── layout/ # BasicLayout(侧边栏/顶栏/面包屑)、AppMenu +│ └── table/ # StatusTag(0/1 状态标签)、AppPagination +├── composables/ +│ ├── useAppMenu.ts # 菜单从路由表派生(meta.title/group/order/requiresAdmin) +│ ├── useConfirmAction.ts # 确认框→执行→提示→刷新 动作流(启停/删除共用) +│ └── usePagedList.ts # 分页列表状态(请求序号防竞态、删除后页码回退) +├── router/index.ts # 路由表与全局守卫(四层,含 requiresAdmin) ├── store/ │ ├── index.ts # Pinia 实例 │ └── user.ts # 会话状态:token、用户信息、登录/登出 -├── utils/storage.ts # localStorage 读写 +├── utils/ # storage/format/validate/constants/error(handledError) └── views/ ├── login/ # 登录页 - └── dashboard/ # 首页(登录链路验证页) + ├── dashboard/ # 首页(登录链路验证页) + └── system/ # 系统管理:users(用户)/ roles(角色)/ logs(日志查询) ``` ## 启动 @@ -85,6 +93,8 @@ pnpm build # 类型检查 + 生产构建 **字体**:品牌名用**宋体**(衬线,拟正式、拟账册),正文用系统无衬线,数字与验证码用等宽。 **界面不引入网络字体**——内网环境字体加载不可靠,靠字重、字距与留白做层次更稳妥。新页面请沿用这套字号节奏。 +**落地方式**:品牌色已提升为全局 CSS 变量(`src/assets/styles/index.css` 的 `:root`,`--ys-*` 前缀),登录页与布局、系统管理页面共用;全局还把 `--el-color-primary` 指向赭金,Element Plus 组件默认走品牌色。登录页 scoped 里的同名局部变量只是全局 token 的别名。 + **签名元素**:品牌面板的水纹层理线,越往下越密(如潜向深处),载入时用 `stroke-dashoffset` 逐条画出——**一个统一的入场动作,而不是散落的特效**。 **构图**:品牌内容(名称 / 金线 / 系统名 / 主标语 / 环节行)**垂直居中聚成一块**,配一层柔和径向光晕作落点。 @@ -104,14 +114,20 @@ pnpm build # 类型检查 + 生产构建 ## 端到端测试 -`e2e/login_flow.py` 用 Playwright 驱动真实浏览器,覆盖 8 项:路由守卫、验证码渲染、失败后自动刷新、登录跳转、用户信息展示、刷新恢复会话、登出、登出后令牌失效。 +`e2e/` 下两个脚本用 Playwright 驱动真实浏览器,公共工具抽在 `e2e/common.py`(登录动作、从 Redis 取验证码答案、提示等待、失败断言与控制台错误收集): + +- `login_flow.py`(8 项):路由守卫、验证码渲染、失败后自动刷新、登录跳转、用户信息展示、刷新恢复会话、登出、登出后令牌失效 +- `system_management.py`(13 项):菜单导航、用户增删改查(含重名负例)、重置密码、启用/禁用、角色保护与 CRUD、操作日志留痕与详情懒加载脱敏、登录日志、普通用户菜单隐藏与路由守卫 ```bash # 前置:MySQL/Redis 容器、后端、前端 dev server 均在运行 python e2e/login_flow.py +# 管理员密码经环境变量传入(不写死在源码里) +E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py ``` 需要 Python `playwright` 包与 chromium(`python -m playwright install chromium`)。 +脚本自建的测试数据(`e2e_` 前缀)在正常流程内清理;中途失败会残留在开发库。 ## 常见问题 diff --git a/frontend/e2e/common.py b/frontend/e2e/common.py new file mode 100644 index 0000000..cb88b58 --- /dev/null +++ b/frontend/e2e/common.py @@ -0,0 +1,153 @@ +"""端到端测试公共工具。 + +抽取自 `login_flow.py`,供各流程脚本复用:环境常量、Redis 读取(取验证码 +答案,仅自动化测试可用)、完整登录动作、失败处理与控制台错误收集。 + +约定:断言失败抛 `E2EFailure`(消息格式 `步骤:原因`),由各脚本的 `main()` +统一截图并返回退出码 1。 +""" + +import os +import subprocess +import sys +import time +from urllib.parse import urlparse + +from playwright.sync_api import Page + +BASE_URL = "http://127.0.0.1:5173" +REDIS_CONTAINER = "ys-redis-dev" + +# Windows 终端默认 cp936,✓/✗ 等符号会抛 UnicodeEncodeError 而非打印失败原因。 +# 与后端 core/logging.py 的处理一致,显式重配标准输出编码。 +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + + +class E2EFailure(Exception): + """步骤断言失败。""" + + +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: + """断言失败:抛异常而非进程退出 —— 由 main() 统一截图后再退出。""" + raise E2EFailure(f"{step}:{detail}") + + +def current_path(page: Page) -> str: + """当前 URL 的路径部分。 + + 比较路径**不能**用 `**/dashboard` 这类 glob —— 它会把 + `/login?redirect=/dashboard` 也匹配上,"登录失败"会被误判为成功。 + """ + return urlparse(page.url).path + + +def collect_console_errors(page: Page, sink: list[str] | None = None) -> list[str]: + """注册控制台错误收集(失败诊断时打印最近几条)。 + + :param sink: 可选的外部列表(如脚本的模块级变量,供 main() 的失败路径读取) + """ + errors = sink if sink is not None else [] + page.on("console", lambda msg: errors.append(msg.text) if msg.type == "error" else None) + page.on("pageerror", lambda exc: errors.append(str(exc))) + return errors + + +def toast_text(page: Page) -> str: + """当前显示的全局提示文本(el-message),无提示时为空串。""" + locator = page.locator(".el-message") + return locator.first.text_content().strip() if locator.count() else "" + + +def wait_toast(page: Page, expected: str, timeout_ms: int = 5000) -> str: + """等待包含 expected 的全局提示出现,返回其文本(超时返回当前文本)。 + + 直接读 `toast_text` 会在「上一条提示尚未消失、新提示尚未出现」的窗口里 + 读到旧文本,产生误判;轮询等待把这类时序问题挡在断言之外。 + """ + deadline = time.monotonic() + timeout_ms / 1000 + text = "" + while time.monotonic() < deadline: + text = toast_text(page) + if expected in text: + return text + page.wait_for_timeout(200) + return text + + +def expect_toast(page: Page, expected: str, step: str, timeout_ms: int = 5000) -> None: + """断言出现了包含指定文本的全局提示。""" + text = wait_toast(page, expected, timeout_ms) + if expected not in text: + fail(step, f"未出现提示「{expected}」,当前提示:{text or '(无)'}") + + +def login(page: Page, username: str, password: str) -> None: + """完整登录:打开登录页 → 从 Redis 取验证码答案 → 提交 → 等待进入首页。 + + 验证码 id 经响应监听捕获(前端把它内联在响应体里,不是 URL 参数)。 + """ + captcha_ids: list[str] = [] + + def on_response(response) -> None: + if "/api/v1/auth/captcha" not in response.url or response.status != 200: + return + try: + captcha_ids.append(response.json()["data"]["captcha_id"]) + except Exception: + # 页面导航后响应体已不可读(会抛协议错误),属可忽略的噪声 + pass + + page.on("response", on_response) + try: + page.goto(f"{BASE_URL}/login", wait_until="networkidle") + for _ in range(50): + if captcha_ids: + break + page.wait_for_timeout(100) + if not captcha_ids: + fail("登录", "未捕获到验证码请求") + + captcha_id = captcha_ids[-1] + answer = redis_get(f"ys:captcha:{captcha_id}") + if not answer: + fail("登录", f"Redis 中未找到验证码 ys:captcha:{captcha_id}") + + page.fill('input[autocomplete="username"]', username) + page.fill('input[autocomplete="current-password"]', password) + 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 message := toast_text(page): + detail += f",提示:{message}" + fail("登录", detail) + page.wait_for_load_state("networkidle") + finally: + page.remove_listener("response", on_response) diff --git a/frontend/e2e/login_flow.py b/frontend/e2e/login_flow.py index 8fbfa89..e1f60f3 100644 --- a/frontend/e2e/login_flow.py +++ b/frontend/e2e/login_flow.py @@ -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: diff --git a/frontend/e2e/system_management.py b/frontend/e2e/system_management.py new file mode 100644 index 0000000..30fcb83 --- /dev/null +++ b/frontend/e2e/system_management.py @@ -0,0 +1,401 @@ +"""系统管理端到端验证:用户 / 角色 / 日志 / 权限。 + +覆盖的链路(只有真实浏览器才能验证的组合): + +- 侧边菜单导航与「系统管理」分组 +- 用户增删改查:新建(含重名负例)、编辑、重置密码、启用/禁用、删除 +- 角色保护:内置 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()) diff --git a/frontend/src/api/logs.ts b/frontend/src/api/logs.ts new file mode 100644 index 0000000..dbb9284 --- /dev/null +++ b/frontend/src/api/logs.ts @@ -0,0 +1,78 @@ +import { request } from './request' +import type { PageData } from './types' + +/** 操作日志列表项(对应后端 `OperationLogOut`;不含 request_data 大字段)。 */ +export interface OperationLogItem { + id: number + user_id: number | null + username: string + module: string + action: string + summary: string + method: string | null + path: string | null + ip: string | null + user_agent: string | null + /** 1成功 0失败 */ + success: number + error_message: string | null + duration_ms: number | null + create_time: string | null +} + +/** 操作日志详情(对应后端 `OperationLogDetailOut`;request_data 已脱敏) */ +export interface OperationLogDetail extends OperationLogItem { + request_data: string | null +} + +export interface OperationLogQueryParams { + pageNum?: number + pageSize?: number + username?: string + module?: string + action?: string + success?: number + /** 格式 YYYY-MM-DD HH:mm:ss(el-date-picker 需配 value-format) */ + start_time?: string + end_time?: string +} + +/** 登录日志项(对应后端 `LoginLogOut`)。 */ +export interface LoginLogItem { + id: number + user_id: number | null + username: string + ip: string | null + user_agent: string | null + /** 1成功 0失败 */ + success: number + fail_reason: string | null + create_time: string | null +} + +export interface LoginLogQueryParams { + pageNum?: number + pageSize?: number + username?: string + ip?: string + success?: number + start_time?: string + end_time?: string +} + +/** 分页查询操作日志(列表不含 request_data,详情走 getOperationLog) */ +export function listOperationLogs( + params: OperationLogQueryParams, +): Promise> { + return request({ url: '/v1/system/operation-logs', method: 'get', params }) +} + +/** 操作日志详情(含已脱敏的请求参数,展开行时按需加载) */ +export function getOperationLog(logId: number): Promise { + return request({ url: `/v1/system/operation-logs/${logId}`, method: 'get' }) +} + +/** 分页查询登录日志 */ +export function listLoginLogs(params: LoginLogQueryParams): Promise> { + return request({ url: '/v1/system/login-logs', method: 'get', params }) +} diff --git a/frontend/src/api/roles.ts b/frontend/src/api/roles.ts new file mode 100644 index 0000000..b459a27 --- /dev/null +++ b/frontend/src/api/roles.ts @@ -0,0 +1,54 @@ +import { request } from './request' +import type { PageData, RoleOption } from './types' + +/** 角色管理列表项(对应后端 `RoleManageOut`,比下拉多绑定用户数)。 */ +export interface RoleItem extends RoleOption { + /** 绑定用户数:>0 的角色不可删除,前端据此禁用按钮 */ + user_count: number +} + +export interface RoleQueryParams { + pageNum?: number + pageSize?: number + keyword?: string + status?: number +} + +export interface RoleCreateParams { + role_code: string + role_name: string + description?: string + sort_order?: number +} + +/** 编辑:不含编码(编码是授权判定锚点,创建后不可改) */ +export interface RoleUpdateParams { + role_name: string + description?: string + sort_order?: number +} + +/** 分页查询角色(含停用角色) */ +export function listRoles(params: RoleQueryParams): Promise> { + return request({ url: '/v1/system/roles', method: 'get', params }) +} + +/** 新增角色 */ +export function createRole(data: RoleCreateParams): Promise { + return request({ url: '/v1/system/roles', method: 'post', data }) +} + +/** 编辑角色 */ +export function updateRole(roleId: number, data: RoleUpdateParams): Promise { + return request({ url: `/v1/system/roles/${roleId}`, method: 'put', data }) +} + +/** 启用 / 禁用角色(内置 admin 不可停用) */ +export function setRoleStatus(roleId: number, status: number): Promise { + return request({ url: `/v1/system/roles/${roleId}/status`, method: 'put', data: { status } }) +} + +/** 删除角色(内置 admin 与仍有绑定用户的角色受后端保护) */ +export function deleteRole(roleId: number): Promise { + return request({ url: `/v1/system/roles/${roleId}`, method: 'delete' }) +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..561f225 --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,25 @@ +/** 请求/响应公共类型。 */ + +/** + * 分页响应 —— 与后端 `PageResult` 的序列化字段一致。 + * + * 分页字段是驼峰(list/total/pageNum/pageSize,规范约定),而业务筛选 + * 参数是蛇形(username、real_name…,与后端 Query 模型一致), + * 这是既有契约,不要"顺手统一"。 + */ +export interface PageData { + list: T[] + total: number + pageNum: number + pageSize: number +} + +/** 已启用角色的下拉选项 */ +export interface RoleOption { + id: number + role_code: string + role_name: string + description: string | null + sort_order: number + status: number +} diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts new file mode 100644 index 0000000..7594508 --- /dev/null +++ b/frontend/src/api/users.ts @@ -0,0 +1,86 @@ +import { request } from './request' +import type { PageData, RoleOption } from './types' + +/** 用户列表项(对应后端 `UserOut`)。 */ +export interface UserItem { + id: number + username: string + real_name: string | null + phone: string | null + email: string | null + /** 1启用 0禁用 */ + status: number + /** 角色编码列表 */ + roles: string[] + last_login_time: string | null + remark: string | null + create_time: string | null +} + +/** 列表查询:分页参数为驼峰,业务筛选为蛇形(与后端契约一致,勿"统一") */ +export interface UserQueryParams { + pageNum?: number + pageSize?: number + username?: string + real_name?: string + status?: number + role_code?: string +} + +export interface UserCreateParams { + username: string + password: string + real_name?: string + phone?: string + email?: string + roles: string[] + remark?: string +} + +/** 编辑:不含用户名与密码(后者走专门的重置接口) */ +export interface UserUpdateParams { + real_name?: string + phone?: string + email?: string + roles?: string[] + remark?: string +} + +/** 分页查询用户 */ +export function listUsers(params: UserQueryParams): Promise> { + return request({ url: '/v1/system/users', method: 'get', params }) +} + +/** 新增用户 */ +export function createUser(data: UserCreateParams): Promise { + return request({ url: '/v1/system/users', method: 'post', data }) +} + +/** 编辑用户 */ +export function updateUser(userId: number, data: UserUpdateParams): Promise { + return request({ url: `/v1/system/users/${userId}`, method: 'put', data }) +} + +/** 删除用户(物理删除,建议优先用禁用) */ +export function deleteUser(userId: number): Promise { + return request({ url: `/v1/system/users/${userId}`, method: 'delete' }) +} + +/** 重置密码(该用户已签发的令牌立即失效,需重新登录) */ +export function resetPassword(userId: number, newPassword: string): Promise { + return request({ + url: `/v1/system/users/${userId}/password`, + method: 'put', + data: { new_password: newPassword }, + }) +} + +/** 启用 / 禁用 */ +export function setUserStatus(userId: number, status: number): Promise { + return request({ url: `/v1/system/users/${userId}/status`, method: 'put', data: { status } }) +} + +/** 角色下拉选项(仅启用角色) */ +export function listRoleOptions(): Promise { + return request({ url: '/v1/system/users/meta/roles', method: 'get' }) +} diff --git a/frontend/src/assets/styles/index.css b/frontend/src/assets/styles/index.css index d3510fa..6bb84be 100644 --- a/frontend/src/assets/styles/index.css +++ b/frontend/src/assets/styles/index.css @@ -1,4 +1,34 @@ -/* 全局样式:重置 + 基础版式 */ +/* 全局样式:重置 + 品牌 token + 基础版式 */ + +:root { + /* + * 品牌色:与登录页视觉体系同一套(原为登录页 scoped 局部变量,布局框架 + * 与系统管理页面共用后提升为全局 token)。 + * 金色分两档:亮金只用于深色底、深金只用于浅色底,保证两处对比度都达标。 + */ + --ys-ink-900: #061a22; + --ys-ink-800: #0a2833; + --ys-ink-700: #0f3646; + --ys-ink-600: #1a4f63; + --ys-gold-bright: #c9a227; + --ys-gold-deep: #8f6520; + --ys-paper: #fafaf7; + --ys-text: #16262d; + --ys-muted: #6b7b84; + + /* + * Element Plus 主色 = 赭金(浅色底配白字约 6.2:1)。 + * 色阶为按 Element Plus 公式「主色与白色混合」预先算好的静态值 —— + * 不用 color-mix(),避免依赖较新的浏览器特性。 + */ + --el-color-primary: #8f6520; + --el-color-primary-light-3: #b19363; + --el-color-primary-light-5: #c7b290; + --el-color-primary-light-7: #ddd1bc; + --el-color-primary-light-8: #e9e0d2; + --el-color-primary-light-9: #f4f0e9; + --el-color-primary-dark-2: #72511a; +} * { box-sizing: border-box; @@ -20,3 +50,12 @@ body { background-color: #f5f7fa; -webkit-font-smoothing: antialiased; } + +/* 系统管理列表页共用的版式(原在三个页面 scoped 中各自重复) */ +.filter-form { + margin-bottom: 4px; +} + +.toolbar { + margin-bottom: 12px; +} diff --git a/frontend/src/components/layout/AppMenu.vue b/frontend/src/components/layout/AppMenu.vue new file mode 100644 index 0000000..ac0f945 --- /dev/null +++ b/frontend/src/components/layout/AppMenu.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/frontend/src/components/layout/BasicLayout.vue b/frontend/src/components/layout/BasicLayout.vue new file mode 100644 index 0000000..9476f51 --- /dev/null +++ b/frontend/src/components/layout/BasicLayout.vue @@ -0,0 +1,219 @@ + + + + + + + diff --git a/frontend/src/components/table/AppPagination.vue b/frontend/src/components/table/AppPagination.vue new file mode 100644 index 0000000..f39d135 --- /dev/null +++ b/frontend/src/components/table/AppPagination.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/frontend/src/components/table/StatusTag.vue b/frontend/src/components/table/StatusTag.vue new file mode 100644 index 0000000..d7994c3 --- /dev/null +++ b/frontend/src/components/table/StatusTag.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/composables/useAppMenu.ts b/frontend/src/composables/useAppMenu.ts new file mode 100644 index 0000000..857e743 --- /dev/null +++ b/frontend/src/composables/useAppMenu.ts @@ -0,0 +1,65 @@ +import { computed } from 'vue' +import { useRouter } from 'vue-router' + +import { useUserStore } from '@/store/user' +import { ROLE_ADMIN } from '@/utils/constants' + +/** 菜单节点:从路由表派生,避免菜单标题与页面标题两处维护漂移。 */ +export interface MenuNode { + path: string + title: string + order: number + group?: string +} + +export interface MenuGroup { + name: string + items: MenuNode[] +} + +/** + * 侧边菜单数据。 + * + * 规则: + * - 取路由表中带 `meta.title` 且未被 `meta.menu === false` 排除的记录 + * - `meta.requiresAdmin` 的项仅管理员可见(与路由守卫双保险,隐藏入口在前) + * - `meta.group` 相同的项聚合为一个子菜单;无 group 的为顶层菜单项 + * - 按 `meta.order` 排序 + */ +export function useAppMenu() { + const router = useRouter() + const userStore = useUserStore() + + const isAdmin = computed(() => userStore.roles.includes(ROLE_ADMIN)) + + const menuNodes = computed(() => + router + .getRoutes() + .filter((record) => Boolean(record.meta.title) && record.meta.menu !== false) + .filter((record) => !record.meta.requiresAdmin || isAdmin.value) + .map((record) => ({ + path: record.path, + title: record.meta.title ?? '', + order: record.meta.order ?? 99, + group: record.meta.group, + })) + .sort((a, b) => a.order - b.order), + ) + + /** 无分组的顶层菜单项 */ + const topLevelMenus = computed(() => menuNodes.value.filter((node) => !node.group)) + + /** 分组菜单(按 group 名聚合,组内保持 order 顺序) */ + const groupedMenus = computed(() => { + const groups = new Map() + for (const node of menuNodes.value) { + if (!node.group) continue + const items = groups.get(node.group) ?? [] + items.push(node) + groups.set(node.group, items) + } + return [...groups.entries()].map(([name, items]) => ({ name, items })) + }) + + return { topLevelMenus, groupedMenus } +} diff --git a/frontend/src/composables/useConfirmAction.ts b/frontend/src/composables/useConfirmAction.ts new file mode 100644 index 0000000..00943e5 --- /dev/null +++ b/frontend/src/composables/useConfirmAction.ts @@ -0,0 +1,52 @@ +import { ElMessage, ElMessageBox } from 'element-plus' + +import { handledError } from '@/utils/error' + +/** + * 「确认框 → 执行 → 成功提示 → 收尾刷新」动作流。 + * + * 用户/角色两页的启用禁用、删除共四处结构逐行相同,收敛到这里: + * 取消确认静默返回 false;接口失败由请求拦截器统一提示,此处仅兜底 + * 阻止 Promise 继续传播(handledError 显式标注,不写空 catch)。 + */ +export function useConfirmAction() { + /** + * 弹出确认框并执行;返回是否真正执行成功(用户取消或执行失败均为 false)。 + */ + async function confirmAndRun(options: { + /** 确认框正文 */ + confirmText: string + /** 确认框标题 */ + title?: string + /** 确认按钮文案 */ + confirmButtonText?: string + /** 执行成功后的全局提示 */ + successText: string + /** 真正的业务动作 */ + run: () => Promise + /** 成功后的收尾(如刷新列表) */ + after?: () => Promise | void + }): Promise { + try { + await ElMessageBox.confirm(options.confirmText, options.title ?? '操作确认', { + type: 'warning', + confirmButtonText: options.confirmButtonText ?? '确定', + cancelButtonText: '取消', + }) + } catch { + return false // 用户取消,静默返回 + } + + try { + await options.run() + ElMessage.success(options.successText) + await options.after?.() + return true + } catch (error) { + handledError(error) // 失败提示由请求拦截器统一完成 + return false + } + } + + return { confirmAndRun } +} diff --git a/frontend/src/composables/usePagedList.ts b/frontend/src/composables/usePagedList.ts new file mode 100644 index 0000000..936701d --- /dev/null +++ b/frontend/src/composables/usePagedList.ts @@ -0,0 +1,86 @@ +import { ref, type Ref } from 'vue' + +import type { PageData } from '@/api/types' +import { handledError } from '@/utils/error' + +/** + * 分页列表状态管理。 + * + * 四个列表页(用户/角色/操作日志/登录日志)共用同一套分页交互,要点: + * + * - 「删除当前页最后一条后页码回退」是高频踩坑点:不回退就会停在空页 + * - **请求序号防竞态**:快速翻页/改条件时并发请求乱序返回,只有最新 + * 请求的响应允许写入状态,否则旧数据会覆盖新数据、与分页器错位 + * - 请求失败不外抛(提示由拦截器统一完成),loading 仅由最新请求复位 + */ +export function usePagedList( + fetcher: (params: F & { pageNum: number; pageSize: number }) => Promise>, + getFilters: () => F, +) { + const records = ref([]) as Ref + const total = ref(0) + const pageNum = ref(1) + const pageSize = ref(10) + const loading = ref(false) + + /** 自增请求序号:仅最新请求的响应可写入状态 */ + let requestSeq = 0 + + /** 按当前页码与筛选条件加载 */ + async function load(): Promise { + const seq = ++requestSeq + loading.value = true + try { + const data = await fetcher({ + ...getFilters(), + pageNum: pageNum.value, + pageSize: pageSize.value, + }) + if (seq !== requestSeq) return // 已有更新的请求发出,丢弃过期响应 + records.value = data.list + total.value = data.total + } catch (error) { + handledError(error) // 失败提示由请求拦截器统一完成 + } finally { + if (seq === requestSeq) loading.value = false + } + } + + /** 条件查询:回到第一页再加载(停留旧页码会查到空页) */ + function search(): void { + pageNum.value = 1 + void load() + } + + function onPageChange(page: number): void { + pageNum.value = page + void load() + } + + function onSizeChange(size: number): void { + pageSize.value = size + pageNum.value = 1 + void load() + } + + /** 删除后刷新:当前页已被删空且不在第一页时,先回退页码再加载 */ + async function reloadAfterDelete(removedCount = 1): Promise { + if (records.value.length - removedCount <= 0 && pageNum.value > 1) { + pageNum.value -= 1 + } + await load() + } + + return { + records, + total, + pageNum, + pageSize, + loading, + load, + search, + onPageChange, + onSizeChange, + reloadAfterDelete, + } +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 856d6dc..51c84ef 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,20 +1,64 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' +import { ElMessage } from 'element-plus' import { useUserStore } from '@/store/user' +import { ROLE_ADMIN } from '@/utils/constants' + +declare module 'vue-router' { + interface RouteMeta { + /** 免登录页面(登录页) */ + public?: boolean + /** 页面标题:浏览器标题 / 面包屑 / 菜单共用,新页面必须写 */ + title?: string + /** 仅管理员可访问 —— 菜单隐藏 + 守卫兜底(直接输 URL 会被弹回首页) */ + requiresAdmin?: boolean + /** 是否出现在侧边菜单(默认 true);false 用于排除登录页等 */ + menu?: boolean + /** 菜单分组名(同名聚合为一个子菜单);不设则为顶层菜单项 */ + group?: string + /** 菜单排序,越小越靠前 */ + order?: number + } +} const routes: RouteRecordRaw[] = [ - { path: '/', redirect: '/dashboard' }, { path: '/login', name: 'login', component: () => import('@/views/login/index.vue'), - meta: { public: true, title: '登录' }, + meta: { public: true, title: '登录', menu: false }, }, { - path: '/dashboard', - name: 'dashboard', - component: () => import('@/views/dashboard/index.vue'), - meta: { title: '首页' }, + // 应用主框架:菜单、顶栏、面包屑由布局提供,业务页面作为子路由渲染 + path: '/', + component: () => import('@/components/layout/BasicLayout.vue'), + redirect: '/dashboard', + children: [ + { + path: '/dashboard', + name: 'dashboard', + component: () => import('@/views/dashboard/index.vue'), + meta: { title: '首页', order: 1 }, + }, + { + path: '/system/users', + name: 'system-users', + component: () => import('@/views/system/users/index.vue'), + meta: { title: '用户管理', group: '系统管理', requiresAdmin: true, order: 2 }, + }, + { + path: '/system/roles', + name: 'system-roles', + component: () => import('@/views/system/roles/index.vue'), + meta: { title: '角色管理', group: '系统管理', requiresAdmin: true, order: 3 }, + }, + { + path: '/system/logs', + name: 'system-logs', + component: () => import('@/views/system/logs/index.vue'), + meta: { title: '日志查询', group: '系统管理', requiresAdmin: true, order: 4 }, + }, + ], }, // 未匹配路由回首页;若未登录会被守卫拦到登录页 { path: '/:pathMatch(.*)*', redirect: '/dashboard' }, @@ -28,11 +72,13 @@ const router = createRouter({ /** * 全局前置守卫。 * - * 三层判断: + * 四层判断: * 1. `meta.public` 标记的页面(登录页)直接放行 * 2. 无令牌 → 跳登录页,并把原目标记在 query 里供登录后回跳 * 3. 有令牌但内存中无用户信息(说明是刷新页面)→ 调 /me 恢复会话; * 失败则视为令牌已失效,清状态并跳登录页 + * 4. `meta.requiresAdmin` 的页面要求管理员角色 —— 此时用户信息已就绪, + * 这是"隐藏菜单"之外的第二道防线(防手输 URL) */ router.beforeEach(async (to) => { const userStore = useUserStore() @@ -54,6 +100,11 @@ router.beforeEach(async (to) => { } } + if (to.meta.requiresAdmin && !userStore.roles.includes(ROLE_ADMIN)) { + ElMessage.error('需要管理员权限') + return { path: '/dashboard' } + } + return true }) diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts new file mode 100644 index 0000000..8109b6d --- /dev/null +++ b/frontend/src/utils/constants.ts @@ -0,0 +1,23 @@ +/** 跨页面共享常量。 */ + +/** 系统管理员:可见「系统管理」菜单,全部管理接口可用(与后端 core/constants.py 一致) */ +export const ROLE_ADMIN = 'admin' + +/** + * 操作日志的模块 / 操作词表 —— 与后端 `core/constants.py` 的 + * `MODULE_*` / `ACTION_*` 保持一致;新增模块时两处同步(后端是权威来源)。 + */ +export const MODULE_OPTIONS = ['用户管理', '角色管理'] as const +export const ACTION_OPTIONS = ['新增', '修改', '删除', '重置密码', '启用', '禁用'] as const + +/** 启用/禁用筛选选项(文案与 StatusTag 的展示一致) */ +export const STATUS_OPTIONS = [ + { label: '启用', value: 1 }, + { label: '禁用', value: 0 }, +] as const + +/** 成功/失败筛选选项 */ +export const RESULT_OPTIONS = [ + { label: '成功', value: 1 }, + { label: '失败', value: 0 }, +] as const diff --git a/frontend/src/utils/error.ts b/frontend/src/utils/error.ts new file mode 100644 index 0000000..037cddf --- /dev/null +++ b/frontend/src/utils/error.ts @@ -0,0 +1,13 @@ +/** 异常处理工具。 */ + +/** + * 显式声明「异常已按约定处理」。 + * + * 本项目请求失败的提示统一由 axios 拦截器完成,页面 catch 块只需阻止 + * Promise 继续传播;但**空 catch 会掩盖非预期异常**(如本地 TypeError), + * 也违反「禁空 catch」规范,故以本函数显式标注意图并留痕。 + * 用 debug 级别:既不污染 e2e 收集的 error 级控制台信息,也便于排查时开启。 + */ +export function handledError(error: unknown): void { + console.debug('[handled]', error) +} diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts new file mode 100644 index 0000000..d05363e --- /dev/null +++ b/frontend/src/utils/format.ts @@ -0,0 +1,12 @@ +/** 显示格式化工具。 */ + +/** + * 时间展示:把后端的时间串规范为 `YYYY-MM-DD HH:mm:ss`。 + * + * Pydantic 序列化 datetime 为 ISO 8601(含 `T`),直接展示不友好; + * 值缺失(空串/null/undefined)时返回占位符而非空白。 + */ +export function formatDateTime(value?: string | null, placeholder = '—'): string { + if (!value) return placeholder + return value.replace('T', ' ').slice(0, 19) +} diff --git a/frontend/src/utils/validate.ts b/frontend/src/utils/validate.ts new file mode 100644 index 0000000..0075843 --- /dev/null +++ b/frontend/src/utils/validate.ts @@ -0,0 +1,38 @@ +/** 表单校验工具。 */ + +/** + * 密码校验:按 **UTF-8 字节数** 而非字符数。 + * + * 后端 bcrypt 对超过 72 字节的密码直接拒绝;中文一个字符占 3 字节, + * 24 个汉字即到顶 —— 按字符数校验(如 maxlength=20)根本拦不住, + * 会出现"前端放行、后端 400"的错位体验。与后端 `PASSWORD_*_BYTES` 对齐。 + */ +export const PASSWORD_MIN_BYTES = 8 +export const PASSWORD_MAX_BYTES = 72 + +const BYTE_HINT = '(中文一个字符约 3 字节)' + +/** 计算 UTF-8 字节数 */ +export function passwordByteLength(value: string): number { + return new TextEncoder().encode(value).length +} + +/** Element Plus 表单校验器(用于 el-form 的 rules.validator) */ +export function passwordRule( + _rule: unknown, + value: string, + callback: (error?: Error) => void, +): void { + if (!value) { + callback(new Error('请输入密码')) + return + } + const length = passwordByteLength(value) + if (length < PASSWORD_MIN_BYTES) { + callback(new Error(`密码不足 ${PASSWORD_MIN_BYTES} 字节${BYTE_HINT}`)) + } else if (length > PASSWORD_MAX_BYTES) { + callback(new Error(`密码超过 ${PASSWORD_MAX_BYTES} 字节上限${BYTE_HINT}`)) + } else { + callback() + } +} diff --git a/frontend/src/views/dashboard/index.vue b/frontend/src/views/dashboard/index.vue index 101396d..835dbbd 100644 --- a/frontend/src/views/dashboard/index.vue +++ b/frontend/src/views/dashboard/index.vue @@ -1,98 +1,49 @@ diff --git a/frontend/src/views/login/index.vue b/frontend/src/views/login/index.vue index 1da5400..31e6e7b 100644 --- a/frontend/src/views/login/index.vue +++ b/frontend/src/views/login/index.vue @@ -182,19 +182,19 @@ onMounted(refreshCaptcha) diff --git a/frontend/src/views/system/roles/index.vue b/frontend/src/views/system/roles/index.vue new file mode 100644 index 0000000..06859fa --- /dev/null +++ b/frontend/src/views/system/roles/index.vue @@ -0,0 +1,305 @@ + + + + + diff --git a/frontend/src/views/system/users/index.vue b/frontend/src/views/system/users/index.vue new file mode 100644 index 0000000..9ceaeb9 --- /dev/null +++ b/frontend/src/views/system/users/index.vue @@ -0,0 +1,467 @@ + + + + +