This commit is contained in:
aaron 2026-04-20 21:18:05 +08:00
parent 0e1de455ef
commit e94f23daba
4 changed files with 105 additions and 33 deletions

View File

@ -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 from sqlalchemy.ext.asyncio import AsyncSession
import asyncio
from app.core.deps import require_role from app.core.deps import require_role
from app.db.database import get_db from app.db.database import get_db
@ -86,8 +87,10 @@ async def get_timelines(
@router.post("/", response_model=TimelineOut) @router.post("/", response_model=TimelineOut)
async def create_new_timeline( async def create_new_timeline(
data: TimelineCreate, title: str = Form(...),
class_id: int | None = None, 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")), user: User = Depends(require_role("super_admin", "class_admin", "student")),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@ -95,21 +98,24 @@ async def create_new_timeline(
if effective_class_id is None: if effective_class_id is None:
raise HTTPException(status_code=400, detail="You are not assigned to a class") 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) post = await create_timeline(db, effective_class_id, user.id, data)
return TimelineOut(
id=post.id, # Upload images in parallel using thread pool
class_id=post.class_id, image_urls: list[str] = []
author_id=post.author_id, if files:
author_name=user.name, async def _upload_one(f: UploadFile) -> str:
title=post.title, contents = await f.read()
content=post.content, if len(contents) > 10 * 1024 * 1024:
image_urls=[], raise HTTPException(status_code=400, detail=f"File {f.filename} too large (max 10MB)")
like_count=0, if f.content_type not in {"image/jpeg", "image/png", "image/gif", "image/webp"}:
has_liked=False, raise HTTPException(status_code=400, detail=f"File {f.filename} has invalid type")
comment_count=0, return await asyncio.to_thread(upload_image, f"timeline/{post.id}", f.filename or "image.jpg", contents, f.content_type)
created_at=post.created_at,
updated_at=post.updated_at, 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") @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)") 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"}: 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") 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) urls.append(url)
await add_images_to_timeline(db, post, urls) await add_images_to_timeline(db, post, urls)

View File

@ -3,7 +3,7 @@
import { useEffect, useState, useRef, useCallback } from "react"; import { useEffect, useState, useRef, useCallback } from "react";
import { useActiveClass } from "@/hooks/use-active-class"; import { useActiveClass } from "@/hooks/use-active-class";
import { useAuth } from "@/hooks/use-auth"; 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 { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -160,6 +160,7 @@ export default function TimelinePage() {
const [selectedFiles, setSelectedFiles] = useState<File[]>([]); const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [previewUrls, setPreviewUrls] = useState<string[]>([]); const [previewUrls, setPreviewUrls] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [uploadProgress, setUploadProgress] = useState("");
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
const [editingImageUrls, setEditingImageUrls] = useState<string[]>([]); const [editingImageUrls, setEditingImageUrls] = useState<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@ -243,32 +244,46 @@ export default function TimelinePage() {
const handleCreate = async () => { const handleCreate = async () => {
if (!newTitle.trim()) return; if (!newTitle.trim()) return;
setSubmitting(true); setSubmitting(true);
setUploadProgress("准备上传...");
try { try {
if (editingId) { if (editingId) {
// Edit mode // Edit mode: update text + upload new images separately
await putAPI(`/api/timeline/${editingId}`, { await putAPI(`/api/timeline/${editingId}`, {
title: newTitle, title: newTitle,
content: newContent || null, content: newContent || null,
}); });
// Upload new images if any
if (selectedFiles.length > 0) { 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(); 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); await uploadAPI(`/api/timeline/${editingId}/images`, formData);
} }
toast.success("已更新"); toast.success("已更新");
} else { } else {
// Create mode // Create mode: send everything in one FormData request
const post: any = await postAPI("/api/timeline/", { const formData = new FormData();
title: newTitle, formData.append("title", newTitle);
content: newContent || null, if (newContent) formData.append("content", newContent);
class_id: activeClassId, if (user?.role === "super_admin" && activeClassId) formData.append("class_id", String(activeClassId));
});
if (selectedFiles.length > 0) { if (selectedFiles.length > 0) {
const formData = new FormData(); setUploadProgress(`压缩图片 (0/${selectedFiles.length})...`);
for (const f of selectedFiles) formData.append("files", f); const compressed = [];
await uploadAPI(`/api/timeline/${post.id}/images`, formData); 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<TimelinePost>("/api/timeline/", formData);
toast.success("发布成功"); toast.success("发布成功");
} }
@ -279,6 +294,7 @@ export default function TimelinePage() {
toast.error(err.message || (editingId ? "更新失败" : "发布失败")); toast.error(err.message || (editingId ? "更新失败" : "发布失败"));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
setUploadProgress("");
} }
}; };
@ -463,7 +479,7 @@ export default function TimelinePage() {
<p className="text-xs text-gray-400 mt-1"> 9 JPG/PNG/GIF/WebP</p> <p className="text-xs text-gray-400 mt-1"> 9 JPG/PNG/GIF/WebP</p>
</div> </div>
<Button onClick={handleCreate} disabled={submitting} className="w-full"> <Button onClick={handleCreate} disabled={submitting} className="w-full">
{submitting ? (editingId ? "保存中..." : "发布中...") : (editingId ? "保存" : "发布")} {submitting ? (uploadProgress || (editingId ? "保存中..." : "发布中...")) : (editingId ? "保存" : "发布")}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>

View File

@ -52,8 +52,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
password, password,
}); });
localStorage.setItem("auth_token", res.token); 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); setUser(res.user);
localStorage.setItem("auth_user", JSON.stringify(res.user));
// Refresh to get complete user data including enabled_modules
const userData = await fetchAPI<AuthUser>("/api/auth/me");
localStorage.setItem("auth_user", JSON.stringify(userData));
setUser(userData);
}, []); }, []);
const logout = useCallback(() => { const logout = useCallback(() => {

View File

@ -95,3 +95,48 @@ export async function uploadAPI<T>(
return res.json(); 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<File> {
// 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);
});
}