fix: image upload proxy, compression, and JSON parse safety
- Add /api/v1/ and /files/ rewrite rules in next.config.ts so frontend can call backend without /admin139 prefix - Fix upload using MultiPartParser instead of req->getUploadedFiles() - Add client-side image compression (canvas resize to 1920px, quality 0.8) for photos >500KB before upload - Safe JSON parsing: catch HTML error responses instead of throwing SyntaxError on non-JSON backend responses - Fix backslash escape in delete filename validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
这个提交包含在:
@@ -156,6 +156,15 @@ type RunResult = {
|
||||
compile_log: string;
|
||||
};
|
||||
|
||||
type WrongBookItem = {
|
||||
problem_id: number;
|
||||
note: string;
|
||||
note_score: number;
|
||||
note_rating: number;
|
||||
note_feedback_md: string;
|
||||
note_images?: string[];
|
||||
};
|
||||
|
||||
type DraftResp = {
|
||||
language: string;
|
||||
code: string;
|
||||
@@ -302,6 +311,8 @@ export default function ProblemDetailPage() {
|
||||
const [policyMsg, setPolicyMsg] = useState("");
|
||||
|
||||
const [noteText, setNoteText] = useState("" );
|
||||
const [noteImages, setNoteImages] = useState<string[]>([]);
|
||||
const [noteUploading, setNoteUploading] = useState(false);
|
||||
const [noteSaving, setNoteSaving] = useState(false);
|
||||
const [noteScoring, setNoteScoring] = useState(false);
|
||||
const [noteScore, setNoteScore] = useState<number | null>(null);
|
||||
@@ -340,6 +351,99 @@ export default function ProblemDetailPage() {
|
||||
if (!problem) return "";
|
||||
return sanitizeStatementMarkdown(problem);
|
||||
}, [problem]);
|
||||
|
||||
|
||||
const compressImage = (file: File, maxWidth = 1920, quality = 0.8): Promise<File> =>
|
||||
new Promise((resolve) => {
|
||||
if (file.size <= 500 * 1024 || !file.type.startsWith("image/")) {
|
||||
resolve(file);
|
||||
return;
|
||||
}
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(file);
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
let { width, height } = img;
|
||||
if (width > maxWidth) {
|
||||
height = Math.round((height * maxWidth) / width);
|
||||
width = maxWidth;
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.getContext("2d")!.drawImage(img, 0, 0, width, height);
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob && blob.size < file.size) {
|
||||
const ext = file.type === "image/png" ? ".png" : ".jpg";
|
||||
resolve(new File([blob], file.name.replace(/\.[^.]+$/, ext), { type: blob.type }));
|
||||
} else {
|
||||
resolve(file);
|
||||
}
|
||||
},
|
||||
file.type === "image/png" ? "image/png" : "image/jpeg",
|
||||
quality,
|
||||
);
|
||||
};
|
||||
img.onerror = () => { URL.revokeObjectURL(url); resolve(file); };
|
||||
img.src = url;
|
||||
});
|
||||
|
||||
const handleNoteImageFiles = async (files: FileList | null) => {
|
||||
const token = readToken();
|
||||
if (!token) {
|
||||
setNoteMsg(tx("请先登录后再上传图片。", "Please login to upload images."));
|
||||
return;
|
||||
}
|
||||
if (!problemId) return;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
setNoteUploading(true);
|
||||
setNoteMsg("");
|
||||
try {
|
||||
const compressed = await Promise.all(Array.from(files).map((f) => compressImage(f)));
|
||||
const form = new FormData();
|
||||
compressed.forEach((f) => form.append("files", f));
|
||||
const resp = await fetch(`/api/v1/me/wrong-book/${problemId}/note-images`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: form,
|
||||
});
|
||||
const text = await resp.text();
|
||||
let json: Record<string, unknown>;
|
||||
try { json = JSON.parse(text); } catch { throw new Error(`Server error: ${resp.status}`); }
|
||||
if (!resp.ok || json?.ok === false) {
|
||||
throw new Error((json?.error as string) || `HTTP ${resp.status}`);
|
||||
}
|
||||
const d = json?.data as Record<string, unknown> | undefined;
|
||||
const arr = Array.isArray(d?.note_images) ? (d.note_images as string[]) : [];
|
||||
setNoteImages(arr);
|
||||
setNoteMsg(tx("图片已上传。", "Images uploaded."));
|
||||
} catch (e: unknown) {
|
||||
setNoteMsg(String(e));
|
||||
} finally {
|
||||
setNoteUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteNoteImage = async (filename: string) => {
|
||||
const token = readToken();
|
||||
if (!token) {
|
||||
setNoteMsg(tx("请先登录后再删除图片。", "Please login to delete images."));
|
||||
return;
|
||||
}
|
||||
if (!problemId) return;
|
||||
try {
|
||||
const resp = await apiFetch<{ note_images: string[] }>(`/api/v1/me/wrong-book/${problemId}/note-images`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ filename }),
|
||||
}, token);
|
||||
if (Array.isArray((resp as any).note_images)) setNoteImages((resp as any).note_images);
|
||||
setNoteMsg(tx("图片已删除。", "Image deleted."));
|
||||
} catch (e: unknown) {
|
||||
setNoteMsg(String(e));
|
||||
}
|
||||
};
|
||||
const saveLearningNote = async () => {
|
||||
const token = readToken();
|
||||
if (!token) {
|
||||
@@ -391,8 +495,32 @@ export default function ProblemDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const sampleInput = problem?.sample_input ?? "";
|
||||
const problemId = problem?.id ?? 0;
|
||||
const loadExistingWrongBookNote = async () => {
|
||||
const token = readToken();
|
||||
if (!token) return;
|
||||
if (!problemId) return;
|
||||
try {
|
||||
const resp = await apiFetch<WrongBookItem[]>(`/api/v1/me/wrong-book`, { method: "GET" }, token);
|
||||
const item = resp.find((x) => x.problem_id === problemId);
|
||||
if (!item) return;
|
||||
if (item.note) setNoteText(item.note);
|
||||
if (Number.isFinite(item.note_score) && item.note_score > 0) setNoteScore(item.note_score);
|
||||
if (Number.isFinite(item.note_rating) && item.note_rating > 0) setNoteRating(item.note_rating);
|
||||
if (item.note_feedback_md) setNoteFeedback(item.note_feedback_md);
|
||||
if (Array.isArray(item.note_images)) setNoteImages(item.note_images);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadExistingWrongBookNote();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [problemId]);
|
||||
const printableAnswerMarkdown = useMemo(
|
||||
() => buildPrintableAnswerMarkdown(solutionData, tx),
|
||||
[solutionData, tx]
|
||||
@@ -916,6 +1044,18 @@ export default function ProblemDetailPage() {
|
||||
/>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<label className={`mc-btn text-xs ${noteUploading ? "opacity-60" : ""}`}>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
onChange={(e) => void handleNoteImageFiles(e.target.files)}
|
||||
disabled={noteUploading}
|
||||
/>
|
||||
{noteUploading ? tx("上传中...", "Uploading...") : tx("拍照/上传图片", "Upload Photos")}
|
||||
</label>
|
||||
<button className="mc-btn mc-btn-success text-xs" onClick={() => void saveLearningNote()} disabled={noteSaving}>
|
||||
{noteSaving ? tx("保存中...", "Saving...") : tx("保存笔记", "Save")}
|
||||
</button>
|
||||
@@ -931,6 +1071,29 @@ export default function ProblemDetailPage() {
|
||||
|
||||
{noteMsg && <p className="mt-2 text-xs text-[color:var(--mc-stone-dark)]">{noteMsg}</p>}
|
||||
|
||||
{noteImages.length > 0 && (
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 sm:grid-cols-4">
|
||||
{noteImages.map((fn) => (
|
||||
<div key={fn} className="rounded border-2 border-black bg-[color:var(--mc-plank-light)] p-1 shadow-[2px_2px_0_rgba(0,0,0,0.25)]">
|
||||
<a href={`/files/note-images/${fn}`} target="_blank" rel="noreferrer">
|
||||
<img
|
||||
src={`/files/note-images/${fn}`}
|
||||
alt={fn}
|
||||
className="h-20 w-full object-cover"
|
||||
/>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 w-full rounded border border-black bg-[color:var(--mc-red)] px-2 py-1 text-[10px] text-white hover:brightness-110"
|
||||
onClick={() => void deleteNoteImage(fn)}
|
||||
>
|
||||
{tx("删除", "Delete")}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{noteFeedback && (
|
||||
<div className="mt-3 border-t-2 border-dashed border-black pt-3">
|
||||
<MarkdownRenderer markdown={noteFeedback} className="problem-markdown text-black" />
|
||||
|
||||
在新工单中引用
屏蔽一个用户