feat: add daily tasks and fix /admin139 admin entry
这个提交包含在:
@@ -14,7 +14,8 @@ const nextConfig: NextConfig = {
|
||||
|
||||
return [
|
||||
{
|
||||
source: "/admin139/:path*",
|
||||
// Keep /admin139 as frontend admin entry page, only proxy nested API paths.
|
||||
source: "/admin139/:path+",
|
||||
destination: `${backendInternal}/:path*`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户