feat: 完成源晶权限与经验系统并优化 me/admin 交互
这个提交包含在:
@@ -4,8 +4,10 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { Edit, Gift, Plus, RefreshCw, ScrollText, Search, Trash2, Coins } from "lucide-react";
|
||||
import { formatUnixDateTime } from "@/lib/time";
|
||||
import { Coins, Edit, Gift, Plus, RefreshCw, ScrollText, Trash2 } from "lucide-react";
|
||||
|
||||
type RedeemItem = {
|
||||
id: number;
|
||||
@@ -58,8 +60,7 @@ const DEFAULT_FORM: ItemForm = {
|
||||
};
|
||||
|
||||
function fmtTs(v: number | null | undefined): string {
|
||||
if (!v) return "-";
|
||||
return new Date(v * 1000).toLocaleString();
|
||||
return formatUnixDateTime(v);
|
||||
}
|
||||
|
||||
function formatDuration(mins: number): string {
|
||||
@@ -179,6 +180,10 @@ export default function AdminRedeemPage() {
|
||||
setMsg("");
|
||||
try {
|
||||
if (!token) throw new Error(tx("请先登录管理员账号", "Please sign in with admin account first"));
|
||||
const ok = window.confirm(
|
||||
tx(`确认下架兑换物品 #${id}?`, `Disable redeem item #${id}?`)
|
||||
);
|
||||
if (!ok) return;
|
||||
await apiFetch(`/api/v1/admin/redeem-items/${id}`, { method: "DELETE" }, token);
|
||||
setMsg(tx(`已下架兑换物品 #${id}`, `Disabled redeem item #${id}`));
|
||||
await load();
|
||||
@@ -193,12 +198,31 @@ export default function AdminRedeemPage() {
|
||||
<Gift size={24} />
|
||||
{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>
|
||||
<div className="mt-1 flex items-center gap-2 text-sm text-zinc-600">
|
||||
<p>{tx("管理兑换物品与全站兑换记录。", "Manage redeem items and global redeem records.")}</p>
|
||||
<HintTip title={tx("管理说明", "Management Notes")} align="left">
|
||||
<ul className="list-disc space-y-1 pl-4">
|
||||
<li>
|
||||
{tx(
|
||||
"物品支持新增、编辑、下架;下架后不会影响历史记录。",
|
||||
"Items support create/update/disable; disabling does not affect historical records."
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{tx(
|
||||
"可同时配置假期/学习日单价与持续时长(永久或分钟)。",
|
||||
"You can configure holiday/study-day cost and duration (permanent or minutes)."
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{tx(
|
||||
"兑换记录支持按 user_id 筛选,便于核对扣分与备注。",
|
||||
"Redeem records can be filtered by user_id for auditing costs and notes."
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</HintTip>
|
||||
</div>
|
||||
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
{msg && <p className="mt-3 text-sm text-emerald-700">{msg}</p>}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { apiFetch, type RatingHistoryItem } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { formatUnixDateTime } from "@/lib/time";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
@@ -30,12 +33,38 @@ type ListResp = {
|
||||
page_size: number;
|
||||
};
|
||||
|
||||
type SourceCrystalSettings = {
|
||||
monthly_interest_rate: number;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
type UserSourceCrystalSummary = {
|
||||
user_id: number;
|
||||
balance: number;
|
||||
monthly_interest_rate: number;
|
||||
last_interest_at: number;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
type UserSourceCrystalRecord = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
tx_type: string;
|
||||
amount: number;
|
||||
balance_after: number;
|
||||
note: string;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type SubmissionRow = {
|
||||
id: number;
|
||||
problem_id: number;
|
||||
status: string;
|
||||
score: number;
|
||||
language: string;
|
||||
rating_delta: number;
|
||||
time_ms: number;
|
||||
memory_kb: number;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
@@ -49,51 +78,171 @@ type RedeemRow = {
|
||||
};
|
||||
|
||||
function fmtTs(v: number): string {
|
||||
if (!v) return "-";
|
||||
return new Date(v * 1000).toLocaleString();
|
||||
return formatUnixDateTime(v);
|
||||
}
|
||||
|
||||
function DetailPanel({ userId, tx }: { userId: number; tx: (zh: string, en: string) => string }) {
|
||||
const [tab, setTab] = useState<"subs" | "rating" | "redeem">("subs");
|
||||
const [tab, setTab] = useState<"subs" | "rating" | "redeem" | "crystal">("subs");
|
||||
const [subRange, setSubRange] = useState<"7d" | "30d" | "all">("all");
|
||||
const [subs, setSubs] = useState<SubmissionRow[]>([]);
|
||||
const [subsPage, setSubsPage] = useState(1);
|
||||
const [subsHasMore, setSubsHasMore] = useState(false);
|
||||
const [subsLoadingMore, setSubsLoadingMore] = useState(false);
|
||||
const [ratingH, setRatingH] = useState<RatingHistoryItem[]>([]);
|
||||
const [redeems, setRedeems] = useState<RedeemRow[]>([]);
|
||||
const [crystalSummary, setCrystalSummary] = useState<UserSourceCrystalSummary | null>(null);
|
||||
const [crystalRecords, setCrystalRecords] = useState<UserSourceCrystalRecord[]>([]);
|
||||
const [crystalAmount, setCrystalAmount] = useState("10");
|
||||
const [crystalNote, setCrystalNote] = useState("");
|
||||
const [crystalSaving, setCrystalSaving] = useState(false);
|
||||
const [crystalMsg, setCrystalMsg] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const token = readToken() ?? "";
|
||||
const subRangeDays = subRange === "7d" ? 7 : subRange === "30d" ? 30 : 0;
|
||||
const subRangeFromTs =
|
||||
subRangeDays > 0
|
||||
? Math.floor(Date.now() / 1000) - subRangeDays * 24 * 60 * 60
|
||||
: 0;
|
||||
const subRangeLabel =
|
||||
subRange === "7d"
|
||||
? tx("近7天", "Last 7 days")
|
||||
: subRange === "30d"
|
||||
? tx("近30天", "Last 30 days")
|
||||
: tx("全部", "All");
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const loadTab = async () => {
|
||||
const subPageSize = 200;
|
||||
const loadSubs = async (page: number, append: boolean) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("user_id", String(userId));
|
||||
params.set("page", String(page));
|
||||
params.set("page_size", String(subPageSize));
|
||||
if (subRangeFromTs > 0) params.set("created_from", String(subRangeFromTs));
|
||||
const d = await apiFetch<{ items: SubmissionRow[] }>(
|
||||
`/api/v1/submissions?${params.toString()}`,
|
||||
undefined,
|
||||
token
|
||||
);
|
||||
const rows = d.items ?? [];
|
||||
setSubs((prev) => (append ? [...prev, ...rows] : rows));
|
||||
setSubsPage(page);
|
||||
setSubsHasMore(rows.length >= subPageSize);
|
||||
};
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (tab === "subs") {
|
||||
const d = await apiFetch<{ items: SubmissionRow[] }>(
|
||||
`/api/v1/submissions?user_id=${userId}&page=1&page_size=50`,
|
||||
undefined, token
|
||||
);
|
||||
setSubs(d.items ?? []);
|
||||
await loadSubs(1, false);
|
||||
} else if (tab === "rating") {
|
||||
const d = await apiFetch<RatingHistoryItem[]>(
|
||||
`/api/v1/admin/users/${userId}/rating-history?limit=100`,
|
||||
undefined, token
|
||||
);
|
||||
setRatingH(Array.isArray(d) ? d : []);
|
||||
} else {
|
||||
} else if (tab === "redeem") {
|
||||
const d = await apiFetch<RedeemRow[]>(
|
||||
`/api/v1/admin/redeem-records?user_id=${userId}&limit=100`,
|
||||
undefined, token
|
||||
);
|
||||
setRedeems(Array.isArray(d) ? d : []);
|
||||
} else {
|
||||
const [summary, records] = await Promise.all([
|
||||
apiFetch<UserSourceCrystalSummary>(
|
||||
`/api/v1/admin/users/${userId}/source-crystal`,
|
||||
undefined,
|
||||
token
|
||||
),
|
||||
apiFetch<UserSourceCrystalRecord[]>(
|
||||
`/api/v1/admin/users/${userId}/source-crystal/records?limit=100`,
|
||||
undefined,
|
||||
token
|
||||
),
|
||||
]);
|
||||
setCrystalSummary(summary ?? null);
|
||||
setCrystalRecords(Array.isArray(records) ? records : []);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false);
|
||||
};
|
||||
void loadTab();
|
||||
}, [tab, userId, token]);
|
||||
}, [tab, userId, token, subRangeFromTs]);
|
||||
|
||||
const loadMoreSubs = async () => {
|
||||
if (tab !== "subs" || !subsHasMore || subsLoadingMore) return;
|
||||
setSubsLoadingMore(true);
|
||||
const nextPage = subsPage + 1;
|
||||
const subPageSize = 200;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set("user_id", String(userId));
|
||||
params.set("page", String(nextPage));
|
||||
params.set("page_size", String(subPageSize));
|
||||
if (subRangeFromTs > 0) params.set("created_from", String(subRangeFromTs));
|
||||
const d = await apiFetch<{ items: SubmissionRow[] }>(
|
||||
`/api/v1/submissions?${params.toString()}`,
|
||||
undefined,
|
||||
token
|
||||
);
|
||||
const rows = d.items ?? [];
|
||||
setSubs((prev) => [...prev, ...rows]);
|
||||
setSubsPage(nextPage);
|
||||
setSubsHasMore(rows.length >= subPageSize);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setSubsLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const tabCls = (t: string) =>
|
||||
`px-3 py-1 text-xs border ${tab === t ? "bg-zinc-900 text-white" : "bg-white text-zinc-700 hover:bg-zinc-100"}`;
|
||||
|
||||
const depositSourceCrystal = async () => {
|
||||
setCrystalMsg("");
|
||||
const amount = Number(crystalAmount);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
setCrystalMsg(tx("请输入大于 0 的存入数量", "Please enter a deposit amount > 0"));
|
||||
return;
|
||||
}
|
||||
setCrystalSaving(true);
|
||||
try {
|
||||
await apiFetch(
|
||||
`/api/v1/admin/users/${userId}/source-crystal/deposit`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ amount, note: crystalNote }),
|
||||
},
|
||||
token
|
||||
);
|
||||
setCrystalNote("");
|
||||
setCrystalMsg(tx("源晶存入成功", "Source crystal deposited"));
|
||||
const [summary, records] = await Promise.all([
|
||||
apiFetch<UserSourceCrystalSummary>(
|
||||
`/api/v1/admin/users/${userId}/source-crystal`,
|
||||
undefined,
|
||||
token
|
||||
),
|
||||
apiFetch<UserSourceCrystalRecord[]>(
|
||||
`/api/v1/admin/users/${userId}/source-crystal/records?limit=100`,
|
||||
undefined,
|
||||
token
|
||||
),
|
||||
]);
|
||||
setCrystalSummary(summary ?? null);
|
||||
setCrystalRecords(Array.isArray(records) ? records : []);
|
||||
} catch (e: unknown) {
|
||||
setCrystalMsg(String(e));
|
||||
} finally {
|
||||
setCrystalSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const crystalBalance = crystalSummary?.balance ?? 0;
|
||||
const crystalMonthlyRate = crystalSummary?.monthly_interest_rate ?? 0;
|
||||
const estimatedMonthlyInterest = Math.max(0, crystalBalance * crystalMonthlyRate);
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-50 border-t p-3 text-xs">
|
||||
<div className="flex gap-1 mb-2">
|
||||
@@ -106,15 +255,50 @@ function DetailPanel({ userId, tx }: { userId: number; tx: (zh: string, en: stri
|
||||
<button className={tabCls("redeem")} onClick={() => setTab("redeem")}>
|
||||
{tx("兑换记录", "Redeem Records")}
|
||||
</button>
|
||||
<button className={tabCls("crystal")} onClick={() => setTab("crystal")}>
|
||||
{tx("源晶管理", "Source Crystal")}
|
||||
</button>
|
||||
</div>
|
||||
{loading && <p className="text-zinc-500">{tx("加载中...", "Loading...")}</p>}
|
||||
{!loading && tab === "subs" && (
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
className={`rounded border px-2 py-1 text-[11px] ${subRange === "7d" ? "bg-zinc-900 text-white" : "bg-white hover:bg-zinc-100"}`}
|
||||
onClick={() => setSubRange("7d")}
|
||||
>
|
||||
{tx("近7天", "Last 7 days")}
|
||||
</button>
|
||||
<button
|
||||
className={`rounded border px-2 py-1 text-[11px] ${subRange === "30d" ? "bg-zinc-900 text-white" : "bg-white hover:bg-zinc-100"}`}
|
||||
onClick={() => setSubRange("30d")}
|
||||
>
|
||||
{tx("近30天", "Last 30 days")}
|
||||
</button>
|
||||
<button
|
||||
className={`rounded border px-2 py-1 text-[11px] ${subRange === "all" ? "bg-zinc-900 text-white" : "bg-white hover:bg-zinc-100"}`}
|
||||
onClick={() => setSubRange("all")}
|
||||
>
|
||||
{tx("全部", "All")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2 text-[11px] text-zinc-500">
|
||||
<span>{tx(`${subRangeLabel} 已加载 ${subs.length} 条`, `${subRangeLabel}: ${subs.length} loaded`)}</span>
|
||||
<Link
|
||||
href={`/submissions?user_id=${userId}${subRangeFromTs > 0 ? `&created_from=${subRangeFromTs}` : ""}`}
|
||||
className="underline text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
{tx("打开完整提交页", "Open full submissions page")}
|
||||
</Link>
|
||||
</div>
|
||||
<table className="min-w-full text-xs">
|
||||
<thead><tr className="text-left text-zinc-500">
|
||||
<th className="pr-2">ID</th><th className="pr-2">{tx("题目", "Problem")}</th>
|
||||
<th className="pr-2">{tx("状态", "Status")}</th><th className="pr-2">{tx("分数", "Score")}</th>
|
||||
<th className="pr-2">ΔR</th>
|
||||
<th className="pr-2">{tx("耗时", "Time")}</th>
|
||||
<th>{tx("时间", "Time")}</th>
|
||||
<th>{tx("详情", "Detail")}</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{subs.map((s) => (
|
||||
@@ -123,12 +307,35 @@ function DetailPanel({ userId, tx }: { userId: number; tx: (zh: string, en: stri
|
||||
<td className="pr-2">P{s.problem_id}</td>
|
||||
<td className={`pr-2 font-bold ${s.status === "AC" ? "text-emerald-600" : "text-red-600"}`}>{s.status}</td>
|
||||
<td className="pr-2">{s.score}</td>
|
||||
<td className={`pr-2 ${s.rating_delta > 0 ? "text-emerald-600" : s.rating_delta < 0 ? "text-red-600" : "text-zinc-500"}`}>
|
||||
{s.rating_delta > 0 ? `+${s.rating_delta}` : s.rating_delta}
|
||||
</td>
|
||||
<td className="pr-2">{s.time_ms}ms</td>
|
||||
<td className="text-zinc-500">{fmtTs(s.created_at)}</td>
|
||||
<td>
|
||||
<Link
|
||||
href={`/submissions/${s.id}`}
|
||||
className="underline text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
{tx("查看", "View")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{subs.length === 0 && <tr><td colSpan={5} className="text-zinc-400 py-2">{tx("无记录", "No records")}</td></tr>}
|
||||
{subs.length === 0 && <tr><td colSpan={8} className="text-zinc-400 py-2">{tx("无记录", "No records")}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{subsHasMore && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
className="rounded border px-2 py-1 text-xs hover:bg-zinc-100 disabled:opacity-60"
|
||||
onClick={() => void loadMoreSubs()}
|
||||
disabled={subsLoadingMore}
|
||||
>
|
||||
{subsLoadingMore ? tx("加载中...", "Loading...") : tx("加载更多", "Load more")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!loading && tab === "rating" && (
|
||||
@@ -168,6 +375,70 @@ function DetailPanel({ userId, tx }: { userId: number; tx: (zh: string, en: stri
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{!loading && tab === "crystal" && (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded border bg-white p-2 text-xs">
|
||||
<p>
|
||||
{tx("当前余额", "Current Balance")}:{" "}
|
||||
<span className="font-bold text-[color:var(--mc-diamond)]">
|
||||
{crystalBalance.toFixed(2)}
|
||||
</span>
|
||||
<span className="ml-2 text-[11px] text-zinc-500">
|
||||
{tx("预计月息", "Est. monthly interest")}: +{estimatedMonthlyInterest.toFixed(2)}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-zinc-500">
|
||||
{tx("月利率", "Monthly rate")}: {(crystalMonthlyRate * 100).toFixed(2)}% ·{" "}
|
||||
{tx("上次计息", "Last interest")}: {crystalSummary ? fmtTs(crystalSummary.last_interest_at) : "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="w-24 rounded border px-2 py-1 text-xs"
|
||||
type="number"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
value={crystalAmount}
|
||||
onChange={(e) => setCrystalAmount(e.target.value)}
|
||||
placeholder={tx("数量", "Amount")}
|
||||
/>
|
||||
<input
|
||||
className="flex-1 min-w-40 rounded border px-2 py-1 text-xs"
|
||||
value={crystalNote}
|
||||
onChange={(e) => setCrystalNote(e.target.value)}
|
||||
placeholder={tx("备注(可选)", "Note (optional)")}
|
||||
/>
|
||||
<button
|
||||
className="rounded border px-2 py-1 text-xs hover:bg-zinc-100 disabled:opacity-60"
|
||||
onClick={() => void depositSourceCrystal()}
|
||||
disabled={crystalSaving}
|
||||
>
|
||||
{crystalSaving ? tx("存入中...", "Depositing...") : tx("管理员存入", "Admin Deposit")}
|
||||
</button>
|
||||
</div>
|
||||
{crystalMsg && <p className="text-xs text-zinc-600">{crystalMsg}</p>}
|
||||
<div className="max-h-48 overflow-y-auto rounded border bg-white p-2">
|
||||
{crystalRecords.map((r) => (
|
||||
<div key={r.id} className="flex justify-between border-b border-zinc-100 py-1 text-xs">
|
||||
<span>
|
||||
<span className={r.amount >= 0 ? "font-bold text-emerald-700" : "font-bold text-red-600"}>
|
||||
{r.amount >= 0 ? "+" : ""}
|
||||
{r.amount.toFixed(2)}
|
||||
</span>
|
||||
<span className="ml-2">{r.tx_type}</span>
|
||||
{r.note ? <span className="ml-2 text-zinc-500">· {r.note}</span> : null}
|
||||
</span>
|
||||
<span className="text-zinc-500">
|
||||
{tx("余额", "Bal")}: {r.balance_after.toFixed(2)} · {fmtTs(r.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{crystalRecords.length === 0 && (
|
||||
<p className="text-xs text-zinc-400">{tx("暂无源晶流水", "No source crystal records")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -179,6 +450,9 @@ export default function AdminUsersPage() {
|
||||
const [error, setError] = useState("");
|
||||
const [msg, setMsg] = useState("");
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [monthlyInterestRate, setMonthlyInterestRate] = useState(0.02);
|
||||
const [rateUpdatedAt, setRateUpdatedAt] = useState<number | null>(null);
|
||||
const [savingRate, setSavingRate] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
@@ -186,8 +460,15 @@ export default function AdminUsersPage() {
|
||||
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);
|
||||
const [data, settings] = await Promise.all([
|
||||
apiFetch<ListResp>("/api/v1/admin/users?page=1&page_size=200", undefined, token),
|
||||
apiFetch<SourceCrystalSettings>("/api/v1/admin/source-crystal/settings", undefined, token),
|
||||
]);
|
||||
setItems(data.items ?? []);
|
||||
setMonthlyInterestRate(
|
||||
Number.isFinite(settings?.monthly_interest_rate) ? settings.monthly_interest_rate : 0.02
|
||||
);
|
||||
setRateUpdatedAt(Number.isFinite(settings?.updated_at) ? settings.updated_at : null);
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
@@ -221,16 +502,67 @@ export default function AdminUsersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const saveMonthlyInterestRate = async () => {
|
||||
setMsg("");
|
||||
setError("");
|
||||
setSavingRate(true);
|
||||
try {
|
||||
const token = readToken();
|
||||
if (!token) throw new Error(tx("请先登录管理员账号", "Please sign in with admin account first"));
|
||||
const nextRate = Math.max(0, Math.min(1, Number(monthlyInterestRate) || 0));
|
||||
const resp = await apiFetch<SourceCrystalSettings>(
|
||||
"/api/v1/admin/source-crystal/settings",
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ monthly_interest_rate: nextRate }),
|
||||
},
|
||||
token
|
||||
);
|
||||
setMonthlyInterestRate(resp.monthly_interest_rate);
|
||||
setRateUpdatedAt(resp.updated_at);
|
||||
setMsg(
|
||||
tx(
|
||||
`已更新源晶月利率为 ${(resp.monthly_interest_rate * 100).toFixed(2)}%`,
|
||||
`Updated source crystal monthly rate to ${(resp.monthly_interest_rate * 100).toFixed(2)}%`
|
||||
)
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setSavingRate(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 flex items-center gap-2">
|
||||
<Users size={24} />
|
||||
{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-2 flex flex-wrap items-center gap-2 text-sm text-zinc-600">
|
||||
<p>
|
||||
{tx(
|
||||
"管理员入口(账号与密码请通过安全渠道配置)",
|
||||
"Admin entry (account/password should be managed via secure channels)"
|
||||
)}
|
||||
</p>
|
||||
<HintTip title={tx("页面说明", "Page Notes")} align="left">
|
||||
<ul className="list-disc space-y-1 pl-4">
|
||||
<li>
|
||||
{tx(
|
||||
"可直接修改并保存用户 Rating,实时刷新统计。",
|
||||
"You can update and save user rating directly with live refresh."
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{tx(
|
||||
"展开详情可查看该用户提交、积分历史、兑换记录。",
|
||||
"Expand a row to inspect submissions, rating history, and redeem records."
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</HintTip>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
@@ -243,6 +575,40 @@ export default function AdminUsersPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="mt-4 rounded-xl border bg-white p-4">
|
||||
<h2 className="text-sm font-semibold">{tx("源晶月利率设置", "Source Crystal Monthly Rate")}</h2>
|
||||
<p className="mt-1 text-xs text-zinc-600">
|
||||
{tx(
|
||||
"默认 0.02(2%/月)。修改后将用于后续计息。",
|
||||
"Default is 0.02 (2%/month). New value applies to future interest accrual."
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="w-36 rounded border px-3 py-2 text-sm"
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.001}
|
||||
value={monthlyInterestRate}
|
||||
onChange={(e) => setMonthlyInterestRate(Number(e.target.value))}
|
||||
/>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{tx("当前显示", "Preview")}: {(Number(monthlyInterestRate) * 100).toFixed(2)}%
|
||||
</span>
|
||||
<button
|
||||
className="rounded border px-3 py-2 text-xs hover:bg-zinc-100 disabled:opacity-60"
|
||||
onClick={() => void saveMonthlyInterestRate()}
|
||||
disabled={savingRate}
|
||||
>
|
||||
{savingRate ? tx("保存中...", "Saving...") : tx("保存月利率", "Save Rate")}
|
||||
</button>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{tx("更新时间", "Updated")}: {rateUpdatedAt ? fmtTs(rateUpdatedAt) : "-"}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{msg && <p className="mt-3 text-sm text-emerald-700">{msg}</p>}
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||
|
||||
export default function AdminEntryPage() {
|
||||
@@ -16,10 +17,12 @@ export default function AdminEntryPage() {
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-10">
|
||||
<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-3 flex items-center gap-2 text-sm text-zinc-600">
|
||||
<p>{t("admin.entry.desc")}</p>
|
||||
<HintTip title="Hint" align="left">
|
||||
<p>{t("admin.entry.moved_to_platform")}</p>
|
||||
</HintTip>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { FileCode, ArrowLeft } from "lucide-react";
|
||||
|
||||
import { API_BASE, apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { API_BASE } from "@/lib/api";
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
|
||||
const SwaggerUI = dynamic(() => import("swagger-ui-react"), { ssr: false });
|
||||
@@ -14,62 +14,6 @@ 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-3 py-5 max-[390px]:px-2 sm:px-4 md:px-6 md:py-6">
|
||||
@@ -83,6 +27,25 @@ export default function ApiDocsPage() {
|
||||
{tx("返回", "Back")}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mb-3 flex items-center gap-2 text-sm text-zinc-600">
|
||||
<p>{tx("查看与调试平台 API。", "Inspect and debug platform APIs.")}</p>
|
||||
<HintTip title={tx("使用说明", "Usage Notes")} align="left">
|
||||
<ul className="list-disc space-y-1 pl-4">
|
||||
<li>
|
||||
{tx(
|
||||
"文档来源于后端 OpenAPI:/api/openapi.json。",
|
||||
"Docs are generated from backend OpenAPI: /api/openapi.json."
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{tx(
|
||||
"鉴权支持 Bearer Token 与 Basic(账号:密码);管理员接口仍需管理员账号。",
|
||||
"Protected APIs support Bearer token and Basic (username:password); admin endpoints still require admin account."
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</HintTip>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-xl border bg-white p-2">
|
||||
<SwaggerUI url={specUrl} docExpansion="list" defaultModelsExpandDepth={1} />
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Eye, EyeOff, Key, LogIn, User, UserPlus } from "lucide-react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { API_BASE, apiFetch } from "@/lib/api";
|
||||
import { readToken, saveToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
@@ -26,8 +27,8 @@ export default function AuthPage() {
|
||||
const [checkingAuth, setCheckingAuth] = useState(true);
|
||||
|
||||
const [mode, setMode] = useState<"register" | "login">("login");
|
||||
const [username, setUsername] = useState(process.env.NEXT_PUBLIC_TEST_USERNAME ?? "");
|
||||
const [password, setPassword] = useState(process.env.NEXT_PUBLIC_TEST_PASSWORD ?? "");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -47,6 +48,7 @@ export default function AuthPage() {
|
||||
mode === "register" && password !== confirmPassword ? tx("两次密码不一致", "Passwords do not match") : "";
|
||||
|
||||
const canSubmit = !loading && !usernameErr && !passwordErr && !confirmErr;
|
||||
const disabledReason = usernameErr || passwordErr || confirmErr;
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit) return;
|
||||
@@ -88,13 +90,14 @@ export default function AuthPage() {
|
||||
<h1 className="text-2xl font-bold text-[color:var(--mc-diamond)] mc-text-shadow leading-relaxed">
|
||||
{tx("欢迎回来,冒险者!", "Welcome Back, Adventurer!")}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm text-[color:var(--mc-stone)] font-mono">
|
||||
{tx("登录服务器以访问任务布告栏、保存冒险进度、查看错题卷轴和个人成就。", "Login to access Quest Board, save Game Progress, review Grimoire, and track Achievements.")}
|
||||
</p>
|
||||
<div className="mt-6 space-y-2 text-sm text-[color:var(--mc-stone)] font-mono">
|
||||
<p>{tx("• 任务按 CSP-J / CSP-S / NOIP 难度分级", "• Quests organized by CSP-J / CSP-S / NOIP Tiers")}</p>
|
||||
<p>{tx("• 任务卷轴支持本地草稿与试炼运行", "• Quest Scrolls support local drafting and trial runs")}</p>
|
||||
<p>{tx("• 先知题解异步生成,包含多种解法", "• Oracles provide asynchronous wisdom with multiple paths")}</p>
|
||||
<div className="mt-3 inline-flex items-center gap-2 text-sm text-[color:var(--mc-stone)] font-mono">
|
||||
<span>{tx("登录后可同步你的学习进度。", "Sign in to sync your learning progress.")}</span>
|
||||
<HintTip title={tx("平台说明", "Platform Overview")} align="left">
|
||||
{tx(
|
||||
"登录后可访问任务布告栏、保存草稿、查看错题卷轴和个人成长。题目按 CSP-J/CSP-S/NOIP 分级,题解支持异步生成。",
|
||||
"After sign-in you can access quests, save drafts, review wrong-book notes, and track growth. Problems are tiered by CSP-J/CSP-S/NOIP, and solutions are generated asynchronously."
|
||||
)}
|
||||
</HintTip>
|
||||
</div>
|
||||
<p className="mt-6 text-xs text-[color:var(--mc-stone-dark)]">
|
||||
Server API: <span className="font-mono text-[color:var(--mc-red)]">{apiBase}</span>
|
||||
@@ -116,7 +119,7 @@ export default function AuthPage() {
|
||||
disabled={loading}
|
||||
>
|
||||
<LogIn size={16} />
|
||||
{tx("登录服务器", "Login")}
|
||||
{tx("已有账号", "Sign In")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -131,7 +134,7 @@ export default function AuthPage() {
|
||||
disabled={loading}
|
||||
>
|
||||
<UserPlus size={16} />
|
||||
{tx("新玩家注册", "New Player")}
|
||||
{tx("新玩家注册", "Create Account")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -197,12 +200,28 @@ export default function AuthPage() {
|
||||
</label>
|
||||
|
||||
<button
|
||||
className={`w-full mc-btn ${mode === "register" ? "mc-btn-success" : ""}`}
|
||||
className={`w-full min-h-[44px] mc-btn ${mode === "register" ? "mc-btn-success" : ""}`}
|
||||
onClick={() => void submit()}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{loading ? tx("连接中...", "Connecting...") : mode === "register" ? tx("创建档案并连接", "Create & Connect") : tx("连接服务器", "Connect")}
|
||||
{loading
|
||||
? tx("连接中...", "Signing in...")
|
||||
: mode === "register"
|
||||
? tx("创建账号", "Create Account")
|
||||
: tx("登录", "Sign In")}
|
||||
</button>
|
||||
{!canSubmit && !loading && disabledReason && (
|
||||
<p className="text-xs text-[color:var(--mc-gold)]">
|
||||
{tx("当前不可提交:", "Cannot submit yet: ")}
|
||||
{disabledReason}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-[color:var(--mc-stone-dark)]">
|
||||
<span>{tx("只想先看看内容?", "Just want to browse first?")}</span>
|
||||
<Link className="underline text-[color:var(--mc-diamond)] hover:text-[color:var(--mc-gold)]" href="/problems">
|
||||
{tx("游客模式", "Guest Mode")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resp && (
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { Activity, AlertCircle, List, Play, RefreshCw, Server, Trash2, Zap } from "lucide-react";
|
||||
import { formatUnixDateTime } from "@/lib/time";
|
||||
import { Activity, AlertCircle, List, RefreshCw, Server, Zap } from "lucide-react";
|
||||
|
||||
type BackendLogItem = {
|
||||
id: number;
|
||||
@@ -75,8 +77,72 @@ type AdminUsersResp = {
|
||||
};
|
||||
|
||||
function fmtTs(v: number | null | undefined): string {
|
||||
if (!v) return "-";
|
||||
return new Date(v * 1000).toLocaleString();
|
||||
return formatUnixDateTime(v);
|
||||
}
|
||||
|
||||
function renderStatusLabel(raw: string, tx: (zhText: string, enText: string) => string): string {
|
||||
const value = raw.toLowerCase();
|
||||
if (value.includes("queue") || value === "queued" || value === "pending") {
|
||||
return tx("排队中", "Queued");
|
||||
}
|
||||
if (value.includes("run") || value === "running" || value === "processing") {
|
||||
return tx("施法中", "Running");
|
||||
}
|
||||
if (value.includes("success") || value === "done" || value === "completed") {
|
||||
return tx("已完成", "Completed");
|
||||
}
|
||||
if (value.includes("fail") || value === "error") {
|
||||
return tx("失败", "Failed");
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function statusToneClass(raw: string): string {
|
||||
const value = raw.toLowerCase();
|
||||
if (value.includes("queue") || value === "queued" || value === "pending") {
|
||||
return "mc-status-warning text-amber-700";
|
||||
}
|
||||
if (value.includes("run") || value === "running" || value === "processing") {
|
||||
return "mc-status-running text-blue-700";
|
||||
}
|
||||
if (value.includes("success") || value === "done" || value === "completed") {
|
||||
return "mc-status-success text-emerald-700";
|
||||
}
|
||||
if (value.includes("fail") || value === "error") {
|
||||
return "mc-status-danger text-red-700";
|
||||
}
|
||||
return "mc-status-muted text-zinc-700";
|
||||
}
|
||||
|
||||
function resolveAccessIssue(
|
||||
hasToken: boolean,
|
||||
rawError: string,
|
||||
tx: (zhText: string, enText: string) => string
|
||||
): { kind: "forbidden" | "expired" | "signin"; title: string; detail: string } {
|
||||
if (!hasToken) {
|
||||
return {
|
||||
kind: "signin",
|
||||
title: tx("登录状态缺失", "Not Signed In"),
|
||||
detail: tx("请先登录,再尝试访问后台日志。", "Please sign in first, then try again."),
|
||||
};
|
||||
}
|
||||
const normalized = rawError.toLowerCase();
|
||||
if (
|
||||
normalized.includes("expired") ||
|
||||
normalized.includes("invalid or expired token") ||
|
||||
normalized.includes("token")
|
||||
) {
|
||||
return {
|
||||
kind: "expired",
|
||||
title: tx("登录已过期", "Session Expired"),
|
||||
detail: tx("当前登录令牌已失效,请重新登录后再访问。", "Your token is no longer valid. Please sign in again."),
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "forbidden",
|
||||
title: tx("权限不足", "No Permission"),
|
||||
detail: tx("该页面仅管理员可见。", "This page is for administrators only."),
|
||||
};
|
||||
}
|
||||
|
||||
export default function BackendLogsPage() {
|
||||
@@ -265,12 +331,35 @@ export default function BackendLogsPage() {
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
const issue = resolveAccessIssue(Boolean(token), error, tx);
|
||||
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>
|
||||
<div className="mt-4 rounded-xl border-[3px] border-black bg-[color:var(--surface)] p-4 text-sm shadow-[4px_4px_0_rgba(0,0,0,0.45)]">
|
||||
<div className="flex items-center gap-2 text-[color:var(--mc-red)]">
|
||||
<AlertCircle size={18} />
|
||||
<p className="font-bold">{issue.title}</p>
|
||||
</div>
|
||||
<p className="mt-2 text-zinc-300">{issue.detail}</p>
|
||||
{!!error && (
|
||||
<details className="mt-3 text-xs text-zinc-400">
|
||||
<summary className="cursor-pointer">{tx("查看原始错误", "Show raw error")}</summary>
|
||||
<pre className="mt-2 overflow-auto whitespace-pre-wrap rounded border border-black bg-black/40 p-2">
|
||||
{error}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Link className="mc-btn mc-btn-primary min-h-[44px]" href="/problems">
|
||||
{tx("返回任务板", "Back to Quest Board")}
|
||||
</Link>
|
||||
<Link className="mc-btn min-h-[44px]" href="/auth">
|
||||
{issue.kind === "expired" || issue.kind === "signin"
|
||||
? tx("重新登录", "Sign In Again")
|
||||
: tx("切换管理员账号", "Switch Admin Account")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -316,12 +405,31 @@ export default function BackendLogsPage() {
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
{triggerMsg && <p className="mt-3 text-sm text-emerald-700">{triggerMsg}</p>}
|
||||
{userMsg && <p className="mt-3 text-sm text-emerald-700">{userMsg}</p>}
|
||||
<p className="mt-3 text-xs text-zinc-500">
|
||||
{tx(
|
||||
"系统已自动单线程异步处理待队列任务,无需手工点击;上方按钮仅用于立即手动补全。",
|
||||
"System auto-processes queued jobs in single-thread async mode; the button above is only for manual trigger."
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-zinc-500">
|
||||
<p>{tx("队列会自动异步处理。", "Queue is processed automatically in async mode.")}</p>
|
||||
<HintTip title={tx("队列说明", "Queue Notes")} align="left">
|
||||
<ul className="list-disc space-y-1 pl-4">
|
||||
<li>
|
||||
{tx(
|
||||
"页面每 5 秒自动刷新一次运行态与排队态。",
|
||||
"This page auto-refreshes running and queued states every 5 seconds."
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{tx(
|
||||
"“手动补全”只是在当前时刻额外触发一次补题,不影响后台常驻处理。",
|
||||
"\"Manual fill\" triggers one extra generation batch and does not replace background processing."
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{tx(
|
||||
"删除用户会级联删除其提交、错题本、草稿与积分记录。",
|
||||
"Deleting a user cascades submissions, wrong-book, drafts, and rating records."
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</HintTip>
|
||||
</div>
|
||||
|
||||
<section className="mt-4 rounded-xl border bg-white p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
@@ -420,7 +528,12 @@ export default function BackendLogsPage() {
|
||||
{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)}
|
||||
{tx("状态", "Status")}{" "}
|
||||
<span className={statusToneClass(job.status)}>
|
||||
{renderStatusLabel(job.status, tx)} ({job.status})
|
||||
</span>{" "}
|
||||
· {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>
|
||||
@@ -445,7 +558,12 @@ export default function BackendLogsPage() {
|
||||
{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)}
|
||||
{tx("状态", "Status")}{" "}
|
||||
<span className={statusToneClass(job.status)}>
|
||||
{renderStatusLabel(job.status, tx)} ({job.status})
|
||||
</span>{" "}
|
||||
· {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>
|
||||
@@ -464,7 +582,10 @@ export default function BackendLogsPage() {
|
||||
{tx("任务", "Job")} #{item.id}
|
||||
</p>
|
||||
<span className={item.runner_pending ? "text-emerald-700" : "text-zinc-700"}>
|
||||
{item.status} · {item.progress}%
|
||||
<span className={statusToneClass(item.status)}>
|
||||
{renderStatusLabel(item.status, tx)} ({item.status})
|
||||
</span>{" "}
|
||||
· {item.progress}%
|
||||
</span>
|
||||
</div>
|
||||
<Link className="text-blue-600 hover:underline" href={`/problems/${item.problem_id}`}>
|
||||
@@ -504,7 +625,9 @@ export default function BackendLogsPage() {
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<span className={item.runner_pending ? "text-emerald-700" : "text-zinc-700"}>
|
||||
{item.status}
|
||||
<span className={statusToneClass(item.status)}>
|
||||
{renderStatusLabel(item.status, tx)} ({item.status})
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2">{item.progress}%</td>
|
||||
|
||||
@@ -4,9 +4,12 @@ import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { PageCrumbs } from "@/components/page-crumbs";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { formatUnixDateTime } from "@/lib/time";
|
||||
|
||||
type Contest = {
|
||||
id: number;
|
||||
@@ -81,9 +84,35 @@ export default function ContestDetailPage() {
|
||||
|
||||
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">
|
||||
<PageCrumbs
|
||||
items={[
|
||||
{ label: tx("主城", "Town"), href: "/" },
|
||||
{ label: tx("副本", "Dungeon"), href: "/contests" },
|
||||
{ label: `#${contestId}` },
|
||||
]}
|
||||
/>
|
||||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
||||
{tx("比赛详情", "Contest Detail")} #{contestId}
|
||||
</h1>
|
||||
<div className="mt-2 flex items-center gap-2 text-sm text-zinc-600">
|
||||
<p>{tx("查看赛程、题目与当前榜单。", "View schedule, problems, and current leaderboard.")}</p>
|
||||
<HintTip title={tx("页面说明", "Page Notes")} align="left">
|
||||
<ul className="list-disc space-y-1 pl-4">
|
||||
<li>
|
||||
{tx(
|
||||
"可报名后再开始提交;重复点击报名用于刷新状态。",
|
||||
"Register first before submitting; clicking register again refreshes status."
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{tx(
|
||||
"Penalty 为罚时秒数,Solved 为已解题数。",
|
||||
"Penalty is time penalty in seconds, Solved is number of solved problems."
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</HintTip>
|
||||
</div>
|
||||
{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>}
|
||||
|
||||
@@ -92,12 +121,17 @@ export default function ContestDetailPage() {
|
||||
<section className="rounded-xl border bg-white p-4">
|
||||
<h2 className="text-lg font-medium">{detail.contest.title}</h2>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{new Date(detail.contest.starts_at * 1000).toLocaleString()} - {" "}
|
||||
{new Date(detail.contest.ends_at * 1000).toLocaleString()}
|
||||
{formatUnixDateTime(detail.contest.starts_at)} - {" "}
|
||||
{formatUnixDateTime(detail.contest.ends_at)}
|
||||
</p>
|
||||
<pre className="mt-3 overflow-x-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
|
||||
{detail.contest.rule_json}
|
||||
</pre>
|
||||
<details className="mt-3 rounded border border-zinc-200 bg-zinc-50 p-2">
|
||||
<summary className="cursor-pointer text-xs font-medium text-zinc-700">
|
||||
{tx("查看完整规则 JSON", "View full rule JSON")}
|
||||
</summary>
|
||||
<pre className="mt-2 overflow-x-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
|
||||
{detail.contest.rule_json}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<button
|
||||
className="mt-3 w-full rounded bg-zinc-900 px-4 py-2 text-white sm:w-auto"
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { formatUnixDateTime } from "@/lib/time";
|
||||
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||
import { Calendar, Swords, Timer, Trophy, Shield } from "lucide-react";
|
||||
import { Calendar, Shield, Swords, Timer } from "lucide-react";
|
||||
|
||||
type Contest = {
|
||||
id: number;
|
||||
@@ -47,6 +49,12 @@ export default function ContestsPage() {
|
||||
<span className="flex items-center gap-2">
|
||||
<Swords size={24} />
|
||||
{tx("突袭公告板", "Raid Board")}
|
||||
<HintTip title={tx("比赛说明", "Contest Notes")} align="left">
|
||||
{tx(
|
||||
"比赛页展示时间窗口与入口。进入详情后可报名、查看题单和排行榜。",
|
||||
"This page shows contest windows and entries. Open contest details to register, view problem sets, and check leaderboard."
|
||||
)}
|
||||
</HintTip>
|
||||
</span>
|
||||
) : (
|
||||
tx("模拟竞赛", "Contests")
|
||||
@@ -74,11 +82,11 @@ export default function ContestsPage() {
|
||||
<div className={`mt-2 text-xs flex flex-col gap-1 ${isMc ? "text-zinc-400" : "text-zinc-500"}`}>
|
||||
<p className="flex items-center gap-2">
|
||||
{isMc && <Calendar size={14} />}
|
||||
{tx("开始", "Start")}: {new Date(c.starts_at * 1000).toLocaleString()}
|
||||
{tx("开始", "Start")}: {formatUnixDateTime(c.starts_at)}
|
||||
</p>
|
||||
<p className="flex items-center gap-2">
|
||||
{isMc && <Timer size={14} />}
|
||||
{tx("结束", "End")}: {new Date(c.ends_at * 1000).toLocaleString()}
|
||||
{tx("结束", "End")}: {formatUnixDateTime(c.ends_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { Activity, HardDrive, Play, RefreshCw, Server, FileText, CheckCircle, XCircle, Clock } from "lucide-react";
|
||||
import { formatUnixDateTime } from "@/lib/time";
|
||||
import { Activity, HardDrive, Play, RefreshCw, Server } from "lucide-react";
|
||||
|
||||
type ImportJob = {
|
||||
id: number;
|
||||
@@ -55,8 +57,7 @@ type MeProfile = {
|
||||
};
|
||||
|
||||
function fmtTs(v: number | null | undefined): string {
|
||||
if (!v) return "-";
|
||||
return new Date(v * 1000).toLocaleString();
|
||||
return formatUnixDateTime(v);
|
||||
}
|
||||
|
||||
type ImportJobOptions = {
|
||||
@@ -78,6 +79,23 @@ function parseOptions(raw: string): ImportJobOptions | null {
|
||||
}
|
||||
}
|
||||
|
||||
function statusToneClass(raw: string): string {
|
||||
const value = raw.toLowerCase();
|
||||
if (value.includes("queue") || value === "queued" || value === "pending") {
|
||||
return "mc-status-warning text-amber-700";
|
||||
}
|
||||
if (value.includes("run") || value === "running" || value === "processing") {
|
||||
return "mc-status-running text-blue-700";
|
||||
}
|
||||
if (value.includes("success") || value === "done" || value === "completed") {
|
||||
return "mc-status-success text-emerald-700";
|
||||
}
|
||||
if (value.includes("fail") || value === "failed" || value === "error") {
|
||||
return "mc-status-danger text-red-700";
|
||||
}
|
||||
return "mc-status-muted text-zinc-700";
|
||||
}
|
||||
|
||||
export default function ImportsPage() {
|
||||
const { tx } = useI18nText();
|
||||
const [token, setToken] = useState("");
|
||||
@@ -254,15 +272,26 @@ export default function ImportsPage() {
|
||||
|
||||
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 flex items-center gap-2">
|
||||
<HardDrive size={24} />
|
||||
{tx("题库导入/出题任务", "Import / Generation Jobs")}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl flex items-center gap-2">
|
||||
<HardDrive size={24} />
|
||||
{tx("题库导入/出题任务", "Import / Generation Jobs")}
|
||||
</h1>
|
||||
<HintTip title={tx("管理说明", "Management Guide")}>
|
||||
{tx(
|
||||
"该页面仅管理员可用。支持 Luogu 导入与本地 PDF + RAG 出题两种模式,建议先小规模验证再扩大批量。",
|
||||
"Admin-only page. Supports Luogu import and Local PDF + RAG generation. Start with a small batch before scaling up."
|
||||
)}
|
||||
</HintTip>
|
||||
</div>
|
||||
|
||||
<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")}
|
||||
{tx(
|
||||
"管理员凭据已配置,请使用授权账号登录。",
|
||||
"Admin credentials are configured separately. Sign in with an authorized account."
|
||||
)}
|
||||
</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">
|
||||
@@ -369,22 +398,24 @@ export default function ImportsPage() {
|
||||
{running ? tx("运行中", "Running") : tx("空闲", "Idle")}
|
||||
</span>
|
||||
</div>
|
||||
{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 className="mt-2 text-xs text-zinc-500 inline-flex items-center gap-2">
|
||||
<span>
|
||||
{runMode === "luogu"
|
||||
? tx("当前模式:Luogu 标签导入。", "Mode: Luogu tag import.")
|
||||
: tx("当前模式:本地 PDF + RAG 出题。", "Mode: Local PDF + RAG generation.")}
|
||||
</span>
|
||||
<HintTip title={tx("模式说明", "Mode Notes")} align="left">
|
||||
{runMode === "luogu"
|
||||
? tx(
|
||||
"抓取洛谷 CSP-J/CSP-S/NOIP 标签题。可按需选择启动前清空历史题库。",
|
||||
"Fetch Luogu problems tagged CSP-J/CSP-S/NOIP. You can choose to clear historical problem sets before start."
|
||||
)
|
||||
: tx(
|
||||
"从本地 PDF 提取文本做 RAG,再调用 LLM 生成题目。系统会按目标规模与去重策略补齐题库。",
|
||||
"Extract text from local PDFs for RAG and generate problems with LLM. The system fills the set based on target size and dedupe strategy."
|
||||
)}
|
||||
</HintTip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
@@ -395,7 +426,8 @@ export default function ImportsPage() {
|
||||
{job && (
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
<p>
|
||||
{tx("任务", "Job")} #{job.id} · {tx("状态", "Status")} <b>{job.status}</b> · {tx("触发方式", "Trigger")} {job.trigger}
|
||||
{tx("任务", "Job")} #{job.id} · {tx("状态", "Status")}{" "}
|
||||
<b className={statusToneClass(job.status)}>{job.status}</b> · {tx("触发方式", "Trigger")} {job.trigger}
|
||||
</p>
|
||||
<p className="text-zinc-600">
|
||||
{tx("模式", "Mode")} {jobOpts?.mode || jobOpts?.source || "luogu"} · {tx("线程", "Workers")} {jobOpts?.workers ?? "-"}
|
||||
@@ -452,7 +484,7 @@ export default function ImportsPage() {
|
||||
<p className="font-medium">
|
||||
{tx("明细", "Detail")} #{item.id}
|
||||
</p>
|
||||
<span>{item.status}</span>
|
||||
<span className={statusToneClass(item.status)}>{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>
|
||||
@@ -487,7 +519,7 @@ export default function ImportsPage() {
|
||||
{item.source_path}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-2">{item.status}</td>
|
||||
<td className={`px-2 py-2 ${statusToneClass(item.status)}`}>{item.status}</td>
|
||||
<td className="max-w-[220px] px-2 py-2">
|
||||
<div className="truncate" title={item.title}>
|
||||
{item.title || "-"}
|
||||
|
||||
@@ -4,9 +4,14 @@ import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { MarkdownRenderer } from "@/components/markdown-renderer";
|
||||
import { PageCrumbs } from "@/components/page-crumbs";
|
||||
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { formatUnixDateTime } from "@/lib/time";
|
||||
|
||||
type Article = {
|
||||
id: number;
|
||||
@@ -16,19 +21,100 @@ type Article = {
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type SkillPoint = {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
difficulty: string;
|
||||
reward: number;
|
||||
prerequisites?: string[];
|
||||
};
|
||||
|
||||
type DetailResp = {
|
||||
article: Article;
|
||||
related_problems: { problem_id: number; title: string }[];
|
||||
skill_points: SkillPoint[];
|
||||
};
|
||||
|
||||
type ClaimListResp = {
|
||||
article_id: number;
|
||||
slug: string;
|
||||
total_reward: number;
|
||||
total_count: number;
|
||||
claimed_keys: string[];
|
||||
};
|
||||
|
||||
type ClaimResp = {
|
||||
article_id: number;
|
||||
slug: string;
|
||||
knowledge_key: string;
|
||||
claimed: boolean;
|
||||
reward: number;
|
||||
rating_after: number;
|
||||
total_claimed: number;
|
||||
};
|
||||
|
||||
type WeeklyTask = {
|
||||
id: number;
|
||||
week_key: string;
|
||||
article_id: number;
|
||||
article_slug: string;
|
||||
article_title: string;
|
||||
knowledge_key: string;
|
||||
knowledge_title: string;
|
||||
knowledge_description: string;
|
||||
difficulty: string;
|
||||
reward: number;
|
||||
prerequisites: string[];
|
||||
completed: boolean;
|
||||
completed_at: number | null;
|
||||
};
|
||||
|
||||
type WeeklyPlanResp = {
|
||||
week_key: string;
|
||||
tasks: WeeklyTask[];
|
||||
total_reward: number;
|
||||
gained_reward: number;
|
||||
bonus_reward: number;
|
||||
bonus_claimed: boolean;
|
||||
completion_percent: number;
|
||||
};
|
||||
|
||||
type WeeklyBonusResp = {
|
||||
claimed: boolean;
|
||||
reward: number;
|
||||
rating_after: number;
|
||||
completion_percent: number;
|
||||
week_key: string;
|
||||
};
|
||||
|
||||
function difficultyClass(level: string): string {
|
||||
const lv = level.toLowerCase();
|
||||
if (lv === "bronze") return "text-[color:var(--mc-wood)]";
|
||||
if (lv === "silver") return "text-zinc-300";
|
||||
if (lv === "gold") return "text-[color:var(--mc-gold)]";
|
||||
return "text-[color:var(--mc-stone)]";
|
||||
}
|
||||
|
||||
export default function KbDetailPage() {
|
||||
const { tx } = useI18nText();
|
||||
const { theme } = useUiPreferences();
|
||||
const isMc = theme === "minecraft";
|
||||
const params = useParams<{ slug: string }>();
|
||||
const slug = useMemo(() => params.slug, [params.slug]);
|
||||
|
||||
const [data, setData] = useState<DetailResp | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [claimedKeys, setClaimedKeys] = useState<Set<string>>(new Set());
|
||||
const [claimTotalReward, setClaimTotalReward] = useState(0);
|
||||
const [claimLoadingKey, setClaimLoadingKey] = useState("");
|
||||
const [claimMsg, setClaimMsg] = useState("");
|
||||
const [weeklyPlan, setWeeklyPlan] = useState<WeeklyPlanResp | null>(null);
|
||||
const [weeklyLoading, setWeeklyLoading] = useState(false);
|
||||
const [weeklyError, setWeeklyError] = useState("");
|
||||
const [weeklyHint, setWeeklyHint] = useState("");
|
||||
const [weeklyClaiming, setWeeklyClaiming] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
@@ -46,8 +132,130 @@ export default function KbDetailPage() {
|
||||
if (slug) void load();
|
||||
}, [slug]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
const token = readToken();
|
||||
if (!token) {
|
||||
setClaimedKeys(new Set());
|
||||
setClaimTotalReward(0);
|
||||
setWeeklyPlan(null);
|
||||
setWeeklyError("");
|
||||
return;
|
||||
}
|
||||
void apiFetch<ClaimListResp>(`/api/v1/kb/articles/${slug}/claims`, undefined, token)
|
||||
.then((resp) => {
|
||||
setClaimedKeys(new Set(resp.claimed_keys ?? []));
|
||||
setClaimTotalReward(resp.total_reward ?? 0);
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore auth/api errors for non-blocking reading
|
||||
});
|
||||
|
||||
setWeeklyLoading(true);
|
||||
setWeeklyError("");
|
||||
void apiFetch<WeeklyPlanResp>("/api/v1/kb/weekly-plan", {}, token)
|
||||
.then((plan) => setWeeklyPlan(plan))
|
||||
.catch((e: unknown) => {
|
||||
setWeeklyPlan(null);
|
||||
setWeeklyError(String(e));
|
||||
})
|
||||
.finally(() => setWeeklyLoading(false));
|
||||
}, [slug]);
|
||||
|
||||
const claimSkill = async (skillKey: string) => {
|
||||
const token = readToken();
|
||||
if (!token) {
|
||||
setClaimMsg(tx("请先登录后领取知识点奖励。", "Please sign in before claiming skill points."));
|
||||
return;
|
||||
}
|
||||
setClaimLoadingKey(skillKey);
|
||||
setClaimMsg("");
|
||||
try {
|
||||
const resp = await apiFetch<ClaimResp>(
|
||||
`/api/v1/kb/articles/${slug}/claim`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ knowledge_key: skillKey }),
|
||||
},
|
||||
token
|
||||
);
|
||||
if (resp.claimed) {
|
||||
setClaimedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(skillKey);
|
||||
return next;
|
||||
});
|
||||
setClaimTotalReward((v) => v + (resp.reward ?? 0));
|
||||
setClaimMsg(
|
||||
tx(
|
||||
`领取成功:+${resp.reward} 知识点积分(当前积分 ${resp.rating_after})`,
|
||||
`Claimed: +${resp.reward} knowledge points (rating ${resp.rating_after})`
|
||||
)
|
||||
);
|
||||
const refreshToken = readToken();
|
||||
if (refreshToken) {
|
||||
void apiFetch<WeeklyPlanResp>("/api/v1/kb/weekly-plan", {}, refreshToken)
|
||||
.then((plan) => setWeeklyPlan(plan))
|
||||
.catch(() => {
|
||||
// keep current UI when weekly refresh fails
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setClaimMsg(tx("该知识点已领取过。", "This skill point is already claimed."));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setClaimMsg(String(e));
|
||||
} finally {
|
||||
setClaimLoadingKey("");
|
||||
}
|
||||
};
|
||||
|
||||
const claimWeeklyBonus = async () => {
|
||||
const token = readToken();
|
||||
if (!token) {
|
||||
setWeeklyHint(tx("请先登录。", "Please sign in first."));
|
||||
return;
|
||||
}
|
||||
setWeeklyClaiming(true);
|
||||
setWeeklyError("");
|
||||
setWeeklyHint("");
|
||||
try {
|
||||
const result = await apiFetch<WeeklyBonusResp>(
|
||||
"/api/v1/kb/weekly-bonus/claim",
|
||||
{ method: "POST", body: JSON.stringify({}) },
|
||||
token
|
||||
);
|
||||
setWeeklyHint(
|
||||
result.claimed
|
||||
? tx(
|
||||
`领取成功:+${result.reward} 周奖励积分(当前积分 ${result.rating_after})`,
|
||||
`Claimed: +${result.reward} weekly bonus (rating ${result.rating_after})`
|
||||
)
|
||||
: tx("本周奖励已领取过。", "Weekly bonus already claimed.")
|
||||
);
|
||||
const latest = await apiFetch<WeeklyPlanResp>("/api/v1/kb/weekly-plan", {}, token);
|
||||
setWeeklyPlan(latest);
|
||||
} catch (e: unknown) {
|
||||
setWeeklyError(String(e));
|
||||
} finally {
|
||||
setWeeklyClaiming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const relatedWeeklyTasks = useMemo(() => {
|
||||
if (!weeklyPlan || !data) return [];
|
||||
return weeklyPlan.tasks.filter((task) => task.article_slug === data.article.slug);
|
||||
}, [data, weeklyPlan]);
|
||||
|
||||
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">
|
||||
<PageCrumbs
|
||||
items={[
|
||||
{ label: tx("主城", "Town"), href: "/" },
|
||||
{ label: tx("附魔", "Enchant"), href: "/kb" },
|
||||
{ label: data?.article.title || slug },
|
||||
]}
|
||||
/>
|
||||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
||||
{tx("知识库文章", "Knowledge Article")}
|
||||
</h1>
|
||||
@@ -56,17 +264,189 @@ export default function KbDetailPage() {
|
||||
|
||||
{data && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<section
|
||||
className={`p-4 ${
|
||||
isMc
|
||||
? "rounded-none border-[3px] border-black bg-[color:var(--mc-card)] shadow-[6px_6px_0_rgba(0,0,0,0.48)]"
|
||||
: "rounded-xl border bg-white"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className={`text-sm font-medium inline-flex items-center gap-2 ${
|
||||
isMc ? "text-[color:var(--mc-text-main)]" : ""
|
||||
}`}>
|
||||
{tx("本周学习进度", "Weekly Progress")}
|
||||
<HintTip title={tx("进度说明", "Progress Notes")} align="left">
|
||||
{tx(
|
||||
"完成所有周任务后可领取 100% 奖励。当前会额外显示与本文章相关的周任务,方便边学边完成。",
|
||||
"Complete all weekly tasks to claim the 100% bonus. This panel highlights tasks related to this article so you can learn and complete them together."
|
||||
)}
|
||||
</HintTip>
|
||||
</h3>
|
||||
{weeklyPlan && (
|
||||
<span className={`text-xs ${isMc ? "text-[color:var(--mc-text-muted)]" : "text-zinc-500"}`}>
|
||||
{tx("周起始", "Week")} {weeklyPlan.week_key}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{weeklyLoading && (
|
||||
<p className={`mt-2 text-xs ${isMc ? "text-[color:var(--mc-text-dim)]" : "text-zinc-500"}`}>
|
||||
{tx("加载周任务中...", "Loading weekly plan...")}
|
||||
</p>
|
||||
)}
|
||||
{weeklyError && <p className="mt-2 text-xs text-red-600">{weeklyError}</p>}
|
||||
{weeklyPlan && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className={`text-xs flex flex-wrap items-center justify-between gap-2 ${
|
||||
isMc ? "text-[color:var(--mc-text-muted)]" : "text-zinc-600"
|
||||
}`}>
|
||||
<span>
|
||||
{tx("完成度", "Progress")}:{" "}
|
||||
<span className={isMc ? "font-semibold text-[color:var(--mc-text-main)]" : "font-semibold text-zinc-900"}>
|
||||
{weeklyPlan.completion_percent}%
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
{tx("任务积分", "Task Reward")}:{" "}
|
||||
<span className={isMc ? "font-semibold text-[color:var(--mc-text-main)]" : "font-semibold text-zinc-900"}>
|
||||
{weeklyPlan.gained_reward}/{weeklyPlan.total_reward}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
{tx("周奖励", "Bonus")}:{" "}
|
||||
<span className={isMc ? "font-semibold text-[color:var(--mc-warning)]" : "font-semibold text-[color:var(--mc-gold)]"}>
|
||||
+{weeklyPlan.bonus_reward}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={`h-2 w-full overflow-hidden rounded-full ${isMc ? "bg-black/50" : "bg-zinc-200"}`}>
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
isMc
|
||||
? "bg-gradient-to-r from-[color:var(--mc-success)] to-[color:var(--mc-warning)]"
|
||||
: "bg-gradient-to-r from-emerald-500 to-amber-500"
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, Math.max(0, weeklyPlan.completion_percent))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className={`text-xs ${isMc ? "text-[color:var(--mc-text-muted)]" : "text-zinc-600"}`}>
|
||||
{weeklyPlan.bonus_claimed
|
||||
? tx("本周 100% 奖励已领取", "100% weekly bonus already claimed")
|
||||
: tx("完成全部任务后可领取 100% 奖励", "Claim 100% bonus after all tasks are completed")}
|
||||
</span>
|
||||
<button
|
||||
className={`min-h-[44px] text-xs disabled:opacity-50 ${
|
||||
isMc ? "mc-btn mc-btn-warning" : "mc-btn mc-btn-primary"
|
||||
}`}
|
||||
disabled={
|
||||
weeklyClaiming || weeklyPlan.bonus_claimed || weeklyPlan.completion_percent < 100
|
||||
}
|
||||
onClick={() => void claimWeeklyBonus()}
|
||||
>
|
||||
{weeklyClaiming ? tx("领取中...", "Claiming...") : tx("领取 100% 奖励", "Claim 100% Bonus")}
|
||||
</button>
|
||||
</div>
|
||||
{weeklyHint && (
|
||||
<p className={`text-xs ${isMc ? "text-[color:var(--mc-success)]" : "text-emerald-700"}`}>
|
||||
{weeklyHint}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{relatedWeeklyTasks.length > 0 && (
|
||||
<div className={`rounded border p-3 ${
|
||||
isMc
|
||||
? "border-[color:var(--mc-border-soft)] bg-[color:var(--mc-card-inner)]"
|
||||
: "border-zinc-200 bg-zinc-50"
|
||||
}`}>
|
||||
<p className={`text-xs font-medium ${isMc ? "text-[color:var(--mc-text-main)]" : "text-zinc-700"}`}>
|
||||
{tx("本文章相关周任务", "Weekly Tasks Related to This Article")}
|
||||
</p>
|
||||
<ul className={`mt-2 space-y-1 text-xs ${isMc ? "text-[color:var(--mc-text-muted)]" : "text-zinc-600"}`}>
|
||||
{relatedWeeklyTasks.map((task) => (
|
||||
<li key={task.id}>
|
||||
{task.completed ? "✅" : "⬜"} {task.knowledge_title} (+{task.reward})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border bg-white p-4">
|
||||
<h2 className="text-xl font-medium">{data.article.title}</h2>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{tx("更新时间:", "Updated: ")}
|
||||
{new Date(data.article.created_at * 1000).toLocaleString()}
|
||||
{formatUnixDateTime(data.article.created_at)}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<MarkdownRenderer markdown={data.article.content_md} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{data.skill_points.length > 0 && (
|
||||
<section className="rounded-xl border bg-white p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium inline-flex items-center gap-2">
|
||||
{tx("技能打卡与积分奖励", "Skill Checkpoints & Rewards")}
|
||||
<HintTip title={tx("领取规则", "Claim Rules")} align="left">
|
||||
{tx(
|
||||
"每个知识点仅可领取一次。存在前置依赖时,必须先完成前置点才可领取后续点。",
|
||||
"Each skill point can be claimed once. If prerequisites exist, complete them first before claiming follow-up points."
|
||||
)}
|
||||
</HintTip>
|
||||
</h3>
|
||||
<span className="text-xs text-[color:var(--mc-gold)]">
|
||||
{tx("已领取积分", "Claimed Points")}: {claimTotalReward}
|
||||
</span>
|
||||
</div>
|
||||
{claimMsg && (
|
||||
<p className="mt-2 text-xs text-[color:var(--mc-diamond)]">{claimMsg}</p>
|
||||
)}
|
||||
<div className="mt-3 space-y-2">
|
||||
{data.skill_points.map((point) => {
|
||||
const done = claimedKeys.has(point.key);
|
||||
return (
|
||||
<article key={point.key} className="rounded border border-black/20 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm font-semibold">
|
||||
{done ? "✅" : "⬜"} {point.title}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs ${difficultyClass(point.difficulty)}`}>
|
||||
{point.difficulty}
|
||||
</span>
|
||||
<span className="text-xs text-[color:var(--mc-gold)]">+{point.reward}</span>
|
||||
<button
|
||||
className={`mc-btn min-h-[44px] text-xs ${done ? "" : "mc-btn-primary"}`}
|
||||
onClick={() => void claimSkill(point.key)}
|
||||
disabled={done || claimLoadingKey === point.key}
|
||||
>
|
||||
{done
|
||||
? tx("已领取", "Claimed")
|
||||
: claimLoadingKey === point.key
|
||||
? tx("领取中...", "Claiming...")
|
||||
: tx("领取奖励", "Claim")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-600">{point.description}</p>
|
||||
{(point.prerequisites?.length ?? 0) > 0 && (
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
{tx("前置:", "Prerequisites: ")}
|
||||
{point.prerequisites?.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-[11px] text-zinc-500">ID: {point.key}</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="rounded-xl border bg-white p-4">
|
||||
<h3 className="text-sm font-medium">{tx("关联题目", "Related Problems")}</h3>
|
||||
{data.related_problems.length ? (
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { formatUnixDateTime } from "@/lib/time";
|
||||
import {
|
||||
Book,
|
||||
CheckCircle2,
|
||||
Code2,
|
||||
FileQuestion,
|
||||
Library,
|
||||
@@ -15,6 +19,8 @@ import {
|
||||
RefreshCw,
|
||||
Shield,
|
||||
Sword,
|
||||
Target,
|
||||
Trophy,
|
||||
} from "lucide-react";
|
||||
|
||||
type Article = {
|
||||
@@ -38,14 +44,51 @@ type TriggerKbRefreshResp = KbRefreshStatus & {
|
||||
message: string;
|
||||
};
|
||||
|
||||
type WeeklyTask = {
|
||||
id: number;
|
||||
week_key: string;
|
||||
article_id: number;
|
||||
article_slug: string;
|
||||
article_title: string;
|
||||
knowledge_key: string;
|
||||
knowledge_title: string;
|
||||
knowledge_description: string;
|
||||
difficulty: string;
|
||||
reward: number;
|
||||
prerequisites: string[];
|
||||
completed: boolean;
|
||||
completed_at: number | null;
|
||||
};
|
||||
|
||||
type WeeklyPlanResp = {
|
||||
week_key: string;
|
||||
tasks: WeeklyTask[];
|
||||
total_reward: number;
|
||||
gained_reward: number;
|
||||
bonus_reward: number;
|
||||
bonus_claimed: boolean;
|
||||
completion_percent: number;
|
||||
};
|
||||
|
||||
type WeeklyBonusResp = {
|
||||
claimed: boolean;
|
||||
reward: number;
|
||||
rating_after: number;
|
||||
completion_percent: number;
|
||||
week_key: string;
|
||||
};
|
||||
|
||||
function fmtTs(v: number | null | undefined): string {
|
||||
if (!v) return "-";
|
||||
return new Date(v * 1000).toLocaleString();
|
||||
return formatUnixDateTime(v);
|
||||
}
|
||||
|
||||
export default function KbListPage() {
|
||||
const { tx } = useI18nText();
|
||||
const { theme } = useUiPreferences();
|
||||
const isMc = theme === "minecraft";
|
||||
const router = useRouter();
|
||||
const [refreshToken, setRefreshToken] = useState("");
|
||||
const [userToken, setUserToken] = useState("");
|
||||
const [canManageRefresh, setCanManageRefresh] = useState(false);
|
||||
const [items, setItems] = useState<Article[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -55,31 +98,59 @@ export default function KbListPage() {
|
||||
const [hint, setHint] = useState("");
|
||||
const [refreshStatus, setRefreshStatus] = useState<KbRefreshStatus | null>(null);
|
||||
const [lastSyncedFinishedAt, setLastSyncedFinishedAt] = useState(0);
|
||||
const [queryInput, setQueryInput] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [weeklyPlan, setWeeklyPlan] = useState<WeeklyPlanResp | null>(null);
|
||||
const [weeklyLoading, setWeeklyLoading] = useState(false);
|
||||
const [weeklyError, setWeeklyError] = useState("");
|
||||
const [weeklyHint, setWeeklyHint] = useState("");
|
||||
const [claimWeeklyLoading, setClaimWeeklyLoading] = useState(false);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const source = query.trim()
|
||||
? items.filter((article) => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
return article.title.toLowerCase().includes(needle) || article.slug.toLowerCase().includes(needle);
|
||||
})
|
||||
: items;
|
||||
const buckets: Record<string, Article[]> = {
|
||||
roadmap: [],
|
||||
cpp: [],
|
||||
web: [],
|
||||
game: [],
|
||||
cspj: [],
|
||||
csps: [],
|
||||
github: [],
|
||||
linux: [],
|
||||
csfund: [],
|
||||
other: [],
|
||||
};
|
||||
for (const article of items) {
|
||||
for (const article of source) {
|
||||
const slug = article.slug.toLowerCase();
|
||||
if (slug.includes("roadmap")) {
|
||||
buckets.roadmap.push(article);
|
||||
} else if (slug.includes("web")) {
|
||||
buckets.web.push(article);
|
||||
} else if (slug.includes("game")) {
|
||||
buckets.game.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 if (slug.includes("github") || slug.includes("git-")) {
|
||||
buckets.github.push(article);
|
||||
} else if (slug.includes("linux")) {
|
||||
buckets.linux.push(article);
|
||||
} else if (slug.includes("computer") || slug.includes("cs-") || slug.includes("fundamental")) {
|
||||
buckets.csfund.push(article);
|
||||
} else {
|
||||
buckets.other.push(article);
|
||||
}
|
||||
}
|
||||
return buckets;
|
||||
}, [items]);
|
||||
}, [items, query]);
|
||||
|
||||
const loadArticles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -110,12 +181,32 @@ export default function KbListPage() {
|
||||
}
|
||||
}, [canManageRefresh, refreshToken]);
|
||||
|
||||
const loadWeeklyPlan = useCallback(async () => {
|
||||
if (!userToken) {
|
||||
setWeeklyPlan(null);
|
||||
setWeeklyError("");
|
||||
return;
|
||||
}
|
||||
setWeeklyLoading(true);
|
||||
setWeeklyError("");
|
||||
try {
|
||||
const data = await apiFetch<WeeklyPlanResp>("/api/v1/kb/weekly-plan", {}, userToken);
|
||||
setWeeklyPlan(data);
|
||||
} catch (e: unknown) {
|
||||
setWeeklyPlan(null);
|
||||
setWeeklyError(String(e));
|
||||
} finally {
|
||||
setWeeklyLoading(false);
|
||||
}
|
||||
}, [userToken]);
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
const refreshAdminState = async () => {
|
||||
const tk = readToken();
|
||||
if (!tk) {
|
||||
if (!canceled) {
|
||||
setUserToken("");
|
||||
setRefreshToken("");
|
||||
setCanManageRefresh(false);
|
||||
}
|
||||
@@ -124,12 +215,14 @@ export default function KbListPage() {
|
||||
try {
|
||||
const me = await apiFetch<{ username?: string }>("/api/v1/me", {}, tk);
|
||||
if (!canceled) {
|
||||
setUserToken(tk);
|
||||
const isAdmin = (me?.username ?? "") === "admin";
|
||||
setCanManageRefresh(isAdmin);
|
||||
setRefreshToken(isAdmin ? tk : "");
|
||||
}
|
||||
} catch {
|
||||
if (!canceled) {
|
||||
setUserToken("");
|
||||
setRefreshToken("");
|
||||
setCanManageRefresh(false);
|
||||
}
|
||||
@@ -151,6 +244,17 @@ export default function KbListPage() {
|
||||
void loadStatus();
|
||||
}, [loadArticles, loadStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadWeeklyPlan();
|
||||
}, [loadWeeklyPlan]);
|
||||
|
||||
useEffect(() => {
|
||||
const q = new URLSearchParams(window.location.search).get("q")?.trim() ?? "";
|
||||
if (!q) return;
|
||||
setQueryInput(q);
|
||||
setQuery(q);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
void loadStatus();
|
||||
@@ -205,6 +309,47 @@ export default function KbListPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const applyQuery = () => {
|
||||
const normalized = queryInput.trim();
|
||||
setQuery(normalized);
|
||||
router.replace(normalized ? `/kb?q=${encodeURIComponent(normalized)}` : "/kb");
|
||||
};
|
||||
|
||||
const applyQuickQuery = (value: string) => {
|
||||
setQueryInput(value);
|
||||
setQuery(value);
|
||||
router.replace(`/kb?q=${encodeURIComponent(value)}`);
|
||||
};
|
||||
|
||||
const claimWeeklyBonus = async () => {
|
||||
if (!userToken || !weeklyPlan) return;
|
||||
setClaimWeeklyLoading(true);
|
||||
setWeeklyError("");
|
||||
setWeeklyHint("");
|
||||
try {
|
||||
const result = await apiFetch<WeeklyBonusResp>(
|
||||
"/api/v1/kb/weekly-bonus/claim",
|
||||
{ method: "POST", body: JSON.stringify({}) },
|
||||
userToken
|
||||
);
|
||||
if (result.claimed) {
|
||||
setWeeklyHint(
|
||||
tx(
|
||||
`领取成功:+${result.reward} 周奖励积分(当前积分 ${result.rating_after})`,
|
||||
`Claimed: +${result.reward} weekly bonus (rating ${result.rating_after})`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setWeeklyHint(tx("本周奖励已领取过。", "Weekly bonus already claimed."));
|
||||
}
|
||||
await loadWeeklyPlan();
|
||||
} catch (e: unknown) {
|
||||
setWeeklyError(String(e));
|
||||
} finally {
|
||||
setClaimWeeklyLoading(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">
|
||||
@@ -232,11 +377,38 @@ export default function KbListPage() {
|
||||
)}
|
||||
</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."
|
||||
)}
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{tx("按路线学习并完成每周任务。", "Follow the roadmap and finish weekly tasks.")}
|
||||
<HintTip title={tx("学习说明", "Learning Guide")}>
|
||||
{tx(
|
||||
"这里整合了 C++ 基础、CSP-J、CSP-S 与工程协作资料。建议先从路线图开始,再按每周任务推进,学习后去做对应题目巩固。",
|
||||
"This hub covers C++ basics, CSP-J/S, and engineering collaboration. Start with the roadmap, follow weekly tasks, then solve related problems for reinforcement."
|
||||
)}
|
||||
</HintTip>
|
||||
</span>
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<input
|
||||
className="min-h-[44px] flex-1 rounded border px-3 py-2 text-sm"
|
||||
value={queryInput}
|
||||
placeholder={tx("搜索知识点 / 标题 / slug...", "Search keyword / title / slug...")}
|
||||
onChange={(e) => setQueryInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") applyQuery();
|
||||
}}
|
||||
/>
|
||||
<button className="mc-btn min-h-[44px]" onClick={applyQuery}>
|
||||
{tx("搜索", "Search")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||||
<button className="mc-btn min-h-[44px]" onClick={() => applyQuickQuery("cpp14")}>C++14</button>
|
||||
<button className="mc-btn min-h-[44px]" onClick={() => applyQuickQuery("web")}>{tx("Web开发", "Web Dev")}</button>
|
||||
<button className="mc-btn min-h-[44px]" onClick={() => applyQuickQuery("game")}>{tx("游戏开发", "Game Dev")}</button>
|
||||
<button className="mc-btn min-h-[44px]" onClick={() => applyQuickQuery("github")}>GitHub</button>
|
||||
<button className="mc-btn min-h-[44px]" onClick={() => applyQuickQuery("linux")}>Linux</button>
|
||||
<button className="mc-btn min-h-[44px]" onClick={() => applyQuickQuery("computer")}>{tx("计算机基础", "Computer Fundamentals")}</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{canManageRefresh
|
||||
? tx("更新状态:", "Refresh status:")
|
||||
@@ -253,13 +425,227 @@ export default function KbListPage() {
|
||||
{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>}
|
||||
{query && (
|
||||
<p className="mt-2 text-xs text-[color:var(--mc-gold)]">
|
||||
{tx("当前筛选:", "Current filter: ")} {query}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<section
|
||||
className={`mt-4 overflow-hidden ${
|
||||
isMc
|
||||
? "rounded-none border-[3px] border-black bg-[color:var(--mc-card)] shadow-[6px_6px_0_rgba(0,0,0,0.48)]"
|
||||
: "rounded-2xl border border-zinc-300 bg-gradient-to-br from-amber-50 via-white to-zinc-100"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex flex-wrap items-center justify-between gap-3 px-4 py-3 ${
|
||||
isMc ? "border-b border-black/80 bg-[color:var(--mc-card-inner)]" : "border-b border-zinc-200/80"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Target size={18} className={isMc ? "text-[color:var(--mc-accent)]" : "text-amber-700"} />
|
||||
<h2 className={`text-sm font-semibold ${isMc ? "text-[color:var(--mc-text-main)]" : "text-zinc-800"}`}>
|
||||
{tx("本周学习任务", "Weekly Learning Plan")}
|
||||
</h2>
|
||||
<HintTip title={tx("周任务规则", "Weekly Rules")} align="left">
|
||||
{tx(
|
||||
"周任务会自动生成。完成全部任务后可领取 100% 奖励。若任务有前置依赖,需先完成前置知识点。",
|
||||
"Weekly tasks are generated automatically. Complete all tasks to claim the 100% bonus. If a task has prerequisites, finish those first."
|
||||
)}
|
||||
</HintTip>
|
||||
</div>
|
||||
{weeklyPlan && (
|
||||
<span className={`rounded-full px-2 py-1 text-[11px] ${
|
||||
isMc
|
||||
? "border border-[color:var(--mc-border-soft)] bg-black/40 text-[color:var(--mc-text-muted)]"
|
||||
: "bg-zinc-900 text-white"
|
||||
}`}>
|
||||
{tx("周起始", "Week")} {weeklyPlan.week_key}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!userToken && (
|
||||
<div className={`px-4 py-4 text-sm ${isMc ? "text-[color:var(--mc-text-muted)]" : "text-zinc-600"}`}>
|
||||
{tx("登录后可查看每周任务与 100% 完成奖励。", "Sign in to see weekly tasks and 100% completion bonus.")}
|
||||
<Link href="/auth" className={`ml-2 underline ${isMc ? "text-[color:var(--mc-accent)]" : "text-zinc-900"}`}>
|
||||
{tx("去登录", "Sign in")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{userToken && weeklyLoading && (
|
||||
<p className={`px-4 py-4 text-sm ${isMc ? "text-[color:var(--mc-text-dim)]" : "text-zinc-500"}`}>
|
||||
{tx("周任务加载中...", "Loading weekly tasks...")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{userToken && !weeklyLoading && weeklyError && (
|
||||
<p className="px-4 py-4 text-sm text-red-600">{weeklyError}</p>
|
||||
)}
|
||||
|
||||
{userToken && !weeklyLoading && !weeklyError && weeklyPlan && (
|
||||
<div className="px-4 py-4">
|
||||
<div
|
||||
className={`p-3 ${
|
||||
isMc
|
||||
? "rounded-none border-[2px] border-black bg-[color:var(--mc-card-inner)]"
|
||||
: "rounded-xl border border-zinc-200 bg-white/90"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-xs">
|
||||
<span className={isMc ? "text-[color:var(--mc-text-muted)]" : "text-zinc-600"}>
|
||||
{tx("完成度", "Progress")}:{" "}
|
||||
<span className={isMc ? "font-semibold text-[color:var(--mc-text-main)]" : "font-semibold text-zinc-900"}>
|
||||
{weeklyPlan.completion_percent}%
|
||||
</span>
|
||||
</span>
|
||||
<span className={isMc ? "text-[color:var(--mc-text-muted)]" : "text-zinc-600"}>
|
||||
{tx("任务积分", "Task Reward")}:{" "}
|
||||
<span className={isMc ? "font-semibold text-[color:var(--mc-text-main)]" : "font-semibold text-zinc-900"}>
|
||||
{weeklyPlan.gained_reward}/{weeklyPlan.total_reward}
|
||||
</span>
|
||||
</span>
|
||||
<span className={isMc ? "text-[color:var(--mc-text-muted)]" : "text-zinc-600"}>
|
||||
{tx("周奖励", "Bonus")}:{" "}
|
||||
<span className={isMc ? "font-semibold text-[color:var(--mc-warning)]" : "font-semibold text-amber-700"}>
|
||||
+{weeklyPlan.bonus_reward}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={`mt-2 h-2 w-full overflow-hidden rounded-full ${isMc ? "bg-black/50" : "bg-zinc-200"}`}>
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
isMc
|
||||
? "bg-gradient-to-r from-[color:var(--mc-success)] to-[color:var(--mc-warning)]"
|
||||
: "bg-gradient-to-r from-emerald-500 to-amber-500"
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, Math.max(0, weeklyPlan.completion_percent))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className={`text-xs ${isMc ? "text-[color:var(--mc-text-muted)]" : "text-zinc-600"}`}>
|
||||
{weeklyPlan.bonus_claimed
|
||||
? tx("本周 100% 奖励已领取", "100% weekly bonus already claimed")
|
||||
: tx("完成所有任务后可领取 100% 奖励", "Complete all tasks to claim 100% bonus")}
|
||||
</div>
|
||||
<button
|
||||
className={`min-h-[44px] text-xs disabled:opacity-50 ${
|
||||
isMc ? "mc-btn mc-btn-warning" : "mc-btn mc-btn-primary"
|
||||
}`}
|
||||
disabled={
|
||||
claimWeeklyLoading ||
|
||||
weeklyPlan.bonus_claimed ||
|
||||
weeklyPlan.completion_percent < 100
|
||||
}
|
||||
onClick={() => void claimWeeklyBonus()}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Trophy size={14} />
|
||||
{claimWeeklyLoading
|
||||
? tx("领取中...", "Claiming...")
|
||||
: tx("领取 100% 奖励", "Claim 100% Bonus")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{weeklyHint && (
|
||||
<p className={`mt-2 text-xs ${isMc ? "text-[color:var(--mc-success)]" : "text-emerald-700"}`}>
|
||||
{weeklyHint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2">
|
||||
{weeklyPlan.tasks.map((task) => (
|
||||
<article
|
||||
key={task.id}
|
||||
className={`p-3 ${
|
||||
isMc
|
||||
? task.completed
|
||||
? "rounded-none border-[2px] border-[color:var(--mc-success)] bg-[color:rgba(46,204,113,0.12)]"
|
||||
: "rounded-none border-[2px] border-zinc-700 bg-[#252525]"
|
||||
: task.completed
|
||||
? "rounded-xl border border-emerald-300 bg-emerald-50/70"
|
||||
: "rounded-xl border border-zinc-200 bg-white/90"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className={`text-sm font-semibold flex items-center gap-1 ${
|
||||
isMc ? "text-[color:var(--mc-text-main)]" : "text-zinc-900"
|
||||
}`}>
|
||||
{task.completed && (
|
||||
<CheckCircle2 size={15} className={isMc ? "text-[color:var(--mc-success)]" : "text-emerald-600"} />
|
||||
)}
|
||||
{task.knowledge_title}
|
||||
</p>
|
||||
<p className={`mt-1 text-xs ${isMc ? "text-[color:var(--mc-text-muted)]" : "text-zinc-500"}`}>
|
||||
{task.article_title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className={`rounded border px-2 py-0.5 ${
|
||||
isMc
|
||||
? "border-[color:var(--mc-border-soft)] text-[color:var(--mc-text-main)]"
|
||||
: "border-zinc-300 text-zinc-700"
|
||||
}`}>
|
||||
{task.difficulty}
|
||||
</span>
|
||||
<span className={`rounded px-2 py-0.5 ${
|
||||
isMc
|
||||
? "bg-[color:rgba(242,201,76,0.18)] text-[color:var(--mc-warning)]"
|
||||
: "bg-amber-100 text-amber-800"
|
||||
}`}>
|
||||
+{task.reward}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className={`mt-2 text-xs ${isMc ? "text-[color:var(--mc-text-muted)]" : "text-zinc-600"}`}>
|
||||
{task.knowledge_description}
|
||||
</p>
|
||||
{task.prerequisites?.length > 0 && (
|
||||
<p className={`mt-2 text-[11px] ${isMc ? "text-[color:var(--mc-text-dim)]" : "text-zinc-500"}`}>
|
||||
{tx("前置:", "Prerequisites: ")}
|
||||
{task.prerequisites.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<span className={`text-[11px] ${
|
||||
task.completed
|
||||
? isMc
|
||||
? "text-[color:var(--mc-success)]"
|
||||
: "text-emerald-700"
|
||||
: isMc
|
||||
? "text-[color:var(--mc-warning)]"
|
||||
: "text-zinc-500"
|
||||
}`}>
|
||||
{task.completed
|
||||
? tx("状态:已完成", "Status: Completed")
|
||||
: tx("状态:待完成", "Status: Pending")}
|
||||
</span>
|
||||
<Link href={`/kb/${task.article_slug}`} className="mc-btn min-h-[44px] text-xs">
|
||||
{tx("去学习", "Study")}
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mt-4 space-y-5">
|
||||
{[
|
||||
["roadmap", tx("学习总路线", "Learning Roadmap"), MapIcon],
|
||||
["cpp", tx("C++ 基础", "C++ Fundamentals"), Code2],
|
||||
["web", tx("Web 开发(C++)", "Web Dev (C++)"), Library],
|
||||
["game", tx("游戏开发(C++)", "Game Dev (C++)"), Library],
|
||||
["cspj", "CSP-J", Sword],
|
||||
["csps", "CSP-S", Shield],
|
||||
["github", tx("GitHub 协作", "GitHub Collaboration"), Library],
|
||||
["linux", tx("Linux 服务器", "Linux Server"), Library],
|
||||
["csfund", tx("计算机基础", "Computer Fundamentals"), Library],
|
||||
["other", tx("其他资料", "Other Resources"), FileQuestion],
|
||||
].map(([key, label, Icon]) => {
|
||||
const group = grouped[key as string] ?? [];
|
||||
@@ -271,16 +657,22 @@ export default function KbListPage() {
|
||||
{label as string}
|
||||
</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 key={a.slug} className="rounded-xl border bg-white p-4 hover:border-zinc-400">
|
||||
<Link href={`/kb/${a.slug}`} className="block">
|
||||
<h3 className="text-lg font-medium">{a.title}</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
slug: {a.slug} · {formatUnixDateTime(a.created_at)}
|
||||
</p>
|
||||
</Link>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Link href={`/kb/${a.slug}`} className="mc-btn min-h-[44px] text-xs">
|
||||
{tx("阅读知识", "Read")}
|
||||
</Link>
|
||||
<Link href={`/problems?q=${encodeURIComponent(a.title)}`} className="mc-btn mc-btn-primary min-h-[44px] text-xs">
|
||||
{tx("做相关任务", "Related Problems")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { formatUnixDateTime } from "@/lib/time";
|
||||
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||
import { Crown, Medal, Trophy, User, Calendar } from "lucide-react";
|
||||
|
||||
@@ -11,9 +14,12 @@ type Row = {
|
||||
user_id: number;
|
||||
username: string;
|
||||
rating: number;
|
||||
period_score: number;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type Scope = "all" | "week" | "today";
|
||||
|
||||
export default function LeaderboardPage() {
|
||||
const { tx } = useI18nText();
|
||||
const { theme } = useUiPreferences();
|
||||
@@ -21,13 +27,23 @@ export default function LeaderboardPage() {
|
||||
const [items, setItems] = useState<Row[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [scope, setScope] = useState<Scope>("all");
|
||||
const [meId, setMeId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const token = readToken();
|
||||
if (!token) return;
|
||||
void apiFetch<{ id?: number }>("/api/v1/me", {}, token)
|
||||
.then((me) => setMeId(typeof me?.id === "number" ? me.id : null))
|
||||
.catch(() => setMeId(null));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await apiFetch<Row[]>("/api/v1/leaderboard/global?limit=200");
|
||||
const data = await apiFetch<Row[]>(`/api/v1/leaderboard/global?limit=200&scope=${scope}`);
|
||||
setItems(data);
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
@@ -36,14 +52,14 @@ export default function LeaderboardPage() {
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, []);
|
||||
}, [scope]);
|
||||
|
||||
const getRankColor = (index: number) => {
|
||||
if (!isMc) return "";
|
||||
switch (index) {
|
||||
case 0: return "text-[color:var(--mc-gold)] drop-shadow-sm"; // Gold
|
||||
case 1: return "text-zinc-300"; // Iron
|
||||
case 2: return "text-orange-700"; // Copper
|
||||
case 0: return "text-[color:var(--mc-gold)] drop-shadow-sm";
|
||||
case 1: return "text-zinc-300";
|
||||
case 2: return "text-orange-700";
|
||||
default: return "text-zinc-400";
|
||||
}
|
||||
};
|
||||
@@ -58,6 +74,32 @@ export default function LeaderboardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const scoreLabel = useMemo(
|
||||
() => (scope === "all" ? tx("总 XP", "Total XP") : scope === "week" ? tx("本周 XP", "Weekly XP") : tx("今日 XP", "Today XP")),
|
||||
[scope, tx]
|
||||
);
|
||||
|
||||
const currentItems = useMemo(
|
||||
() =>
|
||||
items.map((item) => ({
|
||||
...item,
|
||||
boardScore: scope === "all" ? item.rating : item.period_score,
|
||||
})),
|
||||
[items, scope]
|
||||
);
|
||||
|
||||
const meGap = useMemo(() => {
|
||||
if (meId == null) return null;
|
||||
const idx = currentItems.findIndex((item) => item.user_id === meId);
|
||||
if (idx <= 0) return null;
|
||||
const me = currentItems[idx];
|
||||
const prev = currentItems[idx - 1];
|
||||
return {
|
||||
rank: idx + 1,
|
||||
gap: Math.max(1, prev.boardScore - me.boardScore),
|
||||
};
|
||||
}, [currentItems, meId]);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8 font-mono">
|
||||
<h1 className={`text-xl font-bold max-[390px]:text-lg sm:text-2xl ${isMc ? "text-[color:var(--mc-diamond)] mc-text-shadow" : ""}`}>
|
||||
@@ -70,27 +112,56 @@ export default function LeaderboardPage() {
|
||||
tx("全站排行榜", "Global Leaderboard")
|
||||
)}
|
||||
</h1>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-zinc-500">
|
||||
<p>{tx("切换时间范围查看排行。", "Switch scope to compare rankings.")}</p>
|
||||
<HintTip title={tx("榜单说明", "Leaderboard Notes")} align="left">
|
||||
<ul className="list-disc space-y-1 pl-4">
|
||||
<li>{tx("总榜按总 Rating 排序。", "All-time board is ranked by total rating.")}</li>
|
||||
<li>{tx("本周/今日按周期增量 XP 排序。", "Week/today boards use period XP gain.")}</li>
|
||||
<li>{tx("提示会显示你与上一名的差距。", "Gap hint shows distance to the player above you.")}</li>
|
||||
</ul>
|
||||
</HintTip>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
<button className={`mc-btn min-h-[44px] ${scope === "all" ? "mc-btn-primary" : ""}`} onClick={() => setScope("all")}>
|
||||
{tx("总榜", "All Time")}
|
||||
</button>
|
||||
<button className={`mc-btn min-h-[44px] ${scope === "week" ? "mc-btn-primary" : ""}`} onClick={() => setScope("week")}>
|
||||
{tx("本周", "This Week")}
|
||||
</button>
|
||||
<button className={`mc-btn min-h-[44px] ${scope === "today" ? "mc-btn-primary" : ""}`} onClick={() => setScope("today")}>
|
||||
{tx("今日", "Today")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{meGap && (
|
||||
<p className="mt-3 rounded border border-black bg-[color:var(--surface)] px-3 py-2 text-xs text-[color:var(--mc-gold)]">
|
||||
{tx(`你当前第 ${meGap.rank} 名,距离上一名还差 ${meGap.gap} ${scoreLabel}。`, `You are #${meGap.rank}, ${meGap.gap} ${scoreLabel} behind the next player.`)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading && <p className="mt-3 text-sm text-zinc-500">{tx("正在读取卷轴...", "Reading scrolls...")}</p>}
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
|
||||
<div className={`mt-4 rounded-xl border ${isMc ? "border-[3px] border-black bg-[color:var(--mc-deep-slate)] shadow-[4px_4px_0_rgba(0,0,0,0.5)] text-white" : "bg-white border-zinc-200"}`}>
|
||||
<div className="divide-y md:hidden">
|
||||
{items.map((row, i) => (
|
||||
{currentItems.map((row, i) => (
|
||||
<article key={row.user_id} className={`space-y-1 p-3 text-sm ${isMc ? "border-zinc-700" : ""}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className={`font-medium ${getRankColor(i)}`}>
|
||||
<span className="mr-2 text-lg">{getRankIcon(i)}</span>
|
||||
{row.username}
|
||||
</p>
|
||||
<span className="text-[color:var(--mc-emerald)] font-bold">{row.rating}</span>
|
||||
<span className="text-[color:var(--mc-green)] font-bold">{row.boardScore}</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{tx("注册时间:", "Registered: ")}
|
||||
{new Date(row.created_at * 1000).toLocaleString()}
|
||||
{formatUnixDateTime(row.created_at)}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
{!loading && items.length === 0 && (
|
||||
{!loading && currentItems.length === 0 && (
|
||||
<p className="px-3 py-5 text-center text-sm text-zinc-500">
|
||||
{tx("暂无数据", "No legends yet")}
|
||||
</p>
|
||||
@@ -108,6 +179,7 @@ export default function LeaderboardPage() {
|
||||
{tx("用户", "User")}
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left">{scoreLabel}</th>
|
||||
<th className="px-3 py-2 text-left">Rating</th>
|
||||
<th className="px-3 py-2 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -118,19 +190,20 @@ export default function LeaderboardPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={isMc ? "divide-y divide-zinc-700" : ""}>
|
||||
{items.map((row, i) => (
|
||||
{currentItems.map((row, i) => (
|
||||
<tr key={row.user_id} className={isMc ? "hover:bg-white/5 transition-colors" : "border-t"}>
|
||||
<td className={`px-3 py-2 font-bold flex items-center justify-center ${getRankColor(i)}`}>{getRankIcon(i)}</td>
|
||||
<td className={`px-3 py-2 font-bold ${getRankColor(i)}`}>{getRankIcon(i)}</td>
|
||||
<td className={`px-3 py-2 font-medium ${getRankColor(i)}`}>{row.username}</td>
|
||||
<td className="px-3 py-2 text-[color:var(--mc-emerald)]">{row.rating}</td>
|
||||
<td className="px-3 py-2 text-[color:var(--mc-green)]">{row.boardScore}</td>
|
||||
<td className="px-3 py-2">{row.rating}</td>
|
||||
<td className="px-3 py-2 text-zinc-500">
|
||||
{new Date(row.created_at * 1000).toLocaleString()}
|
||||
{formatUnixDateTime(row.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && items.length === 0 && (
|
||||
{!loading && currentItems.length === 0 && (
|
||||
<tr>
|
||||
<td className="px-3 py-5 text-center text-zinc-500" colSpan={4}>
|
||||
<td className="px-3 py-5 text-center text-zinc-500" colSpan={5}>
|
||||
{tx("暂无数据", "No legends yet")}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function LoginPage() {
|
||||
redirect("/auth");
|
||||
}
|
||||
@@ -1,23 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
CircleMinus,
|
||||
History,
|
||||
IdCard,
|
||||
RefreshCw,
|
||||
ShoppingBag,
|
||||
LogOut,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { PixelAvatar } from "@/components/pixel-avatar";
|
||||
import { SourceCrystalIcon } from "@/components/source-crystal-icon";
|
||||
import { apiFetch, listRatingHistory, type RatingHistoryItem } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { clearToken, readToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { dayKeyInShanghai, dayKeySerial, formatUnixDate, formatUnixDateTime, serialToDayKey } from "@/lib/time";
|
||||
|
||||
type Me = {
|
||||
id: number;
|
||||
@@ -26,10 +32,6 @@ type Me = {
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
// Use a distinct style for inputs/selects
|
||||
const inputClass = "w-full bg-[color:var(--mc-surface)] text-[color:var(--mc-plank-light)] border-2 border-[color:var(--mc-stone-dark)] px-3 py-2 text-base focus:border-[color:var(--mc-gold)] focus:outline-none transition-colors font-minecraft";
|
||||
const labelClass = "text-sm text-[color:var(--mc-stone)] mb-1 block";
|
||||
|
||||
type RedeemItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -57,6 +59,53 @@ type RedeemCreateResp = RedeemRecord & {
|
||||
rating_after?: number;
|
||||
};
|
||||
|
||||
type RedeemDayTypeInfo = {
|
||||
day_type: "holiday" | "studyday";
|
||||
is_holiday: boolean;
|
||||
reason: string;
|
||||
source: string;
|
||||
date_ymd: string;
|
||||
checked_at: number;
|
||||
};
|
||||
|
||||
type SourceCrystalSummary = {
|
||||
user_id: number;
|
||||
balance: number;
|
||||
monthly_interest_rate: number;
|
||||
last_interest_at: number;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
type SourceCrystalRecord = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
tx_type: "deposit" | "withdraw" | "interest" | string;
|
||||
amount: number;
|
||||
balance_after: number;
|
||||
note: string;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type ExperienceSummary = {
|
||||
user_id: number;
|
||||
experience: number;
|
||||
level: number;
|
||||
current_level_base: number;
|
||||
next_level_experience: number;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
type ExperienceHistoryItem = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
xp_delta: number;
|
||||
rating_before: number;
|
||||
rating_after: number;
|
||||
source: string;
|
||||
note: string;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type DailyTaskItem = {
|
||||
code: string;
|
||||
title: string;
|
||||
@@ -74,8 +123,12 @@ type DailyTaskPayload = {
|
||||
};
|
||||
|
||||
function fmtTs(v: number | null | undefined): string {
|
||||
if (!v) return "-";
|
||||
return new Date(v * 1000).toLocaleString();
|
||||
return formatUnixDateTime(v);
|
||||
}
|
||||
|
||||
function fmtCrystal(v: number | null | undefined): string {
|
||||
if (typeof v !== "number" || !Number.isFinite(v)) return "0.00";
|
||||
return v.toFixed(2);
|
||||
}
|
||||
|
||||
function resolveRank(rating: number): { label: string; color: string; icon: string } {
|
||||
@@ -86,30 +139,76 @@ function resolveRank(rating: number): { label: string; color: string; icon: stri
|
||||
return { label: "Wood", color: "text-[color:var(--mc-wood)]", icon: "🪵" };
|
||||
}
|
||||
|
||||
function calcLearningStreak(items: RatingHistoryItem[]): number {
|
||||
const daySet = new Set(
|
||||
items
|
||||
.filter((item) => item.type === "daily_task" && item.change > 0)
|
||||
.map((item) => dayKeyInShanghai(item.created_at))
|
||||
.filter((key) => key.length > 0)
|
||||
);
|
||||
if (daySet.size === 0) return 0;
|
||||
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const todaySerial = dayKeySerial(dayKeyInShanghai(nowSec));
|
||||
if (todaySerial == null) return 0;
|
||||
|
||||
let cursor = todaySerial;
|
||||
const todayKey = serialToDayKey(cursor);
|
||||
if (!daySet.has(todayKey)) {
|
||||
const yesterday = cursor - 1;
|
||||
const yesterdayKey = serialToDayKey(yesterday);
|
||||
if (!daySet.has(yesterdayKey)) return 0;
|
||||
cursor = yesterday;
|
||||
}
|
||||
|
||||
let streak = 0;
|
||||
while (true) {
|
||||
const key = serialToDayKey(cursor);
|
||||
if (!daySet.has(key)) break;
|
||||
streak += 1;
|
||||
cursor -= 1;
|
||||
}
|
||||
return streak;
|
||||
}
|
||||
|
||||
export default function MePage() {
|
||||
const { isZh, tx } = useI18nText();
|
||||
const router = useRouter();
|
||||
const [token, setToken] = useState("");
|
||||
const [profile, setProfile] = useState<Me | null>(null);
|
||||
const [items, setItems] = useState<RedeemItem[]>([]);
|
||||
const [records, setRecords] = useState<RedeemRecord[]>([]);
|
||||
const [historyItems, setHistoryItems] = useState<RatingHistoryItem[]>([]);
|
||||
const [ratingHistoryTypeFilter, setRatingHistoryTypeFilter] = useState("all");
|
||||
const [tradeTypeFilter, setTradeTypeFilter] = useState("all");
|
||||
const [dailyTasks, setDailyTasks] = useState<DailyTaskItem[]>([]);
|
||||
const [dailyDayKey, setDailyDayKey] = useState("");
|
||||
const [dailyTotalReward, setDailyTotalReward] = useState(0);
|
||||
const [dailyGainedReward, setDailyGainedReward] = useState(0);
|
||||
const [learningStreak, setLearningStreak] = useState(0);
|
||||
const [redeemDayType, setRedeemDayType] = useState<RedeemDayTypeInfo | null>(null);
|
||||
const [sourceCrystal, setSourceCrystal] = useState<SourceCrystalSummary | null>(null);
|
||||
const [sourceCrystalRecords, setSourceCrystalRecords] = useState<SourceCrystalRecord[]>([]);
|
||||
const [experience, setExperience] = useState<ExperienceSummary | null>(null);
|
||||
const [experienceHistory, setExperienceHistory] = useState<ExperienceHistoryItem[]>([]);
|
||||
const [experienceHistoryOpen, setExperienceHistoryOpen] = useState(false);
|
||||
const [experienceHistoryLoading, setExperienceHistoryLoading] = useState(false);
|
||||
|
||||
const [selectedItemId, setSelectedItemId] = useState<number>(0);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [dayType, setDayType] = useState<"holiday" | "studyday">("holiday");
|
||||
const [note, setNote] = useState("");
|
||||
const [crystalAmount, setCrystalAmount] = useState("10");
|
||||
const [crystalNote, setCrystalNote] = useState("");
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [redeemLoading, setRedeemLoading] = useState(false);
|
||||
const [crystalLoading, setCrystalLoading] = useState(false);
|
||||
|
||||
// Toast notification system
|
||||
const [toast, setToast] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
const [toastVisible, setToastVisible] = useState(false);
|
||||
const toastTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const lastCompletedTaskCountRef = useRef<number>(-1);
|
||||
|
||||
const showToast = useCallback((type: "success" | "error", text: string) => {
|
||||
if (toastTimer.current) clearTimeout(toastTimer.current);
|
||||
@@ -134,22 +233,6 @@ export default function MePage() {
|
||||
[items, selectedItemId]
|
||||
);
|
||||
|
||||
const unitCost = useMemo(() => {
|
||||
if (!selectedItem) return 0;
|
||||
return dayType === "holiday" ? selectedItem.holiday_cost : selectedItem.studyday_cost;
|
||||
}, [dayType, selectedItem]);
|
||||
|
||||
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 "First Blood";
|
||||
if (task.code === "code_quality") return "Craftsman";
|
||||
return task.title;
|
||||
};
|
||||
|
||||
const itemName = (name: string): string => {
|
||||
if (isZh) return name;
|
||||
if (name === "私人玩游戏时间") return "Game Time pass";
|
||||
@@ -201,21 +284,30 @@ export default function MePage() {
|
||||
setToken(tk);
|
||||
if (!tk) throw new Error(tx("请先登录", "Please sign in first"));
|
||||
|
||||
const [me, redeemItems, redeemRecords, daily, history] = await Promise.all([
|
||||
const [me, redeemItems, redeemRecords, daily, history, dayTypeInfo, crystalSummary, crystalRows, expSummary] = await Promise.all([
|
||||
apiFetch<Me>("/api/v1/me", {}, tk),
|
||||
apiFetch<RedeemItem[]>("/api/v1/me/redeem/items", {}, tk),
|
||||
apiFetch<RedeemRecord[]>("/api/v1/me/redeem/records?limit=200", {}, tk),
|
||||
apiFetch<DailyTaskPayload>("/api/v1/me/daily-tasks", {}, tk),
|
||||
listRatingHistory(50, tk),
|
||||
apiFetch<RedeemDayTypeInfo>("/api/v1/me/redeem/day-type", {}, tk),
|
||||
apiFetch<SourceCrystalSummary>("/api/v1/me/source-crystal", {}, tk),
|
||||
apiFetch<SourceCrystalRecord[]>("/api/v1/me/source-crystal/records?limit=200", {}, tk),
|
||||
apiFetch<ExperienceSummary>("/api/v1/me/experience", {}, tk),
|
||||
]);
|
||||
setProfile(me);
|
||||
setItems(redeemItems ?? []);
|
||||
setRecords(redeemRecords ?? []);
|
||||
setHistoryItems(history ?? []);
|
||||
setLearningStreak(calcLearningStreak(history ?? []));
|
||||
setDailyTasks(daily?.tasks ?? []);
|
||||
setDailyDayKey(daily?.day_key ?? "");
|
||||
setDailyTotalReward(daily?.total_reward ?? 0);
|
||||
setDailyGainedReward(daily?.gained_reward ?? 0);
|
||||
setRedeemDayType(dayTypeInfo ?? null);
|
||||
setSourceCrystal(crystalSummary ?? null);
|
||||
setSourceCrystalRecords(crystalRows ?? []);
|
||||
setExperience(expSummary ?? null);
|
||||
|
||||
if ((redeemItems ?? []).length > 0) {
|
||||
setSelectedItemId((prev) => prev || redeemItems[0].id);
|
||||
@@ -227,11 +319,54 @@ export default function MePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleExperienceHistory = async () => {
|
||||
const nextOpen = !experienceHistoryOpen;
|
||||
setExperienceHistoryOpen(nextOpen);
|
||||
if (!nextOpen || experienceHistory.length > 0) return;
|
||||
try {
|
||||
const tk = token || readToken();
|
||||
if (!tk) throw new Error(tx("请先登录", "Please sign in first"));
|
||||
setExperienceHistoryLoading(true);
|
||||
const rows = await apiFetch<ExperienceHistoryItem[]>(
|
||||
"/api/v1/me/experience/history?limit=200",
|
||||
{},
|
||||
tk
|
||||
);
|
||||
setExperienceHistory(rows ?? []);
|
||||
} catch (e: unknown) {
|
||||
showToast("error", String(e));
|
||||
} finally {
|
||||
setExperienceHistoryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLearningStreak(calcLearningStreak(historyItems));
|
||||
}, [historyItems]);
|
||||
|
||||
useEffect(() => {
|
||||
const completedCount = dailyTasks.filter((task) => task.completed).length;
|
||||
if (lastCompletedTaskCountRef.current < 0) {
|
||||
lastCompletedTaskCountRef.current = completedCount;
|
||||
return;
|
||||
}
|
||||
if (completedCount > lastCompletedTaskCountRef.current) {
|
||||
const gained = Math.max(0, dailyGainedReward);
|
||||
showToast(
|
||||
"success",
|
||||
isZh
|
||||
? `🎉 每日任务推进!当前奖励 ${gained}/${dailyTotalReward} XP`
|
||||
: `🎉 Daily quest progressed! Reward ${gained}/${dailyTotalReward} XP`
|
||||
);
|
||||
}
|
||||
lastCompletedTaskCountRef.current = completedCount;
|
||||
}, [dailyGainedReward, dailyTasks, dailyTotalReward, isZh, showToast]);
|
||||
|
||||
const redeem = async () => {
|
||||
setRedeemLoading(true);
|
||||
try {
|
||||
@@ -248,7 +383,6 @@ export default function MePage() {
|
||||
body: JSON.stringify({
|
||||
item_id: selectedItemId,
|
||||
quantity,
|
||||
day_type: dayType,
|
||||
note,
|
||||
}),
|
||||
},
|
||||
@@ -269,13 +403,190 @@ export default function MePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const withdrawSourceCrystal = async () => {
|
||||
setCrystalLoading(true);
|
||||
try {
|
||||
if (!token) throw new Error(tx("请先登录", "Please sign in first"));
|
||||
const amount = Number(crystalAmount);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
throw new Error(tx("源晶数量必须大于 0", "Source crystal amount must be greater than 0"));
|
||||
}
|
||||
|
||||
const payload = { amount, note: crystalNote };
|
||||
await apiFetch(
|
||||
"/api/v1/me/source-crystal/withdraw",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
token
|
||||
);
|
||||
|
||||
showToast(
|
||||
"success",
|
||||
tx(`已支出 ${fmtCrystal(amount)} 源晶`, `Spent ${fmtCrystal(amount)} Source Crystals`)
|
||||
);
|
||||
setCrystalNote("");
|
||||
await loadAll();
|
||||
} catch (e: unknown) {
|
||||
showToast("error", String(e));
|
||||
} finally {
|
||||
setCrystalLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = useCallback(() => {
|
||||
const confirmed = window.confirm(
|
||||
tx(
|
||||
"确认断开连接并退出当前账号?",
|
||||
"Disconnect and sign out from current account?"
|
||||
)
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
clearToken();
|
||||
setToken("");
|
||||
setProfile(null);
|
||||
setItems([]);
|
||||
setRecords([]);
|
||||
setHistoryItems([]);
|
||||
setDailyTasks([]);
|
||||
setDailyDayKey("");
|
||||
setDailyTotalReward(0);
|
||||
setDailyGainedReward(0);
|
||||
setRedeemDayType(null);
|
||||
setSourceCrystal(null);
|
||||
setSourceCrystalRecords([]);
|
||||
setExperience(null);
|
||||
setExperienceHistory([]);
|
||||
setExperienceHistoryOpen(false);
|
||||
showToast(
|
||||
"success",
|
||||
tx("已断开连接并退出登录。", "Disconnected and signed out.")
|
||||
);
|
||||
router.replace("/auth");
|
||||
}, [router, showToast, tx]);
|
||||
|
||||
const rank = resolveRank(profile?.rating ?? 0);
|
||||
const expValue = experience?.experience ?? 0;
|
||||
const expLevel = experience?.level ?? 1;
|
||||
const expCurrentBase = experience?.current_level_base ?? 0;
|
||||
const expNext = experience?.next_level_experience ?? 100;
|
||||
const expProgress = Math.max(
|
||||
0,
|
||||
Math.min(1, (expValue - expCurrentBase) / Math.max(1, expNext - expCurrentBase))
|
||||
);
|
||||
const expToNext = Math.max(0, expNext - expValue);
|
||||
const currentRedeemDayType = redeemDayType?.day_type === "holiday" ? "holiday" : "studyday";
|
||||
const sourceCrystalBalance = sourceCrystal?.balance ?? 0;
|
||||
const sourceCrystalMonthlyRate = sourceCrystal?.monthly_interest_rate ?? 0;
|
||||
const sourceCrystalEstimatedMonthlyInterest = Math.max(0, sourceCrystalBalance * sourceCrystalMonthlyRate);
|
||||
const sectionTitleClass =
|
||||
"text-lg font-extrabold text-[color:var(--mc-gold)] drop-shadow-sm tracking-wide";
|
||||
const sectionIconClass = "text-[color:var(--mc-diamond)]";
|
||||
const crystalTxLabel = (txType: string): string => {
|
||||
if (txType === "deposit") return tx("存入", "Deposit");
|
||||
if (txType === "withdraw") return tx("取出", "Withdraw");
|
||||
if (txType === "interest") return tx("月息", "Interest");
|
||||
return txType;
|
||||
};
|
||||
const ratingTypeLabel = (type: string): string => {
|
||||
if (type === "daily_task") return tx("每日任务", "Daily Task");
|
||||
if (type === "redeem") return tx("兑换消费", "Redeem");
|
||||
if (type === "solution_view") return tx("题解查看", "Solution View");
|
||||
if (type === "kb_skill") return tx("知识库奖励", "KB Reward");
|
||||
return type;
|
||||
};
|
||||
const tradeTypeLabel = (type: string): string => {
|
||||
if (type === "studyday") return tx("学习日", "Study Day");
|
||||
if (type === "holiday") return tx("假期", "Holiday");
|
||||
if (type === "unknown") return tx("未标注", "Unknown");
|
||||
return type;
|
||||
};
|
||||
const ratingHistoryTypes = useMemo(
|
||||
() => Array.from(new Set(historyItems.map((item) => item.type).filter((v) => v.length > 0))),
|
||||
[historyItems]
|
||||
);
|
||||
const filteredHistoryItems = useMemo(
|
||||
() =>
|
||||
historyItems.filter(
|
||||
(item) => ratingHistoryTypeFilter === "all" || item.type === ratingHistoryTypeFilter
|
||||
),
|
||||
[historyItems, ratingHistoryTypeFilter]
|
||||
);
|
||||
const tradeTypes = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set(records.map((row) => (row.day_type && row.day_type.length > 0 ? row.day_type : "unknown")))
|
||||
),
|
||||
[records]
|
||||
);
|
||||
const filteredTradeRecords = useMemo(
|
||||
() =>
|
||||
records.filter((row) => {
|
||||
const rowType = row.day_type && row.day_type.length > 0 ? row.day_type : "unknown";
|
||||
return tradeTypeFilter === "all" || rowType === tradeTypeFilter;
|
||||
}),
|
||||
[records, tradeTypeFilter]
|
||||
);
|
||||
const achievementItems = useMemo(() => {
|
||||
const hasFirstAc = historyItems.some((item) => item.type === "daily_task" && item.note === "first_ac");
|
||||
const hasSubmit = historyItems.some((item) => item.type === "daily_task" && item.note === "daily_submit");
|
||||
return [
|
||||
{
|
||||
key: "workbench",
|
||||
icon: "🧰",
|
||||
label: tx("工作台", "Workbench"),
|
||||
unlock: hasSubmit || hasFirstAc,
|
||||
hint: tx("完成一次提交", "Complete one submission"),
|
||||
},
|
||||
{
|
||||
key: "torch",
|
||||
icon: "🕯️",
|
||||
label: tx("火把", "Torch"),
|
||||
unlock: learningStreak >= 3,
|
||||
hint: tx("连续学习 3 天", "Study 3 days in a row"),
|
||||
},
|
||||
{
|
||||
key: "compass",
|
||||
icon: "🧭",
|
||||
label: tx("指南针", "Compass"),
|
||||
unlock: historyItems.length >= 10,
|
||||
hint: tx("累计 10 次成长记录", "Collect 10 progress logs"),
|
||||
},
|
||||
{
|
||||
key: "iron-pickaxe",
|
||||
icon: "⛏️",
|
||||
label: tx("铁镐", "Iron Pickaxe"),
|
||||
unlock: hasFirstAc,
|
||||
hint: tx("首次通过题目", "Get first AC"),
|
||||
},
|
||||
];
|
||||
}, [historyItems, learningStreak, tx]);
|
||||
|
||||
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 font-mono">
|
||||
<h1 className="text-xl font-bold max-[390px]:text-lg sm:text-2xl text-[color:var(--mc-diamond)] mc-text-shadow">
|
||||
{tx("冒险者档案 & 交易站", "Character Sheet & Trading Post")}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-xl font-bold max-[390px]:text-lg sm:text-2xl text-[color:var(--mc-diamond)] mc-text-shadow">
|
||||
{tx("冒险者档案 & 交易站", "Character Sheet & Trading Post")}
|
||||
</h1>
|
||||
<HintTip title={tx("页面说明", "Page Guide")}>
|
||||
{tx(
|
||||
"这里汇总每日任务、成长记录与交易记录。建议优先完成每日任务,再按需求在交易站兑换物品。",
|
||||
"This page combines daily tasks, growth history, and trade records. Complete daily tasks first, then redeem items in the trading post when needed."
|
||||
)}
|
||||
</HintTip>
|
||||
<button
|
||||
type="button"
|
||||
className="mc-btn mc-btn-danger ml-auto min-h-[40px] px-3 py-1 text-xs"
|
||||
onClick={logout}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<LogOut size={14} />
|
||||
{tx("断开连接", "Disconnect")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{loading && <p className="mt-3 text-sm text-[color:var(--mc-stone)]">{tx("读取存档中...", "Loading Save...")}</p>}
|
||||
|
||||
{/* Toast notification */}
|
||||
@@ -349,6 +660,38 @@ export default function MePage() {
|
||||
<span>{tx("下一等级", "Next Lv")}</span>
|
||||
</div>
|
||||
<span className="text-right text-zinc-600">{100 - (profile.rating % 100)} XP</span>
|
||||
|
||||
<div className="flex items-center gap-1 text-zinc-800">
|
||||
<span className="text-xs">🔥</span>
|
||||
<span>{tx("连学", "Streak")}</span>
|
||||
</div>
|
||||
<span className="text-right font-bold text-[color:var(--mc-red)]">
|
||||
{learningStreak} {tx("天", "days")}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1 text-zinc-800">
|
||||
<Zap size={13} className="text-[color:var(--mc-diamond)]" />
|
||||
<span>{tx("经验值", "Experience")}</span>
|
||||
</div>
|
||||
<span className="text-right font-bold text-[color:var(--mc-diamond)]">{expValue}</span>
|
||||
|
||||
<div className="flex items-center gap-1 text-zinc-800">
|
||||
<TrendingUp size={13} className="text-[color:var(--mc-diamond)]" />
|
||||
<span>{tx("经验等级", "XP Level")}</span>
|
||||
</div>
|
||||
<span className="text-right font-bold text-[color:var(--mc-diamond)]">Lv.{expLevel}</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full mb-2">
|
||||
<div className="h-2 overflow-hidden rounded bg-black/20">
|
||||
<div
|
||||
className="h-full bg-[color:var(--mc-diamond)]"
|
||||
style={{ width: `${(expProgress * 100).toFixed(2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-right text-[11px] text-zinc-600">
|
||||
{tx("下一级经验", "XP to next")}: {expToNext}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full border-t border-zinc-300 my-1"></div>
|
||||
@@ -366,7 +709,32 @@ export default function MePage() {
|
||||
<Calendar size={14} className="text-zinc-500" />
|
||||
{tx("加入时间", "Joined")}
|
||||
</span>
|
||||
<span className="text-zinc-600 font-mono">{new Date(profile.created_at * 1000).toLocaleDateString()}</span>
|
||||
<span className="text-zinc-600 font-mono">{formatUnixDate(profile.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full border-t border-zinc-300 mt-3 pt-3 text-left">
|
||||
<div className="rounded border border-zinc-300 bg-white p-3">
|
||||
<p className="flex items-center gap-1 text-sm font-bold text-[color:var(--mc-gold)]">
|
||||
<SourceCrystalIcon size={14} className={sectionIconClass} />
|
||||
{tx("源晶账户", "Source Crystal Account")}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-bold text-[color:var(--mc-diamond)]">
|
||||
{fmtCrystal(sourceCrystalBalance)} {tx("源晶", "SC")}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
{tx("月利率", "Monthly Interest")}: {(sourceCrystalMonthlyRate * 100).toFixed(2)}% ·{" "}
|
||||
{tx("预计月息", "Est. monthly interest")}: +{fmtCrystal(sourceCrystalEstimatedMonthlyInterest)}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
{tx("上次计息", "Last interest update")}: {fmtTs(sourceCrystal?.last_interest_at)}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
{tx(
|
||||
"仅管理员可在管理页为你存入源晶;你可在此自行支出并填写备注。",
|
||||
"Only admin can deposit Source Crystals for you; you can spend here with notes."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -375,9 +743,9 @@ export default function MePage() {
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Daily Tasks */}
|
||||
<div className="bg-[color:var(--mc-surface)] border-4 border-black p-4 relative">
|
||||
<h2 className="text-xl text-[color:var(--mc-dirt)] mb-4 flex justify-between items-center font-minecraft">
|
||||
<h2 className={`${sectionTitleClass} mb-4 flex justify-between items-center font-minecraft`}>
|
||||
<span>每日悬赏任务</span>
|
||||
<span className="text-xs text-[color:var(--mc-gold)]">进度: {dailyGainedReward} / {dailyTotalReward} XP</span>
|
||||
<span className="text-xs text-[color:var(--mc-gold)]">进度: {dailyGainedReward} / {dailyTotalReward} XP · 🔥 {learningStreak}d · {dailyDayKey || "--"}</span>
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
@@ -407,8 +775,33 @@ export default function MePage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[color:var(--mc-surface)] border-4 border-black p-4">
|
||||
<h3 className={`${sectionTitleClass} mb-3 font-minecraft`}>
|
||||
{tx("物品图鉴", "Item Collection")}
|
||||
</h3>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{achievementItems.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`border-2 border-[color:var(--mc-stone-dark)] p-2 text-sm ${
|
||||
item.unlock
|
||||
? "bg-[color:var(--mc-grass-top)]/20 text-[color:var(--mc-plank-light)]"
|
||||
: "bg-black/20 text-[color:var(--mc-stone)]"
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold flex items-center gap-2">
|
||||
<span>{item.icon}</span>
|
||||
{item.label}
|
||||
{item.unlock ? <span className="text-[color:var(--mc-gold)]">✓</span> : null}
|
||||
</p>
|
||||
<p className="mt-1 text-xs">{item.hint}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<section className="flex-1 rounded-none border-[3px] border-black bg-[color:var(--mc-stone)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)] text-white">
|
||||
<h2 className="text-xl text-[color:var(--mc-obsidian)] mb-6 flex items-center gap-2 border-b-2 border-[color:var(--mc-stone)]/30 pb-2 font-minecraft">
|
||||
<h2 className={`${sectionTitleClass} mb-6 flex items-center gap-2 border-b-2 border-[color:var(--mc-stone)]/30 pb-2 font-minecraft`}>
|
||||
<span className="text-2xl">💎</span>
|
||||
<span>村民交易站</span>
|
||||
<span className="ml-auto text-sm text-[color:var(--mc-stone-dark)]">消耗: RATING</span>
|
||||
@@ -445,20 +838,27 @@ export default function MePage() {
|
||||
<div className="bg-[color:var(--mc-stone)]/20 p-3 border border-[color:var(--mc-stone)]/30 rounded-none text-base text-[color:var(--mc-obsidian)]">
|
||||
<p>{selectedItem.description}</p>
|
||||
<p className="mt-1 text-[color:var(--mc-wood-dark)]">
|
||||
单价: {dayType === 'holiday' ? selectedItem.holiday_cost : selectedItem.studyday_cost} Rating / {selectedItem.unit_label}
|
||||
{tx("单价", "Unit cost")}:{" "}
|
||||
{currentRedeemDayType === "holiday"
|
||||
? selectedItem.holiday_cost
|
||||
: selectedItem.studyday_cost}{" "}
|
||||
Rating / {selectedItem.unit_label}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[color:var(--mc-stone-dark)]">
|
||||
{currentRedeemDayType === "holiday"
|
||||
? tx("今日判定:假期", "Today: Holiday")
|
||||
: tx("今日判定:学习日", "Today: Study Day")}
|
||||
{redeemDayType?.reason ? ` · ${redeemDayType.reason}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
className="flex-1 rounded-none border-2 border-black bg-[color:var(--stone-dark)] text-black px-2 py-1 text-base"
|
||||
value={dayType}
|
||||
onChange={(e) => setDayType(e.target.value === "studyday" ? "studyday" : "holiday")}
|
||||
>
|
||||
<option value="holiday">{tx("假期特惠", "Holiday Price")}</option>
|
||||
<option value="studyday">{tx("工作日价格", "Workday Price")}</option>
|
||||
</select>
|
||||
<div className="flex-1 rounded-none border-2 border-black bg-[color:var(--stone-dark)] px-2 py-2 text-sm text-black">
|
||||
{currentRedeemDayType === "holiday"
|
||||
? tx("自动使用假期价格", "Auto using holiday price")
|
||||
: tx("自动使用学习日价格", "Auto using study-day price")}
|
||||
</div>
|
||||
<button
|
||||
className="mc-btn mc-btn-success text-xs px-4 flex items-center gap-2"
|
||||
onClick={() => void redeem()}
|
||||
@@ -474,28 +874,168 @@ export default function MePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="mt-4 rounded-none border-[3px] border-black bg-[color:var(--mc-surface)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className={`${sectionTitleClass} flex items-center gap-2`}>
|
||||
<SourceCrystalIcon size={20} className={sectionIconClass} />
|
||||
{tx("源晶支出与流水", "Source Crystal Spend & History")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-[160px_1fr_auto]">
|
||||
<input
|
||||
className="rounded border px-3 py-2 text-sm"
|
||||
type="number"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
value={crystalAmount}
|
||||
onChange={(e) => setCrystalAmount(e.target.value)}
|
||||
placeholder={tx("数量", "Amount")}
|
||||
/>
|
||||
<input
|
||||
className="rounded border px-3 py-2 text-sm"
|
||||
value={crystalNote}
|
||||
onChange={(e) => setCrystalNote(e.target.value)}
|
||||
placeholder={tx("备注(可选)", "Note (optional)")}
|
||||
/>
|
||||
<button
|
||||
className="mc-btn mc-btn-danger text-xs flex items-center gap-1"
|
||||
onClick={() => void withdrawSourceCrystal()}
|
||||
disabled={crystalLoading}
|
||||
>
|
||||
<CircleMinus size={14} />
|
||||
{tx("支出", "Spend")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 max-h-56 space-y-1 overflow-y-auto rounded border border-zinc-200 bg-white p-2 text-xs">
|
||||
{sourceCrystalRecords.map((row) => (
|
||||
<div key={row.id} className="flex items-center justify-between border-b border-zinc-100 pb-1">
|
||||
<span>
|
||||
<span className={`font-bold ${row.amount >= 0 ? "text-emerald-700" : "text-red-600"}`}>
|
||||
{row.amount >= 0 ? "+" : ""}
|
||||
{fmtCrystal(row.amount)} {tx("源晶", "SC")}
|
||||
</span>
|
||||
<span className="ml-2 text-zinc-700">{crystalTxLabel(row.tx_type)}</span>
|
||||
{row.note ? <span className="ml-2 text-zinc-500">· {row.note}</span> : null}
|
||||
</span>
|
||||
<span className="text-zinc-500">
|
||||
{tx("余额", "Bal")}: {fmtCrystal(row.balance_after)} · {fmtTs(row.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{!loading && sourceCrystalRecords.length === 0 && (
|
||||
<p className="text-zinc-500">{tx("暂无源晶流水。", "No source crystal records yet.")}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-4 rounded-none border-[3px] border-black bg-[color:var(--mc-surface)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className={`${sectionTitleClass} flex items-center gap-2`}>
|
||||
<Zap size={18} className={sectionIconClass} />
|
||||
{tx("经验值系统", "Experience")}
|
||||
</h2>
|
||||
<button
|
||||
className="mc-btn text-xs px-3 py-1"
|
||||
onClick={() => void toggleExperienceHistory()}
|
||||
disabled={experienceHistoryLoading}
|
||||
>
|
||||
{experienceHistoryOpen
|
||||
? tx("收起经验历史", "Hide XP History")
|
||||
: tx("查看经验历史", "View XP History")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-600">
|
||||
{tx(
|
||||
"规则:1 经验值 = 1 Rating 增量;消费 Rating 不会减少经验值。",
|
||||
"Rule: 1 XP = 1 rating gain; spending rating never decreases XP."
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-3 rounded border border-zinc-300 bg-white p-3">
|
||||
<p className="text-sm font-bold text-[color:var(--mc-diamond)]">
|
||||
{tx("当前经验", "Current XP")}: {expValue}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-zinc-600">
|
||||
{tx("当前等级", "Level")}: Lv.{expLevel}
|
||||
</p>
|
||||
<div className="mt-2 h-2 overflow-hidden rounded bg-zinc-200">
|
||||
<div
|
||||
className="h-full bg-[color:var(--mc-diamond)]"
|
||||
style={{ width: `${(expProgress * 100).toFixed(2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
{expValue} / {expNext} {tx("(下一级)", "(next level)")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{experienceHistoryOpen && (
|
||||
<div className="mt-3 max-h-56 space-y-1 overflow-y-auto rounded border border-zinc-200 bg-white p-2 text-xs">
|
||||
{experienceHistoryLoading && (
|
||||
<p className="text-zinc-500">{tx("加载经验历史中...", "Loading XP history...")}</p>
|
||||
)}
|
||||
{!experienceHistoryLoading && experienceHistory.map((row) => (
|
||||
<div key={row.id} className="flex items-center justify-between border-b border-zinc-100 pb-1">
|
||||
<span>
|
||||
<span className="font-bold text-emerald-700">+{row.xp_delta} XP</span>
|
||||
<span className="ml-2 text-zinc-700">
|
||||
{tx("Rating", "Rating")} {row.rating_before} → {row.rating_after}
|
||||
</span>
|
||||
{row.note ? <span className="ml-2 text-zinc-500">· {row.note}</span> : null}
|
||||
</span>
|
||||
<span className="text-zinc-500">{fmtTs(row.created_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
{!experienceHistoryLoading && experienceHistory.length === 0 && (
|
||||
<p className="text-zinc-500">{tx("暂无经验历史。", "No XP history yet.")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Rating History Section */}
|
||||
<section className="mt-4 rounded-none border-[3px] border-black bg-[color:var(--mc-surface)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||
<h2 className="text-base font-bold text-black mb-2 flex items-center gap-2">
|
||||
<History size={18} />
|
||||
{tx("积分变动记录", "Rating History")}
|
||||
</h2>
|
||||
<div className="max-h-60 overflow-y-auto space-y-1">
|
||||
{historyItems.map((item, idx) => (
|
||||
<div key={idx} className="flex justify-between text-xs text-zinc-800 border-b border-zinc-200 pb-1">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className={`${sectionTitleClass} flex items-center gap-2`}>
|
||||
<History size={18} className={sectionIconClass} />
|
||||
{tx("积分变动记录", "Rating History")}
|
||||
</h2>
|
||||
<label className="flex items-center gap-1 text-xs text-zinc-600">
|
||||
<span>{tx("类型", "Type")}</span>
|
||||
<select
|
||||
className="rounded border px-2 py-1 text-xs text-zinc-700"
|
||||
value={ratingHistoryTypeFilter}
|
||||
onChange={(e) => setRatingHistoryTypeFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">{tx("全部", "All")}</option>
|
||||
{ratingHistoryTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{ratingTypeLabel(type)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto space-y-1 rounded border border-zinc-200 bg-white p-2 text-xs">
|
||||
{filteredHistoryItems.map((item, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between border-b border-zinc-100 pb-1">
|
||||
<span>
|
||||
<span className={`font-bold flex items-center gap-1 ${item.change > 0 ? 'text-[color:var(--mc-green)]' : 'text-[color:var(--mc-red)]'}`}>
|
||||
{item.change > 0 ? <TrendingUp size={14} /> : <TrendingDown size={14} />}
|
||||
{item.change > 0 ? `+${item.change}` : item.change}
|
||||
</span>
|
||||
<span className="ml-2">{formatRatingNote(item.note, item.type)}</span>
|
||||
<span className="ml-2 text-zinc-700">{formatRatingNote(item.note, item.type)}</span>
|
||||
<span className="ml-2 rounded border border-zinc-200 px-1 text-[11px] text-zinc-500">
|
||||
{ratingTypeLabel(item.type)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[color:var(--mc-stone-dark)]">
|
||||
{new Date(item.created_at * 1000).toLocaleString()}
|
||||
<span className="text-zinc-500">
|
||||
{formatUnixDateTime(item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{!loading && historyItems.length === 0 && (
|
||||
{!loading && filteredHistoryItems.length === 0 && (
|
||||
<p className="text-xs text-zinc-500">{tx("暂无记录。", "No history.")}</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -504,29 +1044,52 @@ export default function MePage() {
|
||||
{/* Trades Section */}
|
||||
<section className="mt-4 rounded-none border-[3px] border-black bg-[color:var(--mc-surface)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<h2 className="text-base font-bold text-black">{tx("交易记录", "Trade History")}</h2>
|
||||
<button
|
||||
className="text-xs text-[color:var(--mc-stone-dark)] underline flex items-center gap-1 hover:text-black"
|
||||
onClick={() => void loadAll()}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||
{tx("刷新", "Refresh")}
|
||||
</button>
|
||||
<h2 className={`${sectionTitleClass} flex items-center gap-2`}>
|
||||
<ArrowRightLeft size={18} className={sectionIconClass} />
|
||||
{tx("交易记录", "Trade History")}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1 text-xs text-zinc-600">
|
||||
<span>{tx("类型", "Type")}</span>
|
||||
<select
|
||||
className="rounded border px-2 py-1 text-xs text-zinc-700"
|
||||
value={tradeTypeFilter}
|
||||
onChange={(e) => setTradeTypeFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">{tx("全部", "All")}</option>
|
||||
{tradeTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{tradeTypeLabel(type)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
className="mc-btn text-xs px-3 py-1 flex items-center gap-1"
|
||||
onClick={() => void loadAll()}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||
{tx("刷新", "Refresh")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto space-y-1">
|
||||
{records.map((row) => (
|
||||
<div key={row.id} className="flex justify-between text-xs text-zinc-800 border-b border-zinc-200 pb-1">
|
||||
<span>
|
||||
<div className="max-h-56 overflow-y-auto space-y-1 rounded border border-zinc-200 bg-white p-2 text-xs">
|
||||
{filteredTradeRecords.map((row) => (
|
||||
<div key={row.id} className="flex items-center justify-between border-b border-zinc-100 pb-1">
|
||||
<span className="text-zinc-700">
|
||||
{itemName(row.item_name)} × {row.quantity}
|
||||
<span className="ml-2 rounded border border-zinc-200 px-1 text-[11px] text-zinc-500">
|
||||
{tradeTypeLabel(row.day_type && row.day_type.length > 0 ? row.day_type : "unknown")}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[color:var(--mc-stone-dark)]">
|
||||
-{row.total_cost} Gems · {new Date(row.created_at * 1000).toLocaleDateString()}
|
||||
<span className="text-zinc-500">
|
||||
-{row.total_cost} Gems · {formatUnixDate(row.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{!loading && records.length === 0 && (
|
||||
{!loading && filteredTradeRecords.length === 0 && (
|
||||
<p className="text-xs text-zinc-500">{tx("暂无交易。", "No trades.")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import NextImage from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { CodeEditor } from "@/components/code-editor";
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { MarkdownRenderer } from "@/components/markdown-renderer";
|
||||
import { PageCrumbs } from "@/components/page-crumbs";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { type Cpp14PolicyIssue } from "@/lib/cpp14-policy";
|
||||
@@ -77,29 +80,12 @@ function normalizeCodeText(raw: string): string {
|
||||
return `${merged}\n`;
|
||||
}
|
||||
|
||||
function codeLineCount(raw: string): number {
|
||||
const text = normalizeCodeText(raw);
|
||||
if (!text) return 0;
|
||||
return text.split("\n").length - 1;
|
||||
}
|
||||
|
||||
function countCompileWarnings(log: string): number {
|
||||
if (!log) return 0;
|
||||
const lines = log.split("\n");
|
||||
return lines.filter((line) => line.toLowerCase().includes("warning:")).length;
|
||||
}
|
||||
|
||||
function countCompileErrors(log: string): number {
|
||||
if (!log) return 0;
|
||||
const lines = log.split("\n");
|
||||
return lines.filter((line) => line.toLowerCase().includes("error:")).length;
|
||||
}
|
||||
|
||||
function scoreRatio(score: number): number {
|
||||
if (!Number.isFinite(score)) return 0;
|
||||
return Math.max(0, Math.min(100, score));
|
||||
}
|
||||
|
||||
function buildDraftSignature(code: string, stdinText: string): string {
|
||||
const normCode = normalizeCodeText(code);
|
||||
const normStdin = (stdinText ?? "").replace(/\r\n?/g, "\n");
|
||||
@@ -280,6 +266,10 @@ int main() {
|
||||
|
||||
const defaultRunInput = ``;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function difficultyIcon(diff: number): string {
|
||||
if (diff <= 2) return "🪵";
|
||||
if (diff <= 4) return "🪨";
|
||||
@@ -296,6 +286,8 @@ export default function ProblemDetailPage() {
|
||||
const [problem, setProblem] = useState<Problem | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [reloadNonce, setReloadNonce] = useState(0);
|
||||
const [mobilePanel, setMobilePanel] = useState<"statement" | "code">("statement");
|
||||
|
||||
const [code, setCode] = useState(starterCode);
|
||||
const [runInput, setRunInput] = useState(defaultRunInput);
|
||||
@@ -306,7 +298,6 @@ export default function ProblemDetailPage() {
|
||||
const [submitResp, setSubmitResp] = useState<Submission | null>(null);
|
||||
const [runResp, setRunResp] = useState<RunResult | null>(null);
|
||||
const [draftMsg, setDraftMsg] = useState("");
|
||||
const [showPolicyTips, setShowPolicyTips] = useState(false);
|
||||
const [policyIssues, setPolicyIssues] = useState<Cpp14PolicyIssue[]>([]);
|
||||
const [policyMsg, setPolicyMsg] = useState("");
|
||||
|
||||
@@ -328,6 +319,7 @@ export default function ProblemDetailPage() {
|
||||
const [solutionData, setSolutionData] = useState<SolutionResp | null>(null);
|
||||
const [solutionMsg, setSolutionMsg] = useState("");
|
||||
const [printAnswerMarkdown, setPrintAnswerMarkdown] = useState("");
|
||||
const outputAnchorRef = useRef<HTMLDivElement | null>(null);
|
||||
const draftLatestRef = useRef<{ code: string; stdin: string }>({
|
||||
code: starterCode,
|
||||
stdin: defaultRunInput,
|
||||
@@ -438,7 +430,7 @@ export default function ProblemDetailPage() {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ filename }),
|
||||
}, token);
|
||||
if (Array.isArray((resp as any).note_images)) setNoteImages((resp as any).note_images);
|
||||
if (Array.isArray(resp.note_images)) setNoteImages(resp.note_images);
|
||||
setNoteMsg(tx("图片已删除。", "Image deleted."));
|
||||
} catch (e: unknown) {
|
||||
setNoteMsg(String(e));
|
||||
@@ -457,7 +449,7 @@ export default function ProblemDetailPage() {
|
||||
await apiFetch<{ note: string }>(`/api/v1/me/wrong-book/${problemId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ note: noteText }),
|
||||
}, token);
|
||||
}, token, { retryOnStatus: [500], retryCount: 10, retryDelayMs: 500 });
|
||||
setNoteMsg(tx("笔记已保存。", "Notes saved."));
|
||||
} catch (e: unknown) {
|
||||
setNoteMsg(String(e));
|
||||
@@ -473,6 +465,15 @@ export default function ProblemDetailPage() {
|
||||
return;
|
||||
}
|
||||
if (!problemId) return;
|
||||
if (!noteText.trim()) {
|
||||
setNoteMsg(
|
||||
tx(
|
||||
"请先填写或保存学习笔记后再进行鉴定。",
|
||||
"Write or save your learning note before appraising."
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
setNoteScoring(true);
|
||||
setNoteMsg("");
|
||||
try {
|
||||
@@ -483,7 +484,7 @@ export default function ProblemDetailPage() {
|
||||
}>(`/api/v1/me/wrong-book/${problemId}/note-score`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ note: noteText }),
|
||||
}, token);
|
||||
}, token, { retryOnStatus: [500], retryCount: 10, retryDelayMs: 500 });
|
||||
setNoteScore(resp.note_score);
|
||||
setNoteRating(resp.note_rating);
|
||||
setNoteFeedback(resp.note_feedback_md || "");
|
||||
@@ -533,11 +534,6 @@ export default function ProblemDetailPage() {
|
||||
() => policyIssues.filter((item) => item.severity === "warning").length,
|
||||
[policyIssues]
|
||||
);
|
||||
const policyHintCount = useMemo(
|
||||
() => policyIssues.filter((item) => item.severity === "hint").length,
|
||||
[policyIssues]
|
||||
);
|
||||
const visiblePolicyIssues = useMemo(() => policyIssues.slice(0, 6), [policyIssues]);
|
||||
const hasSolutionAnswer = useMemo(
|
||||
() => Boolean(solutionData?.has_solutions),
|
||||
[solutionData]
|
||||
@@ -564,18 +560,6 @@ export default function ProblemDetailPage() {
|
||||
],
|
||||
[tx]
|
||||
);
|
||||
const submitWarningCount = useMemo(
|
||||
() => countCompileWarnings(submitResp?.compile_log ?? ""),
|
||||
[submitResp?.compile_log]
|
||||
);
|
||||
const submitErrorCount = useMemo(
|
||||
() => countCompileErrors(submitResp?.compile_log ?? ""),
|
||||
[submitResp?.compile_log]
|
||||
);
|
||||
const runWarningCount = useMemo(
|
||||
() => countCompileWarnings(runResp?.compile_log ?? ""),
|
||||
[runResp?.compile_log]
|
||||
);
|
||||
const runErrorCount = useMemo(
|
||||
() => countCompileErrors(runResp?.compile_log ?? ""),
|
||||
[runResp?.compile_log]
|
||||
@@ -598,12 +582,29 @@ export default function ProblemDetailPage() {
|
||||
if (solutionStatusLoading) return "text-[color:var(--mc-stone)]";
|
||||
return "text-[color:var(--mc-gold)]";
|
||||
}, [hasSolutionAnswer, solutionStatusLoading]);
|
||||
const isProblemNotFound = /problem not found|404/i.test(error);
|
||||
const missionProgress = useMemo(
|
||||
() => [
|
||||
{ label: tx("运行", "Run"), done: Boolean(runResp) },
|
||||
{ label: tx("提交", "Submit"), done: Boolean(submitResp) },
|
||||
{ label: tx("笔记", "Notes"), done: noteText.trim().length >= 20 },
|
||||
],
|
||||
[noteText, runResp, submitResp, tx]
|
||||
);
|
||||
const relatedKnowledge = useMemo(() => {
|
||||
const raw = [...(llmProfile?.knowledge_points ?? []), ...(llmProfile?.tags ?? [])]
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
const uniq = Array.from(new Set(raw));
|
||||
return uniq.slice(0, 8);
|
||||
}, [llmProfile?.knowledge_points, llmProfile?.tags]);
|
||||
|
||||
useEffect(() => {
|
||||
setRunInput(defaultRunInput);
|
||||
setShowSolutions(false);
|
||||
setSolutionData(null);
|
||||
setSolutionMsg("");
|
||||
setMobilePanel("statement");
|
||||
draftLatestRef.current = { code: starterCode, stdin: defaultRunInput };
|
||||
draftLastSavedSigRef.current = buildDraftSignature(starterCode, defaultRunInput);
|
||||
}, [id]);
|
||||
@@ -646,16 +647,35 @@ export default function ProblemDetailPage() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await apiFetch<Problem>(`/api/v1/problems/${id}`);
|
||||
setProblem(data);
|
||||
let loaded: Problem | null = null;
|
||||
let lastErr: unknown = null;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
loaded = await apiFetch<Problem>(`/api/v1/problems/${id}`);
|
||||
break;
|
||||
} catch (e: unknown) {
|
||||
lastErr = e;
|
||||
if (attempt < 2) {
|
||||
await sleep(300 * (2 ** attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!loaded) throw lastErr ?? new Error("load failed");
|
||||
setProblem(loaded);
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
const msg = String(e);
|
||||
console.error("[problem-detail] load failed", {
|
||||
problemId: id,
|
||||
error: msg,
|
||||
});
|
||||
setProblem(null);
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, [id]);
|
||||
}, [id, reloadNonce]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -778,6 +798,7 @@ export default function ProblemDetailPage() {
|
||||
token
|
||||
);
|
||||
setSubmitResp(resp);
|
||||
setMobilePanel("code");
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
@@ -796,6 +817,7 @@ export default function ProblemDetailPage() {
|
||||
body: JSON.stringify({ code: sourceCode, input: runInput }),
|
||||
});
|
||||
setRunResp(resp);
|
||||
setMobilePanel("code");
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
@@ -970,18 +992,106 @@ export default function ProblemDetailPage() {
|
||||
window.print();
|
||||
};
|
||||
|
||||
const focusOutput = () => {
|
||||
setMobilePanel("code");
|
||||
window.setTimeout(() => {
|
||||
outputAnchorRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}, 80);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-[1400px] px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8 font-mono">
|
||||
<h1 className="text-xl font-bold max-[390px]:text-lg sm:text-2xl text-[color:var(--mc-diamond)] mc-text-shadow">
|
||||
{tx("任务详情与试炼", "Mission Details")}
|
||||
</h1>
|
||||
<PageCrumbs
|
||||
items={[
|
||||
{ label: tx("主城", "Town"), href: "/" },
|
||||
{ label: tx("任务", "Quests"), href: "/problems" },
|
||||
{ label: problem ? `${problem.id}` : `${id}` },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h1 className="text-xl font-bold max-[390px]:text-lg sm:text-2xl text-[color:var(--mc-diamond)] mc-text-shadow">
|
||||
{tx("任务详情与试炼", "Mission Details")}
|
||||
</h1>
|
||||
<Link href="/problems" className="mc-btn min-h-[44px]">
|
||||
{tx("返回任务板", "Back to Quest Board")}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-[color:var(--mc-stone)]">
|
||||
<p>{tx("题面、代码、评测输出都在本页完成。", "Solve, run, and review outputs on this page.")}</p>
|
||||
<HintTip title={tx("页面说明", "Page Notes")} align="left">
|
||||
<ul className="list-disc space-y-1 pl-4">
|
||||
<li>{tx("支持自动保存草稿,默认每分钟检查并保存。", "Draft auto-save checks and persists changes every minute.")}</li>
|
||||
<li>{tx("可提交学习笔记评分,60 分对应 +6 XP。", "Learning notes can be scored; 60 points yields +6 XP.")}</li>
|
||||
<li>{tx("先知题解支持预览状态、生成、解锁与一键写入代码。", "Oracle solutions support preview, generation, unlock, and one-click code insert.")}</li>
|
||||
</ul>
|
||||
</HintTip>
|
||||
</div>
|
||||
|
||||
{loading && <p className="mt-4 text-sm text-[color:var(--mc-stone)]">{tx("加载地图中...", "Loading Map...")}</p>}
|
||||
{error && <p className="mt-4 text-sm text-[color:var(--mc-red)]">{error}</p>}
|
||||
{!loading && error && !problem && (
|
||||
<div className="mt-4 rounded border-[3px] border-black bg-[color:var(--surface)] p-4 text-sm shadow-[4px_4px_0_rgba(0,0,0,0.45)]">
|
||||
<p className="font-bold text-[color:var(--mc-red)]">
|
||||
{isProblemNotFound
|
||||
? tx("该任务不存在或已下线。", "This problem does not exist or was removed.")
|
||||
: tx("任务加载失败。", "Failed to load this problem.")}
|
||||
</p>
|
||||
<p className="mt-2 text-[color:var(--mc-stone)]">{error}</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button className="mc-btn mc-btn-primary min-h-[44px]" onClick={() => setReloadNonce((v) => v + 1)}>
|
||||
{tx("重试加载", "Retry")}
|
||||
</button>
|
||||
<Link href="/problems" className="mc-btn min-h-[44px]">
|
||||
{tx("返回任务板", "Back to Quest Board")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!!error && !!problem && <p className="mt-4 text-sm text-[color:var(--mc-red)]">{error}</p>}
|
||||
|
||||
{problem && (
|
||||
<div className="problem-detail-grid mt-4 grid gap-4 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]">
|
||||
<section className="problem-print-section rounded-none border-[3px] border-black bg-[color:var(--mc-plank-light)] p-4 sm:p-5 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="rounded border-[2px] border-black bg-black/20 px-3 py-2 text-xs text-[color:var(--mc-gold)]">
|
||||
{tx("任务简报", "Mission Brief")}
|
||||
</div>
|
||||
<div className="rounded border-[3px] border-black bg-[color:var(--surface)] p-3 text-xs text-[color:var(--mc-stone)] shadow-[3px_3px_0_rgba(0,0,0,0.35)]">
|
||||
<p className="font-bold text-[color:var(--mc-gold)]">{tx("本任务进度", "Mission Progress")}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{missionProgress.map((item) => (
|
||||
<span
|
||||
key={item.label}
|
||||
className={`rounded border border-black px-2 py-1 ${item.done
|
||||
? "bg-[color:var(--mc-grass-top)] text-white"
|
||||
: "bg-black/30 text-[color:var(--mc-stone)]"
|
||||
}`}
|
||||
>
|
||||
{item.done ? "✅" : "⬜"} {item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 xl:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className={`mc-btn min-h-[44px] ${mobilePanel === "statement" ? "mc-btn-primary" : ""}`}
|
||||
onClick={() => setMobilePanel("statement")}
|
||||
>
|
||||
{tx("题面", "Statement")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`mc-btn min-h-[44px] ${mobilePanel === "code" ? "mc-btn-primary" : ""}`}
|
||||
onClick={() => setMobilePanel("code")}
|
||||
>
|
||||
{tx("代码与输出", "Code & Output")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="problem-detail-grid grid gap-4 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]">
|
||||
<section className={`problem-print-section rounded-none border-[3px] border-black bg-[color:var(--mc-plank-light)] p-4 sm:p-5 shadow-[4px_4px_0_rgba(0,0,0,0.5)] ${mobilePanel === "statement" ? "block" : "hidden"} xl:block`}>
|
||||
<div className="mb-2 text-xs font-semibold text-[color:var(--mc-stone-dark)]">
|
||||
{tx("阅读区", "Reading Zone")}
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-black mc-text-shadow-sm">{problem.title}</h2>
|
||||
<div className="mt-1 flex items-center gap-2 text-sm">
|
||||
<span className={`font-bold ${problem.difficulty > 6 ? "text-[color:var(--mc-diamond)]" :
|
||||
@@ -994,6 +1104,22 @@ export default function ProblemDetailPage() {
|
||||
<span className="text-zinc-500">·</span>
|
||||
<span className="text-zinc-800">{tx("来源", "Origin")}: {problem.source}</span>
|
||||
</div>
|
||||
{relatedKnowledge.length > 0 && (
|
||||
<div className="mt-2 rounded border-[2px] border-black bg-[color:var(--mc-plank)] p-2">
|
||||
<p className="text-xs font-bold text-black">{tx("相关知识", "Related Knowledge")}</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{relatedKnowledge.map((item) => (
|
||||
<Link
|
||||
key={item}
|
||||
href={`/kb?q=${encodeURIComponent(item)}`}
|
||||
className="rounded border border-black bg-[color:var(--mc-plank-light)] px-2 py-1 text-[11px] text-black hover:bg-white"
|
||||
>
|
||||
{item}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="print-hidden mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
@@ -1007,24 +1133,15 @@ export default function ProblemDetailPage() {
|
||||
? tx("🖨️ 打印题目+答案", "🖨️ Print with Answer")
|
||||
: tx("🖨️ 打印题目", "🖨️ Print Problem")}
|
||||
</button>
|
||||
<button
|
||||
className="mc-btn text-sm py-1"
|
||||
onClick={() => setShowPolicyTips((v) => !v)}
|
||||
>
|
||||
{showPolicyTips ? tx("收起公约", "Hide Rules") : tx("福建考场公约", "Fujian Rules")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showPolicyTips && (
|
||||
<div className="mt-3 rounded border-[2px] border-black bg-[color:var(--mc-plank)] p-3 shadow-[2px_2px_0_rgba(0,0,0,0.4)]">
|
||||
<p className="font-bold text-black mb-1">{tx("考场生存指南:", "Survival Guide:")}</p>
|
||||
<ul className="list-disc space-y-1 pl-4 text-xs text-black">
|
||||
<HintTip title={tx("福建考场公约", "Fujian Rules")} align="left" widthClassName="w-80 sm:w-96">
|
||||
<p className="mb-1 font-semibold text-zinc-900">{tx("考场生存指南", "Contest Survival Guide")}</p>
|
||||
<ul className="list-disc space-y-1 pl-4">
|
||||
{policyTips.map((tip, idx) => (
|
||||
<li key={idx}>{tip}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</HintTip>
|
||||
</div>
|
||||
|
||||
<div className={`print-content mt-4 ${printAnswerMarkdown ? "print-with-answer" : ""}`}>
|
||||
<MarkdownRenderer markdown={statementMarkdown} className="problem-markdown text-black" />
|
||||
@@ -1037,17 +1154,20 @@ export default function ProblemDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded border-[3px] border-black bg-[color:var(--mc-plank)] p-3 shadow-[3px_3px_0_rgba(0,0,0,0.45)]">
|
||||
<div className="print-hidden mt-5 rounded border-[3px] border-black bg-[color:var(--mc-plank)] p-3 shadow-[3px_3px_0_rgba(0,0,0,0.45)]">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="font-bold text-black">📜 {tx("探索笔记(看完视频后记录)", "Explorer Notes (record after watching)")}</h3>
|
||||
<h3 className="font-bold text-black">📜 {tx("探索笔记", "Explorer Notes")}</h3>
|
||||
<span className="text-xs text-zinc-700">⚡ {tx("满分60 = 经验值+6", "Max 60 = +6 XP")}</span>
|
||||
<HintTip title={tx("笔记建议", "Note Guide")} align="left">
|
||||
<p>{tx("建议记录:题意、思路、关键代码、踩坑与修复、复盘结论。", "Suggested structure: understanding, approach, key code, pitfalls/fixes, recap.")}</p>
|
||||
</HintTip>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
className="mt-2 w-full rounded-none border-[2px] border-black bg-[color:var(--mc-plank-light)] p-2 text-xs text-black shadow-[2px_2px_0_rgba(0,0,0,0.35)]"
|
||||
rows={8}
|
||||
value={noteText}
|
||||
placeholder={tx("⛏️ 记录你的探索:题意理解/解题思路/代码配方/踩坑与修复/总结", "⛏️ Log your adventure: problem understanding / approach / code recipe / pitfalls / summary")}
|
||||
placeholder={tx("⛏️ 记录题意、思路、关键代码与复盘结论", "⛏️ Record understanding, approach, key code, and recap")}
|
||||
onChange={(e) => setNoteText(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -1067,7 +1187,7 @@ export default function ProblemDetailPage() {
|
||||
<button className="mc-btn mc-btn-success text-xs" onClick={() => void saveLearningNote()} disabled={noteSaving}>
|
||||
{noteSaving ? tx("刻录中...", "Engraving...") : tx("💾 存入宝典", "💾 Save to Codex")}
|
||||
</button>
|
||||
<button className="mc-btn mc-btn-primary text-xs" onClick={() => void scoreLearningNote()} disabled={noteScoring}>
|
||||
<button className="mc-btn mc-btn-primary text-xs" onClick={() => void scoreLearningNote()} disabled={noteScoring || !noteText.trim()}>
|
||||
{noteScoring ? tx("⛏️ 鉴定中...", "⛏️ Appraising...") : tx("⛏️ 矿石鉴定", "⛏️ Appraise Ore")}
|
||||
</button>
|
||||
{noteScore !== null && noteRating !== null && (
|
||||
@@ -1084,9 +1204,12 @@ export default function ProblemDetailPage() {
|
||||
{noteImages.map((fn) => (
|
||||
<div key={fn} className="rounded border-2 border-black bg-[color:var(--mc-plank-light)] p-1 shadow-[2px_2px_0_rgba(0,0,0,0.25)]">
|
||||
<a href={`/files/note-images/${fn}`} target="_blank" rel="noreferrer">
|
||||
<img
|
||||
<NextImage
|
||||
src={`/files/note-images/${fn}`}
|
||||
alt={fn}
|
||||
width={240}
|
||||
height={80}
|
||||
unoptimized
|
||||
className="h-20 w-full object-cover"
|
||||
/>
|
||||
</a>
|
||||
@@ -1110,7 +1233,10 @@ export default function ProblemDetailPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4 print:hidden">
|
||||
<section className={`print:hidden flex-col gap-4 ${mobilePanel === "code" ? "flex" : "hidden"} xl:flex`}>
|
||||
<div className="text-xs font-semibold text-[color:var(--mc-gold)]">
|
||||
{tx("战斗区", "Combat Zone")}
|
||||
</div>
|
||||
<div className="rounded-none border-[3px] border-black bg-[color:var(--mc-obsidian)] p-1 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||
<div className="relative">
|
||||
<CodeEditor
|
||||
@@ -1178,21 +1304,22 @@ export default function ProblemDetailPage() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
className="mc-btn mc-btn-primary w-full py-3 text-sm"
|
||||
className="mc-btn mc-btn-primary w-full min-h-[44px] py-3 text-sm"
|
||||
onClick={() => void runCode()}
|
||||
disabled={runLoading || submitLoading}
|
||||
>
|
||||
{runLoading ? tx("施法中...", "Casting...") : tx("试运行", "Test Run")}
|
||||
{runLoading ? tx("运行中...", "Running...") : tx("运行", "Run")}
|
||||
</button>
|
||||
<button
|
||||
className="mc-btn mc-btn-success w-full py-3 text-sm"
|
||||
className="mc-btn mc-btn-success w-full min-h-[44px] py-3 text-sm"
|
||||
onClick={() => void submit()}
|
||||
disabled={submitLoading || runLoading}
|
||||
>
|
||||
{submitLoading ? tx("施法中...", "Casting...") : tx("施放咒语", "Cast Spell")}
|
||||
{submitLoading ? tx("提交中...", "Submitting...") : tx("提交评测", "Submit")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref={outputAnchorRef} />
|
||||
{(runResp || submitResp) && (
|
||||
<div className="animation-slide-up space-y-3">
|
||||
{runResp && (
|
||||
@@ -1206,7 +1333,7 @@ export default function ProblemDetailPage() {
|
||||
</div>
|
||||
|
||||
{runResp.compile_log && (
|
||||
<details className="mb-2">
|
||||
<details className="mb-2" open={runErrorCount > 0 || runResp.status === "CE"}>
|
||||
<summary className="cursor-pointer text-xs font-bold text-[color:var(--mc-red)]">{tx("编译日志", "Compile Log")}</summary>
|
||||
<pre className="mt-1 max-h-32 overflow-auto whitespace-pre-wrap rounded bg-black p-2 text-xs text-red-300 font-mono">
|
||||
{runResp.compile_log}
|
||||
@@ -1270,6 +1397,9 @@ export default function ProblemDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-none border-[3px] border-black bg-[color:var(--surface)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||
<div className="mb-2 text-xs font-semibold text-[color:var(--mc-gold)]">
|
||||
{tx("智库区", "Oracle Zone")}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-bold text-[color:var(--mc-diamond)] mc-text-shadow-sm">{tx("先知启示 (AI 题解)", "Oracle's Wisdom")}</h3>
|
||||
<button
|
||||
@@ -1298,15 +1428,18 @@ export default function ProblemDetailPage() {
|
||||
{/* Unlock Logic: Check if we need to show the unlock button */}
|
||||
{solutionData?.has_solutions && solutionData?.access?.mode !== "full" && (
|
||||
<div className="bg-[color:var(--mc-wood-dark)]/10 p-4 border-2 border-[color:var(--mc-gold)]/50 rounded-none mb-4">
|
||||
<p className="text-sm text-[color:var(--mc-red)] font-bold mb-3 flex items-start gap-2">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-bold text-[color:var(--mc-red)]">
|
||||
<span className="text-xl">⚠️</span>
|
||||
<span>
|
||||
{tx(
|
||||
"查看题解后,本题分数将被锁定,再次提交无法获得更高评分。",
|
||||
"Viewing the solution will LOCK your score for this problem. Future submissions will not increase your rating."
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
<span>{tx("解锁后本题将锁分。", "Unlocking will lock this problem's score.")}</span>
|
||||
<HintTip title={tx("锁分说明", "Score Lock Notes")} align="left">
|
||||
<p>
|
||||
{tx(
|
||||
"查看完整题解后,本题后续提交不再提升积分;是否收费或免费由后端规则判定。",
|
||||
"After full solution unlock, future submissions on this problem no longer increase rating; billing/free eligibility is backend-controlled."
|
||||
)}
|
||||
</p>
|
||||
</HintTip>
|
||||
</div>
|
||||
|
||||
{unlockConfirm ? (
|
||||
<div className="flex gap-3 animate-in fade-in zoom-in duration-200">
|
||||
@@ -1471,6 +1604,35 @@ export default function ProblemDetailPage() {
|
||||
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="fixed inset-x-0 bottom-[calc(4.4rem+env(safe-area-inset-bottom))] z-40 border-t-[3px] border-black bg-[color:var(--surface)]/95 px-3 py-2 backdrop-blur xl:hidden print:hidden">
|
||||
<div className="mx-auto grid max-w-5xl grid-cols-3 gap-2">
|
||||
<button
|
||||
className="mc-btn mc-btn-primary min-h-[44px]"
|
||||
onClick={() => {
|
||||
setMobilePanel("code");
|
||||
void runCode();
|
||||
}}
|
||||
disabled={runLoading || submitLoading}
|
||||
>
|
||||
{runLoading ? tx("运行中", "Running") : tx("运行", "Run")}
|
||||
</button>
|
||||
<button
|
||||
className="mc-btn mc-btn-success min-h-[44px]"
|
||||
onClick={() => {
|
||||
setMobilePanel("code");
|
||||
void submit();
|
||||
}}
|
||||
disabled={submitLoading || runLoading}
|
||||
>
|
||||
{submitLoading ? tx("提交中", "Submitting") : tx("提交", "Submit")}
|
||||
</button>
|
||||
<button className="mc-btn min-h-[44px]" onClick={focusOutput}>
|
||||
{tx("输出", "Output")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -5,18 +5,19 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Book,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Globe,
|
||||
Search,
|
||||
Shield,
|
||||
Sword,
|
||||
Tag,
|
||||
Trophy,
|
||||
Filter,
|
||||
ArrowUpDown
|
||||
Filter
|
||||
} from "lucide-react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
@@ -219,6 +220,9 @@ export default function ProblemsPage() {
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
const [expandedMobileCards, setExpandedMobileCards] = useState<Set<number>>(new Set());
|
||||
const [showAdminLogEntry, setShowAdminLogEntry] = useState(false);
|
||||
|
||||
const preset = useMemo(
|
||||
() => PRESETS.find((item) => item.key === presetKey) ?? PRESETS[0],
|
||||
@@ -258,6 +262,35 @@ export default function ProblemsPage() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
const q = new URLSearchParams(window.location.search).get("q")?.trim() ?? "";
|
||||
if (!q) return;
|
||||
setKeywordInput(q);
|
||||
setKeyword(q);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
const token = readToken();
|
||||
if (!token) {
|
||||
setShowAdminLogEntry(false);
|
||||
return;
|
||||
}
|
||||
void apiFetch<{ username?: string }>("/api/v1/me", {}, token)
|
||||
.then((me) => {
|
||||
if (!canceled) {
|
||||
setShowAdminLogEntry((me?.username ?? "") === "admin");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!canceled) setShowAdminLogEntry(false);
|
||||
});
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
items.map((problem) => {
|
||||
@@ -277,6 +310,82 @@ export default function ProblemsPage() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const filterControls = (mobile: boolean) => (
|
||||
<>
|
||||
<div className={`relative ${mobile ? "" : ""}`}>
|
||||
<Filter className="absolute left-2 top-2.5 h-4 w-4 text-zinc-400" />
|
||||
<select
|
||||
className="w-full rounded-none border-2 border-black bg-[color:var(--surface)] text-white pl-8 pr-3 py-2 text-sm appearance-none min-h-[44px]"
|
||||
value={presetKey}
|
||||
onChange={(e) => {
|
||||
selectPreset(e.target.value);
|
||||
}}
|
||||
>
|
||||
{PRESETS.map((item) => (
|
||||
<option key={item.key} value={item.key}>
|
||||
{isZh ? item.labelZh : item.labelEn}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<input
|
||||
className={`rounded-none border-2 border-black bg-[color:var(--surface)] text-white px-3 py-2 text-sm min-h-[44px] ${mobile ? "" : "lg:col-span-2"}`}
|
||||
placeholder={tx("搜索任务 ID / 标题...", "Search Quest ID / Keyword...")}
|
||||
value={keywordInput}
|
||||
onChange={(e) => setKeywordInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") applySearch();
|
||||
}}
|
||||
/>
|
||||
|
||||
<select
|
||||
className="rounded-none border-2 border-black bg-[color:var(--surface)] text-white px-3 py-2 text-sm min-h-[44px]"
|
||||
value={difficulty}
|
||||
onChange={(e) => {
|
||||
setDifficulty(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{DIFFICULTY_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{tx("难度: ", "Tier: ")} {isZh ? item.labelZh : item.labelEn}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="rounded-none border-2 border-black bg-[color:var(--surface)] text-white px-3 py-2 text-sm min-h-[44px]"
|
||||
value={`${orderBy}:${order}`}
|
||||
onChange={(e) => {
|
||||
const [ob, od] = e.target.value.split(":");
|
||||
setOrderBy(ob || "id");
|
||||
setOrder(od || "asc");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="id:asc">{tx("编号升序", "ID Asc")}</option>
|
||||
<option value="id:desc">{tx("编号降序", "ID Desc")}</option>
|
||||
<option value="difficulty:asc">{tx("难度升序", "Tier Asc")}</option>
|
||||
<option value="difficulty:desc">{tx("难度降序", "Tier Desc")}</option>
|
||||
<option value="created_at:desc">{tx("最新发布", "Newest")}</option>
|
||||
<option value="title:asc">{tx("标题 A-Z", "Title A-Z")}</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
className="mc-btn mc-btn-primary flex items-center justify-center gap-2 min-h-[44px]"
|
||||
onClick={() => {
|
||||
applySearch();
|
||||
if (mobile) setMobileFiltersOpen(false);
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<Search size={16} />
|
||||
{loading ? tx("加载中...", "Loading...") : tx("搜索", "Search")}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
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 font-mono">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
@@ -284,18 +393,27 @@ export default function ProblemsPage() {
|
||||
<h1 className="text-xl font-bold max-[390px]:text-lg sm:text-2xl text-[color:var(--mc-diamond)] mc-text-shadow">
|
||||
{tx("任务布告栏", "Quest Board")}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-[color:var(--mc-stone)]">
|
||||
{tx(
|
||||
"接受任务,赚取 XP,提升等级!",
|
||||
"Accept Quests, Earn XP, Level Up!"
|
||||
)}
|
||||
<p className="mt-1 text-sm text-[color:var(--mc-stone)] inline-flex items-center gap-2">
|
||||
{tx("筛选任务并开始训练。", "Filter quests and start training.")}
|
||||
<HintTip title={tx("筛选说明", "Filter Guide")} align="left">
|
||||
{tx(
|
||||
"可按预设频道、关键词、难度和排序筛选。建议从 C++ 基础或 CSP-J 频道开始,逐步提升到 CSP-S。",
|
||||
"Use preset channels, keywords, difficulty, and sorting to filter. It is recommended to start with C++ Basics or CSP-J and then move up to CSP-S."
|
||||
)}
|
||||
</HintTip>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap items-center gap-3 text-sm sm:w-auto sm:justify-end">
|
||||
<p className="text-[color:var(--mc-gold)]">{tx("总任务数: ", "Total Quests: ")} {totalCount}</p>
|
||||
<Link className="mc-btn w-full text-center sm:w-auto" href="/backend-logs">
|
||||
{tx("服务器日志", "Server Logs")}
|
||||
</Link>
|
||||
{showAdminLogEntry ? (
|
||||
<Link className="mc-btn w-full text-center sm:w-auto min-h-[44px]" href="/backend-logs">
|
||||
{tx("服务器日志", "Server Logs")}
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-xs text-[color:var(--mc-stone-dark)]">
|
||||
{tx("仅管理员可查看服务器日志", "Server logs are admin-only")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -306,7 +424,7 @@ export default function ProblemsPage() {
|
||||
<button
|
||||
key={card.presetKey}
|
||||
type="button"
|
||||
className={`rounded-xl border px-4 py-3 text-left transition flex items-center gap-4 ${active
|
||||
className={`rounded-xl border px-4 py-3 text-left transition flex items-center gap-4 min-h-[84px] ${active
|
||||
? "bg-[color:var(--mc-grass-dark)] text-white"
|
||||
: "bg-[color:var(--mc-plank)] text-black hover:bg-[color:var(--mc-plank-light)]"
|
||||
}`}
|
||||
@@ -326,75 +444,26 @@ export default function ProblemsPage() {
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="mt-4 grid gap-3 rounded-xl border bg-[color:var(--mc-stone-dark)] p-4 md:grid-cols-2 lg:grid-cols-6 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||
<div className="relative">
|
||||
<Filter className="absolute left-2 top-2.5 h-4 w-4 text-zinc-400" />
|
||||
<select
|
||||
className="w-full rounded-none border-2 border-black bg-[color:var(--surface)] text-white pl-8 pr-3 py-2 text-sm appearance-none"
|
||||
value={presetKey}
|
||||
onChange={(e) => {
|
||||
selectPreset(e.target.value);
|
||||
}}
|
||||
>
|
||||
{PRESETS.map((item) => (
|
||||
<option key={item.key} value={item.key}>
|
||||
{isZh ? item.labelZh : item.labelEn}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<input
|
||||
className="rounded-none border-2 border-black bg-[color:var(--surface)] text-white px-3 py-2 text-sm lg:col-span-2"
|
||||
placeholder={tx("搜索任务 ID / 标题...", "Search Quest ID / Keyword...")}
|
||||
value={keywordInput}
|
||||
onChange={(e) => setKeywordInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") applySearch();
|
||||
}}
|
||||
/>
|
||||
|
||||
<select
|
||||
className="rounded-none border-2 border-black bg-[color:var(--surface)] text-white px-3 py-2 text-sm"
|
||||
value={difficulty}
|
||||
onChange={(e) => {
|
||||
setDifficulty(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{DIFFICULTY_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{tx("难度: ", "Tier: ")} {isZh ? item.labelZh : item.labelEn}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="rounded-none border-2 border-black bg-[color:var(--surface)] text-white px-3 py-2 text-sm"
|
||||
value={`${orderBy}:${order}`}
|
||||
onChange={(e) => {
|
||||
const [ob, od] = e.target.value.split(":");
|
||||
setOrderBy(ob || "id");
|
||||
setOrder(od || "asc");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="id:asc">{tx("编号升序", "ID Asc")}</option>
|
||||
<option value="id:desc">{tx("编号降序", "ID Desc")}</option>
|
||||
<option value="difficulty:asc">{tx("难度升序", "Tier Asc")}</option>
|
||||
<option value="difficulty:desc">{tx("难度降序", "Tier Desc")}</option>
|
||||
<option value="created_at:desc">{tx("最新发布", "Newest")}</option>
|
||||
<option value="title:asc">{tx("标题 A-Z", "Title A-Z")}</option>
|
||||
</select>
|
||||
|
||||
<section className="mt-4 md:hidden">
|
||||
<button
|
||||
className="mc-btn mc-btn-primary flex items-center justify-center gap-2"
|
||||
onClick={applySearch}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
className="mc-btn sticky top-2 z-20 w-full min-h-[44px] flex items-center justify-center gap-2"
|
||||
onClick={() => setMobileFiltersOpen((v) => !v)}
|
||||
>
|
||||
<Search size={16} />
|
||||
{loading ? tx("加载中...", "Loading...") : tx("搜索", "Search")}
|
||||
<Filter size={16} />
|
||||
{mobileFiltersOpen
|
||||
? tx("收起筛选", "Hide Filters")
|
||||
: tx("筛选与搜索", "Filter & Search")}
|
||||
</button>
|
||||
{mobileFiltersOpen && (
|
||||
<div className="mt-2 grid gap-3 rounded-xl border bg-[color:var(--mc-stone-dark)] p-3 shadow-[4px_4px_0_rgba(0,0,0,0.5)]">
|
||||
{filterControls(true)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mt-4 hidden gap-3 rounded-xl border bg-[color:var(--mc-stone-dark)] p-4 shadow-[4px_4px_0_rgba(0,0,0,0.5)] md:grid md:grid-cols-2 lg:grid-cols-6">
|
||||
{filterControls(false)}
|
||||
</section>
|
||||
|
||||
{error && <p className="mt-3 text-sm text-[color:var(--mc-red)]">{error}</p>}
|
||||
@@ -404,6 +473,7 @@ export default function ProblemsPage() {
|
||||
{rows.map(({ problem, profile }) => {
|
||||
const pid = resolvePid(problem, profile);
|
||||
const tags = resolveTags(profile);
|
||||
const expanded = expandedMobileCards.has(problem.id);
|
||||
return (
|
||||
<article key={problem.id} className="space-y-2 p-3 bg-[color:var(--surface)] text-zinc-100">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -414,24 +484,52 @@ export default function ProblemsPage() {
|
||||
{difficultyIcon(problem.difficulty)} T{problem.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-[color:var(--mc-stone)]">{tx("完成率:", "Clear Rate: ")}{resolvePassRate(problem)}</p>
|
||||
{problem.user_ac !== undefined && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs">
|
||||
{problem.user_ac
|
||||
? <span className="text-[color:var(--mc-green)] font-bold">✅ AC</span>
|
||||
? <span className="text-[color:var(--mc-green)] font-bold">{tx("已通关", "Cleared")}</span>
|
||||
: problem.user_fail_count && problem.user_fail_count > 0
|
||||
? <span className="text-[color:var(--mc-red)]">❌ ×{problem.user_fail_count}</span>
|
||||
: <span className="text-[color:var(--mc-stone-dark)]">-</span>}
|
||||
? <span className="text-[color:var(--mc-red)]">{tx("进行中", "In Progress")} ×{problem.user_fail_count}</span>
|
||||
: <span className="text-[color:var(--mc-stone)]">{tx("未开始", "Not Started")}</span>}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.length === 0 && <span className="text-xs text-[color:var(--mc-stone-dark)]">-</span>}
|
||||
{tags.map((tag) => (
|
||||
<span key={tag} className="border border-black bg-[color:var(--mc-stone-dark)] px-2 py-0.5 text-xs text-white">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="mc-btn px-2 py-1 text-[10px]"
|
||||
onClick={() =>
|
||||
setExpandedMobileCards((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(problem.id)) next.delete(problem.id);
|
||||
else next.add(problem.id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
{expanded ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ChevronUp size={12} />
|
||||
{tx("收起", "Less")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ChevronDown size={12} />
|
||||
{tx("展开", "More")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<>
|
||||
<p className="text-xs text-[color:var(--mc-stone)]">{tx("完成率:", "Clear Rate: ")}{resolvePassRate(problem)}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.length === 0 && <span className="text-xs text-[color:var(--mc-stone-dark)]">-</span>}
|
||||
{tags.map((tag) => (
|
||||
<span key={tag} className="border border-black bg-[color:var(--mc-stone-dark)] px-2 py-0.5 text-xs text-white">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
@@ -518,6 +616,16 @@ export default function ProblemsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-4 flex justify-end md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="mc-btn min-h-[40px] px-3 py-1 text-xs"
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||
>
|
||||
{tx("回到顶部", "Back to Top")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { AlertTriangle, Code2, Monitor, Play, Terminal, Timer } from "lucide-react";
|
||||
import { formatMsTime } from "@/lib/time";
|
||||
import { AlertTriangle, Code2, Loader2, Monitor, Play, RotateCcw, Save, Terminal, Timer } from "lucide-react";
|
||||
|
||||
type RunResult = {
|
||||
status: string;
|
||||
@@ -16,7 +18,11 @@ type RunResult = {
|
||||
|
||||
const starterCode = `#include <bits/stdc++.h>
|
||||
using namespace std;
|
||||
|
||||
int main() {
|
||||
ios::sync_with_stdio(false);
|
||||
cin.tie(nullptr);
|
||||
|
||||
string s;
|
||||
getline(cin, s);
|
||||
cout << s << "\\n";
|
||||
@@ -24,6 +30,9 @@ int main() {
|
||||
}
|
||||
`;
|
||||
|
||||
const LOCAL_CODE_KEY = "csp.run.workspace.code";
|
||||
const LOCAL_INPUT_KEY = "csp.run.workspace.input";
|
||||
|
||||
export default function RunPage() {
|
||||
const { tx } = useI18nText();
|
||||
const [code, setCode] = useState(starterCode);
|
||||
@@ -31,6 +40,41 @@ export default function RunPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [result, setResult] = useState<RunResult | null>(null);
|
||||
const [runningSec, setRunningSec] = useState(0);
|
||||
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const savedCode = window.localStorage.getItem(LOCAL_CODE_KEY);
|
||||
const savedInput = window.localStorage.getItem(LOCAL_INPUT_KEY);
|
||||
if (savedCode) setCode(savedCode);
|
||||
if (savedInput !== null) setInput(savedInput);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(LOCAL_CODE_KEY, code);
|
||||
setLastSavedAt(Date.now());
|
||||
}, [code]);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(LOCAL_INPUT_KEY, input);
|
||||
setLastSavedAt(Date.now());
|
||||
}, [input]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
setRunningSec(0);
|
||||
return;
|
||||
}
|
||||
const timer = window.setInterval(() => {
|
||||
setRunningSec((v) => v + 1);
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loading]);
|
||||
|
||||
const shouldExpandCompileLog = useMemo(
|
||||
() => Boolean(result?.compile_log && (result.status === "CE" || result.status === "RE" || result.status === "WA")),
|
||||
[result?.compile_log, result?.status]
|
||||
);
|
||||
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
@@ -49,12 +93,52 @@ export default function RunPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const resetTemplate = () => {
|
||||
setCode(starterCode);
|
||||
setInput("hello csp");
|
||||
setResult(null);
|
||||
setError("");
|
||||
};
|
||||
|
||||
const restoreLastLocal = () => {
|
||||
const savedCode = window.localStorage.getItem(LOCAL_CODE_KEY);
|
||||
const savedInput = window.localStorage.getItem(LOCAL_INPUT_KEY);
|
||||
if (savedCode) setCode(savedCode);
|
||||
if (savedInput !== null) setInput(savedInput);
|
||||
};
|
||||
|
||||
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 flex items-center gap-2">
|
||||
<Terminal size={24} />
|
||||
{tx("在线 C++ 编写 / 编译 / 运行", "Online C++ Editor / Compile / Run")}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl flex items-center gap-2">
|
||||
<Terminal size={24} />
|
||||
{tx("在线 C++ 编写 / 编译 / 运行", "Online C++ Editor / Compile / Run")}
|
||||
</h1>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button className="mc-btn min-h-[44px]" onClick={restoreLastLocal}>
|
||||
<Save size={14} />
|
||||
{tx("恢复上次代码", "Restore Last")}
|
||||
</button>
|
||||
<button className="mc-btn min-h-[44px]" onClick={resetTemplate}>
|
||||
<RotateCcw size={14} />
|
||||
{tx("加载模板", "Load Template")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-zinc-400">
|
||||
<p>
|
||||
{lastSavedAt
|
||||
? tx(`本地草稿已自动保存:${formatMsTime(lastSavedAt)}`, `Local draft saved at ${formatMsTime(lastSavedAt)}`)
|
||||
: tx("本地草稿将自动保存", "Local draft auto-save is enabled")}
|
||||
</p>
|
||||
<HintTip title={tx("运行说明", "Run Guide")} align="left">
|
||||
{tx(
|
||||
"这里用于快速验证代码片段,不会保存到题目提交记录。若运行时间持续过长,建议检查死循环或等待条件。",
|
||||
"This runner is for quick snippet checks and does not create formal submission records. If execution keeps running, check for infinite loops or blocking waits."
|
||||
)}
|
||||
</HintTip>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<section className="rounded-xl border bg-white p-4">
|
||||
@@ -78,13 +162,13 @@ export default function RunPage() {
|
||||
/>
|
||||
|
||||
<button
|
||||
className="mt-3 w-full rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50 sm:w-auto"
|
||||
className="mt-3 w-full min-h-[44px] rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50 sm:w-auto"
|
||||
onClick={() => void run()}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Play size={16} className="animate-spin" />
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
{tx("运行中...", "Running...")}
|
||||
</span>
|
||||
) : (
|
||||
@@ -95,6 +179,13 @@ export default function RunPage() {
|
||||
)}
|
||||
</button>
|
||||
|
||||
{loading && (
|
||||
<p className="mt-2 text-xs text-zinc-500 flex items-center gap-2">
|
||||
<Timer size={14} />
|
||||
{tx(`已运行 ${runningSec}s,若超过 10s 可重试`, `Running for ${runningSec}s. Retry if it exceeds 10s.`)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
|
||||
{result && (
|
||||
@@ -122,12 +213,12 @@ export default function RunPage() {
|
||||
{result.stderr || "(empty)"}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium">compile_log</h3>
|
||||
<details open={shouldExpandCompileLog}>
|
||||
<summary className="cursor-pointer font-medium">compile_log</summary>
|
||||
<pre className="mt-1 overflow-auto rounded bg-zinc-900 p-3 text-xs text-zinc-100">
|
||||
{result.compile_log || "(empty)"}
|
||||
</pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -4,10 +4,13 @@ import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { MarkdownRenderer } from "@/components/markdown-renderer";
|
||||
import { PageCrumbs } from "@/components/page-crumbs";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { formatUnixDateTime } from "@/lib/time";
|
||||
|
||||
type SubmissionAnalysis = {
|
||||
feedback_md: string;
|
||||
@@ -38,12 +41,13 @@ type Submission = {
|
||||
answer_view_count: number;
|
||||
answer_view_total_cost: number;
|
||||
last_answer_view_at: number | null;
|
||||
same_user_prev_submission_id: number | null;
|
||||
same_user_next_submission_id: number | null;
|
||||
analysis: SubmissionAnalysis | null;
|
||||
};
|
||||
|
||||
function fmtTs(v: number | null): string {
|
||||
if (!v) return "-";
|
||||
return new Date(v * 1000).toLocaleString();
|
||||
return formatUnixDateTime(v);
|
||||
}
|
||||
|
||||
function fmtRatingDelta(delta: number): string {
|
||||
@@ -108,6 +112,13 @@ export default function SubmissionDetailPage() {
|
||||
|
||||
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">
|
||||
<PageCrumbs
|
||||
items={[
|
||||
{ label: tx("主城", "Town"), href: "/" },
|
||||
{ label: tx("施法", "Spell Log"), href: "/submissions" },
|
||||
{ label: `#${id}` },
|
||||
]}
|
||||
/>
|
||||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
|
||||
{tx("提交详情", "Submission Detail")} #{id}
|
||||
</h1>
|
||||
@@ -117,6 +128,41 @@ export default function SubmissionDetailPage() {
|
||||
{data && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<section className="rounded-xl border bg-white p-4 text-sm">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-zinc-500">
|
||||
{tx("同用户快速查看", "Quick view (same user)")}
|
||||
</span>
|
||||
<Link
|
||||
href={
|
||||
data.same_user_prev_submission_id
|
||||
? `/submissions/${data.same_user_prev_submission_id}`
|
||||
: "#"
|
||||
}
|
||||
aria-disabled={!data.same_user_prev_submission_id}
|
||||
className={`rounded border px-2 py-1 text-xs ${
|
||||
data.same_user_prev_submission_id
|
||||
? "hover:bg-zinc-100"
|
||||
: "pointer-events-none cursor-not-allowed opacity-40"
|
||||
}`}
|
||||
>
|
||||
{tx("← 上一个(更早)", "← Previous (older)")}
|
||||
</Link>
|
||||
<Link
|
||||
href={
|
||||
data.same_user_next_submission_id
|
||||
? `/submissions/${data.same_user_next_submission_id}`
|
||||
: "#"
|
||||
}
|
||||
aria-disabled={!data.same_user_next_submission_id}
|
||||
className={`rounded border px-2 py-1 text-xs ${
|
||||
data.same_user_next_submission_id
|
||||
? "hover:bg-zinc-100"
|
||||
: "pointer-events-none cursor-not-allowed opacity-40"
|
||||
}`}
|
||||
>
|
||||
{tx("下一个(更新) →", "Next (newer) →")}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid gap-1 sm:grid-cols-2">
|
||||
<p>{tx("用户", "User")}: {data.user_id}</p>
|
||||
<p>
|
||||
@@ -147,7 +193,15 @@ export default function SubmissionDetailPage() {
|
||||
|
||||
<section className="rounded-xl border bg-white p-4">
|
||||
<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>
|
||||
<h2 className="text-sm font-medium inline-flex items-center gap-2">
|
||||
{tx("LLM 评测建议", "LLM Analysis")}
|
||||
<HintTip title={tx("建议说明", "Analysis Notes")} align="left">
|
||||
{tx(
|
||||
"评测建议用于辅助复盘,不替代正式判题结果。建议结合编译日志、运行日志和样例自行验证。",
|
||||
"Analysis is for review assistance and does not replace official judge results. Verify with compile log, runtime log, and your own sample tests."
|
||||
)}
|
||||
</HintTip>
|
||||
</h2>
|
||||
<button
|
||||
className="rounded border px-3 py-1 text-xs disabled:opacity-50"
|
||||
onClick={() => void generateAnalysis(false)}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { formatUnixDateTime } from "@/lib/time";
|
||||
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -27,22 +30,26 @@ type Submission = {
|
||||
user_id: number;
|
||||
problem_id: number;
|
||||
contest_id: number | null;
|
||||
language: string;
|
||||
status: string;
|
||||
score: number;
|
||||
rating_delta: number;
|
||||
time_ms: number;
|
||||
memory_kb: number;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type ListResp = { items: Submission[]; page: number; page_size: number };
|
||||
|
||||
export default function SubmissionsPage() {
|
||||
function SubmissionsPageInner() {
|
||||
const { tx } = useI18nText();
|
||||
const { theme } = useUiPreferences();
|
||||
const searchParams = useSearchParams();
|
||||
const isMc = theme === "minecraft";
|
||||
const [userId, setUserId] = useState("");
|
||||
const [problemId, setProblemId] = useState("");
|
||||
const [contestId, setContestId] = useState("");
|
||||
const [createdFrom, setCreatedFrom] = useState("");
|
||||
const [items, setItems] = useState<Submission[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -60,38 +67,48 @@ export default function SubmissionsPage() {
|
||||
|
||||
/** Map raw status codes to themed display text */
|
||||
const statusLabel = (raw: string) => {
|
||||
const status = raw.toUpperCase();
|
||||
if (!isMc) return raw;
|
||||
switch (raw) {
|
||||
case "Accepted":
|
||||
switch (status) {
|
||||
case "ACCEPTED":
|
||||
case "AC":
|
||||
return <span className="flex items-center gap-1 text-[color:var(--mc-green)]"><Check size={14} /> AC</span>;
|
||||
return <span className="mc-status-success flex items-center gap-1"><Check size={14} /> AC</span>;
|
||||
case "WA":
|
||||
case "Wrong Answer":
|
||||
return <span className="flex items-center gap-1 text-[color:var(--mc-red)]"><X size={14} /> WA</span>;
|
||||
case "WRONG ANSWER":
|
||||
return <span className="mc-status-danger flex items-center gap-1"><X size={14} /> WA</span>;
|
||||
case "TLE":
|
||||
case "Time Limit Exceeded":
|
||||
return <span className="flex items-center gap-1 text-[color:var(--mc-gold)]"><Clock size={14} /> TLE</span>;
|
||||
case "TIME LIMIT EXCEEDED":
|
||||
return <span className="mc-status-warning flex items-center gap-1"><Clock size={14} /> TLE</span>;
|
||||
case "MLE":
|
||||
return <span className="flex items-center gap-1 text-[color:var(--mc-red)]"><Zap size={14} /> MLE</span>;
|
||||
return <span className="mc-status-danger flex items-center gap-1"><Zap size={14} /> MLE</span>;
|
||||
case "RE":
|
||||
case "Runtime Error":
|
||||
return <span className="flex items-center gap-1 text-orange-500"><AlertTriangle size={14} /> RE</span>;
|
||||
case "RUNTIME ERROR":
|
||||
return <span className="mc-status-danger flex items-center gap-1"><AlertTriangle size={14} /> RE</span>;
|
||||
case "CE":
|
||||
case "Compile Error":
|
||||
return <span className="flex items-center gap-1 text-zinc-500"><Wrench size={14} /> CE</span>;
|
||||
case "COMPILE ERROR":
|
||||
return <span className="mc-status-danger flex items-center gap-1"><Wrench size={14} /> CE</span>;
|
||||
case "PENDING":
|
||||
case "COMPILING":
|
||||
case "RUNNING":
|
||||
return <span className="mc-status-running flex items-center gap-1"><Clock size={14} /> {status}</span>;
|
||||
default:
|
||||
return raw;
|
||||
return <span className="mc-status-muted">{raw}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const load = async (override?: { userId?: string; problemId?: string; contestId?: string; createdFrom?: string }) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const finalUserId = override?.userId ?? userId;
|
||||
const finalProblemId = override?.problemId ?? problemId;
|
||||
const finalContestId = override?.contestId ?? contestId;
|
||||
const finalCreatedFrom = override?.createdFrom ?? createdFrom;
|
||||
const params = new URLSearchParams();
|
||||
if (userId) params.set("user_id", userId);
|
||||
if (problemId) params.set("problem_id", problemId);
|
||||
if (contestId) params.set("contest_id", contestId);
|
||||
if (finalUserId) params.set("user_id", finalUserId);
|
||||
if (finalProblemId) params.set("problem_id", finalProblemId);
|
||||
if (finalContestId) params.set("contest_id", finalContestId);
|
||||
if (finalCreatedFrom) params.set("created_from", finalCreatedFrom);
|
||||
const data = await apiFetch<ListResp>(`/api/v1/submissions?${params.toString()}`);
|
||||
setItems(data.items);
|
||||
} catch (e: unknown) {
|
||||
@@ -102,9 +119,22 @@ export default function SubmissionsPage() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const fromUserId = searchParams.get("user_id") ?? "";
|
||||
const fromProblemId = searchParams.get("problem_id") ?? "";
|
||||
const fromContestId = searchParams.get("contest_id") ?? "";
|
||||
const fromCreatedFrom = searchParams.get("created_from") ?? "";
|
||||
setUserId(fromUserId);
|
||||
setProblemId(fromProblemId);
|
||||
setContestId(fromContestId);
|
||||
setCreatedFrom(fromCreatedFrom);
|
||||
void load({
|
||||
userId: fromUserId,
|
||||
problemId: fromProblemId,
|
||||
contestId: fromContestId,
|
||||
createdFrom: fromCreatedFrom,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [searchParams]);
|
||||
|
||||
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 font-mono">
|
||||
@@ -118,6 +148,16 @@ export default function SubmissionsPage() {
|
||||
tx("提交记录", "Submissions")
|
||||
)}
|
||||
</h1>
|
||||
<div className={`mt-2 flex items-center gap-2 text-xs ${isMc ? "text-zinc-400" : "text-zinc-600"}`}>
|
||||
<p>{tx("按用户/题目/比赛筛选提交记录。", "Filter submissions by user, problem, or contest.")}</p>
|
||||
<HintTip title={tx("字段说明", "Field Notes")} align="left">
|
||||
<ul className="list-disc space-y-1 pl-4">
|
||||
<li>{tx("Rating 变化为该次提交带来的积分增减。", "Rating Delta is points gained/lost from this submission.")}</li>
|
||||
<li>{tx("状态包含 AC/WA/TLE/RE/CE 等评测结果。", "Status includes AC/WA/TLE/RE/CE judge results.")}</li>
|
||||
<li>{tx("点击详情可查看编译输出与运行日志。", "Open detail to inspect compile output and runtime logs.")}</li>
|
||||
</ul>
|
||||
</HintTip>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className={`mt-4 grid gap-3 rounded-xl border p-4 md:grid-cols-4 ${isMc
|
||||
@@ -188,19 +228,28 @@ export default function SubmissionsPage() {
|
||||
<p className={`text-xs ${isMc ? "text-zinc-400" : "text-zinc-600"}`}>
|
||||
{isMc ? tx("冒险者", "Player") : tx("用户", "User")} {s.user_id} · {tx("任务", "Quest")} {s.problem_id} · {tx("分数", "Score")} {s.score}
|
||||
</p>
|
||||
<p className={`text-xs ${isMc ? "text-zinc-400" : "text-zinc-600"}`}>
|
||||
{tx("语言", "Language")}: {s.language} · {tx("内存", "Memory")}: {s.memory_kb} KB
|
||||
</p>
|
||||
<p className={`text-xs ${ratingDeltaClass(s.rating_delta)}`}>
|
||||
{isMc ? tx("绿宝石变化", "Emerald Δ") : tx("Rating 变化", "Rating Delta")} {fmtRatingDelta(s.rating_delta)}
|
||||
</p>
|
||||
<p className={`text-xs ${isMc ? "text-zinc-400" : "text-zinc-600"}`}>{tx("耗时", "Time")} {s.time_ms} ms</p>
|
||||
<p className={`text-xs ${isMc ? "text-zinc-400" : "text-zinc-600"}`}>{tx("提交时间", "Submitted")} {formatUnixDateTime(s.created_at)}</p>
|
||||
<Link className={`underline ${isMc ? "text-[color:var(--mc-diamond)]" : "text-blue-600"}`} href={`/submissions/${s.id}`}>
|
||||
{isMc ? tx("📜 查看详情", "📜 View Detail") : tx("查看详情", "View Detail")}
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
{!loading && items.length === 0 && (
|
||||
<p className={`px-3 py-5 text-center text-sm ${isMc ? "text-zinc-500" : "text-zinc-500"}`}>
|
||||
{isMc ? tx("暂无施法记录", "No spell casts yet") : tx("暂无提交记录", "No submissions yet")}
|
||||
</p>
|
||||
<div className="px-3 py-6 text-center text-sm">
|
||||
<p className={`${isMc ? "text-zinc-500" : "text-zinc-500"}`}>
|
||||
{isMc ? tx("暂无施法记录", "No spell casts yet") : tx("暂无提交记录", "No submissions yet")}
|
||||
</p>
|
||||
<Link href="/problems" className="mt-3 inline-flex min-h-[44px] items-center justify-center mc-btn mc-btn-primary px-4">
|
||||
{tx("先去做第一题", "Solve Your First Problem")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -241,6 +290,9 @@ export default function SubmissionsPage() {
|
||||
{tx("耗时(ms)", "Time(ms)")}
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-3 py-2">{tx("内存(KB)", "Memory(KB)")}</th>
|
||||
<th className="px-3 py-2">{tx("语言", "Language")}</th>
|
||||
<th className="px-3 py-2">{tx("提交时间", "Submitted")}</th>
|
||||
<th className="px-3 py-2">{tx("详情", "Detail")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -256,6 +308,9 @@ export default function SubmissionsPage() {
|
||||
{fmtRatingDelta(s.rating_delta)}
|
||||
</td>
|
||||
<td className="px-3 py-2">{s.time_ms}</td>
|
||||
<td className="px-3 py-2">{s.memory_kb}</td>
|
||||
<td className="px-3 py-2">{s.language}</td>
|
||||
<td className="px-3 py-2">{formatUnixDateTime(s.created_at)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Link className={`underline ${isMc ? "text-[color:var(--mc-diamond)]" : "text-blue-600"}`} href={`/submissions/${s.id}`}>
|
||||
{isMc ? tx("📜 查看", "📜 View") : tx("查看", "View")}
|
||||
@@ -265,8 +320,11 @@ export default function SubmissionsPage() {
|
||||
))}
|
||||
{!loading && items.length === 0 && (
|
||||
<tr>
|
||||
<td className={`px-3 py-5 text-center ${isMc ? "text-zinc-500" : "text-zinc-500"}`} colSpan={8}>
|
||||
{isMc ? tx("暂无施法记录", "No spell casts yet") : tx("暂无提交记录", "No submissions yet")}
|
||||
<td className={`px-3 py-5 text-center ${isMc ? "text-zinc-500" : "text-zinc-500"}`} colSpan={11}>
|
||||
<p>{isMc ? tx("暂无施法记录", "No spell casts yet") : tx("暂无提交记录", "No submissions yet")}</p>
|
||||
<Link href="/problems" className="mt-3 inline-flex min-h-[44px] items-center justify-center mc-btn mc-btn-primary px-4">
|
||||
{tx("先去做第一题", "Solve Your First Problem")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -277,3 +335,17 @@ export default function SubmissionsPage() {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SubmissionsPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8 font-mono">
|
||||
<p className="text-sm text-zinc-500">Loading...</p>
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<SubmissionsPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { HintTip } from "@/components/hint-tip";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { readToken } from "@/lib/auth";
|
||||
import { useI18nText } from "@/lib/i18n";
|
||||
import { formatUnixDateTime } from "@/lib/time";
|
||||
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||
import { BookX, Trash2, RefreshCw, RotateCcw, Save, Search, Skull } from "lucide-react";
|
||||
|
||||
@@ -19,8 +21,7 @@ type WrongBookItem = {
|
||||
};
|
||||
|
||||
function fmtTs(v: number): string {
|
||||
if (!v) return "-";
|
||||
return new Date(v * 1000).toLocaleString();
|
||||
return formatUnixDateTime(v);
|
||||
}
|
||||
|
||||
export default function WrongBookPage() {
|
||||
@@ -60,7 +61,7 @@ export default function WrongBookPage() {
|
||||
await apiFetch(`/api/v1/me/wrong-book/${problemId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ note }),
|
||||
}, token);
|
||||
}, token, { retryOnStatus: [500], retryCount: 10, retryDelayMs: 500 });
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
@@ -69,6 +70,10 @@ export default function WrongBookPage() {
|
||||
|
||||
const removeItem = async (problemId: number) => {
|
||||
try {
|
||||
const ok = window.confirm(
|
||||
tx("确认移除该条错题记录?", "Remove this wrong-book entry?")
|
||||
);
|
||||
if (!ok) return;
|
||||
await apiFetch(`/api/v1/me/wrong-book/${problemId}`, { method: "DELETE" }, token);
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
@@ -91,11 +96,29 @@ export default function WrongBookPage() {
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<p className={`mt-2 text-sm ${isMc ? "text-zinc-400" : "text-zinc-600"}`}>
|
||||
{isMc
|
||||
? tx("失败的咒语会自动记录在诅咒卷轴中,复习并重新挑战!", "Failed spells are recorded in your Cursed Scrolls. Review and retry!")
|
||||
: tx("未通过提交会自动进入错题本。", "Failed submissions are added to the wrong-book automatically.")}
|
||||
</p>
|
||||
<div className={`mt-2 flex items-center gap-2 text-sm ${isMc ? "text-zinc-400" : "text-zinc-600"}`}>
|
||||
<p>
|
||||
{isMc
|
||||
? tx("失败记录会自动入卷。", "Failed attempts are auto-saved here.")
|
||||
: tx("未通过提交会自动加入错题本。", "Failed submissions are auto-added to wrong-book.")}
|
||||
</p>
|
||||
<HintTip title={tx("复盘说明", "Review Notes")} align="left">
|
||||
<ul className="list-disc space-y-1 pl-4">
|
||||
<li>
|
||||
{tx(
|
||||
"每条记录可写复盘笔记,建议记录错因、修复思路与复测结论。",
|
||||
"Use notes to capture root cause, fix approach, and retest conclusion."
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{tx(
|
||||
"可直接跳转到题目页重做,或查看最近一次提交详情。",
|
||||
"You can jump to problem page to retry or inspect latest submission detail."
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</HintTip>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
|
||||
@@ -12,23 +12,21 @@ import type { ThemeId } from "@/themes/types";
|
||||
import {
|
||||
BookOpen,
|
||||
BookX,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Crown,
|
||||
FileText,
|
||||
Home,
|
||||
Key,
|
||||
Library,
|
||||
LogOut,
|
||||
Play,
|
||||
Settings,
|
||||
Sword,
|
||||
Settings2,
|
||||
ShieldAlert,
|
||||
Trophy,
|
||||
User,
|
||||
Users,
|
||||
Menu,
|
||||
X,
|
||||
Database,
|
||||
FileJson,
|
||||
Shield,
|
||||
Gift,
|
||||
FileCode
|
||||
} from "lucide-react";
|
||||
@@ -128,10 +126,11 @@ export function AppNav() {
|
||||
const directHttpAccessUrl =
|
||||
process.env.NEXT_PUBLIC_HTTP_ENTRY_URL?.trim() || "http://8.211.173.24:7888/";
|
||||
|
||||
const [hasToken, setHasToken] = useState<boolean>(() => Boolean(readToken()));
|
||||
const [hasToken, setHasToken] = useState(false);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [meProfile, setMeProfile] = useState<MeProfile | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [mobileSettingsOpen, setMobileSettingsOpen] = useState(false);
|
||||
const [desktopOpenGroup, setDesktopOpenGroup] = useState<string | null>(null);
|
||||
const desktopMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const navGroups = useMemo(() => buildNavGroups(t, isAdmin), [isAdmin, t]);
|
||||
@@ -192,27 +191,42 @@ export function AppNav() {
|
||||
? `${meProfile.username ?? "user"}-${meProfile.id ?? ""}`
|
||||
: "guest";
|
||||
const handleLogout = () => {
|
||||
const confirmed = window.confirm(
|
||||
language === "zh"
|
||||
? "确认断开连接并退出当前账号?"
|
||||
: "Disconnect and sign out from current account?"
|
||||
);
|
||||
if (!confirmed) return;
|
||||
clearToken();
|
||||
setHasToken(false);
|
||||
setIsAdmin(false);
|
||||
setMeProfile(null);
|
||||
setDesktopOpenGroup(null);
|
||||
setMobileSettingsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="print-hidden border-b bg-[color:var(--surface)]/95 backdrop-blur supports-[backdrop-filter]:bg-[color:var(--surface)]/85">
|
||||
<header className="print-hidden relative z-[120] border-b bg-[color:var(--surface)]/95 backdrop-blur supports-[backdrop-filter]:bg-[color:var(--surface)]/85">
|
||||
<div className="mx-auto max-w-6xl px-3 py-3 max-[390px]:px-2 max-[390px]:py-2 sm:px-4">
|
||||
<div className="flex items-center justify-between md:hidden">
|
||||
<span className="text-sm font-medium text-zinc-700">{t("nav.menu")}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border px-3 py-1 text-sm hover:bg-zinc-100 max-[390px]:px-2 max-[390px]:text-xs"
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="main-nav-links"
|
||||
>
|
||||
{menuOpen ? t("nav.collapse") : t("nav.expand")}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAdmin && (
|
||||
<span className="inline-flex items-center gap-1 rounded border border-amber-500/50 bg-amber-500/10 px-2 py-0.5 text-[10px] font-semibold text-amber-700">
|
||||
<ShieldAlert size={12} />
|
||||
{t("nav.admin_mode")}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border px-3 py-1 text-sm hover:bg-zinc-100 max-[390px]:px-2 max-[390px]:text-xs"
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="main-nav-links"
|
||||
>
|
||||
{menuOpen ? t("nav.collapse") : t("nav.expand")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="main-nav-links" className={`${menuOpen ? "mt-3 block" : "hidden"} md:mt-0 md:block`}>
|
||||
@@ -237,7 +251,7 @@ export function AppNav() {
|
||||
</button>
|
||||
{opened && (
|
||||
<div
|
||||
className={`absolute left-0 top-full z-50 mt-2 rounded-md border bg-[color:var(--surface)] p-1 shadow-lg ${group.key === "account" ? "min-w-[18rem]" : "min-w-[11rem]"
|
||||
className={`absolute left-0 top-full z-[130] mt-2 rounded-md border bg-[color:var(--surface)] p-1 shadow-lg ${group.key === "account" ? "min-w-[18rem]" : "min-w-[11rem]"
|
||||
}`}
|
||||
>
|
||||
{group.links.map((item) => {
|
||||
@@ -287,6 +301,20 @@ export function AppNav() {
|
||||
<option value="zh">{t("prefs.lang.zh")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<details className="rounded border border-zinc-200 bg-zinc-50/80 px-2 py-1 text-xs">
|
||||
<summary className="cursor-pointer text-zinc-600">
|
||||
{t("nav.link.http_ip_port")}
|
||||
</summary>
|
||||
<a
|
||||
href={directHttpAccessUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-2 block rounded border px-2 py-1 text-xs hover:bg-zinc-100"
|
||||
title={directHttpAccessUrl}
|
||||
>
|
||||
{t("nav.link.http_ip_port")}
|
||||
</a>
|
||||
</details>
|
||||
<div className="flex items-center justify-between gap-2 rounded-md border border-zinc-200 px-2 py-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={hasToken ? "text-emerald-700" : "text-zinc-500"}>
|
||||
@@ -361,6 +389,12 @@ export function AppNav() {
|
||||
)}
|
||||
|
||||
<div className="space-y-3 md:hidden">
|
||||
{isAdmin && (
|
||||
<div className="inline-flex items-center gap-1 rounded border border-amber-500/50 bg-amber-500/10 px-2 py-1 text-[10px] font-semibold text-amber-700">
|
||||
<ShieldAlert size={12} />
|
||||
{t("nav.admin_mode")}
|
||||
</div>
|
||||
)}
|
||||
{navGroups.map((group) => (
|
||||
<section key={group.key} className="rounded-lg border p-2">
|
||||
<h3 className="mb-2 flex items-center gap-2 text-xs font-semibold text-zinc-600">
|
||||
@@ -390,75 +424,150 @@ export function AppNav() {
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<section className="rounded-lg border p-2">
|
||||
<button
|
||||
type="button"
|
||||
className="mb-2 flex w-full items-center justify-between rounded border px-2 py-1 text-xs font-semibold text-zinc-700 hover:bg-zinc-50"
|
||||
onClick={() => setMobileSettingsOpen((v) => !v)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Settings2 size={14} />
|
||||
{t("nav.group.settings")}
|
||||
</span>
|
||||
{mobileSettingsOpen ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
{mobileSettingsOpen && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs">
|
||||
<span className="text-zinc-500">{t("prefs.theme")}</span>
|
||||
<select
|
||||
className="mt-1 w-full rounded-md border px-2 py-1 text-xs"
|
||||
value={theme}
|
||||
onChange={(e) => setTheme(e.target.value as ThemeId)}
|
||||
>
|
||||
{themes.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.labels[language]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-xs">
|
||||
<span className="text-zinc-500">{t("prefs.language")}</span>
|
||||
<select
|
||||
className="mt-1 w-full rounded-md border px-2 py-1 text-xs"
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value === "zh" ? "zh" : "en")}
|
||||
>
|
||||
<option value="en">{t("prefs.lang.en")}</option>
|
||||
<option value="zh">{t("prefs.lang.zh")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<details className="rounded border border-zinc-200 bg-zinc-50/70 px-2 py-1">
|
||||
<summary className="cursor-pointer text-xs font-semibold text-zinc-700">
|
||||
{t("nav.link.http_ip_port")}
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
<a
|
||||
href={directHttpAccessUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block rounded border px-2 py-1 text-xs hover:bg-zinc-100"
|
||||
title={directHttpAccessUrl}
|
||||
>
|
||||
{t("nav.link.http_ip_port")}
|
||||
</a>
|
||||
<div className="flex items-center justify-between rounded border px-2 py-1">
|
||||
<span className={`text-xs ${hasToken ? "text-emerald-700" : "text-zinc-500"}`}>
|
||||
{hasToken ? t("nav.logged_in") : t("nav.logged_out")}
|
||||
</span>
|
||||
{hasToken && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="rounded border px-2 py-0.5 text-xs text-red-700 hover:bg-red-50"
|
||||
>
|
||||
{t("nav.logout")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`mt-2 flex flex-wrap items-center justify-end gap-2 text-xs sm:text-sm ${usePopupSecondary ? "md:hidden" : ""}`}>
|
||||
<label className="inline-flex items-center gap-1">
|
||||
<span className="text-zinc-500">{t("prefs.theme")}</span>
|
||||
<select
|
||||
className="rounded-md border px-2 py-1 text-xs sm:text-sm"
|
||||
value={theme}
|
||||
onChange={(e) => {
|
||||
setDesktopOpenGroup(null);
|
||||
setTheme(e.target.value as ThemeId);
|
||||
}}
|
||||
{!usePopupSecondary && (
|
||||
<div className="mt-2 hidden flex-wrap items-center justify-end gap-2 text-xs sm:text-sm md:flex">
|
||||
<label className="inline-flex items-center gap-1">
|
||||
<span className="text-zinc-500">{t("prefs.theme")}</span>
|
||||
<select
|
||||
className="rounded-md border px-2 py-1 text-xs sm:text-sm"
|
||||
value={theme}
|
||||
onChange={(e) => {
|
||||
setDesktopOpenGroup(null);
|
||||
setTheme(e.target.value as ThemeId);
|
||||
}}
|
||||
>
|
||||
{themes.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.labels[language]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1">
|
||||
<span className="text-zinc-500">{t("prefs.language")}</span>
|
||||
<select
|
||||
className="rounded-md border px-2 py-1 text-xs sm:text-sm"
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value === "zh" ? "zh" : "en")}
|
||||
>
|
||||
<option value="en">{t("prefs.lang.en")}</option>
|
||||
<option value="zh">{t("prefs.lang.zh")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<a
|
||||
href={directHttpAccessUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-md border px-2 py-1 text-xs hover:bg-zinc-100 sm:text-sm"
|
||||
title={directHttpAccessUrl}
|
||||
>
|
||||
{themes.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.labels[language]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1">
|
||||
<span className="text-zinc-500">{t("prefs.language")}</span>
|
||||
<select
|
||||
className="rounded-md border px-2 py-1 text-xs sm:text-sm"
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value === "zh" ? "zh" : "en")}
|
||||
>
|
||||
<option value="en">{t("prefs.lang.en")}</option>
|
||||
<option value="zh">{t("prefs.lang.zh")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<a
|
||||
href={directHttpAccessUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-md border px-2 py-1 text-xs hover:bg-zinc-100 sm:text-sm"
|
||||
title={directHttpAccessUrl}
|
||||
>
|
||||
{t("nav.link.http_ip_port")}
|
||||
</a>
|
||||
<span className={hasToken ? "text-emerald-700" : "text-zinc-500"}>
|
||||
{hasToken ? t("nav.logged_in") : t("nav.logged_out")}
|
||||
</span>
|
||||
{hasToken && (
|
||||
<>
|
||||
{theme === "minecraft" && (
|
||||
<div className="hidden md:block w-32 mr-2">
|
||||
<XpBar level={5} currentXp={750} nextLevelXp={1000} />
|
||||
</div>
|
||||
)}
|
||||
<PixelAvatar
|
||||
seed={avatarSeed}
|
||||
size={24}
|
||||
className="border-zinc-700"
|
||||
alt={meProfile?.username ? `${meProfile.username} avatar` : "avatar"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{hasToken && (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="rounded-md border px-3 py-1 hover:bg-zinc-100"
|
||||
>
|
||||
{t("nav.logout")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{t("nav.link.http_ip_port")}
|
||||
</a>
|
||||
<span className={hasToken ? "text-emerald-700" : "text-zinc-500"}>
|
||||
{hasToken ? t("nav.logged_in") : t("nav.logged_out")}
|
||||
</span>
|
||||
{hasToken && (
|
||||
<>
|
||||
{theme === "minecraft" && (
|
||||
<div className="hidden md:block w-32 mr-2">
|
||||
<XpBar level={5} currentXp={750} nextLevelXp={1000} />
|
||||
</div>
|
||||
)}
|
||||
<PixelAvatar
|
||||
seed={avatarSeed}
|
||||
size={24}
|
||||
className="border-zinc-700"
|
||||
alt={meProfile?.username ? `${meProfile.username} avatar` : "avatar"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{hasToken && (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="rounded-md border px-3 py-1 hover:bg-zinc-100"
|
||||
>
|
||||
{t("nav.logout")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Info, X } from "lucide-react";
|
||||
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||
|
||||
type HintTipProps = {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
align?: "left" | "right";
|
||||
widthClassName?: string;
|
||||
};
|
||||
|
||||
export function HintTip({
|
||||
title,
|
||||
children,
|
||||
align = "right",
|
||||
widthClassName = "w-72 sm:w-80",
|
||||
}: HintTipProps) {
|
||||
const { theme } = useUiPreferences();
|
||||
const isMc = theme === "minecraft";
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
if (!rootRef.current) return;
|
||||
if (!rootRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
|
||||
window.addEventListener("mousedown", onPointerDown);
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", onPointerDown);
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`mc-reset inline-flex h-6 w-6 items-center justify-center rounded-full border ${
|
||||
isMc
|
||||
? "border-[color:var(--mc-border-soft)] bg-[color:var(--mc-card-inner)] text-[color:var(--mc-accent)] hover:bg-black/30 hover:text-white"
|
||||
: "border-zinc-400 bg-white text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900"
|
||||
}`}
|
||||
aria-label={title}
|
||||
title={title}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<Info size={14} />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className={`absolute z-30 mt-2 rounded-xl border p-3 text-xs shadow-xl ${
|
||||
isMc
|
||||
? "border-black bg-[color:var(--mc-card)] text-[color:var(--mc-text-muted)]"
|
||||
: "border-zinc-300 bg-white text-zinc-700"
|
||||
} ${
|
||||
align === "left" ? "left-0" : "right-0"
|
||||
} ${widthClassName}`}
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<p className={`text-xs font-semibold ${isMc ? "text-[color:var(--mc-text-main)]" : "text-zinc-900"}`}>{title}</p>
|
||||
<button
|
||||
type="button"
|
||||
className={`mc-reset rounded border p-0.5 ${
|
||||
isMc
|
||||
? "border-[color:var(--mc-border-soft)] text-[color:var(--mc-text-dim)] hover:text-[color:var(--mc-text-main)]"
|
||||
: "border-zinc-300 text-zinc-500 hover:text-zinc-800"
|
||||
}`}
|
||||
aria-label="Close hint"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="leading-relaxed">{children}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { useUiPreferences } from "@/components/ui-preference-provider";
|
||||
import { BookOpen, Calendar, ScrollText, Swords, User } from "lucide-react";
|
||||
import { BookOpen, ScrollText, Swords, User, WandSparkles } from "lucide-react";
|
||||
|
||||
function isActivePath(pathname: string, href: string): boolean {
|
||||
if (pathname === href) return true;
|
||||
@@ -20,33 +20,56 @@ export function MobileTabBar() {
|
||||
{ label: t("mobile.tab.problems"), href: "/problems", icon: BookOpen },
|
||||
{ label: t("mobile.tab.submissions"), href: "/submissions", icon: ScrollText },
|
||||
{ label: t("mobile.tab.contests"), href: "/contests", icon: Swords },
|
||||
{ label: t("mobile.tab.kb"), href: "/kb", icon: Calendar }, // KB maps to "Library" usually but keeping order
|
||||
{ label: t("mobile.tab.kb"), href: "/kb", icon: WandSparkles },
|
||||
{ label: t("mobile.tab.me"), href: "/me", icon: User },
|
||||
] as const;
|
||||
|
||||
const accentByHref: Record<string, string> = {
|
||||
"/problems": "var(--mc-accent)",
|
||||
"/submissions": "var(--mc-admin)",
|
||||
"/contests": "var(--mc-contest)",
|
||||
"/kb": "var(--mc-admin)",
|
||||
"/me": "var(--mc-success)",
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className={`print-hidden fixed inset-x-0 bottom-0 z-40 border-t pb-[calc(0.3rem+env(safe-area-inset-bottom))] pt-2 md:hidden ${isMc
|
||||
? "bg-[color:var(--mc-stone-dark)] border-black border-t-[3px]"
|
||||
? "bg-[color:var(--mc-card)] border-black border-t-[3px] shadow-[0_-6px_0_rgba(0,0,0,0.45)]"
|
||||
: "bg-[color:var(--surface)]/95 backdrop-blur supports-[backdrop-filter]:bg-[color:var(--surface)]/85"
|
||||
}`}>
|
||||
<div className="mx-auto max-w-5xl px-2 max-[390px]:px-1.5">
|
||||
<div className="grid grid-cols-5 gap-1 max-[390px]:gap-0.5">
|
||||
<div className={`grid grid-cols-5 gap-1 max-[390px]:gap-0.5 ${isMc ? "rounded-lg border border-black bg-black/30 p-1" : ""}`}>
|
||||
{tabs.map((tab) => {
|
||||
const active = isActivePath(pathname, tab.href);
|
||||
const accentColor = accentByHref[tab.href] ?? "var(--mc-accent)";
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={`flex flex-col items-center justify-center rounded-none px-1 py-1 text-center text-[10px] sm:text-xs ${isMc
|
||||
? active
|
||||
? "bg-[color:var(--mc-diamond)] text-black font-bold border-2 border-black"
|
||||
: "bg-[color:var(--mc-stone)] text-zinc-300 border-2 border-black/50 hover:bg-[color:var(--mc-stone-light)]"
|
||||
? "bg-[color:var(--mc-card-inner)] font-bold border-2"
|
||||
: "bg-[color:var(--mc-obsidian)] text-[color:var(--mc-text-dim)] border-2 border-black/50 hover:bg-black/60"
|
||||
: active
|
||||
? "bg-zinc-900 font-semibold text-white rounded-md"
|
||||
: "text-zinc-600 hover:bg-zinc-100 rounded-md"
|
||||
}`}
|
||||
style={
|
||||
isMc && active
|
||||
? {
|
||||
borderColor: accentColor,
|
||||
color: accentColor,
|
||||
boxShadow: `inset 0 0 0 1px ${accentColor}`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isMc && <tab.icon size={18} className="mb-0.5" />}
|
||||
{isMc && (
|
||||
<tab.icon
|
||||
size={18}
|
||||
className={`mb-0.5 ${active ? "text-inherit" : "text-[color:var(--mc-border-soft)]"}`}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate w-full">{tab.label}</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
type Crumb = {
|
||||
label: string;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export function PageCrumbs({ items }: { items: Crumb[] }) {
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<nav aria-label="breadcrumb" className="mb-2 flex flex-wrap items-center gap-1 text-xs text-zinc-500">
|
||||
{items.map((item, idx) => {
|
||||
const isLast = idx === items.length - 1;
|
||||
return (
|
||||
<span key={`${item.label}-${idx}`} className="inline-flex items-center gap-1">
|
||||
{item.href && !isLast ? (
|
||||
<Link href={item.href} className="hover:text-zinc-800 hover:underline">
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={isLast ? "font-semibold text-zinc-700" : ""}>{item.label}</span>
|
||||
)}
|
||||
{!isLast && <ChevronRight size={12} />}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
type SourceCrystalIconProps = {
|
||||
size?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function SourceCrystalIcon({ size = 24, className = "" }: SourceCrystalIconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="crystalMain" x1="8" y1="8" x2="56" y2="56" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#7FE7FF" />
|
||||
<stop offset="0.45" stopColor="#3AB7FF" />
|
||||
<stop offset="1" stopColor="#1C4DFF" />
|
||||
</linearGradient>
|
||||
<linearGradient id="crystalGlow" x1="32" y1="6" x2="32" y2="58" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#E8FFFF" stopOpacity="0.9" />
|
||||
<stop offset="1" stopColor="#7EE8FF" stopOpacity="0.15" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M32 4L56 24L44 56H20L8 24L32 4Z" fill="url(#crystalMain)" />
|
||||
<path d="M32 10L49 24L40 49H24L15 24L32 10Z" fill="url(#crystalGlow)" />
|
||||
<path d="M32 4V56M8 24H56" stroke="#B9F8FF" strokeOpacity="0.45" strokeWidth="2" />
|
||||
<circle cx="50" cy="12" r="3" fill="#DFFBFF" />
|
||||
<circle cx="14" cy="50" r="2.2" fill="#C7F4FF" />
|
||||
<circle cx="54" cy="44" r="1.8" fill="#E9FEFF" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -12,10 +12,17 @@ type ApiEnvelope<T> =
|
||||
| { ok: true; data?: T;[k: string]: unknown }
|
||||
| { ok: false; error?: string;[k: string]: unknown };
|
||||
|
||||
export type ApiFetchRetryOptions = {
|
||||
retryOnStatus?: number[];
|
||||
retryCount?: number;
|
||||
retryDelayMs?: number;
|
||||
};
|
||||
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
token?: string
|
||||
token?: string,
|
||||
retryOptions?: ApiFetchRetryOptions
|
||||
): Promise<T> {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
@@ -25,40 +32,54 @@ export async function apiFetch<T>(
|
||||
|
||||
const method = (init?.method ?? "GET").toUpperCase();
|
||||
const retryable = method === "GET" || method === "HEAD";
|
||||
const retryOnStatus = new Set((retryOptions?.retryOnStatus ?? []).filter((status) => Number.isInteger(status)));
|
||||
const retryDelayMs = retryOptions?.retryDelayMs ?? 400;
|
||||
let statusRetryRemaining = Math.max(0, retryOptions?.retryCount ?? 0);
|
||||
const requestUrl = `${API_BASE}${path}`;
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch (err) {
|
||||
if (!retryable) {
|
||||
throw new Error(
|
||||
uiText(
|
||||
`网络请求失败,请检查后端服务或代理连接(${err instanceof Error ? err.message : String(err)})`,
|
||||
`Network request failed. Please check backend/proxy connectivity (${err instanceof Error ? err.message : String(err)}).`
|
||||
)
|
||||
);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
const requestOnce = async (): Promise<Response> => {
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(`${API_BASE}${path}`, {
|
||||
resp = await fetch(requestUrl, {
|
||||
...init,
|
||||
headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch (retryErr) {
|
||||
throw new Error(
|
||||
uiText(
|
||||
`网络请求失败,请检查后端服务或代理连接(${retryErr instanceof Error ? retryErr.message : String(retryErr)
|
||||
})`,
|
||||
`Network request failed. Please check backend/proxy connectivity (${retryErr instanceof Error ? retryErr.message : String(retryErr)
|
||||
}).`
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
if (!retryable) {
|
||||
throw new Error(
|
||||
uiText(
|
||||
`网络请求失败,请检查后端服务或代理连接(${err instanceof Error ? err.message : String(err)})`,
|
||||
`Network request failed. Please check backend/proxy connectivity (${err instanceof Error ? err.message : String(err)}).`
|
||||
)
|
||||
);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
try {
|
||||
resp = await fetch(requestUrl, {
|
||||
...init,
|
||||
headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch (retryErr) {
|
||||
throw new Error(
|
||||
uiText(
|
||||
`网络请求失败,请检查后端服务或代理连接(${retryErr instanceof Error ? retryErr.message : String(retryErr)
|
||||
})`,
|
||||
`Network request failed. Please check backend/proxy connectivity (${retryErr instanceof Error ? retryErr.message : String(retryErr)
|
||||
}).`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
return resp;
|
||||
};
|
||||
|
||||
let resp = await requestOnce();
|
||||
while (!resp.ok && statusRetryRemaining > 0 && retryOnStatus.has(resp.status)) {
|
||||
statusRetryRemaining -= 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
||||
resp = await requestOnce();
|
||||
}
|
||||
|
||||
const text = await resp.text();
|
||||
|
||||
45
frontend/src/lib/time.ts
普通文件
45
frontend/src/lib/time.ts
普通文件
@@ -0,0 +1,45 @@
|
||||
export const ASIA_SHANGHAI_TIME_ZONE = "Asia/Shanghai";
|
||||
|
||||
type LocaleArg = string | string[] | undefined;
|
||||
|
||||
export function formatUnixDateTime(tsSec: number | null | undefined, locale?: LocaleArg): string {
|
||||
if (!tsSec) return "-";
|
||||
return new Date(tsSec * 1000).toLocaleString(locale, {
|
||||
timeZone: ASIA_SHANGHAI_TIME_ZONE,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatUnixDate(tsSec: number | null | undefined, locale?: LocaleArg): string {
|
||||
if (!tsSec) return "-";
|
||||
return new Date(tsSec * 1000).toLocaleDateString(locale, {
|
||||
timeZone: ASIA_SHANGHAI_TIME_ZONE,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatMsTime(ms: number | null | undefined, locale?: LocaleArg): string {
|
||||
if (!ms) return "-";
|
||||
return new Date(ms).toLocaleTimeString(locale, {
|
||||
timeZone: ASIA_SHANGHAI_TIME_ZONE,
|
||||
});
|
||||
}
|
||||
|
||||
export function dayKeyInShanghai(tsSec: number): string {
|
||||
if (!Number.isFinite(tsSec)) return "";
|
||||
return new Date(tsSec * 1000).toLocaleDateString("sv-SE", {
|
||||
timeZone: ASIA_SHANGHAI_TIME_ZONE,
|
||||
});
|
||||
}
|
||||
|
||||
export function dayKeySerial(dayKey: string): number | null {
|
||||
const m = dayKey.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!m) return null;
|
||||
const y = Number(m[1]);
|
||||
const mon = Number(m[2]);
|
||||
const d = Number(m[3]);
|
||||
return Math.floor(Date.UTC(y, mon - 1, d) / 86400000);
|
||||
}
|
||||
|
||||
export function serialToDayKey(serial: number): string {
|
||||
if (!Number.isFinite(serial)) return "";
|
||||
return new Date(serial * 86400000).toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -10,34 +10,38 @@ export const enMessages: ThemeMessages = {
|
||||
"nav.logged_in": "Signed in",
|
||||
"nav.logged_out": "Signed out",
|
||||
"nav.logout": "Sign out",
|
||||
"nav.admin_mode": "Admin Mode",
|
||||
"nav.group.settings": "Settings",
|
||||
"nav.settings.show": "Open Settings",
|
||||
"nav.settings.hide": "Hide Settings",
|
||||
|
||||
"nav.group.learn": "Learning",
|
||||
"nav.group.contest": "Contests",
|
||||
"nav.group.system": "Platform",
|
||||
"nav.group.account": "Account",
|
||||
"nav.group.learn": "Adventure",
|
||||
"nav.group.contest": "Dungeon",
|
||||
"nav.group.system": "Server",
|
||||
"nav.group.account": "Player",
|
||||
|
||||
"nav.link.home": "Home",
|
||||
"nav.link.problems": "Problems",
|
||||
"nav.link.submissions": "Submissions",
|
||||
"nav.link.wrong_book": "Wrong Book",
|
||||
"nav.link.kb": "Knowledge Base",
|
||||
"nav.link.run": "Run Code",
|
||||
"nav.link.contests": "Contests",
|
||||
"nav.link.leaderboard": "Leaderboard",
|
||||
"nav.link.home": "Town",
|
||||
"nav.link.problems": "Quests",
|
||||
"nav.link.submissions": "Spell Log",
|
||||
"nav.link.wrong_book": "Curse Scroll",
|
||||
"nav.link.kb": "Enchant",
|
||||
"nav.link.run": "Workbench",
|
||||
"nav.link.contests": "Dungeon",
|
||||
"nav.link.leaderboard": "Hall of Fame",
|
||||
"nav.link.imports": "Imports",
|
||||
"nav.link.backend_logs": "Backend Logs",
|
||||
"nav.link.admin_users": "User Rating",
|
||||
"nav.link.admin_redeem": "Redeem Admin",
|
||||
"nav.link.api_docs": "API Docs",
|
||||
"nav.link.auth": "Sign In",
|
||||
"nav.link.me": "My Account",
|
||||
"nav.link.auth": "Server Login",
|
||||
"nav.link.me": "Character",
|
||||
"nav.link.http_ip_port": "HTTP (IP:Port)",
|
||||
|
||||
"mobile.tab.problems": "Problems",
|
||||
"mobile.tab.submissions": "Submits",
|
||||
"mobile.tab.contests": "Contests",
|
||||
"mobile.tab.kb": "KB",
|
||||
"mobile.tab.me": "Me",
|
||||
"mobile.tab.problems": "Quests",
|
||||
"mobile.tab.submissions": "Spell",
|
||||
"mobile.tab.contests": "Dungeon",
|
||||
"mobile.tab.kb": "Enchant",
|
||||
"mobile.tab.me": "Hero",
|
||||
|
||||
"prefs.theme": "Theme",
|
||||
"prefs.language": "Language",
|
||||
@@ -46,7 +50,7 @@ export const enMessages: ThemeMessages = {
|
||||
|
||||
"admin.entry.title": "Admin Entry",
|
||||
"admin.entry.desc":
|
||||
"Default admin account: admin / whoami139",
|
||||
"Admin entry (credentials managed separately)",
|
||||
"admin.entry.login": "Go to Sign In",
|
||||
"admin.entry.user_rating": "Manage User Rating",
|
||||
"admin.entry.redeem": "Manage Redeem Items",
|
||||
|
||||
@@ -10,34 +10,38 @@ export const zhMessages: ThemeMessages = {
|
||||
"nav.logged_in": "已登录",
|
||||
"nav.logged_out": "未登录",
|
||||
"nav.logout": "退出",
|
||||
"nav.admin_mode": "管理员模式",
|
||||
"nav.group.settings": "设置",
|
||||
"nav.settings.show": "打开设置",
|
||||
"nav.settings.hide": "收起设置",
|
||||
|
||||
"nav.group.learn": "学习训练",
|
||||
"nav.group.contest": "竞赛评测",
|
||||
"nav.group.system": "平台管理",
|
||||
"nav.group.account": "账号中心",
|
||||
"nav.group.learn": "冒险学习",
|
||||
"nav.group.contest": "副本竞技",
|
||||
"nav.group.system": "服务器管理",
|
||||
"nav.group.account": "玩家中心",
|
||||
|
||||
"nav.link.home": "首页",
|
||||
"nav.link.problems": "题库",
|
||||
"nav.link.submissions": "提交记录",
|
||||
"nav.link.wrong_book": "错题本",
|
||||
"nav.link.kb": "知识库",
|
||||
"nav.link.run": "在线运行",
|
||||
"nav.link.contests": "比赛",
|
||||
"nav.link.leaderboard": "排行榜",
|
||||
"nav.link.home": "主城",
|
||||
"nav.link.problems": "任务",
|
||||
"nav.link.submissions": "施法",
|
||||
"nav.link.wrong_book": "错题卷轴",
|
||||
"nav.link.kb": "附魔",
|
||||
"nav.link.run": "代码工作台",
|
||||
"nav.link.contests": "副本",
|
||||
"nav.link.leaderboard": "英雄榜",
|
||||
"nav.link.imports": "导入任务",
|
||||
"nav.link.backend_logs": "后台日志",
|
||||
"nav.link.admin_users": "用户积分",
|
||||
"nav.link.admin_redeem": "积分兑换",
|
||||
"nav.link.api_docs": "API文档",
|
||||
"nav.link.auth": "登录",
|
||||
"nav.link.me": "我的",
|
||||
"nav.link.auth": "登录服务器",
|
||||
"nav.link.me": "角色",
|
||||
"nav.link.http_ip_port": "IP+端口访问",
|
||||
|
||||
"mobile.tab.problems": "题库",
|
||||
"mobile.tab.submissions": "提交",
|
||||
"mobile.tab.contests": "比赛",
|
||||
"mobile.tab.kb": "知识库",
|
||||
"mobile.tab.me": "我的",
|
||||
"mobile.tab.problems": "任务",
|
||||
"mobile.tab.submissions": "施法",
|
||||
"mobile.tab.contests": "副本",
|
||||
"mobile.tab.kb": "附魔",
|
||||
"mobile.tab.me": "角色",
|
||||
|
||||
"prefs.theme": "主题",
|
||||
"prefs.language": "语言",
|
||||
@@ -45,7 +49,7 @@ export const zhMessages: ThemeMessages = {
|
||||
"prefs.lang.zh": "中文",
|
||||
|
||||
"admin.entry.title": "后台管理入口",
|
||||
"admin.entry.desc": "默认管理员账号:admin / whoami139",
|
||||
"admin.entry.desc": "管理员入口(凭据请联系管理员)",
|
||||
"admin.entry.login": "去登录",
|
||||
"admin.entry.user_rating": "用户积分管理",
|
||||
"admin.entry.redeem": "积分兑换管理",
|
||||
|
||||
@@ -10,19 +10,23 @@ export const enMessages: ThemeMessages = {
|
||||
"nav.logged_in": "Online",
|
||||
"nav.logged_out": "Offline",
|
||||
"nav.logout": "Disconnect",
|
||||
"nav.admin_mode": "Admin Mode",
|
||||
"nav.group.settings": "Settings",
|
||||
"nav.settings.show": "Open Settings",
|
||||
"nav.settings.hide": "Hide Settings",
|
||||
|
||||
"nav.group.learn": "Adventure",
|
||||
"nav.group.contest": "Battle Area",
|
||||
"nav.group.contest": "Raids",
|
||||
"nav.group.system": "Server Ops",
|
||||
"nav.group.account": "Player",
|
||||
"nav.group.account": "Player Hub",
|
||||
|
||||
"nav.link.home": "Spawn Point",
|
||||
"nav.link.problems": "Quest Board",
|
||||
"nav.link.submissions": "Adventure Log",
|
||||
"nav.link.problems": "Quests",
|
||||
"nav.link.submissions": "Spell Log",
|
||||
"nav.link.wrong_book": "Grimoire",
|
||||
"nav.link.kb": "Enchanted Library",
|
||||
"nav.link.kb": "Enchant",
|
||||
"nav.link.run": "Craft Code",
|
||||
"nav.link.contests": "Raids",
|
||||
"nav.link.contests": "Dungeon",
|
||||
"nav.link.leaderboard": "Hall of Fame",
|
||||
"nav.link.imports": "Import Maps",
|
||||
"nav.link.backend_logs": "Server Logs",
|
||||
@@ -30,14 +34,14 @@ export const enMessages: ThemeMessages = {
|
||||
"nav.link.admin_redeem": "Loot Config",
|
||||
"nav.link.api_docs": "Redstone Logic",
|
||||
"nav.link.auth": "Login to Server",
|
||||
"nav.link.me": "Character Sheet",
|
||||
"nav.link.me": "Character",
|
||||
"nav.link.http_ip_port": "HTTP (IP:Port)",
|
||||
|
||||
"mobile.tab.problems": "Quests",
|
||||
"mobile.tab.submissions": "History",
|
||||
"mobile.tab.contests": "Raids",
|
||||
"mobile.tab.kb": "Library",
|
||||
"mobile.tab.me": "Char",
|
||||
"mobile.tab.submissions": "Spell",
|
||||
"mobile.tab.contests": "Dungeon",
|
||||
"mobile.tab.kb": "Enchant",
|
||||
"mobile.tab.me": "Hero",
|
||||
|
||||
"prefs.theme": "Texture Pack",
|
||||
"prefs.language": "Language",
|
||||
@@ -45,7 +49,7 @@ export const enMessages: ThemeMessages = {
|
||||
"prefs.lang.zh": "Chinese",
|
||||
|
||||
"admin.entry.title": "OP Control Panel",
|
||||
"admin.entry.desc": "Super User: admin / whoami139",
|
||||
"admin.entry.desc": "Admin entry (credentials managed separately)",
|
||||
"admin.entry.login": "Enter Portal",
|
||||
"admin.entry.user_rating": "Manage XP",
|
||||
"admin.entry.redeem": "Manage Loot",
|
||||
|
||||
@@ -10,19 +10,23 @@ export const zhMessages: ThemeMessages = {
|
||||
"nav.logged_in": "在线",
|
||||
"nav.logged_out": "离线",
|
||||
"nav.logout": "断开连接",
|
||||
"nav.admin_mode": "管理员模式",
|
||||
"nav.group.settings": "系统设置",
|
||||
"nav.settings.show": "打开设置",
|
||||
"nav.settings.hide": "收起设置",
|
||||
|
||||
"nav.group.learn": "冒险模式",
|
||||
"nav.group.contest": "竞技场",
|
||||
"nav.group.contest": "副本竞技",
|
||||
"nav.group.system": "服务器指令",
|
||||
"nav.group.account": "玩家档案",
|
||||
"nav.group.account": "玩家中心",
|
||||
|
||||
"nav.link.home": "出生点",
|
||||
"nav.link.problems": "任务布告栏",
|
||||
"nav.link.submissions": "冒险日志",
|
||||
"nav.link.problems": "任务",
|
||||
"nav.link.submissions": "施法",
|
||||
"nav.link.wrong_book": "错题卷轴",
|
||||
"nav.link.kb": "附魔指南",
|
||||
"nav.link.kb": "附魔",
|
||||
"nav.link.run": "代码工作台",
|
||||
"nav.link.contests": "团队副本",
|
||||
"nav.link.contests": "副本",
|
||||
"nav.link.leaderboard": "英雄榜",
|
||||
"nav.link.imports": "地图导入",
|
||||
"nav.link.backend_logs": "服务器日志",
|
||||
@@ -30,13 +34,13 @@ export const zhMessages: ThemeMessages = {
|
||||
"nav.link.admin_redeem": "战利品配置",
|
||||
"nav.link.api_docs": "红石电路图",
|
||||
"nav.link.auth": "登录服务器",
|
||||
"nav.link.me": "角色面板",
|
||||
"nav.link.me": "角色",
|
||||
"nav.link.http_ip_port": "IP+端口访问",
|
||||
|
||||
"mobile.tab.problems": "任务",
|
||||
"mobile.tab.submissions": "日志",
|
||||
"mobile.tab.submissions": "施法",
|
||||
"mobile.tab.contests": "副本",
|
||||
"mobile.tab.kb": "指南",
|
||||
"mobile.tab.kb": "附魔",
|
||||
"mobile.tab.me": "角色",
|
||||
|
||||
"prefs.theme": "材质包",
|
||||
@@ -45,7 +49,7 @@ export const zhMessages: ThemeMessages = {
|
||||
"prefs.lang.zh": "中文",
|
||||
|
||||
"admin.entry.title": "OP 控制台",
|
||||
"admin.entry.desc": "管理员账号:admin / whoami139",
|
||||
"admin.entry.desc": "管理员入口(凭据请联系管理员)",
|
||||
"admin.entry.login": "进入传送门",
|
||||
"admin.entry.user_rating": "XP 管理",
|
||||
"admin.entry.redeem": "战利品管理",
|
||||
|
||||
@@ -6,29 +6,44 @@
|
||||
|
||||
@font-face {
|
||||
font-family: "VT323";
|
||||
src: url("https://fonts.gstatic.com/s/vt323/v17/pxiKyp0ihIEF2hsY.woff2") format("woff2");
|
||||
src: url("https://fonts.gstatic.com/s/vt323/v18/pxiKyp0ihIEF2isfFJU.woff2") format("woff2");
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root[data-theme="minecraft"] {
|
||||
--mc-grass-top: #7cb342;
|
||||
--mc-grass-dark: #558b2f;
|
||||
--mc-bg-main: #151515;
|
||||
--mc-bg-pattern: #1b1b1b;
|
||||
--mc-card: #232323;
|
||||
--mc-card-inner: #2b2b2b;
|
||||
--mc-border-soft: #8a8a8a;
|
||||
--mc-text-main: #eaeaea;
|
||||
--mc-text-muted: #b8b8b8;
|
||||
--mc-text-dim: #7a7a7a;
|
||||
--mc-accent: #35c3ff;
|
||||
--mc-success: #2ecc71;
|
||||
--mc-warning: #f2c94c;
|
||||
--mc-danger: #ff4d4f;
|
||||
--mc-admin: #b47cff;
|
||||
--mc-contest: #f2994a;
|
||||
|
||||
--mc-grass-top: var(--mc-success);
|
||||
--mc-grass-dark: #1f8f4f;
|
||||
--mc-dirt: #795548;
|
||||
--mc-wood-dark: #5d4037;
|
||||
--mc-wood: #8d6e63;
|
||||
--mc-plank: #c69c6d;
|
||||
--mc-plank-light: #efebe9;
|
||||
--mc-stone: #9e9e9e;
|
||||
--mc-stone-dark: #616161;
|
||||
--mc-obsidian: #1f1f1f;
|
||||
--mc-diamond: #00b0d6;
|
||||
--mc-gold: #ffb300;
|
||||
--mc-red: #ef5350;
|
||||
--mc-stone: #8b8b8b;
|
||||
--mc-stone-dark: #474747;
|
||||
--mc-obsidian: #111111;
|
||||
--mc-diamond: var(--mc-accent);
|
||||
--mc-gold: var(--mc-warning);
|
||||
--mc-red: var(--mc-danger);
|
||||
--mc-shadow: #000000;
|
||||
|
||||
--background: #161616;
|
||||
--foreground: #f5f5f5;
|
||||
--surface: #262626;
|
||||
--background: var(--mc-bg-main);
|
||||
--foreground: var(--mc-text-main);
|
||||
--surface: var(--mc-card);
|
||||
--surface-soft: #1f1f1f;
|
||||
--border: #000000;
|
||||
}
|
||||
@@ -36,10 +51,10 @@
|
||||
:root[data-theme="minecraft"] body {
|
||||
background-color: var(--background);
|
||||
background-image:
|
||||
linear-gradient(45deg, #1f1f1f 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #1f1f1f 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #1f1f1f 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #1f1f1f 75%);
|
||||
linear-gradient(45deg, var(--mc-bg-pattern) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, var(--mc-bg-pattern) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, var(--mc-bg-pattern) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, var(--mc-bg-pattern) 75%);
|
||||
background-size: 40px 40px;
|
||||
background-position: 0 0, 0 20px, 20px -20px, -20px 0;
|
||||
color: var(--foreground);
|
||||
@@ -136,15 +151,18 @@
|
||||
}
|
||||
|
||||
:root[data-theme="minecraft"] .text-zinc-400,
|
||||
:root[data-theme="minecraft"] .text-zinc-500,
|
||||
:root[data-theme="minecraft"] .text-zinc-500 {
|
||||
color: var(--mc-text-dim) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="minecraft"] .text-zinc-600,
|
||||
:root[data-theme="minecraft"] .text-zinc-700 {
|
||||
color: #d7d7d7 !important;
|
||||
color: var(--mc-text-muted) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="minecraft"] .text-zinc-800,
|
||||
:root[data-theme="minecraft"] .text-zinc-900 {
|
||||
color: #ececec !important;
|
||||
color: var(--mc-text-main) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="minecraft"] .text-blue-600,
|
||||
@@ -166,6 +184,26 @@
|
||||
color: var(--mc-red) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="minecraft"] .mc-status-running {
|
||||
color: var(--mc-accent) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="minecraft"] .mc-status-success {
|
||||
color: var(--mc-success) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="minecraft"] .mc-status-warning {
|
||||
color: var(--mc-warning) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="minecraft"] .mc-status-danger {
|
||||
color: var(--mc-danger) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="minecraft"] .mc-status-muted {
|
||||
color: var(--mc-text-dim) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="minecraft"] button:not(.mc-reset),
|
||||
:root[data-theme="minecraft"] .mc-btn {
|
||||
background: linear-gradient(180deg, var(--mc-wood) 0%, var(--mc-wood-dark) 100%) !important;
|
||||
@@ -211,6 +249,12 @@
|
||||
background: linear-gradient(180deg, var(--mc-diamond) 0%, #008ba3 100%) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="minecraft"] .mc-btn-warning {
|
||||
background: linear-gradient(180deg, var(--mc-warning) 0%, #b9891b 100%) !important;
|
||||
color: #151515 !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Variant: Danger/Red */
|
||||
:root[data-theme="minecraft"] .mc-btn-danger {
|
||||
background: linear-gradient(180deg, var(--mc-red) 0%, #c62828 100%) !important;
|
||||
@@ -363,4 +407,4 @@
|
||||
|
||||
:root[data-theme="minecraft"] .problem-markdown a {
|
||||
color: #1565c0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户