fixbug
This commit is contained in:
parent
0e1de455ef
commit
e94f23daba
@ -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)
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user