0841b1a103
- 前端:Vue3 + TS + Element Plus,24 个页面路由(课件/组题/教案/动画/思维导图/作文批改/命题/课堂/资源/社区等) - 后端:FastAPI + SQLAlchemy + SQLite,17 个路由模块,AI 服务层含降级模板 - AI:glm-5.x 推理模型已禁用思维链,确保输出真实内容 - 修复:ai_service 两处请求体注入 enable_thinking/thinking disabled - 测试账号:13900999999 / test1234
189 lines
6.9 KiB
Python
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()
|