完善角色与消息权限:收紧板管内容处置,彻底删除仅站长,并统一管理删帖/评弹窗。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -41,6 +41,7 @@ import {
|
||||
type AdminContentStatus,
|
||||
type PublicSettings,
|
||||
} from "@/lib/api";
|
||||
import { canModerateAuthor } from "@/lib/roles";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
|
||||
@@ -290,6 +291,8 @@ export default function ContentAdmin({
|
||||
initialCounts,
|
||||
initError,
|
||||
canConfigureSettings,
|
||||
canPurge,
|
||||
viewerRole,
|
||||
initialSettings,
|
||||
}: {
|
||||
initialKind: ContentKind;
|
||||
@@ -301,6 +304,8 @@ export default function ContentAdmin({
|
||||
initialCounts: AdminContentCounts;
|
||||
initError: string;
|
||||
canConfigureSettings: boolean;
|
||||
canPurge: boolean;
|
||||
viewerRole: string;
|
||||
initialSettings: PublicSettings;
|
||||
}) {
|
||||
// 窄屏分段:lg 以下只显示一栏;PC 双栏同时可见
|
||||
@@ -663,46 +668,50 @@ export default function ContentAdmin({
|
||||
) : null}
|
||||
{p.deleted ? (
|
||||
<>
|
||||
<ContentAction
|
||||
label="恢复"
|
||||
ok
|
||||
disabled={busy}
|
||||
icon={
|
||||
busy ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<RotateCcw size={13} />
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
runAction(
|
||||
key,
|
||||
() => apiAdminRestorePost(p.id),
|
||||
"帖子已恢复",
|
||||
() => reloadPosts()
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ContentAction
|
||||
label="清除"
|
||||
danger
|
||||
disabled={busy}
|
||||
icon={<Trash2 size={13} />}
|
||||
onClick={() =>
|
||||
askConfirm(
|
||||
"彻底删除帖子",
|
||||
`「${p.title}」将从数据库永久删除,且其下评论一并清除,不可撤销。`,
|
||||
() =>
|
||||
apiAdminPurgePost(p.id).then(() => {
|
||||
toast("帖子已彻底删除", "ok");
|
||||
reloadPosts();
|
||||
}),
|
||||
true
|
||||
)
|
||||
}
|
||||
/>
|
||||
{canModerateAuthor({ role: viewerRole }, p.user?.role) ? (
|
||||
<ContentAction
|
||||
label="恢复"
|
||||
ok
|
||||
disabled={busy}
|
||||
icon={
|
||||
busy ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<RotateCcw size={13} />
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
runAction(
|
||||
key,
|
||||
() => apiAdminRestorePost(p.id),
|
||||
"帖子已恢复",
|
||||
() => reloadPosts()
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{canPurge ? (
|
||||
<ContentAction
|
||||
label="清除"
|
||||
danger
|
||||
disabled={busy}
|
||||
icon={<Trash2 size={13} />}
|
||||
onClick={() =>
|
||||
askConfirm(
|
||||
"彻底删除帖子",
|
||||
`「${p.title}」将从数据库永久删除,且其下评论一并清除,不可撤销。`,
|
||||
() =>
|
||||
apiAdminPurgePost(p.id).then(() => {
|
||||
toast("帖子已彻底删除", "ok");
|
||||
reloadPosts();
|
||||
}),
|
||||
true
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
) : canModerateAuthor({ role: viewerRole }, p.user?.role) ? (
|
||||
<ContentAction
|
||||
label="删除"
|
||||
danger
|
||||
@@ -710,7 +719,7 @@ export default function ContentAdmin({
|
||||
icon={<Trash2 size={13} />}
|
||||
onClick={() => setStaffDeletePost(p)}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -803,46 +812,50 @@ export default function ContentAdmin({
|
||||
/>
|
||||
{c.deleted ? (
|
||||
<>
|
||||
<ContentAction
|
||||
label="恢复"
|
||||
ok
|
||||
disabled={busy}
|
||||
icon={
|
||||
busy ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<RotateCcw size={13} />
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
runAction(
|
||||
key,
|
||||
() => apiAdminRestoreComment(c.id),
|
||||
"评论已恢复",
|
||||
() => reloadComments()
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ContentAction
|
||||
label="清除"
|
||||
danger
|
||||
disabled={busy}
|
||||
icon={<Trash2 size={13} />}
|
||||
onClick={() =>
|
||||
askConfirm(
|
||||
"彻底删除评论",
|
||||
"该评论将永久删除且不可恢复。",
|
||||
() =>
|
||||
apiAdminPurgeComment(c.id).then(() => {
|
||||
toast("评论已彻底删除", "ok");
|
||||
reloadComments();
|
||||
}),
|
||||
true
|
||||
)
|
||||
}
|
||||
/>
|
||||
{canModerateAuthor({ role: viewerRole }, c.user?.role) ? (
|
||||
<ContentAction
|
||||
label="恢复"
|
||||
ok
|
||||
disabled={busy}
|
||||
icon={
|
||||
busy ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<RotateCcw size={13} />
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
runAction(
|
||||
key,
|
||||
() => apiAdminRestoreComment(c.id),
|
||||
"评论已恢复",
|
||||
() => reloadComments()
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{canPurge ? (
|
||||
<ContentAction
|
||||
label="清除"
|
||||
danger
|
||||
disabled={busy}
|
||||
icon={<Trash2 size={13} />}
|
||||
onClick={() =>
|
||||
askConfirm(
|
||||
"彻底删除评论",
|
||||
"该评论将永久删除且不可恢复。",
|
||||
() =>
|
||||
apiAdminPurgeComment(c.id).then(() => {
|
||||
toast("评论已彻底删除", "ok");
|
||||
reloadComments();
|
||||
}),
|
||||
true
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
) : canModerateAuthor({ role: viewerRole }, c.user?.role) ? (
|
||||
<ContentAction
|
||||
label="删除"
|
||||
danger
|
||||
@@ -850,7 +863,7 @@ export default function ContentAdmin({
|
||||
icon={<Trash2 size={13} />}
|
||||
onClick={() => setStaffDeleteComment(c)}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type AdminContentStatus,
|
||||
} from "@/lib/api";
|
||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { canModerateAny, isSuperOrOwner } from "@/lib/roles";
|
||||
import { canModerateAny, isSuperOrOwner, isOwner } from "@/lib/roles";
|
||||
import Forbidden from "../Forbidden";
|
||||
import ContentAdmin, { type ContentKind } from "./ContentAdmin";
|
||||
|
||||
@@ -93,6 +93,8 @@ export default async function AdminContentPage({ searchParams }: ContentPageProp
|
||||
initialCounts={counts}
|
||||
initError={initError}
|
||||
canConfigureSettings={isSuperOrOwner(me.user.role)}
|
||||
canPurge={isOwner(me.user.role)}
|
||||
viewerRole={me.user.role}
|
||||
initialSettings={settings}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cookies } from "next/headers";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import { authCookieHeader, TOKEN_COOKIE } from "@/lib/cookies";
|
||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { isStaff } from "@/lib/roles";
|
||||
import { canAccessAdminMessages, isStaff } from "@/lib/roles";
|
||||
import AdminShell from "@/components/admin/AdminShell";
|
||||
|
||||
// 整个 /admin 树不进入索引(子页面无需重复声明)
|
||||
@@ -12,8 +12,8 @@ export const metadata: Metadata = {
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
// 权限以 Go 端 RequireStaff 为唯一判定;这里仅决定管理界面是否直出,
|
||||
// 非管理团队访问任意 /admin/* 时 SSR 统一给出无权限提示(所有写接口仍由后端 403 兜底)。
|
||||
// 权限以 Go 端为唯一判定;此处仅决定管理界面是否直出。
|
||||
// 允许:管理角色,或仅有消息管理/群管权限的用户(仅消息菜单可见)。
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const cookieStore = await cookies();
|
||||
const cookie = authCookieHeader(cookieStore);
|
||||
@@ -21,7 +21,10 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
const settings = await getPublicSettingsCached();
|
||||
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
|
||||
|
||||
if (!me.user || !isStaff(me.user.role)) {
|
||||
const allowed =
|
||||
!!me.user && (isStaff(me.user.role) || canAccessAdminMessages(me.user));
|
||||
|
||||
if (!allowed || !me.user) {
|
||||
return (
|
||||
<div
|
||||
data-admin-viewport
|
||||
@@ -48,12 +51,14 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
);
|
||||
}
|
||||
|
||||
const user = me.user;
|
||||
|
||||
// 未读仅在有 access cookie 时才可信;壳层铃铛用 SSR 直出避免挂载后才出红点
|
||||
const unread = cookieStore.get(TOKEN_COOKIE)?.value ? me.unread_count : 0;
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
user={me.user}
|
||||
user={user}
|
||||
siteName={settings.site_name || "姜十三论坛"}
|
||||
initialTheme={theme}
|
||||
initialUnread={unread}
|
||||
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
apiAdminRecallChatMessage,
|
||||
type AdminChatMessage,
|
||||
type AdminChatRoomType,
|
||||
type User,
|
||||
} from "@/lib/api";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { canOverseeChat } from "@/lib/roles";
|
||||
import { toast } from "@/lib/toast";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import AutolinkText from "@/components/AutolinkText";
|
||||
@@ -247,7 +249,8 @@ function useMessageColumn(roomType: MsgKind) {
|
||||
}
|
||||
|
||||
/** 群聊(含大厅)| 私聊 双栏监管 */
|
||||
export default function MessagesAdmin() {
|
||||
export default function MessagesAdmin({ me }: { me: User }) {
|
||||
const canOversee = canOverseeChat(me.role);
|
||||
const [mobileKind, setMobileKind] = useState<MsgKind>("group");
|
||||
const group = useMessageColumn("group");
|
||||
const direct = useMessageColumn("direct");
|
||||
@@ -485,37 +488,43 @@ export default function MessagesAdmin() {
|
||||
<AdminPageHeader
|
||||
icon={MessagesSquare}
|
||||
title="消息管理"
|
||||
description="分栏监管群聊与私聊;可搜索并撤回违规内容"
|
||||
description={
|
||||
canOversee
|
||||
? "分栏监管群聊与私聊;可搜索并撤回违规内容"
|
||||
: "监管授权范围内的群聊消息;可搜索并撤回违规内容(不含私聊)"
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="admin-content-kind lg:hidden shrink-0" role="tablist" aria-label="消息类型">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mobileKind === "group"}
|
||||
className="admin-content-kind-btn"
|
||||
onClick={() => setMobileKind("group")}
|
||||
>
|
||||
<Users size={14} aria-hidden />
|
||||
群聊
|
||||
<span className="admin-content-kind-count">{group.total}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mobileKind === "direct"}
|
||||
className="admin-content-kind-btn"
|
||||
onClick={() => setMobileKind("direct")}
|
||||
>
|
||||
<MessageCircle size={14} aria-hidden />
|
||||
私聊
|
||||
<span className="admin-content-kind-count">{direct.total}</span>
|
||||
</button>
|
||||
</div>
|
||||
{canOversee ? (
|
||||
<div className="admin-content-kind lg:hidden shrink-0" role="tablist" aria-label="消息类型">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mobileKind === "group"}
|
||||
className="admin-content-kind-btn"
|
||||
onClick={() => setMobileKind("group")}
|
||||
>
|
||||
<Users size={14} aria-hidden />
|
||||
群聊
|
||||
<span className="admin-content-kind-count">{group.total}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mobileKind === "direct"}
|
||||
className="admin-content-kind-btn"
|
||||
onClick={() => setMobileKind("direct")}
|
||||
>
|
||||
<MessageCircle size={14} aria-hidden />
|
||||
私聊
|
||||
<span className="admin-content-kind-count">{direct.total}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="admin-content-triptych">
|
||||
<div className={canOversee ? "admin-content-triptych" : "admin-content-triptych admin-content-triptych--single"}>
|
||||
{groupCol}
|
||||
{directCol}
|
||||
{canOversee ? directCol : null}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isStaff } from "@/lib/roles";
|
||||
import { canAccessAdminMessages } from "@/lib/roles";
|
||||
import Forbidden from "../Forbidden";
|
||||
import MessagesAdmin from "./MessagesAdmin";
|
||||
|
||||
@@ -10,13 +10,13 @@ export const metadata: Metadata = {
|
||||
title: "消息管理",
|
||||
};
|
||||
|
||||
/** 群聊(含大厅)与私聊分栏监管;鉴权与 staff API 一致 */
|
||||
/** 群聊(含大厅)与私聊分栏监管;鉴权与消息管理权限一致 */
|
||||
export default async function AdminMessagesPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isStaff(me.user.role)) {
|
||||
return <Forbidden text="消息管理仅管理团队成员可进入。" />;
|
||||
if (!me.user || !canAccessAdminMessages(me.user)) {
|
||||
return <Forbidden text="消息管理需站长授予权限,或被任命为群管理员。" />;
|
||||
}
|
||||
|
||||
return <MessagesAdmin />;
|
||||
return <MessagesAdmin me={me.user} />;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from "@/lib/api";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { canModerateAny, isSuperOrOwner, isAdminOrAbove } from "@/lib/roles";
|
||||
import { canModerateAny, isSuperOrOwner, isAdminOrAbove, ROLES } from "@/lib/roles";
|
||||
import { AdminPageHeader, AdminStatCard } from "@/components/admin";
|
||||
import DashboardClient from "./DashboardClient";
|
||||
|
||||
@@ -115,9 +115,12 @@ export default async function AdminDashboardPage() {
|
||||
const canMod = canModerateAny(me.user);
|
||||
const canUsers = isSuperOrOwner(me.user?.role);
|
||||
const canEconomy = isAdminOrAbove(me.user?.role);
|
||||
const boardScoped = me.user?.role === ROLES.BOARD_ADMIN;
|
||||
|
||||
const [overview, pendingCounts, analytics] = await Promise.all([
|
||||
fetchOverview(cookie).catch(() => null),
|
||||
boardScoped
|
||||
? Promise.resolve(null)
|
||||
: fetchOverview(cookie).catch(() => null),
|
||||
canMod
|
||||
? fetchAdminPendingCounts(cookie).catch(() => ({ posts: 0, comments: 0 }))
|
||||
: Promise.resolve(null),
|
||||
@@ -133,7 +136,15 @@ export default async function AdminDashboardPage() {
|
||||
}).format(now);
|
||||
|
||||
const stats = overview?.stats;
|
||||
const newUsers = overview?.new_users ?? [];
|
||||
const newUsers = boardScoped ? [] : overview?.new_users ?? [];
|
||||
|
||||
// 板管:今日内容用授权板 analytics 序列今日点;隐藏全站注册/在线卡的误导数字
|
||||
const todayPosts = boardScoped
|
||||
? analytics.posts_series[analytics.posts_series.length - 1]?.count ?? 0
|
||||
: stats?.today_posts ?? 0;
|
||||
const todayComments = boardScoped
|
||||
? analytics.comments_series[analytics.comments_series.length - 1]?.count ?? 0
|
||||
: stats?.today_comments ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -155,30 +166,34 @@ export default async function AdminDashboardPage() {
|
||||
<AdminStatCard
|
||||
label="今日 PV"
|
||||
value={fmt(analytics.today_pv)}
|
||||
hint={`独立访客 UV ${fmt(analytics.today_uv)}`}
|
||||
hint={boardScoped ? "仅全站可见(板管不展示)" : `独立访客 UV ${fmt(analytics.today_uv)}`}
|
||||
icon={<Eye size={16} />}
|
||||
/>
|
||||
<AdminStatCard
|
||||
label="今日 UV"
|
||||
value={fmt(analytics.today_uv)}
|
||||
hint="独立访客"
|
||||
hint={boardScoped ? "板管仪表盘不含全站 UV" : "独立访客"}
|
||||
icon={<Users size={16} />}
|
||||
color="var(--ok)"
|
||||
soft="var(--ok-soft)"
|
||||
/>
|
||||
<AdminStatCard
|
||||
label="今日内容"
|
||||
value={fmt(stats?.today_posts ?? 0)}
|
||||
value={fmt(todayPosts)}
|
||||
sub="帖"
|
||||
hint={`评论 ${fmt(stats?.today_comments ?? 0)} · 注册 ${fmt(stats?.today_users ?? 0)}`}
|
||||
hint={
|
||||
boardScoped
|
||||
? `评论 ${fmt(todayComments)} · 授权板块`
|
||||
: `评论 ${fmt(todayComments)} · 注册 ${fmt(stats?.today_users ?? 0)}`
|
||||
}
|
||||
icon={<FileText size={16} />}
|
||||
color="var(--gold)"
|
||||
soft="var(--gold-soft)"
|
||||
/>
|
||||
<AdminStatCard
|
||||
label="在线"
|
||||
value={fmt(stats?.online ?? 0)}
|
||||
hint={`峰值 ${fmt(stats?.peak_online ?? 0)}`}
|
||||
value={boardScoped ? "—" : fmt(stats?.online ?? 0)}
|
||||
hint={boardScoped ? "板管不展示全站在线" : `峰值 ${fmt(stats?.peak_online ?? 0)}`}
|
||||
icon={<Activity size={16} />}
|
||||
color="var(--clay)"
|
||||
soft="var(--clay-soft)"
|
||||
|
||||
Reference in New Issue
Block a user