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>

View File

@@ -77,7 +77,7 @@ export default async function BoardPage({ params, searchParams }: PageProps) {
<p className="meta mt-1.5">{postsData.total} 篇讨论</p>
</div>
</div>
<Link href="/compose" className="btn btn-primary">
<Link href={`/compose?board=${id}`} className="btn btn-primary">
<PenSquare size={15} /> 在此板块发帖
</Link>
</div>
@@ -117,7 +117,7 @@ export default async function BoardPage({ params, searchParams }: PageProps) {
</span>
<p className="font-bold text-lg" style={{ color: "var(--ink)" }}>该板块还没有帖子</p>
<p className="meta mt-1.5">来发第一篇,抢占沙发</p>
<Link href="/compose" className="btn btn-primary mt-6">
<Link href={`/compose?board=${id}`} className="btn btn-primary mt-6">
<PenSquare size={15} /> 写新帖
</Link>
</div>

View File

@@ -331,7 +331,7 @@ export default function ChatShell({
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-[var(--accent-soft)] transition-colors"
style={{ color: "var(--accent)" }}
aria-label="新建群聊"
title="新建群聊"
data-tip="新建群聊"
>
<Plus size={18} />
</button>
@@ -480,7 +480,7 @@ export default function ChatShell({
background: "var(--accent-soft)",
color: "var(--accent)",
}}
title={room.is_default ? "强制置顶" : "置顶"}
data-tip={room.is_default ? "强制置顶" : "置顶"}
>
<Pin size={10} /> 置顶
</span>

View File

@@ -132,7 +132,7 @@ function RecallNotice({
href={`/u/${uid}`}
className="font-semibold transition-opacity hover:opacity-80"
style={nameStyle}
title={`查看 ${name} 的主页`}
data-tip={`查看 ${name} 的主页`}
>
{name}
</Link>
@@ -217,7 +217,7 @@ function highlightMentions(content: string, members: ChatRoomMember[]) {
key={`m-${i}`}
href={`/u/${user.id}`}
className="j13-mention-token"
title={`查看 ${user.nickname || user.username} 的主页`}
data-tip={`查看 ${user.nickname || user.username} 的主页`}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.stopPropagation()}
>
@@ -1001,7 +1001,7 @@ export default function ChatRoomClient({
color: rightPanel === "members" ? "var(--accent)" : "var(--ink-2)",
background: rightPanel === "members" ? "var(--accent-soft)" : undefined,
}}
title="成员"
data-tip="成员"
>
<Users size={16} />
</button>
@@ -1015,7 +1015,7 @@ export default function ChatRoomClient({
color: rightPanel === "invite" ? "var(--accent)" : "var(--ink-2)",
background: rightPanel === "invite" ? "var(--accent-soft)" : undefined,
}}
title="邀请成员"
data-tip="邀请成员"
>
<UserPlus size={16} />
</button>
@@ -1029,7 +1029,7 @@ export default function ChatRoomClient({
color: rightPanel === "settings" ? "var(--accent)" : "var(--ink-2)",
background: rightPanel === "settings" ? "var(--accent-soft)" : undefined,
}}
title="群设置"
data-tip="群设置"
>
<Settings size={16} />
</button>
@@ -1040,7 +1040,7 @@ export default function ChatRoomClient({
aria-label={isDirect ? "关闭私聊" : "退出群聊"}
className="w-9 h-9 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--danger-soft)]"
style={{ color: "var(--ink-3)" }}
title={isDirect ? "关闭私聊" : "退出群聊"}
data-tip={isDirect ? "关闭私聊" : "退出群聊"}
>
<LogOut size={16} />
</button>

View File

@@ -0,0 +1,18 @@
/**
* 发帖路由布局:标记 data-compose-viewport,配合 globals.css
* 让舞台占满 main 剩余高度(对齐私信体量,保留页脚)。
*/
export default function ComposeLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div
data-compose-viewport
className="flex flex-col flex-1 min-h-0 w-full max-w-6xl mx-auto"
>
{children}
</div>
);
}

View File

@@ -1,153 +1,64 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useEffect, useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { ChevronRight, PenSquare } from "lucide-react";
import { apiCreatePost, apiMe, fetchBoards, type Board } from "@/lib/api";
import { toast } from "@/lib/toast";
import { ChevronRight } from "lucide-react";
import { apiMe } from "@/lib/api";
import PostComposer from "@/components/PostComposer";
export default function ComposePage() {
function ComposeInner() {
const router = useRouter();
const [boards, setBoards] = useState<Board[]>([]);
const [boardId, setBoardId] = useState<number>(0);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [tags, setTags] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const searchParams = useSearchParams();
const initialBoardId = Number(searchParams.get("board")) || 0;
const [ready, setReady] = useState(false);
useEffect(() => {
apiMe()
.then((res) => {
if (!res.user) {
router.push("/login?redirect=/compose");
const q = initialBoardId > 0 ? `?board=${initialBoardId}` : "";
router.push(`/login?redirect=${encodeURIComponent(`/compose${q}`)}`);
return;
}
fetchBoards().then((data) => {
setBoards(data.boards);
if (data.boards.length > 0) setBoardId(data.boards[0].id);
});
setReady(true);
})
.catch(() => router.push("/login?redirect=/compose"));
}, [router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
try {
const res = await apiCreatePost({
board_id: boardId,
title,
content,
tags,
post_type: "normal",
.catch(() => {
router.push("/login?redirect=/compose");
});
if (res.post) {
// 普通用户发帖进入待审核队列:跳转详情页(作者可见)并提示
if (res.post.status === "pending") {
toast("已提交,等待审核通过后公开", "ok");
}
router.push(`/post/${res.post.id}`);
} else {
setError(res.error || "发帖失败");
}
} finally {
setLoading(false);
}
};
}, [router, initialBoardId]);
if (!ready) {
return (
<div className="j13-compose-stage px-8 py-16 text-center">
<p className="meta">正在准备发帖…</p>
</div>
);
}
return <PostComposer mode="create" initialBoardId={initialBoardId} />;
}
export default function ComposePage() {
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>
<div className="j13-compose-page">
<nav className="meta mb-4 flex items-center gap-1.5 text-[13px] shrink-0">
<Link href="/" className="hover:text-[var(--accent)] transition-colors">
首页
</Link>
<ChevronRight size={13} />
<span style={{ color: "var(--accent)" }}>发布新帖</span>
<span style={{ color: "var(--ink-2)" }}>发布新帖</span>
</nav>
<div className="mb-7 flex items-center gap-3.5">
<span
className="w-12 h-12 rounded-2xl flex items-center justify-center shrink-0"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<PenSquare size={22} />
</span>
<div>
<h1 className="text-[26px] sm:text-[30px] font-extrabold tracking-tight leading-tight" style={{ color: "var(--ink)" }}>
发布新帖
</h1>
<p className="meta mt-0.5">清晰的标题和内容,更容易获得回应</p>
</div>
</div>
<form onSubmit={handleSubmit} className="panel p-5 sm:p-7 space-y-5">
{error && <div className="alert-error">{error}</div>}
<div>
<label className="field-label">选择板块</label>
<select
value={boardId}
onChange={(e) => setBoardId(Number(e.target.value))}
className="field"
>
{boards.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</div>
<div>
<label className="field-label">标题</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
maxLength={256}
placeholder="一句话说清楚你要讨论什么"
className="field"
/>
</div>
<div>
<label className="field-label">标签(逗号分隔,可选)</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="Go, PostgreSQL, 架构"
className="field"
/>
</div>
<div>
<label className="field-label">正文(支持 Markdown)</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
required
rows={14}
placeholder="# 开头写结论&#10;&#10;支持 Markdown 语法..."
className="field"
/>
</div>
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={loading || !title.trim() || !content.trim()}
className="btn btn-primary"
>
{loading ? "发布中..." : "发布帖子"}
</button>
<Link href="/" className="btn btn-line">
取消
</Link>
</div>
</form>
<Suspense
fallback={
<div className="j13-compose-stage px-8 py-16 text-center">
<p className="meta">正在准备发帖…</p>
</div>
}
>
<ComposeInner />
</Suspense>
</div>
);
}

View File

@@ -113,6 +113,497 @@ body {
text-rendering: optimizeLegibility;
}
/* 全站悬停即显提示气泡(配合 TipHost + data-tip,替代原生 title 延迟) */
.j13-tip-bubble {
position: fixed;
z-index: 9999;
max-width: min(280px, calc(100vw - 20px));
padding: 5px 10px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
line-height: 1.35;
letter-spacing: 0.01em;
pointer-events: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--panel);
background: color-mix(in srgb, var(--ink) 92%, transparent);
border: 1px solid color-mix(in srgb, var(--ink) 88%, transparent);
box-shadow: var(--shadow);
animation: j13-tip-in 80ms ease-out;
}
.dark .j13-tip-bubble {
color: var(--ink);
background: color-mix(in srgb, var(--panel-2) 94%, var(--ink));
border-color: var(--line-2);
}
@keyframes j13-tip-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.j13-tip-bubble {
animation: none;
}
}
/* ============================================================
发帖台:文档式舞台(对齐私信页体量;不改动全站 .field)
控件:白底 + 单层描边;聚焦同 j13-chat-search
============================================================ */
.j13-compose-page {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
/* Suspense / 客户端壳层也要把高度往下传 */
.j13-compose-page > :last-child {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
.j13-compose-stage {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--r);
box-shadow: var(--shadow);
overflow: visible;
}
.j13-compose-body {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
padding: 1.35rem 1.35rem 0.85rem;
gap: 1.1rem;
}
@media (min-width: 640px) {
.j13-compose-body {
padding: 1.5rem 1.75rem 0.9rem;
gap: 1.15rem;
}
}
.j13-compose-actions {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem;
padding: 1rem 1.5rem 1.25rem;
border-top: 1px solid var(--line);
background: var(--panel);
}
@media (min-width: 640px) {
.j13-compose-actions {
padding-left: 2rem;
padding-right: 2rem;
}
}
/* 元信息:板块固定宽 + 标签吃满剩余 + 可见性 */
.j13-compose-meta {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 0.85rem 1rem;
}
.j13-compose-meta-board {
width: 100%;
max-width: 220px;
flex: 0 1 220px;
}
.j13-compose-meta-tags {
flex: 1 1 240px;
min-width: 200px;
max-width: 100%;
}
.j13-compose-meta-access {
width: 100%;
max-width: 200px;
flex: 0 1 200px;
}
.j13-compose-typebar {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.j13-compose-typechip {
height: 34px;
padding: 0 14px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--panel);
color: var(--ink-2);
font-size: 13px;
font-weight: 700;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.j13-compose-typechip:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--accent) 35%, var(--line));
color: var(--accent);
}
.j13-compose-typechip.is-on {
background: var(--accent-soft);
border-color: color-mix(in srgb, var(--accent) 40%, var(--line));
color: var(--accent);
}
.j13-compose-typechip:disabled:not(.is-on) {
opacity: 0.45;
cursor: not-allowed;
}
.j13-compose-typepanel {
padding: 12px 14px;
border-radius: 12px;
border: 1px dashed color-mix(in srgb, var(--accent) 28%, var(--line));
background: color-mix(in srgb, var(--accent-soft) 55%, var(--panel));
}
.j13-compose-attach-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--panel);
}
.j13-interact-card {
padding: 14px 16px;
border-radius: 14px;
border: 1px solid var(--line);
background: color-mix(in srgb, var(--tint) 65%, var(--panel));
}
.j13-compose-field {
width: 100%;
padding: 10px 14px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
color: var(--ink);
font-size: 15px;
font-family: var(--font-sans);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.j13-compose-field::placeholder { color: var(--ink-3); }
.j13-compose-field:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.j13-compose-field:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.j13-compose-title {
width: 100%;
padding: 0.25rem 0 0.75rem;
margin: 0;
border: 0;
border-bottom: 1px solid var(--line);
border-radius: 0;
background: transparent;
color: var(--ink);
font-family: var(--font-sans);
font-size: clamp(1.4rem, 2.5vw, 1.75rem);
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.3;
transition: border-color 0.15s ease;
}
.j13-compose-title::placeholder {
color: var(--ink-3);
font-weight: 700;
}
.j13-compose-title:focus {
outline: none;
border-bottom-color: var(--accent);
box-shadow: none;
}
/* 标签:对齐私信搜索 —— 同高、同聚焦,输入无内层描边;白底避免灰块 */
.j13-tag-field {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-height: 2.5rem;
padding: 4px 12px;
cursor: text;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.j13-tag-field:focus-within {
border-color: var(--accent) !important;
box-shadow: 0 0 0 3px var(--accent-soft);
}
.j13-tag-field-input,
.j13-tag-field-input:focus,
.j13-tag-field-input:focus-visible {
outline: none !important;
box-shadow: none !important;
border: 0 !important;
background: transparent !important;
}
.j13-board-picker { position: relative; }
.j13-board-trigger {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
height: 2.5rem;
min-height: 2.5rem;
padding: 0 12px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
color: var(--ink);
text-align: left;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
cursor: pointer;
}
.j13-board-trigger:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--line-2) 40%, var(--line));
}
.j13-board-trigger:focus { outline: none; }
.j13-board-trigger:focus-visible,
.j13-board-trigger[aria-expanded="true"] {
outline: none !important;
border-color: var(--accent) !important;
box-shadow: 0 0 0 3px var(--accent-soft);
}
.j13-board-trigger:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.j13-board-trigger-ico {
width: 24px;
height: 24px;
border-radius: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 800;
flex-shrink: 0;
}
.j13-board-menu {
position: absolute;
z-index: 40;
top: calc(100% + 6px);
left: 0;
right: 0;
min-width: 100%;
max-height: 280px;
overflow-y: auto;
padding: 6px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
box-shadow: var(--shadow-lg);
}
.j13-board-option {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 10px;
color: var(--ink);
font-size: 14px;
font-weight: 600;
text-align: left;
cursor: pointer;
transition: background 0.12s ease;
}
.j13-board-option:hover,
.j13-board-option.is-active,
.j13-board-option.is-selected {
background: var(--accent-soft);
color: var(--accent);
}
.j13-board-badge {
display: inline-flex;
align-items: center;
gap: 10px;
height: 2.5rem;
min-height: 2.5rem;
padding: 0 12px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
color: var(--ink-2);
font-size: 14px;
font-weight: 700;
}
/* 正文画布占满剩余高度 */
.j13-compose-canvas {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
.j13-md-editor {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
margin-top: 0;
border: 0;
background: transparent;
overflow: visible;
}
.j13-md-editor.is-fullscreen {
position: fixed;
inset: 0;
z-index: 80;
margin: 0;
padding: 1rem 1.25rem 1.25rem;
background: var(--panel);
border: 0;
box-shadow: var(--shadow-lg);
}
.j13-md-toolbar {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
padding: 4px 0 12px;
border-bottom: 1px solid var(--line);
background: transparent;
}
.j13-md-tool {
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
color: var(--ink-2);
transition: background 0.12s ease, color 0.12s ease;
}
.j13-md-tool:hover:not(:disabled) {
background: var(--accent-soft);
color: var(--accent);
}
.j13-md-tool:disabled { opacity: 0.4; }
.j13-md-tool:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.j13-md-mode {
display: inline-flex;
align-items: center;
padding: 3px;
border-radius: 999px;
background: var(--panel);
border: 1px solid var(--line);
}
.j13-md-mode button {
height: 30px;
padding: 0 12px;
border-radius: 999px;
font-size: 12.5px;
font-weight: 700;
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--ink-3);
}
.j13-md-mode button.is-on {
background: var(--accent-soft);
color: var(--accent);
}
.j13-md-textarea {
flex: 1 1 auto;
width: 100%;
min-height: 320px;
height: 100%;
resize: none;
border: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
background: transparent !important;
padding: 1rem 0 0.75rem !important;
color: var(--ink);
font-family: var(--font-sans);
font-size: 15.5px !important;
line-height: 1.8 !important;
}
.j13-md-editor.is-fullscreen .j13-md-textarea,
.j13-md-editor.is-fullscreen .j13-md-preview {
min-height: 0;
}
.j13-md-textarea::placeholder { color: var(--ink-3); }
.j13-md-textarea:focus,
.j13-md-textarea:focus-visible {
outline: none !important;
box-shadow: none !important;
border: 0 !important;
}
.j13-md-preview {
flex: 1 1 auto;
min-height: 320px;
overflow-y: auto;
padding: 1rem 0 0.75rem;
background: transparent;
}
.j13-md-foot {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 0 0;
font-size: 12.5px;
color: var(--ink-3);
border-top: 1px solid var(--line);
background: transparent;
}
.j13-compose-shell {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--r);
box-shadow: var(--shadow);
overflow: visible;
}
/* 发帖/编辑:占满 main 剩余高度,减少卡片下方空余(保留页脚) */
body:has([data-compose-viewport]) #main {
display: flex;
flex-direction: column;
min-height: 0;
/* 略收紧上下边距,把高度留给舞台 */
padding-top: 1.15rem;
padding-bottom: 1.15rem;
}
body:has([data-compose-viewport]) #main > [data-compose-viewport] {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
/* 聊天页:整页锁在视口内,避免「页面滚动 + 对话区滚动」双滚动条 */
body:has([data-chat-viewport]) {
height: 100dvh;
@@ -511,6 +1002,28 @@ a.j13-mention-token:hover {
border-radius: 999px;
white-space: nowrap;
}
.badge-type {
display: inline-flex;
align-items: center;
font-size: 12px;
font-weight: 700;
padding: 4px 11px;
background: color-mix(in srgb, var(--accent) 12%, var(--tint));
color: var(--accent);
border-radius: 999px;
white-space: nowrap;
}
.badge-access {
display: inline-flex;
align-items: center;
font-size: 12px;
font-weight: 700;
padding: 4px 11px;
background: var(--tint);
color: var(--ink-2);
border-radius: 999px;
white-space: nowrap;
}
/* 帖子列表中的板块小标签:全站统一浅灰配色,避免每行多色眩目 */
.j13-board-tag {

View File

@@ -5,6 +5,7 @@ import "./globals.css";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import Toaster from "@/components/Toaster";
import TipHost from "@/components/TipHost";
import AccountGuard from "@/components/AccountGuard";
import ThemeSync from "@/components/ThemeSync";
import RealtimeProvider from "@/components/RealtimeProvider";
@@ -112,6 +113,7 @@ export default async function RootLayout({
</main>
<Footer />
<Toaster />
<TipHost />
<AccountGuard />
<ThemeSync initialAccent={accent || ""} />
<RealtimeProvider userId={user ? user.id : null} />

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 />;
}

View File

@@ -0,0 +1,56 @@
"use client";
import PostComposer from "@/components/PostComposer";
import type { PostAttachment } from "@/lib/api";
type Props = {
postId: number;
initialTitle: string;
initialContent: string;
initialTags: string;
initialPostType?: string;
initialContentAccess?: string;
initialAccessPoints?: number;
initialTypeMeta?: string;
initialAttachments?: PostAttachment[];
canEditPollOptions?: boolean;
boardName: string;
boardId: number;
boardColorIndex?: number;
};
export default function EditPostClient({
postId,
initialTitle,
initialContent,
initialTags,
initialPostType,
initialContentAccess,
initialAccessPoints,
initialTypeMeta,
initialAttachments,
canEditPollOptions,
boardName,
boardId,
boardColorIndex = 0,
}: Props) {
return (
<PostComposer
mode="edit"
postId={postId}
initialTitle={initialTitle}
initialContent={initialContent}
initialTags={initialTags}
initialPostType={initialPostType}
initialContentAccess={initialContentAccess}
initialAccessPoints={initialAccessPoints}
initialTypeMeta={initialTypeMeta}
initialAttachments={initialAttachments}
canEditPollOptions={canEditPollOptions}
boardName={boardName}
boardId={boardId}
boardColorIndex={boardColorIndex}
cancelHref={`/post/${postId}`}
/>
);
}

View File

@@ -1,152 +1,74 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { cookies } from "next/headers";
import { notFound, redirect } from "next/navigation";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import { fetchPostDetail, apiMe, apiUpdatePost, type Post, type User } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { fetchPostDetail } from "@/lib/api";
import { canModerateBoard } from "@/lib/roles";
import EditPostClient from "./EditPostClient";
interface EditPageProps {
interface PageProps {
params: Promise<{ id: string }>;
}
export default function EditPostPage({ params }: EditPageProps) {
const router = useRouter();
const [post, setPost] = useState<Post | null>(null);
const [user, setUser] = useState<User | null>(null);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [tags, setTags] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [checking, setChecking] = useState(true);
const [id, setId] = useState("");
useEffect(() => {
params.then((p) => setId(p.id));
}, [params]);
useEffect(() => {
if (!id) return;
Promise.all([fetchPostDetail(id), apiMe()])
.then(([postData, meData]) => {
setPost(postData.post);
setTitle(postData.post.title);
setContent(postData.post.content);
setTags(postData.post.tags || "");
const u = meData.user;
setUser(u || null);
if (
!u ||
(u.id !== postData.post.user.id && !canModerateBoard(u, postData.post.board_id))
) {
router.push(`/post/${id}`);
}
})
.catch(() => router.push(`/post/${id}`))
.finally(() => setChecking(false));
}, [id, router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!post) return;
setLoading(true);
setError("");
try {
const res = await apiUpdatePost(String(post.id), { title, content, tags });
if (res.post) {
router.push(`/post/${post.id}`);
} else {
setError(res.error || "更新失败");
}
} finally {
setLoading(false);
}
};
if (checking) {
return (
<div className="max-w-3xl mx-auto text-center py-20">
<p className="meta">正在加载帖子…</p>
</div>
);
export default async function EditPostPage({ params }: PageProps) {
const { id } = await params;
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user) {
redirect(`/login?redirect=${encodeURIComponent(`/post/${id}/edit`)}`);
}
if (!post) return null;
let post: Awaited<ReturnType<typeof fetchPostDetail>>["post"];
try {
({ post } = await fetchPostDetail(id, cookie));
} catch {
notFound();
}
const allowed =
me.user.id === post.user.id ||
canModerateBoard(me.user, post.board_id);
if (!allowed) {
redirect(`/post/${id}`);
}
return (
<div className="max-w-3xl mx-auto">
<nav className="meta mb-5 flex items-center gap-1.5 flex-wrap text-[13px]">
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
<div
data-compose-viewport
className="j13-compose-page w-full max-w-6xl mx-auto"
>
<nav className="meta mb-4 flex items-center gap-1.5 flex-wrap text-[13px] shrink-0">
<Link href="/" className="hover:text-[var(--accent)] transition-colors">
首页
</Link>
<ChevronRight size={13} />
<Link href={`/post/${post.id}`} className="hover:text-[var(--accent)] transition-colors truncate max-w-[12rem]">
<Link
href={`/post/${post.id}`}
className="hover:text-[var(--accent)] transition-colors truncate max-w-[12rem]"
>
帖子详情
</Link>
<ChevronRight size={13} />
<span style={{ color: "var(--accent)" }}>编辑</span>
<span style={{ color: "var(--ink-2)" }}>编辑</span>
</nav>
<div className="mb-7">
<h1 className="text-[26px] sm:text-[30px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
编辑帖子
</h1>
</div>
<form onSubmit={handleSubmit} className="panel p-5 sm:p-7 space-y-5">
{error && <div className="alert-error">{error}</div>}
<div>
<label className="field-label">标题</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
maxLength={256}
className="field"
/>
</div>
<div>
<label className="field-label">标签(逗号分隔,可选)</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="Go, PostgreSQL, 架构"
className="field"
/>
</div>
<div>
<label className="field-label">正文(支持 Markdown)</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
required
rows={14}
className="field"
/>
</div>
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={loading || !title.trim() || !content.trim()}
className="btn btn-primary"
>
{loading ? "保存中..." : "保存修改"}
</button>
<button
type="button"
onClick={() => router.push(`/post/${post.id}`)}
className="btn btn-line"
>
取消
</button>
</div>
</form>
<EditPostClient
postId={post.id}
initialTitle={post.title}
initialContent={post.content}
initialTags={post.tags || ""}
initialPostType={post.post_type}
initialContentAccess={post.content_access}
initialAccessPoints={post.access_points}
initialTypeMeta={post.type_meta}
initialAttachments={post.attachments || []}
canEditPollOptions={post.poll?.can_edit_options !== false}
boardName={post.board?.name || "未知板块"}
boardId={post.board_id}
boardColorIndex={post.board?.color_index ?? 0}
/>
</div>
);
}

View File

@@ -2,7 +2,6 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { cookies } from "next/headers";
import ReactMarkdown from "react-markdown";
import {
Clock,
Heart,
@@ -33,8 +32,13 @@ import PostToc, { type TocItem } from "@/components/PostToc";
import MobilePostBar from "@/components/MobilePostBar";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
import MarkdownBody from "@/components/MarkdownBody";
import ContentLockGate from "@/components/ContentLockGate";
import PostAttachments from "@/components/PostAttachments";
import PostInteractPanels from "@/components/PostInteractPanels";
import { canModerateBoard } from "@/lib/roles";
import { IconComment, IconEye } from "@/components/Icons";
import { normalizePostType, postTypeLabel, contentAccessLabel, typeStatusLabel } from "@/lib/postMeta";
interface PageProps {
params: Promise<{ id: string }>;
@@ -47,12 +51,15 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
try {
// 带登录态:作者/管理团队看待审帖时标签标题也应是真实标题而非"帖子不存在"
const { post } = await fetchPostDetail(id, authCookieHeader(await cookies()));
const desc = post.content_locked
? post.access_hint || "部分内容需满足条件后可见"
: post.content.slice(0, 160);
return {
title: post.title,
description: post.content.slice(0, 160),
description: desc,
openGraph: {
title: post.title,
description: post.content.slice(0, 160),
description: desc,
type: "article",
},
};
@@ -194,7 +201,7 @@ function AuthorPosts({ posts }: { posts: PostListItem[] }) {
<li key={p.id}>
<Link
href={`/post/${p.id}`}
title={p.title}
data-tip={p.title}
className="block rounded-[10px] px-2 py-2 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
>
<span
@@ -236,7 +243,7 @@ function RelatedPosts({
<ol>
{posts.map((p, i) => (
<li key={p.id}>
<Link href={`/post/${p.id}`} className="j13-rank" title={p.title}>
<Link href={`/post/${p.id}`} className="j13-rank" data-tip={p.title}>
<span className="j13-rank-no">{i + 1}</span>
<span className="min-w-0 flex-1">
<span
@@ -333,20 +340,35 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
const related = (relatedData?.posts ?? []).filter((p) => p.id !== post.id).slice(0, 5);
const authorPosts = (profile?.posts ?? []).filter((p) => p.id !== post.id).slice(0, 5);
const toc = extractToc(post.content);
const readingMinutes = Math.max(1, Math.ceil(post.content.length / 400));
const toc = post.content_locked ? [] : extractToc(post.content);
const readingMinutes = Math.max(
1,
Math.ceil((post.content_locked ? post.title.length : post.content.length) / 400)
);
const edited =
new Date(post.updated_at).getTime() - new Date(post.created_at).getTime() > 60_000;
// 非公开帖(待审/未通过)仅作者本人与管理团队可看,顶部给出醒目状态
const isPublished = post.status === "published";
const canSeeStatus = !!me.user && (me.user.id === post.user.id || canModerateBoard(me.user, post.board_id));
const postType = normalizePostType(post.post_type);
const typeStatus = typeStatusLabel(postType, post.type_status);
const canManageTyped =
!!me.user && (me.user.id === post.user.id || canModerateBoard(me.user, post.board_id));
const canSeeStatus = canManageTyped;
const questionSolved = !!post.question?.solved;
const acceptMode =
postType === "question" && !questionSolved && canManageTyped
? ("question" as const)
: post.bounty?.can_accept
? ("bounty" as const)
: null;
const jsonLd = {
"@context": "https://schema.org",
"@type": "DiscussionForumPosting",
headline: post.title,
articleBody: post.content,
// 锁定正文不进 JSON-LD,避免 SEO 泄露付费/回复可见内容
articleBody: post.content_locked ? undefined : post.content,
datePublished: post.created_at,
dateModified: post.updated_at,
author: { "@type": "Person", name: post.user.nickname },
@@ -409,11 +431,35 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
<div className="panel p-6 sm:p-8">
<header className="mb-7">
<div className="flex items-center gap-2 mb-4 flex-wrap">
{post.pinned > 0 && <span className="badge-pin">置顶</span>}
{post.recommended && <span className="badge-rec">推荐</span>}
<Link href={`/board/${post.board.id}`} className="chip">
{post.board.name}
</Link>
</div>
<div className="flex items-start gap-2 flex-wrap mb-6">
{/* 仅置顶/推荐在标题左侧 */}
{post.pinned > 0 && <span className="badge-pin shrink-0 mt-2 sm:mt-3">置顶</span>}
{post.recommended && <span className="badge-rec shrink-0 mt-2 sm:mt-3">推荐</span>}
<h1
className="text-[26px] sm:text-[34px] font-extrabold leading-tight tracking-tight min-w-0"
style={{ color: "var(--ink)" }}
>
{post.title}
</h1>
{/* 其余徽章紧跟标题右侧;问答不显示类型徽章 */}
{postType !== "discussion" && postType !== "question" && (
<span className="badge-type shrink-0 mt-2 sm:mt-3">{postTypeLabel(postType)}</span>
)}
{typeStatus && (
<span className="badge-access shrink-0 mt-2 sm:mt-3">{typeStatus}</span>
)}
{post.content_access && post.content_access !== "public" && (
<span className="badge-access shrink-0 mt-2 sm:mt-3">
{contentAccessLabel(post.content_access)}
</span>
)}
{!isPublished && canSeeStatus && post.status === "pending" && (
<span
className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11.5px] font-bold"
className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11.5px] font-bold shrink-0 mt-2 sm:mt-3"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
>
等待审核
@@ -421,19 +467,13 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
)}
{!isPublished && canSeeStatus && post.status === "rejected" && (
<span
className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11.5px] font-bold"
className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11.5px] font-bold shrink-0 mt-2 sm:mt-3"
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
>
未通过审核
</span>
)}
<Link href={`/board/${post.board.id}`} className="chip">
{post.board.name}
</Link>
</div>
<h1 className="text-[26px] sm:text-[34px] font-extrabold leading-tight tracking-tight mb-6" style={{ color: "var(--ink)" }}>
{post.title}
</h1>
{/* 作者行:头像 + 昵称居左,点赞/更多操作居右 */}
<div
@@ -487,11 +527,43 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
</div>
)}
<div className="prose-content">
<ReactMarkdown components={{ h2: headingRenderer("h2"), h3: headingRenderer("h3") }}>
{post.content}
</ReactMarkdown>
</div>
<PostInteractPanels
postId={post.id}
question={
postType === "question"
? post.question ?? {
solved: false,
accepted_comment_id: 0,
can_accept: canManageTyped,
can_solve: canManageTyped,
can_reopen: false,
}
: post.question
}
poll={post.poll}
bounty={post.bounty}
lottery={post.lottery}
loggedIn={!!me.user}
/>
{post.content_locked ? (
<ContentLockGate
postId={post.id}
hint={post.access_hint}
access={post.content_access}
points={post.access_points}
loggedIn={!!me.user}
/>
) : (
<MarkdownBody
content={post.content}
components={{ h2: headingRenderer("h2"), h3: headingRenderer("h3") }}
/>
)}
{!post.content_locked && (
<PostAttachments postId={post.id} items={post.attachments || []} />
)}
{/* 文末点赞:分隔线后居中,唯一的点赞入口(作者行不再放置) */}
<div
@@ -519,12 +591,19 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
<CommentSection
postId={id}
boardId={post.board_id}
authorId={post.user.id}
comments={comments}
total={commentsData.total}
totalComments={commentsData.total_comments}
page={commentsData.page}
size={commentsData.size}
user={me.user}
acceptMode={acceptMode}
acceptedCommentId={
post.question?.accepted_comment_id ||
post.bounty?.accepted_comment_id ||
0
}
/>
) : (
<section className="panel p-6 sm:p-8 mt-6 text-center" aria-label="评论区">

View File

@@ -88,7 +88,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
const u = data.user;
const joinDate = new Date(u.created_at).toLocaleDateString("zh-CN");
const showPoints = data.stats.points > 0;
const showPoints = data.stats.points > 0 || isOwner;
// 导航项(桌面左栏 / 移动 chips 共用定义)
const navItems: { key: UserTab; label: string; count?: number; icon: React.ReactNode; ownerOnly?: boolean }[] = [
@@ -359,7 +359,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
<section className="j13-module">
<div className="flex items-center justify-between">
<p className="j13-module-title">
<Flame size={15} style={{ color: "var(--clay)" }} /> 签到
<Flame size={15} style={{ color: "var(--clay)" }} /> 积分
</p>
{data.stats.streak > 0 && (
<span
@@ -376,7 +376,16 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
>
{data.stats.points}
</p>
<p className="meta text-[11px] mt-1.5">累计积分</p>
<p className="meta text-[11px] mt-1.5">可用余额</p>
{isOwner && (
<Link
href="/points"
className="inline-flex mt-3 text-[12.5px] font-semibold hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink-2)" }}
>
查看流水 →
</Link>
)}
</section>
)}