feat(admin, admin-ui): 实现RuoYi风格后台菜单管理,优化媒体资源处理
新增服务端菜单校验与归一化逻辑,完善菜单相关Schema;前端实现条件渲染的菜单编辑器,支持搜索式图标选择器与父级树过滤。新增媒体URL统一处理工具修复管理端本地静态资源路径映射问题,更新全部相关文档、技术决策记录与测试用例。本次变更不影响WonderQ-MiniAPP端。
This commit is contained in:
1 parent
6245159e7c
commit
4c245f0d1f
28 files changed
+625
-36
No files matched your search
@@ -24,6 +24,8 @@ Copy-Item .env.example .env
|
||||
- `ADMIN_ACCESS_EXPIRES_MINUTES`、`ADMIN_REFRESH_EXPIRES_DAYS`:管理员访问令牌和刷新令牌有效期。
|
||||
- `OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_ENDPOINT`、`OSS_BUCKET_NAME`:填写实际 OSS 配置;真实密钥只放在 `.env` 或部署平台密钥中,不提交到 Git。
|
||||
|
||||
菜单管理接口 `GET/POST/PATCH/DELETE /api/admin/system/menus` 维护目录、页面和按钮的父子树。服务端校验父级存在、禁止按钮作为父级、禁止循环归属和重复权限标识;具体字段规则以 [`docs/admin-api-requirements.md`](../docs/admin-api-requirements.md) 为准。
|
||||
|
||||
### 2. 创建并启用 Python 虚拟环境
|
||||
|
||||
首次启动或 `.venv` 不存在时执行:
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
MENU_FIELDS = (
|
||||
"parentId",
|
||||
"name",
|
||||
"type",
|
||||
"path",
|
||||
"componentKey",
|
||||
"permissionCode",
|
||||
"icon",
|
||||
"sortOrder",
|
||||
"isVisible",
|
||||
"isActive",
|
||||
)
|
||||
|
||||
|
||||
def _clean_optional(value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
return value.strip() or None
|
||||
return value
|
||||
|
||||
|
||||
def normalize_menu_values(values: dict[str, Any], *, partial: bool = False) -> dict[str, Any]:
|
||||
"""Normalize menu form values and enforce type-specific fields."""
|
||||
normalized = dict(values)
|
||||
for field in ("parentId", "name", "path", "componentKey", "permissionCode", "icon"):
|
||||
if field in normalized:
|
||||
normalized[field] = _clean_optional(normalized[field])
|
||||
|
||||
if "name" in normalized and not normalized["name"]:
|
||||
raise ValueError("菜单名称不能为空")
|
||||
|
||||
if partial or "type" not in normalized:
|
||||
return normalized
|
||||
|
||||
menu_type = normalized["type"]
|
||||
if menu_type == "button":
|
||||
if not normalized.get("permissionCode"):
|
||||
raise ValueError("按钮菜单必须填写权限标识")
|
||||
if normalized.get("path") or normalized.get("componentKey"):
|
||||
raise ValueError("按钮菜单不能填写路由地址或组件路径")
|
||||
normalized["icon"] = None
|
||||
else:
|
||||
if not normalized.get("path"):
|
||||
raise ValueError("目录或页面必须填写路由地址")
|
||||
if menu_type == "page" and not normalized.get("componentKey"):
|
||||
raise ValueError("页面菜单必须填写组件路径")
|
||||
if menu_type == "directory":
|
||||
normalized["permissionCode"] = None
|
||||
|
||||
return normalized
|
||||
@@ -22,6 +22,7 @@ def build_menu_tree(menus: Iterable[AdminMenu], visible_only: bool = True) -> li
|
||||
nodes: dict[str, dict[str, Any]] = {
|
||||
menu.id: {
|
||||
"id": menu.id,
|
||||
"parentId": menu.parentId,
|
||||
"name": menu.name,
|
||||
"type": menu.type,
|
||||
"path": menu.path,
|
||||
@@ -30,6 +31,8 @@ def build_menu_tree(menus: Iterable[AdminMenu], visible_only: bool = True) -> li
|
||||
"icon": menu.icon,
|
||||
"children": [],
|
||||
"sortOrder": menu.sortOrder,
|
||||
"isVisible": menu.isVisible,
|
||||
"isActive": menu.isActive,
|
||||
}
|
||||
for menu in active_menus
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import get_actor_id, hash_password, require_admin
|
||||
from ..database import get_db
|
||||
from ..menu_rules import MENU_FIELDS, normalize_menu_values
|
||||
from ..redis_session import AdminSessionStore, RedisUnavailableError, get_admin_session_store
|
||||
from ..models import (
|
||||
AdminDepartment,
|
||||
@@ -87,6 +88,43 @@ def _ensure_ids_exist(db: Session, model, ids: list[str], label: str) -> None:
|
||||
raise HTTPException(status_code=400, detail={"msg": f"{label}不存在", "errorCode": "REFERENCE_NOT_FOUND", "details": {"ids": missing}})
|
||||
|
||||
|
||||
def _ensure_menu_parent(db: Session, menu_id: str | None, parent_id: str | None) -> None:
|
||||
if not parent_id:
|
||||
return
|
||||
parent = db.get(AdminMenu, parent_id)
|
||||
if not parent:
|
||||
raise HTTPException(status_code=400, detail="父级菜单不存在")
|
||||
if parent.type == "button":
|
||||
raise HTTPException(status_code=400, detail="按钮菜单不能作为父级菜单")
|
||||
|
||||
seen: set[str] = set()
|
||||
current: AdminMenu | None = parent
|
||||
while current:
|
||||
if current.id == menu_id:
|
||||
raise HTTPException(status_code=400, detail="菜单不能移动到自己的子菜单下")
|
||||
if current.id in seen:
|
||||
break
|
||||
seen.add(current.id)
|
||||
current = db.get(AdminMenu, current.parentId) if current.parentId else None
|
||||
|
||||
|
||||
def _normalized_menu_or_422(values: dict) -> dict:
|
||||
try:
|
||||
return normalize_menu_values(values)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def _ensure_permission_available(db: Session, permission_code: str | None, menu_id: str | None = None) -> None:
|
||||
if not permission_code:
|
||||
return
|
||||
stmt = select(AdminMenu.id).where(AdminMenu.permissionCode == permission_code)
|
||||
if menu_id:
|
||||
stmt = stmt.where(AdminMenu.id != menu_id)
|
||||
if db.scalar(stmt):
|
||||
raise HTTPException(status_code=409, detail="权限标识已存在")
|
||||
|
||||
|
||||
def _invalidate_permissions(store: AdminSessionStore, user_ids: list[str] | None = None) -> None:
|
||||
try:
|
||||
store.invalidate_permission_cache(user_ids)
|
||||
@@ -335,10 +373,10 @@ def delete_role(
|
||||
|
||||
@router.post("/menus", status_code=status.HTTP_201_CREATED)
|
||||
def create_menu(body: AdminMenuCreate, request: Request, _user: AdminUser = Depends(require_permission("system:menu:create")), db: Session = Depends(get_db)):
|
||||
if body.parentId and not db.get(AdminMenu, body.parentId):
|
||||
raise HTTPException(status_code=400, detail="父级菜单不存在")
|
||||
menu = AdminMenu(**body.model_dump())
|
||||
menu.componentKey = body.componentKey
|
||||
values = _normalized_menu_or_422(body.model_dump())
|
||||
_ensure_menu_parent(db, None, values["parentId"])
|
||||
_ensure_permission_available(db, values["permissionCode"])
|
||||
menu = AdminMenu(**values)
|
||||
db.add(menu)
|
||||
db.flush()
|
||||
_audit(db, request, "create", "admin_menu", menu.id, after=_menu_dict(menu))
|
||||
@@ -351,13 +389,13 @@ def update_menu(menu_id: str, body: AdminMenuPatch, request: Request, _user: Adm
|
||||
menu = db.get(AdminMenu, menu_id)
|
||||
if not menu:
|
||||
raise HTTPException(status_code=404, detail="菜单不存在")
|
||||
if body.parentId == menu.id:
|
||||
raise HTTPException(status_code=400, detail="菜单不能将自身设置为父级")
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
if field == "componentKey":
|
||||
setattr(menu, "componentKey", value)
|
||||
else:
|
||||
setattr(menu, field, value)
|
||||
values = {field: getattr(menu, field) for field in MENU_FIELDS}
|
||||
values.update(body.model_dump(exclude_unset=True))
|
||||
values = _normalized_menu_or_422(values)
|
||||
_ensure_menu_parent(db, menu.id, values["parentId"])
|
||||
_ensure_permission_available(db, values["permissionCode"], menu.id)
|
||||
for field in MENU_FIELDS:
|
||||
setattr(menu, field, values[field])
|
||||
db.flush()
|
||||
_audit(db, request, "update", "admin_menu", menu.id, after=_menu_dict(menu))
|
||||
db.commit()
|
||||
|
||||
@@ -3,6 +3,8 @@ from typing import Literal
|
||||
from urllib.parse import urlsplit
|
||||
from pydantic import BaseModel, EmailStr, Field, ValidationInfo, field_validator, model_validator
|
||||
|
||||
from .menu_rules import normalize_menu_values
|
||||
|
||||
|
||||
LeadStatus = Literal["new", "assigned", "contacted", "planning", "won", "invalid"]
|
||||
LeadType = Literal["general", "vehicle"]
|
||||
@@ -662,6 +664,13 @@ class AdminMenuCreate(BaseModel):
|
||||
isVisible: bool = True
|
||||
isActive: bool = True
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_menu_fields(self):
|
||||
normalized = normalize_menu_values(self.model_dump())
|
||||
for field, value in normalized.items():
|
||||
setattr(self, field, value)
|
||||
return self
|
||||
|
||||
|
||||
class AdminMenuPatch(BaseModel):
|
||||
parentId: str | None = None
|
||||
@@ -675,6 +684,13 @@ class AdminMenuPatch(BaseModel):
|
||||
isVisible: bool | None = None
|
||||
isActive: bool | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def normalize_menu_fields(self):
|
||||
normalized = normalize_menu_values(self.model_dump(exclude_unset=True), partial=True)
|
||||
for field, value in normalized.items():
|
||||
setattr(self, field, value)
|
||||
return self
|
||||
|
||||
|
||||
class AdminDepartmentCreate(BaseModel):
|
||||
parentId: str | None = None
|
||||
|
||||
@@ -28,7 +28,7 @@ def media_model_dict(instance) -> dict:
|
||||
|
||||
def resolve_media_fields(data: dict) -> dict:
|
||||
result = dict(data)
|
||||
for field in ("image", "avatar", "qrImage"):
|
||||
for field in ("image", "avatar", "qrImage", "url"):
|
||||
if field in result:
|
||||
result[field] = resolve_media_url(result[field])
|
||||
for field in ("gallery", "images"):
|
||||
|
||||
@@ -302,6 +302,8 @@ def test_dynamic_menu_tree_preserves_parent_order_and_button_permissions():
|
||||
assert DATA_SCOPE_VALUES == {"all", "dept", "dept_and_children", "custom_dept", "self"}
|
||||
assert [item["id"] for item in tree] == ["root"]
|
||||
assert tree[0]["children"][0]["permission"] == "home:edit"
|
||||
assert tree[0]["isVisible"] is True
|
||||
assert tree[0]["isActive"] is True
|
||||
|
||||
|
||||
def test_dynamic_menu_tree_keeps_granted_menu_when_parent_is_not_in_scope():
|
||||
|
||||
@@ -68,6 +68,7 @@ def test_resolve_media_fields_signs_common_image_fields(monkeypatch):
|
||||
"image": "oss-image",
|
||||
"avatar": "oss-avatar",
|
||||
"qrImage": "oss-qr",
|
||||
"url": "oss-url",
|
||||
"gallery": ["oss-gallery-1", "oss-gallery-2"],
|
||||
"label": "unchanged",
|
||||
}
|
||||
@@ -77,6 +78,7 @@ def test_resolve_media_fields_signs_common_image_fields(monkeypatch):
|
||||
"image": "signed:oss-image",
|
||||
"avatar": "signed:oss-avatar",
|
||||
"qrImage": "signed:oss-qr",
|
||||
"url": "signed:oss-url",
|
||||
"gallery": ["signed:oss-gallery-1", "signed:oss-gallery-2"],
|
||||
"label": "unchanged",
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.models import AdminDepartment, AdminMenu, AdminRole
|
||||
from app.routers.system import _dept_dict, _menu_dict, _role_dict
|
||||
from app.schemas import AdminMenuCreate
|
||||
|
||||
|
||||
def test_role_audit_snapshot_serializes_datetime_fields():
|
||||
@@ -42,3 +46,21 @@ def test_menu_and_department_audit_snapshots_serialize_datetime_fields():
|
||||
|
||||
json.dumps(_menu_dict(menu))
|
||||
json.dumps(_dept_dict(dept))
|
||||
|
||||
|
||||
def test_menu_create_validates_fields_by_menu_type():
|
||||
with pytest.raises(ValidationError):
|
||||
AdminMenuCreate(name=" ", type="page", path="/users", componentKey="SystemUsers")
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
AdminMenuCreate(name="按钮", type="button")
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
AdminMenuCreate(name="页面", type="page", path="/users")
|
||||
|
||||
button = AdminMenuCreate(name="编辑用户", type="button", permissionCode="system:user:update")
|
||||
assert button.path is None
|
||||
assert button.componentKey is None
|
||||
|
||||
page = AdminMenuCreate(name="用户管理", type="page", path="/users", componentKey="SystemUsers")
|
||||
assert page.path == "/users"
|
||||
Reference in new issue
Block a user