feat: 类型帖互动、积分经济与发帖体验

补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 03:17:07 +08:00
parent 1ef3d8e299
commit b9ba8cb8c9
58 changed files with 6913 additions and 506 deletions

View File

@@ -12,6 +12,7 @@ import {
ShieldCheck,
ArrowLeft,
ClipboardCheck,
Coins,
} from "lucide-react";
import type { User } from "@/lib/api";
import { apiAdminPendingCounts } from "@/lib/api";
@@ -47,6 +48,12 @@ const NAV_ITEMS: NavItem[] = [
visible: (u) => canModerateAny(u),
pendingBadge: true,
},
{
href: "/admin/economy",
label: "积分看板",
icon: Coins,
visible: (u) => isAdminOrAbove(u.role),
},
{
href: "/admin/announcements",
label: "公告管理",

View File

@@ -191,7 +191,7 @@ export default function AnnouncementAdmin() {
<button
key={c.value}
type="button"
title={c.label}
data-tip={c.label}
aria-label={c.label}
aria-pressed={form.tag_color === c.value}
className={`j13-anno-dot is-${c.value}`}
@@ -322,7 +322,7 @@ export default function AnnouncementAdmin() {
<p
className="mt-1 truncate text-[14px] font-semibold"
style={{ color: "var(--ink)" }}
title={a.title}
data-tip={a.title}
>
{a.title}
</p>
@@ -334,7 +334,7 @@ export default function AnnouncementAdmin() {
target="_blank"
className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
style={{ color: "var(--ink-3)" }}
title="查看"
data-tip="查看"
aria-label="查看公告"
>
<ExternalLink size={15} />
@@ -344,7 +344,7 @@ export default function AnnouncementAdmin() {
onClick={() => startEdit(a)}
className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
style={{ color: "var(--ink-3)" }}
title="编辑"
data-tip="编辑"
aria-label="编辑公告"
>
<Pencil size={15} />
@@ -356,7 +356,7 @@ export default function AnnouncementAdmin() {
style={{ color: "var(--ink-3)" }}
onMouseEnter={(e) => (e.currentTarget.style.color = "var(--danger)")}
onMouseLeave={(e) => (e.currentTarget.style.color = "var(--ink-3)")}
title="删除"
data-tip="删除"
aria-label="删除公告"
>
{deletingId === a.id ? <Loader2 size={15} className="animate-spin" /> : <Trash2 size={15} />}

View File

@@ -121,7 +121,7 @@ export default function AppearanceAdmin({ initialAccent }: { initialAccent: stri
cursor: "pointer",
}}
aria-pressed={active}
title={isDefault ? `${p.name}(默认)` : p.name}
data-tip={isDefault ? `${p.name}(默认)` : p.name}
>
<span className="relative flex items-center justify-center">
{/* 上半圆=浅色模式,下半圆=暗色模式 */}
@@ -163,7 +163,7 @@ export default function AppearanceAdmin({ initialAccent }: { initialAccent: stri
<label
className="relative w-12 h-12 rounded-[var(--r-sm)] overflow-hidden border cursor-pointer shrink-0"
style={{ borderColor: "var(--line-2)" }}
title="打开取色器"
data-tip="打开取色器"
>
<input
type="color"

View File

@@ -0,0 +1,176 @@
import type { Metadata } from "next";
import Link from "next/link";
import { cookies } from "next/headers";
import { Coins, Flame, Gift, Layers } from "lucide-react";
import { fetchAdminEconomyStats } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { postTypeLabel } from "@/lib/postMeta";
import { pointReasonLabel } from "@/lib/points";
import { formatRelative } from "@/lib/format";
import Forbidden from "../Forbidden";
export const metadata: Metadata = {
title: "积分看板",
};
const fmt = (n: number) => n.toLocaleString("zh-CN");
export default async function AdminEconomyPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isAdminOrAbove(me.user.role)) {
return <Forbidden text="积分看板仅管理员及以上可查看。" />;
}
let stats: Awaited<ReturnType<typeof fetchAdminEconomyStats>> | null = null;
try {
stats = await fetchAdminEconomyStats(cookie);
} catch {
stats = null;
}
if (!stats) {
return (
<div className="panel p-8 text-center">
<p className="meta">暂时无法加载积分数据</p>
</div>
);
}
const cards = [
{
label: "用户余额合计",
value: fmt(stats.total_balance),
icon: <Coins size={18} />,
hint: "全站可用积分之和",
},
{
label: "今日签到",
value: fmt(stats.checkin_today),
icon: <Flame size={18} />,
hint: "今日已签到人数",
},
{
label: "托管中悬赏",
value: `${fmt(stats.open_bounties)} / ${fmt(stats.escrowed_points)}`,
icon: <Gift size={18} />,
hint: "未结算帖数 / 托管积分",
},
{
label: "流水条数",
value: fmt(stats.ledger_count),
icon: <Layers size={18} />,
hint: "历史入账出账记录",
},
];
const typeEntries = Object.entries(stats.posts_by_type || {}).sort(
(a, b) => b[1] - a[1]
);
return (
<div>
<div className="mb-6">
<h1 className="text-[22px] font-extrabold" style={{ color: "var(--ink)" }}>
积分与类型帖看板
</h1>
<p className="meta text-[13px] mt-1">
观察全站积分流通、悬赏托管与帖子类型分布
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-6">
{cards.map((c) => (
<div key={c.label} className="panel p-5">
<div className="flex items-center gap-2 meta text-[12.5px]">
<span style={{ color: "var(--accent)" }}>{c.icon}</span>
{c.label}
</div>
<p
className="mt-3 text-[22px] font-extrabold tabular-nums leading-none"
style={{ color: "var(--ink)" }}
>
{c.value}
</p>
<p className="meta text-[12px] mt-2">{c.hint}</p>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<section className="panel overflow-hidden">
<div
className="px-5 py-3.5 font-bold text-[15px]"
style={{ borderBottom: "1px solid var(--line)", color: "var(--ink)" }}
>
帖子类型分布
</div>
{typeEntries.length === 0 ? (
<p className="meta text-center py-10">暂无数据</p>
) : (
<ul>
{typeEntries.map(([t, n]) => (
<li
key={t}
className="px-5 py-3 flex items-center justify-between"
style={{ borderTop: "1px solid var(--line)" }}
>
<span className="font-semibold text-[14px]" style={{ color: "var(--ink)" }}>
{postTypeLabel(t)}
</span>
<span className="tabular-nums meta">{fmt(n)}</span>
</li>
))}
</ul>
)}
</section>
<section className="panel overflow-hidden">
<div
className="px-5 py-3.5 font-bold text-[15px]"
style={{ borderBottom: "1px solid var(--line)", color: "var(--ink)" }}
>
最近流水
</div>
{(stats.recent_ledger || []).length === 0 ? (
<p className="meta text-center py-10">暂无流水</p>
) : (
<ul>
{stats.recent_ledger.map((it) => (
<li
key={it.id}
className="px-5 py-3 flex items-start justify-between gap-3"
style={{ borderTop: "1px solid var(--line)" }}
>
<div className="min-w-0">
<p className="font-semibold text-[13.5px] truncate" style={{ color: "var(--ink)" }}>
{pointReasonLabel(it.reason)}
{it.note ? ` · ${it.note}` : ""}
</p>
<p className="meta text-[12px] mt-0.5">{formatRelative(it.created_at)}</p>
</div>
<span
className="tabular-nums font-bold shrink-0"
style={{ color: it.delta >= 0 ? "var(--accent)" : "var(--danger)" }}
>
{it.delta >= 0 ? `+${it.delta}` : it.delta}
</span>
</li>
))}
</ul>
)}
</section>
</div>
<p className="meta text-[12.5px] mt-5">
用户可在{" "}
<Link href="/points" className="hover:text-[var(--accent)]">
/points
</Link>{" "}
查看个人流水。
</p>
</div>
);
}

View File

@@ -17,7 +17,7 @@ import {
} from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { canModerateAny, isSuperOrOwner } from "@/lib/roles";
import { canModerateAny, isSuperOrOwner, isAdminOrAbove } from "@/lib/roles";
import Avatar from "@/components/Avatar";
import { formatRelative } from "@/lib/format";
@@ -127,6 +127,7 @@ export default async function AdminDashboardPage() {
const me = await getMeCached(cookie || undefined);
const canMod = canModerateAny(me.user);
const canUsers = isSuperOrOwner(me.user?.role);
const canEconomy = isAdminOrAbove(me.user?.role);
const [overview, pendingCounts] = await Promise.all([
fetchOverview(cookie).catch(() => null),
@@ -163,6 +164,21 @@ export default async function AdminDashboardPage() {
{canMod && pendingCounts && <PendingWorkPanel counts={pendingCounts} />}
{canEconomy && (
<Link
href="/admin/economy"
className="panel mb-4 px-4 sm:px-5 py-4 flex items-center justify-between gap-3 transition-colors hover:bg-[var(--panel-2)]"
>
<div>
<p className="font-bold text-[15px]" style={{ color: "var(--ink)" }}>
积分与类型帖看板
</p>
<p className="meta text-[12.5px] mt-0.5">余额合计、悬赏托管、类型分布与最近流水</p>
</div>
<ChevronRight size={18} style={{ color: "var(--ink-3)" }} />
</Link>
)}
<div className="grid lg:grid-cols-2 gap-4 items-start">
<div className="panel overflow-hidden">
<PanelHead

View File

@@ -281,7 +281,7 @@ export default function UsersAdmin({
<span
className="inline-flex items-center gap-1.5 text-[12.5px] font-bold"
style={{ color: "var(--danger)" }}
title="账号已封禁"
data-tip="账号已封禁"
>
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: "var(--danger)" }} aria-hidden />
封禁
@@ -293,7 +293,7 @@ export default function UsersAdmin({
<span
className="inline-flex items-center gap-1.5 text-[12.5px] font-bold"
style={{ color: "var(--ok)" }}
title={u.last_seen_at ? `最近活跃 ${formatRelative(u.last_seen_at)}` : "在线"}
data-tip={u.last_seen_at ? `最近活跃 ${formatRelative(u.last_seen_at)}` : "在线"}
>
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: "var(--ok)" }} aria-hidden />
在线
@@ -304,7 +304,7 @@ export default function UsersAdmin({
<span
className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold"
style={{ color: "var(--ink-3)" }}
title={u.last_seen_at ? `最近活跃 ${formatRelative(u.last_seen_at)}` : "从未活跃"}
data-tip={u.last_seen_at ? `最近活跃 ${formatRelative(u.last_seen_at)}` : "从未活跃"}
>
<span
className="w-2 h-2 rounded-full shrink-0"
@@ -321,7 +321,7 @@ export default function UsersAdmin({
type="button"
onClick={() => setLogsTarget(u)}
className="min-w-0 text-left group rounded-lg -mx-1.5 px-1.5 py-1 transition-colors hover:bg-[var(--panel-2)]"
title="查看登录历史"
data-tip="查看登录历史"
aria-label={`查看 ${u.username} 的登录历史`}
>
<span
@@ -341,7 +341,7 @@ export default function UsersAdmin({
const auditBtn = canAudit ? (
<Link
href={`/admin/users/${u.id}`}
title="内容档案"
data-tip="内容档案"
aria-label={`查看 ${u.username} 的内容档案`}
className={ghostBtn}
style={{ color: "var(--accent)" }}
@@ -357,7 +357,7 @@ export default function UsersAdmin({
{auditBtn}
<Link
href={`/u/${u.id}`}
title="查看个人主页"
data-tip="查看个人主页"
className={ghostBtn}
style={{ color: "var(--ink-3)" }}
>
@@ -385,7 +385,7 @@ export default function UsersAdmin({
{auditBtn}
<button
type="button"
title="登录历史"
data-tip="登录历史"
aria-label={`查看 ${u.username} 的登录历史`}
onClick={() => setLogsTarget(u)}
className={ghostBtn}
@@ -401,7 +401,7 @@ export default function UsersAdmin({
</button>
<Link
href={`/u/${u.id}`}
title="查看个人主页"
data-tip="查看个人主页"
aria-label={`查看 ${u.nickname} 的个人主页`}
className={ghostBtn}
style={{ color: "var(--ink-3)" }}
@@ -410,7 +410,7 @@ export default function UsersAdmin({
</Link>
<button
type="button"
title="角色与账号"
data-tip="角色与账号"
aria-label={`设置 ${u.username} 的角色与账号状态`}
disabled={busy}
onClick={() => setRoleTarget(u)}
@@ -653,7 +653,7 @@ export default function UsersAdmin({
<p
className="text-[12.5px] font-semibold tabular-nums"
style={{ color: "var(--ink-2)" }}
title="仅统计已发布内容;驳回/删除见内容档案"
data-tip="仅统计已发布内容;驳回/删除见内容档案"
>
帖 {u.post_count} · 评 {u.comment_count}
</p>
@@ -695,7 +695,7 @@ export default function UsersAdmin({
<p
className="mt-2.5 text-[11.5px] tabular-nums"
style={{ color: "var(--ink-3)" }}
title="仅统计已发布内容;驳回/删除见内容档案"
data-tip="仅统计已发布内容;驳回/删除见内容档案"
>
帖 {u.post_count} · 评 {u.comment_count} · 注册 {formatDate(u.created_at)}
</p>
@@ -1195,7 +1195,7 @@ function LoginLogsDialog({ target, onClose }: { target: AdminUser | null; onClos
<span
className="mt-1 w-2 h-2 rounded-full shrink-0"
style={{ background: log.success ? "var(--ok)" : "var(--danger)" }}
title={log.success ? "成功" : "失败"}
data-tip={log.success ? "成功" : "失败"}
aria-hidden
/>
<div className="min-w-0 flex-1">
@@ -1212,7 +1212,7 @@ function LoginLogsDialog({ target, onClose }: { target: AdminUser | null; onClos
{log.success ? "成功" : "失败"}
</span>
</p>
<p className="text-[11px] mt-0.5 truncate" style={{ color: "var(--ink-3)" }} title={log.user_agent}>
<p className="text-[11px] mt-0.5 truncate" style={{ color: "var(--ink-3)" }} data-tip={log.user_agent}>
{log.user_agent || "未知设备"}
</p>
</div>