feat: 类型帖互动、积分经济与发帖体验
补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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: "公告管理",
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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"
|
||||
|
||||
176
frontend/app/admin/economy/page.tsx
Normal file
176
frontend/app/admin/economy/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user