feat: 帖子分级置顶(板块/全站)与板块管理员置顶权限、消息管理权限调整
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
224
frontend/app/admin/pinned/PinnedAdmin.tsx
Normal file
224
frontend/app/admin/pinned/PinnedAdmin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
frontend/app/admin/pinned/page.tsx
Normal file
42
frontend/app/admin/pinned/page.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)" }}>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: "消息管理",
|
||||
|
||||
@@ -81,7 +81,7 @@ export interface User {
|
||||
role: string;
|
||||
// 板块管理员被授权的板块(仅 /api/me 返回;前端据此渲染审核入口/按钮)
|
||||
board_ids?: number[];
|
||||
// 站点消息管理(站长授予;站长/超管固有监管不必依赖此字段)
|
||||
// 站点消息管理(仅站长可授;超管亦需授予,站长固有监管不必依赖此字段)
|
||||
can_manage_messages?: boolean;
|
||||
// 生效权限码(角色默认 ∪ 账号级渠道授予;仅 /api/me 返回,用于后台入口显隐)
|
||||
perms?: string[];
|
||||
@@ -2261,8 +2261,8 @@ export async function apiDeletePost(
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiTogglePin(postId: string): Promise<{ pinned: number }> {
|
||||
const res = await fetchWithRefresh(`/api/posts/${postId}/pin`, {
|
||||
export async function apiTogglePin(postId: string, level: 0 | 1 | 2): Promise<{ pinned: number }> {
|
||||
const res = await fetchWithRefresh(`/api/posts/${postId}/pin?level=${level}`, {
|
||||
method: "PUT",
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
@@ -4059,6 +4059,38 @@ export const apiAdminRestoreComment = (id: number) =>
|
||||
export const apiAdminPurgeComment = (id: number) =>
|
||||
contentAction(`/api/admin/content/comments/${id}/purge`, "DELETE");
|
||||
|
||||
// ===== 置顶顺序配置 =====
|
||||
export interface AdminPinnedPost {
|
||||
id: number;
|
||||
title: string;
|
||||
pinned: number;
|
||||
pin_order: number;
|
||||
board_id: number;
|
||||
board_name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function apiAdminPinnedPosts(
|
||||
level: 1 | 2,
|
||||
boardId?: number
|
||||
): Promise<{ posts: AdminPinnedPost[] }> {
|
||||
const params = new URLSearchParams({ level: String(level) });
|
||||
if (boardId) params.set("board_id", String(boardId));
|
||||
const res = await fetchWithRefresh(`/api/admin/pinned-posts?${params}`, {
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取置顶列表失败");
|
||||
return data;
|
||||
}
|
||||
|
||||
export const apiAdminSavePinOrder = (level: 1 | 2, boardId: number, ids: number[]) =>
|
||||
contentAction(`/api/admin/pinned-posts/order`, "PUT", {
|
||||
level,
|
||||
board_id: boardId,
|
||||
ids,
|
||||
});
|
||||
|
||||
export interface CommentEditHistoryItem {
|
||||
id: number;
|
||||
editor: {
|
||||
|
||||
@@ -76,9 +76,11 @@ export const PERM_META: Record<
|
||||
[PERMS.ADS]: { label: "广告与赞助", desc: "管理广告位与赞助配置、审批投放" },
|
||||
};
|
||||
|
||||
/** 站长/超管:群聊监管旁路(可见全部群含私聊、撤回任意消息) */
|
||||
export function canOverseeChat(role?: string | null): boolean {
|
||||
return role === ROLES.OWNER || role === ROLES.SUPER_ADMIN;
|
||||
/** 全站消息监管(可见全部群含私聊、撤回任意消息):站长固有;超管需先被授予「消息管理」 */
|
||||
export function canOverseeChat(user?: RoleUserLike | null): boolean {
|
||||
if (!user) return false;
|
||||
if (isOwner(user.role)) return true;
|
||||
return user.role === ROLES.SUPER_ADMIN && !!user.can_manage_messages;
|
||||
}
|
||||
|
||||
/** 彻底删除帖/评:仅站长 */
|
||||
@@ -152,10 +154,10 @@ export function canModerateAny(user: RoleUserLike | null | undefined): boolean {
|
||||
return user.role === ROLES.BOARD_ADMIN && (user.board_ids?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** 站点级消息管理(站长/超管固有,或 can_manage_messages) */
|
||||
/** 站点级消息管理:站长固有;超管及其他角色需站长授予 can_manage_messages */
|
||||
export function hasSiteMessagePerm(user: RoleUserLike | null | undefined): boolean {
|
||||
if (!user) return false;
|
||||
if (canOverseeChat(user.role)) return true;
|
||||
if (isOwner(user.role)) return true;
|
||||
return !!user.can_manage_messages;
|
||||
}
|
||||
|
||||
@@ -201,7 +203,7 @@ export const ROLE_META: Record<RoleKey, RoleMeta> = {
|
||||
colorToken: "--accent",
|
||||
softToken: "--accent-soft",
|
||||
grantDesc:
|
||||
"用户与角色管理、封禁、外观主题、公告;全站内容软删与置顶/加精;群聊监管(可见全部会话、可撤回任意消息)。不可彻底删除内容,不可动站长账号,亦无站长专属的用户内容档案。",
|
||||
"用户与角色管理、封禁、外观主题、公告;全站内容软删与置顶/加精;站长授予「消息管理」后方可进入后台消息管理并全站会话监管(可见全部会话、可撤回任意消息)。不可彻底删除内容,不可动站长账号,亦无站长专属的用户内容档案。",
|
||||
},
|
||||
[ROLES.ADMIN]: {
|
||||
key: ROLES.ADMIN,
|
||||
@@ -219,7 +221,7 @@ export const ROLE_META: Record<RoleKey, RoleMeta> = {
|
||||
colorToken: "--accent",
|
||||
softToken: "--accent-soft",
|
||||
grantDesc:
|
||||
"仅审核并软删被授权板块内普通用户的帖子与评论;不可处置管理员/站长发布的内容,无消息管理、公告、置顶/加精、用户管理与外观设置;须至少勾选一个板块。",
|
||||
"仅审核并软删被授权板块内普通用户的帖子与评论;可在被授权板块内单独置顶帖子(板块置顶)。不可处置管理员/站长发布的内容,无消息管理、公告、全站置顶/加精、用户管理与外观设置;须至少勾选一个板块。",
|
||||
},
|
||||
[ROLES.USER]: {
|
||||
key: ROLES.USER,
|
||||
|
||||
Reference in New Issue
Block a user