"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import clsx from "clsx"; import ReactMarkdown from "react-markdown"; import Cropper from "react-easy-crop"; const apiBase = process.env.NEXT_PUBLIC_API_BASE || "/api"; type AssignmentSummary = { id: number; username: string; title: string; score: number; imagePath?: string; imagePaths?: string[]; createdAt: string; }; type AssignmentDetail = AssignmentSummary & { ocrText?: string; markdown: string; feedback: string; imagePath?: string; imagePaths?: string[]; }; type ImageItem = { id: string; name: string; file: Blob; previewUrl: string; croppedBlob?: Blob; croppedUrl?: string; }; export default function HomePage() { const [username, setUsername] = useState(""); const [savedUser, setSavedUser] = useState(""); const [assignments, setAssignments] = useState([]); const [selectedId, setSelectedId] = useState(null); const [selected, setSelected] = useState(null); const [title, setTitle] = useState(""); const [images, setImages] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(""); const [info, setInfo] = useState(""); const videoRef = useRef(null); const streamRef = useRef(null); const imagesRef = useRef([]); const [cropTarget, setCropTarget] = useState(null); const [crop, setCrop] = useState({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); const [croppedAreaPixels, setCroppedAreaPixels] = useState<{ x: number; y: number; width: number; height: number; } | null>(null); useEffect(() => { const stored = window.localStorage.getItem("hw_username"); if (stored) { setUsername(stored); setSavedUser(stored); void loadAssignments(stored); } return () => { if (streamRef.current) { streamRef.current.getTracks().forEach((track) => track.stop()); } imagesRef.current.forEach((item) => { URL.revokeObjectURL(item.previewUrl); if (item.croppedUrl) URL.revokeObjectURL(item.croppedUrl); }); }; }, []); useEffect(() => { imagesRef.current = images; }, [images]); const loadAssignments = async (user: string) => { setError(""); try { const res = await fetch(`${apiBase}/assignments?username=${encodeURIComponent(user)}`); if (!res.ok) throw new Error("加载作业失败"); const data = (await res.json()) as AssignmentSummary[]; setAssignments(data); if (data.length > 0) { setSelectedId(data[0].id); await loadAssignmentDetail(data[0].id, user); } } catch (err) { setError(err instanceof Error ? err.message : "加载失败"); } }; const loadAssignmentDetail = async (id: number, user = savedUser) => { setError(""); try { const res = await fetch( `${apiBase}/assignments/${id}?username=${encodeURIComponent(user)}` ); if (!res.ok) throw new Error("加载详情失败"); const data = (await res.json()) as AssignmentDetail; setSelected(data); } catch (err) { setError(err instanceof Error ? err.message : "加载失败"); } }; const handleSaveUser = async () => { if (!username.trim()) return; const user = username.trim(); setSavedUser(user); window.localStorage.setItem("hw_username", user); await loadAssignments(user); }; const createId = () => typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`; const revokeItemUrls = (item: ImageItem) => { URL.revokeObjectURL(item.previewUrl); if (item.croppedUrl) URL.revokeObjectURL(item.croppedUrl); }; const createImageItem = (file: Blob, name: string): ImageItem => ({ id: createId(), name, file, previewUrl: URL.createObjectURL(file), }); const handleFileChange = (event: React.ChangeEvent) => { setError(""); const files = Array.from(event.target.files ?? []); if (files.length === 0) return; const newItems = files.map((file) => createImageItem(file, file.name || `upload-${Date.now()}.png`) ); setImages((prev) => [...prev, ...newItems]); setInfo(`已选择 ${files.length} 张图片,可继续追加上传。`); event.target.value = ""; }; const clearImages = () => { setImages((prev) => { prev.forEach(revokeItemUrls); return []; }); setCropTarget(null); setCroppedAreaPixels(null); }; const removeImage = (id: string) => { setImages((prev) => { const target = prev.find((item) => item.id === id); if (target) revokeItemUrls(target); return prev.filter((item) => item.id !== id); }); if (cropTarget?.id === id) { setCropTarget(null); setCroppedAreaPixels(null); } }; const onCropComplete = useCallback((_: unknown, areaPixels: any) => { setCroppedAreaPixels(areaPixels); }, []); const createImage = (url: string) => new Promise((resolve, reject) => { const image = new Image(); image.addEventListener("load", () => resolve(image)); image.addEventListener("error", (err) => reject(err)); image.setAttribute("crossOrigin", "anonymous"); image.src = url; }); const getCroppedImg = async (imageSrc: string, cropArea: { x: number; y: number; width: number; height: number }) => { const image = await createImage(imageSrc); const canvas = document.createElement("canvas"); canvas.width = cropArea.width; canvas.height = cropArea.height; const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Canvas not supported"); ctx.drawImage( image, cropArea.x, cropArea.y, cropArea.width, cropArea.height, 0, 0, cropArea.width, cropArea.height ); return new Promise((resolve, reject) => { canvas.toBlob((blob) => { if (!blob) { reject(new Error("Crop failed")); return; } resolve(blob); }, "image/png"); }); }; const applyCrop = async () => { if (!cropTarget || !croppedAreaPixels) return; try { const blob = await getCroppedImg(cropTarget.previewUrl, croppedAreaPixels); const croppedUrl = URL.createObjectURL(blob); setImages((prev) => prev.map((item) => { if (item.id !== cropTarget.id) return item; if (item.croppedUrl) URL.revokeObjectURL(item.croppedUrl); return { ...item, croppedBlob: blob, croppedUrl }; }) ); setInfo("裁剪已应用,可直接提交或继续裁剪。"); } catch { setError("裁剪失败,请重试"); } finally { setCropTarget(null); setCroppedAreaPixels(null); setZoom(1); setCrop({ x: 0, y: 0 }); } }; const openCrop = (item: ImageItem) => { setCropTarget(item); setCrop({ x: 0, y: 0 }); setZoom(1); setCroppedAreaPixels(null); }; const restoreCrop = (id: string) => { setImages((prev) => prev.map((item) => { if (item.id !== id) return item; if (item.croppedUrl) URL.revokeObjectURL(item.croppedUrl); return { ...item, croppedBlob: undefined, croppedUrl: undefined }; }) ); }; const startCamera = async () => { setError(""); try { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" }, }); streamRef.current = stream; if (videoRef.current) { videoRef.current.srcObject = stream; await videoRef.current.play(); } } catch (err) { setError("无法打开摄像头,请检查权限设置"); } }; const capturePhoto = async () => { setError(""); if (!videoRef.current) return; const video = videoRef.current; const canvas = document.createElement("canvas"); canvas.width = video.videoWidth; canvas.height = video.videoHeight; const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.drawImage(video, 0, 0, canvas.width, canvas.height); const dataUrl = canvas.toDataURL("image/png"); const blob = await (await fetch(dataUrl)).blob(); const item = createImageItem(blob, `camera-${Date.now()}.png`); setImages((prev) => [...prev, item]); setInfo("拍照完成,可直接提交或继续拍照。"); }; const stopCamera = () => { if (streamRef.current) { streamRef.current.getTracks().forEach((track) => track.stop()); streamRef.current = null; } }; const handleSubmit = async () => { if (!savedUser) { setError("请先设置用户名"); return; } if (images.length === 0) { setError("请先上传或拍照作业图片"); return; } setError(""); setIsSubmitting(true); setInfo("正在提交图片并调用 LLM 批改,请稍候..."); try { const formData = new FormData(); formData.append("username", savedUser); if (title.trim()) { formData.append("title", title.trim()); } images.forEach((item, index) => { const blob = item.croppedBlob ?? item.file; const name = item.name || `page-${index + 1}.png`; formData.append("images", blob, name); }); const res = await fetch(`${apiBase}/assignments`, { method: "POST", body: formData, }); if (!res.ok) { const payload = await res.json().catch(() => ({})); throw new Error(payload.error || "提交失败"); } const created = (await res.json()) as AssignmentDetail; setAssignments((prev) => [created, ...prev]); setSelectedId(created.id); setSelected(created); setTitle(""); clearImages(); setInfo("批改完成,可在右侧查看结果。"); } catch (err) { setError(err instanceof Error ? err.message : "提交失败"); } finally { setIsSubmitting(false); } }; const handleDelete = async (id: number) => { if (!savedUser) return; try { const res = await fetch( `${apiBase}/assignments/${id}?username=${encodeURIComponent(savedUser)}`, { method: "DELETE" } ); if (!res.ok) throw new Error("删除失败"); setAssignments((prev) => prev.filter((item) => item.id !== id)); if (selectedId === id) { setSelectedId(null); setSelected(null); } } catch (err) { setError(err instanceof Error ? err.message : "删除失败"); } }; const imageUrlFor = (id: number, index = 0) => `${apiBase}/assignments/${id}/image?username=${encodeURIComponent(savedUser)}&index=${index}`; const imageCountFor = (item: AssignmentSummary) => item.imagePaths?.length ?? (item.imagePath ? 1 : 0); const selectedImagePaths = selected?.imagePaths && selected.imagePaths.length > 0 ? selected.imagePaths : selected?.imagePath ? [selected.imagePath] : []; return (

作业工坊

上传或拍照作业,直接交给 LLM 识别与批改,返回 Markdown 与反馈。 前端无需登录,只需一个用户名即可管理自己的作业记录。

Homework Atelier

我的身份

NO LOGIN REQUIRED
setUsername(event.target.value)} /> {savedUser && ( 当前用户:{savedUser} )}

上传作业

IMAGE + LLM
setTitle(event.target.value)} />
默认不裁剪,可选择“裁剪”后提交。
{images.length > 0 && (
{images.map((item, index) => (
{`第${index
第 {index + 1} 张
{item.croppedBlob && ( )}
))}
)} {images.length > 0 && ( )} {error &&
{error}
} {info &&
{info}
}

我的作业档案

HISTORY
{assignments.length === 0 && (
暂无作业记录
)} {assignments.map((item) => (
{ setSelectedId(item.id); await loadAssignmentDetail(item.id); }} > {item.title}
评分:{item.score || "-"} {new Date(item.createdAt).toLocaleString()}
{imageCountFor(item) > 0 && ( event.stopPropagation()} > 保存图片{imageCountFor(item) > 1 ? ` (${imageCountFor(item)}张)` : ""} )}
))}

批改结果

MARKDOWN
{!selected &&
选择一条作业查看详情
} {selected && (
标题:{selected.title} 评分:{selected.score || "-"}
{selectedImagePaths.length > 0 && (
原始图片(共 {selectedImagePaths.length} 张)
{selectedImagePaths.map((_, index) => ( ))}
)}
{selected.markdown}
{selected.feedback && (
批改意见:
{selected.feedback}
)}
)}
{cropTarget && (
setZoom(Number(event.target.value))} />
)}
); }