智教助手平台:完整初始化

- 前端:Vue3 + TS + Element Plus,24 个页面路由(课件/组题/教案/动画/思维导图/作文批改/命题/课堂/资源/社区等)
- 后端:FastAPI + SQLAlchemy + SQLite,17 个路由模块,AI 服务层含降级模板
- AI:glm-5.x 推理模型已禁用思维链,确保输出真实内容
- 修复:ai_service 两处请求体注入 enable_thinking/thinking disabled
- 测试账号:13900999999 / test1234
This commit is contained in:
Zhang Jing Xuan
2026-07-29 16:29:10 +08:00
commit 0841b1a103
193 changed files with 44995 additions and 0 deletions
+317
View File
@@ -0,0 +1,317 @@
import html
import re
import secrets
from io import BytesIO
from urllib.parse import quote
from docx import Document
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.courseware import Courseware, CoursewareShare
from models.resource import Resource
from models.user import User
from schemas.courseware import CoursewareCreate, CoursewareAIGenerate, CoursewareShareOut, CoursewareUpdate, CoursewareOut
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/coursewares", 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 _plain_text_from_html(raw_html: str) -> str:
text = str(raw_html or "")
text = re.sub(r"<(script|style)[\s\S]*?</\1>", " ", text, flags=re.IGNORECASE)
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"</(p|div|section|article|li|h[1-6]|tr)>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"<[^>]+>", " ", text)
text = html.unescape(text)
text = re.sub(r"[ \t\u00a0]+", " ", text)
text = re.sub(r"\n\s+", "\n", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def _courseware_or_404(
courseware_id: int,
current_user: User | None,
db: Session,
) -> Courseware:
cw = db.query(Courseware).filter(Courseware.id == courseware_id).first()
if not cw or cw.status == "archived":
raise HTTPException(status_code=404, detail="课件不存在")
if cw.status != "published" and (not current_user or cw.user_id != current_user.id):
raise HTTPException(status_code=403, detail="无权访问该课件")
return cw
def _add_if_present(document: Document, label: str, value: str | None) -> None:
if value:
document.add_paragraph(f"{label}{value}")
def _share_payload(share: CoursewareShare, request: Request | None = None) -> dict:
data = CoursewareShareOut.model_validate(share).model_dump()
data["share_url"] = f"/preview/share/{share.token}"
return data
@limiter.limit("10/minute")
@router.post("/ai-generate", response_model=dict)
async def ai_generate_courseware(
request: Request,
data: CoursewareAIGenerate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
account = spend_credits(db, current_user, "courseware_generate", f"生成互动课件:{data.prompt[:80]}")
db.commit()
result = await ai_service.generate_courseware(
prompt=data.prompt, subject=data.subject, grade=data.grade, page_count=data.page_count,
aspect_ratio=data.aspect_ratio,
)
return {"success": True, "data": result, "credits": credits_payload(account, "courseware_generate")}
@router.post("/", response_model=CoursewareOut, status_code=201)
def create_courseware(data: CoursewareCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
cw = Courseware(user_id=current_user.id, **data.model_dump())
db.add(cw)
db.commit()
db.refresh(cw)
return cw
@router.get("/", response_model=list[CoursewareOut])
def list_coursewares(
subject: Optional[str] = None,
grade: Optional[str] = None,
status: 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(Courseware).filter(Courseware.user_id == current_user.id)
if subject:
query = query.filter(Courseware.subject == subject)
if grade:
query = query.filter(Courseware.grade == grade)
if status:
query = query.filter(Courseware.status == status)
else:
query = query.filter(Courseware.status != "archived")
return query.order_by(Courseware.updated_at.desc()).offset(skip).limit(limit).all()
@router.get("/shares/mine/list", response_model=list[CoursewareShareOut])
def list_my_courseware_shares(
request: Request,
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),
):
shares = (
db.query(CoursewareShare)
.filter(CoursewareShare.user_id == current_user.id, CoursewareShare.status == "active")
.order_by(CoursewareShare.updated_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return [_share_payload(share, request) for share in shares]
@router.delete("/shares/mine/{share_id}", status_code=204)
def revoke_courseware_share(
share_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
share = db.query(CoursewareShare).filter(
CoursewareShare.id == share_id,
CoursewareShare.user_id == current_user.id,
CoursewareShare.status == "active",
).first()
if not share:
raise HTTPException(status_code=404, detail="分享不存在")
share.status = "revoked"
db.commit()
@router.get("/shares/{token}", response_model=CoursewareShareOut)
def get_courseware_share(
token: str,
request: Request,
db: Session = Depends(get_db),
):
share = db.query(CoursewareShare).filter(
CoursewareShare.token == token,
CoursewareShare.status == "active",
).first()
if not share:
raise HTTPException(status_code=404, detail="分享不存在或已失效")
share.views = (share.views or 0) + 1
db.commit()
db.refresh(share)
return _share_payload(share, request)
@router.get("/{courseware_id}", response_model=CoursewareOut)
def get_courseware(
courseware_id: int,
current_user: User | None = Depends(get_optional_current_user),
db: Session = Depends(get_db),
):
return _courseware_or_404(courseware_id, current_user, db)
@router.post("/{courseware_id}/share", response_model=CoursewareShareOut, status_code=201)
def create_courseware_share(
courseware_id: int,
request: Request,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
cw = db.query(Courseware).filter(
Courseware.id == courseware_id,
Courseware.user_id == current_user.id,
Courseware.status != "archived",
).first()
if not cw:
raise HTTPException(status_code=404, detail="课件不存在")
share = db.query(CoursewareShare).filter(
CoursewareShare.courseware_id == courseware_id,
CoursewareShare.user_id == current_user.id,
CoursewareShare.status == "active",
).first()
if not share:
share = CoursewareShare(
courseware_id=courseware_id,
user_id=current_user.id,
token=secrets.token_urlsafe(18),
)
db.add(share)
share.title = cw.title
share.subject = cw.subject or ""
share.grade = cw.grade or ""
share.description = cw.description or ""
share.content = cw.content or []
share.tags = list(cw.tags or [])
share.status = "active"
db.commit()
db.refresh(share)
return _share_payload(share, request)
@router.get("/{courseware_id}/export-docx")
def export_courseware_docx(
courseware_id: int,
current_user: User | None = Depends(get_optional_current_user),
db: Session = Depends(get_db),
):
cw = _courseware_or_404(courseware_id, current_user, db)
document = Document()
document.add_heading(cw.title or "互动课件讲义", level=1)
meta_parts = [part for part in [cw.subject, cw.grade] if part]
if meta_parts:
document.add_paragraph(" / ".join(meta_parts))
_add_if_present(document, "课件说明", cw.description)
if cw.tags:
document.add_paragraph("标签:" + "".join(str(tag) for tag in cw.tags if str(tag).strip()))
pages = cw.content or []
document.add_paragraph(f"页数:{len(pages)}")
for index, page in enumerate(pages, start=1):
if not isinstance(page, dict):
continue
page_title = str(page.get("title") or f"{index}").strip()
document.add_heading(f"{index} 页:{page_title}", level=2)
page_type = page.get("type")
if page_type:
document.add_paragraph(f"页面类型:{page_type}")
text = _plain_text_from_html(page.get("content") or "")
document.add_paragraph(text or "该页暂无可提取文本。")
notes = str(page.get("notes") or "").strip()
if notes:
document.add_paragraph(f"教师备注:{notes}")
buffer = BytesIO()
document.save(buffer)
buffer.seek(0)
filename = _safe_filename(f"{cw.title or '课件'}-讲义", ".docx")
return StreamingResponse(
buffer,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)
@router.put("/{courseware_id}", response_model=CoursewareOut)
def update_courseware(
courseware_id: int, data: CoursewareUpdate,
current_user: User = Depends(get_current_user), db: Session = Depends(get_db),
):
cw = db.query(Courseware).filter(Courseware.id == courseware_id, Courseware.user_id == current_user.id).first()
if not cw:
raise HTTPException(status_code=404, detail="课件不存在")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(cw, key, value)
db.commit()
db.refresh(cw)
return cw
@router.post("/{courseware_id}/remix", response_model=CoursewareOut, status_code=201)
def remix_courseware(
courseware_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
source = db.query(Courseware).filter(Courseware.id == courseware_id).first()
if not source or source.status == "archived":
raise HTTPException(status_code=404, detail="课件不存在")
if source.status != "published" and source.user_id != current_user.id:
raise HTTPException(status_code=403, detail="无权改编该课件")
clone = Courseware(
user_id=current_user.id,
title=f"{source.title}(改编)",
subject=source.subject or "",
grade=source.grade or "",
description=source.description or "",
content=source.content or [],
cover_image=source.cover_image or "",
tags=list(source.tags or []),
status="draft",
)
db.add(clone)
db.commit()
db.refresh(clone)
return clone
@router.delete("/{courseware_id}", status_code=204)
def delete_courseware(courseware_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
cw = db.query(Courseware).filter(Courseware.id == courseware_id, Courseware.user_id == current_user.id).first()
if not cw:
raise HTTPException(status_code=404, detail="课件不存在")
cw.status = "archived"
db.query(Resource).filter(
Resource.user_id == current_user.id,
Resource.content_ref == f"courseware:{courseware_id}",
Resource.status == "active",
).update({"status": "archived"}, synchronize_session=False)
db.commit()