Files
jiaoyu/backend/routers/animation.py
T
Zhang Jing Xuan 0841b1a103 智教助手平台:完整初始化
- 前端:Vue3 + TS + Element Plus,24 个页面路由(课件/组题/教案/动画/思维导图/作文批改/命题/课堂/资源/社区等)
- 后端:FastAPI + SQLAlchemy + SQLite,17 个路由模块,AI 服务层含降级模板
- AI:glm-5.x 推理模型已禁用思维链,确保输出真实内容
- 修复:ai_service 两处请求体注入 enable_thinking/thinking disabled
- 测试账号:13900999999 / test1234
2026-07-29 16:29:10 +08:00

189 lines
6.9 KiB
Python

import re
from html import escape
from io import BytesIO
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from typing import Optional
from database import get_db
from models.animation import Animation
from models.resource import Resource
from models.user import User
from schemas.animation import AnimationGenerate, AnimationCreate, AnimationUpdate, AnimationOut
from services.credits import credits_payload, spend_credits
from services.limiter import limiter
from services.auth import get_current_user, get_optional_current_user
from services.ai_service import AIService
router = APIRouter(prefix="/api/animations", tags=["教学动画"])
ai_service = AIService()
def _safe_filename(name: str, suffix: str) -> str:
stem = re.sub(r"[\\/:*?\"<>|\r\n]+", "_", name).strip(" .") or "教学动画"
return f"{stem[:80]}{suffix}"
def _animation_or_404(anim_id: int, current_user: User | None, db: Session) -> Animation:
anim = db.query(Animation).filter(Animation.id == anim_id).first()
if not anim:
raise HTTPException(status_code=404, detail="动画不存在")
if (not current_user or anim.user_id != current_user.id) and not has_public_resource(db, anim_id):
raise HTTPException(status_code=403, detail="无权访问该动画")
return anim
def _build_animation_html(anim: Animation) -> str:
config = anim.config or {}
body = config.get("html") or config.get("content") or ""
if not isinstance(body, str) or not body.strip():
raise HTTPException(status_code=400, detail="当前动画没有可导出的 HTML")
title = escape(anim.title or config.get("title") or "教学动画")
return f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>
html, body {{ margin: 0; width: 100%; min-height: 100%; background: #f6faf7; }}
body {{ display: flex; align-items: center; justify-content: center; padding: 24px; box-sizing: border-box; }}
.animation-stage {{ width: min(1120px, 100%); aspect-ratio: 16 / 9; min-height: 540px; background: #fff; border: 1px solid #dce8de; border-radius: 12px; overflow: hidden; box-shadow: 0 18px 40px rgba(17, 69, 52, .10); }}
</style>
</head>
<body>
<main class="animation-stage">
{body}
</main>
</body>
</html>
"""
def has_public_resource(db: Session, anim_id: int) -> bool:
return db.query(Resource).filter(
Resource.content_ref == f"animation:{anim_id}",
Resource.status == "active",
Resource.is_public == 1,
).first() is not None
@limiter.limit("10/minute")
@router.post("/ai-generate", response_model=dict)
async def ai_generate_animation(
request: Request,
data: AnimationGenerate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
account = spend_credits(db, current_user, "animation_generate", f"生成教学动画:{data.prompt[:80]}")
db.commit()
result = await ai_service.generate_animation(prompt=data.prompt, anim_type=data.anim_type)
return {"success": True, "data": result, "credits": credits_payload(account, "animation_generate")}
@router.post("/", response_model=AnimationOut, status_code=201)
def create_animation(data: AnimationCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
anim = Animation(user_id=current_user.id, **data.model_dump())
db.add(anim)
db.commit()
db.refresh(anim)
return anim
@router.get("/", response_model=list[AnimationOut])
def list_animations(
anim_type: Optional[str] = None,
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
query = db.query(Animation).filter(Animation.user_id == current_user.id)
if anim_type:
query = query.filter(Animation.anim_type == anim_type)
return query.order_by(Animation.updated_at.desc()).offset(skip).limit(limit).all()
@router.get("/{anim_id}", response_model=AnimationOut)
def get_animation(
anim_id: int,
current_user: User | None = Depends(get_optional_current_user),
db: Session = Depends(get_db),
):
return _animation_or_404(anim_id, current_user, db)
@router.get("/{anim_id}/export-html")
def export_animation_html(
anim_id: int,
current_user: User | None = Depends(get_optional_current_user),
db: Session = Depends(get_db),
):
anim = _animation_or_404(anim_id, current_user, db)
content = _build_animation_html(anim)
filename = _safe_filename(f"{anim.title or '教学动画'}-动画", ".html")
return StreamingResponse(
BytesIO(content.encode("utf-8")),
media_type="text/html; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)
@router.put("/{anim_id}", response_model=AnimationOut)
def update_animation(
anim_id: int, data: AnimationUpdate,
current_user: User = Depends(get_current_user), db: Session = Depends(get_db),
):
anim = db.query(Animation).filter(Animation.id == anim_id, Animation.user_id == current_user.id).first()
if not anim:
raise HTTPException(status_code=404, detail="动画不存在")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(anim, key, value)
db.commit()
db.refresh(anim)
return anim
@router.post("/{anim_id}/remix", response_model=AnimationOut, status_code=201)
def remix_animation(
anim_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
source = db.query(Animation).filter(Animation.id == anim_id).first()
if not source:
raise HTTPException(status_code=404, detail="动画不存在")
if source.user_id != current_user.id and not has_public_resource(db, anim_id):
raise HTTPException(status_code=403, detail="无权改编该动画")
clone = Animation(
user_id=current_user.id,
title=f"{source.title}(改编)",
anim_type=source.anim_type or "general",
description=source.description or "",
config=source.config or {},
thumbnail=source.thumbnail or "",
status="draft",
)
db.add(clone)
db.commit()
db.refresh(clone)
return clone
@router.delete("/{anim_id}", status_code=204)
def delete_animation(anim_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
anim = db.query(Animation).filter(Animation.id == anim_id, Animation.user_id == current_user.id).first()
if not anim:
raise HTTPException(status_code=404, detail="动画不存在")
db.query(Resource).filter(
Resource.user_id == current_user.id,
Resource.content_ref == f"animation:{anim_id}",
Resource.status == "active",
).update({"status": "archived"}, synchronize_session=False)
db.delete(anim)
db.commit()