feat: 帖子分级置顶(板块/全站)与板块管理员置顶权限、消息管理权限调整

This commit is contained in:
2026-10-15 23:35:30 +08:00
parent e7b0686947
commit 47f87a011c
40 changed files with 822 additions and 207 deletions

View File

@@ -250,7 +250,7 @@ function useMessageColumn(roomType: MsgKind) {
/** 群聊(含大厅)| 私聊 双栏监管 */
export default function MessagesAdmin({ me }: { me: User }) {
const canOversee = canOverseeChat(me.role);
const canOversee = canOverseeChat(me);
const [mobileKind, setMobileKind] = useState<MsgKind>("group");
const group = useMessageColumn("group");
const direct = useMessageColumn("direct");

View File

@@ -15,7 +15,7 @@ export default async function AdminMessagesPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !canAccessAdminMessages(me.user)) {
return <Forbidden text="消息管理需站长授予权限,或被任命为群管理员。" />;
return <Forbidden text="消息管理需站长授予权限(超管亦需授权),或被任命为群管理员。" />;
}
return <MessagesAdmin me={me.user} />;

View File

@@ -0,0 +1,224 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { ChevronDown, ChevronUp, Pin } from "lucide-react";
import {
apiAdminPinnedPosts,
apiAdminSavePinOrder,
type AdminPinnedPost,
} from "@/lib/api";
import AdminPageHeader from "@/components/admin/AdminPageHeader";
import AdminEmpty from "@/components/admin/AdminEmpty";
import { toast } from "@/lib/toast";
interface BoardOpt {
id: number;
name: string;
}
interface PinnedAdminProps {
boards: BoardOpt[];
/** 管理员及以上:可排全站置顶 */
canGlobal: boolean;
}
type Level = 1 | 2;
export default function PinnedAdmin({ boards, canGlobal }: PinnedAdminProps) {
const [level, setLevel] = useState<Level>(canGlobal ? 2 : 1);
const [boardId, setBoardId] = useState<number>(boards[0]?.id ?? 0);
const [posts, setPosts] = useState<AdminPinnedPost[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const load = useCallback(async (lv: Level, bid: number) => {
setLoading(true);
setError("");
try {
const r = await apiAdminPinnedPosts(lv, lv === 1 ? bid : undefined);
setPosts(r.posts ?? []);
} catch (e) {
setPosts([]);
setError(e instanceof Error ? e.message : "获取置顶列表失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load(level, boardId);
}, [level, boardId, load]);
const switchLevel = (lv: Level) => {
if (lv === level) return;
setLevel(lv);
setPosts([]);
};
// 上移/下移:乐观交换后整体保存顺序,失败回滚
const move = async (idx: number, dir: -1 | 1) => {
const target = idx + dir;
if (target < 0 || target >= posts.length || busy) return;
const prev = posts;
const next = [...posts];
const tmp = next[idx];
next[idx] = next[target];
next[target] = tmp;
setPosts(next);
setBusy(true);
setError("");
try {
await apiAdminSavePinOrder(level, level === 1 ? boardId : 0, next.map((p) => p.id));
} catch (e) {
setPosts(prev);
setError(e instanceof Error ? e.message : "保存顺序失败");
} finally {
setBusy(false);
}
};
const tabCls = (active: boolean) =>
`px-3.5 py-2 text-[13px] font-semibold rounded-[var(--r-sm)] transition-colors ${
active ? "" : "hover:bg-[var(--panel-2)]"
}`;
return (
<div>
<AdminPageHeader
icon={Pin}
title="置顶管理"
description="调整置顶帖在列表中的显示顺序,越靠前越先展示。板块管理员仅可排序授权板块的板块置顶。"
/>
{error && (
<div
className="mb-3 px-3 py-2 rounded-[var(--r-sm)] text-[12.5px]"
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
>
{error}
</div>
)}
{/* 层级切换 + 板块选择 */}
<div className="mb-4 flex items-center gap-3 flex-wrap">
<div
className="inline-flex items-center gap-1 p-1 rounded-[var(--r-sm)]"
style={{ background: "var(--panel-2)" }}
role="tablist"
>
{canGlobal && (
<button
type="button"
role="tab"
aria-selected={level === 2}
className={tabCls(level === 2)}
style={
level === 2
? { background: "var(--panel)", color: "var(--ink)", boxShadow: "var(--shadow-sm)" }
: { color: "var(--ink-3)" }
}
onClick={() => switchLevel(2)}
>
全站置顶
</button>
)}
<button
type="button"
role="tab"
aria-selected={level === 1}
className={tabCls(level === 1)}
style={
level === 1
? { background: "var(--panel)", color: "var(--ink)", boxShadow: "var(--shadow-sm)" }
: { color: "var(--ink-3)" }
}
onClick={() => switchLevel(1)}
>
板块置顶
</button>
</div>
{level === 1 && (
<select
className="input text-[13px] py-2"
style={{ minWidth: 160 }}
value={boardId}
onChange={(e) => setBoardId(Number(e.target.value))}
aria-label="选择板块"
>
{boards.length === 0 && <option value={0}>无可用板块</option>}
{boards.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
)}
</div>
<div className="panel overflow-hidden">
{loading ? (
<p className="meta text-center py-14 text-[13px]">加载中…</p>
) : posts.length === 0 ? (
<AdminEmpty
icon={<Pin size={20} />}
title={level === 2 ? "暂无全站置顶帖" : "该板块暂无置顶帖"}
description="在帖子页的「管理」菜单中可将帖子设为置顶。"
/>
) : (
<ul>
{posts.map((p, i) => (
<li
key={p.id}
className="flex items-center gap-3 px-4 py-3"
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
>
<span
className="w-6 text-center text-[12.5px] font-bold tabular-nums shrink-0"
style={{ color: "var(--ink-3)" }}
>
{i + 1}
</span>
<div className="flex flex-col gap-0.5 shrink-0">
<button
type="button"
className="admin-icon-btn"
onClick={() => void move(i, -1)}
disabled={i === 0 || busy}
aria-label="上移"
>
<ChevronUp size={14} />
</button>
<button
type="button"
className="admin-icon-btn"
onClick={() => void move(i, 1)}
disabled={i === posts.length - 1 || busy}
aria-label="下移"
>
<ChevronDown size={14} />
</button>
</div>
<div className="min-w-0 flex-1">
<a
href={`/post/${p.id}`}
target="_blank"
rel="noreferrer"
className="text-[13.5px] font-semibold truncate block hover:underline"
style={{ color: "var(--ink)" }}
>
{p.title}
</a>
<p className="meta text-[12px] truncate">
{p.board_name} · {new Date(p.created_at).toLocaleDateString("zh-CN")}
</p>
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { fetchBoards, type BoardWithCount } from "@/lib/api";
import { getMeCached } from "@/lib/serverData";
import { canModerateAny, isAdminOrAbove } from "@/lib/roles";
import Forbidden from "../Forbidden";
import PinnedAdmin from "./PinnedAdmin";
export const metadata: Metadata = {
title: "置顶管理",
};
export default async function AdminPinnedPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
// 管理员及以上排全站置顶;板块管理员排自己授权板块的板块置顶
if (!me.user || !canModerateAny(me.user)) {
return <Forbidden text="你所在的角色没有置顶管理权限,或尚未被授权任何板块。" />;
}
// 板块管理员只能看到自己被授权的板块
let boards: BoardWithCount[] = [];
try {
const r = await fetchBoards();
boards = r.boards ?? [];
} catch {
boards = [];
}
if (!isAdminOrAbove(me.user.role)) {
const ids = new Set(me.user.board_ids ?? []);
boards = boards.filter((b) => ids.has(b.id));
}
return (
<PinnedAdmin
boards={boards.map((b) => ({ id: b.id, name: b.name }))}
canGlobal={isAdminOrAbove(me.user.role)}
/>
);
}

View File

@@ -118,7 +118,7 @@ export default async function AnnouncementPage({ params }: PageProps) {
<>
{a.pinned ? (
<span className="badge-pin" title="置顶" aria-label="置顶">
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
<Pin size={12} fill="none" strokeWidth={2} aria-hidden />
</span>
) : null}
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>

View File

@@ -59,7 +59,7 @@ function FeaturedAnnouncement({ a, urlStyle }: { a: Announcement; urlStyle: UrlS
</span>
{a.pinned ? (
<span className="badge-pin" title="置顶" aria-label="置顶">
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
<Pin size={12} fill="none" strokeWidth={2} aria-hidden />
</span>
) : null}
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
@@ -93,7 +93,7 @@ function AnnouncementRow({ a, showDivider, urlStyle }: { a: Announcement; showDi
<div className="flex items-center gap-2 flex-wrap">
{a.pinned ? (
<span className="badge-pin" title="置顶" aria-label="置顶">
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
<Pin size={12} fill="none" strokeWidth={2} aria-hidden />
</span>
) : null}
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>

View File

@@ -688,7 +688,7 @@ export default function ChatRoomClient({
const isDirect = roomType === "direct";
const isRealMember = myRole === "owner" || myRole === "admin" || myRole === "member";
const canView = joined;
const oversee = canOverseeChat(meRole);
const oversee = canOverseeChat({ role: meRole, can_manage_messages: canManageMessages });
const siteOwner = isSiteOwner(meRole);
const canModerateChat =
oversee || isOwner || isRoomAdmin || (!!canManageMessages && !isDirect);

View File

@@ -30,6 +30,14 @@
--gold: #825700;
--gold-soft: rgba(130, 87, 0, 0.13);
/* 置顶 / 推荐徽章专用色:与 --clay/--gold(还被文档类型、图表等复用)解耦,单独可调 */
--pin: #c2410c; /* 置顶:焦橙,明度高、不浑浊 */
--pin-soft: rgba(194, 65, 12, 0.1);
--pin-line: rgba(194, 65, 12, 0.26);
--rec: #b7791f; /* 推荐:琥珀金,偏 saffron 而非暗橄榄 */
--rec-soft: rgba(183, 121, 31, 0.13);
--rec-line: rgba(183, 121, 31, 0.3);
--danger: #c8281e;
--danger-soft: rgba(200, 40, 30, 0.09);
--ok: #137333;
@@ -154,6 +162,13 @@
--gold: #e0b259;
--gold-soft: rgba(224, 178, 89, 0.16);
--pin: #ff9a6b;
--pin-soft: rgba(255, 154, 107, 0.14);
--pin-line: rgba(255, 154, 107, 0.3);
--rec: #e8bd63;
--rec-soft: rgba(232, 189, 99, 0.14);
--rec-line: rgba(232, 189, 99, 0.3);
--danger: #ff6b60;
--danger-soft: rgba(255, 107, 96, 0.15);
--ok: #58d18a;
@@ -6592,23 +6607,33 @@ a.j13-mention-token:hover {
background: var(--accent-soft);
color: var(--accent);
}
/* 置顶 / 推荐:图标徽章(无文字,配 title/aria-label),全站统一,节省标题空间 */
/* 置顶 / 推荐:图标徽章(无文字,配 title/aria-label),全站统一,节省标题空间。
形态:圆角方块 + 极淡底 + 同色发丝边 + 线性图标,避免实心图标在小尺寸下发糊。 */
.badge-pin,
.badge-rec {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 20px;
height: 20px;
border-radius: 6px;
border-radius: 7px;
border: 1px solid;
line-height: 0;
}
.badge-pin {
background: var(--clay-soft);
color: var(--clay);
color: var(--pin);
background: var(--pin-soft);
border-color: var(--pin-line);
}
/* 板块置顶:去掉底色只留描边,弱一档,与全站置顶拉开层级(不换色相,避免多一套语义色) */
.badge-pin[data-scope="board"] {
background: transparent;
}
.badge-rec {
background: var(--gold-soft);
color: var(--gold);
color: var(--rec);
background: var(--rec-soft);
border-color: var(--rec-line);
}
.badge-pending {
display: inline-flex;

View File

@@ -491,13 +491,18 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
使 h1 在徽章旁的剩余空间内折行,徽章始终与标题首行同行(移动端) */}
<div className="flex items-start gap-2 min-w-0">
{post.pinned > 0 && (
<span className="badge-pin shrink-0 mt-2 sm:mt-3" title="置顶" aria-label="置顶">
<Pin size={13} fill="currentColor" strokeWidth={2.2} aria-hidden />
<span
className="badge-pin shrink-0 mt-2 sm:mt-3"
data-scope={post.pinned >= 2 ? "global" : "board"}
title={post.pinned >= 2 ? "置顶" : "板块置顶"}
aria-label={post.pinned >= 2 ? "置顶" : "板块置顶"}
>
<Pin size={13} fill="none" strokeWidth={2} aria-hidden />
</span>
)}
{post.recommended && (
<span className="badge-rec shrink-0 mt-2 sm:mt-3" title="推荐" aria-label="推荐">
<Star size={13} fill="currentColor" strokeWidth={2.2} aria-hidden />
<Star size={13} fill="none" strokeWidth={2} aria-hidden />
</span>
)}
<h1

View File

@@ -1,6 +1,8 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { fetchSnippets, fetchSnippetLanguages, fetchSnippetStats, type SnippetStats } from "@/lib/api";
import { getPublicSettingsCached } from "@/lib/serverData";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { authCookieHeader } from "@/lib/cookies";
import { requestCanonical } from "@/lib/canonicalMeta";
import { snippetsPath } from "@/lib/urlStyle";
import SnippetExplorer from "@/components/SnippetExplorer";
@@ -17,6 +19,11 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function SnippetsPage() {
const settings = await getPublicSettingsCached();
// 登录态由服务端判定后下发:客户端组件内再读 cookie 会造成 hydration 不一致。
// getMeCached 与 layout 同参调用,命中 React cache 不额外请求后端。
const cookie = authCookieHeader(await cookies());
const isLoggedIn = !!(await getMeCached(cookie || undefined)).user;
let initialItems: Awaited<ReturnType<typeof fetchSnippets>>["items"] = [];
let initialTotal = 0;
let languages: string[] = [];
@@ -43,6 +50,7 @@ export default async function SnippetsPage() {
languages={languages}
urlStyle={settings.url_style}
stats={stats}
isLoggedIn={isLoggedIn}
/>
</div>
);

View File

@@ -30,13 +30,14 @@ const SORTS: { key: string; label: string }[] = [
/** 帖子卡(发帖列表与收藏列表共用) */
async function PostCardItem({ post, allowComments }: { post: PostListItem; allowComments?: boolean }) {
const urlStyle = (await getPublicSettingsCached()).url_style;
const pinned = post.pinned > 0;
// 用户主页为全站语境:仅全站置顶(2)高亮,板块置顶(1)在主页不作置顶展示
const pinned = post.pinned >= 2;
return (
<Link
href={postPath(urlStyle, post.id)}
className={`${ITEM_BASE} ${
pinned
? "bg-[color-mix(in_srgb,#fde68a_22%,var(--panel))] border-[color-mix(in_srgb,#fde68a_70%,var(--line))] hover:bg-[color-mix(in_srgb,#fde68a_30%,var(--panel))]"
? "bg-[color-mix(in_srgb,var(--pin)_9%,var(--panel))] border-[color-mix(in_srgb,var(--pin)_30%,var(--line))] hover:bg-[color-mix(in_srgb,var(--pin)_14%,var(--panel))]"
: "bg-[var(--panel)] border-[var(--line)] hover:bg-[color-mix(in_srgb,var(--ink)_3.5%,var(--panel))]"
}`}
style={{ boxShadow: CARD_SHADOW }}
@@ -45,10 +46,11 @@ async function PostCardItem({ post, allowComments }: { post: PostListItem; allow
{pinned && (
<span
className="badge-pin shrink-0 mt-0.5"
data-scope="global"
title="置顶"
aria-label="置顶"
>
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
<Pin size={12} fill="none" strokeWidth={2} aria-hidden />
</span>
)}
<h2
@@ -59,7 +61,7 @@ async function PostCardItem({ post, allowComments }: { post: PostListItem; allow
</h2>
{post.recommended && (
<span className="badge-rec shrink-0 mt-0.5" title="推荐" aria-label="推荐">
<Star size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
<Star size={12} fill="none" strokeWidth={2} aria-hidden />
</span>
)}
</div>