feat: expand platform management, admin controls, and learning workflows
这个提交包含在:
@@ -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>
|
||||
);
|
||||
|
||||
在新工单中引用
屏蔽一个用户