feat: restore source parity and harden agent runtime

This commit is contained in:
2026-06-22 11:17:26 +08:00
parent e33f08277b
commit 0793eb82d6
596 changed files with 168879 additions and 290 deletions
@@ -0,0 +1,9 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
大屏设计器模块
"""
from online_dev.screen_design.screen_api import router as screen_router
from online_dev.screen_design.material_api import router as material_router
__all__ = ['screen_router', 'material_router']
@@ -0,0 +1,255 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
大屏素材 API
素材分类和素材管理
"""
import logging
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from online_dev.screen_design.material_schema import (
MaterialCategoryCreateIn,
MaterialCategoryUpdateIn,
MaterialCategoryOut,
MaterialCreateIn,
MaterialUpdateIn,
MaterialOut,
)
from online_dev.screen_design.material_service import MaterialCategoryService, MaterialService
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/screen/material', tags=['大屏素材'])
# ============ 素材分类 API ============
@router.get('/categories', response_model=List[MaterialCategoryOut], summary='素材分类列表')
async def list_material_categories(db: AsyncSession = Depends(get_db)):
"""获取所有素材分类"""
categories = await MaterialCategoryService.get_all(db)
result = []
for cat in categories:
count = await MaterialCategoryService.get_material_count(db, cat.id)
result.append(MaterialCategoryOut.from_orm_model(cat, material_count=count))
return result
@router.post('/categories', response_model=MaterialCategoryOut, summary='创建素材分类')
async def create_material_category(
data: MaterialCategoryCreateIn,
db: AsyncSession = Depends(get_db),
):
"""创建素材分类"""
# 检查编码是否已存在
if await MaterialCategoryService.check_code_exists(db, data.code):
raise HTTPException(status_code=400, detail=f"分类编码 {data.code} 已存在")
category = await MaterialCategoryService.create(db, data)
await db.commit()
return MaterialCategoryOut.from_orm_model(category, material_count=0)
@router.put('/categories/{category_id}', response_model=MaterialCategoryOut, summary='更新素材分类')
async def update_material_category(
category_id: str,
data: MaterialCategoryUpdateIn,
db: AsyncSession = Depends(get_db),
):
"""更新素材分类"""
category = await MaterialCategoryService.get_by_id(db, category_id)
if not category:
raise HTTPException(status_code=404, detail="分类不存在")
category = await MaterialCategoryService.update(db, category_id, data)
await db.commit()
count = await MaterialCategoryService.get_material_count(db, category.id)
return MaterialCategoryOut.from_orm_model(category, material_count=count)
@router.delete('/categories/{category_id}', summary='删除素材分类')
async def delete_material_category(
category_id: str,
db: AsyncSession = Depends(get_db),
):
"""删除素材分类(同时删除分类下的所有素材)"""
category = await MaterialCategoryService.get_by_id(db, category_id)
if not category:
raise HTTPException(status_code=404, detail="分类不存在")
await MaterialCategoryService.delete_with_materials(db, category_id)
await db.commit()
return {'success': True}
# ============ 素材 API ============
async def _get_file_info(db: AsyncSession, file_id: str):
"""获取文件信息"""
file_url = ''
file_type = 'image'
file_size = 0
if file_id:
try:
from core.file_manager.model import FileManager
stmt = select(FileManager).where(FileManager.id == file_id)
file_result = await db.execute(stmt)
file_obj = file_result.scalar_one_or_none()
if file_obj:
file_url = file_obj.url or ''
file_size = file_obj.size or 0
file_ext = (file_obj.file_ext or '').lower()
file_type = 'svg' if file_ext == 'svg' or file_ext == '.svg' else 'image'
except Exception as e:
logger.warning(f"获取文件信息失败: {e}")
return file_url, file_type, file_size
@router.get('/list', response_model=List[MaterialOut], summary='素材列表')
async def list_materials(
category_id: str = Query(default=None, alias="categoryId", description="分类ID"),
db: AsyncSession = Depends(get_db),
):
"""获取素材列表"""
materials = await MaterialService.get_list_by_category(db, category_id)
result = []
for material in materials:
# 获取分类名称
category_name = ''
if material.category_id:
category = await MaterialCategoryService.get_by_id(db, material.category_id)
if category:
category_name = category.name
# 获取文件信息
file_url, file_type, file_size = await _get_file_info(db, material.file_id)
result.append(MaterialOut.from_orm_model(
material,
category_name=category_name,
file_url=file_url,
file_type=file_type,
file_size=file_size,
))
return result
@router.post('/create', response_model=MaterialOut, summary='创建素材')
async def create_material(
data: MaterialCreateIn,
db: AsyncSession = Depends(get_db),
):
"""创建素材(关联已上传的文件)"""
# 验证分类(category_id 为空时表示无分类素材)
category = None
if data.category_id:
category = await MaterialCategoryService.get_by_id(db, data.category_id)
if not category:
raise HTTPException(status_code=400, detail="分类不存在")
# 验证文件
try:
from core.file_manager.model import FileManager
stmt = select(FileManager).where(FileManager.id == data.file_id)
file_result = await db.execute(stmt)
file_obj = file_result.scalar_one_or_none()
if not file_obj:
raise HTTPException(status_code=400, detail="文件不存在")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=400, detail=f"验证文件失败: {e}")
# 创建素材
material = await MaterialService.create(db, data)
await db.commit()
# 获取文件信息
file_url = file_obj.url or ''
file_size = file_obj.size or 0
file_ext = (file_obj.file_ext or '').lower()
file_type = 'svg' if file_ext == 'svg' or file_ext == '.svg' else 'image'
return MaterialOut.from_orm_model(
material,
category_name=category.name if category else '',
file_url=file_url,
file_type=file_type,
file_size=file_size,
)
@router.put('/{material_id}', response_model=MaterialOut, summary='更新素材')
async def update_material(
material_id: str,
data: MaterialUpdateIn,
db: AsyncSession = Depends(get_db),
):
"""更新素材信息"""
material = await MaterialService.get_by_id(db, material_id)
if not material:
raise HTTPException(status_code=404, detail="素材不存在")
# 如果更新分类,验证分类存在
if data.category_id:
category = await MaterialCategoryService.get_by_id(db, data.category_id)
if not category:
raise HTTPException(status_code=400, detail="分类不存在")
material = await MaterialService.update(db, material_id, data)
await db.commit()
# 获取分类名称
category_name = ''
if material.category_id:
category = await MaterialCategoryService.get_by_id(db, material.category_id)
if category:
category_name = category.name
# 获取文件信息
file_url, file_type, file_size = await _get_file_info(db, material.file_id)
return MaterialOut.from_orm_model(
material,
category_name=category_name,
file_url=file_url,
file_type=file_type,
file_size=file_size,
)
@router.delete('/{material_id}', summary='删除素材')
async def delete_material(
material_id: str,
db: AsyncSession = Depends(get_db),
):
"""删除素材"""
material = await MaterialService.get_by_id(db, material_id)
if not material:
raise HTTPException(status_code=404, detail="素材不存在")
await MaterialService.delete(db, material_id)
await db.commit()
return {'success': True}
@router.delete('/batch', summary='批量删除素材')
async def batch_delete_materials(
ids: List[str] = Query(..., description="素材ID列表"),
db: AsyncSession = Depends(get_db),
):
"""批量删除素材"""
count = await MaterialService.batch_delete(db, ids)
await db.commit()
return {'count': count}
@@ -0,0 +1,32 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
大屏素材数据模型
"""
from sqlalchemy import Column, String
from app.base_model import BaseModel
class ScreenMaterialCategory(BaseModel):
"""大屏素材分类"""
__tablename__ = "screen_material_category"
name = Column(String(50), nullable=False, comment="分类名称")
code = Column(String(50), unique=True, index=True, nullable=False, comment="分类编码")
icon = Column(String(50), default='Image', comment="图标名称")
def __repr__(self):
return f"<ScreenMaterialCategory {self.name}>"
class ScreenMaterial(BaseModel):
"""大屏素材"""
__tablename__ = "screen_material"
category_id = Column(String(32), nullable=True, index=True, comment="分类ID")
name = Column(String(100), nullable=False, comment="素材名称")
file_id = Column(String(32), nullable=True, comment="文件ID(关联文件管理器)")
def __repr__(self):
return f"<ScreenMaterial {self.name}>"
@@ -0,0 +1,105 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
大屏素材 Schema 定义
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
# ============ 素材分类 Schema ============
class MaterialCategoryCreateIn(BaseModel):
"""创建素材分类请求"""
name: str = Field(..., description="分类名称")
code: str = Field(..., description="分类编码")
icon: str = Field(default='Image', description="图标名称")
sort: int = Field(default=0, description="排序")
class MaterialCategoryUpdateIn(BaseModel):
"""更新素材分类请求"""
name: Optional[str] = Field(default=None, description="分类名称")
icon: Optional[str] = Field(default=None, description="图标名称")
sort: Optional[int] = Field(default=None, description="排序")
class MaterialCategoryOut(BaseModel):
"""素材分类输出"""
id: str
name: str
code: str
icon: str
sort: int
material_count: int = Field(default=0, description="素材数量")
model_config = ConfigDict(from_attributes=True)
@classmethod
def from_orm_model(cls, obj, material_count: int = 0):
"""从ORM模型转换"""
return cls(
id=str(obj.id),
name=obj.name,
code=obj.code,
icon=obj.icon or 'Image',
sort=obj.sort or 0,
material_count=material_count,
)
# ============ 素材 Schema ============
class MaterialCreateIn(BaseModel):
"""创建素材请求"""
category_id: Optional[str] = Field(default=None, description="分类ID(为空表示无分类)")
name: str = Field(..., description="素材名称")
file_id: str = Field(..., description="文件ID(来自文件管理器)")
class MaterialUpdateIn(BaseModel):
"""更新素材请求"""
name: Optional[str] = Field(default=None, description="素材名称")
category_id: Optional[str] = Field(default=None, description="分类ID")
sort: Optional[int] = Field(default=None, description="排序")
class MaterialOut(BaseModel):
"""素材输出"""
id: str
name: str
category_id: str
category_name: str = Field(default='', description="分类名称")
file_id: Optional[str] = Field(default=None, description="文件ID")
file_url: str = Field(default='', description="文件URL")
file_type: str = Field(default='image', description="文件类型")
file_size: int = Field(default=0, description="文件大小")
sort: int = 0
sys_create_datetime: str
model_config = ConfigDict(from_attributes=True)
@classmethod
def from_orm_model(
cls,
obj,
category_name: str = '',
file_url: str = '',
file_type: str = 'image',
file_size: int = 0,
):
"""从ORM模型转换"""
return cls(
id=str(obj.id),
name=obj.name,
category_id=str(obj.category_id) if obj.category_id else '',
category_name=category_name,
file_id=str(obj.file_id) if obj.file_id else None,
file_url=file_url,
file_type=file_type,
file_size=file_size,
sort=obj.sort or 0,
sys_create_datetime=obj.sys_create_datetime.strftime('%Y-%m-%d %H:%M:%S') if obj.sys_create_datetime else '',
)
@@ -0,0 +1,100 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
大屏素材服务
"""
import logging
from typing import List
from sqlalchemy import select, func, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.base_service import BaseService
from online_dev.screen_design.material_model import ScreenMaterialCategory, ScreenMaterial
from online_dev.screen_design.material_schema import (
MaterialCategoryCreateIn, MaterialCategoryUpdateIn,
MaterialCreateIn, MaterialUpdateIn,
)
logger = logging.getLogger(__name__)
class MaterialCategoryService(BaseService[ScreenMaterialCategory, MaterialCategoryCreateIn, MaterialCategoryUpdateIn]):
"""素材分类服务"""
model = ScreenMaterialCategory
@classmethod
async def get_all(cls, db: AsyncSession) -> List[ScreenMaterialCategory]:
"""获取所有素材分类"""
stmt = select(ScreenMaterialCategory).where(
ScreenMaterialCategory.is_deleted == False
).order_by(ScreenMaterialCategory.sort.asc(), ScreenMaterialCategory.sys_create_datetime.desc())
result = await db.execute(stmt)
return list(result.scalars().all())
@classmethod
async def check_code_exists(cls, db: AsyncSession, code: str, exclude_id: str = None) -> bool:
"""检查编码是否存在"""
stmt = select(ScreenMaterialCategory).where(
ScreenMaterialCategory.code == code,
ScreenMaterialCategory.is_deleted == False,
)
if exclude_id:
stmt = stmt.where(ScreenMaterialCategory.id != exclude_id)
result = await db.execute(stmt)
return result.scalar_one_or_none() is not None
@classmethod
async def get_material_count(cls, db: AsyncSession, category_id: str) -> int:
"""获取分类下的素材数量"""
stmt = select(func.count()).where(
ScreenMaterial.category_id == category_id,
ScreenMaterial.is_deleted == False,
)
result = await db.execute(stmt)
return result.scalar() or 0
@classmethod
async def delete_with_materials(cls, db: AsyncSession, category_id: str) -> bool:
"""删除分类及其下的所有素材"""
# 删除分类下的所有素材(硬删除)
await db.execute(
delete(ScreenMaterial).where(ScreenMaterial.category_id == category_id)
)
# 删除分类(硬删除)
await db.execute(
delete(ScreenMaterialCategory).where(ScreenMaterialCategory.id == category_id)
)
return True
class MaterialService(BaseService[ScreenMaterial, MaterialCreateIn, MaterialUpdateIn]):
"""素材服务"""
model = ScreenMaterial
@classmethod
async def get_list_by_category(
cls,
db: AsyncSession,
category_id: str = None,
) -> List[ScreenMaterial]:
"""获取素材列表"""
stmt = select(ScreenMaterial).where(ScreenMaterial.is_deleted == False)
if category_id:
stmt = stmt.where(ScreenMaterial.category_id == category_id)
stmt = stmt.order_by(ScreenMaterial.sort.asc(), ScreenMaterial.sys_create_datetime.desc())
result = await db.execute(stmt)
return list(result.scalars().all())
@classmethod
async def batch_delete(cls, db: AsyncSession, material_ids: List[str]) -> int:
"""批量删除素材(硬删除)"""
stmt = delete(ScreenMaterial).where(ScreenMaterial.id.in_(material_ids))
result = await db.execute(stmt)
return result.rowcount
@@ -0,0 +1,276 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
大屏设计器 API
大屏项目的 CRUD、发布、复制、导入导出
"""
import json
import logging
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.config import settings
from app.base_schema import PaginatedResponse
from online_dev.screen_design.screen_model import ScreenProject
from online_dev.screen_design.screen_schema import (
ScreenProjectCreateIn,
ScreenProjectUpdateIn,
ScreenProjectOut,
ScreenProjectListOut,
ScreenPublishIn,
ScreenSetPasswordIn,
ScreenImportCheckIn,
ScreenImportCheckOut,
ScreenImportIn,
)
from online_dev.screen_design.screen_service import ScreenProjectService, ScreenServiceException
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/screen', tags=['大屏设计器'])
@router.get('/list', response_model=PaginatedResponse[ScreenProjectListOut], summary='大屏项目列表')
async def list_projects(
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="每页数量"),
name: str = Query(default=None, description="项目名称"),
code: str = Query(default=None, description="项目编码"),
status: str = Query(default=None, description="状态"),
application_id: str = Query(default=None, alias="applicationId", description="应用ID"),
db: AsyncSession = Depends(get_db),
):
"""分页查询大屏项目列表(自动应用数据权限)"""
# 构建过滤条件
filters = []
# 应用过滤
if application_id is not None:
if application_id == "":
filters.append(ScreenProject.application_id.is_(None))
else:
filters.append(ScreenProject.application_id == application_id)
if name:
filters.append(ScreenProject.name.contains(name))
if code:
filters.append(ScreenProject.code.contains(code))
if status:
filters.append(ScreenProject.status == status)
# 使用带数据权限的列表查询
items, total = await ScreenProjectService.get_list_with_data_scope(
db, page=page, page_size=page_size, filters=filters
)
# 转换为输出格式
result_items = [ScreenProjectListOut.from_orm_model(item) for item in items]
return PaginatedResponse(items=result_items, total=total)
@router.get('/code/{code}', response_model=ScreenProjectOut, summary='根据编码获取大屏项目')
async def get_project_by_code(
code: str,
db: AsyncSession = Depends(get_db),
):
"""根据编码获取大屏项目详情"""
project = await ScreenProjectService.get_by_code(db, code)
if not project:
raise HTTPException(status_code=400, detail=f"大屏项目不存在: {code}")
return ScreenProjectOut.from_orm_model(project)
@router.get('/{project_id}', response_model=ScreenProjectOut, summary='大屏项目详情')
async def get_project(
project_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取大屏项目详情"""
project = await ScreenProjectService.get_by_id(db, project_id)
if not project:
raise HTTPException(status_code=400, detail="大屏项目不存在")
return ScreenProjectOut.from_orm_model(project)
@router.post('', response_model=ScreenProjectOut, summary='创建大屏项目')
async def create_project(
data: ScreenProjectCreateIn,
db: AsyncSession = Depends(get_db),
):
"""创建大屏项目"""
# 检查编码唯一性
if await ScreenProjectService.check_code_exists(db, data.code):
raise HTTPException(status_code=400, detail=f"项目编码已存在: {data.code}")
project = await ScreenProjectService.create(db, data)
await db.commit()
logger.info(f"大屏项目创建成功: {project.code}")
return ScreenProjectOut.from_orm_model(project)
@router.put('/{project_id}', response_model=ScreenProjectOut, summary='更新大屏项目')
async def update_project(
project_id: str,
data: ScreenProjectUpdateIn,
db: AsyncSession = Depends(get_db),
):
"""更新大屏项目"""
project = await ScreenProjectService.update(db, project_id, data)
if not project:
raise HTTPException(status_code=400, detail="大屏项目不存在")
await db.commit()
logger.info(f"大屏项目更新成功: {project.code}")
return ScreenProjectOut.from_orm_model(project)
@router.delete('/{project_id}', response_model=ScreenProjectOut, summary='删除大屏项目')
async def delete_project(
project_id: str,
db: AsyncSession = Depends(get_db),
):
"""删除大屏项目"""
project = await ScreenProjectService.get_by_id(db, project_id)
if not project:
raise HTTPException(status_code=400, detail="大屏项目不存在")
result = ScreenProjectOut.from_orm_model(project)
await ScreenProjectService.delete(db, project_id)
await db.commit()
return result
@router.delete('/batch', summary='批量删除大屏项目')
async def batch_delete_projects(
ids: List[str] = Query(..., description="项目ID列表"),
db: AsyncSession = Depends(get_db),
):
"""批量删除大屏项目"""
count = await ScreenProjectService.batch_delete(db, ids)
await db.commit()
return {'count': count}
@router.post('/{project_id}/publish', response_model=ScreenProjectOut, summary='发布大屏')
async def publish_project(
project_id: str,
data: ScreenPublishIn = None,
db: AsyncSession = Depends(get_db),
):
"""发布大屏"""
try:
access_password = data.access_password if data else None
project = await ScreenProjectService.publish(db, project_id, access_password)
await db.commit()
return ScreenProjectOut.from_orm_model(project)
except ScreenServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post('/{project_id}/unpublish', response_model=ScreenProjectOut, summary='取消发布')
async def unpublish_project(
project_id: str,
db: AsyncSession = Depends(get_db),
):
"""取消发布大屏"""
try:
project = await ScreenProjectService.unpublish(db, project_id)
await db.commit()
return ScreenProjectOut.from_orm_model(project)
except ScreenServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post('/{project_id}/set-password', response_model=ScreenProjectOut, summary='设置访问密码')
async def set_access_password(
project_id: str,
data: ScreenSetPasswordIn,
db: AsyncSession = Depends(get_db),
):
"""设置或清除大屏访问密码"""
try:
project = await ScreenProjectService.set_password(db, project_id, data.access_password)
await db.commit()
return ScreenProjectOut.from_orm_model(project)
except ScreenServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post('/{project_id}/verify-password', summary='验证访问密码')
async def verify_access_password(
project_id: str,
data: ScreenSetPasswordIn,
db: AsyncSession = Depends(get_db),
):
"""验证大屏访问密码"""
try:
is_valid = await ScreenProjectService.verify_password(db, project_id, data.access_password)
return {'valid': is_valid}
except ScreenServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post('/{project_id}/copy', response_model=ScreenProjectOut, summary='复制大屏项目')
async def copy_project(
project_id: str,
new_code: str = Query(..., alias="newCode", description="新项目编码"),
new_name: str = Query(default=None, alias="newName", description="新项目名称"),
db: AsyncSession = Depends(get_db),
):
"""复制大屏项目"""
try:
project = await ScreenProjectService.copy(db, project_id, new_code, new_name)
await db.commit()
return ScreenProjectOut.from_orm_model(project)
except ScreenServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get('/{project_id}/export', summary='导出大屏配置')
async def export_project_config(
project_id: str,
db: AsyncSession = Depends(get_db),
):
"""导出大屏配置为 JSON"""
try:
config = await ScreenProjectService.export_config(db, project_id)
content = json.dumps(config, ensure_ascii=False, indent=2)
return StreamingResponse(
iter([content]),
media_type='application/json',
headers={
'Content-Disposition': f'attachment; filename="{config["code"]}.json"'
}
)
except ScreenServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post('/import/check', response_model=ScreenImportCheckOut, summary='导入预检查')
async def check_import_project_config(
data: ScreenImportCheckIn,
db: AsyncSession = Depends(get_db),
):
"""导入预检查:检查项目编码是否冲突"""
try:
return await ScreenProjectService.check_import(db, data.code)
except ScreenServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post('/import', response_model=ScreenProjectOut, summary='导入大屏配置')
async def import_project_config(
data: ScreenImportIn,
db: AsyncSession = Depends(get_db),
):
"""导入大屏配置"""
try:
project = await ScreenProjectService.import_config(db, data.model_dump())
await db.commit()
return ScreenProjectOut.from_orm_model(project)
except ScreenServiceException as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -0,0 +1,35 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
大屏设计器数据模型
"""
from sqlalchemy import Column, String, Text, Integer, JSON, DateTime
from app.base_model import BaseModel
class ScreenProject(BaseModel):
"""大屏项目"""
__tablename__ = "screen_project"
# 所属应用(逻辑外键关联 core_application
application_id = Column(String(21), nullable=True, index=True, comment="所属应用ID")
name = Column(String(100), nullable=False, comment="项目名称")
code = Column(String(100), unique=True, index=True, nullable=False, comment="项目编码")
description = Column(Text, default='', comment="项目描述")
status = Column(String(20), default='draft', comment="状态: draft/published")
version = Column(Integer, default=1, comment="版本号")
# 缩略图
thumbnail = Column(Text, default='', comment="缩略图(Base64或URL)")
# 大屏配置(存储完整的设计器配置)
screen_config = Column(JSON, default=dict, comment="大屏设计配置")
# 发布相关
access_password = Column(String(100), default='', comment="访问密码")
published_at = Column(DateTime, nullable=True, comment="发布时间")
def __repr__(self):
return f"<ScreenProject {self.name} ({self.code})>"
@@ -0,0 +1,149 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
大屏设计器 Schema 定义
"""
from datetime import datetime
from typing import Any, Dict, Optional
from pydantic import BaseModel, ConfigDict, Field
class ScreenProjectBase(BaseModel):
"""大屏项目基础信息"""
application_id: Optional[str] = Field(None, description="所属应用ID")
name: str = Field(..., description="项目名称")
code: str = Field(..., pattern=r"^[a-zA-Z][a-zA-Z0-9_]*$", description="项目编码(字母开头,只能包含字母、数字和下划线)")
description: str = Field(default='', description="项目描述")
sort: int = Field(default=0, description="排序")
class ScreenProjectCreateIn(ScreenProjectBase):
"""创建大屏项目请求"""
screen_config: Dict[str, Any] = Field(default_factory=dict, description="大屏设计配置")
thumbnail: str = Field(default='', description="缩略图")
class ScreenProjectUpdateIn(BaseModel):
"""更新大屏项目请求"""
name: Optional[str] = Field(default=None, description="项目名称")
description: Optional[str] = Field(default=None, description="项目描述")
sort: Optional[int] = Field(default=None, description="排序")
screen_config: Optional[Dict[str, Any]] = Field(default=None, description="大屏设计配置")
thumbnail: Optional[str] = Field(default=None, description="缩略图")
class ScreenProjectOut(BaseModel):
"""大屏项目详情输出"""
id: str
application_id: Optional[str] = None
name: str
code: str
description: str
status: str
version: int
thumbnail: str
screen_config: Dict[str, Any]
sort: int
access_password: str = Field(default='', description="访问密码")
published_at: Optional[str] = Field(default=None, description="发布时间")
sys_create_datetime: str
sys_update_datetime: str
model_config = ConfigDict(from_attributes=True)
@classmethod
def from_orm_model(cls, obj):
"""从ORM模型转换"""
return cls(
id=str(obj.id),
application_id=obj.application_id,
name=obj.name,
code=obj.code,
description=obj.description or '',
status=obj.status or 'draft',
version=obj.version or 1,
thumbnail=obj.thumbnail or '',
screen_config=obj.screen_config or {},
sort=obj.sort or 0,
access_password=obj.access_password or '',
published_at=obj.published_at.strftime('%Y-%m-%d %H:%M:%S') if obj.published_at else None,
sys_create_datetime=obj.sys_create_datetime.strftime('%Y-%m-%d %H:%M:%S') if obj.sys_create_datetime else '',
sys_update_datetime=obj.sys_update_datetime.strftime('%Y-%m-%d %H:%M:%S') if obj.sys_update_datetime else '',
)
class ScreenProjectListOut(BaseModel):
"""大屏项目列表输出(不含screen_config"""
id: str
application_id: Optional[str] = None
name: str
code: str
description: str
status: str
version: int
thumbnail: str
sort: int
access_password: str = Field(default='', description="访问密码")
published_at: Optional[str] = Field(default=None, description="发布时间")
sys_create_datetime: str
sys_update_datetime: str
model_config = ConfigDict(from_attributes=True)
@classmethod
def from_orm_model(cls, obj):
"""从ORM模型转换"""
return cls(
id=str(obj.id),
application_id=obj.application_id,
name=obj.name,
code=obj.code,
description=obj.description or '',
status=obj.status or 'draft',
version=obj.version or 1,
thumbnail=obj.thumbnail or '',
sort=obj.sort or 0,
access_password=obj.access_password or '',
published_at=obj.published_at.strftime('%Y-%m-%d %H:%M:%S') if obj.published_at else None,
sys_create_datetime=obj.sys_create_datetime.strftime('%Y-%m-%d %H:%M:%S') if obj.sys_create_datetime else '',
sys_update_datetime=obj.sys_update_datetime.strftime('%Y-%m-%d %H:%M:%S') if obj.sys_update_datetime else '',
)
class ScreenExportOut(BaseModel):
"""大屏配置导出"""
name: str
code: str
description: str
screen_config: Dict[str, Any]
class ScreenImportCheckIn(BaseModel):
"""大屏导入预检查请求"""
code: str = Field(..., description="项目编码")
class ScreenImportCheckOut(BaseModel):
"""大屏导入预检查结果"""
code_exists: bool = Field(..., description="项目编码是否已存在")
can_import: bool = Field(..., description="是否可以直接导入(编码不冲突)")
class ScreenImportIn(BaseModel):
"""大屏配置导入"""
application_id: Optional[str] = Field(None, description="所属应用ID")
name: str = Field(..., description="项目名称")
code: str = Field(..., description="项目编码")
description: str = Field(default='', description="项目描述")
screen_config: Dict[str, Any] = Field(default_factory=dict, description="大屏设计配置")
class ScreenPublishIn(BaseModel):
"""发布大屏请求"""
access_password: Optional[str] = Field(default=None, description="访问密码")
class ScreenSetPasswordIn(BaseModel):
"""设置访问密码请求"""
access_password: str = Field(default='', description="访问密码,空字符串表示清除密码")
@@ -0,0 +1,272 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
大屏设计器服务
"""
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.base_service import BaseService
from online_dev.screen_design.screen_model import ScreenProject
from online_dev.screen_design.screen_schema import ScreenProjectCreateIn, ScreenProjectUpdateIn
logger = logging.getLogger(__name__)
class ScreenServiceException(Exception):
"""大屏服务异常"""
pass
class ScreenProjectService(BaseService[ScreenProject, ScreenProjectCreateIn, ScreenProjectUpdateIn]):
"""
大屏项目服务
数据权限:
- 使用 get_list_with_data_scope() 自动应用数据权限
- 支持本人、本部门、本部门及下级、全部等数据范围
"""
model = ScreenProject
# 资源类型(用于数据权限配置)
RESOURCE_TYPE = "screen_project"
RESOURCE_DISPLAY_NAME = "大屏管理"
@classmethod
async def get_list(
cls,
db: AsyncSession,
page: int = 1,
page_size: int = 20,
name: str = None,
code: str = None,
status: str = None,
application_id: str = None,
) -> Tuple[List[ScreenProject], int]:
"""获取大屏项目列表"""
stmt = select(ScreenProject).where(ScreenProject.is_deleted == False)
# 应用过滤
if application_id is not None:
if application_id == "":
stmt = stmt.where(ScreenProject.application_id.is_(None))
else:
stmt = stmt.where(ScreenProject.application_id == application_id)
if name:
stmt = stmt.where(ScreenProject.name.contains(name))
if code:
stmt = stmt.where(ScreenProject.code.contains(code))
if status:
stmt = stmt.where(ScreenProject.status == status)
# 计算总数
count_stmt = select(func.count()).select_from(stmt.subquery())
total_result = await db.execute(count_stmt)
total = total_result.scalar() or 0
# 分页
stmt = stmt.order_by(ScreenProject.sort.asc(), ScreenProject.sys_create_datetime.desc())
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(stmt)
items = list(result.scalars().all())
return items, total
@classmethod
async def get_by_code(cls, db: AsyncSession, code: str) -> Optional[ScreenProject]:
"""根据编码获取大屏项目"""
stmt = select(ScreenProject).where(
ScreenProject.code == code,
ScreenProject.is_deleted == False,
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
@classmethod
async def check_code_exists(cls, db: AsyncSession, code: str, exclude_id: str = None) -> bool:
"""检查编码是否存在"""
stmt = select(ScreenProject).where(
ScreenProject.code == code,
ScreenProject.is_deleted == False,
)
if exclude_id:
stmt = stmt.where(ScreenProject.id != exclude_id)
result = await db.execute(stmt)
return result.scalar_one_or_none() is not None
@classmethod
async def publish(cls, db: AsyncSession, project_id: str, access_password: str = None) -> ScreenProject:
"""发布大屏"""
project = await cls.get_by_id(db, project_id)
if not project:
raise ScreenServiceException(f"大屏项目不存在: {project_id}")
if project.status == 'published':
raise ScreenServiceException("大屏已发布")
project.status = 'published'
project.version += 1
project.published_at = datetime.now()
if access_password is not None:
project.access_password = access_password
db.add(project)
await db.flush()
await db.refresh(project)
logger.info(f"大屏发布成功: {project.code}, version={project.version}")
return project
@classmethod
async def unpublish(cls, db: AsyncSession, project_id: str) -> ScreenProject:
"""取消发布大屏"""
project = await cls.get_by_id(db, project_id)
if not project:
raise ScreenServiceException(f"大屏项目不存在: {project_id}")
if project.status == 'draft':
raise ScreenServiceException("大屏未发布")
project.status = 'draft'
db.add(project)
await db.flush()
await db.refresh(project)
logger.info(f"大屏取消发布: {project.code}")
return project
@classmethod
async def set_password(cls, db: AsyncSession, project_id: str, password: str) -> ScreenProject:
"""设置或清除访问密码"""
project = await cls.get_by_id(db, project_id)
if not project:
raise ScreenServiceException(f"大屏项目不存在: {project_id}")
project.access_password = password
db.add(project)
await db.flush()
await db.refresh(project)
logger.info(f"大屏密码已{'设置' if password else '清除'}: {project.code}")
return project
@classmethod
async def verify_password(cls, db: AsyncSession, project_id: str, password: str) -> bool:
"""验证访问密码"""
project = await cls.get_by_id(db, project_id)
if not project:
raise ScreenServiceException(f"大屏项目不存在: {project_id}")
# 如果没有设置密码,直接返回 True
if not project.access_password:
return True
return project.access_password == password
@classmethod
async def copy(
cls,
db: AsyncSession,
project_id: str,
new_code: str,
new_name: str = None,
) -> ScreenProject:
"""复制大屏项目"""
source = await cls.get_by_id(db, project_id)
if not source:
raise ScreenServiceException(f"大屏项目不存在: {project_id}")
# 检查新编码唯一性
if await cls.check_code_exists(db, new_code):
raise ScreenServiceException(f"项目编码已存在: {new_code}")
new_project = ScreenProject(
application_id=source.application_id,
name=new_name or f"{source.name}_副本",
code=new_code,
description=source.description,
status='draft',
version=1,
thumbnail=source.thumbnail,
screen_config=source.screen_config,
sort=source.sort,
)
db.add(new_project)
await db.flush()
await db.refresh(new_project)
logger.info(f"大屏项目复制成功: {source.code} -> {new_code}")
return new_project
@classmethod
async def export_config(cls, db: AsyncSession, project_id: str) -> Dict[str, Any]:
"""导出大屏配置"""
project = await cls.get_by_id(db, project_id)
if not project:
raise ScreenServiceException(f"大屏项目不存在: {project_id}")
return {
'name': project.name,
'code': project.code,
'description': project.description,
'screen_config': project.screen_config,
}
@classmethod
async def check_import(cls, db: AsyncSession, code: str) -> Dict[str, Any]:
"""导入预检查:编码是否冲突"""
code_exists = False
if code:
code_exists = await cls.check_code_exists(db, code)
return {
'code_exists': code_exists,
'can_import': not code_exists,
}
@classmethod
async def import_config(cls, db: AsyncSession, data: Dict[str, Any]) -> ScreenProject:
"""导入大屏配置"""
required_fields = ['name', 'code']
for field in required_fields:
if not data.get(field):
raise ScreenServiceException(f"缺少必要字段: {field}")
if await cls.check_code_exists(db, data['code']):
raise ScreenServiceException(f"项目编码已存在: {data['code']}")
project = ScreenProject(
application_id=data.get('application_id'),
name=data['name'],
code=data['code'],
description=data.get('description', ''),
screen_config=data.get('screen_config', {}),
)
db.add(project)
await db.flush()
await db.refresh(project)
logger.info(f"大屏项目导入成功: {project.code}")
return project
@classmethod
async def batch_delete(cls, db: AsyncSession, project_ids: List[str]) -> int:
"""批量删除大屏项目"""
stmt = (
update(ScreenProject)
.where(ScreenProject.id.in_(project_ids), ScreenProject.is_deleted == False)
.values(is_deleted=True)
)
result = await db.execute(stmt)
count = result.rowcount
logger.info(f"批量删除大屏项目成功: {count}")
return count