feat: homework multi-image upload and crop
这个提交包含在:
638
frontend/app/page.tsx
普通文件
638
frontend/app/page.tsx
普通文件
@@ -0,0 +1,638 @@
|
||||
"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<AssignmentSummary[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [selected, setSelected] = useState<AssignmentDetail | null>(null);
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [images, setImages] = useState<ImageItem[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [info, setInfo] = useState("");
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const imagesRef = useRef<ImageItem[]>([]);
|
||||
|
||||
const [cropTarget, setCropTarget] = useState<ImageItem | null>(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<HTMLInputElement>) => {
|
||||
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<HTMLImageElement>((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<Blob>((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 (
|
||||
<main>
|
||||
<div className="page">
|
||||
<section className="hero">
|
||||
<div>
|
||||
<h1 className="hero-title">作业工坊</h1>
|
||||
<p>
|
||||
上传或拍照作业,直接交给 LLM 识别与批改,返回 Markdown 与反馈。
|
||||
前端无需登录,只需一个用户名即可管理自己的作业记录。
|
||||
</p>
|
||||
</div>
|
||||
<div className="stamp">Homework Atelier</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<header>
|
||||
<h2 className="section-title">我的身份</h2>
|
||||
<span className="tag">NO LOGIN REQUIRED</span>
|
||||
</header>
|
||||
<div className="username-card">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="输入用户名(例如:小明)"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
/>
|
||||
<button className="button" onClick={handleSaveUser}>
|
||||
开始管理
|
||||
</button>
|
||||
{savedUser && (
|
||||
<span className="muted">当前用户:{savedUser}</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid">
|
||||
<div className="card">
|
||||
<header>
|
||||
<h2 className="section-title">上传作业</h2>
|
||||
<span className="tag">IMAGE + LLM</span>
|
||||
</header>
|
||||
<div className="upload-area">
|
||||
<label className="muted">作业标题</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="例如:数学作业 第5次"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
|
||||
<label className="muted">上传图片(支持多页)</label>
|
||||
<input type="file" accept="image/*" multiple onChange={handleFileChange} />
|
||||
<div className="muted">默认不裁剪,可选择“裁剪”后提交。</div>
|
||||
|
||||
<div className="camera">
|
||||
<label className="muted">在线拍照</label>
|
||||
<video ref={videoRef} playsInline muted />
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<button className="button secondary" onClick={startCamera}>
|
||||
打开摄像头
|
||||
</button>
|
||||
<button className="button" onClick={capturePhoto}>
|
||||
拍照提交
|
||||
</button>
|
||||
<button className="button ghost" onClick={stopCamera}>
|
||||
关闭摄像头
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{images.length > 0 && (
|
||||
<div className="thumbnail-grid">
|
||||
{images.map((item, index) => (
|
||||
<div key={item.id} className="thumb-card">
|
||||
<img
|
||||
className="preview"
|
||||
src={item.croppedUrl ?? item.previewUrl}
|
||||
alt={`第${index + 1}张`}
|
||||
/>
|
||||
<div className="thumb-actions">
|
||||
<span className="muted">第 {index + 1} 张</span>
|
||||
<div className="thumb-buttons">
|
||||
<button className="button secondary" onClick={() => openCrop(item)}>
|
||||
裁剪
|
||||
</button>
|
||||
{item.croppedBlob && (
|
||||
<button
|
||||
className="button ghost"
|
||||
onClick={() => restoreCrop(item.id)}
|
||||
>
|
||||
恢复原图
|
||||
</button>
|
||||
)}
|
||||
<button className="button ghost" onClick={() => removeImage(item.id)}>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{images.length > 0 && (
|
||||
<button className="button ghost" onClick={clearImages}>
|
||||
清空图片
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "批改中..." : "提交批改"}
|
||||
</button>
|
||||
|
||||
{error && <div className="feedback">{error}</div>}
|
||||
{info && <div className="feedback">{info}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<header>
|
||||
<h2 className="section-title">我的作业档案</h2>
|
||||
<span className="tag">HISTORY</span>
|
||||
</header>
|
||||
<div className="assignments">
|
||||
{assignments.length === 0 && (
|
||||
<div className="muted">暂无作业记录</div>
|
||||
)}
|
||||
{assignments.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={clsx("assignment-item", {
|
||||
active: item.id === selectedId,
|
||||
})}
|
||||
onClick={async () => {
|
||||
setSelectedId(item.id);
|
||||
await loadAssignmentDetail(item.id);
|
||||
}}
|
||||
>
|
||||
<strong>{item.title}</strong>
|
||||
<div className="assignment-meta">
|
||||
<span>评分:{item.score || "-"}</span>
|
||||
<span>{new Date(item.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 8 }}>
|
||||
{imageCountFor(item) > 0 && (
|
||||
<a
|
||||
className="button secondary"
|
||||
href={imageUrlFor(item.id, 0)}
|
||||
download={`assignment-${item.id}-1.png`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
保存图片{imageCountFor(item) > 1 ? ` (${imageCountFor(item)}张)` : ""}
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
className="button ghost"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDelete(item.id);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<header>
|
||||
<h2 className="section-title">批改结果</h2>
|
||||
<span className="tag">MARKDOWN</span>
|
||||
</header>
|
||||
{!selected && <div className="muted">选择一条作业查看详情</div>}
|
||||
{selected && (
|
||||
<div style={{ display: "grid", gap: 16 }}>
|
||||
<div className="assignment-meta">
|
||||
<span>标题:{selected.title}</span>
|
||||
<span>评分:{selected.score || "-"}</span>
|
||||
</div>
|
||||
{selectedImagePaths.length > 0 && (
|
||||
<div style={{ display: "grid", gap: 12 }}>
|
||||
<div className="assignment-meta">
|
||||
<span>原始图片(共 {selectedImagePaths.length} 张)</span>
|
||||
</div>
|
||||
<div className="image-grid">
|
||||
{selectedImagePaths.map((_, index) => (
|
||||
<div key={`${selected.id}-${index}`} className="image-card">
|
||||
<img
|
||||
className="preview"
|
||||
src={imageUrlFor(selected.id, index)}
|
||||
alt={`作业原图 ${index + 1}`}
|
||||
/>
|
||||
<a
|
||||
className="button secondary"
|
||||
href={imageUrlFor(selected.id, index)}
|
||||
download={`assignment-${selected.id}-${index + 1}.png`}
|
||||
>
|
||||
保存第 {index + 1} 张
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="markdown">
|
||||
<ReactMarkdown>{selected.markdown}</ReactMarkdown>
|
||||
</div>
|
||||
{selected.feedback && (
|
||||
<div className="feedback">
|
||||
<strong>批改意见:</strong>
|
||||
<div>{selected.feedback}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{cropTarget && (
|
||||
<div className="crop-overlay">
|
||||
<div className="crop-modal">
|
||||
<div className="crop-area">
|
||||
<Cropper
|
||||
image={cropTarget.previewUrl}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={4 / 3}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropComplete}
|
||||
/>
|
||||
</div>
|
||||
<div className="crop-controls">
|
||||
<label className="muted">缩放</label>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.05}
|
||||
value={zoom}
|
||||
onChange={(event) => setZoom(Number(event.target.value))}
|
||||
/>
|
||||
<div className="crop-buttons">
|
||||
<button className="button secondary" onClick={applyCrop}>
|
||||
应用裁剪
|
||||
</button>
|
||||
<button className="button ghost" onClick={() => setCropTarget(null)}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户