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
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Dict Module - 字典模块
"""
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Dict API - 字典管理接口
提供字典的 CRUD 操作
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from fastapi.responses import StreamingResponse
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.config import settings
from app.base_schema import PaginatedResponse, ResponseModel
from core.application.model import Application
from core.dict.model import Dict
from core.dict.schema import (
DictCreate, DictUpdate, DictResponse, DictSimple,
DictBatchDeleteIn, DictBatchDeleteOut,
DictBatchUpdateStatusIn, DictBatchUpdateStatusOut,
DictSearchRequest
)
from core.dict.service import DictService
router = APIRouter(prefix="/dict", tags=["字典管理"])
@router.post("", response_model=DictResponse, summary="创建字典")
async def create_dict(data: DictCreate, db: AsyncSession = Depends(get_db)):
"""创建字典"""
# 唯一性校验
if not await DictService.check_unique(db, field="code", value=data.code):
raise HTTPException(status_code=400, detail=f"字典编码已存在: {data.code}")
dict_obj = await DictService.create(db=db, data=data)
return dict_obj
@router.get("/all", response_model=List[DictSimple], summary="获取所有字典(简化版)")
async def get_all_dicts(
application_id: str = Query(None, alias="applicationId", description="所属应用ID"),
db: AsyncSession = Depends(get_db),
):
"""获取所有启用的字典(用于选择器)"""
dicts = await DictService.get_all_active(db, application_id=application_id)
return dicts
@router.get("", response_model=PaginatedResponse[DictResponse], summary="获取字典列表")
async def get_dict_list(
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=settings.PAGE_SIZE, ge=1, le=settings.PAGE_MAX_SIZE, alias="pageSize", description="每页数量"),
application_id: str = Query(None, alias="applicationId", description="所属应用ID"),
name: Optional[str] = Query(default=None, description="字典名称"),
code: Optional[str] = Query(default=None, description="字典编码"),
status: Optional[bool] = Query(default=None, description="状态"),
db: AsyncSession = Depends(get_db)
):
"""获取字典列表(分页,自动应用数据权限)"""
filters = []
# 应用过滤(同时包含全局可见的字典)
if application_id:
filters.append(or_(
Dict.application_id == application_id,
Dict.is_global == True # noqa: E712
))
else:
filters.append(or_(
Dict.application_id.is_(None),
Dict.is_global == True # noqa: E712
))
if name:
filters.append(Dict.name.ilike(f"%{name}%"))
if code:
filters.append(Dict.code.ilike(f"%{code}%"))
if status is not None:
filters.append(Dict.status == status)
# 使用带数据权限的列表查询
items, total = await DictService.get_list_with_data_scope(db, page=page, page_size=page_size, filters=filters)
# 批量查询应用名称
app_ids = list({item.application_id for item in items if item.application_id})
app_name_map = {}
if app_ids:
app_result = await db.execute(
select(Application.id, Application.name).where(Application.id.in_(app_ids))
)
app_name_map = {row.id: row.name for row in app_result}
for item in items:
item.application_name = app_name_map.get(item.application_id, "")
return PaginatedResponse(items=items, total=total)
@router.post("/batch/delete", response_model=DictBatchDeleteOut, summary="批量删除字典")
async def batch_delete_dicts(
data: DictBatchDeleteIn,
db: AsyncSession = Depends(get_db)
):
"""批量删除字典"""
count, failed_ids = await DictService.batch_delete(db, data.ids)
return DictBatchDeleteOut(count=count, failed_ids=failed_ids)
@router.post("/batch/update_status", response_model=DictBatchUpdateStatusOut, summary="批量更新字典状态")
async def batch_update_dict_status(
data: DictBatchUpdateStatusIn,
db: AsyncSession = Depends(get_db)
):
"""批量更新字典状态"""
count = await DictService.batch_update_status(db, data.ids, data.status)
return DictBatchUpdateStatusOut(count=count)
@router.post("/search", response_model=PaginatedResponse[DictResponse], summary="搜索字典")
async def search_dicts(
data: DictSearchRequest,
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=settings.PAGE_SIZE, ge=1, le=settings.PAGE_MAX_SIZE, alias="pageSize", description="每页数量"),
db: AsyncSession = Depends(get_db)
):
"""搜索字典"""
items, total = await DictService.search(db, data.keyword, page, page_size)
return PaginatedResponse(items=items, total=total)
@router.get("/export/excel", summary="导出字典Excel")
async def export_dict_excel(db: AsyncSession = Depends(get_db)):
"""导出字典到Excel"""
output = await DictService.export_to_excel(db)
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=dict_export.xlsx"}
)
@router.get("/import/template", summary="下载字典导入模板")
async def download_dict_template():
"""下载字典导入模板"""
output = DictService.get_import_template()
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=dict_template.xlsx"}
)
@router.post("/import/excel", response_model=ResponseModel, summary="导入字典Excel")
async def import_dict_excel(
file: UploadFile = File(..., description="Excel文件(.xlsx)"),
db: AsyncSession = Depends(get_db)
):
"""从Excel导入字典"""
if not file.filename.endswith(".xlsx"):
raise HTTPException(status_code=400, detail="只支持.xlsx格式")
content = await file.read()
success, fail = await DictService.import_from_excel(db, content)
return ResponseModel(message=f"成功{success}条,失败{fail}", data={"success": success, "fail": fail})
@router.get("/check/unique", response_model=ResponseModel, summary="检查字典唯一性")
async def check_dict_unique(
field: str = Query(..., description="字段名"),
value: str = Query(..., description="字段值"),
exclude_id: str = Query(default=None, alias="excludeId", description="排除ID"),
db: AsyncSession = Depends(get_db)
):
"""检查字典字段唯一性"""
allowed_fields = ["code"]
if field not in allowed_fields:
raise HTTPException(status_code=400, detail=f"不支持检查字段: {field}")
is_unique = await DictService.check_unique(db, field=field, value=value, exclude_id=exclude_id)
return ResponseModel(message="可用" if is_unique else "已存在", data={"unique": is_unique})
@router.get("/by/code/{code}", response_model=DictResponse, summary="根据编码获取字典")
async def get_dict_by_code(code: str, db: AsyncSession = Depends(get_db)):
"""根据编码获取字典"""
dict_obj = await DictService.get_by_code(db, code)
if dict_obj is None:
raise HTTPException(status_code=404, detail=f"字典编码不存在: {code}")
return dict_obj
@router.get("/{dict_id}", response_model=DictResponse, summary="获取字典详情")
async def get_dict_by_id(dict_id: str, db: AsyncSession = Depends(get_db)):
"""获取字典详情"""
dict_obj = await DictService.get_by_id(db, dict_id)
if dict_obj is None:
raise HTTPException(status_code=404, detail="字典不存在")
return dict_obj
@router.put("/{dict_id}", response_model=DictResponse, summary="更新字典")
async def update_dict(dict_id: str, data: DictUpdate, db: AsyncSession = Depends(get_db)):
"""更新字典"""
# 唯一性校验(排除自身)
if data.code and not await DictService.check_unique(db, field="code", value=data.code, exclude_id=dict_id):
raise HTTPException(status_code=400, detail=f"字典编码已存在: {data.code}")
dict_obj = await DictService.update(db, record_id=dict_id, data=data)
if dict_obj is None:
raise HTTPException(status_code=404, detail="字典不存在")
return dict_obj
@router.delete("/{dict_id}", response_model=ResponseModel, summary="删除字典")
async def delete_dict(
dict_id: str,
hard: bool = Query(default=False, description="是否物理删除"),
db: AsyncSession = Depends(get_db)
):
"""删除字典"""
success = await DictService.delete(db, record_id=dict_id, hard=hard)
if not success:
raise HTTPException(status_code=404, detail="字典不存在")
return ResponseModel(message="删除成功")
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Dict Model - 字典模型
用于管理系统字典数据
"""
from sqlalchemy import Column, String, Boolean, Text
from app.base_model import BaseModel
class Dict(BaseModel):
"""
系统字典表
字段说明:
- name: 字典名称
- code: 字典编码(唯一)
- status: 状态
- remark: 备注
"""
__tablename__ = "core_dict"
# 所属应用(逻辑外键关联 core_application
application_id = Column(String(21), nullable=True, index=True, comment="所属应用ID")
# 是否全局可见(开启后在任何应用中都可访问)
is_global = Column(Boolean, default=False, comment="是否全局可见")
# 字典名称
name = Column(String(100), nullable=False, index=True, comment="字典名称")
# 字典编码
code = Column(String(100), unique=True, nullable=False, index=True, comment="字典编码")
# 状态
status = Column(Boolean, default=True, index=True, comment="状态")
# 备注
remark = Column(Text, nullable=True, comment="备注")
def __str__(self):
return f"{self.name} ({self.code})"
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Dict Schema - 字典数据验证模式
"""
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.base_schema import CSTDatetime
class DictBase(BaseModel):
"""字典基础Schema"""
application_id: Optional[str] = Field(None, description="所属应用ID")
is_global: bool = Field(default=False, description="是否全局可见")
name: str = Field(..., min_length=1, max_length=100, description="字典名称")
code: str = Field(..., min_length=1, max_length=100, description="字典编码")
status: bool = Field(default=True, description="状态")
remark: Optional[str] = Field(None, description="备注")
sort: int = Field(default=0, description="排序")
@field_validator("code")
@classmethod
def validate_code(cls, v):
"""验证字典编码格式"""
if not v:
raise ValueError("字典编码不能为空")
if not v.replace('_', '').isalnum():
raise ValueError("字典编码只能包含字母、数字和下划线")
return v
class DictCreate(DictBase):
"""字典创建Schema"""
pass
class DictUpdate(BaseModel):
"""字典更新Schema - 所有字段可选"""
application_id: Optional[str] = Field(None, description="所属应用ID")
is_global: Optional[bool] = Field(None, description="是否全局可见")
name: Optional[str] = Field(None, min_length=1, max_length=100, description="字典名称")
code: Optional[str] = Field(None, min_length=1, max_length=100, description="字典编码")
status: Optional[bool] = Field(None, description="状态")
remark: Optional[str] = Field(None, description="备注")
sort: Optional[int] = Field(None, description="排序")
@field_validator("code")
@classmethod
def validate_code(cls, v):
"""验证字典编码格式"""
if v is not None:
if not v:
raise ValueError("字典编码不能为空")
if not v.replace('_', '').isalnum():
raise ValueError("字典编码只能包含字母、数字和下划线")
return v
class DictResponse(BaseModel):
"""字典响应Schema"""
id: str
application_id: Optional[str] = None
application_name: Optional[str] = None
is_global: Optional[bool] = False
name: str
code: str
status: bool
remark: Optional[str] = None
sort: int = 0
is_deleted: bool = False
sys_create_datetime: Optional[CSTDatetime] = None
sys_update_datetime: Optional[CSTDatetime] = None
model_config = ConfigDict(from_attributes=True)
@field_validator("is_global", mode="before")
@classmethod
def coerce_is_global(cls, v):
return bool(v) if v is not None else False
class DictSimple(BaseModel):
"""字典简单输出(用于选择器)"""
id: str
application_id: Optional[str] = None
is_global: Optional[bool] = False
name: str
code: str
status: bool
model_config = ConfigDict(from_attributes=True)
@field_validator("is_global", mode="before")
@classmethod
def coerce_is_global(cls, v):
return bool(v) if v is not None else False
class DictBatchDeleteIn(BaseModel):
"""批量删除字典输入"""
ids: List[str] = Field(..., description="要删除的字典ID列表")
class DictBatchDeleteOut(BaseModel):
"""批量删除字典输出"""
count: int = Field(..., description="删除的记录数")
failed_ids: List[str] = Field(default=[], description="删除失败的ID列表")
class DictBatchUpdateStatusIn(BaseModel):
"""批量更新字典状态输入"""
ids: List[str] = Field(..., description="字典ID列表")
status: bool = Field(..., description="状态")
class DictBatchUpdateStatusOut(BaseModel):
"""批量更新字典状态输出"""
count: int = Field(..., description="更新的记录数")
class DictSearchRequest(BaseModel):
"""搜索字典请求"""
keyword: str = Field(..., description="搜索关键词")
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Dict Service - 字典服务层
"""
from io import BytesIO
from typing import Tuple, Dict as DictType, Any, Optional, List
from sqlalchemy import select, or_, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.base_service import BaseService
from core.dict.model import Dict
from core.dict.schema import DictCreate, DictUpdate
class DictService(BaseService[Dict, DictCreate, DictUpdate]):
"""
字典服务层
继承BaseService,自动获得增删改查功能
数据权限:
- 使用 get_list_with_data_scope() 自动应用数据权限
- 支持本人、本部门、本部门及下级、全部等数据范围
"""
model = Dict
# 资源类型(用于数据权限配置)
RESOURCE_TYPE = "dict"
RESOURCE_DISPLAY_NAME = "字典管理"
# Excel导入导出配置
excel_columns = {
"name": "字典名称",
"code": "字典编码",
"status": "状态",
"remark": "备注",
}
excel_sheet_name = "字典列表"
@classmethod
def _export_converter(cls, item: Any) -> DictType[str, Any]:
"""导出数据转换器"""
return {
"name": item.name,
"code": item.code,
"status": "启用" if item.status else "禁用",
"remark": item.remark or "",
}
@classmethod
def _import_processor(cls, row: DictType[str, Any]) -> Optional[Dict]:
"""导入数据处理器"""
name = row.get("name")
code = row.get("code")
if not name or not code:
return None
status_str = row.get("status", "启用")
status = status_str in ("启用", "true", "True", "1", True)
return Dict(
name=str(name),
code=str(code),
status=status,
remark=str(row.get("remark") or ""),
)
@classmethod
async def export_to_excel(
cls,
db: AsyncSession,
data_converter: Any = None
) -> BytesIO:
"""导出到Excel"""
return await super().export_to_excel(db, cls._export_converter)
@classmethod
async def import_from_excel(
cls,
db: AsyncSession,
file_content: bytes,
row_processor: Any = None
) -> Tuple[int, int]:
"""从Excel导入"""
return await super().import_from_excel(db, file_content, cls._import_processor)
@classmethod
async def get_by_code(cls, db: AsyncSession, code: str) -> Optional[Dict]:
"""根据编码获取字典"""
result = await db.execute(
select(Dict).where(
Dict.code == code,
Dict.is_deleted == False # noqa: E712
)
)
return result.scalar_one_or_none()
@classmethod
async def get_all_active(cls, db: AsyncSession, application_id: str = None) -> List[Dict]:
"""获取所有启用的字典(包含全局可见的字典)"""
conditions = [
Dict.status == True, # noqa: E712
Dict.is_deleted == False # noqa: E712
]
if application_id:
conditions.append(or_(
Dict.application_id == application_id,
Dict.is_global == True # noqa: E712
))
else:
conditions.append(or_(
Dict.application_id.is_(None),
Dict.is_global == True # noqa: E712
))
result = await db.execute(
select(Dict).where(*conditions).order_by(Dict.sort)
)
return list(result.scalars().all())
@classmethod
async def search(
cls,
db: AsyncSession,
keyword: str,
page: int = 1,
page_size: int = 20
) -> Tuple[List[Dict], int]:
"""搜索字典"""
filters = [
or_(
Dict.name.ilike(f"%{keyword}%"),
Dict.code.ilike(f"%{keyword}%"),
)
]
return await cls.get_list(db, page=page, page_size=page_size, filters=filters)
@classmethod
async def batch_delete(
cls,
db: AsyncSession,
ids: List[str],
hard: bool = False
) -> Tuple[int, List[str]]:
"""
批量删除字典
:return: (成功数量, 失败的ID列表)
"""
success_count = 0
failed_ids = []
for dict_id in ids:
try:
success = await cls.delete(db, record_id=dict_id, hard=hard)
if success:
success_count += 1
else:
failed_ids.append(dict_id)
except Exception:
failed_ids.append(dict_id)
return success_count, failed_ids
@classmethod
async def batch_update_status(
cls,
db: AsyncSession,
ids: List[str],
status: bool
) -> int:
"""批量更新字典状态"""
count = 0
for dict_id in ids:
dict_obj = await cls.get_by_id(db, dict_id)
if dict_obj:
dict_obj.status = status
await db.commit()
count += 1
return count