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

@@ -0,0 +1,130 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import {
apiGetPoints,
apiGetPointsLedger,
type PointLedgerItem,
} from "@/lib/api";
import { pointReasonLabel } from "@/lib/points";
import { formatRelative } from "@/lib/format";
import Pagination from "@/components/Pagination";
export default function PointsClient() {
const [balance, setBalance] = useState<number | null>(null);
const [items, setItems] = useState<PointLedgerItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const size = 20;
const load = useCallback(async (p: number) => {
setLoading(true);
try {
const [bal, led] = await Promise.all([
apiGetPoints(),
apiGetPointsLedger(p, size),
]);
setBalance(typeof bal.points === "number" ? bal.points : 0);
setItems(led.items || []);
setTotal(led.total || 0);
setPage(led.page || p);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load(1);
}, [load]);
return (
<div className="max-w-3xl mx-auto">
<nav className="meta mb-5 flex items-center gap-1.5 text-[13px]">
<Link href="/" className="hover:text-[var(--accent)] transition-colors">
首页
</Link>
<ChevronRight size={13} />
<span style={{ color: "var(--ink-2)" }}>我的积分</span>
</nav>
<section className="panel p-6 sm:p-8 mb-5">
<p className="meta text-[13px]">可用余额</p>
<p
className="mt-2 text-[36px] font-extrabold tabular-nums leading-none"
style={{ color: "var(--accent)" }}
>
{balance === null ? "—" : balance.toLocaleString("zh-CN")}
</p>
<p className="meta text-[12.5px] mt-3">
签到、悬赏采纳与附件收益会增加余额;解锁正文、悬赏托管与积分附件会扣减。
</p>
</section>
<section className="panel overflow-hidden">
<div
className="px-5 py-3.5 flex items-center justify-between"
style={{ borderBottom: "1px solid var(--line)" }}
>
<h1 className="font-extrabold text-[16px]" style={{ color: "var(--ink)" }}>
积分流水
</h1>
<span className="meta text-[12px] tabular-nums">{total} 条</span>
</div>
{loading && items.length === 0 ? (
<p className="meta text-center py-12">加载中…</p>
) : items.length === 0 ? (
<p className="meta text-center py-12">暂无流水,去首页签到赚第一笔积分吧</p>
) : (
<ul>
{items.map((it) => (
<li
key={it.id}
className="px-5 py-3.5 flex items-start justify-between gap-4"
style={{ borderTop: "1px solid var(--line)" }}
>
<div className="min-w-0">
<p className="font-bold text-[14px]" style={{ color: "var(--ink)" }}>
{pointReasonLabel(it.reason)}
</p>
{it.note && (
<p className="meta text-[12.5px] mt-0.5 truncate">{it.note}</p>
)}
<p className="meta text-[12px] mt-1 tabular-nums">
{formatRelative(it.created_at)}
</p>
</div>
<div className="text-right shrink-0">
<p
className="font-extrabold tabular-nums text-[15px]"
style={{ color: it.delta >= 0 ? "var(--accent)" : "var(--danger)" }}
>
{it.delta >= 0 ? `+${it.delta}` : it.delta}
</p>
<p className="meta text-[12px] tabular-nums mt-0.5">
余 {it.balance}
</p>
</div>
</li>
))}
</ul>
)}
{total > size && (
<div className="px-5 py-4" style={{ borderTop: "1px solid var(--line)" }}>
<Pagination
total={total}
page={page}
size={size}
basePath="/points"
onChange={(p) => void load(p)}
/>
</div>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import PointsClient from "./PointsClient";
export const metadata: Metadata = {
title: "我的积分",
robots: { index: false, follow: false },
};
export default async function PointsPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user) {
redirect(`/login?redirect=${encodeURIComponent("/points")}`);
}
return <PointsClient />;
}