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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
74
frontend/src/lib/pixel-avatar.ts
普通文件
74
frontend/src/lib/pixel-avatar.ts
普通文件
@@ -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>;
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户