feat: ship minecraft theme updates and platform workflow improvements

这个提交包含在:
Codex CLI
2026-02-15 17:36:56 +08:00
父节点 cd7540ab9d
当前提交 37266bb846
修改 32 个文件,包含 5297 行新增119 行删除

查看文件

@@ -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>

查看文件

@@ -3,6 +3,7 @@
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { PixelAvatar } from "@/components/pixel-avatar";
import { useUiPreferences } from "@/components/ui-preference-provider";
import { apiFetch } from "@/lib/api";
import { clearToken, readToken } from "@/lib/auth";
@@ -19,6 +20,11 @@ type NavGroup = {
links: NavLink[];
};
type MeProfile = {
id?: number;
username?: string;
};
function buildNavGroups(t: (key: string) => string, isAdmin: boolean): NavGroup[] {
const groups: NavGroup[] = [
{
@@ -97,12 +103,13 @@ export function AppNav() {
const [hasToken, setHasToken] = useState<boolean>(() => Boolean(readToken()));
const [isAdmin, setIsAdmin] = useState(false);
const [meProfile, setMeProfile] = useState<MeProfile | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [desktopOpenGroup, setDesktopOpenGroup] = useState<string | null>(null);
const desktopMenuRef = useRef<HTMLDivElement | null>(null);
const navGroups = useMemo(() => buildNavGroups(t, isAdmin), [isAdmin, t]);
const activeGroup = resolveActiveGroup(pathname, navGroups);
const usePopupSecondary = theme === "default";
const usePopupSecondary = theme === "default" || theme === "minecraft";
useEffect(() => {
let canceled = false;
@@ -112,15 +119,20 @@ export function AppNav() {
setHasToken(Boolean(token));
if (!token) {
setIsAdmin(false);
setMeProfile(null);
return;
}
try {
const me = await apiFetch<{ username?: string }>("/api/v1/me", {}, token);
const me = await apiFetch<MeProfile>("/api/v1/me", {}, token);
if (!canceled) {
setIsAdmin((me?.username ?? "") === "admin");
setMeProfile(me ?? null);
}
} catch {
if (!canceled) setIsAdmin(false);
if (!canceled) {
setIsAdmin(false);
setMeProfile(null);
}
}
};
const onRefresh = () => {
@@ -149,6 +161,16 @@ export function AppNav() {
}, [desktopOpenGroup, usePopupSecondary]);
const currentGroup = navGroups.find((g) => g.key === activeGroup) ?? navGroups[0];
const avatarSeed = meProfile
? `${meProfile.username ?? "user"}-${meProfile.id ?? ""}`
: "guest";
const handleLogout = () => {
clearToken();
setHasToken(false);
setIsAdmin(false);
setMeProfile(null);
setDesktopOpenGroup(null);
};
return (
<header className="print-hidden border-b bg-[color:var(--surface)]/95 backdrop-blur supports-[backdrop-filter]:bg-[color:var(--surface)]/85">
@@ -188,7 +210,11 @@ export function AppNav() {
{group.label}
</button>
{opened && (
<div className="absolute left-0 top-full z-50 mt-2 min-w-[11rem] rounded-md border bg-[color:var(--surface)] p-1 shadow-lg">
<div
className={`absolute left-0 top-full z-50 mt-2 rounded-md border bg-[color:var(--surface)] p-1 shadow-lg ${
group.key === "account" ? "min-w-[18rem]" : "min-w-[11rem]"
}`}
>
{group.links.map((item) => {
const linkActive = isActivePath(pathname, item.href);
return (
@@ -207,6 +233,64 @@ export function AppNav() {
</button>
);
})}
{group.key === "account" && (
<div className="mt-2 space-y-2 border-t border-zinc-200 px-2 pt-2">
<label className="block text-xs">
<span className="text-zinc-500">{t("prefs.theme")}</span>
<select
className="mt-1 w-full rounded-md border px-2 py-1 text-xs"
value={theme}
onChange={(e) => setTheme(e.target.value as ThemeId)}
>
{themes.map((item) => (
<option key={item.id} value={item.id}>
{item.labels[language]}
</option>
))}
</select>
</label>
<label className="block text-xs">
<span className="text-zinc-500">{t("prefs.language")}</span>
<select
className="mt-1 w-full rounded-md border px-2 py-1 text-xs"
value={language}
onChange={(e) => setLanguage(e.target.value === "zh" ? "zh" : "en")}
>
<option value="en">{t("prefs.lang.en")}</option>
<option value="zh">{t("prefs.lang.zh")}</option>
</select>
</label>
<div className="flex items-center justify-between gap-2 rounded-md border border-zinc-200 px-2 py-1.5">
<div className="flex min-w-0 items-center gap-2">
<span className={hasToken ? "text-emerald-700" : "text-zinc-500"}>
{hasToken ? t("nav.logged_in") : t("nav.logged_out")}
</span>
{hasToken && (
<>
<PixelAvatar
seed={avatarSeed}
size={20}
className="border-zinc-700"
alt={meProfile?.username ? `${meProfile.username} avatar` : "avatar"}
/>
{meProfile?.username && (
<span className="truncate text-xs text-zinc-500">{meProfile.username}</span>
)}
</>
)}
</div>
{hasToken && (
<button
type="button"
className="rounded-md border px-2 py-1 text-xs hover:bg-zinc-100"
onClick={handleLogout}
>
{t("nav.logout")}
</button>
)}
</div>
</div>
)}
</div>
)}
</div>
@@ -274,7 +358,7 @@ export function AppNav() {
</div>
</div>
<div className="mt-2 flex flex-wrap items-center justify-end gap-2 text-xs sm:text-sm">
<div className={`mt-2 flex flex-wrap items-center justify-end gap-2 text-xs sm:text-sm ${usePopupSecondary ? "md:hidden" : ""}`}>
<label className="inline-flex items-center gap-1">
<span className="text-zinc-500">{t("prefs.theme")}</span>
<select
@@ -306,13 +390,17 @@ export function AppNav() {
<span className={hasToken ? "text-emerald-700" : "text-zinc-500"}>
{hasToken ? t("nav.logged_in") : t("nav.logged_out")}
</span>
{hasToken && (
<PixelAvatar
seed={avatarSeed}
size={24}
className="border-zinc-700"
alt={meProfile?.username ? `${meProfile.username} avatar` : "avatar"}
/>
)}
{hasToken && (
<button
onClick={() => {
clearToken();
setHasToken(false);
setIsAdmin(false);
}}
onClick={handleLogout}
className="rounded-md border px-3 py-1 hover:bg-zinc-100"
>
{t("nav.logout")}

查看文件

@@ -4,11 +4,13 @@ import dynamic from "next/dynamic";
import { useCallback, useEffect, useRef } from "react";
import type { editor as MonacoEditorNS, IDisposable, MarkerSeverity, IPosition } from "monaco-editor";
import { useUiPreferences } from "@/components/ui-preference-provider";
import { analyzeCpp14Policy, type Cpp14PolicyIssue } from "@/lib/cpp14-policy";
import { useI18nText } from "@/lib/i18n";
const MonacoEditor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
const POLICY_MARKER_OWNER = "csp-cpp14-policy";
const MINECRAFT_MONACO_THEME = "csp-minecraft-dark";
type Props = {
value: string;
@@ -94,6 +96,40 @@ function localizePolicyIssue(
return { ...issue, ...localized };
}
function defineMinecraftMonacoTheme(monaco: typeof import("monaco-editor")): void {
monaco.editor.defineTheme(MINECRAFT_MONACO_THEME, {
base: "vs-dark",
inherit: true,
rules: [
{ token: "", foreground: "ECECEC", background: "111317" },
{ token: "comment", foreground: "7F8A99", fontStyle: "italic" },
{ token: "keyword", foreground: "C792EA" },
{ token: "type", foreground: "82AAFF" },
{ token: "number", foreground: "F78C6C" },
{ token: "string", foreground: "C3E88D" },
{ token: "operator", foreground: "89DDFF" },
{ token: "delimiter.bracket", foreground: "D8DEE9" },
{ token: "predefined", foreground: "FFD580" },
],
colors: {
"editor.background": "#111317",
"editor.foreground": "#ECECEC",
"editorCursor.foreground": "#FFE082",
"editorLineNumber.foreground": "#768296",
"editorLineNumber.activeForeground": "#C7D0DE",
"editor.selectionBackground": "#2A3347",
"editor.inactiveSelectionBackground": "#232B3C",
"editor.lineHighlightBackground": "#171D26",
"editor.wordHighlightBackground": "#303A4E99",
"editorIndentGuide.background": "#2A3240",
"editorIndentGuide.activeBackground": "#3D485A",
"editorBracketMatch.background": "#2B364A",
"editorBracketMatch.border": "#89DDFF88",
"editorGutter.background": "#111317",
},
});
}
export function CodeEditor({
value,
onChange,
@@ -101,10 +137,12 @@ export function CodeEditor({
fontSize = 14,
onPolicyIssuesChange,
}: Props) {
const { theme } = useUiPreferences();
const { tx } = useI18nText();
const editorRef = useRef<MonacoEditorNS.IStandaloneCodeEditor | null>(null);
const monacoRef = useRef<typeof import("monaco-editor") | null>(null);
const completionRef = useRef<IDisposable | null>(null);
const editorTheme = theme === "minecraft" ? MINECRAFT_MONACO_THEME : "vs";
const updatePolicyIssues = useCallback(
(nextCode: string) => {
@@ -145,10 +183,20 @@ export function CodeEditor({
[]
);
useEffect(() => {
const monaco = monacoRef.current;
if (!monaco) return;
if (editorTheme === MINECRAFT_MONACO_THEME) {
defineMinecraftMonacoTheme(monaco);
}
monaco.editor.setTheme(editorTheme);
}, [editorTheme]);
return (
<MonacoEditor
height={height}
language="cpp"
theme={editorTheme}
value={value}
options={{
fontSize,
@@ -167,6 +215,10 @@ export function CodeEditor({
onMount={(editor, monaco) => {
editorRef.current = editor;
monacoRef.current = monaco;
if (editorTheme === MINECRAFT_MONACO_THEME) {
defineMinecraftMonacoTheme(monaco);
}
monaco.editor.setTheme(editorTheme);
if (!completionRef.current) {
completionRef.current = monaco.languages.registerCompletionItemProvider("cpp", {

查看文件

@@ -0,0 +1,39 @@
"use client";
import Image from "next/image";
import { useMemo } from "react";
import { buildAvatarSeed, generatePixelAvatarDataUri } from "@/lib/pixel-avatar";
type PixelAvatarProps = {
seed: string;
size?: number;
className?: string;
alt?: string;
};
export function PixelAvatar({
seed,
size = 40,
className = "",
alt = "pixel avatar",
}: PixelAvatarProps) {
const resolvedSeed = useMemo(() => buildAvatarSeed(seed), [seed]);
const src = useMemo(() => generatePixelAvatarDataUri(resolvedSeed), [resolvedSeed]);
return (
<span
className={`pixel-avatar-frame inline-flex items-center justify-center overflow-hidden rounded border ${className}`.trim()}
style={{ width: size, height: size }}
>
<Image
src={src}
alt={alt}
width={size}
height={size}
unoptimized
className="pixel-avatar-image h-full w-full object-cover"
/>
</span>
);
}

查看文件

@@ -3,9 +3,9 @@ export const API_BASE =
(process.env.NODE_ENV === "development" ? "http://localhost:8080" : "/admin139");
function uiText(zhText: string, enText: string): string {
if (typeof window === "undefined") return enText;
if (typeof window === "undefined") return zhText;
const lang = window.localStorage.getItem("csp.ui.language");
return lang === "zh" ? zhText : enText;
return lang === "en" ? enText : zhText;
}
type ApiEnvelope<T> =

查看文件

@@ -186,21 +186,5 @@ export function analyzeCpp14Policy(code: string): Cpp14PolicyIssue[] {
);
}
if (!/\bfreopen\s*\(/.test(text) && /\bint\s+main\s*\(/.test(text)) {
const idx = text.search(/\bint\s+main\s*\(/);
const pos = offsetToPosition(lineStarts, Math.max(0, idx));
pushIssue(
issues,
"freopen-tip",
"hint",
"未检测到 freopen福建二轮常见文件读写要求",
"若考场题面要求 *.in/*.out,请按官方文件名补上 freopen。",
pos.line,
pos.column,
pos.line,
pos.column + 3
);
}
return issues;
}

查看文件

@@ -1,15 +1,20 @@
"use client";
import { useCallback } from "react";
import { useUiPreferences } from "@/components/ui-preference-provider";
export function readUiLanguage(): "en" | "zh" {
if (typeof window === "undefined") return "en";
return window.localStorage.getItem("csp.ui.language") === "zh" ? "zh" : "en";
if (typeof window === "undefined") return "zh";
return window.localStorage.getItem("csp.ui.language") === "en" ? "en" : "zh";
}
export function useI18nText() {
const { language } = useUiPreferences();
const isZh = language === "zh";
const tx = (zhText: string, enText: string) => (isZh ? zhText : enText);
const tx = useCallback(
(zhText: string, enText: string) => (isZh ? zhText : enText),
[isZh]
);
return { language, isZh, tx };
}

查看文件

@@ -0,0 +1,74 @@
function hashSeed(seed: string): number {
let h = 2166136261 >>> 0;
for (let i = 0; i < seed.length; i += 1) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return h >>> 0;
}
function mulberry32(seed: number): () => number {
let t = seed >>> 0;
return () => {
t += 0x6d2b79f5;
let x = Math.imul(t ^ (t >>> 15), t | 1);
x ^= x + Math.imul(x ^ (x >>> 7), x | 61);
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
};
}
const SKIN_PALETTE = ["#f6d3b3", "#e6be95", "#d7a980", "#f8dec6", "#c99264"];
const HAIR_PALETTE = ["#2f1b12", "#4a2f1d", "#6d4c41", "#212121", "#4e342e", "#1b5e20"];
const ACCENT_PALETTE = ["#7cb342", "#00b0d6", "#ffb300", "#8d6e63", "#ab47bc", "#ef5350"];
export function buildAvatarSeed(...parts: Array<string | number | null | undefined>): string {
const cleaned = parts
.map((part) => String(part ?? "").trim())
.filter((part) => part.length > 0);
return cleaned.length > 0 ? cleaned.join("|") : "guest|pixel";
}
export function generatePixelAvatarDataUri(seedInput: string, pixelSize = 10): string {
const seed = buildAvatarSeed(seedInput);
const rng = mulberry32(hashSeed(seed));
const skin = SKIN_PALETTE[Math.floor(rng() * SKIN_PALETTE.length)] ?? SKIN_PALETTE[0];
const hair = HAIR_PALETTE[Math.floor(rng() * HAIR_PALETTE.length)] ?? HAIR_PALETTE[0];
const accent = ACCENT_PALETTE[Math.floor(rng() * ACCENT_PALETTE.length)] ?? ACCENT_PALETTE[0];
const bg = rng() > 0.5 ? "#1f1f1f" : "#2b2b2b";
const border = rng() > 0.5 ? "#000000" : "#3e2723";
const width = 8 * pixelSize;
const height = 8 * pixelSize;
const rects: string[] = [
`<rect width="${width}" height="${height}" fill="${bg}"/>`,
`<rect x="0" y="0" width="${width}" height="${pixelSize}" fill="${accent}" opacity="0.4"/>`,
];
for (let y = 0; y < 8; y += 1) {
for (let x = 0; x < 4; x += 1) {
const fillFace = y >= 1 && y <= 6 && x >= 1;
const threshold = fillFace ? 0.2 : 0.52;
if (rng() < threshold) continue;
const color = y <= 1 ? hair : y >= 6 ? accent : skin;
const leftX = x * pixelSize;
const rightX = (7 - x) * pixelSize;
const yPos = y * pixelSize;
rects.push(`<rect x="${leftX}" y="${yPos}" width="${pixelSize}" height="${pixelSize}" fill="${color}"/>`);
if (rightX !== leftX) {
rects.push(`<rect x="${rightX}" y="${yPos}" width="${pixelSize}" height="${pixelSize}" fill="${color}"/>`);
}
}
}
const eyeY = 3 * pixelSize;
rects.push(`<rect x="${2 * pixelSize}" y="${eyeY}" width="${pixelSize}" height="${pixelSize}" fill="#111"/>`);
rects.push(`<rect x="${5 * pixelSize}" y="${eyeY}" width="${pixelSize}" height="${pixelSize}" fill="#111"/>`);
const mouthY = 5 * pixelSize;
rects.push(`<rect x="${3 * pixelSize}" y="${mouthY}" width="${2 * pixelSize}" height="${pixelSize}" fill="${hair}" opacity="0.8"/>`);
rects.push(`<rect x="${0}" y="${0}" width="${width}" height="${height}" fill="none" stroke="${border}" stroke-width="${Math.max(2, Math.floor(pixelSize / 4))}"/>`);
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" shape-rendering="crispEdges">${rects.join("")}</svg>`;
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
}

查看文件

@@ -247,6 +247,8 @@ labels: {
来源参考:
- `ref/CSP-Minecraft-UI-Kit/docs/Design-Delivery-Document.html`
- `ref/CSP-Minecraft-UI-Kit/html/index.html`
- `ref/CSP-Minecraft-UI-Kit/html/pages/*.html`
已落地核心规范:
@@ -256,6 +258,7 @@ labels: {
- 输入控件像素化风格。
- 背景纹理与图片 `image-rendering: pixelated`
- 标题字体与文本阴影风格。
- 像素默认头像(按账号随机种子生成)。
---
@@ -291,4 +294,3 @@ labels: {
- 将“按钮、卡片、输入框、徽章”收敛为主题化组件(而非纯 class 覆盖)。
- 增加主题视觉回归截图(关键页面自动快照)。
- 建立对比度检测脚本,避免低对比度文本上线。

查看文件

@@ -1,49 +1,51 @@
@font-face {
font-family: "DelaGothicOne";
src: url("https://assets-persist.lovart.ai/agent-static-assets/DelaGothicOne-Regular.ttf");
font-family: "PressStart2P";
src: url("https://fonts.gstatic.com/s/pressstart2p/v15/e3t4euO8T-267oIAQAu6jDQyK3nVivM.woff2")
format("woff2");
font-display: swap;
}
@font-face {
font-family: "MiSans";
src: url("https://assets-persist.lovart.ai/agent-static-assets/MiSans-Regular.ttf");
font-display: swap;
}
@font-face {
font-family: "MiSansBold";
src: url("https://assets-persist.lovart.ai/agent-static-assets/MiSans-Bold.ttf");
font-family: "VT323";
src: url("https://fonts.gstatic.com/s/vt323/v17/pxiKyp0ihIEF2hsY.woff2") format("woff2");
font-display: swap;
}
:root[data-theme="minecraft"] {
--mc-grass-top: #5cb85c;
--mc-grass-side: #4cae4c;
--mc-grass-top: #7cb342;
--mc-grass-dark: #558b2f;
--mc-dirt: #795548;
--mc-wood-dark: #5d4037;
--mc-wood: #8d6e63;
--mc-plank: #c69c6d;
--mc-plank-light: #efebe9;
--mc-stone: #9e9e9e;
--mc-stone-dark: #616161;
--mc-obsidian: #212121;
--mc-wood: #8d6e63;
--mc-wood-dark: #5d4037;
--mc-gold: #ffd700;
--mc-diamond: #40e0d0;
--mc-redstone: #f44336;
--mc-obsidian: #1f1f1f;
--mc-diamond: #00b0d6;
--mc-gold: #ffb300;
--mc-red: #ef5350;
--mc-shadow: #000000;
--background: #1a1a1a;
--background: #161616;
--foreground: #f5f5f5;
--surface: #2d2d2d;
--surface-soft: #242424;
--surface: #262626;
--surface-soft: #1f1f1f;
--border: #000000;
}
:root[data-theme="minecraft"] body {
background-color: var(--background);
background-image:
linear-gradient(rgba(26, 26, 26, 0.9), rgba(26, 26, 26, 0.9)),
url("https://a.lovart.ai/artifacts/agent/W1iXxVdg3xIm5fP9.png");
background-size: 320px 320px;
linear-gradient(45deg, #1f1f1f 25%, transparent 25%),
linear-gradient(-45deg, #1f1f1f 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #1f1f1f 75%),
linear-gradient(-45deg, transparent 75%, #1f1f1f 75%);
background-size: 40px 40px;
background-position: 0 0, 0 20px, 20px -20px, -20px 0;
color: var(--foreground);
font-family: "MiSans", "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
font-family: "VT323", "MiSans", "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 1.06rem;
}
:root[data-theme="minecraft"] h1,
@@ -52,9 +54,9 @@
:root[data-theme="minecraft"] h4,
:root[data-theme="minecraft"] h5,
:root[data-theme="minecraft"] h6 {
color: #ffffff;
font-family: "DelaGothicOne", "MiSansBold", "MiSans", sans-serif;
font-family: "PressStart2P", "VT323", sans-serif;
letter-spacing: 0.04em;
line-height: 1.5;
text-shadow: 2px 2px 0 #000000;
}
@@ -65,19 +67,35 @@
}
:root[data-theme="minecraft"] ::-webkit-scrollbar-thumb {
background: var(--mc-stone);
border: 2px solid var(--mc-obsidian);
background: var(--mc-wood);
border: 2px solid #000;
}
:root[data-theme="minecraft"] .print-hidden {
image-rendering: pixelated;
}
:root[data-theme="minecraft"] header.print-hidden {
background: rgba(33, 33, 33, 0.96) !important;
border-bottom: 4px solid var(--mc-stone-dark);
box-shadow: 0 4px 0 rgba(0, 0, 0, 0.35);
background: linear-gradient(180deg, #1d1d1d 0%, #2b2b2b 100%) !important;
border-bottom: 4px solid #000 !important;
box-shadow: 0 6px 0 rgba(0, 0, 0, 0.4);
position: relative;
}
:root[data-theme="minecraft"] header.print-hidden::before {
content: "";
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 8px;
background: linear-gradient(180deg, var(--mc-grass-top) 0%, var(--mc-grass-dark) 100%);
border-bottom: 2px solid #2e7d32;
}
:root[data-theme="minecraft"] nav.print-hidden.fixed {
background: rgba(33, 33, 33, 0.96) !important;
border-top: 4px solid var(--mc-stone-dark);
background: linear-gradient(180deg, #1d1d1d 0%, #2b2b2b 100%) !important;
border-top: 4px solid #000;
}
:root[data-theme="minecraft"] .rounded-xl.border,
@@ -85,28 +103,39 @@
:root[data-theme="minecraft"] .rounded-md.border,
:root[data-theme="minecraft"] .rounded.border {
border-radius: 0 !important;
border-color: #000000 !important;
border-width: 3px !important;
box-shadow: 6px 6px 0 rgba(0, 0, 0, 0.45);
border: 3px solid #000 !important;
box-shadow:
5px 5px 0 rgba(0, 0, 0, 0.48),
inset 2px 2px 0 rgba(255, 255, 255, 0.08);
}
:root[data-theme="minecraft"] main section.rounded-xl.border.bg-white,
:root[data-theme="minecraft"] main article.rounded-xl.border.bg-white {
background: linear-gradient(180deg, #2a2a2a 0%, #232323 100%) !important;
}
:root[data-theme="minecraft"] .bg-white {
background-color: #2d2d2d !important;
background-color: #292929 !important;
}
:root[data-theme="minecraft"] .bg-zinc-50 {
background-color: #252525 !important;
background-color: #242424 !important;
}
:root[data-theme="minecraft"] .bg-zinc-100 {
background-color: #343434 !important;
background-color: #323232 !important;
}
:root[data-theme="minecraft"] .text-zinc-400,
:root[data-theme="minecraft"] .text-zinc-500,
:root[data-theme="minecraft"] .text-zinc-600,
:root[data-theme="minecraft"] .text-zinc-700 {
color: #d0d0d0 !important;
color: #d7d7d7 !important;
}
:root[data-theme="minecraft"] .text-zinc-800,
:root[data-theme="minecraft"] .text-zinc-900 {
color: #ececec !important;
}
:root[data-theme="minecraft"] .text-blue-600,
@@ -125,24 +154,26 @@
:root[data-theme="minecraft"] .text-red-600,
:root[data-theme="minecraft"] .text-red-700 {
color: var(--mc-redstone) !important;
color: var(--mc-red) !important;
}
:root[data-theme="minecraft"] button {
background: linear-gradient(180deg, var(--mc-stone) 0%, var(--mc-stone-dark) 100%) !important;
border: 3px solid #000000 !important;
background: linear-gradient(180deg, var(--mc-wood) 0%, var(--mc-wood-dark) 100%) !important;
border: 3px solid #000 !important;
border-bottom-width: 7px !important;
border-radius: 0 !important;
box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.45);
color: #ffffff !important;
font-family: "DelaGothicOne", "MiSansBold", "MiSans", sans-serif;
letter-spacing: 0.03em;
text-shadow: 1px 1px 0 #000000;
color: #fff !important;
font-family: "PressStart2P", "VT323", sans-serif !important;
font-size: 0.62rem !important;
letter-spacing: 0.04em;
line-height: 1.4;
text-shadow: 1px 1px 0 #000;
box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.48);
transition: transform 0.08s ease, filter 0.08s ease;
}
:root[data-theme="minecraft"] button:hover:not(:disabled) {
filter: brightness(1.08);
filter: brightness(1.07);
transform: translateY(-1px);
}
@@ -152,23 +183,25 @@
}
:root[data-theme="minecraft"] button:disabled {
filter: saturate(0.25);
opacity: 0.7;
opacity: 0.68;
filter: saturate(0.28);
}
:root[data-theme="minecraft"] input,
:root[data-theme="minecraft"] textarea,
:root[data-theme="minecraft"] select {
background: #1f1f1f !important;
border: 3px solid #000000 !important;
border: 3px solid #000 !important;
border-radius: 0 !important;
box-shadow: inset 2px 2px 0 rgba(255, 255, 255, 0.1);
color: #f2f2f2 !important;
color: #f4f4f4 !important;
box-shadow: inset 2px 2px 0 rgba(255, 255, 255, 0.12);
font-family: "VT323", "MiSans", sans-serif;
font-size: 1rem;
}
:root[data-theme="minecraft"] input::placeholder,
:root[data-theme="minecraft"] textarea::placeholder {
color: #acacac;
color: #adadad;
}
:root[data-theme="minecraft"] a {
@@ -180,22 +213,80 @@
}
:root[data-theme="minecraft"] table thead {
background: #333333 !important;
background: #353535 !important;
}
:root[data-theme="minecraft"] table tr {
border-color: #000000 !important;
border-color: #000 !important;
}
:root[data-theme="minecraft"] pre {
border: 2px solid #000000;
border: 2px solid #000;
box-shadow: inset 1px 1px 0 rgba(255, 255, 255, 0.08);
}
:root[data-theme="minecraft"] code {
font-family: "VT323", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
:root[data-theme="minecraft"] .monaco-editor,
:root[data-theme="minecraft"] .monaco-editor .margin,
:root[data-theme="minecraft"] .monaco-editor .monaco-editor-background,
:root[data-theme="minecraft"] .monaco-editor-background,
:root[data-theme="minecraft"] .monaco-editor .inputarea.ime-input {
background-color: #111317 !important;
}
:root[data-theme="minecraft"] .monaco-editor .current-line {
border-color: #232b3c !important;
}
:root[data-theme="minecraft"] .monaco-editor .suggest-widget,
:root[data-theme="minecraft"] .monaco-editor .parameter-hints-widget {
border: 2px solid #000 !important;
background: #161a22 !important;
color: #ececec !important;
}
:root[data-theme="minecraft"] .monaco-editor .suggest-widget .monaco-list-row.focused {
background: #2a3347 !important;
}
:root[data-theme="minecraft"] img {
image-rendering: pixelated;
}
:root[data-theme="minecraft"] .pixel-avatar-frame {
border: 3px solid #000 !important;
border-radius: 0 !important;
background: linear-gradient(180deg, var(--mc-plank) 0%, var(--mc-dirt) 100%);
box-shadow:
3px 3px 0 rgba(0, 0, 0, 0.45),
inset 1px 1px 0 rgba(255, 255, 255, 0.18);
}
:root[data-theme="minecraft"] .pixel-avatar-image {
image-rendering: pixelated;
}
:root[data-theme="minecraft"] .problem-markdown-compact {
font-size: 66%;
}
:root[data-theme="minecraft"] .problem-markdown-compact article {
color: #ececec !important;
}
:root[data-theme="minecraft"] .problem-markdown-compact blockquote {
color: #d9d9d9 !important;
border-left-color: #8d6e63 !important;
}
:root[data-theme="minecraft"] .problem-markdown-compact th,
:root[data-theme="minecraft"] .problem-markdown-compact td {
color: #ececec !important;
}
:root[data-theme="minecraft"] .problem-markdown-compact th {
background: #3a3a3a !important;
}

查看文件

@@ -2,7 +2,7 @@ export type ThemeId = "default" | "minecraft";
export type UiLanguage = "en" | "zh";
export const DEFAULT_THEME: ThemeId = "default";
export const DEFAULT_LANGUAGE: UiLanguage = "en";
export const DEFAULT_LANGUAGE: UiLanguage = "zh";
export type ThemeMessages = Record<string, string>;