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:
parent
c83287819a
commit
8355e0cc7d
@ -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/
|
src/
|
||||||
├── api/
|
├── api/
|
||||||
│ ├── request.ts # axios 实例、拦截器、统一信封解包
|
│ ├── request.ts # axios 实例、拦截器、统一信封解包
|
||||||
│ └── auth.ts # 认证接口封装与类型
|
│ ├── auth.ts # 认证接口封装与类型
|
||||||
├── assets/styles/ # 全局样式
|
│ ├── users.ts / roles.ts / logs.ts # 系统管理接口封装
|
||||||
├── components/ # 通用组件(待补充)
|
│ └── types.ts # 分页等公共类型
|
||||||
├── composables/ # 组合式函数(待补充)
|
├── assets/styles/ # 全局样式与品牌 token(:root 的 --ys-*)
|
||||||
├── router/index.ts # 路由表与全局守卫
|
├── 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/
|
├── store/
|
||||||
│ ├── index.ts # Pinia 实例
|
│ ├── index.ts # Pinia 实例
|
||||||
│ └── user.ts # 会话状态:token、用户信息、登录/登出
|
│ └── user.ts # 会话状态:token、用户信息、登录/登出
|
||||||
├── utils/storage.ts # localStorage 读写
|
├── utils/ # storage/format/validate/constants/error(handledError)
|
||||||
└── views/
|
└── views/
|
||||||
├── login/ # 登录页
|
├── 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` 逐条画出——**一个统一的入场动作,而不是散落的特效**。
|
**签名元素**:品牌面板的水纹层理线,越往下越密(如潜向深处),载入时用 `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
|
```bash
|
||||||
# 前置:MySQL/Redis 容器、后端、前端 dev server 均在运行
|
# 前置:MySQL/Redis 容器、后端、前端 dev server 均在运行
|
||||||
python e2e/login_flow.py
|
python e2e/login_flow.py
|
||||||
|
# 管理员密码经环境变量传入(不写死在源码里)
|
||||||
|
E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py
|
||||||
```
|
```
|
||||||
|
|
||||||
需要 Python `playwright` 包与 chromium(`python -m playwright install chromium`)。
|
需要 Python `playwright` 包与 chromium(`python -m playwright install chromium`)。
|
||||||
|
脚本自建的测试数据(`e2e_` 前缀)在正常流程内清理;中途失败会残留在开发库。
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
|
|||||||
153
frontend/e2e/common.py
Normal file
153
frontend/e2e/common.py
Normal file
@ -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)
|
||||||
@ -1,4 +1,4 @@
|
|||||||
"""浏览器端端到端验证:登录 + 图形验证码。
|
"""浏览器端到端验证:登录 + 图形验证码。
|
||||||
|
|
||||||
验证的是**只有真实浏览器才能暴露**的问题:
|
验证的是**只有真实浏览器才能暴露**的问题:
|
||||||
|
|
||||||
@ -20,53 +20,28 @@
|
|||||||
cd frontend && python e2e/login_flow.py
|
cd frontend && python e2e/login_flow.py
|
||||||
|
|
||||||
依赖:Python `playwright` 包与 chromium(`python -m playwright install chromium`)。
|
依赖: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
|
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"
|
USERNAME = "admin"
|
||||||
PASSWORD = "Admin@2026"
|
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:
|
def run(page: Page) -> None:
|
||||||
captured_captcha_ids: list[str] = []
|
captured_captcha_ids: list[str] = []
|
||||||
console_errors: list[str] = []
|
console_errors = collect_console_errors(page)
|
||||||
|
|
||||||
def on_response(response) -> None:
|
def on_response(response) -> None:
|
||||||
if "/api/v1/auth/captcha" not in response.url or response.status != 200:
|
if "/api/v1/auth/captcha" not in response.url or response.status != 200:
|
||||||
@ -79,27 +54,11 @@ def run(page: Page) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
page.on("response", on_response)
|
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. 路由守卫:未登录访问首页应被拦回登录页 ----
|
# ---- 1. 路由守卫:未登录访问首页应被拦回登录页 ----
|
||||||
page.goto(BASE_URL, wait_until="networkidle")
|
page.goto(BASE_URL, wait_until="networkidle")
|
||||||
if "/login" not in page.url:
|
if "/login" not in page.url:
|
||||||
_fail("路由守卫", f"未登录访问首页未被拦截,当前 URL={page.url}")
|
fail("路由守卫", f"未登录访问首页未被拦截,当前 URL={page.url}")
|
||||||
print(f"✓ 1. 路由守卫 未登录访问 / → 重定向到 {page.url}")
|
print(f"✓ 1. 路由守卫 未登录访问 / → 重定向到 {page.url}")
|
||||||
|
|
||||||
# ---- 2. 验证码渲染(证明 浏览器→Vite代理→uvicorn→Redis 全链路通)----
|
# ---- 2. 验证码渲染(证明 浏览器→Vite代理→uvicorn→Redis 全链路通)----
|
||||||
@ -107,9 +66,9 @@ def run(page: Page) -> None:
|
|||||||
image.wait_for(state="visible", timeout=10_000)
|
image.wait_for(state="visible", timeout=10_000)
|
||||||
src = image.get_attribute("src") or ""
|
src = image.get_attribute("src") or ""
|
||||||
if not src.startswith("data:image/png;base64,"):
|
if not src.startswith("data:image/png;base64,"):
|
||||||
_fail("验证码渲染", f"图片 src 前缀异常:{src[:40]}")
|
fail("验证码渲染", f"图片 src 前缀异常:{src[:40]}")
|
||||||
if len(src) < 1000:
|
if len(src) < 1000:
|
||||||
_fail("验证码渲染", f"图片数据过小({len(src)}),可能未真正渲染")
|
fail("验证码渲染", f"图片数据过小({len(src)}),可能未真正渲染")
|
||||||
print(f"✓ 2. 验证码渲染 PNG base64 长度 {len(src)}(代理链路已通)")
|
print(f"✓ 2. 验证码渲染 PNG base64 长度 {len(src)}(代理链路已通)")
|
||||||
|
|
||||||
# ---- 3. 输错验证码 → 应提示且验证码自动刷新 ----
|
# ---- 3. 输错验证码 → 应提示且验证码自动刷新 ----
|
||||||
@ -121,38 +80,38 @@ def run(page: Page) -> None:
|
|||||||
page.wait_for_timeout(1500)
|
page.wait_for_timeout(1500)
|
||||||
|
|
||||||
if "/login" not in page.url:
|
if "/login" not in page.url:
|
||||||
_fail("失败处理", f"登录失败后不应离开登录页,当前 URL={page.url}")
|
fail("失败处理", f"登录失败后不应离开登录页,当前 URL={page.url}")
|
||||||
second_id = captured_captcha_ids[-1]
|
second_id = captured_captcha_ids[-1]
|
||||||
if second_id == first_id:
|
if second_id == first_id:
|
||||||
_fail("验证码刷新", "登录失败后验证码未刷新 —— 第二次提交必然报「验证码错误」")
|
fail("验证码刷新", "登录失败后验证码未刷新 —— 第二次提交必然报「验证码错误」")
|
||||||
print("✓ 3. 失败自动刷新 验证码已换新(GETDEL 消费后的必要处理)")
|
print("✓ 3. 失败自动刷新 验证码已换新(GETDEL 消费后的必要处理)")
|
||||||
|
|
||||||
# ---- 4. 正确登录 ----
|
# ---- 4. 正确登录 ----
|
||||||
answer = redis_get(f"ys:captcha:{second_id}")
|
answer = redis_get(f"ys:captcha:{second_id}")
|
||||||
if not answer:
|
if not answer:
|
||||||
_fail("取验证码", f"Redis 中未找到 ys:captcha:{second_id}")
|
fail("取验证码", f"Redis 中未找到 ys:captcha:{second_id}")
|
||||||
page.fill('input[autocomplete="off"]', answer)
|
page.fill('input[autocomplete="off"]', answer)
|
||||||
page.click("button:has-text('登 录')")
|
page.click("button:has-text('登 录')")
|
||||||
page.wait_for_timeout(2000)
|
page.wait_for_timeout(2000)
|
||||||
|
|
||||||
if not at_dashboard():
|
if current_path(page) != "/dashboard":
|
||||||
detail = f"仍停留在 {page.url}"
|
detail = f"仍停留在 {page.url}"
|
||||||
if message := toast_text():
|
if message := toast_text(page):
|
||||||
detail += f",提示:{message}"
|
detail += f",提示:{message}"
|
||||||
if console_errors:
|
if console_errors:
|
||||||
detail += f",控制台错误:{console_errors[:3]}"
|
detail += f",控制台错误:{console_errors[:3]}"
|
||||||
_fail("登录", detail)
|
fail("登录", detail)
|
||||||
page.wait_for_load_state("networkidle")
|
page.wait_for_load_state("networkidle")
|
||||||
print(f"✓ 4. 登录成功 跳转到 {page.url}")
|
print(f"✓ 4. 登录成功 跳转到 {page.url}")
|
||||||
|
|
||||||
if USERNAME not in page.content():
|
if USERNAME not in page.content():
|
||||||
_fail("用户信息", "首页未显示当前用户名")
|
fail("用户信息", "首页未显示当前用户名")
|
||||||
print("✓ 5. 用户信息 首页已展示当前账号")
|
print("✓ 5. 用户信息 首页已展示当前账号")
|
||||||
|
|
||||||
# ---- 6. 刷新页面 → 守卫应调用 /me 恢复会话 ----
|
# ---- 6. 刷新页面 → 守卫应调用 /me 恢复会话 ----
|
||||||
page.reload(wait_until="networkidle")
|
page.reload(wait_until="networkidle")
|
||||||
if "/dashboard" not in page.url:
|
if current_path(page) != "/dashboard":
|
||||||
_fail("会话恢复", f"刷新后被踢回登录页,当前 URL={page.url}")
|
fail("会话恢复", f"刷新后被踢回登录页,当前 URL={page.url}")
|
||||||
print("✓ 6. 会话恢复 刷新页面后仍保持登录(/me 恢复成功)")
|
print("✓ 6. 会话恢复 刷新页面后仍保持登录(/me 恢复成功)")
|
||||||
|
|
||||||
# ---- 7. 登出 → 令牌立即失效 ----
|
# ---- 7. 登出 → 令牌立即失效 ----
|
||||||
@ -162,8 +121,8 @@ def run(page: Page) -> None:
|
|||||||
|
|
||||||
page.goto(f"{BASE_URL}/dashboard", wait_until="networkidle")
|
page.goto(f"{BASE_URL}/dashboard", wait_until="networkidle")
|
||||||
page.wait_for_timeout(1000)
|
page.wait_for_timeout(1000)
|
||||||
if at_dashboard():
|
if current_path(page) == "/dashboard":
|
||||||
_fail("登出失效", "登出后仍能访问受保护页面")
|
fail("登出失效", "登出后仍能访问受保护页面")
|
||||||
print("✓ 8. 令牌失效 登出后直接访问 /dashboard 被拦回登录页")
|
print("✓ 8. 令牌失效 登出后直接访问 /dashboard 被拦回登录页")
|
||||||
|
|
||||||
|
|
||||||
@ -174,9 +133,14 @@ def main() -> int:
|
|||||||
page.set_default_timeout(10_000)
|
page.set_default_timeout(10_000)
|
||||||
try:
|
try:
|
||||||
run(page)
|
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 — 测试脚本需要打印完整失败原因
|
except Exception as exc: # noqa: BLE001 — 测试脚本需要打印完整失败原因
|
||||||
page.screenshot(path="e2e/failure.png", full_page=True)
|
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")
|
print(" 已保存截图 e2e/failure.png")
|
||||||
return 1
|
return 1
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
401
frontend/e2e/system_management.py
Normal file
401
frontend/e2e/system_management.py
Normal file
@ -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())
|
||||||
78
frontend/src/api/logs.ts
Normal file
78
frontend/src/api/logs.ts
Normal file
@ -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<PageData<OperationLogItem>> {
|
||||||
|
return request({ url: '/v1/system/operation-logs', method: 'get', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 操作日志详情(含已脱敏的请求参数,展开行时按需加载) */
|
||||||
|
export function getOperationLog(logId: number): Promise<OperationLogDetail> {
|
||||||
|
return request({ url: `/v1/system/operation-logs/${logId}`, method: 'get' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页查询登录日志 */
|
||||||
|
export function listLoginLogs(params: LoginLogQueryParams): Promise<PageData<LoginLogItem>> {
|
||||||
|
return request({ url: '/v1/system/login-logs', method: 'get', params })
|
||||||
|
}
|
||||||
54
frontend/src/api/roles.ts
Normal file
54
frontend/src/api/roles.ts
Normal file
@ -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<PageData<RoleItem>> {
|
||||||
|
return request({ url: '/v1/system/roles', method: 'get', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增角色 */
|
||||||
|
export function createRole(data: RoleCreateParams): Promise<RoleItem> {
|
||||||
|
return request({ url: '/v1/system/roles', method: 'post', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑角色 */
|
||||||
|
export function updateRole(roleId: number, data: RoleUpdateParams): Promise<RoleItem> {
|
||||||
|
return request({ url: `/v1/system/roles/${roleId}`, method: 'put', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启用 / 禁用角色(内置 admin 不可停用) */
|
||||||
|
export function setRoleStatus(roleId: number, status: number): Promise<RoleItem> {
|
||||||
|
return request({ url: `/v1/system/roles/${roleId}/status`, method: 'put', data: { status } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除角色(内置 admin 与仍有绑定用户的角色受后端保护) */
|
||||||
|
export function deleteRole(roleId: number): Promise<null> {
|
||||||
|
return request({ url: `/v1/system/roles/${roleId}`, method: 'delete' })
|
||||||
|
}
|
||||||
25
frontend/src/api/types.ts
Normal file
25
frontend/src/api/types.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
/** 请求/响应公共类型。 */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页响应 —— 与后端 `PageResult` 的序列化字段一致。
|
||||||
|
*
|
||||||
|
* 分页字段是驼峰(list/total/pageNum/pageSize,规范约定),而业务筛选
|
||||||
|
* 参数是蛇形(username、real_name…,与后端 Query 模型一致),
|
||||||
|
* 这是既有契约,不要"顺手统一"。
|
||||||
|
*/
|
||||||
|
export interface PageData<T> {
|
||||||
|
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
|
||||||
|
}
|
||||||
86
frontend/src/api/users.ts
Normal file
86
frontend/src/api/users.ts
Normal file
@ -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<PageData<UserItem>> {
|
||||||
|
return request({ url: '/v1/system/users', method: 'get', params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增用户 */
|
||||||
|
export function createUser(data: UserCreateParams): Promise<UserItem> {
|
||||||
|
return request({ url: '/v1/system/users', method: 'post', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑用户 */
|
||||||
|
export function updateUser(userId: number, data: UserUpdateParams): Promise<UserItem> {
|
||||||
|
return request({ url: `/v1/system/users/${userId}`, method: 'put', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除用户(物理删除,建议优先用禁用) */
|
||||||
|
export function deleteUser(userId: number): Promise<null> {
|
||||||
|
return request({ url: `/v1/system/users/${userId}`, method: 'delete' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置密码(该用户已签发的令牌立即失效,需重新登录) */
|
||||||
|
export function resetPassword(userId: number, newPassword: string): Promise<null> {
|
||||||
|
return request({
|
||||||
|
url: `/v1/system/users/${userId}/password`,
|
||||||
|
method: 'put',
|
||||||
|
data: { new_password: newPassword },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启用 / 禁用 */
|
||||||
|
export function setUserStatus(userId: number, status: number): Promise<UserItem> {
|
||||||
|
return request({ url: `/v1/system/users/${userId}/status`, method: 'put', data: { status } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 角色下拉选项(仅启用角色) */
|
||||||
|
export function listRoleOptions(): Promise<RoleOption[]> {
|
||||||
|
return request({ url: '/v1/system/users/meta/roles', method: 'get' })
|
||||||
|
}
|
||||||
@ -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;
|
box-sizing: border-box;
|
||||||
@ -20,3 +50,12 @@ body {
|
|||||||
background-color: #f5f7fa;
|
background-color: #f5f7fa;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 系统管理列表页共用的版式(原在三个页面 scoped 中各自重复) */
|
||||||
|
.filter-form {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|||||||
49
frontend/src/components/layout/AppMenu.vue
Normal file
49
frontend/src/components/layout/AppMenu.vue
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
import { useAppMenu } from '@/composables/useAppMenu'
|
||||||
|
|
||||||
|
/** 侧边导航菜单:桌面端 aside 与移动端抽屉复用同一份(路由派生)数据。 */
|
||||||
|
const route = useRoute()
|
||||||
|
const { topLevelMenus, groupedMenus } = useAppMenu()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-menu class="app-menu" :default-active="route.path" router>
|
||||||
|
<el-menu-item v-for="item in topLevelMenus" :key="item.path" :index="item.path">
|
||||||
|
{{ item.title }}
|
||||||
|
</el-menu-item>
|
||||||
|
<el-sub-menu v-for="group in groupedMenus" :key="group.name" :index="group.name">
|
||||||
|
<template #title>{{ group.name }}</template>
|
||||||
|
<el-menu-item v-for="item in group.items" :key="item.path" :index="item.path">
|
||||||
|
{{ item.title }}
|
||||||
|
</el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
|
</el-menu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/*
|
||||||
|
* 深色底上的菜单:覆盖 Element Plus 的主题变量而非直接写颜色 ——
|
||||||
|
* 后者与组件库规则同权重,受样式注入顺序影响而不稳定。
|
||||||
|
* 激活色用亮金(深色底专用档,见 index.css 的金色两档约定)。
|
||||||
|
*/
|
||||||
|
.app-menu {
|
||||||
|
--el-menu-bg-color: transparent;
|
||||||
|
--el-menu-text-color: rgb(250 250 247 / 78%);
|
||||||
|
--el-menu-active-color: var(--ys-gold-bright);
|
||||||
|
--el-menu-hover-bg-color: rgb(255 255 255 / 6%);
|
||||||
|
--el-menu-item-height: 44px;
|
||||||
|
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu :deep(.el-menu-item.is-active) {
|
||||||
|
background-color: rgb(201 162 39 / 12%);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu :deep(.el-sub-menu.is-active > .el-sub-menu__title) {
|
||||||
|
color: var(--ys-gold-bright);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
219
frontend/src/components/layout/BasicLayout.vue
Normal file
219
frontend/src/components/layout/BasicLayout.vue
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
import AppMenu from '@/components/layout/AppMenu.vue'
|
||||||
|
import { useUserStore } from '@/store/user'
|
||||||
|
|
||||||
|
/** 基础布局:左侧品牌导航(窄屏收为抽屉)+ 顶栏(面包屑/用户/退出)+ 内容区。 */
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const drawerVisible = ref(false)
|
||||||
|
|
||||||
|
/** 面包屑:首页 + 分组(如有)+ 当前页标题(首页自身不重复) */
|
||||||
|
const breadcrumbs = computed<string[]>(() => {
|
||||||
|
const items: string[] = []
|
||||||
|
if (route.meta.group) items.push(route.meta.group)
|
||||||
|
if (route.meta.title && route.path !== '/dashboard') items.push(route.meta.title)
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
// 移动端在抽屉里点完菜单即收起(路由变化意味着导航已发生)
|
||||||
|
watch(
|
||||||
|
() => route.path,
|
||||||
|
() => {
|
||||||
|
drawerVisible.value = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async function handleLogout(): Promise<void> {
|
||||||
|
await userStore.logout()
|
||||||
|
ElMessage.success('已退出登录')
|
||||||
|
await router.replace('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-container class="app-layout">
|
||||||
|
<el-aside class="app-aside" width="220px">
|
||||||
|
<div class="app-brand">
|
||||||
|
<span class="app-brand__name">衍水集团</span>
|
||||||
|
<span class="app-brand__sub">营销人员工资管理系统</span>
|
||||||
|
</div>
|
||||||
|
<AppMenu class="app-aside__menu" />
|
||||||
|
</el-aside>
|
||||||
|
|
||||||
|
<el-container class="app-body">
|
||||||
|
<el-header class="app-header">
|
||||||
|
<div class="app-header__left">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="app-header__menu-toggle"
|
||||||
|
aria-label="打开导航菜单"
|
||||||
|
@click="drawerVisible = true"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M3 5h14M3 10h14M3 15h14"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.6"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<el-breadcrumb separator="/">
|
||||||
|
<el-breadcrumb-item :to="{ path: '/dashboard' }">首页</el-breadcrumb-item>
|
||||||
|
<el-breadcrumb-item v-for="item in breadcrumbs" :key="item">
|
||||||
|
{{ item }}
|
||||||
|
</el-breadcrumb-item>
|
||||||
|
</el-breadcrumb>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="app-header__user">
|
||||||
|
<el-tag v-for="role in userStore.roles" :key="role" size="small" type="info">
|
||||||
|
{{ role }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="app-header__username">{{ userStore.displayName }}</span>
|
||||||
|
<el-button link type="primary" @click="handleLogout">退出登录</el-button>
|
||||||
|
</div>
|
||||||
|
</el-header>
|
||||||
|
|
||||||
|
<el-main class="app-main">
|
||||||
|
<router-view />
|
||||||
|
</el-main>
|
||||||
|
</el-container>
|
||||||
|
|
||||||
|
<el-drawer
|
||||||
|
v-model="drawerVisible"
|
||||||
|
class="app-drawer"
|
||||||
|
direction="ltr"
|
||||||
|
size="240px"
|
||||||
|
:with-header="false"
|
||||||
|
>
|
||||||
|
<div class="app-brand">
|
||||||
|
<span class="app-brand__name">衍水集团</span>
|
||||||
|
<span class="app-brand__sub">营销人员工资管理系统</span>
|
||||||
|
</div>
|
||||||
|
<AppMenu />
|
||||||
|
</el-drawer>
|
||||||
|
</el-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-layout {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-aside {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: linear-gradient(180deg, var(--ys-ink-900) 0%, var(--ys-ink-700) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-brand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 18px 20px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-brand__name {
|
||||||
|
font-family: 'Songti SC', SimSun, serif;
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: var(--ys-paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-brand__sub {
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: rgb(250 250 247 / 55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-aside__menu {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 56px;
|
||||||
|
background-color: #fff;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 窄屏才出现的汉堡按钮;不引入图标库,用内联 SVG */
|
||||||
|
.app-header__menu-toggle {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--ys-text);
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__menu-toggle:hover {
|
||||||
|
background-color: #f2f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__menu-toggle:focus-visible {
|
||||||
|
outline: 2px solid var(--ys-gold-deep);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__username {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ys-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.app-aside {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__menu-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.app-header__menu-toggle {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* 抽屉内容铺满深色底(非 scoped:目标元素由 el-drawer 渲染在组件根之外) */
|
||||||
|
.app-drawer .el-drawer__body {
|
||||||
|
padding: 0;
|
||||||
|
background: linear-gradient(180deg, var(--ys-ink-900) 0%, var(--ys-ink-700) 100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
36
frontend/src/components/table/AppPagination.vue
Normal file
36
frontend/src/components/table/AppPagination.vue
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 分页条:统一四个列表页的布局与页码尺寸选项。
|
||||||
|
* 与 `usePagedList` 的分页字段(pageNum/pageSize/total)配套使用。
|
||||||
|
*/
|
||||||
|
defineProps<{
|
||||||
|
total: number
|
||||||
|
pageNum: number
|
||||||
|
pageSize: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'page-change', page: number): void
|
||||||
|
(e: 'size-change', size: number): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-pagination
|
||||||
|
class="app-pager"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
:total="total"
|
||||||
|
:current-page="pageNum"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
@current-change="$emit('page-change', $event)"
|
||||||
|
@size-change="$emit('size-change', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-pager {
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
29
frontend/src/components/table/StatusTag.vue
Normal file
29
frontend/src/components/table/StatusTag.vue
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 状态标签:0/1 语义的通用展示。
|
||||||
|
*
|
||||||
|
* 用户状态、角色状态、操作结果、登录结果四处共用,故提取;
|
||||||
|
* 语义文字由调用方通过 props 定制(如同一组件展示「启用/禁用」与「成功/失败」)。
|
||||||
|
*/
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
value: number
|
||||||
|
activeText?: string
|
||||||
|
inactiveText?: string
|
||||||
|
activeType?: 'success' | 'warning' | 'danger' | 'info' | 'primary'
|
||||||
|
inactiveType?: 'success' | 'warning' | 'danger' | 'info' | 'primary'
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
activeText: '启用',
|
||||||
|
inactiveText: '禁用',
|
||||||
|
activeType: 'success',
|
||||||
|
inactiveType: 'info',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-tag :type="value === 1 ? activeType : inactiveType" size="small" disable-transitions>
|
||||||
|
{{ value === 1 ? activeText : inactiveText }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
65
frontend/src/composables/useAppMenu.ts
Normal file
65
frontend/src/composables/useAppMenu.ts
Normal file
@ -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<MenuNode[]>(() =>
|
||||||
|
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<MenuGroup[]>(() => {
|
||||||
|
const groups = new Map<string, MenuNode[]>()
|
||||||
|
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 }
|
||||||
|
}
|
||||||
52
frontend/src/composables/useConfirmAction.ts
Normal file
52
frontend/src/composables/useConfirmAction.ts
Normal file
@ -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<unknown>
|
||||||
|
/** 成功后的收尾(如刷新列表) */
|
||||||
|
after?: () => Promise<void> | void
|
||||||
|
}): Promise<boolean> {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
86
frontend/src/composables/usePagedList.ts
Normal file
86
frontend/src/composables/usePagedList.ts
Normal file
@ -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<T, F extends object>(
|
||||||
|
fetcher: (params: F & { pageNum: number; pageSize: number }) => Promise<PageData<T>>,
|
||||||
|
getFilters: () => F,
|
||||||
|
) {
|
||||||
|
const records = ref([]) as Ref<T[]>
|
||||||
|
const total = ref(0)
|
||||||
|
const pageNum = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
/** 自增请求序号:仅最新请求的响应可写入状态 */
|
||||||
|
let requestSeq = 0
|
||||||
|
|
||||||
|
/** 按当前页码与筛选条件加载 */
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,20 +1,64 @@
|
|||||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
import { useUserStore } from '@/store/user'
|
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[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{ path: '/', redirect: '/dashboard' },
|
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
name: 'login',
|
name: 'login',
|
||||||
component: () => import('@/views/login/index.vue'),
|
component: () => import('@/views/login/index.vue'),
|
||||||
meta: { public: true, title: '登录' },
|
meta: { public: true, title: '登录', menu: false },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/dashboard',
|
// 应用主框架:菜单、顶栏、面包屑由布局提供,业务页面作为子路由渲染
|
||||||
name: 'dashboard',
|
path: '/',
|
||||||
component: () => import('@/views/dashboard/index.vue'),
|
component: () => import('@/components/layout/BasicLayout.vue'),
|
||||||
meta: { title: '首页' },
|
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' },
|
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
|
||||||
@ -28,11 +72,13 @@ const router = createRouter({
|
|||||||
/**
|
/**
|
||||||
* 全局前置守卫。
|
* 全局前置守卫。
|
||||||
*
|
*
|
||||||
* 三层判断:
|
* 四层判断:
|
||||||
* 1. `meta.public` 标记的页面(登录页)直接放行
|
* 1. `meta.public` 标记的页面(登录页)直接放行
|
||||||
* 2. 无令牌 → 跳登录页,并把原目标记在 query 里供登录后回跳
|
* 2. 无令牌 → 跳登录页,并把原目标记在 query 里供登录后回跳
|
||||||
* 3. 有令牌但内存中无用户信息(说明是刷新页面)→ 调 /me 恢复会话;
|
* 3. 有令牌但内存中无用户信息(说明是刷新页面)→ 调 /me 恢复会话;
|
||||||
* 失败则视为令牌已失效,清状态并跳登录页
|
* 失败则视为令牌已失效,清状态并跳登录页
|
||||||
|
* 4. `meta.requiresAdmin` 的页面要求管理员角色 —— 此时用户信息已就绪,
|
||||||
|
* 这是"隐藏菜单"之外的第二道防线(防手输 URL)
|
||||||
*/
|
*/
|
||||||
router.beforeEach(async (to) => {
|
router.beforeEach(async (to) => {
|
||||||
const userStore = useUserStore()
|
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
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
23
frontend/src/utils/constants.ts
Normal file
23
frontend/src/utils/constants.ts
Normal file
@ -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
|
||||||
13
frontend/src/utils/error.ts
Normal file
13
frontend/src/utils/error.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
/** 异常处理工具。 */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显式声明「异常已按约定处理」。
|
||||||
|
*
|
||||||
|
* 本项目请求失败的提示统一由 axios 拦截器完成,页面 catch 块只需阻止
|
||||||
|
* Promise 继续传播;但**空 catch 会掩盖非预期异常**(如本地 TypeError),
|
||||||
|
* 也违反「禁空 catch」规范,故以本函数显式标注意图并留痕。
|
||||||
|
* 用 debug 级别:既不污染 e2e 收集的 error 级控制台信息,也便于排查时开启。
|
||||||
|
*/
|
||||||
|
export function handledError(error: unknown): void {
|
||||||
|
console.debug('[handled]', error)
|
||||||
|
}
|
||||||
12
frontend/src/utils/format.ts
Normal file
12
frontend/src/utils/format.ts
Normal file
@ -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)
|
||||||
|
}
|
||||||
38
frontend/src/utils/validate.ts
Normal file
38
frontend/src/utils/validate.ts
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,98 +1,49 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
|
import { formatDateTime } from '@/utils/format'
|
||||||
|
|
||||||
const router = useRouter()
|
/**
|
||||||
|
* 首页:登录链路验证页。
|
||||||
|
*
|
||||||
|
* 顶部栏(用户信息、退出登录)已由 BasicLayout 提供,本页只保留内容区;
|
||||||
|
* 用户名等字段的展示是端到端脚本的断言对象,调整时需同步 e2e。
|
||||||
|
*/
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
async function handleLogout(): Promise<void> {
|
|
||||||
await userStore.logout()
|
|
||||||
ElMessage.success('已退出登录')
|
|
||||||
await router.replace('/login')
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-container class="layout">
|
<el-card shadow="never">
|
||||||
<el-header class="layout-header">
|
<template #header>
|
||||||
<span class="layout-title">衍水集团营销人员工资管理系统</span>
|
<span>登录状态验证通过</span>
|
||||||
<div class="layout-user">
|
</template>
|
||||||
<el-tag v-for="role in userStore.roles" :key="role" size="small" type="info">
|
|
||||||
{{ role }}
|
|
||||||
</el-tag>
|
|
||||||
<span class="layout-username">{{ userStore.displayName }}</span>
|
|
||||||
<el-button link type="primary" @click="handleLogout">退出登录</el-button>
|
|
||||||
</div>
|
|
||||||
</el-header>
|
|
||||||
|
|
||||||
<el-main class="layout-main">
|
<el-descriptions :column="2" border>
|
||||||
<el-card shadow="never">
|
<el-descriptions-item label="用户 ID">{{ userStore.userInfo?.id }}</el-descriptions-item>
|
||||||
<template #header>
|
<el-descriptions-item label="用户名">{{ userStore.userInfo?.username }}</el-descriptions-item>
|
||||||
<span>登录状态验证通过</span>
|
<el-descriptions-item label="姓名">
|
||||||
</template>
|
{{ userStore.userInfo?.real_name || '—' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="角色">
|
||||||
|
{{ userStore.roles.join('、') || '—' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="上次登录">
|
||||||
|
{{ formatDateTime(userStore.userInfo?.last_login_time) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
<el-descriptions :column="2" border>
|
<el-alert
|
||||||
<el-descriptions-item label="用户 ID">{{ userStore.userInfo?.id }}</el-descriptions-item>
|
class="dashboard-hint"
|
||||||
<el-descriptions-item label="用户名">{{ userStore.userInfo?.username }}</el-descriptions-item>
|
type="info"
|
||||||
<el-descriptions-item label="姓名">
|
:closable="false"
|
||||||
{{ userStore.userInfo?.real_name || '—' }}
|
show-icon
|
||||||
</el-descriptions-item>
|
title="这是登录链路的验证页"
|
||||||
<el-descriptions-item label="角色">
|
description="刷新页面可验证会话恢复(守卫会调用 /me);「系统管理」菜单已接入用户管理、角色管理与日志查询。"
|
||||||
{{ userStore.roles.join('、') || '—' }}
|
/>
|
||||||
</el-descriptions-item>
|
</el-card>
|
||||||
<el-descriptions-item label="上次登录">
|
|
||||||
{{ userStore.userInfo?.last_login_time || '—' }}
|
|
||||||
</el-descriptions-item>
|
|
||||||
</el-descriptions>
|
|
||||||
|
|
||||||
<el-alert
|
|
||||||
class="layout-hint"
|
|
||||||
type="info"
|
|
||||||
:closable="false"
|
|
||||||
show-icon
|
|
||||||
title="这是登录链路的验证页"
|
|
||||||
description="刷新页面可验证会话恢复(守卫会调用 /me);点击右上角退出登录可验证令牌即时失效。业务模块页面将在后续阶段接入。"
|
|
||||||
/>
|
|
||||||
</el-card>
|
|
||||||
</el-main>
|
|
||||||
</el-container>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.layout {
|
.dashboard-hint {
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
color: #fff;
|
|
||||||
background-color: #1f4e79;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-title {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-user {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-username {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-main {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-hint {
|
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -182,19 +182,19 @@ onMounted(refreshCaptcha)
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/*
|
/*
|
||||||
* 配色说明:金色分两档 —— 亮金只用于深色底、深金只用于浅色底,
|
* 配色:品牌 token 已提升为全局(见 assets/styles/index.css,布局框架与
|
||||||
* 保证两处对比度都达标(深金配白字约 6.2:1)。
|
* 系统管理页面共用),此处只做局部别名,用法与取值均与原先一致。
|
||||||
*/
|
*/
|
||||||
.login {
|
.login {
|
||||||
--ink-900: #061a22;
|
--ink-900: var(--ys-ink-900);
|
||||||
--ink-800: #0a2833;
|
--ink-800: var(--ys-ink-800);
|
||||||
--ink-700: #0f3646;
|
--ink-700: var(--ys-ink-700);
|
||||||
--ink-600: #1a4f63;
|
--ink-600: var(--ys-ink-600);
|
||||||
--gold-bright: #c9a227;
|
--gold-bright: var(--ys-gold-bright);
|
||||||
--gold-deep: #8f6520;
|
--gold-deep: var(--ys-gold-deep);
|
||||||
--paper: #fafaf7;
|
--paper: var(--ys-paper);
|
||||||
--text: #16262d;
|
--text: var(--ys-text);
|
||||||
--muted: #6b7b84;
|
--muted: var(--ys-muted);
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
|||||||
393
frontend/src/views/system/logs/index.vue
Normal file
393
frontend/src/views/system/logs/index.vue
Normal file
@ -0,0 +1,393 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import * as logApi from '@/api/logs'
|
||||||
|
import type {
|
||||||
|
LoginLogItem,
|
||||||
|
LoginLogQueryParams,
|
||||||
|
OperationLogDetail,
|
||||||
|
OperationLogItem,
|
||||||
|
OperationLogQueryParams,
|
||||||
|
} from '@/api/logs'
|
||||||
|
import StatusTag from '@/components/table/StatusTag.vue'
|
||||||
|
import AppPagination from '@/components/table/AppPagination.vue'
|
||||||
|
import { usePagedList } from '@/composables/usePagedList'
|
||||||
|
import { ACTION_OPTIONS, MODULE_OPTIONS, RESULT_OPTIONS } from '@/utils/constants'
|
||||||
|
import { handledError } from '@/utils/error'
|
||||||
|
import { formatDateTime } from '@/utils/format'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志查询(需求 2.5.1「支持日志查询」)。
|
||||||
|
*
|
||||||
|
* 操作日志与登录日志分开两个 tab(后端也是两个端点:字段与筛选项不同)。
|
||||||
|
*
|
||||||
|
* 时间筛选的两个关键点:
|
||||||
|
* - 必须给 el-date-picker 指定 `value-format` —— 否则绑定值是 Date 对象,
|
||||||
|
* axios 会序列化为 UTC ISO 串,随本地时区产生 8 小时偏移
|
||||||
|
* - 必须配 `default-time` —— datetimerange 只选日期时时刻默认 00:00:00,
|
||||||
|
* 跨天查询会漏掉结束日整天(起点补 00:00:00、终点补 23:59:59)
|
||||||
|
*
|
||||||
|
* 操作日志列表**不含请求参数**(大字段,随列表返回会让翻页负担随日志表
|
||||||
|
* 增长);展开行时按 id 调详情端点懒加载并缓存。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** datetimerange 的起止时刻补全(与 value-format 配合) */
|
||||||
|
const RANGE_DEFAULT_TIME: [Date, Date] = [
|
||||||
|
new Date(2000, 0, 1, 0, 0, 0),
|
||||||
|
new Date(2000, 0, 1, 23, 59, 59),
|
||||||
|
]
|
||||||
|
|
||||||
|
const activeTab = ref('operation')
|
||||||
|
|
||||||
|
// ==================== 操作日志 ====================
|
||||||
|
|
||||||
|
const opFilters = reactive({
|
||||||
|
username: '',
|
||||||
|
module: undefined as string | undefined,
|
||||||
|
action: undefined as string | undefined,
|
||||||
|
success: undefined as number | undefined,
|
||||||
|
})
|
||||||
|
const opTimeRange = ref<[string, string] | null>(null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
records: opRecords,
|
||||||
|
total: opTotal,
|
||||||
|
pageNum: opPageNum,
|
||||||
|
pageSize: opPageSize,
|
||||||
|
loading: opLoading,
|
||||||
|
load: loadOperationLogs,
|
||||||
|
search: searchOperationLogs,
|
||||||
|
onPageChange: onOpPageChange,
|
||||||
|
onSizeChange: onOpSizeChange,
|
||||||
|
} = usePagedList<OperationLogItem, OperationLogQueryParams>(logApi.listOperationLogs, () => ({
|
||||||
|
username: opFilters.username,
|
||||||
|
module: opFilters.module,
|
||||||
|
action: opFilters.action,
|
||||||
|
success: opFilters.success,
|
||||||
|
start_time: opTimeRange.value?.[0],
|
||||||
|
end_time: opTimeRange.value?.[1],
|
||||||
|
}))
|
||||||
|
|
||||||
|
function resetOpFilters(): void {
|
||||||
|
opFilters.username = ''
|
||||||
|
opFilters.module = undefined
|
||||||
|
opFilters.action = undefined
|
||||||
|
opFilters.success = undefined
|
||||||
|
opTimeRange.value = null
|
||||||
|
searchOperationLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 详情缓存与加载中集合(列表无 request_data,展开时按需拉取) */
|
||||||
|
const detailMap = reactive(new Map<number, OperationLogDetail>())
|
||||||
|
const detailLoading = reactive(new Set<number>())
|
||||||
|
|
||||||
|
async function onExpandChange(row: OperationLogItem, expandedRows: OperationLogItem[]): Promise<void> {
|
||||||
|
const isExpanded = expandedRows.some((item) => item.id === row.id)
|
||||||
|
if (!isExpanded || detailMap.has(row.id)) return
|
||||||
|
detailLoading.add(row.id)
|
||||||
|
try {
|
||||||
|
detailMap.set(row.id, await logApi.getOperationLog(row.id))
|
||||||
|
} catch (error) {
|
||||||
|
handledError(error) // 拦截器已提示,展开处显示占位
|
||||||
|
} finally {
|
||||||
|
detailLoading.delete(row.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 展开行展示的请求参数:加载中 / 暂无 / 格式化后的 JSON(已脱敏) */
|
||||||
|
function detailText(logId: number): string {
|
||||||
|
if (detailLoading.has(logId)) return '加载中…'
|
||||||
|
const detail = detailMap.get(logId)
|
||||||
|
if (!detail) return '—'
|
||||||
|
return formatRequestData(detail.request_data)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRequestData(raw: string | null): string {
|
||||||
|
if (!raw) return '—'
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(raw), null, 2)
|
||||||
|
} catch {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 登录日志 ====================
|
||||||
|
|
||||||
|
const loginFilters = reactive({
|
||||||
|
username: '',
|
||||||
|
ip: '',
|
||||||
|
success: undefined as number | undefined,
|
||||||
|
})
|
||||||
|
const loginTimeRange = ref<[string, string] | null>(null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
records: loginRecords,
|
||||||
|
total: loginTotal,
|
||||||
|
pageNum: loginPageNum,
|
||||||
|
pageSize: loginPageSize,
|
||||||
|
loading: loginLoading,
|
||||||
|
load: loadLoginLogs,
|
||||||
|
search: searchLoginLogs,
|
||||||
|
onPageChange: onLoginPageChange,
|
||||||
|
onSizeChange: onLoginSizeChange,
|
||||||
|
} = usePagedList<LoginLogItem, LoginLogQueryParams>(logApi.listLoginLogs, () => ({
|
||||||
|
username: loginFilters.username,
|
||||||
|
ip: loginFilters.ip,
|
||||||
|
success: loginFilters.success,
|
||||||
|
start_time: loginTimeRange.value?.[0],
|
||||||
|
end_time: loginTimeRange.value?.[1],
|
||||||
|
}))
|
||||||
|
|
||||||
|
function resetLoginFilters(): void {
|
||||||
|
loginFilters.username = ''
|
||||||
|
loginFilters.ip = ''
|
||||||
|
loginFilters.success = undefined
|
||||||
|
loginTimeRange.value = null
|
||||||
|
searchLoginLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 初始化 ====================
|
||||||
|
|
||||||
|
onMounted(loadOperationLogs)
|
||||||
|
|
||||||
|
// 登录日志 tab 每次激活都刷新(首屏不打多余请求;此前"只加载一次"的
|
||||||
|
// 标志在首次失败后不会重置,切回时停在空表且无从自查)
|
||||||
|
watch(activeTab, (tab) => {
|
||||||
|
if (tab === 'login') {
|
||||||
|
void loadLoginLogs()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-card shadow="never">
|
||||||
|
<el-tabs v-model="activeTab">
|
||||||
|
<!-- 操作日志 -->
|
||||||
|
<el-tab-pane label="操作日志" name="operation" lazy>
|
||||||
|
<el-form class="filter-form" :inline="true" @submit.prevent>
|
||||||
|
<el-form-item label="操作人">
|
||||||
|
<el-input
|
||||||
|
v-model="opFilters.username"
|
||||||
|
placeholder="模糊匹配"
|
||||||
|
clearable
|
||||||
|
style="width: 140px"
|
||||||
|
@keyup.enter="searchOperationLogs"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="模块">
|
||||||
|
<el-select v-model="opFilters.module" placeholder="全部" clearable style="width: 130px">
|
||||||
|
<el-option v-for="item in MODULE_OPTIONS" :key="item" :label="item" :value="item" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="操作">
|
||||||
|
<el-select v-model="opFilters.action" placeholder="全部" clearable style="width: 120px">
|
||||||
|
<el-option v-for="item in ACTION_OPTIONS" :key="item" :label="item" :value="item" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="结果">
|
||||||
|
<el-select v-model="opFilters.success" placeholder="全部" clearable style="width: 110px">
|
||||||
|
<el-option
|
||||||
|
v-for="item in RESULT_OPTIONS"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="时间范围">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="opTimeRange"
|
||||||
|
type="datetimerange"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始时间"
|
||||||
|
end-placeholder="结束时间"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
:default-time="RANGE_DEFAULT_TIME"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="searchOperationLogs">查询</el-button>
|
||||||
|
<el-button @click="resetOpFilters">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
v-loading="opLoading"
|
||||||
|
:data="opRecords"
|
||||||
|
stripe
|
||||||
|
@expand-change="onExpandChange"
|
||||||
|
>
|
||||||
|
<el-table-column type="expand">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="log-detail">
|
||||||
|
<p>
|
||||||
|
<span class="log-detail__label">请求:</span>
|
||||||
|
{{ row.method || '—' }} {{ row.path || '—' }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span class="log-detail__label">User-Agent:</span>
|
||||||
|
{{ row.user_agent || '—' }}
|
||||||
|
</p>
|
||||||
|
<p class="log-detail__label">请求参数(已脱敏):</p>
|
||||||
|
<pre class="log-detail__data">{{ detailText(row.id) }}</pre>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="时间" min-width="165">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="username" label="操作人" min-width="110" />
|
||||||
|
<el-table-column prop="module" label="模块" width="100" />
|
||||||
|
<el-table-column prop="action" label="操作" width="90" />
|
||||||
|
<el-table-column prop="summary" label="摘要" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column label="结果" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip
|
||||||
|
:content="row.error_message ?? ''"
|
||||||
|
:disabled="row.success !== 0 || !row.error_message"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<StatusTag
|
||||||
|
:value="row.success"
|
||||||
|
active-text="成功"
|
||||||
|
inactive-text="失败"
|
||||||
|
inactive-type="danger"
|
||||||
|
/>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="耗时(ms)" width="90" align="right">
|
||||||
|
<template #default="{ row }">{{ row.duration_ms ?? '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="ip" label="IP" min-width="120">
|
||||||
|
<template #default="{ row }">{{ row.ip || '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<AppPagination
|
||||||
|
:total="opTotal"
|
||||||
|
:page-num="opPageNum"
|
||||||
|
:page-size="opPageSize"
|
||||||
|
@page-change="onOpPageChange"
|
||||||
|
@size-change="onOpSizeChange"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- 登录日志 -->
|
||||||
|
<el-tab-pane label="登录日志" name="login" lazy>
|
||||||
|
<el-form class="filter-form" :inline="true" @submit.prevent>
|
||||||
|
<el-form-item label="用户名">
|
||||||
|
<el-input
|
||||||
|
v-model="loginFilters.username"
|
||||||
|
placeholder="模糊匹配"
|
||||||
|
clearable
|
||||||
|
style="width: 160px"
|
||||||
|
@keyup.enter="searchLoginLogs"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="IP">
|
||||||
|
<el-input
|
||||||
|
v-model="loginFilters.ip"
|
||||||
|
placeholder="精确匹配"
|
||||||
|
clearable
|
||||||
|
style="width: 150px"
|
||||||
|
@keyup.enter="searchLoginLogs"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="结果">
|
||||||
|
<el-select v-model="loginFilters.success" placeholder="全部" clearable style="width: 110px">
|
||||||
|
<el-option
|
||||||
|
v-for="item in RESULT_OPTIONS"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="时间范围">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="loginTimeRange"
|
||||||
|
type="datetimerange"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始时间"
|
||||||
|
end-placeholder="结束时间"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
:default-time="RANGE_DEFAULT_TIME"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="searchLoginLogs">查询</el-button>
|
||||||
|
<el-button @click="resetLoginFilters">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-table v-loading="loginLoading" :data="loginRecords" stripe>
|
||||||
|
<el-table-column label="时间" min-width="165">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="username" label="用户名" min-width="130" />
|
||||||
|
<el-table-column label="结果" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<StatusTag
|
||||||
|
:value="row.success"
|
||||||
|
active-text="成功"
|
||||||
|
inactive-text="失败"
|
||||||
|
inactive-type="danger"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="fail_reason" label="失败原因" min-width="150">
|
||||||
|
<template #default="{ row }">{{ row.fail_reason || '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="ip" label="IP" min-width="120">
|
||||||
|
<template #default="{ row }">{{ row.ip || '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="User-Agent" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="ua-cell" :title="row.user_agent || ''">{{ row.user_agent || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<AppPagination
|
||||||
|
:total="loginTotal"
|
||||||
|
:page-num="loginPageNum"
|
||||||
|
:page-size="loginPageSize"
|
||||||
|
@page-change="onLoginPageChange"
|
||||||
|
@size-change="onLoginSizeChange"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.log-detail {
|
||||||
|
padding: 4px 16px;
|
||||||
|
color: var(--ys-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-detail__label {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-detail__data {
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: 6px 0 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-family: Consolas, 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
background-color: var(--ys-paper);
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ua-cell {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
305
frontend/src/views/system/roles/index.vue
Normal file
305
frontend/src/views/system/roles/index.vue
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
|
||||||
|
import * as roleApi from '@/api/roles'
|
||||||
|
import type { RoleItem, RoleQueryParams } from '@/api/roles'
|
||||||
|
import AppPagination from '@/components/table/AppPagination.vue'
|
||||||
|
import StatusTag from '@/components/table/StatusTag.vue'
|
||||||
|
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||||
|
import { usePagedList } from '@/composables/usePagedList'
|
||||||
|
import { ROLE_ADMIN, STATUS_OPTIONS } from '@/utils/constants'
|
||||||
|
import { handledError } from '@/utils/error'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色管理(轻量版:不含权限点分配,权限清单待业务方给出后启用)。
|
||||||
|
*
|
||||||
|
* 与后端的保护规则对齐(后端是唯一防线,此处禁用按钮只是体验):
|
||||||
|
* - 内置 admin 角色不可删除、不可禁用
|
||||||
|
* - 仍有绑定用户的角色不可删除(`user_roles` 外键是 CASCADE,
|
||||||
|
* 直接删会静默剥离这些用户的权限)
|
||||||
|
* - 编码创建后不可修改(授权判定的锚点)
|
||||||
|
* - 禁用角色不再出现在用户表单下拉中,且**不可再分配给用户**;
|
||||||
|
* 已绑定用户的既有授权不受影响
|
||||||
|
*/
|
||||||
|
const { confirmAndRun } = useConfirmAction()
|
||||||
|
|
||||||
|
const filters = reactive({
|
||||||
|
keyword: '',
|
||||||
|
status: undefined as number | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||||
|
usePagedList<RoleItem, RoleQueryParams>(roleApi.listRoles, () => ({
|
||||||
|
keyword: filters.keyword,
|
||||||
|
status: filters.status,
|
||||||
|
}))
|
||||||
|
|
||||||
|
function resetFilters(): void {
|
||||||
|
filters.keyword = ''
|
||||||
|
filters.status = undefined
|
||||||
|
search()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内置管理员角色 */
|
||||||
|
function isBuiltin(row: RoleItem): boolean {
|
||||||
|
return row.role_code === ROLE_ADMIN
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除保护的悬停说明(禁用按钮自身不触发悬浮事件,由外层 span 提供 title) */
|
||||||
|
function deleteHint(row: RoleItem): string {
|
||||||
|
if (isBuiltin(row)) return '内置管理员角色不可删除'
|
||||||
|
if (row.user_count > 0) return `仍有 ${row.user_count} 个用户绑定该角色,请先解除绑定`
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 新增 / 编辑 ====================
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const editingId = ref<number | null>(null)
|
||||||
|
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
|
||||||
|
const isEdit = computed(() => editingId.value !== null)
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
role_code: '',
|
||||||
|
role_name: '',
|
||||||
|
description: '',
|
||||||
|
sort_order: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules = computed<FormRules>(() => ({
|
||||||
|
role_code: [
|
||||||
|
{ required: true, message: '请输入角色编码', trigger: 'blur' },
|
||||||
|
{
|
||||||
|
pattern: /^[a-z][a-z0-9_]{1,31}$/,
|
||||||
|
message: '以小写字母开头,仅可含小写字母、数字与下划线(2–32 位)',
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
role_name: [
|
||||||
|
{ required: true, message: '请输入角色名称', trigger: 'blur' },
|
||||||
|
{ max: 64, message: '最长 64 个字符', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
description: [{ max: 255, message: '最长 255 个字符', trigger: 'blur' }],
|
||||||
|
}))
|
||||||
|
|
||||||
|
function openCreate(): void {
|
||||||
|
editingId.value = null
|
||||||
|
Object.assign(form, { role_code: '', role_name: '', description: '', sort_order: 0 })
|
||||||
|
dialogVisible.value = true
|
||||||
|
formRef.value?.clearValidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: RoleItem): void {
|
||||||
|
editingId.value = row.id
|
||||||
|
Object.assign(form, {
|
||||||
|
role_code: row.role_code,
|
||||||
|
role_name: row.role_name,
|
||||||
|
description: row.description ?? '',
|
||||||
|
sort_order: row.sort_order,
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
formRef.value?.clearValidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(): Promise<void> {
|
||||||
|
const valid = await formRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
if (editingId.value !== null) {
|
||||||
|
await roleApi.updateRole(editingId.value, {
|
||||||
|
role_name: form.role_name,
|
||||||
|
description: form.description,
|
||||||
|
// el-input-number 清空后为 undefined,被 JSON 省略时后端会取默认 0,
|
||||||
|
// 排序被静默重置 —— 提交前兜底为 0 并明确表达「未填即 0」的意图
|
||||||
|
sort_order: form.sort_order ?? 0,
|
||||||
|
})
|
||||||
|
ElMessage.success('已保存')
|
||||||
|
} else {
|
||||||
|
await roleApi.createRole({
|
||||||
|
role_code: form.role_code,
|
||||||
|
role_name: form.role_name,
|
||||||
|
description: form.description || undefined,
|
||||||
|
sort_order: form.sort_order ?? 0,
|
||||||
|
})
|
||||||
|
ElMessage.success('角色已创建')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
handledError(error) // 失败提示由请求拦截器统一完成
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 启停 / 删除 ====================
|
||||||
|
|
||||||
|
async function handleToggleStatus(row: RoleItem): Promise<void> {
|
||||||
|
const next = row.status === 1 ? 0 : 1
|
||||||
|
const action = next === 1 ? '启用' : '禁用'
|
||||||
|
await confirmAndRun({
|
||||||
|
confirmText:
|
||||||
|
next === 0
|
||||||
|
? `确定禁用角色「${row.role_name}」?禁用后该角色不再出现在用户表单的下拉中且不可再分配,但已绑定用户的权限不受影响。`
|
||||||
|
: `确定启用角色「${row.role_name}」?`,
|
||||||
|
title: `${action}确认`,
|
||||||
|
confirmButtonText: action,
|
||||||
|
successText: `已${action}`,
|
||||||
|
run: () => roleApi.setRoleStatus(row.id, next),
|
||||||
|
after: load,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row: RoleItem): Promise<void> {
|
||||||
|
await confirmAndRun({
|
||||||
|
confirmText: `确定删除角色「${row.role_name}」(${row.role_code})?删除后不可恢复。`,
|
||||||
|
title: '删除确认',
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
successText: '角色已删除',
|
||||||
|
run: () => roleApi.deleteRole(row.id),
|
||||||
|
after: reloadAfterDelete,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<el-card shadow="never">
|
||||||
|
<el-form class="filter-form" :inline="true" @submit.prevent>
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input
|
||||||
|
v-model="filters.keyword"
|
||||||
|
placeholder="编码或名称,模糊匹配"
|
||||||
|
clearable
|
||||||
|
style="width: 200px"
|
||||||
|
@keyup.enter="search"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
|
||||||
|
<el-option
|
||||||
|
v-for="item in STATUS_OPTIONS"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="search">查询</el-button>
|
||||||
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button type="primary" @click="openCreate">新增角色</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="records" stripe>
|
||||||
|
<el-table-column label="编码" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="role-code">{{ row.role_code }}</span>
|
||||||
|
<el-tag v-if="isBuiltin(row)" class="builtin-tag" size="small" type="warning" disable-transitions>
|
||||||
|
内置
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="role_name" label="名称" min-width="120" />
|
||||||
|
<el-table-column prop="description" label="说明" min-width="180">
|
||||||
|
<template #default="{ row }">{{ row.description || '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="sort_order" label="排序" width="80" align="center" />
|
||||||
|
<el-table-column label="状态" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<StatusTag :value="row.status" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="用户数" width="90" align="center">
|
||||||
|
<template #default="{ row }">{{ row.user_count }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="170" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||||
|
<span :title="isBuiltin(row) ? '内置管理员角色不可禁用' : ''">
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="warning"
|
||||||
|
size="small"
|
||||||
|
:disabled="isBuiltin(row)"
|
||||||
|
@click="handleToggleStatus(row)"
|
||||||
|
>
|
||||||
|
{{ row.status === 1 ? '禁用' : '启用' }}
|
||||||
|
</el-button>
|
||||||
|
</span>
|
||||||
|
<span :title="deleteHint(row)">
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
:disabled="isBuiltin(row) || row.user_count > 0"
|
||||||
|
@click="handleDelete(row)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<AppPagination
|
||||||
|
:total="total"
|
||||||
|
:page-num="pageNum"
|
||||||
|
:page-size="pageSize"
|
||||||
|
@page-change="onPageChange"
|
||||||
|
@size-change="onSizeChange"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="isEdit ? '编辑角色' : '新增角色'"
|
||||||
|
width="480px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||||
|
<el-form-item label="角色编码" prop="role_code">
|
||||||
|
<el-input
|
||||||
|
v-model="form.role_code"
|
||||||
|
:disabled="isEdit"
|
||||||
|
placeholder="如 sales_manager,创建后不可修改"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="角色名称" prop="role_name">
|
||||||
|
<el-input v-model="form.role_name" placeholder="如 销售主管" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="说明" prop="description">
|
||||||
|
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="选填" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="排序" prop="sort_order">
|
||||||
|
<el-input-number v-model="form.sort_order" :min="0" :max="9999" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.role-code {
|
||||||
|
font-family: Consolas, 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builtin-tag {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
467
frontend/src/views/system/users/index.vue
Normal file
467
frontend/src/views/system/users/index.vue
Normal file
@ -0,0 +1,467 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
|
||||||
|
import type { RoleOption } from '@/api/types'
|
||||||
|
import * as userApi from '@/api/users'
|
||||||
|
import type { UserItem, UserQueryParams } from '@/api/users'
|
||||||
|
import AppPagination from '@/components/table/AppPagination.vue'
|
||||||
|
import StatusTag from '@/components/table/StatusTag.vue'
|
||||||
|
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||||
|
import { usePagedList } from '@/composables/usePagedList'
|
||||||
|
import { useUserStore } from '@/store/user'
|
||||||
|
import { STATUS_OPTIONS } from '@/utils/constants'
|
||||||
|
import { handledError } from '@/utils/error'
|
||||||
|
import { formatDateTime } from '@/utils/format'
|
||||||
|
import { passwordRule } from '@/utils/validate'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户管理(需求 2.5.1)。
|
||||||
|
*
|
||||||
|
* 与后端约定对齐:
|
||||||
|
* - 不能删除/禁用自己、不能移除自己的管理员角色 —— 前端对当前登录行
|
||||||
|
* 禁用相应按钮(后端仍会拦截,界面禁用只是体验)
|
||||||
|
* - 密码按字节校验(8–72),中文一个字符占 3 字节
|
||||||
|
* - 重置密码会使用户已登录的会话立即失效
|
||||||
|
* - 编辑表单把未修改字段以空串提交,后端将 '' 归一为 NULL(清空即写回
|
||||||
|
* NULL),前端无需特殊处理
|
||||||
|
*/
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const { confirmAndRun } = useConfirmAction()
|
||||||
|
|
||||||
|
// ==================== 列表 ====================
|
||||||
|
|
||||||
|
const filters = reactive({
|
||||||
|
username: '',
|
||||||
|
real_name: '',
|
||||||
|
status: undefined as number | undefined,
|
||||||
|
role_code: undefined as string | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||||
|
usePagedList<UserItem, UserQueryParams>(userApi.listUsers, () => ({
|
||||||
|
username: filters.username,
|
||||||
|
real_name: filters.real_name,
|
||||||
|
status: filters.status,
|
||||||
|
role_code: filters.role_code,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const roleOptions = ref<RoleOption[]>([])
|
||||||
|
|
||||||
|
async function loadRoleOptions(): Promise<void> {
|
||||||
|
try {
|
||||||
|
roleOptions.value = await userApi.listRoleOptions()
|
||||||
|
} catch (error) {
|
||||||
|
handledError(error) // 拦截器已提示;下拉为空不阻断列表使用
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters(): void {
|
||||||
|
filters.username = ''
|
||||||
|
filters.real_name = ''
|
||||||
|
filters.status = undefined
|
||||||
|
filters.role_code = undefined
|
||||||
|
search()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否当前登录用户本人(其危险操作按钮需禁用) */
|
||||||
|
function isSelf(row: UserItem): boolean {
|
||||||
|
return row.id === userStore.userInfo?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自我保护提示:按钮外层 span 的 title(disabled 按钮自身不触发悬浮事件) */
|
||||||
|
function selfHint(row: UserItem): string {
|
||||||
|
return isSelf(row) ? '不能对自己执行此操作' : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 新增 / 编辑 ====================
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const editingId = ref<number | null>(null)
|
||||||
|
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
|
||||||
|
const isEdit = computed(() => editingId.value !== null)
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
real_name: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
roles: [] as string[],
|
||||||
|
remark: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 编辑时不校验密码(密码走专门的重置入口) */
|
||||||
|
const rules = computed<FormRules>(() => ({
|
||||||
|
username: [
|
||||||
|
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||||
|
{ min: 2, max: 64, message: '用户名长度 2–64 个字符', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
...(isEdit.value ? {} : { password: [{ required: true, validator: passwordRule, trigger: 'blur' }] }),
|
||||||
|
roles: [
|
||||||
|
{ required: true, type: 'array', min: 1, message: '至少选择一个角色', trigger: 'change' },
|
||||||
|
],
|
||||||
|
phone: [{ max: 20, message: '手机号最长 20 个字符', trigger: 'blur' }],
|
||||||
|
email: [{ max: 128, message: '邮箱最长 128 个字符', trigger: 'blur' }],
|
||||||
|
remark: [{ max: 255, message: '备注最长 255 个字符', trigger: 'blur' }],
|
||||||
|
}))
|
||||||
|
|
||||||
|
function openCreate(): void {
|
||||||
|
editingId.value = null
|
||||||
|
Object.assign(form, {
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
real_name: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
roles: [],
|
||||||
|
remark: '',
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
formRef.value?.clearValidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: UserItem): void {
|
||||||
|
editingId.value = row.id
|
||||||
|
Object.assign(form, {
|
||||||
|
username: row.username,
|
||||||
|
password: '',
|
||||||
|
real_name: row.real_name ?? '',
|
||||||
|
phone: row.phone ?? '',
|
||||||
|
email: row.email ?? '',
|
||||||
|
roles: [...row.roles],
|
||||||
|
remark: row.remark ?? '',
|
||||||
|
})
|
||||||
|
dialogVisible.value = true
|
||||||
|
formRef.value?.clearValidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(): Promise<void> {
|
||||||
|
const valid = await formRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
if (editingId.value !== null) {
|
||||||
|
await userApi.updateUser(editingId.value, {
|
||||||
|
real_name: form.real_name,
|
||||||
|
phone: form.phone,
|
||||||
|
email: form.email,
|
||||||
|
roles: form.roles,
|
||||||
|
remark: form.remark,
|
||||||
|
})
|
||||||
|
ElMessage.success('已保存')
|
||||||
|
} else {
|
||||||
|
await userApi.createUser({
|
||||||
|
username: form.username,
|
||||||
|
password: form.password,
|
||||||
|
real_name: form.real_name || undefined,
|
||||||
|
phone: form.phone || undefined,
|
||||||
|
email: form.email || undefined,
|
||||||
|
roles: form.roles,
|
||||||
|
remark: form.remark || undefined,
|
||||||
|
})
|
||||||
|
ElMessage.success('用户已创建')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
handledError(error) // 失败提示由请求拦截器统一完成
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 重置密码 ====================
|
||||||
|
|
||||||
|
const resetVisible = ref(false)
|
||||||
|
const resetFormRef = ref<FormInstance>()
|
||||||
|
const resetSubmitting = ref(false)
|
||||||
|
const resetTarget = reactive({ id: 0, username: '', isSelf: false })
|
||||||
|
const resetForm = reactive({ newPassword: '' })
|
||||||
|
|
||||||
|
const resetRules: FormRules = {
|
||||||
|
newPassword: [{ required: true, validator: passwordRule, trigger: 'blur' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReset(row: UserItem): void {
|
||||||
|
resetTarget.id = row.id
|
||||||
|
resetTarget.username = row.username
|
||||||
|
resetTarget.isSelf = isSelf(row)
|
||||||
|
resetForm.newPassword = ''
|
||||||
|
resetVisible.value = true
|
||||||
|
resetFormRef.value?.clearValidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResetSubmit(): Promise<void> {
|
||||||
|
const valid = await resetFormRef.value?.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
resetSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await userApi.resetPassword(resetTarget.id, resetForm.newPassword)
|
||||||
|
ElMessage.success('密码已重置,该用户需重新登录')
|
||||||
|
resetVisible.value = false
|
||||||
|
} catch (error) {
|
||||||
|
handledError(error) // 拦截器已提示
|
||||||
|
} finally {
|
||||||
|
resetSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 启停 / 删除 ====================
|
||||||
|
|
||||||
|
async function handleToggleStatus(row: UserItem): Promise<void> {
|
||||||
|
const next = row.status === 1 ? 0 : 1
|
||||||
|
const action = next === 1 ? '启用' : '禁用'
|
||||||
|
await confirmAndRun({
|
||||||
|
confirmText:
|
||||||
|
next === 0
|
||||||
|
? `确定禁用用户「${row.username}」?其已登录的会话将立即失效。`
|
||||||
|
: `确定启用用户「${row.username}」?`,
|
||||||
|
title: `${action}确认`,
|
||||||
|
confirmButtonText: action,
|
||||||
|
successText: `已${action}`,
|
||||||
|
run: () => userApi.setUserStatus(row.id, next),
|
||||||
|
after: load,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row: UserItem): Promise<void> {
|
||||||
|
await confirmAndRun({
|
||||||
|
confirmText: `确定删除用户「${row.username}」?删除为物理删除、不可恢复;如仅为停用账号,建议使用「禁用」以保留审计线索。`,
|
||||||
|
title: '删除确认',
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
successText: '用户已删除',
|
||||||
|
run: () => userApi.deleteUser(row.id),
|
||||||
|
after: reloadAfterDelete,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 初始化 ====================
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 两个请求各自容错(load 内部已兜底),互不拖累
|
||||||
|
void load()
|
||||||
|
void loadRoleOptions()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<el-card shadow="never">
|
||||||
|
<el-form class="filter-form" :inline="true" @submit.prevent>
|
||||||
|
<el-form-item label="用户名">
|
||||||
|
<el-input
|
||||||
|
v-model="filters.username"
|
||||||
|
placeholder="模糊匹配"
|
||||||
|
clearable
|
||||||
|
style="width: 160px"
|
||||||
|
@keyup.enter="search"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="姓名">
|
||||||
|
<el-input
|
||||||
|
v-model="filters.real_name"
|
||||||
|
placeholder="模糊匹配"
|
||||||
|
clearable
|
||||||
|
style="width: 140px"
|
||||||
|
@keyup.enter="search"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
|
||||||
|
<el-option
|
||||||
|
v-for="item in STATUS_OPTIONS"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="角色">
|
||||||
|
<el-select v-model="filters.role_code" placeholder="全部" clearable style="width: 150px">
|
||||||
|
<el-option
|
||||||
|
v-for="role in roleOptions"
|
||||||
|
:key="role.role_code"
|
||||||
|
:label="role.role_name"
|
||||||
|
:value="role.role_code"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="search">查询</el-button>
|
||||||
|
<el-button @click="resetFilters">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button type="primary" @click="openCreate">新增用户</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="records" stripe>
|
||||||
|
<el-table-column prop="username" label="用户名" min-width="120" />
|
||||||
|
<el-table-column prop="real_name" label="姓名" min-width="100">
|
||||||
|
<template #default="{ row }">{{ row.real_name || '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="phone" label="手机号" min-width="120">
|
||||||
|
<template #default="{ row }">{{ row.phone || '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="角色" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag
|
||||||
|
v-for="role in row.roles"
|
||||||
|
:key="role"
|
||||||
|
class="role-tag"
|
||||||
|
size="small"
|
||||||
|
type="info"
|
||||||
|
disable-transitions
|
||||||
|
>
|
||||||
|
{{ role }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<StatusTag :value="row.status" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="最后登录" min-width="170">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.last_login_time) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="250" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||||
|
<el-button link type="primary" size="small" @click="openReset(row)">
|
||||||
|
重置密码
|
||||||
|
</el-button>
|
||||||
|
<span :title="selfHint(row)">
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="warning"
|
||||||
|
size="small"
|
||||||
|
:disabled="isSelf(row)"
|
||||||
|
@click="handleToggleStatus(row)"
|
||||||
|
>
|
||||||
|
{{ row.status === 1 ? '禁用' : '启用' }}
|
||||||
|
</el-button>
|
||||||
|
</span>
|
||||||
|
<span :title="selfHint(row)">
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
:disabled="isSelf(row)"
|
||||||
|
@click="handleDelete(row)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<AppPagination
|
||||||
|
:total="total"
|
||||||
|
:page-num="pageNum"
|
||||||
|
:page-size="pageSize"
|
||||||
|
@page-change="onPageChange"
|
||||||
|
@size-change="onSizeChange"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 新增 / 编辑 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="isEdit ? '编辑用户' : '新增用户'"
|
||||||
|
width="520px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||||
|
<el-form-item label="用户名" prop="username">
|
||||||
|
<el-input
|
||||||
|
v-model="form.username"
|
||||||
|
:disabled="isEdit"
|
||||||
|
placeholder="登录用户名,创建后不可修改"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="!isEdit" label="初始密码" prop="password">
|
||||||
|
<el-input
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="8–72 字节(中文一个字符约 3 字节)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="姓名" prop="real_name">
|
||||||
|
<el-input v-model="form.real_name" placeholder="真实姓名" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="手机号" prop="phone">
|
||||||
|
<el-input v-model="form.phone" placeholder="选填" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="邮箱" prop="email">
|
||||||
|
<el-input v-model="form.email" placeholder="选填" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="角色" prop="roles">
|
||||||
|
<el-select v-model="form.roles" multiple placeholder="至少选择一个角色" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="role in roleOptions"
|
||||||
|
:key="role.role_code"
|
||||||
|
:label="`${role.role_name}(${role.role_code})`"
|
||||||
|
:value="role.role_code"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注" prop="remark">
|
||||||
|
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="选填" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 重置密码 -->
|
||||||
|
<el-dialog v-model="resetVisible" title="重置密码" width="440px" destroy-on-close>
|
||||||
|
<p class="reset-tip">
|
||||||
|
将重置用户「{{ resetTarget.username }}」的密码。
|
||||||
|
重置后该用户<strong>已登录的所有会话立即失效</strong>,需用新密码重新登录。
|
||||||
|
<template v-if="resetTarget.isSelf">
|
||||||
|
<br />
|
||||||
|
注意:<strong>你正在重置自己的密码</strong>,保存后当前会话也会失效。
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
<el-form ref="resetFormRef" :model="resetForm" :rules="resetRules" label-width="80px">
|
||||||
|
<el-form-item label="新密码" prop="newPassword">
|
||||||
|
<el-input
|
||||||
|
v-model="resetForm.newPassword"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="8–72 字节(中文一个字符约 3 字节)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="resetVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="resetSubmitting" @click="handleResetSubmit">
|
||||||
|
确认重置
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.role-tag {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-tip {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: var(--ys-text);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in New Issue
Block a user