feat: ship minecraft theme updates and platform workflow improvements
这个提交包含在:
@@ -8,6 +8,16 @@ export const runtime = "nodejs";
|
||||
|
||||
const CACHE_DIR = process.env.CSP_IMAGE_CACHE_DIR ?? "/tmp/csp-image-cache";
|
||||
const MAX_BYTES = 5 * 1024 * 1024;
|
||||
const IMAGE_EXT_TO_TYPE: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".svg": "image/svg+xml",
|
||||
".bmp": "image/bmp",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
|
||||
function toArrayBuffer(view: Uint8Array): ArrayBuffer {
|
||||
return view.buffer.slice(
|
||||
@@ -28,6 +38,25 @@ function pickExt(urlObj: URL, contentType: string): string {
|
||||
return ".img";
|
||||
}
|
||||
|
||||
function inferImageType(urlObj: URL, contentType: string): string {
|
||||
const raw = contentType.split(";")[0].trim().toLowerCase();
|
||||
if (raw.startsWith("image/")) return raw;
|
||||
const ext = path.extname(urlObj.pathname || "").toLowerCase();
|
||||
return IMAGE_EXT_TO_TYPE[ext] ?? raw;
|
||||
}
|
||||
|
||||
function looksLikeImage(urlObj: URL, contentType: string): boolean {
|
||||
if (contentType.startsWith("image/")) return true;
|
||||
const ext = path.extname(urlObj.pathname || "").toLowerCase();
|
||||
return Boolean(IMAGE_EXT_TO_TYPE[ext]);
|
||||
}
|
||||
|
||||
function redirectToTarget(target: URL): NextResponse {
|
||||
const res = NextResponse.redirect(target.toString(), 307);
|
||||
res.headers.set("Cache-Control", "no-store");
|
||||
return res;
|
||||
}
|
||||
|
||||
async function readCachedByKey(
|
||||
key: string
|
||||
): Promise<{ data: Uint8Array; contentType: string } | null> {
|
||||
@@ -94,14 +123,12 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: `fetch image failed: HTTP ${resp.status}` },
|
||||
{ status: 502 }
|
||||
);
|
||||
return redirectToTarget(target);
|
||||
}
|
||||
|
||||
const contentType = (resp.headers.get("content-type") ?? "").toLowerCase();
|
||||
if (!contentType.startsWith("image/")) {
|
||||
const headerType = (resp.headers.get("content-type") ?? "").toLowerCase();
|
||||
const contentType = inferImageType(target, headerType);
|
||||
if (!looksLikeImage(target, contentType)) {
|
||||
return NextResponse.json({ ok: false, error: "url is not an image" }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -125,11 +152,8 @@ export async function GET(req: NextRequest) {
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: `fetch image failed: ${String(e)}` },
|
||||
{ status: 502 }
|
||||
);
|
||||
} catch {
|
||||
return redirectToTarget(target);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,20 @@ type TriggerMissingResp = {
|
||||
max_solutions: number;
|
||||
};
|
||||
|
||||
type AdminUser = {
|
||||
id: number;
|
||||
username: string;
|
||||
rating: number;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type AdminUsersResp = {
|
||||
items: AdminUser[];
|
||||
total_count: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
};
|
||||
|
||||
function fmtTs(v: number | null | undefined): string {
|
||||
if (!v) return "-";
|
||||
return new Date(v * 1000).toLocaleString();
|
||||
@@ -81,17 +95,24 @@ export default function BackendLogsPage() {
|
||||
const [queuedIds, setQueuedIds] = useState<number[]>([]);
|
||||
const [triggerLoading, setTriggerLoading] = useState(false);
|
||||
const [triggerMsg, setTriggerMsg] = useState("");
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [deleteUserId, setDeleteUserId] = useState<number | null>(null);
|
||||
const [userMsg, setUserMsg] = useState("");
|
||||
|
||||
const refresh = async () => {
|
||||
if (!isAdmin || !token) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await apiFetch<BackendLogsResp>(
|
||||
`/api/v1/backend/logs?limit=${limit}&running_limit=20&queued_limit=100`,
|
||||
{},
|
||||
token
|
||||
);
|
||||
const [data, usersData] = await Promise.all([
|
||||
apiFetch<BackendLogsResp>(
|
||||
`/api/v1/backend/logs?limit=${limit}&running_limit=20&queued_limit=100`,
|
||||
{},
|
||||
token
|
||||
),
|
||||
apiFetch<AdminUsersResp>("/api/v1/admin/users?page=1&page_size=200", {}, token),
|
||||
]);
|
||||
|
||||
setPendingJobs(data.pending_jobs ?? 0);
|
||||
setMissingProblems(data.missing_problems ?? 0);
|
||||
setItems(data.items ?? []);
|
||||
@@ -99,6 +120,7 @@ export default function BackendLogsPage() {
|
||||
setQueuedJobs(data.queued_jobs ?? []);
|
||||
setRunningIds(data.running_problem_ids ?? []);
|
||||
setQueuedIds(data.queued_problem_ids ?? []);
|
||||
setUsers(usersData.items ?? []);
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
@@ -185,6 +207,45 @@ export default function BackendLogsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUser = async (user: AdminUser) => {
|
||||
if (!isAdmin || !token) return;
|
||||
if (user.username === "admin") {
|
||||
setError(tx("保留管理员账号不可删除", "Reserved admin account cannot be deleted"));
|
||||
return;
|
||||
}
|
||||
const ok = window.confirm(
|
||||
tx(
|
||||
`确认删除用户 ${user.username}(#${user.id})?该用户提交记录、错题本、草稿、积分记录会被级联删除。`,
|
||||
`Delete user ${user.username}(#${user.id})? Submissions, wrong-book, drafts, and points records will be removed by cascade.`
|
||||
)
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
setDeleteUserId(user.id);
|
||||
setError("");
|
||||
setUserMsg("");
|
||||
try {
|
||||
await apiFetch(
|
||||
`/api/v1/admin/users/${user.id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
token
|
||||
);
|
||||
setUserMsg(
|
||||
tx(
|
||||
`已删除用户 ${user.username}(#${user.id})`,
|
||||
`Deleted user ${user.username}(#${user.id}).`
|
||||
)
|
||||
);
|
||||
await refresh();
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setDeleteUserId(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin || !token) return;
|
||||
const timer = setInterval(() => {
|
||||
@@ -250,12 +311,94 @@ export default function BackendLogsPage() {
|
||||
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
{triggerMsg && <p className="mt-3 text-sm text-emerald-700">{triggerMsg}</p>}
|
||||
{userMsg && <p className="mt-3 text-sm text-emerald-700">{userMsg}</p>}
|
||||
<p className="mt-3 text-xs text-zinc-500">
|
||||
{tx(
|
||||
"系统已自动单线程异步处理待队列任务,无需手工点击;上方按钮仅用于立即手动补全。",
|
||||
"System auto-processes queued jobs in single-thread async mode; the button above is only for manual trigger."
|
||||
)}
|
||||
</p>
|
||||
|
||||
<section className="mt-4 rounded-xl border bg-white p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-medium">{tx("用户管理(可删除)", "User Management (Delete Supported)")}</h2>
|
||||
<p className="text-xs text-zinc-600">
|
||||
{tx("总用户", "Total users")} {users.length}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 divide-y md:hidden">
|
||||
{users.map((user) => (
|
||||
<article key={user.id} className="space-y-1 py-2 text-xs">
|
||||
<p className="font-medium">
|
||||
#{user.id} · {user.username}
|
||||
</p>
|
||||
<p className="text-zinc-600">
|
||||
Rating {user.rating} · {tx("创建", "Created")} {fmtTs(user.created_at)}
|
||||
</p>
|
||||
<button
|
||||
className="rounded border px-2 py-1 text-xs text-red-700 hover:bg-red-50 disabled:opacity-60"
|
||||
disabled={deleteUserId === user.id || user.username === "admin"}
|
||||
onClick={() => void deleteUser(user)}
|
||||
>
|
||||
{user.username === "admin"
|
||||
? tx("保留账号", "Reserved")
|
||||
: deleteUserId === user.id
|
||||
? tx("删除中...", "Deleting...")
|
||||
: tx("删除用户", "Delete User")}
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
{!loading && users.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-zinc-500">{tx("暂无用户数据", "No users found")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 hidden overflow-x-auto md:block">
|
||||
<table className="min-w-full text-xs">
|
||||
<thead className="bg-zinc-100 text-left">
|
||||
<tr>
|
||||
<th className="px-2 py-2">ID</th>
|
||||
<th className="px-2 py-2">{tx("用户名", "Username")}</th>
|
||||
<th className="px-2 py-2">Rating</th>
|
||||
<th className="px-2 py-2">{tx("创建时间", "Created At")}</th>
|
||||
<th className="px-2 py-2">{tx("操作", "Action")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="border-t">
|
||||
<td className="px-2 py-2">{user.id}</td>
|
||||
<td className="px-2 py-2">{user.username}</td>
|
||||
<td className="px-2 py-2">{user.rating}</td>
|
||||
<td className="px-2 py-2 text-zinc-600">{fmtTs(user.created_at)}</td>
|
||||
<td className="px-2 py-2">
|
||||
<button
|
||||
className="rounded border px-2 py-1 text-xs text-red-700 hover:bg-red-50 disabled:opacity-60"
|
||||
disabled={deleteUserId === user.id || user.username === "admin"}
|
||||
onClick={() => void deleteUser(user)}
|
||||
>
|
||||
{user.username === "admin"
|
||||
? tx("保留账号", "Reserved")
|
||||
: deleteUserId === user.id
|
||||
? tx("删除中...", "Deleting...")
|
||||
: tx("删除用户", "Delete User")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && users.length === 0 && (
|
||||
<tr>
|
||||
<td className="px-2 py-4 text-center text-zinc-500" colSpan={5}>
|
||||
{tx("暂无用户数据", "No users found")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<article className="rounded-xl border bg-white p-3">
|
||||
<h2 className="text-sm font-medium">{tx("正在处理(Running)", "Running Jobs")}</h2>
|
||||
|
||||
@@ -35,6 +35,11 @@ body {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.solution-code-block {
|
||||
font-size: calc(0.875rem * 0.7) !important;
|
||||
line-height: 1.05rem !important;
|
||||
}
|
||||
|
||||
.print-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { PixelAvatar } from "@/components/pixel-avatar";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
@@ -218,10 +219,23 @@ export default function MePage() {
|
||||
|
||||
{profile && (
|
||||
<section className="mt-4 rounded-xl border bg-white p-4 text-sm">
|
||||
<p>ID: {profile.id}</p>
|
||||
<p>{tx("用户名", "Username")}: {profile.username}</p>
|
||||
<p>Rating: {profile.rating}</p>
|
||||
<p>{tx("创建时间", "Created At")}: {fmtTs(profile.created_at)}</p>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<PixelAvatar
|
||||
seed={`${profile.username}-${profile.id}`}
|
||||
size={72}
|
||||
className="border-zinc-700"
|
||||
alt={`${profile.username} avatar`}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<p>ID: {profile.id}</p>
|
||||
<p>{tx("用户名", "Username")}: {profile.username}</p>
|
||||
<p>Rating: {profile.rating}</p>
|
||||
<p>{tx("创建时间", "Created At")}: {fmtTs(profile.created_at)}</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{tx("默认像素头像按账号随机生成,可作为主题角色形象。", "Default pixel avatar is randomly generated by account as your theme character.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { CodeEditor } from "@/components/code-editor";
|
||||
import { MarkdownRenderer } from "@/components/markdown-renderer";
|
||||
@@ -100,6 +100,12 @@ function scoreRatio(score: number): number {
|
||||
return Math.max(0, Math.min(100, score));
|
||||
}
|
||||
|
||||
function buildDraftSignature(code: string, stdinText: string): string {
|
||||
const normCode = normalizeCodeText(code);
|
||||
const normStdin = (stdinText ?? "").replace(/\r\n?/g, "\n");
|
||||
return `${normCode}\n__STDIN__\n${normStdin}`;
|
||||
}
|
||||
|
||||
type ResultTone = {
|
||||
title: string;
|
||||
icon: string;
|
||||
@@ -154,7 +160,8 @@ type DraftResp = {
|
||||
language: string;
|
||||
code: string;
|
||||
stdin: string;
|
||||
updated_at: number;
|
||||
updated_at: number | null;
|
||||
exists?: boolean;
|
||||
};
|
||||
|
||||
type SolutionItem = {
|
||||
@@ -292,6 +299,14 @@ export default function ProblemDetailPage() {
|
||||
const [solutionData, setSolutionData] = useState<SolutionResp | null>(null);
|
||||
const [solutionMsg, setSolutionMsg] = useState("");
|
||||
const [printAnswerMarkdown, setPrintAnswerMarkdown] = useState("");
|
||||
const draftLatestRef = useRef<{ code: string; stdin: string }>({
|
||||
code: starterCode,
|
||||
stdin: defaultRunInput,
|
||||
});
|
||||
const draftLastSavedSigRef = useRef<string>(
|
||||
buildDraftSignature(starterCode, defaultRunInput)
|
||||
);
|
||||
const draftAutoSavingRef = useRef(false);
|
||||
|
||||
const llmProfile = useMemo<LlmProfile | null>(() => {
|
||||
if (!problem?.llm_profile_json) return null;
|
||||
@@ -392,8 +407,14 @@ export default function ProblemDetailPage() {
|
||||
setShowSolutions(false);
|
||||
setSolutionData(null);
|
||||
setSolutionMsg("");
|
||||
draftLatestRef.current = { code: starterCode, stdin: defaultRunInput };
|
||||
draftLastSavedSigRef.current = buildDraftSignature(starterCode, defaultRunInput);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
draftLatestRef.current = { code, stdin: runInput };
|
||||
}, [code, runInput]);
|
||||
|
||||
useEffect(() => {
|
||||
const clearPrintCache = () => setPrintAnswerMarkdown("");
|
||||
window.addEventListener("afterprint", clearPrintCache);
|
||||
@@ -477,9 +498,21 @@ export default function ProblemDetailPage() {
|
||||
if (!token) return;
|
||||
try {
|
||||
const draft = await apiFetch<DraftResp>(`/api/v1/problems/${id}/draft`, undefined, token);
|
||||
if (draft.code) setCode(draft.code);
|
||||
if (draft.stdin) setRunInput(draft.stdin);
|
||||
setDraftMsg(tx("已自动加载草稿", "Draft auto-loaded"));
|
||||
const hasDraft = Boolean(draft.exists) || Boolean(draft.code) || Boolean(draft.stdin);
|
||||
let nextCode = draftLatestRef.current.code;
|
||||
let nextStdin = draftLatestRef.current.stdin;
|
||||
if (draft.code) {
|
||||
nextCode = draft.code;
|
||||
setCode(draft.code);
|
||||
}
|
||||
if (typeof draft.stdin === "string" && draft.stdin.length > 0) {
|
||||
nextStdin = draft.stdin;
|
||||
setRunInput(draft.stdin);
|
||||
}
|
||||
draftLastSavedSigRef.current = buildDraftSignature(nextCode, nextStdin);
|
||||
if (hasDraft) {
|
||||
setDraftMsg(tx("已自动加载草稿", "Draft auto-loaded"));
|
||||
}
|
||||
} catch {
|
||||
// ignore empty draft / unauthorized
|
||||
}
|
||||
@@ -487,6 +520,39 @@ export default function ProblemDetailPage() {
|
||||
void loadDraft();
|
||||
}, [id, tx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(id) || id <= 0) return;
|
||||
const timer = window.setInterval(() => {
|
||||
const token = readToken();
|
||||
if (!token || draftAutoSavingRef.current) return;
|
||||
|
||||
const payload = draftLatestRef.current;
|
||||
const nextSig = buildDraftSignature(payload.code, payload.stdin);
|
||||
if (nextSig === draftLastSavedSigRef.current) return;
|
||||
|
||||
draftAutoSavingRef.current = true;
|
||||
void apiFetch<{ saved: boolean }>(
|
||||
`/api/v1/problems/${id}/draft`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ language: "cpp", code: payload.code, stdin: payload.stdin }),
|
||||
},
|
||||
token
|
||||
)
|
||||
.then(() => {
|
||||
draftLastSavedSigRef.current = nextSig;
|
||||
setDraftMsg(tx("草稿已自动保存(每60秒)", "Draft auto-saved (every 60s)"));
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep silent to avoid noisy notifications on transient network errors.
|
||||
})
|
||||
.finally(() => {
|
||||
draftAutoSavingRef.current = false;
|
||||
});
|
||||
}, 60000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [id, tx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!problemId) return;
|
||||
setRunInput((prev) => (prev.trim().length > 0 ? prev : sampleInput || defaultRunInput));
|
||||
@@ -555,6 +621,7 @@ export default function ProblemDetailPage() {
|
||||
},
|
||||
token
|
||||
);
|
||||
draftLastSavedSigRef.current = buildDraftSignature(code, runInput);
|
||||
setDraftMsg(tx("草稿已保存", "Draft saved"));
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
@@ -1084,7 +1151,7 @@ export default function ProblemDetailPage() {
|
||||
{tx("写入并试运行", "Insert & Run")}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="overflow-x-auto whitespace-pre p-3 text-sm leading-6 text-zinc-100">
|
||||
<pre className="solution-code-block overflow-x-auto whitespace-pre p-3 text-sm leading-6 text-zinc-100">
|
||||
{normalizeCodeText(item.code_cpp)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
在新工单中引用
屏蔽一个用户