feat(admin): 独立后台壳、站点分析与聊天监管,公开站拆出主题切换与访问埋点
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
543
frontend/app/admin/messages/MessagesAdmin.tsx
Normal file
543
frontend/app/admin/messages/MessagesAdmin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
frontend/app/admin/messages/page.tsx
Normal file
22
frontend/app/admin/messages/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
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 Forbidden from "../Forbidden";
|
||||
import MessagesAdmin from "./MessagesAdmin";
|
||||
|
||||
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="消息管理仅管理团队成员可进入。" />;
|
||||
}
|
||||
|
||||
return <MessagesAdmin />;
|
||||
}
|
||||
Reference in New Issue
Block a user