feat(admin): 独立后台壳、站点分析与聊天监管,公开站拆出主题切换与访问埋点

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 04:00:14 +08:00
parent cda5fad2e5
commit 9a55c2f6f2
41 changed files with 5655 additions and 1810 deletions

View File

@@ -0,0 +1,543 @@
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import { Loader2, MessageCircle, MessagesSquare, Undo2, Users } from "lucide-react";
import {
apiAdminChatMessages,
apiAdminRecallChatMessage,
type AdminChatMessage,
type AdminChatRoomType,
} from "@/lib/api";
import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import Avatar from "@/components/Avatar";
import AutolinkText from "@/components/AutolinkText";
import ConfirmDialog from "@/components/ConfirmDialog";
import {
AdminEmpty,
AdminPageHeader,
AdminPager,
AdminSearchField,
AdminStatusChip,
} from "@/components/admin";
const PAGE_SIZE = 15;
const CLAMP_TOGGLE_CHARS = 90;
const pagerClass = "admin-content-col-pager px-3 sm:px-3.5";
type MsgKind = AdminChatRoomType;
function needsClampToggle(text: string): boolean {
if (text.length > CLAMP_TOGGLE_CHARS) return true;
return (text.match(/\n/g) || []).length >= 2;
}
function ClampText({
text,
className = "",
style,
}: {
text: string;
className?: string;
style?: CSSProperties;
}) {
const [open, setOpen] = useState(false);
const showToggle = needsClampToggle(text);
const body = text || "(空)";
return (
<div className={className}>
<p
className={`text-[12.5px] leading-relaxed whitespace-pre-wrap break-words admin-content-clamp${open ? " is-open" : ""}`}
style={style}
>
<AutolinkText text={body} forceNewTab />
</p>
{showToggle ? (
<button
type="button"
className="meta text-[11.5px] mt-0.5 hover:underline"
style={{ color: "var(--accent)" }}
onClick={() => setOpen((v) => !v)}
>
{open ? "收起" : "展开"}
</button>
) : null}
</div>
);
}
function ColumnShell({
icon,
title,
count,
toolbar,
body,
pager,
className = "",
}: {
icon: ReactNode;
title: string;
count?: number;
toolbar?: ReactNode;
body: ReactNode;
pager?: ReactNode;
className?: string;
}) {
return (
<section className={`panel admin-content-col ${className}`.trim()}>
<header
className="admin-content-col-head flex items-center gap-2 px-3.5 sm:px-4 py-3"
style={{ borderBottom: "1px solid var(--line)", background: "var(--panel-2)" }}
>
<span className="shrink-0" style={{ color: "var(--accent)" }} aria-hidden>
{icon}
</span>
<h2 className="text-[14px] font-semibold truncate" style={{ color: "var(--ink)" }}>
{title}
</h2>
{typeof count === "number" ? (
<span className="meta text-[12px] tabular-nums shrink-0">{count}</span>
) : null}
</header>
{toolbar}
<div className="admin-content-col-body">{body}</div>
{pager}
</section>
);
}
type DmPeer = { id: number; name: string; avatar: string };
/** 解析私聊:发送者在前、接收者在后 */
function resolveDmPair(m: AdminChatMessage): { sender: DmPeer; peer: DmPeer | null } {
const sender: DmPeer = {
id: m.sender_id,
name: m.sender_name || "?",
avatar: m.sender_avatar || "",
};
const fromParts = m.participants?.find((p) => p.id === m.sender_id);
if (fromParts) {
sender.name = fromParts.name || sender.name;
sender.avatar = fromParts.avatar || sender.avatar;
}
const peer =
m.participants?.find((p) => p.id !== m.sender_id) ||
m.participants?.find((p) => p.name !== sender.name) ||
null;
return {
sender,
peer: peer
? { id: peer.id, name: peer.name || "?", avatar: peer.avatar || "" }
: null,
};
}
/** 私聊方向条:头像贴在各自名字左侧,发送者在前 */
function DmDirection({
sender,
peer,
}: {
sender: DmPeer;
peer: DmPeer | null;
}) {
return (
<span className="admin-dm-pair-names">
<span className="admin-dm-person">
<Avatar name={sender.name} src={sender.avatar || undefined} size={22} />
<span className="admin-dm-person-name" style={{ color: "var(--ink)" }} data-tip={sender.name}>
{sender.name}
</span>
</span>
{peer ? (
<>
<span className="admin-dm-arrow" aria-hidden>
→
</span>
<span className="admin-dm-person">
<Avatar name={peer.name} src={peer.avatar || undefined} size={22} />
<span className="admin-dm-person-name" style={{ color: "var(--ink-2)" }} data-tip={peer.name}>
{peer.name}
</span>
</span>
</>
) : null}
</span>
);
}
function RecallButton({
busy,
onClick,
}: {
busy: boolean;
onClick: () => void;
}) {
return (
<button type="button" className="admin-content-action is-danger" disabled={busy} onClick={onClick}>
{busy ? <Loader2 size={13} className="animate-spin" /> : <Undo2 size={13} />}
撤回
</button>
);
}
function useMessageColumn(roomType: MsgKind) {
const [messages, setMessages] = useState<AdminChatMessage[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [keyword, setKeyword] = useState("");
const [appliedQ, setAppliedQ] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const seq = useRef(0);
const load = useCallback(
async (p: number, q: string) => {
const s = ++seq.current;
setLoading(true);
try {
const res = await apiAdminChatMessages(p, q, roomType);
if (s !== seq.current) return;
setMessages(Array.isArray(res.messages) ? res.messages : []);
setTotal(typeof res.total === "number" ? res.total : 0);
setPage(res.page || p);
setAppliedQ(q);
setError("");
} catch (e) {
if (s !== seq.current) return;
setError(e instanceof Error ? e.message : "获取消息失败");
} finally {
if (s === seq.current) setLoading(false);
}
},
[roomType]
);
useEffect(() => {
void load(1, "");
return () => {
seq.current += 1;
};
}, [load]);
const markRecalled = (id: number) => {
setMessages((prev) =>
prev.map((x) => (x.id === id ? { ...x, recalled_at: new Date().toISOString() } : x))
);
};
return {
messages,
total,
page,
keyword,
setKeyword,
appliedQ,
loading,
error,
load,
markRecalled,
};
}
/** 群聊(含大厅)| 私聊 双栏监管 */
export default function MessagesAdmin() {
const [mobileKind, setMobileKind] = useState<MsgKind>("group");
const group = useMessageColumn("group");
const direct = useMessageColumn("direct");
const [actingKey, setActingKey] = useState<string | null>(null);
const [confirm, setConfirm] = useState<{
title: string;
message: string;
run: () => Promise<void>;
} | null>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const askRecall = (m: AdminChatMessage, markRecalled: (id: number) => void) => {
const key = `m-${m.id}`;
setConfirm({
title: "撤回这条消息?",
message: "撤回后会话中将显示为已撤回,此操作不可撤销。",
run: async () => {
setActingKey(key);
try {
await apiAdminRecallChatMessage(m.room_id, m.id);
toast("消息已撤回", "ok");
markRecalled(m.id);
} finally {
setActingKey(null);
}
},
});
};
const groupToolbar = (
<div className="admin-content-col-toolbar">
<p className="meta text-[11.5px] leading-snug">含全站大厅与普通群聊</p>
<AdminSearchField
value={group.keyword}
onChange={group.setKeyword}
onSubmit={() => void group.load(1, group.keyword.trim())}
onClear={() => {
group.setKeyword("");
void group.load(1, "");
}}
placeholder="搜索内容、群名或用户…"
ariaLabel="搜索群聊消息"
/>
</div>
);
const directToolbar = (
<div className="admin-content-col-toolbar">
<p className="meta text-[11.5px] leading-snug">一对一私信;按双方会话归档</p>
<AdminSearchField
value={direct.keyword}
onChange={direct.setKeyword}
onSubmit={() => void direct.load(1, direct.keyword.trim())}
onClear={() => {
direct.setKeyword("");
void direct.load(1, "");
}}
placeholder="搜索内容或参与者…"
ariaLabel="搜索私聊消息"
/>
</div>
);
const groupBody = (
<>
{group.error ? <p className="alert-error m-3 text-[12.5px]">{group.error}</p> : null}
{group.loading && group.messages.length === 0 ? (
<div className="flex justify-center py-14">
<Loader2 size={20} className="animate-spin" style={{ color: "var(--ink-3)" }} />
</div>
) : group.messages.length === 0 ? (
<AdminEmpty
icon={<Users size={20} />}
title="暂无群聊消息"
description="大厅与群聊消息会按时间出现在这里"
/>
) : (
group.messages.map((m, idx) => {
const key = `m-${m.id}`;
const busy = actingKey === key;
const recalled = !!m.recalled_at;
return (
<div
key={m.id}
className="flex items-start gap-2.5 px-3 sm:px-3.5 py-3"
style={idx === 0 ? undefined : { borderTop: "1px solid var(--line)" }}
>
<Avatar
name={m.sender_name || "?"}
src={m.sender_avatar || undefined}
size={30}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
<span
className="text-[12.5px] font-medium truncate max-w-[9rem]"
style={{ color: "var(--ink)" }}
>
{m.sender_name || "?"}
</span>
{recalled ? <AdminStatusChip tone="muted">已撤回</AdminStatusChip> : null}
{m.is_default ? (
<AdminStatusChip tone="muted">大厅</AdminStatusChip>
) : (
<span className="meta text-[11px] truncate max-w-[10rem]" data-tip={m.room_name}>
{m.room_name}
</span>
)}
<span className="meta text-[11px] tabular-nums">
{formatRelative(m.created_at)}
</span>
</div>
<ClampText
text={m.content || "(空消息)"}
className="mt-1"
style={{
color: recalled ? "var(--ink-3)" : "var(--ink)",
textDecoration: recalled ? "line-through" : undefined,
}}
/>
{!recalled ? (
<div className="mt-1.5">
<RecallButton busy={busy} onClick={() => askRecall(m, group.markRecalled)} />
</div>
) : null}
</div>
</div>
);
})
)}
</>
);
const directBody = (
<>
{direct.error ? <p className="alert-error m-3 text-[12.5px]">{direct.error}</p> : null}
{direct.loading && direct.messages.length === 0 ? (
<div className="flex justify-center py-14">
<Loader2 size={20} className="animate-spin" style={{ color: "var(--ink-3)" }} />
</div>
) : direct.messages.length === 0 ? (
<AdminEmpty
icon={<MessageCircle size={20} />}
title="暂无私聊消息"
description="用户一对一私信会出现在这里,可搜索参与者"
/>
) : (
direct.messages.map((m, idx) => {
const key = `m-${m.id}`;
const busy = actingKey === key;
const recalled = !!m.recalled_at;
const { sender, peer } = resolveDmPair(m);
return (
<div
key={m.id}
className="admin-dm-row px-3 sm:px-3.5 py-3"
style={idx === 0 ? undefined : { borderTop: "1px solid var(--line)" }}
>
<article className="admin-dm-card">
<header className="admin-dm-card-head">
<div className="admin-dm-pair-meta min-w-0">
<DmDirection sender={sender} peer={peer} />
{recalled ? <AdminStatusChip tone="muted">已撤回</AdminStatusChip> : null}
<span className="meta text-[11px] tabular-nums shrink-0">
{formatRelative(m.created_at)}
</span>
</div>
</header>
<div className="admin-dm-card-body">
<ClampText
text={m.content || "(空消息)"}
style={{
color: recalled ? "var(--ink-3)" : "var(--ink)",
textDecoration: recalled ? "line-through" : undefined,
}}
/>
{!recalled ? (
<div className="mt-1.5">
<RecallButton busy={busy} onClick={() => askRecall(m, direct.markRecalled)} />
</div>
) : null}
</div>
</article>
</div>
);
})
)}
</>
);
const groupCol = (
<ColumnShell
icon={<Users size={15} />}
title="群聊"
count={!group.loading || group.total > 0 ? group.total : undefined}
toolbar={groupToolbar}
body={groupBody}
pager={
<AdminPager
page={group.page}
total={group.total}
pageSize={PAGE_SIZE}
loading={group.loading}
onChange={(p) => void group.load(p, group.appliedQ)}
className={pagerClass}
/>
}
className={mobileKind === "group" ? "max-lg:flex" : "max-lg:hidden"}
/>
);
const directCol = (
<ColumnShell
icon={<MessageCircle size={15} />}
title="私聊"
count={!direct.loading || direct.total > 0 ? direct.total : undefined}
toolbar={directToolbar}
body={directBody}
pager={
<AdminPager
page={direct.page}
total={direct.total}
pageSize={PAGE_SIZE}
loading={direct.loading}
onChange={(p) => void direct.load(p, direct.appliedQ)}
className={pagerClass}
/>
}
className={mobileKind === "direct" ? "max-lg:flex" : "max-lg:hidden"}
/>
);
return (
<div className="admin-content-page">
<AdminPageHeader
icon={MessagesSquare}
title="消息管理"
description="分栏监管群聊与私聊;可搜索并撤回违规内容"
/>
<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>
<div className="admin-content-triptych">
{groupCol}
{directCol}
</div>
<ConfirmDialog
open={!!confirm}
title={confirm?.title || ""}
message={confirm?.message || ""}
danger
busy={confirmBusy}
confirmLabel="确认撤回"
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>
);
}