Build lightweight AI agent admin
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Menu Module - 菜单模块
|
||||
"""
|
||||
@@ -0,0 +1,477 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Menu API - 菜单管理接口
|
||||
提供菜单的 CRUD 操作和路由树生成
|
||||
"""
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.base_schema import PaginatedResponse, ResponseModel
|
||||
from core.menu.model import Menu
|
||||
from core.menu.schema import (
|
||||
MenuCreate,
|
||||
MenuUpdate,
|
||||
MenuResponse,
|
||||
MenuTreeNode,
|
||||
MenuSimple,
|
||||
MenuBatchDeleteRequest,
|
||||
MenuBatchDeleteResponse,
|
||||
MenuPathResponse,
|
||||
MenuStatsResponse,
|
||||
MenuMoveRequest,
|
||||
MenuCheckNameRequest,
|
||||
MenuCheckPathRequest,
|
||||
)
|
||||
from core.menu.service import MenuService
|
||||
from utils.security import get_current_user, get_current_user_id
|
||||
|
||||
router = APIRouter(prefix="/menu", tags=["菜单管理"])
|
||||
|
||||
|
||||
def _build_menu_response(menu: Menu, level: int = 0, child_count: int = 0) -> MenuResponse:
|
||||
"""构建菜单响应"""
|
||||
return MenuResponse(
|
||||
id=menu.id,
|
||||
parent_id=menu.parent_id,
|
||||
application_id=menu.application_id,
|
||||
is_system=menu.is_system,
|
||||
name=menu.name,
|
||||
title=menu.title,
|
||||
authCode=menu.authCode,
|
||||
path=menu.path,
|
||||
type=menu.type,
|
||||
component=menu.component,
|
||||
redirect=menu.redirect,
|
||||
activePath=menu.activePath,
|
||||
query=menu.query,
|
||||
noBasicLayout=menu.noBasicLayout,
|
||||
icon=menu.icon,
|
||||
activeIcon=menu.activeIcon,
|
||||
order=menu.order,
|
||||
hideInMenu=menu.hideInMenu,
|
||||
hideChildrenInMenu=menu.hideChildrenInMenu,
|
||||
hideInBreadcrumb=menu.hideInBreadcrumb,
|
||||
hideInTab=menu.hideInTab,
|
||||
affixTab=menu.affixTab,
|
||||
affixTabOrder=menu.affixTabOrder,
|
||||
keepAlive=menu.keepAlive,
|
||||
maxNumOfOpenTab=menu.maxNumOfOpenTab,
|
||||
fullPathKey=menu.fullPathKey if hasattr(menu, 'fullPathKey') else True,
|
||||
link=menu.link,
|
||||
iframeSrc=menu.iframeSrc,
|
||||
openInNewWindow=menu.openInNewWindow,
|
||||
badge=menu.badge,
|
||||
badgeType=menu.badgeType,
|
||||
badgeVariants=menu.badgeVariants,
|
||||
sort=menu.sort,
|
||||
is_deleted=menu.is_deleted,
|
||||
sys_create_datetime=menu.sys_create_datetime,
|
||||
sys_update_datetime=menu.sys_update_datetime,
|
||||
level=level,
|
||||
childCount=child_count,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=MenuResponse, summary="创建菜单")
|
||||
async def create_menu(
|
||||
data: MenuCreate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
创建新菜单
|
||||
|
||||
- 检查菜单名称唯一性
|
||||
- 检查路由路径唯一性
|
||||
- 检查父菜单存在性
|
||||
"""
|
||||
# 检查菜单名称是否已存在
|
||||
if await MenuService.check_name_exists(db, data.name):
|
||||
raise HTTPException(status_code=400, detail=f"菜单名称已存在: {data.name}")
|
||||
|
||||
# 检查路由路径是否已存在
|
||||
if await MenuService.check_path_exists(db, data.path):
|
||||
raise HTTPException(status_code=400, detail=f"路由路径已存在: {data.path}")
|
||||
|
||||
# 检查父菜单是否存在
|
||||
if data.parent_id:
|
||||
parent = await MenuService.get_by_id(db, data.parent_id)
|
||||
if not parent:
|
||||
raise HTTPException(status_code=400, detail="父菜单不存在")
|
||||
|
||||
menu = await MenuService.create(db, data)
|
||||
await MenuService.invalidate_cache()
|
||||
|
||||
level = await MenuService.get_level(db, menu)
|
||||
child_count = await MenuService.get_child_count(db, menu.id)
|
||||
return _build_menu_response(menu, level, child_count)
|
||||
|
||||
|
||||
@router.get("/get/tree", response_model=List[dict], summary="获取菜单树")
|
||||
async def get_menu_tree(
|
||||
application_id: str = Query(default=None, alias="applicationId", description="应用ID,用于过滤应用菜单"),
|
||||
include_system: bool = Query(default=True, alias="includeSystem", description="是否包含系统菜单"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取菜单树形结构(带缓存)"""
|
||||
return await MenuService.get_menu_tree_cached(db, application_id=application_id, include_system=include_system)
|
||||
|
||||
|
||||
@router.get("/route/tree", response_model=List[dict], summary="获取用户路由树")
|
||||
async def get_route_tree(
|
||||
application_code: str = Query(default=None, description="应用编码,用于过滤应用专属菜单"),
|
||||
dev_mode: bool = Query(default=False, alias="devMode", description="开发模式:true只返回系统菜单,false只返回应用菜单"),
|
||||
current_user=Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
获取当前用户的路由树
|
||||
|
||||
- 超级管理员获取所有菜单
|
||||
- 普通用户获取其角色关联的菜单
|
||||
- 如果提供 application_code:
|
||||
- dev_mode=true(开发模式):只返回系统菜单(is_system=true)
|
||||
- dev_mode=false(正常模式):只返回该应用的专属菜单(application_id=app.id)
|
||||
"""
|
||||
# 获取用户所有角色关联的菜单ID列表(多对多)
|
||||
role_menu_ids = None
|
||||
if not current_user.is_superuser:
|
||||
from core.user.service import UserService
|
||||
from core.role.service import RoleService
|
||||
|
||||
# 获取用户的所有角色ID
|
||||
role_ids = await UserService.get_user_role_ids(db, current_user.id)
|
||||
|
||||
if role_ids:
|
||||
# 遍历所有角色,收集菜单ID并去重
|
||||
menu_id_set = set()
|
||||
for role_id in role_ids:
|
||||
role = await RoleService.get_by_id_with_relations(db, role_id)
|
||||
if role and role.menus:
|
||||
for menu in role.menus:
|
||||
menu_id_set.add(menu.id)
|
||||
if menu_id_set:
|
||||
role_menu_ids = list(menu_id_set)
|
||||
|
||||
return await MenuService.get_user_route_tree(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
is_superuser=current_user.is_superuser,
|
||||
role_menu_ids=role_menu_ids,
|
||||
application_code=application_code,
|
||||
dev_mode=dev_mode
|
||||
)
|
||||
|
||||
|
||||
@router.get("/list", response_model=PaginatedResponse[MenuResponse], summary="获取菜单列表")
|
||||
async def get_menu_list(
|
||||
page: int = Query(default=1, ge=1, description="页码"),
|
||||
pageSize: int = Query(default=20, ge=1, le=100, description="每页数量"),
|
||||
name: str = Query(default=None, description="菜单名称"),
|
||||
title: str = Query(default=None, description="菜单标题"),
|
||||
type: str = Query(default=None, description="菜单类型"),
|
||||
parent_id: str = Query(default=None, description="父菜单ID"),
|
||||
application_id: str = Query(default=None, alias="applicationId", description="应用ID"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取菜单列表(分页)"""
|
||||
# 构建过滤条件
|
||||
filters = {}
|
||||
if name:
|
||||
filters["name"] = name
|
||||
if title:
|
||||
filters["title"] = title
|
||||
if type:
|
||||
filters["type"] = type
|
||||
if parent_id:
|
||||
filters["parent_id"] = parent_id
|
||||
if application_id:
|
||||
filters["application_id"] = application_id
|
||||
|
||||
items, total = await MenuService.get_list(db, page=page, page_size=pageSize, filters=filters)
|
||||
|
||||
# 构建响应
|
||||
result_items = []
|
||||
for menu in items:
|
||||
level = await MenuService.get_level(db, menu)
|
||||
child_count = await MenuService.get_child_count(db, menu.id)
|
||||
result_items.append(_build_menu_response(menu, level, child_count))
|
||||
|
||||
return PaginatedResponse(items=result_items, total=total)
|
||||
|
||||
|
||||
@router.get("/all", response_model=List[MenuSimple], summary="获取所有菜单")
|
||||
async def get_all_menus(
|
||||
application_id: str = Query(default=None, alias="applicationId", description="应用ID"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取所有菜单(不分页,简化版,用于选择器)"""
|
||||
menus = await MenuService.get_all_menus(db, application_id=application_id)
|
||||
result = []
|
||||
for menu in menus:
|
||||
level = await MenuService.get_level(db, menu)
|
||||
result.append(MenuSimple(
|
||||
id=menu.id,
|
||||
name=menu.name,
|
||||
title=menu.title,
|
||||
path=menu.path,
|
||||
type=menu.type,
|
||||
parent_id=menu.parent_id,
|
||||
level=level,
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/stats", response_model=MenuStatsResponse, summary="获取菜单统计")
|
||||
async def get_menu_stats(
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取菜单统计信息"""
|
||||
stats = await MenuService.get_menu_stats(db)
|
||||
return MenuStatsResponse(**stats)
|
||||
|
||||
|
||||
@router.get("/by-parent/{parent_id}", response_model=List[dict], summary="根据父菜单获取子菜单")
|
||||
async def get_menus_by_parent(
|
||||
parent_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
根据父菜单ID获取直接子菜单
|
||||
|
||||
- parent_id="null" 获取根菜单
|
||||
"""
|
||||
if parent_id == "null":
|
||||
parent_id = None
|
||||
|
||||
children = await MenuService.get_children(db, parent_id)
|
||||
|
||||
result = []
|
||||
for menu in children:
|
||||
level = await MenuService.get_level(db, menu)
|
||||
child_count = await MenuService.get_child_count(db, menu.id)
|
||||
result.append({
|
||||
"id": menu.id,
|
||||
"parent_id": menu.parent_id,
|
||||
"name": menu.name,
|
||||
"title": menu.title,
|
||||
"path": menu.path,
|
||||
"type": menu.type,
|
||||
"icon": menu.icon,
|
||||
"order": menu.order,
|
||||
"level": level,
|
||||
"childCount": child_count,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/path/{menu_id}", response_model=MenuPathResponse, summary="获取菜单路径")
|
||||
async def get_menu_path(
|
||||
menu_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取菜单的完整路径(从根到当前菜单)"""
|
||||
menu = await MenuService.get_by_id(db, menu_id)
|
||||
if not menu:
|
||||
raise HTTPException(status_code=404, detail="菜单不存在")
|
||||
|
||||
# 获取所有祖先
|
||||
ancestors = await MenuService.get_ancestors(db, menu)
|
||||
|
||||
path = []
|
||||
for ancestor in reversed(ancestors):
|
||||
level = await MenuService.get_level(db, ancestor)
|
||||
path.append(MenuSimple(
|
||||
id=ancestor.id,
|
||||
name=ancestor.name,
|
||||
title=ancestor.title,
|
||||
path=ancestor.path,
|
||||
type=ancestor.type,
|
||||
parent_id=ancestor.parent_id,
|
||||
level=level,
|
||||
))
|
||||
|
||||
# 添加当前菜单
|
||||
level = await MenuService.get_level(db, menu)
|
||||
path.append(MenuSimple(
|
||||
id=menu.id,
|
||||
name=menu.name,
|
||||
title=menu.title,
|
||||
path=menu.path,
|
||||
type=menu.type,
|
||||
parent_id=menu.parent_id,
|
||||
level=level,
|
||||
))
|
||||
|
||||
return MenuPathResponse(
|
||||
menuId=menu.id,
|
||||
menuName=menu.title or menu.name,
|
||||
path=path,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/check/name", response_model=ResponseModel, summary="检查菜单名称")
|
||||
async def check_menu_name(
|
||||
data: MenuCheckNameRequest,
|
||||
application_id: str = Query(default=None, alias="applicationId", description="应用ID"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""检查菜单名称是否已存在"""
|
||||
exists = await MenuService.check_name_exists(db, data.name, data.exclude_id, application_id=application_id)
|
||||
return ResponseModel(
|
||||
message=f"菜单名称 '{data.name}' 已存在" if exists else "菜单名称可用",
|
||||
data={"exists": exists}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/check/path", response_model=ResponseModel, summary="检查路由路径")
|
||||
async def check_menu_path(
|
||||
data: MenuCheckPathRequest,
|
||||
application_id: str = Query(default=None, alias="applicationId", description="应用ID"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""检查路由路径是否已存在"""
|
||||
exists = await MenuService.check_path_exists(db, data.path, data.exclude_id, application_id=application_id)
|
||||
return ResponseModel(
|
||||
message=f"路由路径 '{data.path}' 已存在" if exists else "路由路径可用",
|
||||
data={"exists": exists}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/move", response_model=ResponseModel, summary="移动菜单")
|
||||
async def move_menu(
|
||||
data: MenuMoveRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""移动菜单到新的父菜单下"""
|
||||
success, message = await MenuService.move_menu(db, data.menuId, data.newParentId)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
return ResponseModel(message=message)
|
||||
|
||||
|
||||
@router.post("/batch/delete", response_model=MenuBatchDeleteResponse, summary="批量删除菜单")
|
||||
async def batch_delete_menus(
|
||||
data: MenuBatchDeleteRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
批量删除菜单
|
||||
|
||||
- 跳过有子菜单的菜单
|
||||
- 返回删除失败的ID列表
|
||||
"""
|
||||
failed_ids = []
|
||||
success_count = 0
|
||||
|
||||
for menu_id in data.ids:
|
||||
menu = await MenuService.get_by_id(db, menu_id)
|
||||
if not menu:
|
||||
failed_ids.append(menu_id)
|
||||
continue
|
||||
|
||||
if not await MenuService.can_delete(db, menu_id):
|
||||
failed_ids.append(menu_id)
|
||||
continue
|
||||
|
||||
if await MenuService.delete(db, menu_id):
|
||||
success_count += 1
|
||||
else:
|
||||
failed_ids.append(menu_id)
|
||||
|
||||
await MenuService.invalidate_cache()
|
||||
return MenuBatchDeleteResponse(count=success_count, failedIds=failed_ids)
|
||||
|
||||
|
||||
@router.get("/{menu_id}", response_model=MenuResponse, summary="获取菜单详情")
|
||||
async def get_menu(
|
||||
menu_id: str,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取单个菜单的详细信息"""
|
||||
menu = await MenuService.get_by_id(db, menu_id)
|
||||
if not menu:
|
||||
raise HTTPException(status_code=404, detail="菜单不存在")
|
||||
|
||||
level = await MenuService.get_level(db, menu)
|
||||
child_count = await MenuService.get_child_count(db, menu.id)
|
||||
return _build_menu_response(menu, level, child_count)
|
||||
|
||||
|
||||
@router.put("/{menu_id}", response_model=MenuResponse, summary="更新菜单")
|
||||
async def update_menu(
|
||||
menu_id: str,
|
||||
data: MenuUpdate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
更新菜单信息
|
||||
|
||||
- 检查菜单名称唯一性(排除自身)
|
||||
- 检查路由路径唯一性(排除自身)
|
||||
- 防止设置自己为父菜单
|
||||
- 防止形成循环引用
|
||||
"""
|
||||
menu = await MenuService.get_by_id(db, menu_id)
|
||||
if not menu:
|
||||
raise HTTPException(status_code=404, detail="菜单不存在")
|
||||
|
||||
# 检查菜单名称是否已存在(排除自身)
|
||||
if data.name and await MenuService.check_name_exists(db, data.name, menu_id):
|
||||
raise HTTPException(status_code=400, detail=f"菜单名称已存在: {data.name}")
|
||||
|
||||
# 检查路由路径是否已存在(排除自身)
|
||||
if data.path and await MenuService.check_path_exists(db, data.path, menu_id):
|
||||
raise HTTPException(status_code=400, detail=f"路由路径已存在: {data.path}")
|
||||
|
||||
# 检查父菜单
|
||||
if data.parent_id:
|
||||
if data.parent_id == menu_id:
|
||||
raise HTTPException(status_code=400, detail="不能将自己设置为父菜单")
|
||||
|
||||
parent = await MenuService.get_by_id(db, data.parent_id)
|
||||
if not parent:
|
||||
raise HTTPException(status_code=400, detail="父菜单不存在")
|
||||
|
||||
# 检查是否会形成循环引用
|
||||
ancestors = await MenuService.get_ancestors(db, parent)
|
||||
ancestor_ids = [a.id for a in ancestors]
|
||||
if menu.id in ancestor_ids:
|
||||
raise HTTPException(status_code=400, detail="不能将子菜单设置为父菜单,会形成循环引用")
|
||||
|
||||
updated_menu = await MenuService.update(db, menu_id, data)
|
||||
await MenuService.invalidate_cache()
|
||||
|
||||
level = await MenuService.get_level(db, updated_menu)
|
||||
child_count = await MenuService.get_child_count(db, updated_menu.id)
|
||||
return _build_menu_response(updated_menu, level, child_count)
|
||||
|
||||
|
||||
@router.delete("/{menu_id}", response_model=ResponseModel, summary="删除菜单")
|
||||
async def delete_menu(
|
||||
menu_id: str,
|
||||
hard: bool = Query(default=False, description="是否物理删除"),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
删除菜单
|
||||
|
||||
- 检查是否有子菜单
|
||||
"""
|
||||
menu = await MenuService.get_by_id(db, menu_id)
|
||||
if not menu:
|
||||
raise HTTPException(status_code=404, detail="菜单不存在")
|
||||
|
||||
if not await MenuService.can_delete(db, menu_id):
|
||||
raise HTTPException(status_code=400, detail="该菜单下还有子菜单,无法删除")
|
||||
|
||||
success = await MenuService.delete(db, menu_id, hard=hard)
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="删除失败")
|
||||
|
||||
await MenuService.invalidate_cache()
|
||||
return ResponseModel(message="删除成功")
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Menu Model - 菜单模型
|
||||
用于管理系统菜单和前端路由
|
||||
"""
|
||||
from sqlalchemy import Column, String, Boolean, Integer, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.base_model import BaseModel
|
||||
|
||||
|
||||
class Menu(BaseModel):
|
||||
"""系统菜单表"""
|
||||
__tablename__ = "core_menu"
|
||||
|
||||
# 所属应用(逻辑外键关联 core_application)
|
||||
application_id = Column(String(21), nullable=True, index=True, comment="所属应用ID")
|
||||
|
||||
# 系统菜单标识(系统菜单在所有应用中都可见)
|
||||
is_system = Column(Boolean, default=False, comment="是否为系统菜单")
|
||||
|
||||
# 基础信息(字段名使用驼峰,与前端保持一致)
|
||||
parent_id = Column("parent_id", String(21), nullable=True, index=True, comment="父菜单ID")
|
||||
name = Column(String(100), nullable=False, comment="菜单名称(路由名称)")
|
||||
title = Column(String(100), nullable=True, comment="菜单标题(显示名称)")
|
||||
authCode = Column(String(100), nullable=True, comment="后端权限标识")
|
||||
path = Column(String(200), nullable=False, comment="路由路径")
|
||||
type = Column(String(20), default="catalog", comment="菜单类型: catalog/menu/external/link/embedded/online_form/online_page/agent")
|
||||
|
||||
# 路由配置
|
||||
component = Column(String(100), nullable=True, comment="组件路径")
|
||||
redirect = Column(String(200), nullable=True, comment="重定向路径")
|
||||
activePath = Column(String(200), nullable=True, comment="激活路径")
|
||||
query = Column(JSON, nullable=True, comment="额外路由参数")
|
||||
noBasicLayout = Column(Boolean, default=False, comment="无需基础布局")
|
||||
|
||||
# 菜单展示
|
||||
icon = Column(String(100), nullable=True, comment="菜单图标")
|
||||
activeIcon = Column(String(100), nullable=True, comment="激活图标")
|
||||
order = Column(Integer, default=0, comment="菜单排序")
|
||||
hideInMenu = Column(Boolean, default=False, comment="在菜单中隐藏")
|
||||
hideChildrenInMenu = Column(Boolean, default=False, comment="在菜单中隐藏下级")
|
||||
hideInBreadcrumb = Column(Boolean, default=False, comment="在面包屑中隐藏")
|
||||
|
||||
# 标签页配置
|
||||
hideInTab = Column(Boolean, default=False, comment="在标签栏中隐藏")
|
||||
affixTab = Column(Boolean, default=False, comment="固定在标签栏")
|
||||
affixTabOrder = Column(Integer, nullable=True, comment="标签栏固定顺序")
|
||||
keepAlive = Column(Boolean, default=False, comment="缓存页面")
|
||||
maxNumOfOpenTab = Column(Integer, nullable=True, comment="最大打开标签数")
|
||||
fullPathKey = Column(Boolean, default=True, comment="路由完整路径作为key(设为false时路径参数变化不刷新组件)")
|
||||
|
||||
# 外部链接配置
|
||||
link = Column(String(500), nullable=True, comment="外链URL")
|
||||
iframeSrc = Column(String(500), nullable=True, comment="内嵌iframe URL")
|
||||
openInNewWindow = Column(Boolean, default=False, comment="在新窗口打开")
|
||||
|
||||
# 徽标配置
|
||||
badge = Column(String(20), nullable=True, comment="徽标内容")
|
||||
badgeType = Column(String(20), nullable=True, comment="徽标类型: dot/normal")
|
||||
badgeVariants = Column(String(20), nullable=True, comment="徽标颜色")
|
||||
|
||||
# 关系定义(使用primaryjoin指定逻辑关联,lazy='selectin'支持异步加载)
|
||||
parent = relationship(
|
||||
"Menu",
|
||||
remote_side="Menu.id",
|
||||
backref="children",
|
||||
foreign_keys="Menu.parent_id",
|
||||
primaryjoin="Menu.parent_id == Menu.id",
|
||||
lazy="selectin"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Menu {self.title or self.name} ({self.path})>"
|
||||
|
||||
def get_type_display(self) -> str:
|
||||
"""获取菜单类型显示名称"""
|
||||
type_map = {
|
||||
"catalog": "目录",
|
||||
"menu": "菜单",
|
||||
"external": "外部链接",
|
||||
"link": "外链",
|
||||
"embedded": "内嵌",
|
||||
"online_form": "在线表单",
|
||||
"online_page": "在线页面",
|
||||
"online_report": "在线报表",
|
||||
"agent": "智能体",
|
||||
}
|
||||
return type_map.get(self.type, self.type)
|
||||
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Menu Schema - 菜单数据验证模式
|
||||
字段名使用驼峰命名,与前端保持一致
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Any, Dict
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict, field_validator
|
||||
|
||||
from app.base_schema import CSTDatetime
|
||||
|
||||
|
||||
class MenuBase(BaseModel):
|
||||
"""菜单基础Schema"""
|
||||
# 应用关联
|
||||
application_id: Optional[str] = Field(None, description="所属应用ID")
|
||||
is_system: bool = Field(default=False, description="是否为系统菜单")
|
||||
|
||||
name: str = Field(..., description="菜单名称(路由名称)")
|
||||
title: Optional[str] = Field(None, description="菜单标题(显示名称)")
|
||||
authCode: Optional[str] = Field(None, description="后端权限标识")
|
||||
path: str = Field(..., description="路由路径")
|
||||
type: str = Field(default="catalog", description="菜单类型")
|
||||
|
||||
# 路由配置
|
||||
component: Optional[str] = Field(None, description="组件路径")
|
||||
redirect: Optional[str] = Field(None, description="重定向路径")
|
||||
activePath: Optional[str] = Field(None, description="激活路径")
|
||||
query: Optional[Dict[str, Any]] = Field(None, description="额外路由参数")
|
||||
noBasicLayout: bool = Field(default=False, description="无需基础布局")
|
||||
|
||||
# 菜单展示
|
||||
icon: Optional[str] = Field(None, description="菜单图标")
|
||||
activeIcon: Optional[str] = Field(None, description="激活图标")
|
||||
order: int = Field(default=0, description="菜单排序")
|
||||
hideInMenu: bool = Field(default=False, description="在菜单中隐藏")
|
||||
hideChildrenInMenu: bool = Field(default=False, description="在菜单中隐藏下级")
|
||||
hideInBreadcrumb: bool = Field(default=False, description="在面包屑中隐藏")
|
||||
|
||||
# 标签页配置
|
||||
hideInTab: bool = Field(default=False, description="在标签栏中隐藏")
|
||||
affixTab: bool = Field(default=False, description="固定在标签栏")
|
||||
affixTabOrder: Optional[int] = Field(None, description="标签栏固定顺序")
|
||||
keepAlive: bool = Field(default=False, description="缓存页面")
|
||||
maxNumOfOpenTab: Optional[int] = Field(None, description="最大打开标签数")
|
||||
fullPathKey: bool = Field(default=True, description="路由完整路径作为key")
|
||||
|
||||
# 外部链接配置
|
||||
link: Optional[str] = Field(None, description="外链URL")
|
||||
iframeSrc: Optional[str] = Field(None, description="内嵌iframe URL")
|
||||
openInNewWindow: bool = Field(default=False, description="在新窗口打开")
|
||||
|
||||
# 徽标配置
|
||||
badge: Optional[str] = Field(None, description="徽标内容")
|
||||
badgeType: Optional[str] = Field(None, description="徽标类型")
|
||||
badgeVariants: Optional[str] = Field(None, description="徽标颜色")
|
||||
|
||||
@field_validator('name')
|
||||
@classmethod
|
||||
def validate_name(cls, v):
|
||||
"""验证菜单名称格式"""
|
||||
if not v:
|
||||
raise ValueError('菜单名称不能为空')
|
||||
if not v[0].isalpha():
|
||||
raise ValueError('菜单名称必须以字母开头')
|
||||
if not all(c.isalnum() or c in '_-' for c in v):
|
||||
raise ValueError('菜单名称只能包含字母、数字、下划线和横线')
|
||||
return v
|
||||
|
||||
@field_validator('path')
|
||||
@classmethod
|
||||
def validate_path(cls, v):
|
||||
"""验证路由路径"""
|
||||
if not v:
|
||||
raise ValueError('路由路径不能为空')
|
||||
return v
|
||||
|
||||
@field_validator('type')
|
||||
@classmethod
|
||||
def validate_type(cls, v):
|
||||
"""验证菜单类型"""
|
||||
valid_types = [
|
||||
'catalog', 'menu', 'external', 'link', 'embedded',
|
||||
'online_form', 'online_page', 'online_report', 'agent',
|
||||
]
|
||||
if v not in valid_types:
|
||||
raise ValueError(f'菜单类型必须为 {", ".join(valid_types)} 之一')
|
||||
return v
|
||||
|
||||
|
||||
class MenuCreate(MenuBase):
|
||||
"""菜单创建Schema"""
|
||||
parent_id: Optional[str] = Field(None, description="父菜单ID")
|
||||
|
||||
|
||||
class MenuUpdate(BaseModel):
|
||||
"""菜单更新Schema - 所有字段可选"""
|
||||
# 应用关联
|
||||
application_id: Optional[str] = Field(None, description="所属应用ID")
|
||||
is_system: Optional[bool] = Field(None, description="是否为系统菜单")
|
||||
|
||||
name: Optional[str] = Field(None, description="菜单名称")
|
||||
title: Optional[str] = Field(None, description="菜单标题")
|
||||
authCode: Optional[str] = Field(None, description="后端权限标识")
|
||||
path: Optional[str] = Field(None, description="路由路径")
|
||||
type: Optional[str] = Field(None, description="菜单类型")
|
||||
parent_id: Optional[str] = Field(None, description="父菜单ID")
|
||||
|
||||
# 路由配置
|
||||
component: Optional[str] = Field(None, description="组件路径")
|
||||
redirect: Optional[str] = Field(None, description="重定向路径")
|
||||
activePath: Optional[str] = Field(None, description="激活路径")
|
||||
query: Optional[Dict[str, Any]] = Field(None, description="额外路由参数")
|
||||
noBasicLayout: Optional[bool] = Field(None, description="无需基础布局")
|
||||
|
||||
# 菜单展示
|
||||
icon: Optional[str] = Field(None, description="菜单图标")
|
||||
activeIcon: Optional[str] = Field(None, description="激活图标")
|
||||
order: Optional[int] = Field(None, description="菜单排序")
|
||||
hideInMenu: Optional[bool] = Field(None, description="在菜单中隐藏")
|
||||
hideChildrenInMenu: Optional[bool] = Field(None, description="在菜单中隐藏下级")
|
||||
hideInBreadcrumb: Optional[bool] = Field(None, description="在面包屑中隐藏")
|
||||
|
||||
# 标签页配置
|
||||
hideInTab: Optional[bool] = Field(None, description="在标签栏中隐藏")
|
||||
affixTab: Optional[bool] = Field(None, description="固定在标签栏")
|
||||
affixTabOrder: Optional[int] = Field(None, description="标签栏固定顺序")
|
||||
keepAlive: Optional[bool] = Field(None, description="缓存页面")
|
||||
maxNumOfOpenTab: Optional[int] = Field(None, description="最大打开标签数")
|
||||
fullPathKey: Optional[bool] = Field(None, description="路由完整路径作为key")
|
||||
|
||||
# 外部链接配置
|
||||
link: Optional[str] = Field(None, description="外链URL")
|
||||
iframeSrc: Optional[str] = Field(None, description="内嵌iframe URL")
|
||||
openInNewWindow: Optional[bool] = Field(None, description="在新窗口打开")
|
||||
|
||||
# 徽标配置
|
||||
badge: Optional[str] = Field(None, description="徽标内容")
|
||||
badgeType: Optional[str] = Field(None, description="徽标类型")
|
||||
badgeVariants: Optional[str] = Field(None, description="徽标颜色")
|
||||
|
||||
@field_validator('name')
|
||||
@classmethod
|
||||
def validate_name(cls, v):
|
||||
"""验证菜单名称格式"""
|
||||
if v is not None:
|
||||
if not v:
|
||||
raise ValueError('菜单名称不能为空')
|
||||
if not v[0].isalpha():
|
||||
raise ValueError('菜单名称必须以字母开头')
|
||||
if not all(c.isalnum() or c in '_-' for c in v):
|
||||
raise ValueError('菜单名称只能包含字母、数字、下划线和横线')
|
||||
return v
|
||||
|
||||
@field_validator('type')
|
||||
@classmethod
|
||||
def validate_type(cls, v):
|
||||
"""验证菜单类型"""
|
||||
if v is not None:
|
||||
valid_types = [
|
||||
'catalog', 'menu', 'external', 'link', 'embedded',
|
||||
'online_form', 'online_page', 'online_report', 'agent',
|
||||
]
|
||||
if v not in valid_types:
|
||||
raise ValueError(f'菜单类型必须为 {", ".join(valid_types)} 之一')
|
||||
return v
|
||||
|
||||
|
||||
class MenuResponse(BaseModel):
|
||||
"""菜单响应Schema"""
|
||||
id: str
|
||||
parent_id: Optional[str] = None
|
||||
application_id: Optional[str] = None
|
||||
is_system: bool = False
|
||||
name: str
|
||||
title: Optional[str] = None
|
||||
authCode: Optional[str] = None
|
||||
path: str
|
||||
type: str
|
||||
|
||||
# 路由配置
|
||||
component: Optional[str] = None
|
||||
redirect: Optional[str] = None
|
||||
activePath: Optional[str] = None
|
||||
query: Optional[Dict[str, Any]] = None
|
||||
noBasicLayout: bool = False
|
||||
|
||||
# 菜单展示
|
||||
icon: Optional[str] = None
|
||||
activeIcon: Optional[str] = None
|
||||
order: int = 0
|
||||
hideInMenu: bool = False
|
||||
hideChildrenInMenu: bool = False
|
||||
hideInBreadcrumb: bool = False
|
||||
|
||||
# 标签页配置
|
||||
hideInTab: bool = False
|
||||
affixTab: bool = False
|
||||
affixTabOrder: Optional[int] = None
|
||||
keepAlive: bool = False
|
||||
maxNumOfOpenTab: Optional[int] = None
|
||||
fullPathKey: bool = True
|
||||
|
||||
# 外部链接配置
|
||||
link: Optional[str] = None
|
||||
iframeSrc: Optional[str] = None
|
||||
openInNewWindow: bool = False
|
||||
|
||||
# 徽标配置
|
||||
badge: Optional[str] = None
|
||||
badgeType: Optional[str] = None
|
||||
badgeVariants: Optional[str] = None
|
||||
|
||||
# 公共字段
|
||||
sort: int = 0
|
||||
is_deleted: bool = False
|
||||
sys_create_datetime: Optional[CSTDatetime] = None
|
||||
sys_update_datetime: Optional[CSTDatetime] = None
|
||||
|
||||
# 计算字段
|
||||
level: Optional[int] = None
|
||||
childCount: Optional[int] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class MenuTreeNode(BaseModel):
|
||||
"""菜单树节点"""
|
||||
id: str
|
||||
parent_id: Optional[str] = None
|
||||
name: str
|
||||
title: Optional[str] = None
|
||||
path: str
|
||||
type: str
|
||||
icon: Optional[str] = None
|
||||
order: int = 0
|
||||
level: int = 0
|
||||
childCount: int = 0
|
||||
children: List['MenuTreeNode'] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class MenuSimple(BaseModel):
|
||||
"""菜单简单输出(用于选择器)"""
|
||||
id: str
|
||||
name: str
|
||||
title: Optional[str] = None
|
||||
path: str
|
||||
type: str
|
||||
parent_id: Optional[str] = None
|
||||
level: int = 0
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class MenuRouteNode(BaseModel):
|
||||
"""菜单路由输出(前端路由格式)"""
|
||||
name: str
|
||||
path: str
|
||||
component: Optional[str] = None
|
||||
redirect: Optional[str] = None
|
||||
meta: Dict[str, Any] = {}
|
||||
children: List['MenuRouteNode'] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class MenuBatchDeleteRequest(BaseModel):
|
||||
"""批量删除菜单请求"""
|
||||
ids: List[str] = Field(..., description="要删除的菜单ID列表")
|
||||
|
||||
|
||||
class MenuBatchDeleteResponse(BaseModel):
|
||||
"""批量删除菜单响应"""
|
||||
count: int = Field(..., description="删除的记录数")
|
||||
failedIds: List[str] = Field(default=[], description="删除失败的ID列表")
|
||||
|
||||
|
||||
class MenuPathResponse(BaseModel):
|
||||
"""菜单路径响应"""
|
||||
menuId: str
|
||||
menuName: str
|
||||
path: List[MenuSimple] = Field(..., description="从根到当前菜单的路径")
|
||||
|
||||
|
||||
class MenuStatsResponse(BaseModel):
|
||||
"""菜单统计响应"""
|
||||
totalCount: int
|
||||
typeStats: Dict[str, int]
|
||||
maxLevel: int
|
||||
|
||||
|
||||
class MenuMoveRequest(BaseModel):
|
||||
"""移动菜单请求"""
|
||||
menuId: str = Field(..., description="要移动的菜单ID")
|
||||
newParentId: Optional[str] = Field(None, description="新父菜单ID,为空表示移动到根节点")
|
||||
|
||||
|
||||
class MenuCheckNameRequest(BaseModel):
|
||||
"""检查菜单名称请求"""
|
||||
name: str = Field(..., description="菜单名称")
|
||||
exclude_id: Optional[str] = Field(None, description="排除的菜单ID")
|
||||
|
||||
|
||||
class MenuCheckPathRequest(BaseModel):
|
||||
"""检查路由路径请求"""
|
||||
path: str = Field(..., description="路由路径")
|
||||
exclude_id: Optional[str] = Field(None, description="排除的菜单ID")
|
||||
@@ -0,0 +1,728 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Menu Service - 菜单服务层
|
||||
"""
|
||||
from typing import List, Optional, Tuple, Dict, Any
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.base_service import BaseService
|
||||
from utils.redis import CacheManager
|
||||
from core.menu.model import Menu
|
||||
from core.menu.schema import MenuCreate, MenuUpdate
|
||||
|
||||
# 菜单缓存管理器
|
||||
menu_cache = CacheManager(prefix="menu:")
|
||||
|
||||
# 缓存key
|
||||
MENU_TREE_CACHE_KEY = "tree"
|
||||
USER_ROUTE_CACHE_PREFIX = "user_route:"
|
||||
|
||||
|
||||
class MenuService(BaseService[Menu, MenuCreate, MenuUpdate]):
|
||||
"""
|
||||
菜单服务层
|
||||
继承BaseService,自动获得增删改查功能
|
||||
"""
|
||||
|
||||
model = Menu
|
||||
|
||||
@classmethod
|
||||
async def get_by_name(cls, db: AsyncSession, name: str) -> Optional[Menu]:
|
||||
"""根据菜单名称获取菜单"""
|
||||
result = await db.execute(
|
||||
select(Menu).where(
|
||||
Menu.name == name,
|
||||
Menu.is_deleted == False # noqa: E712
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@classmethod
|
||||
async def get_by_path(cls, db: AsyncSession, path: str) -> Optional[Menu]:
|
||||
"""根据路由路径获取菜单"""
|
||||
result = await db.execute(
|
||||
select(Menu).where(
|
||||
Menu.path == path,
|
||||
Menu.is_deleted == False # noqa: E712
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@classmethod
|
||||
async def check_name_exists(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
exclude_id: Optional[str] = None,
|
||||
application_id: Optional[str] = None
|
||||
) -> bool:
|
||||
"""检查菜单名称是否存在(在指定应用范围内)"""
|
||||
query = select(Menu).where(
|
||||
Menu.name == name,
|
||||
Menu.is_deleted == False # noqa: E712
|
||||
)
|
||||
if exclude_id:
|
||||
query = query.where(Menu.id != exclude_id)
|
||||
if application_id:
|
||||
query = query.where(Menu.application_id == application_id)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
@classmethod
|
||||
async def check_path_exists(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
path: str,
|
||||
exclude_id: Optional[str] = None,
|
||||
application_id: Optional[str] = None
|
||||
) -> bool:
|
||||
"""检查路由路径是否存在(在指定应用范围内)"""
|
||||
query = select(Menu).where(
|
||||
Menu.path == path,
|
||||
Menu.is_deleted == False # noqa: E712
|
||||
)
|
||||
if exclude_id:
|
||||
query = query.where(Menu.id != exclude_id)
|
||||
if application_id:
|
||||
query = query.where(Menu.application_id == application_id)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
@classmethod
|
||||
async def get_children(cls, db: AsyncSession, parent_id: Optional[str]) -> List[Menu]:
|
||||
"""获取直接子菜单"""
|
||||
if parent_id:
|
||||
query = select(Menu).where(
|
||||
Menu.parent_id == parent_id,
|
||||
Menu.is_deleted == False # noqa: E712
|
||||
).order_by(Menu.order)
|
||||
else:
|
||||
query = select(Menu).where(
|
||||
Menu.parent_id.is_(None),
|
||||
Menu.is_deleted == False # noqa: E712
|
||||
).order_by(Menu.order)
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def get_child_count(cls, db: AsyncSession, menu_id: str) -> int:
|
||||
"""获取直接子菜单数量"""
|
||||
result = await db.execute(
|
||||
select(func.count(Menu.id)).where(
|
||||
Menu.parent_id == menu_id,
|
||||
Menu.is_deleted == False # noqa: E712
|
||||
)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
@classmethod
|
||||
async def get_level(cls, db: AsyncSession, menu: Menu) -> int:
|
||||
"""计算菜单层级"""
|
||||
level = 0
|
||||
current = menu
|
||||
while current.parent_id:
|
||||
level += 1
|
||||
parent = await cls.get_by_id(db, current.parent_id)
|
||||
if not parent:
|
||||
break
|
||||
current = parent
|
||||
return level
|
||||
|
||||
@classmethod
|
||||
async def get_ancestors(cls, db: AsyncSession, menu: Menu) -> List[Menu]:
|
||||
"""获取所有祖先菜单"""
|
||||
ancestors = []
|
||||
current = menu
|
||||
while current.parent_id:
|
||||
parent = await cls.get_by_id(db, current.parent_id)
|
||||
if not parent:
|
||||
break
|
||||
ancestors.append(parent)
|
||||
current = parent
|
||||
return ancestors
|
||||
|
||||
@classmethod
|
||||
async def get_descendants(cls, db: AsyncSession, menu_id: str) -> List[Menu]:
|
||||
"""获取所有后代菜单"""
|
||||
descendants = []
|
||||
|
||||
async def collect_children(parent_id: str):
|
||||
children = await cls.get_children(db, parent_id)
|
||||
for child in children:
|
||||
descendants.append(child)
|
||||
await collect_children(child.id)
|
||||
|
||||
await collect_children(menu_id)
|
||||
return descendants
|
||||
|
||||
@classmethod
|
||||
async def can_delete(cls, db: AsyncSession, menu_id: str) -> bool:
|
||||
"""判断是否可以删除(没有子菜单)"""
|
||||
child_count = await cls.get_child_count(db, menu_id)
|
||||
return child_count == 0
|
||||
|
||||
@classmethod
|
||||
async def _expand_menu_ids_with_parents(cls, db: AsyncSession, menu_ids: List[str]) -> List[str]:
|
||||
"""
|
||||
扩展菜单ID列表,包含所有父级菜单ID
|
||||
确保菜单树的完整性
|
||||
"""
|
||||
if not menu_ids:
|
||||
return []
|
||||
|
||||
expanded_ids = set(menu_ids)
|
||||
|
||||
# 获取所有指定的菜单
|
||||
result = await db.execute(
|
||||
select(Menu).where(
|
||||
Menu.id.in_(menu_ids),
|
||||
Menu.is_deleted == False # noqa: E712
|
||||
)
|
||||
)
|
||||
menus = list(result.scalars().all())
|
||||
|
||||
# 递归获取每个菜单的所有父级
|
||||
for menu in menus:
|
||||
ancestors = await cls.get_ancestors(db, menu)
|
||||
for ancestor in ancestors:
|
||||
expanded_ids.add(ancestor.id)
|
||||
|
||||
return list(expanded_ids)
|
||||
|
||||
@classmethod
|
||||
async def get_all_menus(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
application_id: Optional[str] = None,
|
||||
include_system: bool = True
|
||||
) -> List[Menu]:
|
||||
"""获取所有菜单(可按应用过滤)
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
application_id: 应用ID,用于过滤应用菜单
|
||||
include_system: 是否包含系统菜单,默认True
|
||||
"""
|
||||
from sqlalchemy import or_
|
||||
|
||||
if application_id:
|
||||
if include_system:
|
||||
# 如果指定了应用ID且包含系统菜单,返回系统菜单 + 该应用的菜单
|
||||
result = await db.execute(
|
||||
select(Menu).where(
|
||||
or_(
|
||||
Menu.is_system == True, # noqa: E712
|
||||
Menu.application_id == application_id
|
||||
),
|
||||
Menu.is_deleted == False # noqa: E712
|
||||
).order_by(Menu.order)
|
||||
)
|
||||
else:
|
||||
# 如果指定了应用ID但不包含系统菜单,只返回该应用的菜单
|
||||
result = await db.execute(
|
||||
select(Menu).where(
|
||||
Menu.application_id == application_id,
|
||||
Menu.is_deleted == False # noqa: E712
|
||||
).order_by(Menu.order)
|
||||
)
|
||||
else:
|
||||
# 如果没有指定应用ID,返回系统菜单 + 主应用菜单
|
||||
result = await db.execute(
|
||||
select(Menu).where(
|
||||
or_(
|
||||
Menu.is_system == True, # noqa: E712
|
||||
Menu.application_id.is_(None)
|
||||
),
|
||||
Menu.is_deleted == False # noqa: E712
|
||||
).order_by(Menu.order)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def build_tree(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
application_id: Optional[str] = None,
|
||||
include_system: bool = True
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""构建菜单树(可按应用过滤)"""
|
||||
menus = await cls.get_all_menus(db, application_id=application_id, include_system=include_system)
|
||||
|
||||
# 构建菜单字典
|
||||
menu_dict = {}
|
||||
for menu in menus:
|
||||
child_count = await cls.get_child_count(db, menu.id)
|
||||
level = await cls.get_level(db, menu)
|
||||
menu_dict[menu.id] = {
|
||||
"id": menu.id,
|
||||
"parent_id": menu.parent_id,
|
||||
"application_id": menu.application_id,
|
||||
"is_system": menu.is_system,
|
||||
"name": menu.name,
|
||||
"title": menu.title,
|
||||
"authCode": menu.authCode,
|
||||
"path": menu.path,
|
||||
"type": menu.type,
|
||||
# 路由配置
|
||||
"component": menu.component,
|
||||
"redirect": menu.redirect,
|
||||
"activePath": menu.activePath,
|
||||
"query": menu.query,
|
||||
"noBasicLayout": menu.noBasicLayout,
|
||||
# 菜单展示
|
||||
"icon": menu.icon,
|
||||
"activeIcon": menu.activeIcon,
|
||||
"order": menu.order,
|
||||
"hideInMenu": menu.hideInMenu,
|
||||
"hideChildrenInMenu": menu.hideChildrenInMenu,
|
||||
"hideInBreadcrumb": menu.hideInBreadcrumb,
|
||||
# 标签页配置
|
||||
"hideInTab": menu.hideInTab,
|
||||
"affixTab": menu.affixTab,
|
||||
"affixTabOrder": menu.affixTabOrder,
|
||||
"keepAlive": menu.keepAlive,
|
||||
"maxNumOfOpenTab": menu.maxNumOfOpenTab,
|
||||
# 外部链接配置
|
||||
"link": menu.link,
|
||||
"iframeSrc": menu.iframeSrc,
|
||||
"openInNewWindow": menu.openInNewWindow,
|
||||
# 徽标配置
|
||||
"badge": menu.badge,
|
||||
"badgeType": menu.badgeType,
|
||||
"badgeVariants": menu.badgeVariants,
|
||||
# 计算字段
|
||||
"level": level,
|
||||
"childCount": child_count,
|
||||
"children": []
|
||||
}
|
||||
|
||||
# 构建树形结构
|
||||
tree = []
|
||||
for menu_id, menu_data in menu_dict.items():
|
||||
parent_id = menu_data["parent_id"]
|
||||
if parent_id is None:
|
||||
tree.append(menu_data)
|
||||
elif parent_id in menu_dict:
|
||||
menu_dict[parent_id]["children"].append(menu_data)
|
||||
|
||||
# 递归排序
|
||||
def sort_children(nodes):
|
||||
nodes.sort(key=lambda x: x["order"])
|
||||
for node in nodes:
|
||||
if node["children"]:
|
||||
sort_children(node["children"])
|
||||
|
||||
sort_children(tree)
|
||||
return tree
|
||||
|
||||
@classmethod
|
||||
async def build_route_tree(
|
||||
cls,
|
||||
menus: List[Menu],
|
||||
app_code: Optional[str] = None,
|
||||
dev_mode: bool = False,
|
||||
selected_menu_ids: Optional[List[str]] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
构建前端路由树
|
||||
|
||||
Args:
|
||||
menus: 菜单列表
|
||||
app_code: 应用编码,如果提供则为所有路径添加前缀
|
||||
dev_mode: 开发模式
|
||||
- True: 使用 /app-dev/:appCode 前缀
|
||||
- False: 使用 /app/:appCode 前缀
|
||||
selected_menu_ids: 原始选中的菜单ID列表,用于过滤非选中的叶子菜单
|
||||
"""
|
||||
# 构建菜单字典
|
||||
menu_dict = {}
|
||||
# 根据模式确定路径前缀
|
||||
path_prefix = "/app-dev" if dev_mode else "/app"
|
||||
|
||||
for menu in menus:
|
||||
meta = {
|
||||
"title": menu.title or menu.name,
|
||||
"icon": menu.icon,
|
||||
"activeIcon": menu.activeIcon,
|
||||
"order": menu.order,
|
||||
"hideInMenu": menu.hideInMenu,
|
||||
"hideChildrenInMenu": menu.hideChildrenInMenu,
|
||||
"hideInBreadcrumb": menu.hideInBreadcrumb,
|
||||
"hideInTab": menu.hideInTab,
|
||||
"affixTab": menu.affixTab,
|
||||
"affixTabOrder": menu.affixTabOrder,
|
||||
"keepAlive": menu.keepAlive,
|
||||
"maxNumOfOpenTab": menu.maxNumOfOpenTab,
|
||||
"fullPathKey": menu.fullPathKey if hasattr(menu, 'fullPathKey') else True,
|
||||
"noBasicLayout": menu.noBasicLayout,
|
||||
"badge": menu.badge,
|
||||
"badgeType": menu.badgeType,
|
||||
"badgeVariants": menu.badgeVariants,
|
||||
"link": menu.link,
|
||||
"iframeSrc": menu.iframeSrc,
|
||||
"openInNewWindow": menu.openInNewWindow,
|
||||
"activePath": menu.activePath,
|
||||
"authCode": menu.authCode,
|
||||
}
|
||||
# 移除None值
|
||||
meta = {k: v for k, v in meta.items() if v is not None}
|
||||
if menu.query and isinstance(menu.query, dict):
|
||||
meta.update(menu.query)
|
||||
|
||||
# 如果是子应用,为所有菜单路径添加前缀(包括系统菜单)
|
||||
path = menu.path
|
||||
redirect = menu.redirect
|
||||
if app_code and path:
|
||||
# 为所有绝对路径添加前缀(以 / 开头的路径)
|
||||
if path.startswith('/'):
|
||||
path = f"{path_prefix}/{app_code}{path}"
|
||||
if redirect and redirect.startswith('/'):
|
||||
redirect = f"{path_prefix}/{app_code}{redirect}"
|
||||
|
||||
menu_dict[menu.id] = {
|
||||
"name": menu.name,
|
||||
"path": path,
|
||||
"component": menu.component,
|
||||
"redirect": redirect,
|
||||
"meta": meta,
|
||||
"children": [],
|
||||
"_parent_id": menu.parent_id,
|
||||
"_order": menu.order,
|
||||
}
|
||||
|
||||
# 构建树形结构
|
||||
tree = []
|
||||
for menu_id, menu_data in menu_dict.items():
|
||||
parent_id = menu_data.pop("_parent_id")
|
||||
if parent_id is None:
|
||||
tree.append(menu_data)
|
||||
elif parent_id in menu_dict:
|
||||
menu_dict[parent_id]["children"].append(menu_data)
|
||||
|
||||
# 递归排序、过滤和清理
|
||||
def sort_filter_and_clean(nodes, selected_ids: Optional[List[str]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
递归处理节点:排序、过滤非选中的叶子节点、清理空children
|
||||
返回过滤后的节点列表
|
||||
"""
|
||||
nodes.sort(key=lambda x: x.pop("_order", 0))
|
||||
result = []
|
||||
for node in nodes:
|
||||
if node["children"]:
|
||||
# 有子节点,递归处理
|
||||
node["children"] = sort_filter_and_clean(node["children"], selected_ids)
|
||||
# 如果子节点全被过滤掉了,检查当前节点是否在选中列表中
|
||||
if node["children"]:
|
||||
result.append(node)
|
||||
elif selected_ids is None or node.get("_menu_id") in selected_ids:
|
||||
node.pop("children", None)
|
||||
result.append(node)
|
||||
else:
|
||||
node.pop("children", None)
|
||||
# 叶子节点:如果没有指定选中列表,或者在选中列表中,则保留
|
||||
if selected_ids is None or node.get("_menu_id") in selected_ids:
|
||||
result.append(node)
|
||||
# 清理临时字段
|
||||
node.pop("_menu_id", None)
|
||||
return result
|
||||
|
||||
# 如果有选中列表,需要在menu_dict中保存menu_id用于过滤
|
||||
if selected_menu_ids:
|
||||
for menu_id, menu_data in menu_dict.items():
|
||||
menu_data["_menu_id"] = menu_id
|
||||
|
||||
tree = sort_filter_and_clean(tree, selected_menu_ids)
|
||||
return tree
|
||||
|
||||
@classmethod
|
||||
async def get_menu_tree_cached(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
application_id: Optional[str] = None,
|
||||
include_system: bool = True
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取菜单树(带缓存)"""
|
||||
# 根据 application_id 和 include_system 生成不同的缓存 key
|
||||
cache_key = MENU_TREE_CACHE_KEY
|
||||
if application_id:
|
||||
cache_key = f"{MENU_TREE_CACHE_KEY}:app:{application_id}"
|
||||
if not include_system:
|
||||
cache_key = f"{cache_key}:no_system"
|
||||
|
||||
# 尝试从缓存获取
|
||||
cached = await menu_cache.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# 从数据库构建
|
||||
tree = await cls.build_tree(db, application_id=application_id, include_system=include_system)
|
||||
|
||||
# 缓存结果(1小时)
|
||||
await menu_cache.set(cache_key, tree, expire=3600)
|
||||
|
||||
return tree
|
||||
|
||||
@classmethod
|
||||
async def get_user_route_tree(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
user_id: str,
|
||||
is_superuser: bool = False,
|
||||
role_menu_ids: Optional[List[str]] = None,
|
||||
application_code: Optional[str] = None,
|
||||
dev_mode: bool = False
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取用户路由树
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
user_id: 用户ID
|
||||
is_superuser: 是否超级管理员
|
||||
role_menu_ids: 角色关联的菜单ID列表
|
||||
application_code: 应用编码
|
||||
dev_mode: 开发模式
|
||||
- True: 只返回系统菜单(is_system=True),用于 /app-dev/{code}/ 路径
|
||||
- False: 只返回应用专属菜单(application_id=app.id),用于 /app/{code}/ 路径
|
||||
"""
|
||||
# 如果指定了应用编码,缓存key包含应用编码和模式
|
||||
cache_key = f"{USER_ROUTE_CACHE_PREFIX}{user_id}"
|
||||
if application_code:
|
||||
mode_suffix = "dev" if dev_mode else "normal"
|
||||
cache_key = f"{cache_key}:app:{application_code}:{mode_suffix}"
|
||||
|
||||
# 尝试从缓存获取
|
||||
cached = await menu_cache.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# 获取菜单
|
||||
menus = []
|
||||
app = None # 初始化app变量
|
||||
|
||||
if application_code:
|
||||
# 子应用模式
|
||||
from core.application.model import Application
|
||||
app_result = await db.execute(
|
||||
select(Application).where(
|
||||
Application.code == application_code,
|
||||
Application.is_deleted == False # noqa: E712
|
||||
)
|
||||
)
|
||||
app = app_result.scalar_one_or_none()
|
||||
|
||||
if app:
|
||||
if dev_mode:
|
||||
# 开发模式:只返回系统菜单
|
||||
# 如果应用配置了 system_menu_ids,则只返回选中的系统菜单及其父级菜单
|
||||
app_system_menu_ids = app.system_menu_ids if app.system_menu_ids else None
|
||||
|
||||
if is_superuser:
|
||||
if app_system_menu_ids:
|
||||
# 有配置:获取选中的菜单及其所有父级菜单
|
||||
expanded_menu_ids = await cls._expand_menu_ids_with_parents(db, app_system_menu_ids)
|
||||
from sqlalchemy import and_
|
||||
result = await db.execute(
|
||||
select(Menu).where(
|
||||
and_(
|
||||
Menu.id.in_(expanded_menu_ids),
|
||||
Menu.is_system == True, # noqa: E712
|
||||
Menu.is_deleted == False # noqa: E712
|
||||
)
|
||||
).order_by(Menu.order)
|
||||
)
|
||||
else:
|
||||
# 无配置:返回所有系统菜单
|
||||
result = await db.execute(
|
||||
select(Menu).where(
|
||||
Menu.is_system == True, # noqa: E712
|
||||
Menu.is_deleted == False # noqa: E712
|
||||
).order_by(Menu.order)
|
||||
)
|
||||
menus = list(result.scalars().all())
|
||||
elif role_menu_ids:
|
||||
from sqlalchemy import and_
|
||||
# 取角色菜单和应用配置菜单的交集
|
||||
effective_menu_ids = role_menu_ids
|
||||
if app_system_menu_ids:
|
||||
# 先扩展应用配置的菜单ID(包含父级)
|
||||
expanded_app_menu_ids = await cls._expand_menu_ids_with_parents(db, app_system_menu_ids)
|
||||
effective_menu_ids = list(set(role_menu_ids) & set(expanded_app_menu_ids))
|
||||
|
||||
result = await db.execute(
|
||||
select(Menu).where(
|
||||
and_(
|
||||
Menu.id.in_(effective_menu_ids),
|
||||
Menu.is_system == True, # noqa: E712
|
||||
Menu.is_deleted == False # noqa: E712
|
||||
)
|
||||
).order_by(Menu.order)
|
||||
)
|
||||
menus = list(result.scalars().all())
|
||||
else:
|
||||
# 正常模式:只返回应用专属菜单(不包含系统菜单)
|
||||
if is_superuser:
|
||||
result = await db.execute(
|
||||
select(Menu).where(
|
||||
Menu.application_id == app.id,
|
||||
Menu.is_deleted == False # noqa: E712
|
||||
).order_by(Menu.order)
|
||||
)
|
||||
menus = list(result.scalars().all())
|
||||
elif role_menu_ids:
|
||||
from sqlalchemy import and_
|
||||
result = await db.execute(
|
||||
select(Menu).where(
|
||||
and_(
|
||||
Menu.id.in_(role_menu_ids),
|
||||
Menu.application_id == app.id,
|
||||
Menu.is_deleted == False # noqa: E712
|
||||
)
|
||||
).order_by(Menu.order)
|
||||
)
|
||||
menus = list(result.scalars().all())
|
||||
else:
|
||||
# 主应用模式:获取系统菜单 + 无应用归属的菜单
|
||||
from sqlalchemy import or_
|
||||
if is_superuser:
|
||||
result = await db.execute(
|
||||
select(Menu).where(
|
||||
or_(
|
||||
Menu.is_system == True, # noqa: E712
|
||||
Menu.application_id.is_(None)
|
||||
),
|
||||
Menu.is_deleted == False # noqa: E712
|
||||
).order_by(Menu.order)
|
||||
)
|
||||
menus = list(result.scalars().all())
|
||||
elif role_menu_ids:
|
||||
from sqlalchemy import and_
|
||||
result = await db.execute(
|
||||
select(Menu).where(
|
||||
and_(
|
||||
Menu.id.in_(role_menu_ids),
|
||||
or_(
|
||||
Menu.is_system == True, # noqa: E712
|
||||
Menu.application_id.is_(None)
|
||||
),
|
||||
Menu.is_deleted == False # noqa: E712
|
||||
)
|
||||
).order_by(Menu.order)
|
||||
)
|
||||
menus = list(result.scalars().all())
|
||||
|
||||
# 构建路由树,传递 application_code 用于添加路径前缀
|
||||
# 开发模式使用 /app-dev/{code} 前缀,正常模式使用 /app/{code} 前缀
|
||||
# 如果是开发模式且有选中的系统菜单,传递选中列表用于过滤
|
||||
selected_ids = None
|
||||
if dev_mode and application_code and app and app.system_menu_ids:
|
||||
selected_ids = app.system_menu_ids
|
||||
tree = await cls.build_route_tree(menus, app_code=application_code, dev_mode=dev_mode, selected_menu_ids=selected_ids)
|
||||
|
||||
# 缓存结果(5分钟,权限可能变更)
|
||||
await menu_cache.set(cache_key, tree, expire=300)
|
||||
|
||||
return tree
|
||||
|
||||
@classmethod
|
||||
async def invalidate_cache(cls):
|
||||
"""清除菜单缓存(包括所有子应用的缓存)"""
|
||||
# 清除主应用菜单树缓存
|
||||
await menu_cache.delete(MENU_TREE_CACHE_KEY)
|
||||
# 清除所有子应用菜单树缓存
|
||||
await menu_cache.delete_pattern(f"{MENU_TREE_CACHE_KEY}:app:*")
|
||||
# 清除所有用户路由树缓存(包括主应用和子应用)
|
||||
await menu_cache.delete_pattern(f"{USER_ROUTE_CACHE_PREFIX}*")
|
||||
|
||||
@classmethod
|
||||
async def invalidate_app_menu_cache(cls, app_code: str):
|
||||
"""清除指定应用的菜单缓存"""
|
||||
# 清除该应用的菜单树缓存
|
||||
await menu_cache.delete_pattern(f"{MENU_TREE_CACHE_KEY}:app:{app_code}*")
|
||||
# 清除所有用户在该应用下的路由树缓存
|
||||
await menu_cache.delete_pattern(f"{USER_ROUTE_CACHE_PREFIX}*:app:{app_code}:*")
|
||||
|
||||
@classmethod
|
||||
async def move_menu(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
menu_id: str,
|
||||
new_parent_id: Optional[str]
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
移动菜单到新的父菜单下
|
||||
|
||||
:return: (是否成功, 消息)
|
||||
"""
|
||||
menu = await cls.get_by_id(db, menu_id)
|
||||
if not menu:
|
||||
return False, "菜单不存在"
|
||||
|
||||
# 检查新父菜单
|
||||
if new_parent_id:
|
||||
if new_parent_id == menu_id:
|
||||
return False, "不能将自己设置为父菜单"
|
||||
|
||||
new_parent = await cls.get_by_id(db, new_parent_id)
|
||||
if not new_parent:
|
||||
return False, "父菜单不存在"
|
||||
|
||||
# 检查是否会形成循环引用
|
||||
ancestors = await cls.get_ancestors(db, new_parent)
|
||||
ancestor_ids = [a.id for a in ancestors]
|
||||
if menu.id in ancestor_ids or menu.id == new_parent.id:
|
||||
return False, "不能移动到自己或子菜单下"
|
||||
|
||||
menu.parent_id = new_parent_id
|
||||
await db.commit()
|
||||
await cls.invalidate_cache()
|
||||
|
||||
return True, "移动成功"
|
||||
|
||||
@classmethod
|
||||
async def get_menu_stats(cls, db: AsyncSession) -> Dict[str, Any]:
|
||||
"""获取菜单统计信息"""
|
||||
# 总数
|
||||
total_result = await db.execute(
|
||||
select(func.count(Menu.id)).where(Menu.is_deleted == False) # noqa: E712
|
||||
)
|
||||
total_count = total_result.scalar() or 0
|
||||
|
||||
# 按类型统计
|
||||
type_stats = {}
|
||||
type_choices = [
|
||||
('catalog', '目录'),
|
||||
('menu', '菜单'),
|
||||
('external', '外部链接'),
|
||||
('link', '外链'),
|
||||
('embedded', '内嵌'),
|
||||
('online_form', '在线表单'),
|
||||
('online_page', '在线页面'),
|
||||
('online_report', '在线报表'),
|
||||
('agent', '智能体'),
|
||||
]
|
||||
for type_code, type_name in type_choices:
|
||||
count_result = await db.execute(
|
||||
select(func.count(Menu.id)).where(
|
||||
Menu.type == type_code,
|
||||
Menu.is_deleted == False # noqa: E712
|
||||
)
|
||||
)
|
||||
type_stats[type_name] = count_result.scalar() or 0
|
||||
|
||||
# 计算最大层级
|
||||
max_level = 0
|
||||
menus = await cls.get_all_menus(db)
|
||||
for menu in menus:
|
||||
level = await cls.get_level(db, menu)
|
||||
if level > max_level:
|
||||
max_level = level
|
||||
|
||||
return {
|
||||
"totalCount": total_count,
|
||||
"typeStats": type_stats,
|
||||
"maxLevel": max_level,
|
||||
}
|
||||
Reference in New Issue
Block a user