Build lightweight AI agent admin

This commit is contained in:
Codex
2026-06-08 18:14:59 +08:00
commit e164840f43
2530 changed files with 435693 additions and 0 deletions
+477
View File
@@ -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="删除成功")