feat: add daily tasks and fix /admin139 admin entry

这个提交包含在:
Codex CLI
2026-02-15 12:51:42 +08:00
父节点 e2ab522b78
当前提交 ad29a9f62d
修改 13 个文件,包含 1200 行新增30 行删除

查看文件

@@ -0,0 +1,28 @@
import Link from "next/link";
export default function AdminEntryPage() {
return (
<main className="mx-auto max-w-3xl px-4 py-10">
<h1 className="text-2xl font-semibold"></h1>
<p className="mt-3 text-sm text-zinc-600">
<span className="font-medium text-zinc-900">admin</span>
<span className="font-medium text-zinc-900">whoami139</span>
</p>
<div className="mt-6 grid gap-3 sm:grid-cols-2">
<Link className="rounded-lg border bg-white px-4 py-3 text-sm hover:bg-zinc-50" href="/auth">
</Link>
<Link className="rounded-lg border bg-white px-4 py-3 text-sm hover:bg-zinc-50" href="/admin-users">
</Link>
<Link className="rounded-lg border bg-white px-4 py-3 text-sm hover:bg-zinc-50" href="/admin-redeem">
</Link>
<Link className="rounded-lg border bg-white px-4 py-3 text-sm hover:bg-zinc-50" href="/backend-logs">
</Link>
</div>
</main>
);
}

查看文件

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
@@ -12,43 +12,322 @@ type Me = {
created_at: number;
};
type RedeemItem = {
id: number;
name: string;
description: string;
unit_label: string;
holiday_cost: number;
studyday_cost: number;
is_active: boolean;
};
type RedeemRecord = {
id: number;
user_id: number;
item_id: number;
item_name: string;
quantity: number;
day_type: string;
unit_cost: number;
total_cost: number;
note: string;
created_at: number;
};
type RedeemCreateResp = RedeemRecord & {
rating_after?: number;
};
type DailyTaskItem = {
code: string;
title: string;
description: string;
reward: number;
completed: boolean;
completed_at?: number | null;
};
type DailyTaskPayload = {
day_key: string;
total_reward: number;
gained_reward: number;
tasks: DailyTaskItem[];
};
function fmtTs(v: number | null | undefined): string {
if (!v) return "-";
return new Date(v * 1000).toLocaleString();
}
export default function MePage() {
const [data, setData] = useState<Me | null>(null);
const [error, setError] = useState("");
const [token, setToken] = useState("");
const [profile, setProfile] = useState<Me | null>(null);
const [items, setItems] = useState<RedeemItem[]>([]);
const [records, setRecords] = useState<RedeemRecord[]>([]);
const [dailyTasks, setDailyTasks] = useState<DailyTaskItem[]>([]);
const [dailyDayKey, setDailyDayKey] = useState("");
const [dailyTotalReward, setDailyTotalReward] = useState(0);
const [dailyGainedReward, setDailyGainedReward] = useState(0);
const [selectedItemId, setSelectedItemId] = useState<number>(0);
const [quantity, setQuantity] = useState(1);
const [dayType, setDayType] = useState<"holiday" | "studyday">("holiday");
const [note, setNote] = useState("");
const [loading, setLoading] = useState(false);
const [redeemLoading, setRedeemLoading] = useState(false);
const [error, setError] = useState("");
const [msg, setMsg] = useState("");
const selectedItem = useMemo(
() => items.find((item) => item.id === selectedItemId) ?? null,
[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 loadAll = async () => {
setLoading(true);
setError("");
setMsg("");
try {
const tk = readToken();
setToken(tk);
if (!tk) throw new Error("请先登录");
const [me, redeemItems, redeemRecords, daily] = 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),
]);
setProfile(me);
setItems(redeemItems ?? []);
setRecords(redeemRecords ?? []);
setDailyTasks(daily?.tasks ?? []);
setDailyDayKey(daily?.day_key ?? "");
setDailyTotalReward(daily?.total_reward ?? 0);
setDailyGainedReward(daily?.gained_reward ?? 0);
if ((redeemItems ?? []).length > 0) {
setSelectedItemId((prev) => prev || redeemItems[0].id);
}
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
};
useEffect(() => {
const load = async () => {
setLoading(true);
setError("");
try {
const token = readToken();
if (!token) throw new Error("请先登录");
const d = await apiFetch<Me>("/api/v1/me", {}, token);
setData(d);
} catch (e: unknown) {
setError(String(e));
} finally {
setLoading(false);
}
};
void load();
void loadAll();
}, []);
const redeem = async () => {
setRedeemLoading(true);
setError("");
setMsg("");
try {
if (!token) throw new Error("请先登录");
if (!selectedItemId) throw new Error("请选择兑换物品");
if (!Number.isFinite(quantity) || quantity <= 0) throw new Error("兑换数量必须大于 0");
const created = await apiFetch<RedeemCreateResp>(
"/api/v1/me/redeem/records",
{
method: "POST",
body: JSON.stringify({
item_id: selectedItemId,
quantity,
day_type: dayType,
note,
}),
},
token
);
setMsg(
`兑换成功:${created.item_name} × ${created.quantity},扣除 ${created.total_cost} 积分${
typeof created.rating_after === "number" ? `,当前 Rating ${created.rating_after}` : ""
}`
);
setNote("");
await loadAll();
} catch (e: unknown) {
setError(String(e));
} finally {
setRedeemLoading(false);
}
};
return (
<main className="mx-auto max-w-3xl px-6 py-8">
<h1 className="text-2xl font-semibold"></h1>
<main className="mx-auto max-w-6xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl"></h1>
{loading && <p className="mt-3 text-sm text-zinc-500">...</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>}
{data && (
<div className="mt-4 rounded-xl border bg-white p-4 text-sm">
<p>ID: {data.id}</p>
<p>: {data.username}</p>
<p>Rating: {data.rating}</p>
<p>: {new Date(data.created_at * 1000).toLocaleString()}</p>
</div>
{profile && (
<section className="mt-4 rounded-xl border bg-white p-4 text-sm">
<p>ID: {profile.id}</p>
<p>: {profile.username}</p>
<p>Rating: {profile.rating}</p>
<p>: {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>
<p className="text-xs text-zinc-600">
{dailyDayKey ? `${dailyDayKey} · ` : ""} {dailyGainedReward}/{dailyTotalReward}
</p>
</div>
<div className="mt-3 divide-y">
{dailyTasks.map((task) => (
<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}
</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 ? "已完成" : "未完成"}
</span>
</div>
<p className="mt-1 text-xs text-zinc-600">{task.description}</p>
{task.completed && (
<p className="mt-1 text-xs text-zinc-500">{fmtTs(task.completed_at)}</p>
)}
</article>
))}
{!loading && dailyTasks.length === 0 && (
<p className="py-3 text-sm text-zinc-500"></p>
)}
</div>
</section>
<section className="mt-4 rounded-xl border bg-white p-4">
<h2 className="text-base font-semibold"></h2>
<p className="mt-1 text-xs text-zinc-600">
1 =5 / 1 =25
</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>
<button
className="rounded border px-2 py-1 text-xs hover:bg-zinc-100"
onClick={() => setSelectedItemId(item.id)}
>
</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>
</article>
))}
{!loading && items.length === 0 && (
<p className="text-sm text-zinc-500"></p>
)}
</div>
<div className="mt-4 rounded-lg border p-3">
<h3 className="text-sm font-medium"></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>
{items.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
<select
className="rounded border px-3 py-2 text-sm"
value={dayType}
onChange={(e) => setDayType(e.target.value === "studyday" ? "studyday" : "holiday")}
>
<option value="holiday"></option>
<option value="studyday">/</option>
</select>
<input
className="rounded border px-3 py-2 text-sm"
type="number"
min={1}
max={24}
value={quantity}
onChange={(e) => setQuantity(Math.max(1, Number(e.target.value) || 1))}
placeholder="兑换时长(小时)"
/>
<input
className="rounded border px-3 py-2 text-sm"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="备注(可选)"
/>
</div>
<p className="mt-2 text-xs text-zinc-600">
{unitCost} / {totalCost}
</p>
<button
className="mt-3 rounded bg-zinc-900 px-4 py-2 text-sm text-white disabled:opacity-50"
onClick={() => void redeem()}
disabled={redeemLoading || !selectedItemId}
>
{redeemLoading ? "兑换中..." : "确认兑换"}
</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>
<button
className="rounded border px-3 py-1 text-xs hover:bg-zinc-100"
onClick={() => void loadAll()}
disabled={loading}
>
</button>
</div>
<div className="mt-3 divide-y">
{records.map((row) => (
<article key={row.id} className="py-2 text-sm">
<p>
#{row.id} · {row.item_name} · {row.quantity} · {row.day_type === "holiday" ? "假期" : "学习日"}
</p>
<p className="text-xs text-zinc-600">
{row.unit_cost} {row.total_cost} · {fmtTs(row.created_at)}
</p>
{row.note && <p className="text-xs text-zinc-500">{row.note}</p>}
</article>
))}
{!loading && records.length === 0 && <p className="py-3 text-sm text-zinc-500"></p>}
</div>
</section>
</main>
);
}