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

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

查看文件

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