diff --git a/backend/app/api/timeline.py b/backend/app/api/timeline.py index 1bbda36..e49dccd 100644 --- a/backend/app/api/timeline.py +++ b/backend/app/api/timeline.py @@ -1,5 +1,6 @@ -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from sqlalchemy.ext.asyncio import AsyncSession +import asyncio from app.core.deps import require_role from app.db.database import get_db @@ -86,8 +87,10 @@ async def get_timelines( @router.post("/", response_model=TimelineOut) async def create_new_timeline( - data: TimelineCreate, - class_id: int | None = None, + title: str = Form(...), + content: str | None = Form(None), + class_id: int | None = Form(None), + files: list[UploadFile] = File(default=[]), user: User = Depends(require_role("super_admin", "class_admin", "student")), db: AsyncSession = Depends(get_db), ): @@ -95,21 +98,24 @@ async def create_new_timeline( if effective_class_id is None: raise HTTPException(status_code=400, detail="You are not assigned to a class") + data = TimelineCreate(title=title, content=content) post = await create_timeline(db, effective_class_id, user.id, data) - return TimelineOut( - id=post.id, - class_id=post.class_id, - author_id=post.author_id, - author_name=user.name, - title=post.title, - content=post.content, - image_urls=[], - like_count=0, - has_liked=False, - comment_count=0, - created_at=post.created_at, - updated_at=post.updated_at, - ) + + # Upload images in parallel using thread pool + image_urls: list[str] = [] + if files: + async def _upload_one(f: UploadFile) -> str: + contents = await f.read() + if len(contents) > 10 * 1024 * 1024: + raise HTTPException(status_code=400, detail=f"File {f.filename} too large (max 10MB)") + if f.content_type not in {"image/jpeg", "image/png", "image/gif", "image/webp"}: + raise HTTPException(status_code=400, detail=f"File {f.filename} has invalid type") + return await asyncio.to_thread(upload_image, f"timeline/{post.id}", f.filename or "image.jpg", contents, f.content_type) + + image_urls = await asyncio.gather(*[_upload_one(f) for f in files]) + await add_images_to_timeline(db, post, image_urls) + + return _build_timeline_out(post, user.id) @router.post("/{post_id}/images") @@ -136,7 +142,7 @@ async def upload_timeline_images( raise HTTPException(status_code=400, detail=f"File {f.filename} too large (max 10MB)") if f.content_type not in {"image/jpeg", "image/png", "image/gif", "image/webp"}: raise HTTPException(status_code=400, detail=f"File {f.filename} has invalid type") - url = upload_image(f"timeline/{post_id}", f.filename or "image.jpg", contents, f.content_type) + url = await asyncio.to_thread(upload_image, f"timeline/{post_id}", f.filename or "image.jpg", contents, f.content_type) urls.append(url) await add_images_to_timeline(db, post, urls) diff --git a/frontend/src/app/(app)/timeline/page.tsx b/frontend/src/app/(app)/timeline/page.tsx index 505c6b3..6b7c51a 100644 --- a/frontend/src/app/(app)/timeline/page.tsx +++ b/frontend/src/app/(app)/timeline/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, useRef, useCallback } from "react"; import { useActiveClass } from "@/hooks/use-active-class"; import { useAuth } from "@/hooks/use-auth"; -import { fetchAPI, postAPI, putAPI, deleteAPI, uploadAPI } from "@/lib/api"; +import { fetchAPI, postAPI, putAPI, deleteAPI, uploadAPI, compressImage } from "@/lib/api"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -160,6 +160,7 @@ export default function TimelinePage() { const [selectedFiles, setSelectedFiles] = useState([]); const [previewUrls, setPreviewUrls] = useState([]); const [submitting, setSubmitting] = useState(false); + const [uploadProgress, setUploadProgress] = useState(""); const [editingId, setEditingId] = useState(null); const [editingImageUrls, setEditingImageUrls] = useState([]); const fileInputRef = useRef(null); @@ -243,32 +244,46 @@ export default function TimelinePage() { const handleCreate = async () => { if (!newTitle.trim()) return; setSubmitting(true); + setUploadProgress("准备上传..."); try { if (editingId) { - // Edit mode + // Edit mode: update text + upload new images separately await putAPI(`/api/timeline/${editingId}`, { title: newTitle, content: newContent || null, }); - // Upload new images if any if (selectedFiles.length > 0) { + setUploadProgress(`压缩图片 (0/${selectedFiles.length})...`); + const compressed = []; + for (let i = 0; i < selectedFiles.length; i++) { + compressed.push(await compressImage(selectedFiles[i])); + setUploadProgress(`压缩图片 (${i + 1}/${selectedFiles.length})...`); + } + setUploadProgress("上传图片..."); const formData = new FormData(); - for (const f of selectedFiles) formData.append("files", f); + for (const f of compressed) formData.append("files", f); await uploadAPI(`/api/timeline/${editingId}/images`, formData); } toast.success("已更新"); } else { - // Create mode - const post: any = await postAPI("/api/timeline/", { - title: newTitle, - content: newContent || null, - class_id: activeClassId, - }); + // Create mode: send everything in one FormData request + const formData = new FormData(); + formData.append("title", newTitle); + if (newContent) formData.append("content", newContent); + if (user?.role === "super_admin" && activeClassId) formData.append("class_id", String(activeClassId)); + if (selectedFiles.length > 0) { - const formData = new FormData(); - for (const f of selectedFiles) formData.append("files", f); - await uploadAPI(`/api/timeline/${post.id}/images`, formData); + setUploadProgress(`压缩图片 (0/${selectedFiles.length})...`); + const compressed = []; + for (let i = 0; i < selectedFiles.length; i++) { + compressed.push(await compressImage(selectedFiles[i])); + setUploadProgress(`压缩图片 (${i + 1}/${selectedFiles.length})...`); + } + setUploadProgress("上传中..."); + for (const f of compressed) formData.append("files", f); } + + await uploadAPI("/api/timeline/", formData); toast.success("发布成功"); } @@ -279,6 +294,7 @@ export default function TimelinePage() { toast.error(err.message || (editingId ? "更新失败" : "发布失败")); } finally { setSubmitting(false); + setUploadProgress(""); } }; @@ -463,7 +479,7 @@ export default function TimelinePage() {

最多 9 张,支持 JPG/PNG/GIF/WebP

diff --git a/frontend/src/hooks/use-auth.tsx b/frontend/src/hooks/use-auth.tsx index c3cce73..c7f0d17 100644 --- a/frontend/src/hooks/use-auth.tsx +++ b/frontend/src/hooks/use-auth.tsx @@ -52,8 +52,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { password, }); localStorage.setItem("auth_token", res.token); - localStorage.setItem("auth_user", JSON.stringify(res.user)); + // Temporarily set user from login response, then refresh to get full data (enabled_modules etc.) setUser(res.user); + localStorage.setItem("auth_user", JSON.stringify(res.user)); + // Refresh to get complete user data including enabled_modules + const userData = await fetchAPI("/api/auth/me"); + localStorage.setItem("auth_user", JSON.stringify(userData)); + setUser(userData); }, []); const logout = useCallback(() => { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ec31711..c984e5f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -95,3 +95,48 @@ export async function uploadAPI( return res.json(); } + +/** Compress an image file: resize to max 1600px and reduce quality */ +export async function compressImage(file: File, maxDim = 1600, quality = 0.8): Promise { + // Only compress JPEG/PNG/WebP; skip GIF and small files + if (!file.type.startsWith("image/") || file.type === "image/gif" || file.size < 200 * 1024) { + return file; + } + + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + let { width, height } = img; + if (width <= maxDim && height <= maxDim) { + // No resize needed, just re-encode with quality + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d")!; + ctx.drawImage(img, 0, 0); + canvas.toBlob( + (blob) => resolve(blob ? new File([blob], file.name, { type: "image/jpeg" }) : file), + "image/jpeg", + quality + ); + return; + } + // Resize + const ratio = Math.min(maxDim / width, maxDim / height); + width = Math.round(width * ratio); + height = Math.round(height * ratio); + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d")!; + ctx.drawImage(img, 0, 0, width, height); + canvas.toBlob( + (blob) => resolve(blob ? new File([blob], file.name, { type: "image/jpeg" }) : file), + "image/jpeg", + quality + ); + }; + img.onerror = () => resolve(file); + img.src = URL.createObjectURL(file); + }); +}