feat: expand platform management, admin controls, and learning workflows

这个提交包含在:
Codex CLI
2026-02-15 15:41:56 +08:00
父节点 ad29a9f62d
当前提交 f209ae82da
修改 75 个文件,包含 9663 行新增794 行删除

查看文件

@@ -0,0 +1,348 @@
"use client";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
import { useI18nText } from "@/lib/i18n";
type RedeemItem = {
id: number;
name: string;
description: string;
unit_label: string;
holiday_cost: number;
studyday_cost: number;
is_active: boolean;
is_global: boolean;
created_at: number;
updated_at: number;
};
type RedeemRecord = {
id: number;
user_id: number;
username: string;
item_id: number;
item_name: string;
quantity: number;
day_type: string;
unit_cost: number;
total_cost: number;
note: string;
created_at: number;
};
type ItemForm = {
name: string;
description: string;
unit_label: string;
holiday_cost: number;
studyday_cost: number;
is_active: boolean;
is_global: boolean;
};
const DEFAULT_FORM: ItemForm = {
name: "",
description: "",
unit_label: "hour",
holiday_cost: 5,
studyday_cost: 25,
is_active: true,
is_global: true,
};
function fmtTs(v: number | null | undefined): string {
if (!v) return "-";
return new Date(v * 1000).toLocaleString();
}
export default function AdminRedeemPage() {
const { tx } = useI18nText();
const [token, setToken] = useState("");
const [items, setItems] = useState<RedeemItem[]>([]);
const [records, setRecords] = useState<RedeemRecord[]>([]);
const [form, setForm] = useState<ItemForm>(DEFAULT_FORM);
const [editingId, setEditingId] = useState<number | null>(null);
const [recordUserId, setRecordUserId] = useState("");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [msg, setMsg] = useState("");
const load = async () => {
setLoading(true);
setError("");
try {
const tk = readToken();
setToken(tk);
if (!tk) throw new Error(tx("请先登录管理员账号", "Please sign in with admin account first"));
const recordsQs = new URLSearchParams({ limit: "200" });
if (recordUserId.trim()) recordsQs.set("user_id", recordUserId.trim());
const [itemRows, recordRows] = await Promise.all([
apiFetch<RedeemItem[]>("/api/v1/admin/redeem-items?include_inactive=1", {}, tk),
apiFetch<RedeemRecord[]>(`/api/v1/admin/redeem-records?${recordsQs.toString()}`, {}, tk),
]);
setItems(itemRows ?? []);
setRecords(recordRows ?? []);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const submit = async () => {
setSaving(true);
setError("");
setMsg("");
try {
if (!token) throw new Error(tx("请先登录管理员账号", "Please sign in with admin account first"));
if (!form.name.trim()) throw new Error(tx("物品名称不能为空", "Item name is required"));
const payload = {
...form,
name: form.name.trim(),
description: form.description.trim(),
unit_label: form.unit_label.trim() || tx("小时", "hour"),
};
if (editingId) {
await apiFetch(`/api/v1/admin/redeem-items/${editingId}`, {
method: "PATCH",
body: JSON.stringify(payload),
}, token);
setMsg(tx(`已更新兑换物品 #${editingId}`, `Updated redeem item #${editingId}`));
} else {
await apiFetch("/api/v1/admin/redeem-items", {
method: "POST",
body: JSON.stringify(payload),
}, token);
setMsg(tx("已新增兑换物品", "Added redeem item"));
}
setForm(DEFAULT_FORM);
setEditingId(null);
await load();
} catch (e: unknown) {
setError(String(e));
} finally {
setSaving(false);
}
};
const edit = (item: RedeemItem) => {
setEditingId(item.id);
setForm({
name: item.name,
description: item.description,
unit_label: item.unit_label,
holiday_cost: item.holiday_cost,
studyday_cost: item.studyday_cost,
is_active: item.is_active,
is_global: item.is_global,
});
};
const deactivate = async (id: number) => {
setError("");
setMsg("");
try {
if (!token) throw new Error(tx("请先登录管理员账号", "Please sign in with admin account first"));
await apiFetch(`/api/v1/admin/redeem-items/${id}`, { method: "DELETE" }, token);
setMsg(tx(`已下架兑换物品 #${id}`, `Disabled redeem item #${id}`));
await load();
} catch (e: unknown) {
setError(String(e));
}
};
return (
<main className="mx-auto max-w-7xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("管理员:积分兑换管理", "Admin: Redeem Management")}
</h1>
<p className="mt-1 text-sm text-zinc-600">
{tx(
"可在此添加/修改/下架全局兑换物品,并查看全站兑换记录。",
"Add/update/disable global redeem items and view all redeem records here."
)}
</p>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
{msg && <p className="mt-3 text-sm text-emerald-700">{msg}</p>}
<section className="mt-4 rounded-xl border bg-white p-4">
<h2 className="text-base font-semibold">{tx("兑换物品表单(增删改查)", "Redeem Item Form (CRUD)")}</h2>
<div className="mt-3 grid gap-2 md:grid-cols-2">
<input
className="rounded border px-3 py-2 text-sm"
placeholder={tx("物品名称", "Item name")}
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
/>
<input
className="rounded border px-3 py-2 text-sm"
placeholder={tx("单位(如:小时)", "Unit (e.g. hour)")}
value={form.unit_label}
onChange={(e) => setForm((prev) => ({ ...prev, unit_label: e.target.value }))}
/>
<input
className="rounded border px-3 py-2 text-sm"
type="number"
min={0}
placeholder={tx("假期单价", "Holiday unit cost")}
value={form.holiday_cost}
onChange={(e) =>
setForm((prev) => ({ ...prev, holiday_cost: Math.max(0, Number(e.target.value) || 0) }))
}
/>
<input
className="rounded border px-3 py-2 text-sm"
type="number"
min={0}
placeholder={tx("学习日单价", "Study-day unit cost")}
value={form.studyday_cost}
onChange={(e) =>
setForm((prev) => ({ ...prev, studyday_cost: Math.max(0, Number(e.target.value) || 0) }))
}
/>
<textarea
className="rounded border px-3 py-2 text-sm md:col-span-2"
placeholder={tx("描述", "Description")}
value={form.description}
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => setForm((prev) => ({ ...prev, is_active: e.target.checked }))}
/>
{tx("启用", "Enabled")}
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.is_global}
onChange={(e) => setForm((prev) => ({ ...prev, is_global: e.target.checked }))}
/>
{tx("全局可兑换", "Global redeemable")}
</label>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
className="rounded bg-zinc-900 px-4 py-2 text-sm text-white disabled:opacity-50"
onClick={() => void submit()}
disabled={saving}
>
{saving
? tx("保存中...", "Saving...")
: editingId
? tx(`保存修改 #${editingId}`, `Save changes #${editingId}`)
: tx("新增物品", "Add item")}
</button>
<button
className="rounded border px-4 py-2 text-sm"
onClick={() => {
setEditingId(null);
setForm(DEFAULT_FORM);
}}
>
{tx("清空表单", "Clear form")}
</button>
<button
className="rounded border px-4 py-2 text-sm"
onClick={() => void load()}
disabled={loading}
>
{tx("刷新数据", "Refresh data")}
</button>
</div>
</section>
<section className="mt-4 rounded-xl border bg-white p-4">
<h2 className="text-base font-semibold">{tx("兑换物品列表", "Redeem Items")}</h2>
<div className="mt-3 divide-y">
{items.map((item) => (
<article key={item.id} className="py-2 text-sm">
<p>
#{item.id} · {item.name} · {tx("假期", "Holiday")} {item.holiday_cost}/{item.unit_label} · {tx("学习日", "Study Day")} {item.studyday_cost}/
{item.unit_label}
</p>
<p className="text-xs text-zinc-600">
{tx("状态:", "Status: ")}
{item.is_active ? tx("启用", "Enabled") : tx("下架", "Disabled")}
{" · "}
{tx("范围:", "Scope: ")}
{item.is_global ? tx("全局", "Global") : tx("非全局", "Non-global")}
{" · "}
{tx("更新:", "Updated: ")}
{fmtTs(item.updated_at)}
</p>
<p className="text-xs text-zinc-500">{item.description || "-"}</p>
<div className="mt-1 flex gap-2">
<button className="rounded border px-2 py-1 text-xs" onClick={() => edit(item)}>
{tx("编辑", "Edit")}
</button>
<button className="rounded border px-2 py-1 text-xs" onClick={() => void deactivate(item.id)}>
{tx("下架", "Disable")}
</button>
</div>
</article>
))}
{!loading && items.length === 0 && (
<p className="py-3 text-sm text-zinc-500">{tx("暂无兑换物品。", "No redeem items yet.")}</p>
)}
</div>
</section>
<section className="mt-4 rounded-xl border bg-white p-4">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-semibold">{tx("兑换记录", "Redeem Records")}</h2>
<input
className="rounded border px-3 py-1 text-xs"
placeholder={tx("按 user_id 筛选(可选)", "Filter by user_id (optional)")}
value={recordUserId}
onChange={(e) => setRecordUserId(e.target.value)}
/>
<button className="rounded border px-3 py-1 text-xs" onClick={() => void load()}>
{tx("筛选/刷新", "Filter / Refresh")}
</button>
</div>
<div className="mt-3 divide-y">
{records.map((row) => (
<article key={row.id} className="py-2 text-sm">
<p>
#{row.id} · {tx("用户", "User")} {row.user_id}({row.username || "-"}) · {row.item_name} × {row.quantity}
</p>
<p className="text-xs text-zinc-600">
{row.day_type === "holiday" ? tx("假期", "Holiday") : tx("学习日", "Study Day")}
{" · "}
{tx("单价", "Unit cost")} {row.unit_cost}
{" · "}
{tx("扣分", "Cost")} {row.total_cost}
{" · "}
{fmtTs(row.created_at)}
</p>
{row.note && <p className="text-xs text-zinc-500">{tx("备注:", "Note: ")}{row.note}</p>}
</article>
))}
{!loading && records.length === 0 && (
<p className="py-3 text-sm text-zinc-500">{tx("暂无兑换记录。", "No redeem records yet.")}</p>
)}
</div>
</section>
</main>
);
}

查看文件

@@ -0,0 +1,192 @@
"use client";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
import { useI18nText } from "@/lib/i18n";
type AdminUser = {
id: number;
username: string;
rating: number;
created_at: number;
};
type ListResp = {
items: AdminUser[];
total_count: number;
page: number;
page_size: number;
};
function fmtTs(v: number): string {
if (!v) return "-";
return new Date(v * 1000).toLocaleString();
}
export default function AdminUsersPage() {
const { tx } = useI18nText();
const [items, setItems] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [msg, setMsg] = useState("");
const load = async () => {
setLoading(true);
setError("");
try {
const token = readToken();
if (!token) throw new Error(tx("请先登录管理员账号", "Please sign in with admin account first"));
const data = await apiFetch<ListResp>("/api/v1/admin/users?page=1&page_size=200", undefined, token);
setItems(data.items ?? []);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const updateRating = async (userId: number, rating: number) => {
setMsg("");
setError("");
try {
const token = readToken();
if (!token) throw new Error(tx("请先登录管理员账号", "Please sign in with admin account first"));
await apiFetch(
`/api/v1/admin/users/${userId}/rating`,
{
method: "PATCH",
body: JSON.stringify({ rating }),
},
token
);
setMsg(tx(`已更新用户 ${userId} Rating=${rating}`, `Updated user ${userId} rating=${rating}`));
await load();
} catch (e: unknown) {
setError(String(e));
}
};
return (
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("管理员用户与积分", "Admin Users & Rating")}
</h1>
<p className="mt-2 text-sm text-zinc-600">
{tx("默认管理员账号:", "Default admin account: ")}
<code>admin</code> / <code>whoami139</code>
</p>
<div className="mt-4 flex flex-wrap gap-2">
<button
className="rounded bg-zinc-900 px-4 py-2 text-sm text-white disabled:opacity-50"
onClick={() => void load()}
disabled={loading}
>
{loading ? tx("刷新中...", "Refreshing...") : tx("刷新用户列表", "Refresh users")}
</button>
</div>
{msg && <p className="mt-3 text-sm text-emerald-700">{msg}</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<div className="mt-4 rounded-xl border bg-white">
<div className="hidden overflow-x-auto md:block">
<table className="min-w-full text-sm">
<thead className="bg-zinc-100 text-left">
<tr>
<th className="px-3 py-2">ID</th>
<th className="px-3 py-2">{tx("用户名", "Username")}</th>
<th className="px-3 py-2">Rating</th>
<th className="px-3 py-2">{tx("创建时间", "Created At")}</th>
<th className="px-3 py-2">{tx("操作", "Action")}</th>
</tr>
</thead>
<tbody>
{items.map((user) => (
<tr key={user.id} className="border-t">
<td className="px-3 py-2">{user.id}</td>
<td className="px-3 py-2">{user.username}</td>
<td className="px-3 py-2">
<input
className="w-24 rounded border px-2 py-1"
type="number"
min={0}
value={user.rating}
onChange={(e) => {
const value = Number(e.target.value);
setItems((prev) =>
prev.map((row) => (row.id === user.id ? { ...row, rating: value } : row))
);
}}
/>
</td>
<td className="px-3 py-2 text-zinc-600">{fmtTs(user.created_at)}</td>
<td className="px-3 py-2">
<button
className="rounded border px-3 py-1 text-xs hover:bg-zinc-100"
onClick={() => void updateRating(user.id, Math.max(0, Number(user.rating) || 0))}
>
{tx("保存", "Save")}
</button>
</td>
</tr>
))}
{!loading && items.length === 0 && (
<tr>
<td className="px-3 py-6 text-center text-zinc-500" colSpan={5}>
{tx("暂无用户数据", "No users found")}
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="divide-y md:hidden">
{items.map((user) => (
<div key={user.id} className="space-y-2 p-3 text-sm">
<p>
#{user.id} · {user.username}
</p>
<p className="text-xs text-zinc-500">
{tx("创建时间:", "Created: ")}
{fmtTs(user.created_at)}
</p>
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-600">Rating</span>
<input
className="w-24 rounded border px-2 py-1 text-xs"
type="number"
min={0}
value={user.rating}
onChange={(e) => {
const value = Number(e.target.value);
setItems((prev) =>
prev.map((row) => (row.id === user.id ? { ...row, rating: value } : row))
);
}}
/>
<button
className="rounded border px-3 py-1 text-xs"
onClick={() => void updateRating(user.id, Math.max(0, Number(user.rating) || 0))}
>
{tx("保存", "Save")}
</button>
</div>
</div>
))}
{!loading && items.length === 0 && (
<p className="px-3 py-6 text-center text-sm text-zinc-500">{tx("暂无用户数据", "No users found")}</p>
)}
</div>
</div>
</main>
);
}

查看文件

@@ -1,28 +1,25 @@
import Link from "next/link";
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useUiPreferences } from "@/components/ui-preference-provider";
export default function AdminEntryPage() {
const { t } = useUiPreferences();
const router = useRouter();
useEffect(() => {
router.replace("/imports");
}, [router]);
return (
<main className="mx-auto max-w-3xl px-4 py-10">
<h1 className="text-2xl font-semibold"></h1>
<p className="mt-3 text-sm text-zinc-600">
<span className="font-medium text-zinc-900">admin</span>
<span className="font-medium text-zinc-900">whoami139</span>
<h1 className="text-2xl font-semibold">{t("admin.entry.title")}</h1>
<p className="mt-3 text-sm text-zinc-600">{t("admin.entry.desc")}</p>
<p className="mt-2 text-sm text-zinc-500">
{t("admin.entry.moved_to_platform")}
</p>
<div className="mt-6 grid gap-3 sm:grid-cols-2">
<Link className="rounded-lg border bg-white px-4 py-3 text-sm hover:bg-zinc-50" href="/auth">
</Link>
<Link className="rounded-lg border bg-white px-4 py-3 text-sm hover:bg-zinc-50" href="/admin-users">
</Link>
<Link className="rounded-lg border bg-white px-4 py-3 text-sm hover:bg-zinc-50" href="/admin-redeem">
</Link>
<Link className="rounded-lg border bg-white px-4 py-3 text-sm hover:bg-zinc-50" href="/backend-logs">
</Link>
</div>
</main>
);
}

查看文件

@@ -1,19 +1,80 @@
"use client";
import dynamic from "next/dynamic";
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { API_BASE } from "@/lib/api";
import { API_BASE, apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
import { useI18nText } from "@/lib/i18n";
const SwaggerUI = dynamic(() => import("swagger-ui-react"), { ssr: false });
export default function ApiDocsPage() {
const { tx } = useI18nText();
const specUrl = useMemo(() => `${API_BASE}/api/openapi.json`, []);
const [checkingAdmin, setCheckingAdmin] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let canceled = false;
const checkAdmin = async () => {
const token = readToken();
if (!token) {
if (!canceled) {
setIsAdmin(false);
setError(tx("请先登录管理员账号", "Please sign in with admin account first"));
setCheckingAdmin(false);
}
return;
}
try {
const me = await apiFetch<{ username?: string }>("/api/v1/me", {}, token);
if (!canceled) {
const allowed = (me?.username ?? "") === "admin";
setIsAdmin(allowed);
setError(allowed ? "" : tx("仅管理员可查看 API 文档", "API docs are visible to admin only"));
}
} catch (e: unknown) {
if (!canceled) {
setIsAdmin(false);
setError(String(e));
}
} finally {
if (!canceled) setCheckingAdmin(false);
}
};
void checkAdmin();
return () => {
canceled = true;
};
}, [tx]);
if (checkingAdmin) {
return (
<main className="mx-auto max-w-7xl px-3 py-8 text-sm text-zinc-600">
{tx("正在校验管理员权限...", "Checking admin access...")}
</main>
);
}
if (!isAdmin) {
return (
<main className="mx-auto max-w-7xl px-3 py-8">
<h1 className="text-xl font-semibold">{tx("API 文档Swagger", "API Docs (Swagger)")}</h1>
<p className="mt-3 text-sm text-red-600">
{error || tx("仅管理员可查看此页面", "This page is available for admin only")}
</p>
</main>
);
}
return (
<main className="mx-auto max-w-7xl px-6 py-6">
<h1 className="mb-4 text-2xl font-semibold">API Swagger</h1>
<div className="rounded-xl border bg-white p-2">
<main className="mx-auto max-w-7xl px-3 py-5 max-[390px]:px-2 sm:px-4 md:px-6 md:py-6">
<h1 className="mb-4 text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("API 文档Swagger", "API Docs (Swagger)")}
</h1>
<div className="overflow-x-auto rounded-xl border bg-white p-2">
<SwaggerUI url={specUrl} docExpansion="list" defaultModelsExpandDepth={1} />
</div>
</main>

查看文件

@@ -1,25 +1,28 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { API_BASE, apiFetch } from "@/lib/api";
import { saveToken } from "@/lib/auth";
import { readToken, saveToken } from "@/lib/auth";
import { useI18nText } from "@/lib/i18n";
type AuthOk = { ok: true; user_id: number; token: string; expires_at: number };
type AuthErr = { ok: false; error: string };
type AuthResp = AuthOk | AuthErr;
function passwordScore(password: string): { label: string; color: string } {
if (password.length >= 12) return { label: "强", color: "text-emerald-600" };
if (password.length >= 8) return { label: "中", color: "text-blue-600" };
return { label: "弱", color: "text-orange-600" };
function passwordScore(password: string, isZh: boolean): { label: string; color: string } {
if (password.length >= 12) return { label: isZh ? "强" : "Strong", color: "text-emerald-600" };
if (password.length >= 8) return { label: isZh ? "中" : "Medium", color: "text-blue-600" };
return { label: isZh ? "弱" : "Weak", color: "text-orange-600" };
}
export default function AuthPage() {
const { isZh, tx } = useI18nText();
const router = useRouter();
const apiBase = useMemo(() => API_BASE, []);
const [checkingAuth, setCheckingAuth] = useState(true);
const [mode, setMode] = useState<"register" | "login">("login");
const [username, setUsername] = useState(process.env.NEXT_PUBLIC_TEST_USERNAME ?? "");
@@ -29,10 +32,18 @@ export default function AuthPage() {
const [loading, setLoading] = useState(false);
const [resp, setResp] = useState<AuthResp | null>(null);
const usernameErr = username.trim().length < 3 ? "用户名至少 3 位" : "";
const passwordErr = password.length < 6 ? "密码至少 6 位" : "";
useEffect(() => {
if (readToken()) {
router.replace("/problems");
return;
}
setCheckingAuth(false);
}, [router]);
const usernameErr = username.trim().length < 3 ? tx("用户名至少 3 位", "Username must be at least 3 chars") : "";
const passwordErr = password.length < 6 ? tx("密码至少 6 位", "Password must be at least 6 chars") : "";
const confirmErr =
mode === "register" && password !== confirmPassword ? "两次密码不一致" : "";
mode === "register" && password !== confirmPassword ? tx("两次密码不一致", "Passwords do not match") : "";
const canSubmit = !loading && !usernameErr && !passwordErr && !confirmErr;
@@ -49,7 +60,7 @@ export default function AuthPage() {
if (j.ok) {
saveToken(j.token);
setTimeout(() => {
router.push("/problems");
router.replace("/problems");
}, 350);
}
} catch (e: unknown) {
@@ -59,20 +70,28 @@ export default function AuthPage() {
}
}
const strength = passwordScore(password);
const strength = passwordScore(password, isZh);
if (checkingAuth) {
return (
<main className="mx-auto max-w-4xl px-3 py-12 text-sm text-zinc-500">
{tx("已登录,正在跳转...", "Already signed in, redirecting...")}
</main>
);
}
return (
<main className="mx-auto max-w-4xl px-6 py-10">
<main className="mx-auto max-w-4xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-10">
<div className="grid gap-6 md:grid-cols-[1.1fr,1fr]">
<section className="rounded-2xl border bg-zinc-900 p-6 text-zinc-100">
<h1 className="text-2xl font-semibold"></h1>
<h1 className="text-2xl font-semibold">{tx("欢迎回来,开始刷题", "Welcome back, let's practice")}</h1>
<p className="mt-3 text-sm text-zinc-300">
稿
{tx("登录后可提交评测、保存草稿、查看错题本和个人进度。", "After sign-in you can submit, save drafts, review wrong-book, and track your progress.")}
</p>
<div className="mt-6 space-y-2 text-sm text-zinc-300">
<p> CSP-J / CSP-S / NOIP </p>
<p> 稿</p>
<p> </p>
<p>{tx("• 题库按 CSP-J / CSP-S / NOIP 入门组织", "• Problem sets are organized by CSP-J / CSP-S / NOIP junior")}</p>
<p>{tx("• 题目页支持本地草稿与试运行", "• Problem page supports local draft and run")}</p>
<p>{tx("• 生成式题解会异步入库,支持多解法", "• Generated solutions are queued asynchronously with multiple methods")}</p>
</div>
<p className="mt-6 text-xs text-zinc-400">
API Base: <span className="font-mono">{apiBase}</span>
@@ -92,7 +111,7 @@ export default function AuthPage() {
}}
disabled={loading}
>
{tx("登录", "Sign In")}
</button>
<button
type="button"
@@ -105,46 +124,46 @@ export default function AuthPage() {
}}
disabled={loading}
>
{tx("注册", "Register")}
</button>
</div>
<div className="mt-5 space-y-4">
<div>
<label className="text-sm font-medium"></label>
<label className="text-sm font-medium">{tx("用户名", "Username")}</label>
<input
className="mt-1 w-full rounded-lg border px-3 py-2"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="例如csp_student"
placeholder={tx("例如csp_student", "e.g. csp_student")}
/>
{usernameErr && <p className="mt-1 text-xs text-red-600">{usernameErr}</p>}
</div>
<div>
<div className="flex items-center justify-between">
<label className="text-sm font-medium"></label>
<span className={`text-xs ${strength.color}`}>{strength.label}</span>
<label className="text-sm font-medium">{tx("密码", "Password")}</label>
<span className={`text-xs ${strength.color}`}>{tx("强度", "Strength")}: {strength.label}</span>
</div>
<input
type={showPassword ? "text" : "password"}
className="mt-1 w-full rounded-lg border px-3 py-2"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="至少 6 位"
placeholder={tx("至少 6 位", "At least 6 chars")}
/>
{passwordErr && <p className="mt-1 text-xs text-red-600">{passwordErr}</p>}
</div>
{mode === "register" && (
<div>
<label className="text-sm font-medium"></label>
<label className="text-sm font-medium">{tx("确认密码", "Confirm Password")}</label>
<input
type={showPassword ? "text" : "password"}
className="mt-1 w-full rounded-lg border px-3 py-2"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="再输入一次密码"
placeholder={tx("再输入一次密码", "Enter password again")}
/>
{confirmErr && <p className="mt-1 text-xs text-red-600">{confirmErr}</p>}
</div>
@@ -156,7 +175,7 @@ export default function AuthPage() {
checked={showPassword}
onChange={(e) => setShowPassword(e.target.checked)}
/>
{tx("显示密码", "Show password")}
</label>
<button
@@ -164,7 +183,7 @@ export default function AuthPage() {
onClick={() => void submit()}
disabled={!canSubmit}
>
{loading ? "提交中..." : mode === "register" ? "注册并登录" : "登录"}
{loading ? tx("提交中...", "Submitting...") : mode === "register" ? tx("注册并登录", "Register & Sign In") : tx("登录", "Sign In")}
</button>
</div>
@@ -175,21 +194,21 @@ export default function AuthPage() {
}`}
>
{resp.ok
? "登录成功,正在跳转到题库..."
: `操作失败:${resp.error}`}
? tx("登录成功,正在跳转到题库...", "Signed in. Redirecting to problem set...")
: `${tx("操作失败:", "Action failed: ")}${resp.error}`}
</div>
)}
<p className="mt-4 text-xs text-zinc-500">
Token localStorage
{tx("登录后 Token 自动保存在浏览器 localStorage,可直接前往", "Token is stored in browser localStorage after sign-in. You can go to")}
<Link className="mx-1 underline" href="/problems">
{tx("题库", "Problems")}
</Link>
{tx("与", "and")}
<Link className="mx-1 underline" href="/me">
{tx("我的", "My Account")}
</Link>
{tx("页面。", ".")}
</p>
</section>
</div>

查看文件

@@ -0,0 +1,383 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
import { useI18nText } from "@/lib/i18n";
type BackendLogItem = {
id: number;
problem_id: number;
problem_title: string;
status: string;
progress: number;
message: string;
created_by: number;
max_solutions: number;
created_at: number;
started_at: number | null;
finished_at: number | null;
updated_at: number;
runner_pending: boolean;
};
type QueueJobItem = {
id: number;
problem_id: number;
problem_title: string;
status: string;
progress: number;
message: string;
updated_at: number;
started_at?: number | null;
};
type BackendLogsResp = {
items: BackendLogItem[];
running_jobs: QueueJobItem[];
queued_jobs: QueueJobItem[];
running_problem_ids: number[];
queued_problem_ids: number[];
running_count: number;
queued_count_preview: number;
pending_jobs: number;
missing_problems: number;
limit: number;
running_limit: number;
queued_limit: number;
};
type TriggerMissingResp = {
started: boolean;
missing_total: number;
candidate_count: number;
queued_count: number;
pending_jobs: number;
limit: number;
max_solutions: number;
};
function fmtTs(v: number | null | undefined): string {
if (!v) return "-";
return new Date(v * 1000).toLocaleString();
}
export default function BackendLogsPage() {
const { tx } = useI18nText();
const [token, setToken] = useState("");
const [checkingAdmin, setCheckingAdmin] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [limit, setLimit] = useState(100);
const [pendingJobs, setPendingJobs] = useState(0);
const [missingProblems, setMissingProblems] = useState(0);
const [items, setItems] = useState<BackendLogItem[]>([]);
const [runningJobs, setRunningJobs] = useState<QueueJobItem[]>([]);
const [queuedJobs, setQueuedJobs] = useState<QueueJobItem[]>([]);
const [runningIds, setRunningIds] = useState<number[]>([]);
const [queuedIds, setQueuedIds] = useState<number[]>([]);
const [triggerLoading, setTriggerLoading] = useState(false);
const [triggerMsg, setTriggerMsg] = 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
);
setPendingJobs(data.pending_jobs ?? 0);
setMissingProblems(data.missing_problems ?? 0);
setItems(data.items ?? []);
setRunningJobs(data.running_jobs ?? []);
setQueuedJobs(data.queued_jobs ?? []);
setRunningIds(data.running_problem_ids ?? []);
setQueuedIds(data.queued_problem_ids ?? []);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
};
useEffect(() => {
let canceled = false;
const checkAdmin = async () => {
setCheckingAdmin(true);
const tk = readToken();
if (!canceled) setToken(tk);
if (!tk) {
if (!canceled) {
setIsAdmin(false);
setError(tx("请先登录管理员账号", "Please sign in with admin account first"));
setCheckingAdmin(false);
}
return;
}
try {
const me = await apiFetch<{ username?: string }>("/api/v1/me", {}, tk);
const allowed = (me?.username ?? "") === "admin";
if (!canceled) {
setIsAdmin(allowed);
if (!allowed) {
setError(tx("仅管理员可查看后台日志", "Backend logs are visible to admin only"));
} else {
setError("");
}
}
} catch (e: unknown) {
if (!canceled) {
setIsAdmin(false);
setError(String(e));
}
} finally {
if (!canceled) setCheckingAdmin(false);
}
};
void checkAdmin();
return () => {
canceled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!isAdmin || !token) return;
void refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAdmin, token, limit]);
const triggerMissingSolutions = async () => {
if (!isAdmin || !token) {
setError(tx("请先登录管理员账号", "Please sign in with admin account first"));
return;
}
setTriggerLoading(true);
setTriggerMsg("");
setError("");
try {
const data = await apiFetch<TriggerMissingResp>(
"/api/v1/backend/solutions/generate-missing",
{
method: "POST",
body: JSON.stringify({ limit: 50000, max_solutions: 3 }),
},
token
);
setPendingJobs(data.pending_jobs ?? 0);
setTriggerMsg(
tx(
`已触发异步任务:候选 ${data.candidate_count} 题,入队 ${data.queued_count} 题(当前待处理 ${data.pending_jobs})。`,
`Async trigger submitted: candidate ${data.candidate_count}, queued ${data.queued_count} (pending ${data.pending_jobs}).`
)
);
await refresh();
} catch (e: unknown) {
setError(String(e));
} finally {
setTriggerLoading(false);
}
};
useEffect(() => {
if (!isAdmin || !token) return;
const timer = setInterval(() => {
void refresh();
}, 5000);
return () => clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAdmin, token, limit]);
if (checkingAdmin) {
return (
<main className="mx-auto max-w-7xl px-3 py-8 text-sm text-zinc-600">
{tx("正在校验管理员权限...", "Checking admin access...")}
</main>
);
}
if (!isAdmin) {
return (
<main className="mx-auto max-w-5xl px-3 py-8">
<h1 className="text-xl font-semibold">{tx("后台日志(题解异步队列)", "Backend Logs (Async Solution Queue)")}</h1>
<p className="mt-3 text-sm text-red-600">
{error || tx("仅管理员可查看此页面", "This page is available for admin only")}
</p>
</main>
);
}
return (
<main className="mx-auto max-w-7xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("后台日志(题解异步队列)", "Backend Logs (Async Solution Queue)")}
</h1>
<div className="flex w-full flex-wrap items-center gap-2 text-sm sm:w-auto sm:justify-end">
<span className={pendingJobs > 0 ? "text-emerald-700" : "text-zinc-600"}>
{tx("待处理任务", "Pending jobs")} {pendingJobs}
</span>
<span className={missingProblems > 0 ? "text-amber-700" : "text-zinc-600"}>
{tx("缺失答案题目", "Problems missing answers")} {missingProblems}
</span>
<button
className="rounded border px-3 py-1 disabled:opacity-50"
onClick={() => void triggerMissingSolutions()}
disabled={triggerLoading}
>
{triggerLoading ? tx("手动补全中...", "Triggering...") : tx("手动补全(可选)", "Manual fill (optional)")}
</button>
<select
className="rounded border px-2 py-1"
value={limit}
onChange={(e) => setLimit(Number(e.target.value))}
>
<option value={50}>{tx("最近 50 条", "Latest 50")}</option>
<option value={100}>{tx("最近 100 条", "Latest 100")}</option>
<option value={200}>{tx("最近 200 条", "Latest 200")}</option>
</select>
<button className="rounded border px-3 py-1 sm:ml-auto" onClick={() => void refresh()} disabled={loading}>
{tx("刷新", "Refresh")}
</button>
</div>
</div>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
{triggerMsg && <p className="mt-3 text-sm text-emerald-700">{triggerMsg}</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 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>
<p className="mt-1 text-xs text-zinc-600">
{tx("当前题目 ID", "Current problem IDs:")}
{runningIds.length ? runningIds.join(", ") : tx("无", "None")}
</p>
<ul className="mt-2 space-y-2 text-xs">
{runningJobs.map((job) => (
<li key={job.id} className="rounded border border-zinc-200 p-2">
<p>
{tx("任务", "Job")} #{job.id} · {tx("题目", "Problem")} #{job.problem_id} {job.problem_title || tx("(未命名题目)", "(Untitled)")}
</p>
<p className="text-zinc-600">
{tx("状态", "Status")} {job.status} · {tx("进度", "Progress")} {job.progress}% · {tx("开始", "Start")} {fmtTs(job.started_at ?? null)}
</p>
<p className="whitespace-pre-wrap break-words text-zinc-600">{job.message || "-"}</p>
</li>
))}
{!runningJobs.length && <li className="text-zinc-500">{tx("当前无运行中的任务", "No running jobs")}</li>}
</ul>
</article>
<article className="rounded-xl border bg-white p-3">
<h2 className="text-sm font-medium">{tx("待处理队列Queued", "Queued Jobs")}</h2>
<p className="mt-1 text-xs text-zinc-600">
{tx("待处理题目 ID预览", "Queued problem IDs (preview):")}
{queuedIds.length ? queuedIds.join(", ") : tx("无", "None")}
</p>
<ul className="mt-2 max-h-56 space-y-2 overflow-auto text-xs">
{queuedJobs.map((job) => (
<li key={job.id} className="rounded border border-zinc-200 p-2">
<p>
{tx("任务", "Job")} #{job.id} · {tx("题目", "Problem")} #{job.problem_id} {job.problem_title || tx("(未命名题目)", "(Untitled)")}
</p>
<p className="text-zinc-600">
{tx("状态", "Status")} {job.status} · {tx("进度", "Progress")} {job.progress}% · {tx("更新", "Updated")} {fmtTs(job.updated_at)}
</p>
<p className="whitespace-pre-wrap break-words text-zinc-600">{job.message || "-"}</p>
</li>
))}
{!queuedJobs.length && <li className="text-zinc-500">{tx("当前无待处理任务", "No queued jobs")}</li>}
</ul>
</article>
</section>
<section className="mt-4 rounded-xl border bg-white">
<div className="divide-y md:hidden">
{items.map((item) => (
<article key={item.id} className="space-y-2 p-3 text-xs">
<div className="flex items-center justify-between gap-2">
<p className="font-medium">
{tx("任务", "Job")} #{item.id}
</p>
<span className={item.runner_pending ? "text-emerald-700" : "text-zinc-700"}>
{item.status} · {item.progress}%
</span>
</div>
<Link className="text-blue-600 hover:underline" href={`/problems/${item.problem_id}`}>
#{item.problem_id} {item.problem_title || tx("(未命名题目)", "(Untitled)")}
</Link>
<p className="whitespace-pre-wrap break-words text-zinc-600">{item.message || "-"}</p>
<p className="text-zinc-500">
{tx("创建", "Created")} {fmtTs(item.created_at)} · {tx("开始", "Start")} {fmtTs(item.started_at)} · {tx("结束", "End")} {fmtTs(item.finished_at)}
</p>
</article>
))}
{!loading && items.length === 0 && (
<p className="px-3 py-6 text-center text-sm text-zinc-500">{tx("暂无后台任务日志", "No backend logs yet")}</p>
)}
</div>
<div className="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">{tx("任务ID", "Job ID")}</th>
<th className="px-2 py-2">{tx("题目", "Problem")}</th>
<th className="px-2 py-2">{tx("状态", "Status")}</th>
<th className="px-2 py-2">{tx("进度", "Progress")}</th>
<th className="px-2 py-2">{tx("消息", "Message")}</th>
<th className="px-2 py-2">{tx("时间", "Time")}</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-t align-top">
<td className="px-2 py-2 font-medium">{item.id}</td>
<td className="max-w-[260px] px-2 py-2">
<Link className="text-blue-600 hover:underline" href={`/problems/${item.problem_id}`}>
#{item.problem_id} {item.problem_title || tx("(未命名题目)", "(Untitled)")}
</Link>
</td>
<td className="px-2 py-2">
<span className={item.runner_pending ? "text-emerald-700" : "text-zinc-700"}>
{item.status}
</span>
</td>
<td className="px-2 py-2">{item.progress}%</td>
<td className="max-w-[420px] px-2 py-2">
<div className="whitespace-pre-wrap break-words">{item.message || "-"}</div>
</td>
<td className="px-2 py-2 text-zinc-600">
{tx("创建", "Created")} {fmtTs(item.created_at)}
<br />
{tx("开始", "Start")} {fmtTs(item.started_at)}
<br />
{tx("结束", "End")} {fmtTs(item.finished_at)}
</td>
</tr>
))}
{!loading && items.length === 0 && (
<tr>
<td className="px-2 py-6 text-center text-zinc-500" colSpan={6}>
{tx("暂无后台任务日志", "No backend logs yet")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
</main>
);
}

查看文件

@@ -6,6 +6,7 @@ import { useEffect, useMemo, useState } from "react";
import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
import { useI18nText } from "@/lib/i18n";
type Contest = {
id: number;
@@ -35,6 +36,7 @@ type DetailResp = {
};
export default function ContestDetailPage() {
const { tx } = useI18nText();
const params = useParams<{ id: string }>();
const contestId = useMemo(() => Number(params.id), [params.id]);
@@ -69,7 +71,7 @@ export default function ContestDetailPage() {
const register = async () => {
try {
const token = readToken();
if (!token) throw new Error("请先登录");
if (!token) throw new Error(tx("请先登录", "Please sign in first"));
await apiFetch(`/api/v1/contests/${contestId}/register`, { method: "POST" }, token);
await load();
} catch (e: unknown) {
@@ -78,9 +80,11 @@ export default function ContestDetailPage() {
};
return (
<main className="mx-auto max-w-6xl px-6 py-8">
<h1 className="text-2xl font-semibold"> #{contestId}</h1>
{loading && <p className="mt-3 text-sm text-zinc-500">...</p>}
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("比赛详情", "Contest Detail")} #{contestId}
</h1>
{loading && <p className="mt-3 text-sm text-zinc-500">{tx("加载中...", "Loading...")}</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
{detail && (
@@ -91,24 +95,29 @@ export default function ContestDetailPage() {
{new Date(detail.contest.starts_at * 1000).toLocaleString()} - {" "}
{new Date(detail.contest.ends_at * 1000).toLocaleString()}
</p>
<pre className="mt-3 rounded bg-zinc-900 p-3 text-xs text-zinc-100">
<pre className="mt-3 overflow-x-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
{detail.contest.rule_json}
</pre>
<button
className="mt-3 rounded bg-zinc-900 px-4 py-2 text-white"
className="mt-3 w-full rounded bg-zinc-900 px-4 py-2 text-white sm:w-auto"
onClick={() => void register()}
>
{detail.registered ? "已报名(可重复点击刷新)" : "报名比赛"}
{detail.registered
? tx("已报名(可重复点击刷新)", "Registered (click again to refresh)")
: tx("报名比赛", "Register Contest")}
</button>
<h3 className="mt-4 text-sm font-medium"></h3>
<h3 className="mt-4 text-sm font-medium">{tx("比赛题目", "Contest Problems")}</h3>
<ul className="mt-2 space-y-2 text-sm">
{detail.problems.map((p) => (
<li key={p.id} className="rounded border p-2">
#{p.id} {p.title} {p.difficulty}
<Link className="ml-2 text-blue-600 underline" href={`/problems/${p.id}`}>
#{p.id} {p.title}
{tx("(难度 ", " (Difficulty ")}
{p.difficulty}
{tx("", ")")}
<Link className="mt-1 block text-blue-600 underline sm:ml-2 sm:mt-0 sm:inline" href={`/problems/${p.id}`}>
{tx("去提交", "Submit")}
</Link>
</li>
))}
@@ -116,28 +125,55 @@ export default function ContestDetailPage() {
</section>
<section className="rounded-xl border bg-white p-4">
<h3 className="text-sm font-medium"></h3>
<div className="mt-2 overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-zinc-100 text-left">
<tr>
<th className="px-2 py-1">#</th>
<th className="px-2 py-1"></th>
<th className="px-2 py-1">Solved</th>
<th className="px-2 py-1">Penalty(s)</th>
</tr>
</thead>
<tbody>
{board.map((r, idx) => (
<tr key={r.user_id} className="border-t">
<td className="px-2 py-1">{idx + 1}</td>
<td className="px-2 py-1">{r.username}</td>
<td className="px-2 py-1">{r.solved}</td>
<td className="px-2 py-1">{r.penalty_sec}</td>
<h3 className="text-sm font-medium">{tx("排行榜", "Leaderboard")}</h3>
<div className="mt-2 rounded-lg border">
<div className="divide-y md:hidden">
{board.map((r, idx) => (
<article key={r.user_id} className="space-y-1 p-3 text-sm">
<p className="font-medium">
#{idx + 1} · {r.username}
</p>
<p className="text-xs text-zinc-600">
Solved {r.solved} · Penalty {r.penalty_sec}s
</p>
</article>
))}
{!loading && board.length === 0 && (
<p className="px-3 py-5 text-center text-sm text-zinc-500">
{tx("暂无榜单数据", "No leaderboard data yet")}
</p>
)}
</div>
<div className="hidden overflow-x-auto md:block">
<table className="min-w-full text-sm">
<thead className="bg-zinc-100 text-left">
<tr>
<th className="px-2 py-1">#</th>
<th className="px-2 py-1">{tx("用户", "User")}</th>
<th className="px-2 py-1">Solved</th>
<th className="px-2 py-1">Penalty(s)</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{board.map((r, idx) => (
<tr key={r.user_id} className="border-t">
<td className="px-2 py-1">{idx + 1}</td>
<td className="px-2 py-1">{r.username}</td>
<td className="px-2 py-1">{r.solved}</td>
<td className="px-2 py-1">{r.penalty_sec}</td>
</tr>
))}
{!loading && board.length === 0 && (
<tr>
<td className="px-2 py-5 text-center text-zinc-500" colSpan={4}>
{tx("暂无榜单数据", "No leaderboard data yet")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</section>
</div>

查看文件

@@ -4,6 +4,7 @@ import Link from "next/link";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { useI18nText } from "@/lib/i18n";
type Contest = {
id: number;
@@ -14,6 +15,7 @@ type Contest = {
};
export default function ContestsPage() {
const { tx } = useI18nText();
const [items, setItems] = useState<Contest[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
@@ -35,9 +37,11 @@ export default function ContestsPage() {
}, []);
return (
<main className="mx-auto max-w-5xl px-6 py-8">
<h1 className="text-2xl font-semibold"></h1>
{loading && <p className="mt-3 text-sm text-zinc-500">...</p>}
<main className="mx-auto max-w-5xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("模拟竞赛", "Contests")}
</h1>
{loading && <p className="mt-3 text-sm text-zinc-500">{tx("加载中...", "Loading...")}</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<div className="mt-4 space-y-3">
@@ -48,8 +52,8 @@ export default function ContestsPage() {
className="block rounded-xl border bg-white p-4 hover:border-zinc-400"
>
<h2 className="text-lg font-medium">{c.title}</h2>
<p className="mt-1 text-xs text-zinc-500">: {new Date(c.starts_at * 1000).toLocaleString()}</p>
<p className="text-xs text-zinc-500">: {new Date(c.ends_at * 1000).toLocaleString()}</p>
<p className="mt-1 text-xs text-zinc-500">{tx("开始", "Start")}: {new Date(c.starts_at * 1000).toLocaleString()}</p>
<p className="text-xs text-zinc-500">{tx("结束", "End")}: {new Date(c.ends_at * 1000).toLocaleString()}</p>
</Link>
))}
</div>

查看文件

@@ -1,8 +1,12 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--background: #fff;
--foreground: #171717;
--surface: #fff;
--surface-soft: #f4f4f5;
--border: #d4d4d8;
-webkit-text-size-adjust: 100%;
}
@theme inline {
@@ -13,15 +17,55 @@
"Courier New", monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
min-height: 100vh;
overflow-x: hidden;
}
.problem-markdown-compact {
font-size: 70%;
line-height: 1.55;
}
.problem-markdown-compact pre,
.problem-markdown-compact code {
font-size: 1em;
}
.print-only {
display: none;
}
@media print {
body {
background: #fff !important;
color: #000 !important;
}
.print-hidden {
display: none !important;
}
.print-only {
display: block !important;
}
.problem-detail-grid {
display: block !important;
}
.problem-print-section {
border: 0 !important;
background: transparent !important;
padding: 0 !important;
}
.problem-print-section pre {
border: 1px solid #d4d4d8 !important;
background: #f4f4f5 !important;
color: #111827 !important;
}
}

查看文件

@@ -1,8 +1,11 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
import { useI18nText } from "@/lib/i18n";
type ImportJob = {
id: number;
@@ -46,19 +49,50 @@ type ItemsResp = {
page_size: number;
};
type MeProfile = {
username?: string;
};
function fmtTs(v: number | null | undefined): string {
if (!v) return "-";
return new Date(v * 1000).toLocaleString();
}
type ImportJobOptions = {
mode?: string;
source?: string;
workers?: number;
target_total?: number;
local_pdf_dir?: string;
pdf_dir?: string;
};
function parseOptions(raw: string): ImportJobOptions | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
return typeof parsed === "object" && parsed !== null ? (parsed as ImportJobOptions) : null;
} catch {
return null;
}
}
export default function ImportsPage() {
const { tx } = useI18nText();
const [token, setToken] = useState("");
const [checkingAdmin, setCheckingAdmin] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [runMode, setRunMode] = useState<"luogu" | "local_pdf_rag">("luogu");
const [loading, setLoading] = useState(false);
const [running, setRunning] = useState(false);
const [error, setError] = useState("");
const [job, setJob] = useState<ImportJob | null>(null);
const [items, setItems] = useState<ImportItem[]>([]);
const [statusFilter, setStatusFilter] = useState("");
const [pageSize, setPageSize] = useState(100);
const [workers, setWorkers] = useState(3);
const [localPdfDir, setLocalPdfDir] = useState("/data/local_pdfs");
const [targetTotal, setTargetTotal] = useState(5000);
const [pageSize, setPageSize] = useState(50);
const [clearAllBeforeRun, setClearAllBeforeRun] = useState(true);
const progress = useMemo(() => {
@@ -66,29 +100,30 @@ export default function ImportsPage() {
return Math.min(100, Math.floor((job.processed_count / job.total_count) * 100));
}, [job]);
const loadLatest = async () => {
const latest = await apiFetch<LatestResp>("/api/v1/import/jobs/latest");
const loadLatest = async (tk: string) => {
const latest = await apiFetch<LatestResp>("/api/v1/import/jobs/latest", {}, tk);
setJob(latest.job ?? null);
setRunning(Boolean(latest.runner_running) || latest.job?.status === "running");
return latest.job;
};
const loadItems = async (jobId: number) => {
const loadItems = async (tk: string, jobId: number) => {
const params = new URLSearchParams();
params.set("page", "1");
params.set("page_size", String(pageSize));
if (statusFilter) params.set("status", statusFilter);
const data = await apiFetch<ItemsResp>(`/api/v1/import/jobs/${jobId}/items?${params.toString()}`);
const data = await apiFetch<ItemsResp>(`/api/v1/import/jobs/${jobId}/items?${params.toString()}`, {}, tk);
setItems(data.items ?? []);
};
const refresh = async () => {
if (!isAdmin || !token) return;
setLoading(true);
setError("");
try {
const latestJob = await loadLatest();
const latestJob = await loadLatest(token);
if (latestJob) {
await loadItems(latestJob.id);
await loadItems(token, latestJob.id);
} else {
setItems([]);
}
@@ -100,159 +135,369 @@ export default function ImportsPage() {
};
const runImport = async () => {
if (!isAdmin || !token) {
setError(tx("请先登录管理员账号", "Please sign in with admin account first"));
return;
}
setError("");
try {
await apiFetch<{ started: boolean }>("/api/v1/import/jobs/run", {
method: "POST",
body: JSON.stringify({ clear_all_problems: clearAllBeforeRun }),
});
const body: Record<string, unknown> = {
mode: runMode,
workers,
};
if (runMode === "luogu") {
body.clear_all_problems = clearAllBeforeRun;
} else {
body.local_pdf_dir = localPdfDir;
body.target_total = targetTotal;
}
await apiFetch<{ started: boolean }>(
"/api/v1/import/jobs/run",
{
method: "POST",
body: JSON.stringify(body),
},
token
);
await refresh();
} catch (e: unknown) {
setError(String(e));
}
};
useEffect(() => {
void refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageSize, statusFilter]);
const jobOpts = useMemo(() => parseOptions(job?.options_json ?? ""), [job?.options_json]);
useEffect(() => {
let canceled = false;
const init = async () => {
setCheckingAdmin(true);
const tk = readToken();
setToken(tk);
if (!tk) {
if (!canceled) {
setIsAdmin(false);
setError(tx("请先登录管理员账号", "Please sign in with admin account first"));
setCheckingAdmin(false);
}
return;
}
try {
const me = await apiFetch<MeProfile>("/api/v1/me", {}, tk);
const allowed = (me?.username ?? "") === "admin";
if (!canceled) {
setIsAdmin(allowed);
if (!allowed) {
setError(tx("仅管理员可查看平台管理信息", "Platform management is visible to admin only"));
} else {
setError("");
}
}
} catch (e: unknown) {
if (!canceled) {
setIsAdmin(false);
setError(String(e));
}
} finally {
if (!canceled) setCheckingAdmin(false);
}
};
void init();
return () => {
canceled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!isAdmin || !token) return;
void refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAdmin, token, pageSize, statusFilter]);
useEffect(() => {
if (!isAdmin || !token) return;
const timer = setInterval(() => {
void refresh();
}, running ? 3000 : 15000);
return () => clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [running, pageSize, statusFilter]);
}, [isAdmin, token, running, pageSize, statusFilter]);
if (checkingAdmin) {
return (
<main className="mx-auto max-w-7xl px-3 py-8 text-sm text-zinc-600">
{tx("正在校验管理员权限...", "Checking admin access...")}
</main>
);
}
if (!isAdmin) {
return (
<main className="mx-auto max-w-4xl px-3 py-8">
<h1 className="text-xl font-semibold">{tx("平台管理", "Platform Management")}</h1>
<p className="mt-3 text-sm text-red-600">
{error || tx("仅管理员可查看此页面", "This page is available for admin only")}
</p>
<div className="mt-4 flex flex-wrap gap-2 text-sm">
<Link className="rounded border bg-white px-3 py-2 hover:bg-zinc-50" href="/auth">
{tx("去登录", "Go to Sign In")}
</Link>
<Link className="rounded border bg-white px-3 py-2 hover:bg-zinc-50" href="/">
{tx("返回首页", "Back to Home")}
</Link>
</div>
</main>
);
}
return (
<main className="mx-auto max-w-7xl px-6 py-8">
<h1 className="text-2xl font-semibold">Luogu CSP J/S</h1>
<main className="mx-auto max-w-7xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("题库导入/出题任务", "Import / Generation Jobs")}
</h1>
<section className="mt-4 rounded-xl border bg-white p-4">
<h2 className="text-base font-medium">{tx("平台管理快捷入口(原 /admin139", "Platform Shortcuts (moved from /admin139)")}</h2>
<p className="mt-1 text-xs text-zinc-600">
{tx("默认管理员账号admin / whoami139", "Default admin account: admin / whoami139")}
</p>
<div className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-5">
<Link className="rounded border bg-zinc-50 px-3 py-2 text-sm hover:bg-zinc-100" href="/auth">
{tx("登录入口", "Sign In")}
</Link>
<Link className="rounded border bg-zinc-50 px-3 py-2 text-sm hover:bg-zinc-100" href="/admin-users">
{tx("用户积分管理", "User Rating")}
</Link>
<Link className="rounded border bg-zinc-50 px-3 py-2 text-sm hover:bg-zinc-100" href="/admin-redeem">
{tx("积分兑换管理", "Redeem Config")}
</Link>
<Link className="rounded border bg-zinc-50 px-3 py-2 text-sm hover:bg-zinc-100" href="/backend-logs">
{tx("后台日志队列", "Backend Logs")}
</Link>
<Link className="rounded border bg-zinc-50 px-3 py-2 text-sm hover:bg-zinc-100" href="/api-docs">
{tx("API 文档", "API Docs")}
</Link>
</div>
</section>
<div className="mt-4 rounded-xl border bg-white p-4">
<div className="flex flex-wrap items-center gap-3">
<select
className="w-full rounded border px-2 py-2 text-sm sm:w-auto"
value={runMode}
onChange={(e) => setRunMode((e.target.value as "luogu" | "local_pdf_rag") ?? "luogu")}
disabled={loading || running}
>
<option value="luogu">{tx("来源Luogu CSP J/S", "Source: Luogu CSP J/S")}</option>
<option value="local_pdf_rag">{tx("来源:本地 PDF + RAG + LLM 出题", "Source: Local PDF + RAG + LLM")}</option>
</select>
<label className="flex w-full items-center gap-2 text-sm sm:w-auto">
{tx("线程", "Workers")}
<input
type="number"
min={1}
max={16}
className="w-full rounded border px-2 py-1 sm:w-20"
value={workers}
onChange={(e) => setWorkers(Math.max(1, Math.min(16, Number(e.target.value) || 1)))}
disabled={loading || running}
/>
</label>
{runMode === "local_pdf_rag" && (
<>
<label className="flex w-full items-center gap-2 text-sm sm:w-auto">
{tx("PDF目录", "PDF Dir")}
<input
className="w-full rounded border px-2 py-1 sm:w-64"
value={localPdfDir}
onChange={(e) => setLocalPdfDir(e.target.value)}
disabled={loading || running}
/>
</label>
<label className="flex w-full items-center gap-2 text-sm sm:w-auto">
{tx("目标题量", "Target Total")}
<input
type="number"
min={1}
max={50000}
className="w-full rounded border px-2 py-1 sm:w-28"
value={targetTotal}
onChange={(e) =>
setTargetTotal(Math.max(1, Math.min(50000, Number(e.target.value) || 1)))
}
disabled={loading || running}
/>
</label>
</>
)}
<button
className="rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50"
className="w-full rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50 sm:ml-auto sm:w-auto"
onClick={() => void runImport()}
disabled={loading || running}
>
{running ? "导入中..." : "启动导入任务"}
{running ? tx("导入中...", "Importing...") : tx("启动导入任务", "Start Import Job")}
</button>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={clearAllBeforeRun}
onChange={(e) => setClearAllBeforeRun(e.target.checked)}
/>
</label>
{runMode === "luogu" && (
<label className="flex w-full items-center gap-2 text-sm sm:w-auto">
<input
type="checkbox"
checked={clearAllBeforeRun}
onChange={(e) => setClearAllBeforeRun(e.target.checked)}
/>
{tx("启动前清空历史题库", "Clear old problem set before start")}
</label>
)}
<button className="rounded border px-3 py-2 text-sm" onClick={() => void refresh()} disabled={loading}>
{tx("刷新", "Refresh")}
</button>
<span className={`text-sm ${running ? "text-emerald-700" : "text-zinc-600"}`}>
{running ? "运行中" : "空闲"}
{running ? tx("运行中", "Running") : tx("空闲", "Idle")}
</span>
</div>
<p className="mt-2 text-xs text-zinc-500">
3 线 CSP-J/CSP-S/NOIP
</p>
{runMode === "luogu" && (
<p className="mt-2 text-xs text-zinc-500">
{tx(
"抓取洛谷 CSP-J/CSP-S/NOIP 标签题;容器重启后可自动触发(可通过环境变量关闭)。",
"Fetch Luogu problems tagged CSP-J/CSP-S/NOIP. It can auto-start after container restart (configurable via env)."
)}
</p>
)}
{runMode === "local_pdf_rag" && (
<p className="mt-2 text-xs text-zinc-500">
{tx(
"从本地 PDF 提取文本做 RAG,调用 LLM 生成 CSP-J/S 题目,按现有题库难度分布补齐到目标题量并自动去重跳过。",
"Extract text from local PDFs for RAG, then call LLM to generate CSP-J/S problems with dedupe and target distribution."
)}
</p>
)}
</div>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<section className="mt-4 rounded-xl border bg-white p-4">
<h2 className="text-lg font-medium"></h2>
{!job && <p className="mt-2 text-sm text-zinc-500"></p>}
<h2 className="text-lg font-medium">{tx("最新任务", "Latest Job")}</h2>
{!job && <p className="mt-2 text-sm text-zinc-500">{tx("暂无任务记录", "No job records")}</p>}
{job && (
<div className="mt-3 space-y-2 text-sm">
<p>
#{job.id} · <b>{job.status}</b> · {job.trigger}
{tx("任务", "Job")} #{job.id} · {tx("状态", "Status")} <b>{job.status}</b> · {tx("触发方式", "Trigger")} {job.trigger}
</p>
<p className="text-zinc-600">
{tx("模式", "Mode")} {jobOpts?.mode || jobOpts?.source || "luogu"} · {tx("线程", "Workers")} {jobOpts?.workers ?? "-"}
{typeof jobOpts?.target_total === "number" && ` · ${tx("目标题量", "Target total")} ${jobOpts.target_total}`}
{(jobOpts?.local_pdf_dir || jobOpts?.pdf_dir) &&
` · ${tx("PDF目录", "PDF dir")} ${jobOpts.local_pdf_dir || jobOpts.pdf_dir}`}
</p>
<p>
{job.total_count} {job.processed_count} {job.success_count} {job.failed_count}
{tx("总数", "Total")} {job.total_count}{tx("已处理", "Processed")} {job.processed_count}{tx("成功", "Success")} {job.success_count}{tx("失败", "Failed")} {job.failed_count}
</p>
<div className="h-2 w-full rounded bg-zinc-100">
<div className="h-2 rounded bg-emerald-500" style={{ width: `${progress}%` }} />
</div>
<p className="text-zinc-600">
{progress}% · {fmtTs(job.started_at)} · {fmtTs(job.finished_at)}
{tx("进度", "Progress")} {progress}% · {tx("开始", "Start")} {fmtTs(job.started_at)} · {tx("结束", "End")} {fmtTs(job.finished_at)}
</p>
{job.last_error && <p className="text-red-600">{job.last_error}</p>}
{job.last_error && <p className="text-red-600">{tx("最近错误:", "Latest error: ")}{job.last_error}</p>}
</div>
)}
</section>
<section className="mt-4 rounded-xl border bg-white p-4">
<div className="flex flex-wrap items-center gap-3">
<h2 className="text-lg font-medium"></h2>
<h2 className="text-lg font-medium">{tx("任务明细", "Job Items")}</h2>
<select
className="rounded border px-2 py-1 text-sm"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value=""></option>
<option value="">{tx("全部状态", "All Status")}</option>
<option value="queued">queued</option>
<option value="running">running</option>
<option value="success">success</option>
<option value="failed">failed</option>
<option value="skipped">skipped</option>
<option value="interrupted">interrupted</option>
</select>
<select
className="rounded border px-2 py-1 text-sm"
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))}
>
<option value={50}>50 </option>
<option value={100}>100 </option>
<option value={200}>200 </option>
<option value={50}>{tx("50 条", "50 rows")}</option>
<option value={100}>{tx("100 条", "100 rows")}</option>
<option value={200}>{tx("200 条", "200 rows")}</option>
</select>
</div>
<div className="mt-3 overflow-x-auto">
<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"></th>
<th className="px-2 py-2"></th>
<th className="px-2 py-2"></th>
<th className="px-2 py-2"></th>
<th className="px-2 py-2">ID</th>
<th className="px-2 py-2"></th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-t align-top">
<td className="px-2 py-2">{item.id}</td>
<td className="max-w-[400px] px-2 py-2">
<div className="truncate" title={item.source_path}>
{item.source_path}
</div>
</td>
<td className="px-2 py-2">{item.status}</td>
<td className="max-w-[220px] px-2 py-2">
<div className="truncate" title={item.title}>
{item.title || "-"}
</div>
</td>
<td className="px-2 py-2">{item.difficulty || "-"}</td>
<td className="px-2 py-2">{item.problem_id ?? "-"}</td>
<td className="max-w-[320px] px-2 py-2 text-red-600">
<div className="truncate" title={item.error_text}>
{item.error_text || "-"}
</div>
</td>
</tr>
))}
{items.length === 0 && (
<div className="mt-3 rounded-lg border">
<div className="divide-y md:hidden">
{items.map((item) => (
<article key={item.id} className="space-y-1 p-3 text-xs">
<div className="flex items-center justify-between gap-2">
<p className="font-medium">
{tx("明细", "Detail")} #{item.id}
</p>
<span>{item.status}</span>
</div>
<p className="break-all text-zinc-600">{tx("路径:", "Path: ")}{item.source_path}</p>
<p className="text-zinc-600">{tx("标题:", "Title: ")}{item.title || "-"}</p>
<p className="text-zinc-600">
{tx("难度:", "Difficulty: ")}{item.difficulty || "-"} · {tx("题目ID", "Problem ID: ")}{item.problem_id ?? "-"}
</p>
{item.error_text && <p className="break-words text-red-600">{tx("错误:", "Error: ")}{item.error_text}</p>}
</article>
))}
{items.length === 0 && <p className="px-3 py-4 text-center text-sm text-zinc-500">{tx("暂无明细", "No details")}</p>}
</div>
<div className="hidden overflow-x-auto md:block">
<table className="min-w-full text-xs">
<thead className="bg-zinc-100 text-left">
<tr>
<td className="px-2 py-4 text-center text-zinc-500" colSpan={7}>
</td>
<th className="px-2 py-2">ID</th>
<th className="px-2 py-2">{tx("路径", "Path")}</th>
<th className="px-2 py-2">{tx("状态", "Status")}</th>
<th className="px-2 py-2">{tx("标题", "Title")}</th>
<th className="px-2 py-2">{tx("难度", "Difficulty")}</th>
<th className="px-2 py-2">{tx("题目ID", "Problem ID")}</th>
<th className="px-2 py-2">{tx("错误", "Error")}</th>
</tr>
)}
</tbody>
</table>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-t align-top">
<td className="px-2 py-2">{item.id}</td>
<td className="max-w-[400px] px-2 py-2">
<div className="truncate" title={item.source_path}>
{item.source_path}
</div>
</td>
<td className="px-2 py-2">{item.status}</td>
<td className="max-w-[220px] px-2 py-2">
<div className="truncate" title={item.title}>
{item.title || "-"}
</div>
</td>
<td className="px-2 py-2">{item.difficulty || "-"}</td>
<td className="px-2 py-2">{item.problem_id ?? "-"}</td>
<td className="max-w-[320px] px-2 py-2 text-red-600">
<div className="truncate" title={item.error_text}>
{item.error_text || "-"}
</div>
</td>
</tr>
))}
{items.length === 0 && (
<tr>
<td className="px-2 py-4 text-center text-zinc-500" colSpan={7}>
{tx("暂无明细", "No details")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</section>
</main>

查看文件

@@ -4,7 +4,9 @@ import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { MarkdownRenderer } from "@/components/markdown-renderer";
import { apiFetch } from "@/lib/api";
import { useI18nText } from "@/lib/i18n";
type Article = {
id: number;
@@ -20,6 +22,7 @@ type DetailResp = {
};
export default function KbDetailPage() {
const { tx } = useI18nText();
const params = useParams<{ slug: string }>();
const slug = useMemo(() => params.slug, [params.slug]);
@@ -44,29 +47,41 @@ export default function KbDetailPage() {
}, [slug]);
return (
<main className="mx-auto max-w-5xl px-6 py-8">
<h1 className="text-2xl font-semibold"></h1>
{loading && <p className="mt-3 text-sm text-zinc-500">...</p>}
<main className="mx-auto max-w-5xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("知识库文章", "Knowledge Article")}
</h1>
{loading && <p className="mt-3 text-sm text-zinc-500">{tx("加载中...", "Loading...")}</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
{data && (
<div className="mt-4 space-y-4">
<section className="rounded-xl border bg-white p-4">
<h2 className="text-xl font-medium">{data.article.title}</h2>
<pre className="mt-3 whitespace-pre-wrap text-sm">{data.article.content_md}</pre>
<p className="mt-1 text-xs text-zinc-500">
{tx("更新时间:", "Updated: ")}
{new Date(data.article.created_at * 1000).toLocaleString()}
</p>
<div className="mt-3">
<MarkdownRenderer markdown={data.article.content_md} />
</div>
</section>
<section className="rounded-xl border bg-white p-4">
<h3 className="text-sm font-medium"></h3>
<ul className="mt-2 space-y-2 text-sm">
{data.related_problems.map((p) => (
<li key={p.problem_id}>
<Link className="text-blue-600 underline" href={`/problems/${p.problem_id}`}>
#{p.problem_id} {p.title}
</Link>
</li>
))}
</ul>
<h3 className="text-sm font-medium">{tx("关联题目", "Related Problems")}</h3>
{data.related_problems.length ? (
<ul className="mt-2 space-y-2 text-sm">
{data.related_problems.map((p) => (
<li key={p.problem_id}>
<Link className="text-blue-600 underline" href={`/problems/${p.problem_id}`}>
#{p.problem_id} {p.title}
</Link>
</li>
))}
</ul>
) : (
<p className="mt-2 text-sm text-zinc-500">{tx("暂无关联题目", "No related problems")}</p>
)}
</section>
</div>
)}

查看文件

@@ -1,9 +1,11 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
import { useI18nText } from "@/lib/i18n";
type Article = {
id: number;
@@ -12,47 +14,263 @@ type Article = {
created_at: number;
};
type KbRefreshStatus = {
running: boolean;
last_command: string;
last_trigger: string;
last_exit_code: number | null;
last_started_at: number;
last_finished_at: number;
};
type TriggerKbRefreshResp = KbRefreshStatus & {
started: boolean;
message: string;
};
function fmtTs(v: number | null | undefined): string {
if (!v) return "-";
return new Date(v * 1000).toLocaleString();
}
export default function KbListPage() {
const { tx } = useI18nText();
const [refreshToken, setRefreshToken] = useState("");
const [canManageRefresh, setCanManageRefresh] = useState(false);
const [items, setItems] = useState<Article[]>([]);
const [loading, setLoading] = useState(false);
const [statusLoading, setStatusLoading] = useState(false);
const [triggerLoading, setTriggerLoading] = useState(false);
const [error, setError] = useState("");
const [hint, setHint] = useState("");
const [refreshStatus, setRefreshStatus] = useState<KbRefreshStatus | null>(null);
const [lastSyncedFinishedAt, setLastSyncedFinishedAt] = useState(0);
useEffect(() => {
const load = async () => {
setLoading(true);
setError("");
try {
const data = await apiFetch<Article[]>("/api/v1/kb/articles");
setItems(data);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
const grouped = useMemo(() => {
const buckets: Record<string, Article[]> = {
roadmap: [],
cpp: [],
cspj: [],
csps: [],
other: [],
};
void load();
for (const article of items) {
const slug = article.slug.toLowerCase();
if (slug.includes("roadmap")) {
buckets.roadmap.push(article);
} else if (slug.includes("cpp")) {
buckets.cpp.push(article);
} else if (slug.includes("csp-j") || slug.includes("cspj")) {
buckets.cspj.push(article);
} else if (slug.includes("csp-s") || slug.includes("csps")) {
buckets.csps.push(article);
} else {
buckets.other.push(article);
}
}
return buckets;
}, [items]);
const loadArticles = useCallback(async () => {
setLoading(true);
setError("");
try {
const data = await apiFetch<Article[]>("/api/v1/kb/articles");
setItems(data);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
}, []);
return (
<main className="mx-auto max-w-5xl px-6 py-8">
<h1 className="text-2xl font-semibold"></h1>
{loading && <p className="mt-3 text-sm text-zinc-500">...</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
const loadStatus = useCallback(async () => {
if (!canManageRefresh || !refreshToken) {
setRefreshStatus(null);
return;
}
setStatusLoading(true);
try {
const data = await apiFetch<KbRefreshStatus>("/api/v1/backend/kb/refresh", {}, refreshToken);
setRefreshStatus(data);
} catch (e: unknown) {
setError(String(e));
} finally {
setStatusLoading(false);
}
}, [canManageRefresh, refreshToken]);
<div className="mt-4 space-y-3">
{items.map((a) => (
<Link
key={a.slug}
href={`/kb/${a.slug}`}
className="block rounded-xl border bg-white p-4 hover:border-zinc-400"
useEffect(() => {
let canceled = false;
const refreshAdminState = async () => {
const tk = readToken();
if (!tk) {
if (!canceled) {
setRefreshToken("");
setCanManageRefresh(false);
}
return;
}
try {
const me = await apiFetch<{ username?: string }>("/api/v1/me", {}, tk);
if (!canceled) {
const isAdmin = (me?.username ?? "") === "admin";
setCanManageRefresh(isAdmin);
setRefreshToken(isAdmin ? tk : "");
}
} catch {
if (!canceled) {
setRefreshToken("");
setCanManageRefresh(false);
}
}
};
void refreshAdminState();
const onFocus = () => {
void refreshAdminState();
};
window.addEventListener("focus", onFocus);
return () => {
canceled = true;
window.removeEventListener("focus", onFocus);
};
}, []);
useEffect(() => {
void loadArticles();
void loadStatus();
}, [loadArticles, loadStatus]);
useEffect(() => {
const timer = setInterval(() => {
void loadStatus();
}, 5000);
return () => clearInterval(timer);
}, [loadStatus]);
useEffect(() => {
if (!refreshStatus) return;
if (
!refreshStatus.running &&
refreshStatus.last_finished_at > 0 &&
refreshStatus.last_finished_at > lastSyncedFinishedAt
) {
setLastSyncedFinishedAt(refreshStatus.last_finished_at);
void loadArticles();
}
}, [lastSyncedFinishedAt, loadArticles, refreshStatus]);
const triggerRefresh = async () => {
if (!canManageRefresh || !refreshToken) {
setError(tx("仅管理员可执行一键更新资料", "Only admin can trigger material refresh"));
return;
}
setTriggerLoading(true);
setError("");
setHint("");
try {
const data = await apiFetch<TriggerKbRefreshResp>(
"/api/v1/backend/kb/refresh",
{
method: "POST",
body: JSON.stringify({}),
},
refreshToken
);
setRefreshStatus({
running: data.running,
last_command: data.last_command,
last_trigger: data.last_trigger,
last_exit_code: data.last_exit_code,
last_started_at: data.last_started_at,
last_finished_at: data.last_finished_at,
});
setHint(
data.message || (data.started ? tx("已触发异步更新", "Async refresh started") : tx("当前已有任务在运行", "A refresh job is already running"))
);
} catch (e: unknown) {
setError(String(e));
} finally {
setTriggerLoading(false);
}
};
return (
<main className="mx-auto max-w-5xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">{tx("学习知识库", "Knowledge Base")}</h1>
{canManageRefresh ? (
<button
className="rounded border px-3 py-1 text-sm disabled:opacity-50"
disabled={triggerLoading || refreshStatus?.running}
onClick={() => void triggerRefresh()}
>
<h2 className="text-lg font-medium">{a.title}</h2>
<p className="mt-1 text-xs text-zinc-500">
slug: {a.slug} · {new Date(a.created_at * 1000).toLocaleString()}
</p>
</Link>
))}
{triggerLoading
? tx("更新触发中...", "Triggering...")
: refreshStatus?.running
? tx("资料更新中...", "Refreshing...")
: tx("手动一键更新资料", "Manual Refresh")}
</button>
) : (
<span className="text-xs text-zinc-500">
{tx("资料更新由管理员维护", "Material refresh is managed by admin")}
</span>
)}
</div>
<p className="mt-2 text-sm text-zinc-600">
{tx(
"已整理 C++ 基础、CSP-J、CSP-S 学习资料,可按阶段逐步学习。",
"Curated learning materials for C++ fundamentals, CSP-J, and CSP-S."
)}
</p>
<p className="mt-1 text-xs text-zinc-500">
{canManageRefresh
? tx("更新状态:", "Refresh status:")
: tx("更新状态:仅管理员可查看与触发。", "Refresh status: admin only.")}
{canManageRefresh &&
(statusLoading && !refreshStatus
? tx("读取中...", "Loading...")
: refreshStatus?.running
? tx(`运行中(开始于 ${fmtTs(refreshStatus.last_started_at)}`, `Running (started at ${fmtTs(refreshStatus.last_started_at)})`)
: tx(`空闲(最近结束 ${fmtTs(refreshStatus?.last_finished_at ?? null)},退出码 ${
refreshStatus?.last_exit_code ?? "-"
}`, `Idle (last finished ${fmtTs(refreshStatus?.last_finished_at ?? null)}, exit code ${
refreshStatus?.last_exit_code ?? "-"
})`))}
</p>
{loading && <p className="mt-3 text-sm text-zinc-500">{tx("加载中...", "Loading...")}</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
{hint && <p className="mt-3 text-sm text-emerald-700">{hint}</p>}
<section className="mt-4 space-y-5">
{[
["roadmap", tx("学习总路线", "Learning Roadmap")],
["cpp", tx("C++ 基础", "C++ Fundamentals")],
["cspj", "CSP-J"],
["csps", "CSP-S"],
["other", tx("其他资料", "Other Resources")],
].map(([key, label]) => {
const group = grouped[key] ?? [];
if (!group.length) return null;
return (
<div key={key} className="space-y-3">
<h2 className="text-sm font-semibold text-zinc-700">{label}</h2>
{group.map((a) => (
<Link
key={a.slug}
href={`/kb/${a.slug}`}
className="block rounded-xl border bg-white p-4 hover:border-zinc-400"
>
<h3 className="text-lg font-medium">{a.title}</h3>
<p className="mt-1 text-xs text-zinc-500">
slug: {a.slug} · {new Date(a.created_at * 1000).toLocaleString()}
</p>
</Link>
))}
</div>
);
})}
</section>
</main>
);
}

查看文件

@@ -1,13 +1,17 @@
import type { Metadata } from "next";
import { AppNav } from "@/components/app-nav";
import { MobileTabBar } from "@/components/mobile-tab-bar";
import { UiPreferenceProvider } from "@/components/ui-preference-provider";
import "katex/dist/katex.min.css";
import "highlight.js/styles/github-dark.css";
import "swagger-ui-react/swagger-ui.css";
import "@/themes/default/theme.css";
import "@/themes/minecraft/theme.css";
import "./globals.css";
export const metadata: Metadata = {
title: "CSP 在线学习与竞赛平台",
description: "题库、错题本、模拟竞赛、知识库与在线 C++ 运行",
title: "CSP Online Learning & Contest Platform",
description: "Problems, wrong-book review, contests, knowledge base, and C++ runner.",
};
export default function RootLayout({
@@ -16,10 +20,13 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<html lang="en" suppressHydrationWarning>
<body className="antialiased">
<AppNav />
{children}
<UiPreferenceProvider>
<AppNav />
<div className="pb-[calc(3.8rem+env(safe-area-inset-bottom))] md:pb-0">{children}</div>
<MobileTabBar />
</UiPreferenceProvider>
</body>
</html>
);

查看文件

@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { useI18nText } from "@/lib/i18n";
type Row = {
user_id: number;
@@ -12,6 +13,7 @@ type Row = {
};
export default function LeaderboardPage() {
const { tx } = useI18nText();
const [items, setItems] = useState<Row[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
@@ -33,34 +35,65 @@ export default function LeaderboardPage() {
}, []);
return (
<main className="mx-auto max-w-4xl px-6 py-8">
<h1 className="text-2xl font-semibold"></h1>
{loading && <p className="mt-3 text-sm text-zinc-500">...</p>}
<main className="mx-auto max-w-4xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("全站排行榜", "Global Leaderboard")}
</h1>
{loading && <p className="mt-3 text-sm text-zinc-500">{tx("加载中...", "Loading...")}</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<div className="mt-4 overflow-x-auto rounded-xl border bg-white">
<table className="min-w-full text-sm">
<thead className="bg-zinc-100 text-left">
<tr>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2">Rating</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{items.map((row, i) => (
<tr key={row.user_id} className="border-t">
<td className="px-3 py-2">{i + 1}</td>
<td className="px-3 py-2">{row.username}</td>
<td className="px-3 py-2">{row.rating}</td>
<td className="px-3 py-2">
{new Date(row.created_at * 1000).toLocaleString()}
</td>
<div className="mt-4 rounded-xl border bg-white">
<div className="divide-y md:hidden">
{items.map((row, i) => (
<article key={row.user_id} className="space-y-1 p-3 text-sm">
<p className="font-medium">
#{i + 1} · {row.username}
</p>
<p className="text-xs text-zinc-600">Rating: {row.rating}</p>
<p className="text-xs text-zinc-500">
{tx("注册时间:", "Registered: ")}
{new Date(row.created_at * 1000).toLocaleString()}
</p>
</article>
))}
{!loading && items.length === 0 && (
<p className="px-3 py-5 text-center text-sm text-zinc-500">
{tx("暂无排行数据", "No ranking data yet")}
</p>
)}
</div>
<div className="hidden overflow-x-auto md:block">
<table className="min-w-full text-sm">
<thead className="bg-zinc-100 text-left">
<tr>
<th className="px-3 py-2">{tx("排名", "Rank")}</th>
<th className="px-3 py-2">{tx("用户", "User")}</th>
<th className="px-3 py-2">Rating</th>
<th className="px-3 py-2">{tx("注册时间", "Registered At")}</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{items.map((row, i) => (
<tr key={row.user_id} className="border-t">
<td className="px-3 py-2">{i + 1}</td>
<td className="px-3 py-2">{row.username}</td>
<td className="px-3 py-2">{row.rating}</td>
<td className="px-3 py-2">
{new Date(row.created_at * 1000).toLocaleString()}
</td>
</tr>
))}
{!loading && items.length === 0 && (
<tr>
<td className="px-3 py-5 text-center text-zinc-500" colSpan={4}>
{tx("暂无排行数据", "No ranking data yet")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</main>
);

查看文件

@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
import { useI18nText } from "@/lib/i18n";
type Me = {
id: number;
@@ -61,6 +62,7 @@ function fmtTs(v: number | null | undefined): string {
}
export default function MePage() {
const { isZh, tx } = useI18nText();
const [token, setToken] = useState("");
const [profile, setProfile] = useState<Me | null>(null);
const [items, setItems] = useState<RedeemItem[]>([]);
@@ -92,6 +94,38 @@ export default function MePage() {
const totalCost = useMemo(() => Math.max(0, unitCost * Math.max(1, quantity)), [quantity, unitCost]);
const taskTitle = (task: DailyTaskItem): string => {
if (isZh) return task.title;
if (task.code === "login_checkin") return "Daily Sign-in";
if (task.code === "daily_submit") return "Daily Submission";
if (task.code === "first_ac") return "Solve One Problem";
if (task.code === "code_quality") return "Code Quality";
return task.title;
};
const taskDesc = (task: DailyTaskItem): string => {
if (isZh) return task.description;
if (task.code === "login_checkin") return "Sign in once today to get 1 point.";
if (task.code === "daily_submit") return "Submit once today to get 1 point.";
if (task.code === "first_ac") return "Get AC once today to get 1 point.";
if (task.code === "code_quality") return "Submit code longer than 10 lines once today to get 1 point.";
return task.description;
};
const itemName = (name: string): string => {
if (isZh) return name;
if (name === "私人玩游戏时间") return "Private Game Time";
return name;
};
const itemDesc = (text: string): string => {
if (isZh) return text;
if (text === "全局用户可兑换:假期 1 小时 5 Rating;学习日/非节假日 1 小时 25 Rating。") {
return "Global redeem item: holiday 1 hour = 5 rating; study day/non-holiday 1 hour = 25 rating.";
}
return text;
};
const loadAll = async () => {
setLoading(true);
setError("");
@@ -99,7 +133,7 @@ export default function MePage() {
try {
const tk = readToken();
setToken(tk);
if (!tk) throw new Error("请先登录");
if (!tk) throw new Error(tx("请先登录", "Please sign in first"));
const [me, redeemItems, redeemRecords, daily] = await Promise.all([
apiFetch<Me>("/api/v1/me", {}, tk),
@@ -127,6 +161,7 @@ export default function MePage() {
useEffect(() => {
void loadAll();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const redeem = async () => {
@@ -134,9 +169,11 @@ export default function MePage() {
setError("");
setMsg("");
try {
if (!token) throw new Error("请先登录");
if (!selectedItemId) throw new Error("请选择兑换物品");
if (!Number.isFinite(quantity) || quantity <= 0) throw new Error("兑换数量必须大于 0");
if (!token) throw new Error(tx("请先登录", "Please sign in first"));
if (!selectedItemId) throw new Error(tx("请选择兑换物品", "Please select a redeem item"));
if (!Number.isFinite(quantity) || quantity <= 0) {
throw new Error(tx("兑换数量必须大于 0", "Quantity must be greater than 0"));
}
const created = await apiFetch<RedeemCreateResp>(
"/api/v1/me/redeem/records",
@@ -153,9 +190,13 @@ export default function MePage() {
);
setMsg(
`兑换成功:${created.item_name} × ${created.quantity},扣除 ${created.total_cost} 积分${
typeof created.rating_after === "number" ? `,当前 Rating ${created.rating_after}` : ""
}`
isZh
? `兑换成功:${created.item_name} × ${created.quantity},扣除 ${created.total_cost} 积分${
typeof created.rating_after === "number" ? `,当前 Rating ${created.rating_after}` : ""
}`
: `Redeemed successfully: ${itemName(created.item_name)} × ${created.quantity}, cost ${created.total_cost} rating${
typeof created.rating_after === "number" ? `, current rating ${created.rating_after}` : ""
}.`
);
setNote("");
await loadAll();
@@ -168,25 +209,28 @@ export default function MePage() {
return (
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl"></h1>
{loading && <p className="mt-3 text-sm text-zinc-500">...</p>}
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("我的信息与积分兑换", "My Profile & Redeem")}
</h1>
{loading && <p className="mt-3 text-sm text-zinc-500">{tx("加载中...", "Loading...")}</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
{msg && <p className="mt-3 text-sm text-emerald-700">{msg}</p>}
{profile && (
<section className="mt-4 rounded-xl border bg-white p-4 text-sm">
<p>ID: {profile.id}</p>
<p>: {profile.username}</p>
<p>{tx("用户名", "Username")}: {profile.username}</p>
<p>Rating: {profile.rating}</p>
<p>: {fmtTs(profile.created_at)}</p>
<p>{tx("创建时间", "Created At")}: {fmtTs(profile.created_at)}</p>
</section>
)}
<section className="mt-4 rounded-xl border bg-white p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-base font-semibold"></h2>
<h2 className="text-base font-semibold">{tx("每日任务", "Daily Tasks")}</h2>
<p className="text-xs text-zinc-600">
{dailyDayKey ? `${dailyDayKey} · ` : ""} {dailyGainedReward}/{dailyTotalReward}
{dailyDayKey ? `${dailyDayKey} · ` : ""}
{tx("已获", "Earned")} {dailyGainedReward}/{dailyTotalReward} {tx("分", "pts")}
</p>
</div>
<div className="mt-3 divide-y">
@@ -194,68 +238,82 @@ export default function MePage() {
<article key={task.code} className="py-2 text-sm">
<div className="flex items-center justify-between gap-2">
<p className="font-medium">
{task.title} · +{task.reward}
{taskTitle(task)} · +{task.reward}
</p>
<span
className={`rounded px-2 py-0.5 text-xs ${
task.completed ? "bg-emerald-100 text-emerald-700" : "bg-zinc-100 text-zinc-600"
}`}
>
{task.completed ? "已完成" : "未完成"}
{task.completed ? tx("已完成", "Completed") : tx("未完成", "Incomplete")}
</span>
</div>
<p className="mt-1 text-xs text-zinc-600">{task.description}</p>
<p className="mt-1 text-xs text-zinc-600">{taskDesc(task)}</p>
{task.completed && (
<p className="mt-1 text-xs text-zinc-500">{fmtTs(task.completed_at)}</p>
<p className="mt-1 text-xs text-zinc-500">
{tx("完成时间:", "Completed At: ")}
{fmtTs(task.completed_at)}
</p>
)}
</article>
))}
{!loading && dailyTasks.length === 0 && (
<p className="py-3 text-sm text-zinc-500"></p>
<p className="py-3 text-sm text-zinc-500">
{tx("今日任务尚未初始化,请稍后刷新。", "Today's tasks are not initialized yet. Please refresh later.")}
</p>
)}
</div>
</section>
<section className="mt-4 rounded-xl border bg-white p-4">
<h2 className="text-base font-semibold"></h2>
<h2 className="text-base font-semibold">{tx("积分兑换物品", "Redeem Items")}</h2>
<p className="mt-1 text-xs text-zinc-600">
1 =5 / 1 =25
{tx(
"示例规则:私人玩游戏时间(假期 1 小时=5 积分;学习日/非节假日 1 小时=25 积分)",
"Sample rule: Private Game Time (holiday 1h=5 points; study day/non-holiday 1h=25 points)"
)}
</p>
<div className="mt-3 grid gap-3 md:grid-cols-2">
{items.map((item) => (
<article key={item.id} className="rounded border bg-zinc-50 p-3 text-sm">
<div className="flex items-start justify-between gap-2">
<p className="font-medium">{item.name}</p>
<p className="font-medium">{itemName(item.name)}</p>
<button
className="rounded border px-2 py-1 text-xs hover:bg-zinc-100"
onClick={() => setSelectedItemId(item.id)}
>
{tx("选中", "Select")}
</button>
</div>
<p className="mt-1 text-xs text-zinc-600">{item.description || "-"}</p>
<p className="mt-1 text-xs text-zinc-700">{item.holiday_cost} / {item.unit_label}</p>
<p className="text-xs text-zinc-700">{item.studyday_cost} / {item.unit_label}</p>
<p className="mt-1 text-xs text-zinc-600">{itemDesc(item.description) || "-"}</p>
<p className="mt-1 text-xs text-zinc-700">
{tx("假期", "Holiday")}: {item.holiday_cost} / {item.unit_label}
</p>
<p className="text-xs text-zinc-700">
{tx("学习日", "Study Day")}: {item.studyday_cost} / {item.unit_label}
</p>
</article>
))}
{!loading && items.length === 0 && (
<p className="text-sm text-zinc-500"></p>
<p className="text-sm text-zinc-500">
{tx("管理员尚未配置可兑换物品。", "No redeem items configured by admin yet.")}
</p>
)}
</div>
<div className="mt-4 rounded-lg border p-3">
<h3 className="text-sm font-medium"></h3>
<h3 className="text-sm font-medium">{tx("兑换表单", "Redeem Form")}</h3>
<div className="mt-2 grid gap-2 md:grid-cols-2">
<select
className="rounded border px-3 py-2 text-sm"
value={selectedItemId}
onChange={(e) => setSelectedItemId(Number(e.target.value))}
>
<option value={0}></option>
<option value={0}>{tx("请选择兑换物品", "Please select an item")}</option>
{items.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
{itemName(item.name)}
</option>
))}
</select>
@@ -265,8 +323,8 @@ export default function MePage() {
value={dayType}
onChange={(e) => setDayType(e.target.value === "studyday" ? "studyday" : "holiday")}
>
<option value="holiday"></option>
<option value="studyday">/</option>
<option value="holiday">{tx("假期时间(按假期单价)", "Holiday time (holiday price)")}</option>
<option value="studyday">{tx("学习日/非节假日(按学习日单价)", "Study day/non-holiday (study-day price)")}</option>
</select>
<input
@@ -276,19 +334,19 @@ export default function MePage() {
max={24}
value={quantity}
onChange={(e) => setQuantity(Math.max(1, Number(e.target.value) || 1))}
placeholder="兑换时长(小时)"
placeholder={tx("兑换时长(小时)", "Redeem duration (hours)")}
/>
<input
className="rounded border px-3 py-2 text-sm"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="备注(可选)"
placeholder={tx("备注(可选)", "Note (optional)")}
/>
</div>
<p className="mt-2 text-xs text-zinc-600">
{unitCost} / {totalCost}
{tx("当前单价", "Current unit price")}: {unitCost} / {tx("小时", "hour")}{tx("预计扣分", "Estimated cost")}: {totalCost}
</p>
<button
@@ -296,20 +354,20 @@ export default function MePage() {
onClick={() => void redeem()}
disabled={redeemLoading || !selectedItemId}
>
{redeemLoading ? "兑换中..." : "确认兑换"}
{redeemLoading ? tx("兑换中...", "Redeeming...") : tx("确认兑换", "Confirm Redeem")}
</button>
</div>
</section>
<section className="mt-4 rounded-xl border bg-white p-4">
<div className="flex items-center justify-between gap-2">
<h2 className="text-base font-semibold"></h2>
<h2 className="text-base font-semibold">{tx("兑换记录", "Redeem Records")}</h2>
<button
className="rounded border px-3 py-1 text-xs hover:bg-zinc-100"
onClick={() => void loadAll()}
disabled={loading}
>
{tx("刷新", "Refresh")}
</button>
</div>
@@ -317,15 +375,18 @@ export default function MePage() {
{records.map((row) => (
<article key={row.id} className="py-2 text-sm">
<p>
#{row.id} · {row.item_name} · {row.quantity} · {row.day_type === "holiday" ? "假期" : "学习日"}
#{row.id} · {itemName(row.item_name)} · {row.quantity} {tx("小时", "hour")} ·{" "}
{row.day_type === "holiday" ? tx("假期", "Holiday") : tx("学习日", "Study Day")}
</p>
<p className="text-xs text-zinc-600">
{row.unit_cost} {row.total_cost} · {fmtTs(row.created_at)}
{tx("单价", "Unit cost")} {row.unit_cost}{tx("总扣分", "Total cost")} {row.total_cost} · {fmtTs(row.created_at)}
</p>
{row.note && <p className="text-xs text-zinc-500">{row.note}</p>}
{row.note && <p className="text-xs text-zinc-500">{tx("备注:", "Note: ")}{row.note}</p>}
</article>
))}
{!loading && records.length === 0 && <p className="py-3 text-sm text-zinc-500"></p>}
{!loading && records.length === 0 && (
<p className="py-3 text-sm text-zinc-500">{tx("暂无兑换记录。", "No redeem records yet.")}</p>
)}
</div>
</section>
</main>

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

查看文件

@@ -4,6 +4,7 @@ import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { apiFetch } from "@/lib/api";
import { useI18nText } from "@/lib/i18n";
type Problem = {
id: number;
@@ -33,7 +34,8 @@ type ProblemProfile = {
type Preset = {
key: string;
label: string;
labelZh: string;
labelEn: string;
sourcePrefix?: string;
tags?: string[];
};
@@ -41,33 +43,39 @@ type Preset = {
const PRESETS: Preset[] = [
{
key: "csp-beginner-default",
label: "CSP J/S 入门默认",
labelZh: "CSP J/S 入门默认",
labelEn: "CSP J/S Beginner Default",
tags: ["csp-j", "csp-s", "noip-junior", "noip-senior"],
},
{
key: "csp-j",
label: "仅 CSP-J / 普及",
labelZh: "仅 CSP-J / 普及",
labelEn: "CSP-J / Junior Only",
tags: ["csp-j", "noip-junior"],
},
{
key: "csp-s",
label: "仅 CSP-S / 提高",
labelZh: "仅 CSP-S / 提高",
labelEn: "CSP-S / Senior Only",
tags: ["csp-s", "noip-senior"],
},
{
key: "noip-junior",
label: "仅 NOIP 入门",
labelZh: "仅 NOIP 入门",
labelEn: "NOIP Junior Only",
tags: ["noip-junior"],
},
{
key: "luogu-all",
label: "洛谷导入全部",
labelZh: "洛谷导入全部",
labelEn: "All Luogu Imports",
sourcePrefix: "luogu:",
tags: [],
},
{
key: "all",
label: "全站全部来源",
labelZh: "全站全部来源",
labelEn: "All Sources",
tags: [],
},
];
@@ -75,33 +83,39 @@ const PRESETS: Preset[] = [
const QUICK_CARDS = [
{
presetKey: "csp-j",
title: "CSP-J 真题",
desc: "普及组入门训练",
titleZh: "CSP-J 真题",
titleEn: "CSP-J Problems",
descZh: "普及组入门训练",
descEn: "Junior training set",
},
{
presetKey: "csp-s",
title: "CSP-S 真题",
desc: "提高组进阶训练",
titleZh: "CSP-S 真题",
titleEn: "CSP-S Problems",
descZh: "提高组进阶训练",
descEn: "Senior advanced set",
},
{
presetKey: "noip-junior",
title: "NOIP 入门",
desc: "基础算法与思维",
titleZh: "NOIP 入门",
titleEn: "NOIP Junior",
descZh: "基础算法与思维",
descEn: "Basic algorithm thinking",
},
] as const;
const DIFFICULTY_OPTIONS = [
{ value: "0", label: "全部难度" },
{ value: "1", label: "1" },
{ value: "2", label: "2" },
{ value: "3", label: "3" },
{ value: "4", label: "4" },
{ value: "5", label: "5" },
{ value: "6", label: "6" },
{ value: "7", label: "7" },
{ value: "8", label: "8" },
{ value: "9", label: "9" },
{ value: "10", label: "10" },
{ value: "0", labelZh: "全部难度", labelEn: "All Levels" },
{ value: "1", labelZh: "1", labelEn: "1" },
{ value: "2", labelZh: "2", labelEn: "2" },
{ value: "3", labelZh: "3", labelEn: "3" },
{ value: "4", labelZh: "4", labelEn: "4" },
{ value: "5", labelZh: "5", labelEn: "5" },
{ value: "6", labelZh: "6", labelEn: "6" },
{ value: "7", labelZh: "7", labelEn: "7" },
{ value: "8", labelZh: "8", labelEn: "8" },
{ value: "9", labelZh: "9", labelEn: "9" },
{ value: "10", labelZh: "10", labelEn: "10" },
] as const;
function parseProfile(raw: string): ProblemProfile | null {
@@ -145,6 +159,7 @@ function resolveTags(profile: ProblemProfile | null): string[] {
}
export default function ProblemsPage() {
const { isZh, tx } = useI18nText();
const [presetKey, setPresetKey] = useState(PRESETS[0].key);
const [keywordInput, setKeywordInput] = useState("");
const [keyword, setKeyword] = useState("");
@@ -217,15 +232,25 @@ export default function ProblemsPage() {
};
return (
<main className="mx-auto max-w-7xl px-6 py-8">
<main className="mx-auto max-w-7xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold">CSP J/S </h1>
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("题库CSP J/S 入门)", "Problem Set (CSP J/S Beginner)")}
</h1>
<p className="mt-1 text-sm text-zinc-600">
CSP-J / CSP-S / NOIP
{tx(
"参考洛谷题库列表交互,默认聚焦 CSP-J / CSP-S / NOIP 入门训练。",
"Interaction style is inspired by Luogu problem list. Default focus: CSP-J / CSP-S / NOIP junior training."
)}
</p>
</div>
<p className="text-sm text-zinc-600"> {totalCount} </p>
<div className="flex w-full flex-wrap items-center gap-3 text-sm sm:w-auto sm:justify-end">
<p className="text-zinc-600">{tx("共", "Total")} {totalCount} {tx("题", "problems")}</p>
<Link className="w-full rounded border px-3 py-1 text-center hover:bg-zinc-100 sm:w-auto" href="/backend-logs">
{tx("查看后台日志", "View Backend Logs")}
</Link>
</div>
</div>
<section className="mt-4 grid gap-3 md:grid-cols-3">
@@ -242,9 +267,9 @@ export default function ProblemsPage() {
}`}
onClick={() => selectPreset(card.presetKey)}
>
<p className="text-base font-semibold">{card.title}</p>
<p className="text-base font-semibold">{isZh ? card.titleZh : card.titleEn}</p>
<p className={`mt-1 text-xs ${active ? "text-zinc-200" : "text-zinc-500"}`}>
{card.desc}
{isZh ? card.descZh : card.descEn}
</p>
</button>
);
@@ -261,14 +286,14 @@ export default function ProblemsPage() {
>
{PRESETS.map((item) => (
<option key={item.key} value={item.key}>
{item.label}
{isZh ? item.labelZh : item.labelEn}
</option>
))}
</select>
<input
className="rounded border px-3 py-2 text-sm lg:col-span-2"
placeholder="搜索题号/标题/题面关键词"
placeholder={tx("搜索题号/标题/题面关键词", "Search id/title/statement keywords")}
value={keywordInput}
onChange={(e) => setKeywordInput(e.target.value)}
onKeyDown={(e) => {
@@ -286,7 +311,7 @@ export default function ProblemsPage() {
>
{DIFFICULTY_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
{tx("难度", "Difficulty")} {isZh ? item.labelZh : item.labelEn}
</option>
))}
</select>
@@ -301,12 +326,12 @@ export default function ProblemsPage() {
setPage(1);
}}
>
<option value="id:asc"></option>
<option value="id:desc"></option>
<option value="difficulty:asc"></option>
<option value="difficulty:desc"></option>
<option value="created_at:desc"></option>
<option value="title:asc"> A-Z</option>
<option value="id:asc">{tx("题号升序", "ID Asc")}</option>
<option value="id:desc">{tx("题号降序", "ID Desc")}</option>
<option value="difficulty:asc">{tx("难度升序", "Difficulty Asc")}</option>
<option value="difficulty:desc">{tx("难度降序", "Difficulty Desc")}</option>
<option value="created_at:desc">{tx("最新导入", "Newest Imported")}</option>
<option value="title:asc">{tx("标题 A-Z", "Title A-Z")}</option>
</select>
<button
@@ -314,88 +339,130 @@ export default function ProblemsPage() {
onClick={applySearch}
disabled={loading}
>
{loading ? "加载中..." : "搜索"}
{loading ? tx("加载中...", "Loading...") : tx("搜索", "Search")}
</button>
</section>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<section className="mt-4 overflow-x-auto rounded-xl border bg-white">
<table className="min-w-full text-sm">
<thead className="bg-zinc-100 text-left text-zinc-700">
<tr>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2">/</th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{rows.map(({ problem, profile }) => {
const pid = resolvePid(problem, profile);
const tags = resolveTags(profile);
return (
<tr key={problem.id} className="border-t hover:bg-zinc-50">
<td className="px-3 py-2 font-medium text-blue-700">{pid}</td>
<td className="px-3 py-2">
<Link className="hover:underline" href={`/problems/${problem.id}`}>
{problem.title}
</Link>
</td>
<td className="px-3 py-2 text-zinc-600">{resolvePassRate(profile)}</td>
<td className={`px-3 py-2 font-semibold ${difficultyClass(problem.difficulty)}`}>
{problem.difficulty}
</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-1">
{tags.length === 0 && <span className="text-zinc-400">-</span>}
{tags.map((tag) => (
<span key={tag} className="rounded bg-zinc-100 px-2 py-0.5 text-xs">
{tag}
</span>
))}
</div>
</td>
<td className="px-3 py-2 text-zinc-500">{problem.source || "-"}</td>
</tr>
);
})}
{!loading && rows.length === 0 && (
<section className="mt-4 rounded-xl border bg-white">
<div className="divide-y md:hidden">
{rows.map(({ problem, profile }) => {
const pid = resolvePid(problem, profile);
const tags = resolveTags(profile);
return (
<article key={problem.id} className="space-y-2 p-3">
<div className="flex items-start justify-between gap-2">
<Link className="font-medium text-blue-700 hover:underline" href={`/problems/${problem.id}`}>
{pid} · {problem.title}
</Link>
<span className={`shrink-0 text-sm font-semibold ${difficultyClass(problem.difficulty)}`}>
{tx("难度", "Difficulty")} {problem.difficulty}
</span>
</div>
<p className="text-xs text-zinc-600">{tx("通过/提交:", "Accepted/Submissions: ")}{resolvePassRate(profile)}</p>
<p className="text-xs text-zinc-500 break-all">{tx("来源:", "Source: ")}{problem.source || "-"}</p>
<div className="flex flex-wrap gap-1">
{tags.length === 0 && <span className="text-xs text-zinc-400">-</span>}
{tags.map((tag) => (
<span key={tag} className="rounded bg-zinc-100 px-2 py-0.5 text-xs">
{tag}
</span>
))}
</div>
</article>
);
})}
{!loading && rows.length === 0 && (
<p className="px-3 py-6 text-center text-sm text-zinc-500">
{tx(
"当前筛选下暂无题目,请切换题单预设或先执行导入脚本。",
"No problems under current filters. Switch preset or run import first."
)}
</p>
)}
</div>
<div className="hidden overflow-x-auto md:block">
<table className="min-w-full text-sm">
<thead className="bg-zinc-100 text-left text-zinc-700">
<tr>
<td className="px-3 py-6 text-center text-zinc-500" colSpan={6}>
</td>
<th className="px-3 py-2">{tx("题号", "ID")}</th>
<th className="px-3 py-2">{tx("标题", "Title")}</th>
<th className="px-3 py-2">{tx("通过/提交", "Accepted/Submissions")}</th>
<th className="px-3 py-2">{tx("难度", "Difficulty")}</th>
<th className="px-3 py-2">{tx("标签", "Tags")}</th>
<th className="px-3 py-2">{tx("来源", "Source")}</th>
</tr>
)}
</tbody>
</table>
</thead>
<tbody>
{rows.map(({ problem, profile }) => {
const pid = resolvePid(problem, profile);
const tags = resolveTags(profile);
return (
<tr key={problem.id} className="border-t hover:bg-zinc-50">
<td className="px-3 py-2 font-medium text-blue-700">{pid}</td>
<td className="px-3 py-2">
<Link className="hover:underline" href={`/problems/${problem.id}`}>
{problem.title}
</Link>
</td>
<td className="px-3 py-2 text-zinc-600">{resolvePassRate(profile)}</td>
<td className={`px-3 py-2 font-semibold ${difficultyClass(problem.difficulty)}`}>
{problem.difficulty}
</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-1">
{tags.length === 0 && <span className="text-zinc-400">-</span>}
{tags.map((tag) => (
<span key={tag} className="rounded bg-zinc-100 px-2 py-0.5 text-xs">
{tag}
</span>
))}
</div>
</td>
<td className="px-3 py-2 text-zinc-500">{problem.source || "-"}</td>
</tr>
);
})}
{!loading && rows.length === 0 && (
<tr>
<td className="px-3 py-6 text-center text-zinc-500" colSpan={6}>
{tx(
"当前筛选下暂无题目,请切换题单预设或先执行导入脚本。",
"No problems under current filters. Switch preset or run import first."
)}
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-sm">
<div className="flex items-center gap-2">
<div className="mt-4 flex flex-col gap-3 text-sm sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<button
className="rounded border px-3 py-1 disabled:opacity-50"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={loading || page <= 1}
>
{tx("上一页", "Prev")}
</button>
<span>
{page} / {totalPages}
{isZh ? `${page} / ${totalPages}` : `Page ${page} / ${totalPages}`}
</span>
<button
className="rounded border px-3 py-1 disabled:opacity-50"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={loading || page >= totalPages}
>
{tx("下一页", "Next")}
</button>
</div>
<div className="flex items-center gap-2">
<span></span>
<div className="flex items-center gap-2 sm:justify-end">
<span>{tx("每页", "Per Page")}</span>
<select
className="rounded border px-2 py-1"
value={pageSize}

查看文件

@@ -3,6 +3,7 @@
import { useState } from "react";
import { apiFetch } from "@/lib/api";
import { useI18nText } from "@/lib/i18n";
type RunResult = {
status: string;
@@ -23,6 +24,7 @@ int main() {
`;
export default function RunPage() {
const { tx } = useI18nText();
const [code, setCode] = useState(starterCode);
const [input, setInput] = useState("hello csp");
const [loading, setLoading] = useState(false);
@@ -47,21 +49,23 @@ export default function RunPage() {
};
return (
<main className="mx-auto max-w-6xl px-6 py-8">
<h1 className="text-2xl font-semibold">线 C++ / / </h1>
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("在线 C++ 编写 / 编译 / 运行", "Online C++ Editor / Compile / Run")}
</h1>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<section className="rounded-xl border bg-white p-4">
<h2 className="text-sm font-medium"></h2>
<h2 className="text-sm font-medium">{tx("代码", "Code")}</h2>
<textarea
className="mt-2 h-[420px] w-full rounded border p-3 font-mono text-sm"
className="mt-2 h-72 w-full rounded border p-3 font-mono text-sm sm:h-[420px]"
value={code}
onChange={(e) => setCode(e.target.value)}
/>
</section>
<section className="rounded-xl border bg-white p-4">
<h2 className="text-sm font-medium"></h2>
<h2 className="text-sm font-medium">{tx("标准输入", "Standard Input")}</h2>
<textarea
className="mt-2 h-32 w-full rounded border p-3 font-mono text-sm"
value={input}
@@ -69,11 +73,11 @@ export default function RunPage() {
/>
<button
className="mt-3 rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50"
className="mt-3 w-full rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50 sm:w-auto"
onClick={() => void run()}
disabled={loading}
>
{loading ? "运行中..." : "运行"}
{loading ? tx("运行中...", "Running...") : tx("运行", "Run")}
</button>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
@@ -81,7 +85,7 @@ export default function RunPage() {
{result && (
<div className="mt-4 space-y-3 text-sm">
<p>
: <b>{result.status}</b> · : {result.time_ms}ms
{tx("状态", "Status")}: <b>{result.status}</b> · {tx("耗时", "Time")}: {result.time_ms}ms
</p>
<div>
<h3 className="font-medium">stdout</h3>

查看文件

@@ -3,7 +3,19 @@
import { useParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { MarkdownRenderer } from "@/components/markdown-renderer";
import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
import { useI18nText } from "@/lib/i18n";
type SubmissionAnalysis = {
feedback_md: string;
links: Array<{ title: string; url: string }>;
model_name: string;
status: string;
created_at: number;
updated_at: number;
};
type Submission = {
id: number;
@@ -14,72 +26,184 @@ type Submission = {
code: string;
status: string;
score: number;
rating_delta: number;
time_ms: number;
memory_kb: number;
compile_log: string;
runtime_log: string;
created_at: number;
has_viewed_answer: boolean;
answer_view_count: number;
answer_view_total_cost: number;
last_answer_view_at: number | null;
analysis: SubmissionAnalysis | null;
};
function fmtTs(v: number | null): string {
if (!v) return "-";
return new Date(v * 1000).toLocaleString();
}
function fmtRatingDelta(delta: number): string {
if (delta > 0) return `+${delta}`;
return `${delta}`;
}
export default function SubmissionDetailPage() {
const { tx } = useI18nText();
const params = useParams<{ id: string }>();
const id = useMemo(() => Number(params.id), [params.id]);
const [data, setData] = useState<Submission | null>(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [analysisLoading, setAnalysisLoading] = useState(false);
const [analysisMsg, setAnalysisMsg] = useState("");
const load = async () => {
setLoading(true);
setError("");
try {
const d = await apiFetch<Submission>(`/api/v1/submissions/${id}`);
setData(d);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
};
useEffect(() => {
const load = async () => {
setLoading(true);
setError("");
try {
const d = await apiFetch<Submission>(`/api/v1/submissions/${id}`);
setData(d);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
};
if (Number.isFinite(id) && id > 0) void load();
if (Number.isFinite(id) && id > 0) {
void load();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
const generateAnalysis = async (refresh = false) => {
setAnalysisLoading(true);
setAnalysisMsg("");
setError("");
try {
const token = readToken();
if (!token) throw new Error(tx("请先登录后再生成评测建议", "Please sign in before generating analysis"));
const result = await apiFetch<SubmissionAnalysis>(
`/api/v1/submissions/${id}/analysis`,
{
method: "POST",
body: JSON.stringify({ refresh }),
},
token
);
setData((prev) => (prev ? { ...prev, analysis: result } : prev));
setAnalysisMsg(refresh ? tx("已重新生成评测建议", "Analysis regenerated") : tx("评测建议已生成", "Analysis generated"));
} catch (e: unknown) {
setAnalysisMsg(`${tx("生成失败:", "Generate failed: ")}${String(e)}`);
} finally {
setAnalysisLoading(false);
}
};
return (
<main className="mx-auto max-w-5xl px-6 py-8">
<h1 className="text-2xl font-semibold"> #{id}</h1>
{loading && <p className="mt-4 text-sm text-zinc-500">...</p>}
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("提交详情", "Submission Detail")} #{id}
</h1>
{loading && <p className="mt-4 text-sm text-zinc-500">{tx("加载中...", "Loading...")}</p>}
{error && <p className="mt-4 text-sm text-red-600">{error}</p>}
{data && (
<div className="mt-4 space-y-4">
<div className="rounded-xl border bg-white p-4 text-sm">
<p>: {data.user_id}</p>
<p>: {data.problem_id}</p>
<p>: {data.contest_id ?? "-"}</p>
<p>: {data.language}</p>
<p>: {data.status}</p>
<p>: {data.score}</p>
<p>: {data.time_ms} ms</p>
<p>: {data.memory_kb} KB</p>
</div>
<section className="rounded-xl border bg-white p-4 text-sm">
<div className="grid gap-1 sm:grid-cols-2">
<p>{tx("用户", "User")}: {data.user_id}</p>
<p>{tx("题目", "Problem")}: {data.problem_id}</p>
<p>{tx("比赛", "Contest")}: {data.contest_id ?? "-"}</p>
<p>{tx("语言", "Language")}: {data.language}</p>
<p>{tx("状态", "Status")}: {data.status}</p>
<p>{tx("分数", "Score")}: {data.score}</p>
<p>{tx("Rating 变化", "Rating Delta")}: {fmtRatingDelta(data.rating_delta)}</p>
<p>{tx("时间", "Time")}: {data.time_ms} ms</p>
<p>{tx("内存", "Memory")}: {data.memory_kb} KB</p>
<p>{tx("提交时间", "Submitted At")}: {fmtTs(data.created_at)}</p>
<p>{tx("查看答案", "Viewed Answer")}: {data.has_viewed_answer ? tx("已查看", "Yes") : tx("未查看", "No")}</p>
<p>{tx("查看次数", "View Count")}: {data.answer_view_count}</p>
<p>{tx("查看扣分", "View Cost")}: {data.answer_view_total_cost}</p>
<p>{tx("最后查看答案", "Last View Time")}: {fmtTs(data.last_answer_view_at)}</p>
</div>
</section>
<section className="rounded-xl border bg-white p-4">
<h2 className="text-sm font-medium"></h2>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-sm font-medium">{tx("LLM 评测建议(福建 CSP-J/S 规范)", "LLM Analysis (Fujian CSP-J/S style)")}</h2>
<button
className="rounded border px-3 py-1 text-xs disabled:opacity-50"
onClick={() => void generateAnalysis(false)}
disabled={analysisLoading}
>
{analysisLoading ? tx("生成中...", "Generating...") : tx("生成评测建议", "Generate Analysis")}
</button>
<button
className="rounded border px-3 py-1 text-xs disabled:opacity-50"
onClick={() => void generateAnalysis(true)}
disabled={analysisLoading}
>
{tx("重新生成", "Regenerate")}
</button>
</div>
{analysisMsg && <p className="mt-2 text-xs text-zinc-600">{analysisMsg}</p>}
{data.analysis ? (
<div className="mt-3 space-y-3">
<div className="rounded border bg-zinc-50 p-3 text-xs text-zinc-600">
<p>{tx("模型", "Model")}: {data.analysis.model_name || "-"}</p>
<p>{tx("状态", "Status")}: {data.analysis.status || "ready"}</p>
<p>{tx("更新时间", "Updated At")}: {fmtTs(data.analysis.updated_at)}</p>
</div>
<div className="rounded border bg-white p-3">
<MarkdownRenderer markdown={data.analysis.feedback_md} />
</div>
{(data.analysis.links ?? []).length > 0 && (
<div className="rounded border bg-zinc-50 p-3">
<p className="text-xs font-medium text-zinc-700">{tx("推荐外链资料", "Recommended Links")}</p>
<div className="mt-2 flex flex-wrap gap-2">
{data.analysis.links.map((item) => (
<a
key={`${item.title}-${item.url}`}
className="rounded border px-2 py-1 text-xs text-blue-700 hover:underline"
href={item.url}
target="_blank"
rel="noreferrer"
>
{item.title}
</a>
))}
</div>
</div>
)}
</div>
) : (
<p className="mt-2 text-xs text-zinc-500">
{tx("暂无评测建议,可点击上方按钮生成。", "No analysis yet. Click the button above to generate one.")}
</p>
)}
</section>
<section className="rounded-xl border bg-white p-4">
<h2 className="text-sm font-medium">{tx("提交代码", "Submitted Code")}</h2>
<pre className="mt-2 overflow-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
{data.code}
{data.code || "(empty)"}
</pre>
</section>
<section className="rounded-xl border bg-white p-4">
<h2 className="text-sm font-medium"></h2>
<h2 className="text-sm font-medium">{tx("编译日志", "Compile Log")}</h2>
<pre className="mt-2 overflow-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
{data.compile_log || "(empty)"}
</pre>
</section>
<section className="rounded-xl border bg-white p-4">
<h2 className="text-sm font-medium"></h2>
<h2 className="text-sm font-medium">{tx("运行日志", "Runtime Log")}</h2>
<pre className="mt-2 overflow-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
{data.runtime_log || "(empty)"}
</pre>

查看文件

@@ -4,6 +4,7 @@ import Link from "next/link";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { useI18nText } from "@/lib/i18n";
type Submission = {
id: number;
@@ -12,6 +13,7 @@ type Submission = {
contest_id: number | null;
status: string;
score: number;
rating_delta: number;
time_ms: number;
created_at: number;
};
@@ -19,6 +21,7 @@ type Submission = {
type ListResp = { items: Submission[]; page: number; page_size: number };
export default function SubmissionsPage() {
const { tx } = useI18nText();
const [userId, setUserId] = useState("");
const [problemId, setProblemId] = useState("");
const [contestId, setContestId] = useState("");
@@ -26,6 +29,17 @@ export default function SubmissionsPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const fmtRatingDelta = (delta: number) => {
if (delta > 0) return `+${delta}`;
return `${delta}`;
};
const ratingDeltaClass = (delta: number) => {
if (delta > 0) return "text-emerald-700";
if (delta < 0) return "text-red-700";
return "text-zinc-600";
};
const load = async () => {
setLoading(true);
setError("");
@@ -49,8 +63,10 @@ export default function SubmissionsPage() {
}, []);
return (
<main className="mx-auto max-w-6xl px-6 py-8">
<h1 className="text-2xl font-semibold"></h1>
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("提交记录", "Submissions")}
</h1>
<div className="mt-4 grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-4">
<input
@@ -76,43 +92,80 @@ export default function SubmissionsPage() {
onClick={() => void load()}
disabled={loading}
>
{loading ? "加载中..." : "筛选"}
{loading ? tx("加载中...", "Loading...") : tx("筛选", "Filter")}
</button>
</div>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<div className="mt-4 overflow-x-auto rounded-xl border bg-white">
<table className="min-w-full text-sm">
<thead className="bg-zinc-100 text-left">
<tr>
<th className="px-3 py-2">ID</th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2">(ms)</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{items.map((s) => (
<tr key={s.id} className="border-t">
<td className="px-3 py-2">{s.id}</td>
<td className="px-3 py-2">{s.user_id}</td>
<td className="px-3 py-2">{s.problem_id}</td>
<td className="px-3 py-2">{s.status}</td>
<td className="px-3 py-2">{s.score}</td>
<td className="px-3 py-2">{s.time_ms}</td>
<td className="px-3 py-2">
<Link className="text-blue-600 underline" href={`/submissions/${s.id}`}>
</Link>
</td>
<div className="mt-4 rounded-xl border bg-white">
<div className="divide-y md:hidden">
{items.map((s) => (
<article key={s.id} className="space-y-2 p-3 text-sm">
<div className="flex items-center justify-between gap-3">
<p className="font-medium">{tx("提交", "Submission")} #{s.id}</p>
<span className="text-xs text-zinc-500">{s.status}</span>
</div>
<p className="text-xs text-zinc-600">
{tx("用户", "User")} {s.user_id} · {tx("题目", "Problem")} {s.problem_id} · {tx("分数", "Score")} {s.score}
</p>
<p className={`text-xs ${ratingDeltaClass(s.rating_delta)}`}>
{tx("Rating 变化", "Rating Delta")} {fmtRatingDelta(s.rating_delta)}
</p>
<p className="text-xs text-zinc-600">{tx("耗时", "Time")} {s.time_ms} ms</p>
<Link className="text-blue-600 underline" href={`/submissions/${s.id}`}>
{tx("查看详情", "View Detail")}
</Link>
</article>
))}
{!loading && items.length === 0 && (
<p className="px-3 py-5 text-center text-sm text-zinc-500">{tx("暂无提交记录", "No submissions yet")}</p>
)}
</div>
<div className="hidden overflow-x-auto md:block">
<table className="min-w-full text-sm">
<thead className="bg-zinc-100 text-left">
<tr>
<th className="px-3 py-2">ID</th>
<th className="px-3 py-2">{tx("用户", "User")}</th>
<th className="px-3 py-2">{tx("题目", "Problem")}</th>
<th className="px-3 py-2">{tx("状态", "Status")}</th>
<th className="px-3 py-2">{tx("分数", "Score")}</th>
<th className="px-3 py-2">{tx("Rating 变化", "Rating Delta")}</th>
<th className="px-3 py-2">{tx("耗时(ms)", "Time(ms)")}</th>
<th className="px-3 py-2">{tx("详情", "Detail")}</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{items.map((s) => (
<tr key={s.id} className="border-t">
<td className="px-3 py-2">{s.id}</td>
<td className="px-3 py-2">{s.user_id}</td>
<td className="px-3 py-2">{s.problem_id}</td>
<td className="px-3 py-2">{s.status}</td>
<td className="px-3 py-2">{s.score}</td>
<td className={`px-3 py-2 ${ratingDeltaClass(s.rating_delta)}`}>
{fmtRatingDelta(s.rating_delta)}
</td>
<td className="px-3 py-2">{s.time_ms}</td>
<td className="px-3 py-2">
<Link className="text-blue-600 underline" href={`/submissions/${s.id}`}>
{tx("查看", "View")}
</Link>
</td>
</tr>
))}
{!loading && items.length === 0 && (
<tr>
<td className="px-3 py-5 text-center text-zinc-500" colSpan={8}>
{tx("暂无提交记录", "No submissions yet")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</main>
);

查看文件

@@ -1,9 +1,11 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
import { useI18nText } from "@/lib/i18n";
type WrongBookItem = {
user_id: number;
@@ -14,7 +16,13 @@ type WrongBookItem = {
updated_at: number;
};
function fmtTs(v: number): string {
if (!v) return "-";
return new Date(v * 1000).toLocaleString();
}
export default function WrongBookPage() {
const { tx } = useI18nText();
const [token, setToken] = useState("");
const [items, setItems] = useState<WrongBookItem[]>([]);
const [loading, setLoading] = useState(false);
@@ -28,7 +36,7 @@ export default function WrongBookPage() {
setLoading(true);
setError("");
try {
if (!token) throw new Error("请先登录");
if (!token) throw new Error(tx("请先登录", "Please sign in first"));
const data = await apiFetch<WrongBookItem[]>("/api/v1/me/wrong-book", {}, token);
setItems(data);
} catch (e: unknown) {
@@ -65,9 +73,13 @@ export default function WrongBookPage() {
};
return (
<main className="mx-auto max-w-5xl px-6 py-8">
<h1 className="text-2xl font-semibold"></h1>
<p className="mt-2 text-sm text-zinc-600"></p>
<main className="mx-auto max-w-5xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("错题本", "Wrong Book")}
</h1>
<p className="mt-2 text-sm text-zinc-600">
{tx("未通过提交会自动进入错题本。", "Failed submissions are added to the wrong-book automatically.")}
</p>
<div className="mt-4">
<button
@@ -75,7 +87,7 @@ export default function WrongBookPage() {
onClick={() => void load()}
disabled={loading}
>
{loading ? "刷新中..." : "刷新"}
{loading ? tx("刷新中...", "Refreshing...") : tx("刷新", "Refresh")}
</button>
</div>
@@ -84,22 +96,42 @@ export default function WrongBookPage() {
<div className="mt-4 space-y-3">
{items.map((item) => (
<div key={item.problem_id} className="rounded-xl border bg-white p-4">
<div className="flex items-center justify-between gap-2">
<p className="font-medium">
<div className="flex flex-wrap items-start justify-between gap-2">
<Link className="font-medium text-blue-700 hover:underline" href={`/problems/${item.problem_id}`}>
#{item.problem_id} {item.problem_title}
</p>
</Link>
<div className="flex flex-wrap items-center gap-2">
<Link
className="rounded border px-3 py-1 text-sm hover:bg-zinc-100"
href={`/problems/${item.problem_id}`}
>
{tx("查看题目", "View Problem")}
</Link>
{item.last_submission_id && (
<Link
className="rounded border px-3 py-1 text-sm hover:bg-zinc-100"
href={`/submissions/${item.last_submission_id}`}
>
{tx("查看最近提交", "View Latest Submission")}
</Link>
)}
</div>
</div>
<p className="mt-1 text-xs text-zinc-500">
{tx("最近提交:", "Latest Submission:")} {item.last_submission_id ?? "-"} ·{" "}
{tx("更新时间:", "Updated:")} {fmtTs(item.updated_at)}
</p>
<div className="mt-2 flex flex-wrap justify-end gap-2">
<button
className="rounded border px-3 py-1 text-sm hover:bg-zinc-100"
onClick={() => void removeItem(item.problem_id)}
>
{tx("移除", "Remove")}
</button>
</div>
<p className="mt-1 text-xs text-zinc-500">
: {item.last_submission_id ?? "-"}
</p>
<textarea
className="mt-2 h-24 w-full rounded border p-2 text-sm"
value={item.note}
@@ -116,10 +148,18 @@ export default function WrongBookPage() {
className="mt-2 rounded border px-3 py-1 text-sm hover:bg-zinc-100"
onClick={() => void updateNote(item.problem_id, item.note)}
>
{tx("保存备注", "Save Note")}
</button>
</div>
))}
{!loading && items.length === 0 && (
<div className="rounded-xl border bg-white p-6 text-center text-sm text-zinc-500">
{tx(
"暂无错题。提交未通过后会自动加入错题本,可点击“查看题目/查看最近提交”快速复盘。",
"No wrong-book entries yet. Failed submissions will be added automatically; use “View Problem/View Latest Submission” to review quickly."
)}
</div>
)}
</div>
</main>
);

查看文件

@@ -1,64 +1,321 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { useUiPreferences } from "@/components/ui-preference-provider";
import { apiFetch } from "@/lib/api";
import { clearToken, readToken } from "@/lib/auth";
import type { ThemeId } from "@/themes/types";
const links = [
["首页", "/"],
["登录", "/auth"],
["题库", "/problems"],
["提交", "/submissions"],
["错题本", "/wrong-book"],
["比赛", "/contests"],
["知识库", "/kb"],
["导入任务", "/imports"],
["在线运行", "/run"],
["我的", "/me"],
["排行榜", "/leaderboard"],
["API文档", "/api-docs"],
] as const;
type NavLink = {
label: string;
href: string;
};
type NavGroup = {
key: string;
label: string;
links: NavLink[];
};
function buildNavGroups(t: (key: string) => string, isAdmin: boolean): NavGroup[] {
const groups: NavGroup[] = [
{
key: "learn",
label: t("nav.group.learn"),
links: [
{ label: t("nav.link.home"), href: "/" },
{ label: t("nav.link.problems"), href: "/problems" },
{ label: t("nav.link.submissions"), href: "/submissions" },
{ label: t("nav.link.wrong_book"), href: "/wrong-book" },
{ label: t("nav.link.kb"), href: "/kb" },
{ label: t("nav.link.run"), href: "/run" },
],
},
{
key: "contest",
label: t("nav.group.contest"),
links: [
{ label: t("nav.link.contests"), href: "/contests" },
{ label: t("nav.link.leaderboard"), href: "/leaderboard" },
],
},
{
key: "account",
label: t("nav.group.account"),
links: [
{ label: t("nav.link.auth"), href: "/auth" },
{ label: t("nav.link.me"), href: "/me" },
],
},
];
if (isAdmin) {
groups.splice(2, 0, {
key: "system",
label: t("nav.group.system"),
links: [
{ label: t("nav.link.imports"), href: "/imports" },
{ label: t("nav.link.backend_logs"), href: "/backend-logs" },
{ label: t("nav.link.admin_users"), href: "/admin-users" },
{ label: t("nav.link.admin_redeem"), href: "/admin-redeem" },
{ label: t("nav.link.api_docs"), href: "/api-docs" },
],
});
}
return groups;
}
function isActivePath(pathname: string, href: string): boolean {
if (pathname === href) return true;
if (href === "/") return pathname === "/";
return pathname.startsWith(`${href}/`);
}
function resolveActiveGroup(pathname: string, groups: NavGroup[]): string {
for (const group of groups) {
for (const item of group.links) {
if (isActivePath(pathname, item.href)) return group.key;
}
}
return groups[0]?.key ?? "learn";
}
function resolveActiveLink(pathname: string, group: NavGroup): string {
for (const item of group.links) {
if (isActivePath(pathname, item.href)) return item.href;
}
return group.links[0]?.href ?? "/";
}
export function AppNav() {
const pathname = usePathname();
const router = useRouter();
const { theme, setTheme, language, setLanguage, themes, t } = useUiPreferences();
const [hasToken, setHasToken] = useState<boolean>(() => Boolean(readToken()));
const [isAdmin, setIsAdmin] = useState(false);
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";
useEffect(() => {
const refresh = () => setHasToken(Boolean(readToken()));
window.addEventListener("storage", refresh);
window.addEventListener("focus", refresh);
return () => {
window.removeEventListener("storage", refresh);
window.removeEventListener("focus", refresh);
let canceled = false;
const refresh = async () => {
const token = readToken();
if (canceled) return;
setHasToken(Boolean(token));
if (!token) {
setIsAdmin(false);
return;
}
try {
const me = await apiFetch<{ username?: string }>("/api/v1/me", {}, token);
if (!canceled) {
setIsAdmin((me?.username ?? "") === "admin");
}
} catch {
if (!canceled) setIsAdmin(false);
}
};
}, []);
const onRefresh = () => {
void refresh();
};
void refresh();
window.addEventListener("storage", onRefresh);
window.addEventListener("focus", onRefresh);
return () => {
canceled = true;
window.removeEventListener("storage", onRefresh);
window.removeEventListener("focus", onRefresh);
};
}, [pathname]);
useEffect(() => {
if (!usePopupSecondary || !desktopOpenGroup) return;
const onMouseDown = (event: MouseEvent) => {
const target = event.target as Node | null;
if (!target) return;
if (desktopMenuRef.current?.contains(target)) return;
setDesktopOpenGroup(null);
};
window.addEventListener("mousedown", onMouseDown);
return () => window.removeEventListener("mousedown", onMouseDown);
}, [desktopOpenGroup, usePopupSecondary]);
const currentGroup = navGroups.find((g) => g.key === activeGroup) ?? navGroups[0];
return (
<header className="border-b bg-white">
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-2 px-4 py-3">
{links.map(([label, href]) => (
<Link
key={href}
href={href}
className="rounded-md border px-3 py-1 text-sm hover:bg-zinc-100"
<header className="print-hidden border-b bg-[color:var(--surface)]/95 backdrop-blur supports-[backdrop-filter]:bg-[color:var(--surface)]/85">
<div className="mx-auto max-w-6xl px-3 py-3 max-[390px]:px-2 max-[390px]:py-2 sm:px-4">
<div className="flex items-center justify-between md:hidden">
<span className="text-sm font-medium text-zinc-700">{t("nav.menu")}</span>
<button
type="button"
className="rounded-md border px-3 py-1 text-sm hover:bg-zinc-100 max-[390px]:px-2 max-[390px]:text-xs"
onClick={() => setMenuOpen((v) => !v)}
aria-expanded={menuOpen}
aria-controls="main-nav-links"
>
{label}
</Link>
))}
{menuOpen ? t("nav.collapse") : t("nav.expand")}
</button>
</div>
<div className="ml-auto flex items-center gap-2 text-sm">
<div id="main-nav-links" className={`${menuOpen ? "mt-3 block" : "hidden"} md:mt-0 md:block`}>
<div className="space-y-3">
{usePopupSecondary ? (
<div ref={desktopMenuRef} className="hidden flex-wrap items-center gap-2 md:flex">
{navGroups.map((group) => {
const active = activeGroup === group.key;
const opened = desktopOpenGroup === group.key;
return (
<div key={group.key} className="relative">
<button
type="button"
className={`rounded-md border px-3 py-1 text-sm ${
active ? "border-zinc-900 bg-zinc-900 text-white" : "hover:bg-zinc-100"
}`}
aria-expanded={opened}
onClick={() =>
setDesktopOpenGroup((prev) => (prev === group.key ? null : group.key))
}
>
{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">
{group.links.map((item) => {
const linkActive = isActivePath(pathname, item.href);
return (
<button
key={item.href}
type="button"
className={`block w-full rounded px-3 py-1.5 text-left text-sm ${
linkActive ? "bg-zinc-900 text-white" : "hover:bg-zinc-100"
}`}
onClick={() => {
setDesktopOpenGroup(null);
router.push(item.href);
}}
>
{item.label}
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
) : (
<>
<div className="hidden flex-wrap items-center gap-2 md:flex">
{navGroups.map((group) => {
const active = activeGroup === group.key;
return (
<button
key={group.key}
type="button"
className={`rounded-md border px-3 py-1 text-sm ${
active ? "border-zinc-900 bg-zinc-900 text-white" : "hover:bg-zinc-100"
}`}
onClick={() => router.push(group.links[0]?.href ?? "/")}
>
{group.label}
</button>
);
})}
</div>
<div className="hidden items-center gap-2 md:flex">
<span className="text-xs text-zinc-500">{t("nav.secondary_menu")}</span>
<select
className="min-w-[220px] rounded-md border px-3 py-1 text-sm"
value={resolveActiveLink(pathname, currentGroup)}
onChange={(e) => router.push(e.target.value)}
>
{currentGroup.links.map((item) => (
<option key={item.href} value={item.href}>
{item.label}
</option>
))}
</select>
</div>
</>
)}
<div className="space-y-3 md:hidden">
{navGroups.map((group) => (
<section key={group.key} className="rounded-lg border p-2">
<h3 className="text-xs font-semibold text-zinc-600">{group.label}</h3>
<select
className="mt-2 w-full rounded-md border px-3 py-1 text-xs"
value={resolveActiveLink(pathname, group)}
onChange={(e) => {
setMenuOpen(false);
router.push(e.target.value);
}}
>
{group.links.map((item) => (
<option key={item.href} value={item.href}>
{item.label}
</option>
))}
</select>
</section>
))}
</div>
</div>
</div>
<div className="mt-2 flex flex-wrap items-center justify-end gap-2 text-xs sm:text-sm">
<label className="inline-flex items-center gap-1">
<span className="text-zinc-500">{t("prefs.theme")}</span>
<select
className="rounded-md border px-2 py-1 text-xs sm:text-sm"
value={theme}
onChange={(e) => {
setDesktopOpenGroup(null);
setTheme(e.target.value as ThemeId);
}}
>
{themes.map((item) => (
<option key={item.id} value={item.id}>
{item.labels[language]}
</option>
))}
</select>
</label>
<label className="inline-flex items-center gap-1">
<span className="text-zinc-500">{t("prefs.language")}</span>
<select
className="rounded-md border px-2 py-1 text-xs sm:text-sm"
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>
<span className={hasToken ? "text-emerald-700" : "text-zinc-500"}>
{hasToken ? "已登录" : "未登录"}
{hasToken ? t("nav.logged_in") : t("nav.logged_out")}
</span>
{hasToken && (
<button
onClick={() => {
clearToken();
setHasToken(false);
setIsAdmin(false);
}}
className="rounded-md border px-3 py-1 hover:bg-zinc-100"
>
退
{t("nav.logout")}
</button>
)}
</div>

查看文件

@@ -1,25 +1,160 @@
"use client";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useRef } from "react";
import type { editor as MonacoEditorNS, IDisposable, MarkerSeverity, IPosition } from "monaco-editor";
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";
type Props = {
value: string;
onChange: (next: string) => void;
height?: string;
fontSize?: number;
onPolicyIssuesChange?: (issues: Cpp14PolicyIssue[]) => void;
};
export function CodeEditor({ value, onChange, height = "420px" }: Props) {
function markerSeverity(monaco: typeof import("monaco-editor"), severity: Cpp14PolicyIssue["severity"]): MarkerSeverity {
if (severity === "error") return monaco.MarkerSeverity.Error;
if (severity === "warning") return monaco.MarkerSeverity.Warning;
if (severity === "hint") return monaco.MarkerSeverity.Hint;
return monaco.MarkerSeverity.Info;
}
function safeEndPosition(
issue: Cpp14PolicyIssue,
model: MonacoEditorNS.ITextModel
): IPosition {
const lineMax = model.getLineCount();
const line = Math.min(Math.max(1, issue.endLine), lineMax);
const colMax = model.getLineMaxColumn(line);
const column = Math.min(Math.max(1, issue.endColumn), colMax);
return { lineNumber: line, column };
}
function localizePolicyIssue(
issue: Cpp14PolicyIssue,
tx: (zhText: string, enText: string) => string
): Cpp14PolicyIssue {
const byId: Record<string, { message: string; detail: string }> = {
"cpp17-header": {
message: tx("检测到 C++17+ 头文件", "Detected C++17+ header"),
detail: tx(
"福建 CSP-J/S 环境通常以 C++14 为准,建议改为 C++14 可用写法。",
"Fujian CSP-J/S judge usually targets C++14; please rewrite with C++14-compatible code."
),
},
"if-constexpr": {
message: tx("检测到 if constexprC++17", "Detected `if constexpr` (C++17)"),
detail: tx("请改用普通条件分支或模板特化方案。", "Use normal condition branches or template specialization."),
},
"structured-binding": {
message: tx("检测到结构化绑定C++17", "Detected structured binding (C++17)"),
detail: tx("可改为 pair.first/second 或自定义结构体字段。", "Use `pair.first/second` or explicit struct fields."),
},
"cpp17-stdlib": {
message: tx("检测到 C++17+ 标准库符号", "Detected C++17+ stdlib symbol"),
detail: tx("请替换为 C++14 可用实现,避免提交到老版本 GCC 报 CE。", "Replace with C++14 implementation to avoid CE on older GCC."),
},
"void-main": {
message: tx("main 函数返回类型不规范", "Invalid `main` return type"),
detail: tx("请使用 int main(),并在末尾 return 0;", "Use `int main()` and end with `return 0;`."),
},
"windows-i64d": {
message: tx("检测到 %I64dWindows 特有)", "Detected `%I64d` (Windows-only)"),
detail: tx("Linux 评测机请使用 %lld 读写 long long。", "Use `%lld` for `long long` on Linux judges."),
},
"windows-int64": {
message: tx("检测到 __int64非标准", "Detected `__int64` (non-standard)"),
detail: tx("建议改为标准类型 long long。", "Use standard type `long long` instead."),
},
"bits-header": {
message: tx("检测到 <bits/stdc++.h>", "Detected `<bits/stdc++.h>`"),
detail: tx("福建实战建议优先使用标准头文件,提升环境兼容性。", "Prefer standard headers for better judge compatibility."),
},
"main-return-zero": {
message: tx("建议在 main 末尾显式 return 0;", "Recommend explicit `return 0;` in `main`"),
detail: tx("部分考场与评测环境会严格检查主函数返回行为。", "Some exam/judge environments check main return behavior strictly."),
},
"ll-format": {
message: tx("检测到 long long + scanf/printf,建议确认格式符为 %lld", "Detected `long long` with scanf/printf; confirm `%lld` format"),
detail: tx("Linux 评测环境不支持 %I64d。", "Linux judge does not support `%I64d`."),
},
"freopen-tip": {
message: tx("未检测到 freopen福建二轮常见文件读写要求", "No `freopen` found (common requirement in Fujian round-2)"),
detail: tx("若考场题面要求 *.in/*.out,请按官方文件名补上 freopen。", "If statement requires `*.in/*.out`, add `freopen` with exact official filenames."),
},
};
const localized = byId[issue.id];
if (!localized) return issue;
return { ...issue, ...localized };
}
export function CodeEditor({
value,
onChange,
height = "420px",
fontSize = 14,
onPolicyIssuesChange,
}: Props) {
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 updatePolicyIssues = useCallback(
(nextCode: string) => {
const issues = analyzeCpp14Policy(nextCode).map((issue) => localizePolicyIssue(issue, tx));
onPolicyIssuesChange?.(issues);
const monaco = monacoRef.current;
const model = editorRef.current?.getModel();
if (!monaco || !model) return;
const markers = issues.map((issue) => {
const endPos = safeEndPosition(issue, model);
return {
severity: markerSeverity(monaco, issue.severity),
message: `${issue.message}\n${issue.detail}`,
startLineNumber: Math.max(1, issue.line),
startColumn: Math.max(1, issue.column),
endLineNumber: endPos.lineNumber,
endColumn: endPos.column,
source: tx("CSP C++14 规范提醒", "CSP C++14 Policy"),
code: issue.id,
};
});
monaco.editor.setModelMarkers(model, POLICY_MARKER_OWNER, markers);
},
[onPolicyIssuesChange, tx]
);
useEffect(() => {
updatePolicyIssues(value);
}, [updatePolicyIssues, value]);
useEffect(
() => () => {
completionRef.current?.dispose();
completionRef.current = null;
},
[]
);
return (
<MonacoEditor
height={height}
language="cpp"
value={value}
options={{
fontSize: 14,
fontSize,
minimap: { enabled: false },
automaticLayout: true,
glyphMargin: true,
tabSize: 2,
wordWrap: "on",
suggestOnTriggerCharacters: true,
@@ -30,40 +165,98 @@ export function CodeEditor({ value, onChange, height = "420px" }: Props) {
},
}}
onMount={(editor, monaco) => {
monaco.languages.registerCompletionItemProvider("cpp", {
provideCompletionItems: () => ({
suggestions: [
{
label: "ios",
kind: monaco.languages.CompletionItemKind.Snippet,
insertText:
"ios::sync_with_stdio(false);\\ncin.tie(nullptr);\\n",
documentation: "Fast IO",
},
{
label: "fori",
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: "for (int i = 0; i < ${1:n}; ++i) {\\n ${2}\\n}",
insertTextRules:
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: "for loop",
},
{
label: "vector",
kind: monaco.languages.CompletionItemKind.Keyword,
insertText: "vector<int> ${1:arr}(${2:n});",
insertTextRules:
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
},
],
}),
});
editorRef.current = editor;
monacoRef.current = monaco;
if (!completionRef.current) {
completionRef.current = monaco.languages.registerCompletionItemProvider("cpp", {
provideCompletionItems: () => ({
suggestions: [
{
label: "cspmain",
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: [
"#include <iostream>",
"#include <vector>",
"#include <algorithm>",
"using namespace std;",
"",
"int main() {",
" ios::sync_with_stdio(false);",
" cin.tie(nullptr);",
"",
" ${1:// code}",
" return 0;",
"}",
].join("\\n"),
insertTextRules:
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: tx("C++14 主函数模板(含 return 0", "C++14 `main` template (with `return 0`)"),
},
{
label: "ios",
kind: monaco.languages.CompletionItemKind.Snippet,
insertText:
"ios::sync_with_stdio(false);\\ncin.tie(nullptr);\\n",
documentation: "Fast IO",
},
{
label: "freopen",
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: [
'freopen("${1:problem}.in", "r", stdin);',
'freopen("${1:problem}.out", "w", stdout);',
].join("\\n"),
insertTextRules:
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: tx("福建赛场常见文件读写模板", "Common Fujian contest file I/O template"),
},
{
label: "scanfll",
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: 'scanf("%lld", &${1:x});',
insertTextRules:
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: tx("long long 输入Linux 评测格式符)", "long long input (Linux judge format)"),
},
{
label: "printfll",
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: 'printf("%lld\\\\n", ${1:x});',
insertTextRules:
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: tx("long long 输出Linux 评测格式符)", "long long output (Linux judge format)"),
},
{
label: "fori",
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: "for (int i = 0; i < ${1:n}; ++i) {\\n ${2}\\n}",
insertTextRules:
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: "for loop",
},
{
label: "vector",
kind: monaco.languages.CompletionItemKind.Keyword,
insertText: "vector<int> ${1:arr}(${2:n});",
insertTextRules:
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
},
],
}),
});
}
updatePolicyIssues(editor.getValue());
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
// handled by page-level save button; reserve shortcut for UX consistency.
});
}}
onChange={(next) => onChange(next ?? "")}
onChange={(next) => {
const nextCode = next ?? "";
onChange(nextCode);
updatePolicyIssues(nextCode);
}}
/>
);
}

查看文件

@@ -0,0 +1,49 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useUiPreferences } from "@/components/ui-preference-provider";
function isActivePath(pathname: string, href: string): boolean {
if (pathname === href) return true;
return pathname.startsWith(`${href}/`);
}
export function MobileTabBar() {
const pathname = usePathname();
const { t } = useUiPreferences();
const tabs = [
{ label: t("mobile.tab.problems"), href: "/problems" },
{ label: t("mobile.tab.submissions"), href: "/submissions" },
{ label: t("mobile.tab.contests"), href: "/contests" },
{ label: t("mobile.tab.kb"), href: "/kb" },
{ label: t("mobile.tab.me"), href: "/me" },
] as const;
return (
<nav className="print-hidden fixed inset-x-0 bottom-0 z-40 border-t bg-[color:var(--surface)]/95 pb-[calc(0.3rem+env(safe-area-inset-bottom))] pt-1 backdrop-blur supports-[backdrop-filter]:bg-[color:var(--surface)]/85 md:hidden">
<div className="mx-auto max-w-5xl px-2 max-[390px]:px-1.5">
<div className="grid grid-cols-5 gap-1 max-[390px]:gap-0.5">
{tabs.map((tab) => {
const active = isActivePath(pathname, tab.href);
return (
<Link
key={tab.href}
href={tab.href}
className={`rounded-md px-1 py-1.5 text-center text-xs max-[390px]:text-[11px] ${
active
? "bg-zinc-900 font-semibold text-white"
: "text-zinc-600 hover:bg-zinc-100"
}`}
>
{tab.label}
</Link>
);
})}
</div>
</div>
</nav>
);
}

查看文件

@@ -0,0 +1,71 @@
"use client";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { listThemes, resolveLanguage, resolveTheme } from "@/themes/registry";
import { DEFAULT_LANGUAGE, DEFAULT_THEME, type ThemeDefinition, type ThemeId, type UiLanguage } from "@/themes/types";
const THEME_STORAGE_KEY = "csp.ui.theme";
const LANGUAGE_STORAGE_KEY = "csp.ui.language";
type UiPreferenceContextValue = {
theme: ThemeId;
language: UiLanguage;
themes: ThemeDefinition[];
setTheme: (theme: ThemeId) => void;
setLanguage: (language: UiLanguage) => void;
t: (key: string) => string;
};
const UiPreferenceContext = createContext<UiPreferenceContextValue | null>(null);
export function UiPreferenceProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<ThemeId>(() => {
if (typeof window === "undefined") return DEFAULT_THEME;
return resolveTheme(window.localStorage.getItem(THEME_STORAGE_KEY)).id;
});
const [language, setLanguageState] = useState<UiLanguage>(() => {
if (typeof window === "undefined") return DEFAULT_LANGUAGE;
return resolveLanguage(window.localStorage.getItem(LANGUAGE_STORAGE_KEY));
});
const themes = useMemo(() => listThemes(), []);
useEffect(() => {
if (typeof window !== "undefined") {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
}
document.documentElement.dataset.theme = theme;
}, [theme]);
useEffect(() => {
if (typeof window !== "undefined") {
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language);
}
document.documentElement.lang = language === "zh" ? "zh-CN" : "en";
}, [language]);
const value = useMemo<UiPreferenceContextValue>(() => {
const resolved = resolveTheme(theme);
const fallbackMessages = resolved.messages.en;
const currentMessages = resolved.messages[language] ?? fallbackMessages;
return {
theme,
language,
themes,
setTheme: (nextTheme) => setThemeState(resolveTheme(nextTheme).id),
setLanguage: (nextLanguage) => setLanguageState(resolveLanguage(nextLanguage)),
t: (key: string) => currentMessages[key] ?? fallbackMessages[key] ?? key,
};
}, [language, theme, themes]);
return <UiPreferenceContext.Provider value={value}>{children}</UiPreferenceContext.Provider>;
}
export function useUiPreferences() {
const ctx = useContext(UiPreferenceContext);
if (!ctx) {
throw new Error("useUiPreferences must be used inside UiPreferenceProvider");
}
return ctx;
}

查看文件

@@ -2,6 +2,12 @@ export const API_BASE =
process.env.NEXT_PUBLIC_API_BASE ??
(process.env.NODE_ENV === "development" ? "http://localhost:8080" : "/admin139");
function uiText(zhText: string, enText: string): string {
if (typeof window === "undefined") return enText;
const lang = window.localStorage.getItem("csp.ui.language");
return lang === "zh" ? zhText : enText;
}
type ApiEnvelope<T> =
| { ok: true; data?: T; [k: string]: unknown }
| { ok: false; error?: string; [k: string]: unknown };
@@ -17,11 +23,45 @@ export async function apiFetch<T>(
headers.set("Content-Type", "application/json");
}
const resp = await fetch(`${API_BASE}${path}`, {
...init,
headers,
cache: "no-store",
});
const method = (init?.method ?? "GET").toUpperCase();
const retryable = method === "GET" || method === "HEAD";
let resp: Response;
try {
resp = await fetch(`${API_BASE}${path}`, {
...init,
headers,
cache: "no-store",
});
} catch (err) {
if (!retryable) {
throw new Error(
uiText(
`网络请求失败,请检查后端服务或代理连接(${err instanceof Error ? err.message : String(err)}`,
`Network request failed. Please check backend/proxy connectivity (${err instanceof Error ? err.message : String(err)}).`
)
);
}
await new Promise((resolve) => setTimeout(resolve, 400));
try {
resp = await fetch(`${API_BASE}${path}`, {
...init,
headers,
cache: "no-store",
});
} catch (retryErr) {
throw new Error(
uiText(
`网络请求失败,请检查后端服务或代理连接(${
retryErr instanceof Error ? retryErr.message : String(retryErr)
}`,
`Network request failed. Please check backend/proxy connectivity (${
retryErr instanceof Error ? retryErr.message : String(retryErr)
}).`
)
);
}
}
const text = await resp.text();
let payload: unknown = null;

查看文件

@@ -0,0 +1,206 @@
export type Cpp14PolicySeverity = "error" | "warning" | "hint";
export type Cpp14PolicyIssue = {
id: string;
severity: Cpp14PolicySeverity;
message: string;
detail: string;
line: number;
column: number;
endLine: number;
endColumn: number;
};
type RegexRule = {
id: string;
severity: Cpp14PolicySeverity;
pattern: RegExp;
message: string;
detail: string;
};
const REGEX_RULES: RegexRule[] = [
{
id: "cpp17-header",
severity: "error",
pattern: /#\s*include\s*<\s*(optional|variant|any|string_view|filesystem|charconv|execution)\s*>/g,
message: "检测到 C++17+ 头文件",
detail: "福建 CSP-J/S 环境通常以 C++14 为准,建议改为 C++14 可用写法。",
},
{
id: "if-constexpr",
severity: "error",
pattern: /\bif\s+constexpr\b/g,
message: "检测到 if constexprC++17",
detail: "请改用普通条件分支或模板特化方案。",
},
{
id: "structured-binding",
severity: "error",
pattern: /\b(?:const\s+)?auto(?:\s*&|\s*&&)?\s*\[[^\]\n]+\]\s*=/g,
message: "检测到结构化绑定C++17",
detail: "可改为 pair.first/second 或自定义结构体字段。",
},
{
id: "cpp17-stdlib",
severity: "error",
pattern: /\bstd::(optional|variant|any|string_view|filesystem|byte|clamp|gcd|lcm)\b/g,
message: "检测到 C++17+ 标准库符号",
detail: "请替换为 C++14 可用实现,避免提交到老版本 GCC 报 CE。",
},
{
id: "void-main",
severity: "error",
pattern: /\bvoid\s+main\s*\(/g,
message: "main 函数返回类型不规范",
detail: "请使用 int main(),并在末尾 return 0;",
},
{
id: "windows-i64d",
severity: "warning",
pattern: /%I64d/g,
message: "检测到 %I64dWindows 特有)",
detail: "Linux 评测机请使用 %lld 读写 long long。",
},
{
id: "windows-int64",
severity: "warning",
pattern: /\b__int64\b/g,
message: "检测到 __int64非标准",
detail: "建议改为标准类型 long long。",
},
{
id: "bits-header",
severity: "warning",
pattern: /#\s*include\s*<\s*bits\/stdc\+\+\.h\s*>/g,
message: "检测到 <bits/stdc++.h>",
detail: "福建实战建议优先使用标准头文件,提升环境兼容性。",
},
];
export const CSP_CPP14_TIPS: string[] = [
"编译标准固定为 C++14建议按 -std=gnu++14 习惯编码),不要使用 C++17+ 特性。",
"main 必须是 int main(),结尾写 return 0;。",
"long long 的 scanf/printf 请使用 %lld,不要使用 %I64d。",
"命名/提交包按考场须知执行:题目目录与源码名使用英文小写。",
"福建二轮常见要求是文件读写freopen,赛前请按官方样例再核对一次。",
];
function buildLineStarts(text: string): number[] {
const starts = [0];
for (let i = 0; i < text.length; i += 1) {
if (text[i] === "\n") starts.push(i + 1);
}
return starts;
}
function offsetToPosition(lineStarts: number[], offset: number): { line: number; column: number } {
let lo = 0;
let hi = lineStarts.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (lineStarts[mid] <= offset) lo = mid + 1;
else hi = mid - 1;
}
const lineIdx = Math.max(0, hi);
return {
line: lineIdx + 1,
column: offset - lineStarts[lineIdx] + 1,
};
}
function pushIssue(
issues: Cpp14PolicyIssue[],
id: string,
severity: Cpp14PolicySeverity,
message: string,
detail: string,
line: number,
column: number,
endLine: number,
endColumn: number
) {
issues.push({ id, severity, message, detail, line, column, endLine, endColumn });
}
export function analyzeCpp14Policy(code: string): Cpp14PolicyIssue[] {
const text = (code ?? "").replace(/\r\n?/g, "\n");
if (!text.trim()) return [];
const issues: Cpp14PolicyIssue[] = [];
const lineStarts = buildLineStarts(text);
for (const rule of REGEX_RULES) {
const matcher = new RegExp(rule.pattern.source, rule.pattern.flags);
let match = matcher.exec(text);
while (match) {
const start = match.index;
const end = start + Math.max(1, match[0].length);
const p1 = offsetToPosition(lineStarts, start);
const p2 = offsetToPosition(lineStarts, end);
pushIssue(
issues,
rule.id,
rule.severity,
rule.message,
rule.detail,
p1.line,
p1.column,
p2.line,
p2.column
);
if (matcher.lastIndex === match.index) matcher.lastIndex += 1;
match = matcher.exec(text);
}
}
if (/\bint\s+main\s*\(/.test(text) && !/\breturn\s+0\s*;/.test(text)) {
const idx = text.search(/\bint\s+main\s*\(/);
const pos = offsetToPosition(lineStarts, Math.max(0, idx));
pushIssue(
issues,
"main-return-zero",
"warning",
"建议在 main 末尾显式 return 0;",
"部分考场与评测环境会严格检查主函数返回行为。",
pos.line,
pos.column,
pos.line,
pos.column + 3
);
}
if (/\blong\s+long\b/.test(text) && /\b(?:scanf|printf)\s*\(/.test(text) && !/%lld/.test(text)) {
const idx = text.search(/\b(?:scanf|printf)\s*\(/);
const pos = offsetToPosition(lineStarts, Math.max(0, idx));
pushIssue(
issues,
"ll-format",
"warning",
"检测到 long long + scanf/printf,建议确认格式符为 %lld",
"Linux 评测环境不支持 %I64d。",
pos.line,
pos.column,
pos.line,
pos.column + 6
);
}
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;
}

15
frontend/src/lib/i18n.ts 普通文件
查看文件

@@ -0,0 +1,15 @@
"use client";
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";
}
export function useI18nText() {
const { language } = useUiPreferences();
const isZh = language === "zh";
const tx = (zhText: string, enText: string) => (isZh ? zhText : enText);
return { language, isZh, tx };
}

查看文件

@@ -0,0 +1,294 @@
# 前端主题开发规范Theme Development Guide
本规范用于统一 `frontend/src/themes` 的主题实现方式,确保:
- 主题切换稳定(不闪烁、不丢样式、可回退)。
- 中英文文案与主题样式解耦。
- 新主题可快速复制、低风险上线。
- 代码评审和验收有统一标准。
---
## 1. 当前主题系统结构
```text
src/themes/
README.md
types.ts
registry.ts
default/
index.ts
theme.css
messages/
en.ts
zh.ts
minecraft/
index.ts
theme.css
```
相关运行时入口:
- `frontend/src/components/ui-preference-provider.tsx`
- 读取/写入本地偏好:
- `csp.ui.theme`
- `csp.ui.language`
- 将主题写入:`document.documentElement.dataset.theme`
- 将语言写入:`document.documentElement.lang`
- `frontend/src/app/layout.tsx`
- 必须导入所有主题 CSS 文件(否则构建后不会产出样式)。
---
## 2. 主题模型与命名规范
### 2.1 ThemeId
`src/themes/types.ts``ThemeId` 使用联合类型,新增主题必须显式加入:
```ts
export type ThemeId = "default" | "minecraft";
```
### 2.2 目录命名
- 目录名使用小写短横线或小写单词(建议与 `ThemeId` 一致)。
- 推荐:
- `default`
- `minecraft`
- 不推荐:
- `MinecraftTheme`
- `theme_minecraft_v2`
### 2.3 主题显示名
`<theme>/index.ts``labels` 中定义:
- `en`: 英文名称
- `zh`: 中文名称
示例:
```ts
labels: {
en: "Minecraft Pixel",
zh: "Minecraft 像素风",
}
```
---
## 3. 主题文件职责划分
### 3.1 `types.ts`
职责:
- 主题类型与默认主题定义。
禁止:
- 放置主题样式、业务逻辑。
### 3.2 `registry.ts`
职责:
- 注册所有主题。
- 提供 `listThemes` / `resolveTheme` / `resolveLanguage`
规范:
- 新主题必须加入 `themes` 数组。
- `resolveTheme` 必须有回退逻辑(回退到 `DEFAULT_THEME`)。
### 3.3 `<theme>/index.ts`
职责:
- 只描述主题元数据(`id` / `labels` / `messages`)。
规范:
- 不写样式代码。
- 可复用默认消息包,也可提供主题专属消息包。
### 3.4 `<theme>/theme.css`
职责:
- 主题 token 与组件样式覆盖。
规范:
- 必须以 `:root[data-theme="<id>"]` 作为作用域入口。
- 优先 token 化,减少硬编码。
- 避免直接污染无作用域的全局选择器。
---
## 4. CSS 实现规范(强制)
### 4.1 作用域规则
必须:
- 使用 `:root[data-theme="<id>"]` 作为根作用域。
- 仅在该作用域下覆盖样式。
示例:
```css
:root[data-theme="minecraft"] {
--background: #1a1a1a;
--foreground: #f5f5f5;
}
```
### 4.2 Token 优先级
实现顺序:
1. 先定义 CSS 变量(颜色、边框、背景、文本)。
2. 业务组件尽量走变量消费。
3. 最后才做类级别覆盖(如 `.rounded-xl.border`)。
### 4.3 覆盖策略
推荐:
- 小范围、可预测覆盖(导航、按钮、卡片、输入等)。
谨慎:
- `!important` 仅用于必要冲突点。
- 覆盖 Tailwind 工具类时,需评估全站影响。
### 4.4 字体规范
推荐:
- `font-display: swap`
- 提供回退字体链。
注意:
- 外链字体失败时应可读、不崩布局。
---
## 5. 国际化i18n规范
### 5.1 文案层级
- 主题消息通过 `ThemeDefinition.messages` 提供。
- 页面业务文案使用 `tx(zh, en)``t(key)`
### 5.2 主题与文案关系
- 主题可以复用默认 `en/zh` 消息包(如 `minecraft`)。
- 如主题有独立文案(例如术语、品牌),再创建主题专属 `messages`
### 5.3 必查项
- 主题切换不影响语言切换。
- EN 模式不出现未预期中文 UI 文案(数据内容除外)。
---
## 6. 新增主题标准流程SOP
1. 新建目录 `src/themes/<id>/`
2. 新建 `<id>/theme.css`(先 token,后覆盖
3. 新建 `<id>/index.ts`(主题元数据)。
4.`types.ts` 扩展 `ThemeId` 联合类型。
5.`registry.ts` 注册主题。
6.`app/layout.tsx` 导入主题 CSS。
7. 运行校验:
- `npm run lint`
- `npm run build`
8. 手工验收:
- 主题切换
- 移动端导航
- 深色页面可读性
- 表单/按钮交互
---
## 7. 验收清单(上线前必须过)
- 功能:
- 主题可切换、可持久化(刷新后保留)。
- 语言切换与主题切换互不影响。
- 样式:
- 首页、题库、题目详情、提交详情、个人中心、后台页无明显错位。
- 按钮 hover/active/disabled 状态清晰。
- 输入框、下拉框、表格、代码区可读。
- 技术:
- `npm run lint` 无报错。
- `npm run build` 成功。
- 无新增严重控制台报错。
---
## 8. Code Review 清单
- 是否遵循 `:root[data-theme="<id>"]` 作用域?
- 是否优先使用 token,而不是散落硬编码颜色?
- 是否避免了无必要的大面积 `!important`
- `ThemeId``registry``layout` 三处是否同步更新?
- 主题名称 `labels` 是否提供中英文?
- 是否验证主要页面可读性与对比度?
---
## 9. Minecraft 主题落地说明(当前版本)
来源参考:
- `ref/CSP-Minecraft-UI-Kit/docs/Design-Delivery-Document.html`
已落地核心规范:
- 色板 tokengrass/stone/wood/gold/diamond/redstone
- 像素风容器与边框(黑边 + 阴影)。
- 按钮 3D 状态normal/hover/active/disabled
- 输入控件像素化风格。
- 背景纹理与图片 `image-rendering: pixelated`
- 标题字体与文本阴影风格。
---
## 10. 常见问题FAQ
### Q1: 新主题写了 CSS,但页面无变化?
优先检查:
- `layout.tsx` 是否 `import` 了该主题 CSS。
- `ThemeId` 是否包含该主题。
- `registry.ts` 是否注册主题。
- `data-theme` 是否正确写入到 `<html>`
### Q2: 主题切换后某些组件颜色不对?
原因通常是:
- 页面用了硬编码颜色类且未被主题覆盖。
建议:
- 先抽成 token,再局部覆盖组件类。
### Q3: 能否让主题拥有独立文案?
可以。把 `messages` 指向主题自己的 `en.ts / zh.ts` 即可。
---
## 11. 后续建议(可选)
- 将“按钮、卡片、输入框、徽章”收敛为主题化组件(而非纯 class 覆盖)。
- 增加主题视觉回归截图(关键页面自动快照)。
- 建立对比度检测脚本,避免低对比度文本上线。

查看文件

@@ -0,0 +1,15 @@
import { enMessages } from "@/themes/default/messages/en";
import { zhMessages } from "@/themes/default/messages/zh";
import type { ThemeDefinition } from "@/themes/types";
export const defaultTheme: ThemeDefinition = {
id: "default",
labels: {
en: "Default",
zh: "默认主题",
},
messages: {
en: enMessages,
zh: zhMessages,
},
};

查看文件

@@ -0,0 +1,55 @@
import type { ThemeMessages } from "@/themes/types";
export const enMessages: ThemeMessages = {
"app.title": "CSP Online Learning & Contest Platform",
"nav.menu": "Navigation",
"nav.expand": "Expand",
"nav.collapse": "Collapse",
"nav.secondary_menu": "Submenu",
"nav.logged_in": "Signed in",
"nav.logged_out": "Signed out",
"nav.logout": "Sign out",
"nav.group.learn": "Learning",
"nav.group.contest": "Contests",
"nav.group.system": "Platform",
"nav.group.account": "Account",
"nav.link.home": "Home",
"nav.link.problems": "Problems",
"nav.link.submissions": "Submissions",
"nav.link.wrong_book": "Wrong Book",
"nav.link.kb": "Knowledge Base",
"nav.link.run": "Run Code",
"nav.link.contests": "Contests",
"nav.link.leaderboard": "Leaderboard",
"nav.link.imports": "Imports",
"nav.link.backend_logs": "Backend Logs",
"nav.link.admin_users": "User Rating",
"nav.link.admin_redeem": "Redeem Admin",
"nav.link.api_docs": "API Docs",
"nav.link.auth": "Sign In",
"nav.link.me": "My Account",
"mobile.tab.problems": "Problems",
"mobile.tab.submissions": "Submits",
"mobile.tab.contests": "Contests",
"mobile.tab.kb": "KB",
"mobile.tab.me": "Me",
"prefs.theme": "Theme",
"prefs.language": "Language",
"prefs.lang.en": "English",
"prefs.lang.zh": "Chinese",
"admin.entry.title": "Admin Entry",
"admin.entry.desc":
"Default admin account: admin / whoami139",
"admin.entry.login": "Go to Sign In",
"admin.entry.user_rating": "Manage User Rating",
"admin.entry.redeem": "Manage Redeem Items",
"admin.entry.logs": "Backend Task Logs",
"admin.entry.moved_to_platform":
"This entry has been merged into Platform Management (/imports).",
};

查看文件

@@ -0,0 +1,53 @@
import type { ThemeMessages } from "@/themes/types";
export const zhMessages: ThemeMessages = {
"app.title": "CSP 在线学习与竞赛平台",
"nav.menu": "导航菜单",
"nav.expand": "展开",
"nav.collapse": "收起",
"nav.secondary_menu": "二级菜单",
"nav.logged_in": "已登录",
"nav.logged_out": "未登录",
"nav.logout": "退出",
"nav.group.learn": "学习训练",
"nav.group.contest": "竞赛评测",
"nav.group.system": "平台管理",
"nav.group.account": "账号中心",
"nav.link.home": "首页",
"nav.link.problems": "题库",
"nav.link.submissions": "提交记录",
"nav.link.wrong_book": "错题本",
"nav.link.kb": "知识库",
"nav.link.run": "在线运行",
"nav.link.contests": "比赛",
"nav.link.leaderboard": "排行榜",
"nav.link.imports": "导入任务",
"nav.link.backend_logs": "后台日志",
"nav.link.admin_users": "用户积分",
"nav.link.admin_redeem": "积分兑换",
"nav.link.api_docs": "API文档",
"nav.link.auth": "登录",
"nav.link.me": "我的",
"mobile.tab.problems": "题库",
"mobile.tab.submissions": "提交",
"mobile.tab.contests": "比赛",
"mobile.tab.kb": "知识库",
"mobile.tab.me": "我的",
"prefs.theme": "主题",
"prefs.language": "语言",
"prefs.lang.en": "English",
"prefs.lang.zh": "中文",
"admin.entry.title": "后台管理入口",
"admin.entry.desc": "默认管理员账号admin / whoami139",
"admin.entry.login": "去登录",
"admin.entry.user_rating": "用户积分管理",
"admin.entry.redeem": "积分兑换管理",
"admin.entry.logs": "后台任务日志",
"admin.entry.moved_to_platform": "该入口已并入“平台管理”页(/imports。",
};

查看文件

@@ -0,0 +1,19 @@
:root,
:root[data-theme="default"] {
--background: #ffffff;
--foreground: #171717;
--surface: #ffffff;
--surface-soft: #f4f4f5;
--border: #d4d4d8;
}
@media (prefers-color-scheme: dark) {
:root,
:root[data-theme="default"] {
--background: #0a0a0a;
--foreground: #ededed;
--surface: #111111;
--surface-soft: #1f1f1f;
--border: #3f3f46;
}
}

查看文件

@@ -0,0 +1,15 @@
import { enMessages } from "@/themes/default/messages/en";
import { zhMessages } from "@/themes/default/messages/zh";
import type { ThemeDefinition } from "@/themes/types";
export const minecraftTheme: ThemeDefinition = {
id: "minecraft",
labels: {
en: "Minecraft Pixel",
zh: "Minecraft 像素风",
},
messages: {
en: enMessages,
zh: zhMessages,
},
};

查看文件

@@ -0,0 +1,201 @@
@font-face {
font-family: "DelaGothicOne";
src: url("https://assets-persist.lovart.ai/agent-static-assets/DelaGothicOne-Regular.ttf");
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-display: swap;
}
:root[data-theme="minecraft"] {
--mc-grass-top: #5cb85c;
--mc-grass-side: #4cae4c;
--mc-dirt: #795548;
--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;
--background: #1a1a1a;
--foreground: #f5f5f5;
--surface: #2d2d2d;
--surface-soft: #242424;
--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;
color: var(--foreground);
font-family: "MiSans", "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
:root[data-theme="minecraft"] h1,
:root[data-theme="minecraft"] h2,
:root[data-theme="minecraft"] h3,
:root[data-theme="minecraft"] h4,
:root[data-theme="minecraft"] h5,
:root[data-theme="minecraft"] h6 {
color: #ffffff;
font-family: "DelaGothicOne", "MiSansBold", "MiSans", sans-serif;
letter-spacing: 0.04em;
text-shadow: 2px 2px 0 #000000;
}
:root[data-theme="minecraft"] ::-webkit-scrollbar {
width: 12px;
height: 12px;
background: var(--mc-obsidian);
}
:root[data-theme="minecraft"] ::-webkit-scrollbar-thumb {
background: var(--mc-stone);
border: 2px solid var(--mc-obsidian);
}
: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);
}
:root[data-theme="minecraft"] nav.print-hidden.fixed {
background: rgba(33, 33, 33, 0.96) !important;
border-top: 4px solid var(--mc-stone-dark);
}
:root[data-theme="minecraft"] .rounded-xl.border,
:root[data-theme="minecraft"] .rounded-lg.border,
: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);
}
:root[data-theme="minecraft"] .bg-white {
background-color: #2d2d2d !important;
}
:root[data-theme="minecraft"] .bg-zinc-50 {
background-color: #252525 !important;
}
:root[data-theme="minecraft"] .bg-zinc-100 {
background-color: #343434 !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;
}
:root[data-theme="minecraft"] .text-blue-600,
:root[data-theme="minecraft"] .text-blue-700 {
color: var(--mc-diamond) !important;
}
:root[data-theme="minecraft"] .text-emerald-700 {
color: var(--mc-grass-top) !important;
}
:root[data-theme="minecraft"] .text-amber-700,
:root[data-theme="minecraft"] .text-amber-800 {
color: var(--mc-gold) !important;
}
:root[data-theme="minecraft"] .text-red-600,
:root[data-theme="minecraft"] .text-red-700 {
color: var(--mc-redstone) !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;
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;
transition: transform 0.08s ease, filter 0.08s ease;
}
:root[data-theme="minecraft"] button:hover:not(:disabled) {
filter: brightness(1.08);
transform: translateY(-1px);
}
:root[data-theme="minecraft"] button:active:not(:disabled) {
border-bottom-width: 3px !important;
transform: translateY(3px);
}
:root[data-theme="minecraft"] button:disabled {
filter: saturate(0.25);
opacity: 0.7;
}
:root[data-theme="minecraft"] input,
:root[data-theme="minecraft"] textarea,
:root[data-theme="minecraft"] select {
background: #1f1f1f !important;
border: 3px solid #000000 !important;
border-radius: 0 !important;
box-shadow: inset 2px 2px 0 rgba(255, 255, 255, 0.1);
color: #f2f2f2 !important;
}
:root[data-theme="minecraft"] input::placeholder,
:root[data-theme="minecraft"] textarea::placeholder {
color: #acacac;
}
:root[data-theme="minecraft"] a {
color: var(--mc-diamond);
}
:root[data-theme="minecraft"] a:hover {
color: var(--mc-gold);
}
:root[data-theme="minecraft"] table thead {
background: #333333 !important;
}
:root[data-theme="minecraft"] table tr {
border-color: #000000 !important;
}
:root[data-theme="minecraft"] pre {
border: 2px solid #000000;
box-shadow: inset 1px 1px 0 rgba(255, 255, 255, 0.08);
}
:root[data-theme="minecraft"] img {
image-rendering: pixelated;
}
:root[data-theme="minecraft"] .problem-markdown-compact {
font-size: 66%;
}

查看文件

@@ -0,0 +1,21 @@
import { defaultTheme } from "@/themes/default";
import { minecraftTheme } from "@/themes/minecraft";
import { DEFAULT_LANGUAGE, DEFAULT_THEME, type ThemeDefinition, type ThemeId, type UiLanguage } from "@/themes/types";
const themes: ThemeDefinition[] = [defaultTheme, minecraftTheme];
const themeMap = new Map<ThemeId, ThemeDefinition>(themes.map((theme) => [theme.id, theme]));
export function listThemes(): ThemeDefinition[] {
return themes;
}
export function resolveTheme(themeId: string | null | undefined): ThemeDefinition {
if (!themeId) return themeMap.get(DEFAULT_THEME) ?? defaultTheme;
return themeMap.get(themeId as ThemeId) ?? (themeMap.get(DEFAULT_THEME) ?? defaultTheme);
}
export function resolveLanguage(language: string | null | undefined): UiLanguage {
if (language === "zh") return "zh";
return DEFAULT_LANGUAGE;
}

查看文件

@@ -0,0 +1,13 @@
export type ThemeId = "default" | "minecraft";
export type UiLanguage = "en" | "zh";
export const DEFAULT_THEME: ThemeId = "default";
export const DEFAULT_LANGUAGE: UiLanguage = "en";
export type ThemeMessages = Record<string, string>;
export type ThemeDefinition = {
id: ThemeId;
labels: Record<UiLanguage, string>;
messages: Record<UiLanguage, ThemeMessages>;
};