Files
jiang13-bbs/frontend/app/admin/users/[id]/UserAuditClient.tsx
freefire 7a44c1f2cf feat: 可配置站点 URL 风格
- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接
- 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配
- 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成
- middleware 与相关组件适配
2026-09-27 04:01:42 +08:00

425 lines
14 KiB
TypeScript

"use client";
import { useCallback, useRef, useState } from "react";
import Link from "next/link";
import { ArrowLeft, ExternalLink, FileText, MessageCircle, MessagesSquare } from "lucide-react";
import {
apiAdminUserAuditComments,
apiAdminUserAuditMessages,
apiAdminUserAuditPosts,
type AdminAuditCommentItem,
type AdminAuditMessageItem,
type AdminAuditPostItem,
type AdminUserAuditProfile,
} from "@/lib/api";
import { formatDate, formatRelative } from "@/lib/format";
import Avatar from "@/components/Avatar";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import RoleBadge from "@/components/RoleBadge";
import { postPath, userPath } from "@/lib/urlStyle";
import {
AdminEmpty,
AdminPager,
AdminSegmented,
AdminStatusChip,
type AdminStatusTone,
} from "@/components/admin";
type TabKey = "posts" | "comments" | "messages";
const PAGE_SIZE = 20;
/** 内容/消息状态粒:可叠加(如已删除 + 待审) */
function StatusChips({
status,
deleted,
recalled,
}: {
status?: string;
deleted?: boolean;
recalled?: boolean;
}) {
const chips: { label: string; tone: AdminStatusTone }[] = [];
if (deleted) chips.push({ label: "已删除", tone: "danger" });
if (recalled) chips.push({ label: "已撤回", tone: "muted" });
if (status === "pending") chips.push({ label: "待审", tone: "warn" });
if (status === "rejected") chips.push({ label: "已驳回", tone: "danger" });
if (status === "published" && !deleted) {
chips.push({ label: "已发布", tone: "ok" });
}
if (chips.length === 0 && !recalled) {
chips.push({ label: "正常", tone: "muted" });
}
return (
<span className="inline-flex flex-wrap gap-1">
{chips.map((c) => (
<AdminStatusChip key={c.label} tone={c.tone}>
{c.label}
</AdminStatusChip>
))}
</span>
);
}
export default function UserAuditClient({
userId,
initial,
initialPosts,
initialPostsTotal,
initError,
}: {
userId: number;
initial: AdminUserAuditProfile;
initialPosts: AdminAuditPostItem[];
initialPostsTotal: number;
initError: string;
}) {
const urlStyle = useUrlStyle();
const [profile] = useState(initial);
const [tab, setTab] = useState<TabKey>("posts");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(initError);
const [posts, setPosts] = useState(initialPosts);
const [comments, setComments] = useState<AdminAuditCommentItem[]>([]);
const [messages, setMessages] = useState<AdminAuditMessageItem[]>([]);
const [postsTotal, setPostsTotal] = useState(initialPostsTotal);
const [commentsTotal, setCommentsTotal] = useState(0);
const [messagesTotal, setMessagesTotal] = useState(0);
const [postsPage, setPostsPage] = useState(1);
const [commentsPage, setCommentsPage] = useState(1);
const [messagesPage, setMessagesPage] = useState(1);
const loadSeq = useRef(0);
const loadedRef = useRef({ posts: true, comments: false, messages: false });
const u = profile.user;
const loadPosts = useCallback(
async (p: number) => {
const seq = ++loadSeq.current;
setLoading(true);
try {
const res = await apiAdminUserAuditPosts(userId, p);
if (seq !== loadSeq.current) return;
setPosts(res.items || []);
setPostsTotal(res.total);
setPostsPage(p);
loadedRef.current.posts = true;
setTab("posts");
setError("");
} catch (e) {
if (seq !== loadSeq.current) return;
setError(e instanceof Error ? e.message : "加载失败");
} finally {
if (seq === loadSeq.current) setLoading(false);
}
},
[userId]
);
const loadComments = useCallback(
async (p: number) => {
const seq = ++loadSeq.current;
setLoading(true);
try {
const res = await apiAdminUserAuditComments(userId, p);
if (seq !== loadSeq.current) return;
setComments(res.items || []);
setCommentsTotal(res.total);
setCommentsPage(p);
loadedRef.current.comments = true;
setTab("comments");
setError("");
} catch (e) {
if (seq !== loadSeq.current) return;
setError(e instanceof Error ? e.message : "加载失败");
} finally {
if (seq === loadSeq.current) setLoading(false);
}
},
[userId]
);
const loadMessages = useCallback(
async (p: number) => {
const seq = ++loadSeq.current;
setLoading(true);
try {
const res = await apiAdminUserAuditMessages(userId, p);
if (seq !== loadSeq.current) return;
setMessages(res.items || []);
setMessagesTotal(res.total);
setMessagesPage(p);
loadedRef.current.messages = true;
setTab("messages");
setError("");
} catch (e) {
if (seq !== loadSeq.current) return;
setError(e instanceof Error ? e.message : "加载失败");
} finally {
if (seq === loadSeq.current) setLoading(false);
}
},
[userId]
);
const switchTab = (t: TabKey) => {
if (t === tab) return;
setError("");
if (loadedRef.current[t]) {
loadSeq.current += 1;
setLoading(false);
setTab(t);
return;
}
if (t === "posts") void loadPosts(1);
else if (t === "comments") void loadComments(1);
else void loadMessages(1);
};
const goPage = (p: number) => {
if (tab === "posts") void loadPosts(p);
else if (tab === "comments") void loadComments(p);
else void loadMessages(p);
};
const tabCount = (t: TabKey) => {
if (t === "posts") return profile.posts_total;
if (t === "comments") return profile.comments_total;
return profile.messages_total;
};
const page = tab === "posts" ? postsPage : tab === "comments" ? commentsPage : messagesPage;
const total = tab === "posts" ? postsTotal : tab === "comments" ? commentsTotal : messagesTotal;
const empty =
tab === "posts" ? posts.length === 0 : tab === "comments" ? comments.length === 0 : messages.length === 0;
const emptyIcon =
tab === "posts" ? (
<FileText size={22} />
) : tab === "comments" ? (
<MessageCircle size={22} />
) : (
<MessagesSquare size={22} />
);
return (
<div>
<Link
href="/admin/users"
className="inline-flex items-center gap-1.5 text-[13px] mb-4 hover:opacity-80"
style={{ color: "var(--accent)" }}
>
<ArrowLeft size={14} />
返回用户列表
</Link>
<div className="panel p-4 sm:p-5 mb-4">
<div className="flex items-start gap-3.5">
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={48} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h1
className="text-[18px] sm:text-[20px] font-extrabold truncate"
style={{ color: "var(--ink)" }}
>
{u.nickname}
</h1>
<RoleBadge role={u.role} />
{u.banned && <AdminStatusChip tone="danger">已封禁</AdminStatusChip>}
</div>
<p className="meta text-[12.5px] mt-0.5 truncate">@{u.username}</p>
<p className="meta text-[12px] mt-2">
注册于 {formatDate(u.created_at)} · 已发布帖 {profile.published_posts} / 评{" "}
{profile.published_comments}
</p>
</div>
<Link
href={userPath(urlStyle, u.id)}
className="btn btn-line btn-sm gap-1.5 shrink-0"
target="_blank"
>
<ExternalLink size={13} />
主页
</Link>
</div>
<div
className="mt-4 grid grid-cols-3 gap-2 sm:gap-3 pt-4"
style={{ borderTop: "1px solid var(--line)" }}
>
{[
{ label: "全部帖子", value: profile.posts_total },
{ label: "全部评论", value: profile.comments_total },
{ label: "全部消息", value: profile.messages_total },
].map((c) => (
<div key={c.label} className="text-center">
<p
className="text-[20px] font-extrabold tabular-nums leading-none"
style={{ color: "var(--ink)" }}
>
{c.value.toLocaleString("zh-CN")}
</p>
<p className="meta text-[11.5px] mt-1.5">{c.label}</p>
</div>
))}
</div>
</div>
<div className="panel overflow-hidden">
<div className="admin-toolbar">
<AdminSegmented<TabKey>
ariaLabel="内容分类"
value={tab}
onChange={switchTab}
options={[
{
key: "posts",
label: "帖子",
icon: <FileText size={14} />,
count: tabCount("posts"),
},
{
key: "comments",
label: "评论",
icon: <MessageCircle size={14} />,
count: tabCount("comments"),
},
{
key: "messages",
label: "私信与群消息",
icon: <MessagesSquare size={14} />,
count: tabCount("messages"),
},
]}
/>
</div>
{error && (
<p className="text-center py-10 text-[13px]" style={{ color: "var(--danger)" }}>
{error}
</p>
)}
{!error && empty && <AdminEmpty icon={emptyIcon} title="该分类暂无内容" />}
{tab === "posts" &&
posts.map((p) => (
<div
key={p.id}
className="px-4 sm:px-5 py-3.5"
style={{ borderTop: "1px solid var(--line)" }}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap mb-1">
<StatusChips status={p.status} deleted={p.deleted} />
{p.board_name && (
<span className="text-[11.5px] meta truncate">{p.board_name}</span>
)}
</div>
{p.deleted ? (
<p className="text-[14px] font-medium" style={{ color: "var(--ink-3)" }}>
{p.title || "(无标题)"}
</p>
) : (
<Link
href={postPath(urlStyle, p.id)}
className="text-[14px] font-medium hover:underline"
style={{ color: "var(--ink)" }}
>
{p.title || "(无标题)"}
</Link>
)}
{p.content && (
<p className="meta text-[12.5px] mt-1 line-clamp-2 leading-relaxed">
{p.content}
</p>
)}
</div>
<span className="shrink-0 text-[11.5px] meta tabular-nums">
{formatRelative(p.created_at)}
</span>
</div>
</div>
))}
{tab === "comments" &&
comments.map((c) => (
<div
key={c.id}
className="px-4 sm:px-5 py-3.5"
style={{ borderTop: "1px solid var(--line)" }}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap mb-1">
<StatusChips status={c.status} deleted={c.deleted} />
{c.deleted ? (
<span className="text-[11.5px] meta">帖子 #{c.post_id}</span>
) : (
<Link
href={`${postPath(urlStyle, c.post_id)}#comment-${c.id}`}
className="text-[11.5px] hover:underline"
style={{ color: "var(--accent)" }}
>
帖子 #{c.post_id}
</Link>
)}
</div>
<p
className="text-[13.5px] leading-relaxed whitespace-pre-wrap break-words"
style={{ color: c.deleted ? "var(--ink-3)" : "var(--ink)" }}
>
{c.content || "(空)"}
</p>
</div>
<span className="shrink-0 text-[11.5px] meta tabular-nums">
{formatRelative(c.created_at)}
</span>
</div>
</div>
))}
{tab === "messages" &&
messages.map((m) => (
<div
key={m.id}
className="px-4 sm:px-5 py-3.5"
style={{ borderTop: "1px solid var(--line)" }}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap mb-1">
<StatusChips deleted={m.deleted} recalled={m.recalled} />
<span className="text-[11.5px] meta truncate">
{m.room_name}
{m.room_type === "direct" ? " · 私聊" : m.room_type === "group" ? " · 群聊" : ""}
</span>
</div>
<p
className="text-[13.5px] leading-relaxed whitespace-pre-wrap break-words"
style={{
color: m.recalled || m.deleted ? "var(--ink-3)" : "var(--ink)",
fontStyle: m.recalled ? "italic" : undefined,
}}
>
{m.content || "(空)"}
</p>
</div>
<span className="shrink-0 text-[11.5px] meta tabular-nums">
{formatRelative(m.created_at)}
</span>
</div>
</div>
))}
<AdminPager
page={page}
total={total}
pageSize={PAGE_SIZE}
loading={loading}
onChange={goPage}
/>
</div>
</div>
);
}