feat(admin): 完善站点设置、内容回收站与后台菜单分组
新增站点设置与内容管理(含已删除恢复/清除),用户页改用独有运营指标,并按概览/内容/成员与经济/站点重组侧栏。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,6 +13,8 @@ import {
|
||||
ArrowLeft,
|
||||
ClipboardCheck,
|
||||
Coins,
|
||||
Globe,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
import type { User } from "@/lib/api";
|
||||
import { apiAdminPendingCounts } from "@/lib/api";
|
||||
@@ -32,45 +34,80 @@ interface NavItem {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
exact?: boolean;
|
||||
/** 按 /me 角色过滤可见性;最终权限以后端路由守卫为准 */
|
||||
visible: (u: RoleUserLike) => boolean;
|
||||
/** 是否携带待审核角标 */
|
||||
pendingBadge?: boolean;
|
||||
}
|
||||
|
||||
// 后台导航项:exact 为精确匹配(仪表盘 /admin),其余按路径前缀匹配
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ href: "/admin", label: "仪表盘", icon: LayoutDashboard, exact: true, visible: () => true },
|
||||
interface NavGroup {
|
||||
title: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
// 分组导航:概览 → 内容运营 → 成员经济 → 站点配置,便于后期在组内扩展
|
||||
const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
href: "/admin/moderation",
|
||||
label: "内容审核",
|
||||
icon: ClipboardCheck,
|
||||
visible: (u) => canModerateAny(u),
|
||||
pendingBadge: true,
|
||||
title: "概览",
|
||||
items: [
|
||||
{ href: "/admin", label: "仪表盘", icon: LayoutDashboard, exact: true, visible: () => true },
|
||||
],
|
||||
},
|
||||
{
|
||||
href: "/admin/economy",
|
||||
label: "积分看板",
|
||||
icon: Coins,
|
||||
visible: (u) => isAdminOrAbove(u.role),
|
||||
title: "内容",
|
||||
items: [
|
||||
{
|
||||
href: "/admin/moderation",
|
||||
label: "内容审核",
|
||||
icon: ClipboardCheck,
|
||||
visible: (u) => canModerateAny(u),
|
||||
pendingBadge: true,
|
||||
},
|
||||
{
|
||||
href: "/admin/content",
|
||||
label: "内容管理",
|
||||
icon: FolderOpen,
|
||||
visible: (u) => canModerateAny(u),
|
||||
},
|
||||
{
|
||||
href: "/admin/announcements",
|
||||
label: "公告管理",
|
||||
icon: Megaphone,
|
||||
visible: (u) => isAdminOrAbove(u.role),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
href: "/admin/announcements",
|
||||
label: "公告管理",
|
||||
icon: Megaphone,
|
||||
visible: (u) => isAdminOrAbove(u.role),
|
||||
title: "成员与经济",
|
||||
items: [
|
||||
{
|
||||
href: "/admin/users",
|
||||
label: "用户权限",
|
||||
icon: Users,
|
||||
visible: (u) => isSuperOrOwner(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/economy",
|
||||
label: "积分看板",
|
||||
icon: Coins,
|
||||
visible: (u) => isAdminOrAbove(u.role),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
href: "/admin/users",
|
||||
label: "用户权限",
|
||||
icon: Users,
|
||||
visible: (u) => isSuperOrOwner(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/appearance",
|
||||
label: "外观设置",
|
||||
icon: Palette,
|
||||
visible: (u) => isSuperOrOwner(u.role),
|
||||
title: "站点",
|
||||
items: [
|
||||
{
|
||||
href: "/admin/settings",
|
||||
label: "站点设置",
|
||||
icon: Globe,
|
||||
visible: (u) => isSuperOrOwner(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/appearance",
|
||||
label: "外观",
|
||||
icon: Palette,
|
||||
visible: (u) => isSuperOrOwner(u.role),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -78,7 +115,6 @@ function isActive(pathname: string, href: string, exact?: boolean) {
|
||||
return exact ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
/** 待审核数量:首屏 + WS 推送 + 回前台校准(无定时轮询) */
|
||||
function usePendingCounts(enabled: boolean) {
|
||||
const [counts, setCounts] = useState({ posts: 0, comments: 0 });
|
||||
useEffect(() => {
|
||||
@@ -116,19 +152,24 @@ function PendingBadge({ count }: { count: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 桌面端后台侧边栏:品牌头 + 分组导航 + 返回站点 + 身份卡。
|
||||
* 选中态复用全站导航语言(--nav-active-bg 淡底 + accent 文字),
|
||||
* 与 j13-bcard / 顶部 pill 保持一致。
|
||||
*/
|
||||
function visibleGroups(user: RoleUserLike) {
|
||||
return NAV_GROUPS.map((g) => ({
|
||||
...g,
|
||||
items: g.items.filter((i) => i.visible(user)),
|
||||
})).filter((g) => g.items.length > 0);
|
||||
}
|
||||
|
||||
function flatItems(user: RoleUserLike) {
|
||||
return visibleGroups(user).flatMap((g) => g.items);
|
||||
}
|
||||
|
||||
export function AdminSidebar({ user }: { user: User }) {
|
||||
const pathname = usePathname();
|
||||
const items = NAV_ITEMS.filter((i) => i.visible(user));
|
||||
const pendingTotal = usePendingCounts(items.some((i) => i.pendingBadge));
|
||||
const groups = visibleGroups(user);
|
||||
const pendingTotal = usePendingCounts(flatItems(user).some((i) => i.pendingBadge));
|
||||
|
||||
return (
|
||||
<div className="panel p-3">
|
||||
{/* 品牌头 */}
|
||||
<div className="flex items-center gap-2.5 px-2 pt-1.5 pb-3.5" style={{ borderBottom: "1px solid var(--line)" }}>
|
||||
<span
|
||||
className="w-9 h-9 rounded-xl flex items-center justify-center shrink-0"
|
||||
@@ -146,35 +187,39 @@ export function AdminSidebar({ user }: { user: User }) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 导航 */}
|
||||
<nav className="flex flex-col gap-1 pt-3 pb-2" aria-label="后台导航">
|
||||
<p className="field-label px-2 pb-1.5" style={{ marginBottom: 0 }}>
|
||||
站点管理
|
||||
</p>
|
||||
{items.map((item) => {
|
||||
const active = isActive(pathname, item.href, item.exact);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-[13.5px] font-medium transition-colors"
|
||||
style={
|
||||
active
|
||||
? { background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }
|
||||
: { color: "var(--ink-2)" }
|
||||
}
|
||||
>
|
||||
<Icon size={16} className="shrink-0" style={{ color: active ? "var(--nav-active-ink)" : "var(--ink-3)" }} />
|
||||
{item.label}
|
||||
{item.pendingBadge && <PendingBadge count={pendingTotal} />}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<nav className="flex flex-col gap-3 pt-3 pb-2" aria-label="后台导航">
|
||||
{groups.map((group) => (
|
||||
<div key={group.title}>
|
||||
<p className="field-label px-2 pb-1.5" style={{ marginBottom: 0 }}>
|
||||
{group.title}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{group.items.map((item) => {
|
||||
const active = isActive(pathname, item.href, item.exact);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-[13.5px] font-medium transition-colors"
|
||||
style={
|
||||
active
|
||||
? { background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }
|
||||
: { color: "var(--ink-2)" }
|
||||
}
|
||||
>
|
||||
<Icon size={16} className="shrink-0" style={{ color: active ? "var(--nav-active-ink)" : "var(--ink-3)" }} />
|
||||
{item.label}
|
||||
{item.pendingBadge && <PendingBadge count={pendingTotal} />}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* 返回前台 */}
|
||||
<div className="pt-2" style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<Link
|
||||
href="/"
|
||||
@@ -186,7 +231,6 @@ export function AdminSidebar({ user }: { user: User }) {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 身份卡:按当前角色显示对应徽章(站长为皇冠标识) */}
|
||||
<div className="flex items-center gap-2.5 mt-2 px-2.5 py-2.5 rounded-xl" style={{ background: "var(--panel-2)" }}>
|
||||
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={32} />
|
||||
<div className="min-w-0 leading-tight">
|
||||
@@ -205,13 +249,9 @@ export function AdminSidebar({ user }: { user: User }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动端后台导航:横向滚动 pill,吸附在全站 Header 下方,
|
||||
* 窄屏下保持与桌面侧栏同等的切换能力且不占垂直空间。
|
||||
*/
|
||||
export function AdminNavMobile({ user }: { user: User }) {
|
||||
const pathname = usePathname();
|
||||
const items = NAV_ITEMS.filter((i) => i.visible(user));
|
||||
const items = flatItems(user);
|
||||
const pendingTotal = usePendingCounts(items.some((i) => i.pendingBadge));
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
X,
|
||||
ExternalLink,
|
||||
Megaphone,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
apiAdminListAnnouncements,
|
||||
@@ -24,6 +23,7 @@ import {
|
||||
} from "@/lib/api";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { toast } from "@/lib/toast";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
|
||||
const COLORS: { value: AnnouncementColor; label: string }[] = [
|
||||
{ value: "blue", label: "蓝色" },
|
||||
@@ -52,6 +52,7 @@ export default function AnnouncementAdmin() {
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Announcement | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -113,7 +114,6 @@ export default function AnnouncementAdmin() {
|
||||
}
|
||||
|
||||
async function remove(a: Announcement) {
|
||||
if (!window.confirm(`确定删除公告「${a.title}」吗?此操作不可撤销。`)) return;
|
||||
setDeletingId(a.id);
|
||||
try {
|
||||
await apiAdminDeleteAnnouncement(a.id);
|
||||
@@ -124,6 +124,7 @@ export default function AnnouncementAdmin() {
|
||||
toast(e instanceof Error ? e.message : "删除失败");
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,10 +151,10 @@ export default function AnnouncementAdmin() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[400px_minmax(0,1fr)] gap-6 items-start">
|
||||
{/* 编辑/新建表单 */}
|
||||
{form ? (
|
||||
<div className="panel p-5 lg:sticky lg:top-24">
|
||||
<div className="grid grid-cols-1 gap-6 items-start">
|
||||
{/* 编辑/新建表单:仅在新建或编辑时展开,空闲不占位 */}
|
||||
{form && (
|
||||
<div className="panel p-5">
|
||||
<p className="font-semibold text-[15px] mb-4" style={{ color: "var(--ink)" }}>
|
||||
{form.id == null ? "新建公告" : "编辑公告"}
|
||||
</p>
|
||||
@@ -257,26 +258,6 @@ export default function AnnouncementAdmin() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="panel p-6 text-center lg:sticky lg:top-24">
|
||||
<span
|
||||
className="inline-flex w-12 h-12 rounded-2xl items-center justify-center mb-3"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
<FileText size={22} />
|
||||
</span>
|
||||
<p className="text-[14.5px]" style={{ color: "var(--ink)" }}>
|
||||
公告编辑器
|
||||
</p>
|
||||
<p className="meta mt-1.5 text-[12.5px] leading-relaxed">
|
||||
点击右上「新建公告」发布公告,
|
||||
<br />
|
||||
或在右侧列表中选择已有公告进行编辑。
|
||||
</p>
|
||||
<button onClick={startCreate} className="btn btn-primary mt-4">
|
||||
<Plus size={15} /> 新建公告
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 公告列表 */}
|
||||
@@ -350,7 +331,7 @@ export default function AnnouncementAdmin() {
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => remove(a)}
|
||||
onClick={() => setDeleteTarget(a)}
|
||||
disabled={deletingId === a.id}
|
||||
className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--danger-soft)] disabled:opacity-50"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
@@ -367,6 +348,19 @@ export default function AnnouncementAdmin() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="删除公告"
|
||||
message={deleteTarget ? `确定删除公告「${deleteTarget.title}」吗?此操作不可撤销。` : ""}
|
||||
danger
|
||||
busy={deletingId != null}
|
||||
confirmLabel="删除"
|
||||
onCancel={() => deletingId == null && setDeleteTarget(null)}
|
||||
onConfirm={() => {
|
||||
if (deleteTarget) void remove(deleteTarget);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
696
frontend/app/admin/content/ContentAdmin.tsx
Normal file
696
frontend/app/admin/content/ContentAdmin.tsx
Normal file
@@ -0,0 +1,696 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
MessagesSquare,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
apiAdminContentCounts,
|
||||
apiAdminContentPosts,
|
||||
apiAdminContentComments,
|
||||
apiAdminSoftDeletePost,
|
||||
apiAdminRestorePost,
|
||||
apiAdminPurgePost,
|
||||
apiAdminSoftDeleteComment,
|
||||
apiAdminRestoreComment,
|
||||
apiAdminPurgeComment,
|
||||
apiUpdateSiteSettings,
|
||||
type AdminContentComment,
|
||||
type AdminContentCounts,
|
||||
type AdminContentPost,
|
||||
type AdminContentStatus,
|
||||
type PublicSettings,
|
||||
} from "@/lib/api";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { toast } from "@/lib/toast";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
type Kind = "posts" | "comments";
|
||||
|
||||
const STATUS_TABS: { key: AdminContentStatus; label: string }[] = [
|
||||
{ key: "live", label: "全部" },
|
||||
{ key: "published", label: "已发布" },
|
||||
{ key: "pending", label: "待审" },
|
||||
{ key: "rejected", label: "已拒绝" },
|
||||
{ key: "deleted", label: "已删除" },
|
||||
];
|
||||
|
||||
function statusLabel(status: string, deleted: boolean) {
|
||||
if (deleted) return "已删除";
|
||||
if (status === "pending") return "待审";
|
||||
if (status === "rejected") return "已拒绝";
|
||||
if (status === "published") return "已发布";
|
||||
return status;
|
||||
}
|
||||
|
||||
function excerpt(md: string, max = 120): string {
|
||||
const text = md
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/[#>*_`~\-]/g, "")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return text.length > max ? `${text.slice(0, max)}…` : text;
|
||||
}
|
||||
|
||||
function Switch({
|
||||
checked,
|
||||
disabled,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: () => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={onChange}
|
||||
className="shrink-0 relative h-7 w-12 rounded-full transition-colors disabled:opacity-60"
|
||||
style={{ background: checked ? "var(--accent)" : "var(--line-2)" }}
|
||||
>
|
||||
<span
|
||||
className="absolute top-0.5 left-0.5 h-6 w-6 rounded-full bg-[var(--panel)] shadow transition-transform"
|
||||
style={{ transform: checked ? "translateX(1.25rem)" : "translateX(0)" }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ContentAdmin({
|
||||
initialPosts,
|
||||
initialPostsTotal,
|
||||
initialCounts,
|
||||
initError,
|
||||
canConfigureSettings,
|
||||
initialSettings,
|
||||
}: {
|
||||
initialPosts: AdminContentPost[];
|
||||
initialPostsTotal: number;
|
||||
initialCounts: AdminContentCounts;
|
||||
initError: string;
|
||||
canConfigureSettings: boolean;
|
||||
initialSettings: PublicSettings;
|
||||
}) {
|
||||
const [kind, setKind] = useState<Kind>("posts");
|
||||
const [status, setStatus] = useState<AdminContentStatus>("live");
|
||||
const [counts, setCounts] = useState(initialCounts);
|
||||
|
||||
const [posts, setPosts] = useState(initialPosts);
|
||||
const [postsTotal, setPostsTotal] = useState(initialPostsTotal);
|
||||
const [postsPage, setPostsPage] = useState(1);
|
||||
|
||||
const [comments, setComments] = useState<AdminContentComment[]>([]);
|
||||
const [commentsTotal, setCommentsTotal] = useState(0);
|
||||
const [commentsPage, setCommentsPage] = useState(1);
|
||||
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [appliedQ, setAppliedQ] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(initError);
|
||||
const [actingKey, setActingKey] = useState<string | null>(null);
|
||||
|
||||
const [trustOn, setTrustOn] = useState(initialSettings.trust_reviewed_publish);
|
||||
const [cooldown, setCooldown] = useState(initialSettings.post_cooldown_hours);
|
||||
const [savingPolicy, setSavingPolicy] = useState(false);
|
||||
|
||||
const [confirm, setConfirm] = useState<{
|
||||
title: string;
|
||||
message: string;
|
||||
danger?: boolean;
|
||||
run: () => Promise<void>;
|
||||
} | null>(null);
|
||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||
|
||||
const refreshCounts = useCallback(() => {
|
||||
apiAdminContentCounts().then(setCounts).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const loadPosts = useCallback(async (page: number, st: AdminContentStatus, q: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiAdminContentPosts(page, st, q);
|
||||
setPosts(res.posts);
|
||||
setPostsTotal(res.total);
|
||||
setPostsPage(page);
|
||||
setError("");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "获取帖子失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadComments = useCallback(async (page: number, st: AdminContentStatus, q: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiAdminContentComments(page, st, q);
|
||||
setComments(res.comments);
|
||||
setCommentsTotal(res.total);
|
||||
setCommentsPage(page);
|
||||
setError("");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "获取评论失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reload = (page = 1) => {
|
||||
if (kind === "posts") void loadPosts(page, status, appliedQ);
|
||||
else void loadComments(page, status, appliedQ);
|
||||
refreshCounts();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshCounts();
|
||||
}, [refreshCounts]);
|
||||
|
||||
const switchKind = (k: Kind) => {
|
||||
if (k === kind) return;
|
||||
setKind(k);
|
||||
setError("");
|
||||
if (k === "posts") void loadPosts(1, status, appliedQ);
|
||||
else void loadComments(1, status, appliedQ);
|
||||
};
|
||||
|
||||
const switchStatus = (st: AdminContentStatus) => {
|
||||
if (st === status) return;
|
||||
setStatus(st);
|
||||
setError("");
|
||||
if (kind === "posts") void loadPosts(1, st, appliedQ);
|
||||
else void loadComments(1, st, appliedQ);
|
||||
};
|
||||
|
||||
const applySearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const q = keyword.trim();
|
||||
setAppliedQ(q);
|
||||
if (kind === "posts") void loadPosts(1, status, q);
|
||||
else void loadComments(1, status, q);
|
||||
};
|
||||
|
||||
const runAction = (key: string, fn: () => Promise<void>, okMsg: string) => {
|
||||
setActingKey(key);
|
||||
fn()
|
||||
.then(() => {
|
||||
toast(okMsg, "ok");
|
||||
reload(kind === "posts" ? postsPage : commentsPage);
|
||||
})
|
||||
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
|
||||
.finally(() => setActingKey(null));
|
||||
};
|
||||
|
||||
const askConfirm = (
|
||||
title: string,
|
||||
message: string,
|
||||
run: () => Promise<void>,
|
||||
danger = false
|
||||
) => setConfirm({ title, message, run, danger });
|
||||
|
||||
const toggleTrust = async () => {
|
||||
if (!canConfigureSettings || savingPolicy) return;
|
||||
setSavingPolicy(true);
|
||||
try {
|
||||
const res = await apiUpdateSiteSettings({ trust_reviewed_publish: !trustOn });
|
||||
setTrustOn(res.trust_reviewed_publish);
|
||||
toast(res.trust_reviewed_publish ? "已开启过审免审" : "已关闭过审免审", "ok");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSavingPolicy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveCooldown = async () => {
|
||||
if (!canConfigureSettings || savingPolicy) return;
|
||||
const n = Math.max(0, Math.min(168, Math.round(Number(cooldown)) || 0));
|
||||
setSavingPolicy(true);
|
||||
try {
|
||||
const res = await apiUpdateSiteSettings({ post_cooldown_hours: n });
|
||||
setCooldown(res.post_cooldown_hours);
|
||||
toast(
|
||||
res.post_cooldown_hours === 0
|
||||
? "已关闭新用户发帖冷静期"
|
||||
: `冷静期已设为 ${res.post_cooldown_hours} 小时`,
|
||||
"ok"
|
||||
);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSavingPolicy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const total = kind === "posts" ? postsTotal : commentsTotal;
|
||||
const page = kind === "posts" ? postsPage : commentsPage;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
const statusCount = (st: AdminContentStatus) => {
|
||||
if (kind === "posts") {
|
||||
if (st === "live") return counts.posts_live;
|
||||
if (st === "pending") return counts.posts_pending;
|
||||
if (st === "rejected") return counts.posts_rejected;
|
||||
if (st === "deleted") return counts.posts_deleted;
|
||||
return null;
|
||||
}
|
||||
if (st === "live") return counts.comments_live;
|
||||
if (st === "pending") return counts.comments_pending;
|
||||
if (st === "rejected") return counts.comments_rejected;
|
||||
if (st === "deleted") return counts.comments_deleted;
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-5">
|
||||
<h1
|
||||
className="text-[22px] sm:text-[26px] font-extrabold tracking-tight flex items-center gap-2.5"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
<span
|
||||
className="w-9 h-9 rounded-xl flex items-center justify-center"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
<FolderOpen size={18} />
|
||||
</span>
|
||||
内容管理
|
||||
</h1>
|
||||
<p className="meta mt-1.5 text-[12.5px]">
|
||||
浏览与处理帖子/评论,含回收站恢复与彻底删除;策略开关仅超管/站长可改
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{canConfigureSettings && (
|
||||
<div className="grid gap-3 mb-5">
|
||||
<section className="panel p-4 sm:p-5 flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
已过审用户后续免审
|
||||
</h2>
|
||||
<p className="meta mt-1 text-[12.5px] leading-relaxed">
|
||||
开启后,已有过审内容的用户后续发帖与评论将直接公开。默认开启。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={trustOn}
|
||||
disabled={savingPolicy}
|
||||
onChange={() => void toggleTrust()}
|
||||
label="已过审用户后续免审"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="panel p-4 sm:p-5">
|
||||
<h2 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
新用户发帖冷静期
|
||||
</h2>
|
||||
<p className="meta mt-1 text-[12.5px] leading-relaxed mb-3">
|
||||
注册后满该小时数才能发帖;设为 0 表示关闭。上限 168 小时(7 天)。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={168}
|
||||
className="field w-28"
|
||||
value={cooldown}
|
||||
onChange={(e) => setCooldown(Number(e.target.value))}
|
||||
aria-label="冷静期小时数"
|
||||
/>
|
||||
<span className="meta text-[12.5px]">小时</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={savingPolicy}
|
||||
onClick={() => void saveCooldown()}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 mb-3 flex-wrap" role="tablist" aria-label="内容类型">
|
||||
{(
|
||||
[
|
||||
{ key: "posts" as Kind, label: "帖子", icon: <FileText size={14} />, count: counts.posts_live },
|
||||
{
|
||||
key: "comments" as Kind,
|
||||
label: "评论",
|
||||
icon: <MessagesSquare size={14} />,
|
||||
count: counts.comments_live,
|
||||
},
|
||||
]
|
||||
).map((t) => {
|
||||
const active = kind === t.key;
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => switchKind(t.key)}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[13px] transition-colors"
|
||||
style={
|
||||
active
|
||||
? { background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }
|
||||
: { color: "var(--ink-2)", background: "var(--panel-2)" }
|
||||
}
|
||||
>
|
||||
{t.icon}
|
||||
{t.label}
|
||||
<span className="text-[11px] tabular-nums" style={{ color: active ? "var(--nav-active-ink)" : "var(--ink-3)" }}>
|
||||
{t.count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 mb-4 flex-wrap" role="tablist" aria-label="状态筛选">
|
||||
{STATUS_TABS.map((t) => {
|
||||
const active = status === t.key;
|
||||
const cnt = statusCount(t.key);
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => switchStatus(t.key)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12.5px] transition-colors"
|
||||
style={
|
||||
active
|
||||
? { background: "var(--accent-soft)", color: "var(--accent)" }
|
||||
: { color: "var(--ink-3)", background: "var(--panel)" }
|
||||
}
|
||||
>
|
||||
{t.label}
|
||||
{cnt != null && (
|
||||
<span className="tabular-nums text-[11px] opacity-80">{cnt}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<form onSubmit={applySearch} className="panel p-3 mb-4 flex gap-2 items-center" role="search">
|
||||
<Search size={15} style={{ color: "var(--ink-3)" }} aria-hidden />
|
||||
<input
|
||||
className="field flex-1 !border-0 !shadow-none !bg-transparent py-1.5"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder={kind === "posts" ? "搜索帖子标题…" : "搜索评论正文…"}
|
||||
aria-label="搜索内容"
|
||||
/>
|
||||
<button type="submit" className="btn btn-line btn-sm">
|
||||
搜索
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="panel overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-16 meta text-[13px]">
|
||||
<Loader2 size={16} className="animate-spin" /> 加载中…
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="alert-error m-4">{error}</p>
|
||||
) : kind === "posts" && posts.length === 0 ? (
|
||||
<p className="meta text-center py-16 text-[13px]">暂无帖子</p>
|
||||
) : kind === "comments" && comments.length === 0 ? (
|
||||
<p className="meta text-center py-16 text-[13px]">暂无评论</p>
|
||||
) : kind === "posts" ? (
|
||||
posts.map((p) => {
|
||||
const key = `p-${p.id}`;
|
||||
const busy = actingKey === key;
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex items-start gap-3 px-4 sm:px-5 py-3.5"
|
||||
style={{ borderTop: "1px solid var(--line)" }}
|
||||
>
|
||||
<Avatar name={p.user?.nickname || p.user?.username || "?"} src={p.user?.avatar || undefined} size={34} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className="text-[11px] px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: p.deleted ? "var(--danger-soft)" : "var(--tint)",
|
||||
color: p.deleted ? "var(--danger)" : "var(--ink-3)",
|
||||
}}
|
||||
>
|
||||
{statusLabel(p.status, p.deleted)}
|
||||
</span>
|
||||
<span className="meta text-[11.5px]">{p.board?.name}</span>
|
||||
<span className="meta text-[11.5px] tabular-nums">{formatRelative(p.created_at)}</span>
|
||||
</div>
|
||||
{p.deleted ? (
|
||||
<p className="mt-1 text-[14px]" style={{ color: "var(--ink-2)" }}>
|
||||
{p.title}
|
||||
</p>
|
||||
) : (
|
||||
<Link href={`/post/${p.id}`} className="mt-1 block text-[14px] hover:underline" style={{ color: "var(--ink)" }}>
|
||||
{p.title}
|
||||
</Link>
|
||||
)}
|
||||
<p className="meta text-[12px] mt-0.5">
|
||||
{p.user?.nickname} · {p.comment_count} 评
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{p.deleted ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
runAction(key, () => apiAdminRestorePost(p.id), "帖子已恢复")
|
||||
}
|
||||
>
|
||||
{busy ? <Loader2 size={14} className="animate-spin" /> : <RotateCcw size={14} />}
|
||||
恢复
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
askConfirm(
|
||||
"彻底删除帖子",
|
||||
`「${p.title}」将从数据库永久删除,且其下评论一并清除,不可撤销。`,
|
||||
() =>
|
||||
apiAdminPurgePost(p.id).then(() => {
|
||||
toast("帖子已彻底删除", "ok");
|
||||
reload(postsPage);
|
||||
}),
|
||||
true
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} /> 清除
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
askConfirm(
|
||||
"移入回收站",
|
||||
`确定软删帖子「${p.title}」吗?可稍后在「已删除」中恢复。`,
|
||||
() =>
|
||||
apiAdminSoftDeletePost(p.id).then(() => {
|
||||
toast("已移入回收站", "ok");
|
||||
reload(postsPage);
|
||||
}),
|
||||
true
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} /> 删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
comments.map((c) => {
|
||||
const key = `c-${c.id}`;
|
||||
const busy = actingKey === key;
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className="flex items-start gap-3 px-4 sm:px-5 py-3.5"
|
||||
style={{ borderTop: "1px solid var(--line)" }}
|
||||
>
|
||||
<Avatar name={c.user?.nickname || c.user?.username || "?"} src={c.user?.avatar || undefined} size={34} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className="text-[11px] px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: c.deleted ? "var(--danger-soft)" : "var(--tint)",
|
||||
color: c.deleted ? "var(--danger)" : "var(--ink-3)",
|
||||
}}
|
||||
>
|
||||
{statusLabel(c.status, c.deleted)}
|
||||
</span>
|
||||
<span className="meta text-[11.5px]">{c.board?.name}</span>
|
||||
<span className="meta text-[11.5px] tabular-nums">{formatRelative(c.created_at)}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[13.5px] leading-relaxed" style={{ color: "var(--ink)" }}>
|
||||
{excerpt(c.content)}
|
||||
</p>
|
||||
<p className="meta text-[12px] mt-0.5">
|
||||
{c.user?.nickname} ·{" "}
|
||||
{c.deleted ? (
|
||||
c.post_title
|
||||
) : (
|
||||
<Link href={`/post/${c.post_id}`} className="hover:underline" style={{ color: "var(--accent)" }}>
|
||||
{c.post_title || `帖子 #${c.post_id}`}
|
||||
</Link>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{c.deleted ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
runAction(key, () => apiAdminRestoreComment(c.id), "评论已恢复")
|
||||
}
|
||||
>
|
||||
{busy ? <Loader2 size={14} className="animate-spin" /> : <RotateCcw size={14} />}
|
||||
恢复
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
askConfirm(
|
||||
"彻底删除评论",
|
||||
"该评论将永久删除且不可恢复。",
|
||||
() =>
|
||||
apiAdminPurgeComment(c.id).then(() => {
|
||||
toast("评论已彻底删除", "ok");
|
||||
reload(commentsPage);
|
||||
}),
|
||||
true
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} /> 清除
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
askConfirm(
|
||||
"移入回收站",
|
||||
"确定软删这条评论吗?可在「已删除」中恢复。",
|
||||
() =>
|
||||
apiAdminSoftDeleteComment(c.id).then(() => {
|
||||
toast("已移入回收站", "ok");
|
||||
reload(commentsPage);
|
||||
}),
|
||||
true
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} /> 删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div
|
||||
className="flex items-center justify-center gap-2 px-4 py-3"
|
||||
style={{ borderTop: "1px solid var(--line)" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={page <= 1 || loading}
|
||||
onClick={() => {
|
||||
const p = page - 1;
|
||||
if (kind === "posts") void loadPosts(p, status, appliedQ);
|
||||
else void loadComments(p, status, appliedQ);
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={14} /> 上一页
|
||||
</button>
|
||||
<span className="meta text-[12.5px] tabular-nums">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={page >= totalPages || loading}
|
||||
onClick={() => {
|
||||
const p = page + 1;
|
||||
if (kind === "posts") void loadPosts(p, status, appliedQ);
|
||||
else void loadComments(p, status, appliedQ);
|
||||
}}
|
||||
>
|
||||
下一页 <ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
title={confirm?.title || ""}
|
||||
message={confirm?.message || ""}
|
||||
danger={confirm?.danger}
|
||||
busy={confirmBusy}
|
||||
confirmLabel={confirm?.danger ? "确认删除" : "确定"}
|
||||
onCancel={() => !confirmBusy && setConfirm(null)}
|
||||
onConfirm={() => {
|
||||
if (!confirm) return;
|
||||
setConfirmBusy(true);
|
||||
confirm
|
||||
.run()
|
||||
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
|
||||
.finally(() => {
|
||||
setConfirmBusy(false);
|
||||
setConfirm(null);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
frontend/app/admin/content/page.tsx
Normal file
56
frontend/app/admin/content/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import {
|
||||
fetchAdminContentCounts,
|
||||
fetchAdminContentPosts,
|
||||
type AdminContentPost,
|
||||
} from "@/lib/api";
|
||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { canModerateAny, isSuperOrOwner } from "@/lib/roles";
|
||||
import Forbidden from "../Forbidden";
|
||||
import ContentAdmin from "./ContentAdmin";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "内容管理",
|
||||
};
|
||||
|
||||
export default async function AdminContentPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !canModerateAny(me.user)) {
|
||||
return <Forbidden text="你所在的角色没有内容管理权限,或尚未被授权任何板块。" />;
|
||||
}
|
||||
|
||||
let initialPosts: AdminContentPost[] = [];
|
||||
let initialTotal = 0;
|
||||
let initError = "";
|
||||
const [postsSettled, counts] = await Promise.all([
|
||||
fetchAdminContentPosts(1, "live", cookie || undefined)
|
||||
.then((res) => ({ ok: true as const, res }))
|
||||
.catch((e) => ({
|
||||
ok: false as const,
|
||||
error: e instanceof Error ? e.message : "获取内容列表失败",
|
||||
})),
|
||||
fetchAdminContentCounts(cookie || undefined),
|
||||
]);
|
||||
if (postsSettled.ok) {
|
||||
initialPosts = postsSettled.res.posts;
|
||||
initialTotal = postsSettled.res.total;
|
||||
} else {
|
||||
initError = postsSettled.error;
|
||||
}
|
||||
|
||||
const settings = await getPublicSettingsCached();
|
||||
|
||||
return (
|
||||
<ContentAdmin
|
||||
initialPosts={initialPosts}
|
||||
initialPostsTotal={initialTotal}
|
||||
initialCounts={counts}
|
||||
initError={initError}
|
||||
canConfigureSettings={isSuperOrOwner(me.user.role)}
|
||||
initialSettings={settings}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
apiAdminRejectPost,
|
||||
apiAdminApproveComment,
|
||||
apiAdminRejectComment,
|
||||
apiUpdateSiteSettings,
|
||||
type PendingCommentItem,
|
||||
type PendingCounts,
|
||||
type Post,
|
||||
@@ -48,15 +47,10 @@ export default function ModerationAdmin({
|
||||
initialPosts,
|
||||
initialPostsTotal,
|
||||
initError,
|
||||
canConfigureSettings,
|
||||
initialTrustReviewedPublish,
|
||||
}: {
|
||||
initialPosts: Post[];
|
||||
initialPostsTotal: number;
|
||||
initError: string;
|
||||
/** 超管/站长可改站点免审策略(后端 PermSettings) */
|
||||
canConfigureSettings: boolean;
|
||||
initialTrustReviewedPublish: boolean;
|
||||
}) {
|
||||
const [tab, setTab] = useState<Tab>("posts");
|
||||
const [counts, setCounts] = useState<PendingCounts>({ posts: initialPostsTotal, comments: 0 });
|
||||
@@ -79,30 +73,12 @@ export default function ModerationAdmin({
|
||||
const [confirmKey, setConfirmKey] = useState<string | null>(null);
|
||||
const confirmTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const [trustReviewedPublish, setTrustReviewedPublish] = useState(initialTrustReviewedPublish);
|
||||
const [savingTrust, setSavingTrust] = useState(false);
|
||||
|
||||
const refreshCounts = useCallback(() => {
|
||||
apiAdminPendingCounts()
|
||||
.then(setCounts)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const toggleTrustReviewedPublish = async () => {
|
||||
if (!canConfigureSettings || savingTrust) return;
|
||||
const next = !trustReviewedPublish;
|
||||
setSavingTrust(true);
|
||||
try {
|
||||
const res = await apiUpdateSiteSettings({ trust_reviewed_publish: next });
|
||||
setTrustReviewedPublish(res.trust_reviewed_publish);
|
||||
toast(res.trust_reviewed_publish ? "已开启:过审用户后续免审" : "已关闭:所有普通用户均需审核", "ok");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSavingTrust(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshCounts();
|
||||
}, [refreshCounts]);
|
||||
@@ -235,44 +211,10 @@ export default function ModerationAdmin({
|
||||
内容审核
|
||||
</h1>
|
||||
<p className="meta mt-1.5 text-[12.5px]">
|
||||
普通用户发布的帖子与评论需审核通过后公开;板块管理员仅能处理授权板块的内容
|
||||
普通用户发布的帖子与评论需审核通过后公开;板块管理员仅能处理授权板块的内容。内容策略与回收站见「内容管理」。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{canConfigureSettings && (
|
||||
<section
|
||||
className="panel p-4 sm:p-5 mb-5 flex items-start justify-between gap-4 flex-wrap"
|
||||
aria-label="审核策略"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
已过审用户后续免审
|
||||
</h2>
|
||||
<p className="meta mt-1 text-[12.5px] leading-relaxed">
|
||||
开启后,若用户已有通过审核的帖子或评论,后续发帖与评论将直接公开,无需再次进入待审队列。默认开启。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={trustReviewedPublish}
|
||||
disabled={savingTrust}
|
||||
onClick={() => void toggleTrustReviewedPublish()}
|
||||
className="shrink-0 relative h-7 w-12 rounded-full transition-colors disabled:opacity-60"
|
||||
style={{
|
||||
background: trustReviewedPublish ? "var(--accent)" : "var(--line-2)",
|
||||
}}
|
||||
data-tip={trustReviewedPublish ? "点击关闭" : "点击开启"}
|
||||
>
|
||||
<span
|
||||
className="absolute top-0.5 left-0.5 h-6 w-6 rounded-full bg-[var(--panel)] shadow transition-transform"
|
||||
style={{ transform: trustReviewedPublish ? "translateX(1.25rem)" : "translateX(0)" }}
|
||||
/>
|
||||
<span className="sr-only">{trustReviewedPublish ? "已开启" : "已关闭"}</span>
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 队列切换 */}
|
||||
<div className="flex items-center gap-1.5 mb-4 flex-wrap" role="tablist" aria-label="审核队列">
|
||||
{(
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { fetchAdminPendingPosts, type Post } from "@/lib/api";
|
||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { canModerateAny, isSuperOrOwner } from "@/lib/roles";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { canModerateAny } from "@/lib/roles";
|
||||
import Forbidden from "../Forbidden";
|
||||
import ModerationAdmin from "./ModerationAdmin";
|
||||
|
||||
@@ -17,7 +17,6 @@ export default async function ModerationPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
|
||||
// 板块管理员及以上且(对板块管理员)拥有授权板块
|
||||
if (!me.user || !canModerateAny(me.user)) {
|
||||
return <Forbidden text="你所在的角色没有内容审核权限,或尚未被授权任何板块。" />;
|
||||
}
|
||||
@@ -33,16 +32,11 @@ export default async function ModerationPage() {
|
||||
initError = e instanceof Error ? e.message : "获取待审核内容失败";
|
||||
}
|
||||
|
||||
const canConfigureSettings = isSuperOrOwner(me.user.role);
|
||||
const settings = await getPublicSettingsCached();
|
||||
|
||||
return (
|
||||
<ModerationAdmin
|
||||
initialPosts={initialPosts}
|
||||
initialPostsTotal={initialTotal}
|
||||
initError={initError}
|
||||
canConfigureSettings={canConfigureSettings}
|
||||
initialTrustReviewedPublish={settings.trust_reviewed_publish !== false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
179
frontend/app/admin/settings/SettingsAdmin.tsx
Normal file
179
frontend/app/admin/settings/SettingsAdmin.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Globe,
|
||||
Loader2,
|
||||
Save,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
apiUpdateSiteSettings,
|
||||
type PublicSettings,
|
||||
} from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
function Switch({
|
||||
checked,
|
||||
disabled,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: () => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={onChange}
|
||||
className="shrink-0 relative h-7 w-12 rounded-full transition-colors disabled:opacity-60"
|
||||
style={{ background: checked ? "var(--accent)" : "var(--line-2)" }}
|
||||
>
|
||||
<span
|
||||
className="absolute top-0.5 left-0.5 h-6 w-6 rounded-full bg-[var(--panel)] shadow transition-transform"
|
||||
style={{ transform: checked ? "translateX(1.25rem)" : "translateX(0)" }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsAdmin({ initial }: { initial: PublicSettings }) {
|
||||
const [siteName, setSiteName] = useState(initial.site_name);
|
||||
const [siteDescription, setSiteDescription] = useState(initial.site_description);
|
||||
const [allowRegister, setAllowRegister] = useState(initial.allow_register);
|
||||
const [savedName, setSavedName] = useState(initial.site_name);
|
||||
const [savedDesc, setSavedDesc] = useState(initial.site_description);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [togglingReg, setTogglingReg] = useState(false);
|
||||
|
||||
const dirty =
|
||||
siteName.trim() !== savedName || siteDescription.trim() !== savedDesc;
|
||||
|
||||
const saveIdentity = async () => {
|
||||
if (saving) return;
|
||||
const name = siteName.trim();
|
||||
if (!name) {
|
||||
toast("站点名称不能为空");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await apiUpdateSiteSettings({
|
||||
site_name: name,
|
||||
site_description: siteDescription.trim(),
|
||||
});
|
||||
setSiteName(res.site_name);
|
||||
setSiteDescription(res.site_description);
|
||||
setSavedName(res.site_name);
|
||||
setSavedDesc(res.site_description);
|
||||
toast("站点信息已保存", "ok");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRegister = async () => {
|
||||
if (togglingReg) return;
|
||||
const next = !allowRegister;
|
||||
setTogglingReg(true);
|
||||
try {
|
||||
const res = await apiUpdateSiteSettings({ allow_register: next });
|
||||
setAllowRegister(res.allow_register);
|
||||
toast(res.allow_register ? "已开放注册" : "已关闭注册", "ok");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setTogglingReg(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<div className="mb-5">
|
||||
<h1
|
||||
className="text-[22px] sm:text-[26px] font-extrabold tracking-tight flex items-center gap-2.5"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
<span
|
||||
className="w-9 h-9 rounded-xl flex items-center justify-center"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
<Globe size={18} />
|
||||
</span>
|
||||
站点设置
|
||||
</h1>
|
||||
<p className="meta mt-1.5 text-[12.5px]">
|
||||
配置站点名称、简介与注册开关;主题色请到「外观」页调整
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="j13-module p-5 sm:p-6 mb-5">
|
||||
<h2 className="text-[15px] font-semibold mb-1" style={{ color: "var(--ink)" }}>
|
||||
基本信息
|
||||
</h2>
|
||||
<p className="meta text-[12.5px] mb-4">用于页眉品牌、页脚版权与 SEO 元信息</p>
|
||||
|
||||
<label className="field-label" htmlFor="site-name">
|
||||
站点名称
|
||||
</label>
|
||||
<input
|
||||
id="site-name"
|
||||
className="field mb-4"
|
||||
maxLength={32}
|
||||
value={siteName}
|
||||
onChange={(e) => setSiteName(e.target.value)}
|
||||
placeholder="姜十三论坛"
|
||||
/>
|
||||
|
||||
<label className="field-label" htmlFor="site-desc">
|
||||
站点简介
|
||||
</label>
|
||||
<textarea
|
||||
id="site-desc"
|
||||
className="field min-h-[96px] mb-4"
|
||||
maxLength={160}
|
||||
value={siteDescription}
|
||||
onChange={(e) => setSiteDescription(e.target.value)}
|
||||
placeholder="一句话介绍社区定位…"
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => void saveIdentity()}
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
保存基本信息
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="j13-module p-5 sm:p-6 mb-5 flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-[15px] font-semibold flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
|
||||
<UserPlus size={15} /> 开放注册
|
||||
</h2>
|
||||
<p className="meta text-[12.5px] mt-1 leading-relaxed">
|
||||
关闭后,游客将无法提交注册;已有账号仍可登录。适用于内测或维护期。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={allowRegister}
|
||||
disabled={togglingReg}
|
||||
onChange={() => void toggleRegister()}
|
||||
label="开放注册"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
frontend/app/admin/settings/page.tsx
Normal file
21
frontend/app/admin/settings/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { isSuperOrOwner } from "@/lib/roles";
|
||||
import Forbidden from "../Forbidden";
|
||||
import SettingsAdmin from "./SettingsAdmin";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "站点设置",
|
||||
};
|
||||
|
||||
export default async function AdminSettingsPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isSuperOrOwner(me.user.role)) {
|
||||
return <Forbidden text="站点设置仅超级管理员和站长可操作。" />;
|
||||
}
|
||||
const settings = await getPublicSettingsCached();
|
||||
return <SettingsAdmin initial={settings} />;
|
||||
}
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
Monitor,
|
||||
RotateCcw,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Users as UsersIcon,
|
||||
UserPlus,
|
||||
Activity,
|
||||
Hourglass,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -121,7 +123,15 @@ export default function UsersAdmin({
|
||||
}) {
|
||||
const [users, setUsers] = useState<AdminUser[]>(initial?.users ?? []);
|
||||
const [summary, setSummary] = useState<AdminUserSummary>(
|
||||
initial?.summary ?? { total: 0, admins: 0, banned: 0, today_new: 0 }
|
||||
initial?.summary ?? {
|
||||
online: 0,
|
||||
in_cooldown: 0,
|
||||
active_7d: 0,
|
||||
failed_logins_24h: 0,
|
||||
total: 0,
|
||||
admins: 0,
|
||||
banned: 0,
|
||||
}
|
||||
);
|
||||
const [total, setTotal] = useState(initial?.total ?? 0);
|
||||
const [page, setPage] = useState(initialQuery.page);
|
||||
@@ -468,37 +478,38 @@ export default function UsersAdmin({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 汇总 */}
|
||||
{/* 汇总:用户运营独有指标(与仪表盘站点总量区分) */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||||
<MiniStat
|
||||
label="注册用户"
|
||||
value={String(summary.total)}
|
||||
sub={summary.today_new > 0 ? `今日 +${summary.today_new}` : undefined}
|
||||
icon={<UsersIcon size={16} />}
|
||||
color="var(--accent)"
|
||||
soft="var(--accent-soft)"
|
||||
label="当前在线"
|
||||
value={String(summary.online)}
|
||||
sub="5 分钟内活跃"
|
||||
icon={<Activity size={16} />}
|
||||
color="var(--ok)"
|
||||
soft="var(--ok-soft)"
|
||||
/>
|
||||
<MiniStat
|
||||
label="管理团队"
|
||||
value={String(summary.admins)}
|
||||
icon={<ShieldCheck size={16} />}
|
||||
label="发帖冷静期中"
|
||||
value={String(summary.in_cooldown)}
|
||||
sub="普通用户"
|
||||
icon={<Hourglass size={16} />}
|
||||
color="var(--gold)"
|
||||
soft="var(--gold-soft)"
|
||||
/>
|
||||
<MiniStat
|
||||
label="已封禁"
|
||||
value={String(summary.banned)}
|
||||
icon={<Ban size={16} />}
|
||||
color="var(--danger)"
|
||||
soft="var(--danger-soft)"
|
||||
label="近 7 日活跃"
|
||||
value={String(summary.active_7d)}
|
||||
icon={<Monitor size={16} />}
|
||||
color="var(--accent)"
|
||||
soft="var(--accent-soft)"
|
||||
/>
|
||||
<MiniStat
|
||||
label="当前筛选"
|
||||
value={String(total)}
|
||||
sub="人"
|
||||
icon={<UserPlus size={16} />}
|
||||
color="var(--ok)"
|
||||
soft="var(--ok-soft)"
|
||||
label="24h 登录失败"
|
||||
value={String(summary.failed_logins_24h)}
|
||||
sub="次"
|
||||
icon={<ShieldAlert size={16} />}
|
||||
color="var(--danger)"
|
||||
soft="var(--danger-soft)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user