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

@@ -15,7 +15,7 @@ function formatTime(dateStr: string) {
/**
* 管理员置顶区:独立于普通帖子列表。
* 陶土色语义(与 badge-pin 一致),紧凑排列,一眼识别为公告性质内容。
* 焦橙语义(与 badge-pin 一致),紧凑排列,一眼识别为公告性质内容。
*/
export default async function PinnedPosts({
posts,
@@ -32,11 +32,11 @@ export default async function PinnedPosts({
<div className="flex items-center gap-2 mb-2.5 px-1">
<span
className="w-6 h-6 rounded-full flex items-center justify-center"
style={{ background: "var(--clay-soft)", color: "var(--clay)" }}
style={{ background: "var(--pin-soft)", color: "var(--pin)" }}
>
<Pin size={12} fill="currentColor" strokeWidth={2.4} />
<Pin size={12} fill="none" strokeWidth={2} />
</span>
<h2 className="text-[13.5px] font-extrabold tracking-wide" style={{ color: "var(--clay)" }}>
<h2 className="text-[13.5px] font-extrabold tracking-wide" style={{ color: "var(--pin)" }}>
置顶讨论
</h2>
<span className="meta text-xs">管理员置顶 · {posts.length}</span>
@@ -78,7 +78,7 @@ export default async function PinnedPosts({
title="推荐"
aria-label="推荐"
>
<Star size={10} fill="currentColor" strokeWidth={2.2} aria-hidden />
<Star size={10} fill="none" strokeWidth={2} aria-hidden />
</span>
)}
</div>
@@ -94,9 +94,9 @@ export default async function PinnedPosts({
<Pin
size={13}
className="shrink-0 hidden sm:block"
style={{ color: "var(--clay)" }}
fill="currentColor"
strokeWidth={2.2}
style={{ color: "var(--pin)" }}
fill="none"
strokeWidth={2}
/>
</Link>
))}

View File

@@ -46,7 +46,7 @@ export default function PostActions({
const [user, setUser] = useState<User | null>(null);
const [open, setOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [isPinned, setIsPinned] = useState(pinned > 0);
const [pinLevel, setPinLevel] = useState(pinned);
const [isRecommended, setIsRecommended] = useState(recommended);
const [isLocked, setIsLocked] = useState(locked);
const [acting, setActing] = useState(false);
@@ -89,6 +89,13 @@ export default function PostActions({
const showEdit = canManage && !(isLocked && !staffUser) && !editTimeLocked;
const canApprove =
!!user && status === "pending" && canModerateBoard(user, boardId);
// 置顶分级:管理员及以上可设全局置顶(2)与板块置顶(1);板块管理员只在其授权板块做板块置顶。
// 已是全站置顶的帖子,板块管理员不可操作,故不展示入口。
const canGlobalPin = isAdmin;
const canBoardPin = !!user && canModerateBoard(user, boardId);
const showGlobalPin = canGlobalPin;
const showBoardPin = canBoardPin && (isAdmin || pinLevel < 2);
const showPin = showGlobalPin || showBoardPin;
// staff 删他人帖:走类型+理由;作者自删:简单确认
const staffDeletingOther =
!!user && user.id !== authorId && canStaffSoftDelete(user, boardId, authorRole);
@@ -126,16 +133,24 @@ export default function PostActions({
}
};
const handlePin = async () => {
const handlePin = async (level: 1 | 2) => {
setActing(true);
setOpen(false);
try {
const res = await apiTogglePin(postId);
setIsPinned(res.pinned > 0);
toast(res.pinned > 0 ? "已置顶" : "已取消置顶", "ok");
// 再次点击当前级别 = 取消置顶
const next = pinLevel === level ? 0 : level;
const res = await apiTogglePin(postId, next as 0 | 1 | 2);
setPinLevel(res.pinned);
const okMsg =
res.pinned <= 0
? "已取消置顶"
: res.pinned === 2
? "已全局置顶"
: "已在本板块置顶";
toast(okMsg, "ok");
router.refresh();
} catch {
toast("操作失败,请稍后重试");
} catch (e) {
toast(e instanceof Error ? e.message : "操作失败,请稍后重试");
} finally {
setActing(false);
}
@@ -220,25 +235,44 @@ export default function PostActions({
<div style={{ height: 1, background: "var(--line)" }} />
</>
)}
{showPin && (
<>
{showGlobalPin && (
<button
type="button"
role="menuitem"
onClick={() => void handlePin(2)}
className={itemCls}
style={pinLevel === 2 ? { color: "var(--pin)" } : { color: "var(--ink)" }}
>
<Pin size={14} className="shrink-0" />
<span className="flex-1 text-left">全局置顶</span>
{pinLevel === 2 && <Check size={14} className="shrink-0" />}
</button>
)}
{showBoardPin && (
<button
type="button"
role="menuitem"
onClick={() => void handlePin(1)}
className={itemCls}
style={pinLevel === 1 ? { color: "var(--pin)" } : { color: "var(--ink)" }}
>
<Pin size={14} className="shrink-0" />
<span className="flex-1 text-left">板块置顶</span>
{pinLevel === 1 && <Check size={14} className="shrink-0" />}
</button>
)}
</>
)}
{isAdmin && (
<>
<button
type="button"
role="menuitem"
onClick={handlePin}
className={itemCls}
style={isPinned ? { color: "var(--accent)" } : { color: "var(--ink)" }}
>
<Pin size={14} className="shrink-0" />
<span className="flex-1 text-left">{isPinned ? "取消置顶" : "置顶"}</span>
{isPinned && <Check size={14} className="shrink-0" />}
</button>
<button
type="button"
role="menuitem"
onClick={handleRecommend}
className={itemCls}
style={isRecommended ? { color: "var(--gold)" } : { color: "var(--ink)" }}
style={isRecommended ? { color: "var(--rec)" } : { color: "var(--ink)" }}
>
<Star size={14} className="shrink-0" />
<span className="flex-1 text-left">{isRecommended ? "取消推荐" : "推荐"}</span>

View File

@@ -73,13 +73,18 @@ export default async function PostRow({
<div className="flex items-center gap-2 min-w-0">
{/* 仅置顶/推荐在标题左侧 */}
{post.pinned > 0 && (
<span className="badge-pin shrink-0" title="置顶" aria-label="置顶">
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
<span
className="badge-pin shrink-0"
data-scope={post.pinned >= 2 ? "global" : "board"}
title={post.pinned >= 2 ? "置顶" : "板块置顶"}
aria-label={post.pinned >= 2 ? "置顶" : "板块置顶"}
>
<Pin size={12} fill="none" strokeWidth={2} aria-hidden />
</span>
)}
{post.recommended && (
<span className="badge-rec shrink-0" title="推荐" aria-label="推荐">
<Star size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
<Star size={12} fill="none" strokeWidth={2} aria-hidden />
</span>
)}
<div className="min-w-0 flex items-center gap-2 flex-1 overflow-hidden">

View File

@@ -149,7 +149,6 @@ export default function RoleAccountModal({
const messagesChanged =
canGrantSuper &&
target.role !== ROLES.OWNER &&
target.role !== ROLES.SUPER_ADMIN &&
manageMessages !== !!target.can_manage_messages;
/** 渠道权限可编辑性跟随弹窗中选中的角色:管理员及以上自带全部渠道 */
const channelEditable = roleLevel(role) < ROLE_LEVEL[ROLES.ADMIN];
@@ -462,48 +461,46 @@ export default function RoleAccountModal({
</div>
)}
{canGrantSuper &&
target.role !== ROLES.OWNER &&
target.role !== ROLES.SUPER_ADMIN && (
<div className="mt-5 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
<p className="text-[12px] font-medium tracking-wide mb-2.5" style={{ color: "var(--ink-3)" }}>
消息管理
</p>
<button
type="button"
role="switch"
aria-checked={manageMessages}
onClick={() => setManageMessages((v) => !v)}
className="w-full rounded-xl px-3.5 py-3 text-left transition-colors flex items-center justify-between gap-3"
style={{
border: `1.5px solid ${manageMessages ? "var(--accent)" : "var(--line)"}`,
background: manageMessages ? "var(--accent-soft)" : "var(--panel-2)",
}}
>
<span>
<span
className="block text-[13.5px] font-medium"
style={{ color: manageMessages ? "var(--accent)" : "var(--ink)" }}
>
站点群消息管理
</span>
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
可在后台管理全部群聊消息(不含私聊);站长/超管固有全站监管无需此项。
</span>
</span>
{canGrantSuper && target.role !== ROLES.OWNER && (
<div className="mt-5 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
<p className="text-[12px] font-medium tracking-wide mb-2.5" style={{ color: "var(--ink-3)" }}>
消息管理
</p>
<button
type="button"
role="switch"
aria-checked={manageMessages}
onClick={() => setManageMessages((v) => !v)}
className="w-full rounded-xl px-3.5 py-3 text-left transition-colors flex items-center justify-between gap-3"
style={{
border: `1.5px solid ${manageMessages ? "var(--accent)" : "var(--line)"}`,
background: manageMessages ? "var(--accent-soft)" : "var(--panel-2)",
}}
>
<span>
<span
className="shrink-0 w-10 h-6 rounded-full relative transition-colors"
style={{ background: manageMessages ? "var(--accent)" : "var(--line)" }}
aria-hidden
className="block text-[13.5px] font-medium"
style={{ color: manageMessages ? "var(--accent)" : "var(--ink)" }}
>
<span
className="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-transform"
style={{ left: manageMessages ? "1.125rem" : "0.125rem" }}
/>
站点群消息管理
</span>
</button>
</div>
)}
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
可在后台管理全部群聊消息(不含私聊);超管授予后另获全站会话(含私聊)监管;站长固有无需此项。
</span>
</span>
<span
className="shrink-0 w-10 h-6 rounded-full relative transition-colors"
style={{ background: manageMessages ? "var(--accent)" : "var(--line)" }}
aria-hidden
>
<span
className="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-transform"
style={{ left: manageMessages ? "1.125rem" : "0.125rem" }}
/>
</span>
</button>
</div>
)}
<div className="mt-5 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
<p className="text-[12px] font-medium tracking-wide mb-2.5" style={{ color: "var(--ink-3)" }}>

View File

@@ -22,7 +22,6 @@ import { snippetPath, type UrlStyle } from "@/lib/urlStyle";
import { formatRelative } from "@/lib/format";
import Avatar from "@/components/Avatar";
import { toast } from "@/lib/toast";
import { hasAuthCookieHint } from "@/lib/cookies";
import { siteCodeToHtml } from "@/components/markdown/shikiHighlighter";
import SnippetLangSelect from "@/components/SnippetLangSelect";
import SnippetHero from "@/components/SnippetHero";
@@ -278,36 +277,20 @@ const SORT_OPTIONS: { value: SnippetSort; label: string }[] = [
{ value: "fork", label: "Fork" },
];
function CardSkeleton() {
return (
<div
className="rounded-xl overflow-hidden animate-pulse"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
>
<div className="px-3.5 pt-3.5 pb-2.5">
<div className="h-2.5 w-16 rounded" style={{ background: "var(--panel-2)" }} />
<div className="mt-2.5 h-3.5 w-3/4 rounded" style={{ background: "var(--panel-2)" }} />
</div>
<div className="mx-3.5 rounded-lg h-[128px]" style={{ background: "var(--panel-2)" }} />
<div className="px-3.5 py-3 mt-1">
<div className="h-2.5 w-2/3 rounded" style={{ background: "var(--panel-2)" }} />
</div>
</div>
);
}
export default function SnippetExplorer({
initialItems,
initialTotal,
languages,
urlStyle,
stats,
isLoggedIn,
}: {
initialItems: SnippetListItem[];
initialTotal: number;
languages: string[];
urlStyle: UrlStyle;
stats: SnippetStats | null;
isLoggedIn: boolean;
}) {
const [items, setItems] = useState(initialItems);
const [total, setTotal] = useState(initialTotal);
@@ -317,7 +300,6 @@ export default function SnippetExplorer({
const [lang, setLang] = useState("");
const [sort, setSort] = useState<SnippetSort>("latest");
const [scope, setScope] = useState<"public" | "mine">("public");
const isLoggedIn = typeof window !== "undefined" ? hasAuthCookieHint() : false;
const pageSize = 12;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
@@ -360,7 +342,7 @@ export default function SnippetExplorer({
return (
<div>
{/* 未来科技感 Hero:网格 + 扫描线 + 统计矩阵 */}
<SnippetHero stats={stats} />
<SnippetHero stats={stats} isLoggedIn={isLoggedIn} />
{/* 工具台:IDE 风格软底工具条,控件浮于其上(无框中框) */}
<div
@@ -489,13 +471,7 @@ export default function SnippetExplorer({
</div>
{/* 列表 / 空态 */}
{loading && items.length === 0 ? (
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
) : items.length === 0 && !loading ? (
{items.length === 0 && loading ? null : items.length === 0 && !loading ? (
filtering ? (
<div
className="mt-4 rounded-xl px-6 py-12 text-center"

View File

@@ -8,7 +8,6 @@ import {
} from "lucide-react";
import type { SnippetStats } from "@/lib/api";
import { snippetLangColor } from "@/lib/snippetLangColors";
import { hasAuthCookieHint } from "@/lib/cookies";
const MONO = "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)";
@@ -151,8 +150,13 @@ function LangTicker({ langs }: { langs: string[] }) {
);
}
export default function SnippetHero({ stats }: { stats: SnippetStats | null }) {
const isLoggedIn = typeof window !== "undefined" ? hasAuthCookieHint() : false;
export default function SnippetHero({
stats,
isLoggedIn,
}: {
stats: SnippetStats | null;
isLoggedIn: boolean;
}) {
const s = stats;
const total = useRollIn(s?.total_snippets ?? 0);
const copies = useRollIn(s?.total_copies ?? 0);

View File

@@ -32,6 +32,7 @@ import {
Images,
Link2,
CodeXml,
Pin,
} from "lucide-react";
import type { User } from "@/lib/api";
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
@@ -95,6 +96,12 @@ const NAV_GROUPS: NavGroup[] = [
visible: (u) => canModerateAny(u),
pendingBadge: true,
},
{
href: "/admin/pinned",
label: "置顶管理",
icon: Pin,
visible: (u) => canModerateAny(u),
},
{
href: "/admin/messages",
label: "消息管理",