feat: homework multi-image upload and crop

这个提交包含在:
cryptocommuniums-afk
2026-02-01 11:33:59 +08:00
当前提交 7a7e0a0d7f
修改 20 个文件,包含 3973 行新增0 行删除

22
frontend/Dockerfile 普通文件
查看文件

@@ -0,0 +1,22 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --no-audit --no-fund
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_PUBLIC_API_BASE=/api
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]

384
frontend/app/globals.css 普通文件
查看文件

@@ -0,0 +1,384 @@
:root {
--ink: #1f1d1a;
--paper: #f8f1e5;
--paper-2: #f2e6d2;
--accent: #f05a28;
--accent-dark: #c03d12;
--teal: #0f4c5c;
--shadow: rgba(31, 29, 26, 0.18);
--radius: 22px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(1200px 600px at 10% 10%, rgba(240, 90, 40, 0.08), transparent 60%),
radial-gradient(1000px 500px at 90% 20%, rgba(15, 76, 92, 0.12), transparent 60%),
var(--paper);
color: var(--ink);
font-family: var(--font-body), serif;
line-height: 1.6;
position: relative;
overflow-x: hidden;
}
body::before {
content: "";
position: fixed;
inset: 0;
background-image: repeating-linear-gradient(
0deg,
rgba(31, 29, 26, 0.03),
rgba(31, 29, 26, 0.03) 1px,
transparent 1px,
transparent 24px
),
repeating-linear-gradient(
90deg,
rgba(31, 29, 26, 0.02),
rgba(31, 29, 26, 0.02) 1px,
transparent 1px,
transparent 24px
);
mix-blend-mode: multiply;
pointer-events: none;
opacity: 0.4;
}
main {
position: relative;
padding: 64px 8vw 80px;
}
h1, h2, h3 {
font-family: var(--font-display), serif;
margin: 0;
letter-spacing: -0.01em;
}
a {
color: inherit;
text-decoration: none;
}
button {
font-family: var(--font-mono), monospace;
}
.page {
display: flex;
flex-direction: column;
gap: 32px;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
gap: 24px;
align-items: center;
padding: 36px 40px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.85), rgba(246, 232, 210, 0.75));
border-radius: var(--radius);
box-shadow: 0 30px 80px var(--shadow);
border: 1px solid rgba(31, 29, 26, 0.08);
}
.hero-title {
font-size: clamp(2.4rem, 4vw, 3.4rem);
}
.hero p {
margin: 12px 0 0;
font-size: 1.05rem;
color: rgba(31, 29, 26, 0.78);
}
.hero .stamp {
justify-self: end;
border: 2px dashed var(--accent);
padding: 16px 20px;
border-radius: 18px;
transform: rotate(-2deg);
background: rgba(240, 90, 40, 0.08);
font-family: var(--font-mono), monospace;
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.75rem;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 24px;
}
.card {
background: rgba(255, 255, 255, 0.85);
border-radius: var(--radius);
padding: 24px;
border: 1px solid rgba(31, 29, 26, 0.08);
box-shadow: 0 20px 50px rgba(31, 29, 26, 0.12);
backdrop-filter: blur(8px);
}
.card header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.section-title {
font-size: 1.4rem;
}
.tag {
font-family: var(--font-mono), monospace;
font-size: 0.72rem;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(31, 29, 26, 0.1);
background: rgba(15, 76, 92, 0.08);
color: var(--teal);
}
.username-card {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.input {
flex: 1 1 220px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(31, 29, 26, 0.2);
background: #fffefb;
font-size: 0.98rem;
font-family: var(--font-body), serif;
}
textarea.input {
resize: vertical;
}
.button {
padding: 12px 18px;
border-radius: 14px;
border: none;
background: var(--accent);
color: #fff;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.button.secondary {
background: rgba(15, 76, 92, 0.12);
color: var(--teal);
border: 1px solid rgba(15, 76, 92, 0.3);
}
.button.ghost {
background: transparent;
border: 1px dashed rgba(31, 29, 26, 0.3);
color: var(--ink);
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
box-shadow: none;
}
.button:not(:disabled):hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(240, 90, 40, 0.25);
}
.upload-area {
border: 2px dashed rgba(31, 29, 26, 0.25);
border-radius: 18px;
padding: 18px;
display: grid;
gap: 12px;
background: rgba(255, 255, 255, 0.7);
}
.preview {
width: 100%;
border-radius: 16px;
border: 1px solid rgba(31, 29, 26, 0.1);
object-fit: cover;
max-height: 260px;
}
.thumbnail-grid {
display: grid;
gap: 12px;
}
.thumb-card {
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(31, 29, 26, 0.08);
border-radius: 16px;
padding: 12px;
display: grid;
gap: 10px;
}
.thumb-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.thumb-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.muted {
color: rgba(31, 29, 26, 0.65);
font-size: 0.92rem;
}
.assignments {
display: grid;
gap: 14px;
max-height: 520px;
overflow: auto;
padding-right: 6px;
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.image-card {
display: grid;
gap: 8px;
}
.crop-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 1000;
}
.crop-modal {
width: min(90vw, 720px);
background: #fff;
border-radius: 18px;
padding: 16px;
display: grid;
gap: 12px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.2);
}
.crop-area {
position: relative;
width: 100%;
height: 360px;
border-radius: 12px;
overflow: hidden;
background: #111;
}
.crop-controls {
display: grid;
gap: 10px;
}
.crop-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.assignment-item {
border: 1px solid rgba(31, 29, 26, 0.08);
padding: 14px 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.7);
cursor: pointer;
transition: border 0.2s ease, transform 0.2s ease;
}
.assignment-item.active {
border-color: var(--accent);
transform: translateY(-2px);
box-shadow: 0 12px 22px rgba(240, 90, 40, 0.18);
}
.assignment-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-family: var(--font-mono), monospace;
font-size: 0.75rem;
color: rgba(31, 29, 26, 0.6);
}
.markdown {
background: #fffefb;
border-radius: 18px;
padding: 20px;
border: 1px solid rgba(31, 29, 26, 0.08);
}
.markdown h1, .markdown h2, .markdown h3 {
margin-top: 1em;
}
.markdown code {
font-family: var(--font-mono), monospace;
background: rgba(31, 29, 26, 0.08);
padding: 2px 6px;
border-radius: 6px;
}
.feedback {
border-left: 4px solid var(--accent);
padding: 12px 16px;
background: rgba(240, 90, 40, 0.08);
border-radius: 12px;
}
.camera {
display: grid;
gap: 12px;
}
.camera video {
width: 100%;
border-radius: 16px;
border: 1px solid rgba(31, 29, 26, 0.1);
}
@media (max-width: 960px) {
.hero {
grid-template-columns: 1fr;
}
.hero .stamp {
justify-self: start;
}
.grid {
grid-template-columns: 1fr;
}
}

39
frontend/app/layout.tsx 普通文件
查看文件

@@ -0,0 +1,39 @@
import "./globals.css";
import { Fraunces, Source_Serif_4, IBM_Plex_Mono } from "next/font/google";
const display = Fraunces({
subsets: ["latin"],
variable: "--font-display",
weight: ["400", "500", "600", "700"],
});
const body = Source_Serif_4({
subsets: ["latin"],
variable: "--font-body",
weight: ["400", "500", "600"],
});
const mono = IBM_Plex_Mono({
subsets: ["latin"],
variable: "--font-mono",
weight: ["400", "500"],
});
export const metadata = {
title: "作业工坊",
description: "拍照或上传作业,自动转为 Markdown 并批改",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh">
<body className={`${display.variable} ${body.variable} ${mono.variable}`}>
{children}
</body>
</html>
);
}

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>
);
}

5
frontend/next-env.d.ts vendored 普通文件
查看文件

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

7
frontend/next.config.js 普通文件
查看文件

@@ -0,0 +1,7 @@
/** @type {import("next").NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: "standalone"
};
module.exports = nextConfig;

1730
frontend/package-lock.json 自动生成的 普通文件

文件差异内容过多而无法显示 加载差异

25
frontend/package.json 普通文件
查看文件

@@ -0,0 +1,25 @@
{
"name": "homework-frontend",
"private": true,
"version": "1.0.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start -p 3000",
"lint": "next lint"
},
"dependencies": {
"clsx": "^2.1.1",
"next": "14.2.10",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-easy-crop": "^5.0.6",
"react-markdown": "^9.0.1"
},
"devDependencies": {
"@types/node": "^20.11.30",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"typescript": "^5.4.5"
}
}

0
frontend/public/.keep 普通文件
查看文件

38
frontend/tsconfig.json 普通文件
查看文件

@@ -0,0 +1,38 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"types": [
"node"
],
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}