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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
18
frontend/app/compose/layout.tsx
Normal file
18
frontend/app/compose/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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="# 开头写结论 支持 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
130
frontend/app/points/PointsClient.tsx
Normal file
130
frontend/app/points/PointsClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
frontend/app/points/page.tsx
Normal file
20
frontend/app/points/page.tsx
Normal 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 />;
|
||||
}
|
||||
56
frontend/app/post/[id]/edit/EditPostClient.tsx
Normal file
56
frontend/app/post/[id]/edit/EditPostClient.tsx
Normal 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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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="评论区">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user