"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, 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"; 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 (

{showToggle ? ( ) : null}
); } function ColumnShell({ icon, title, count, toolbar, body, pager, className = "", }: { icon: ReactNode; title: string; count?: number; toolbar?: ReactNode; body: ReactNode; pager?: ReactNode; className?: string; }) { return (
{icon}

{title}

{typeof count === "number" ? ( {count} ) : null}
{toolbar}
{body}
{pager}
); } 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 ( {sender.name} {peer ? ( <> → {peer.name} ) : null} ); } function RecallButton({ busy, onClick, }: { busy: boolean; onClick: () => void; }) { return ( ); } function useMessageColumn(roomType: MsgKind) { const [messages, setMessages] = useState([]); 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({ me }: { me: User }) { const canOversee = canOverseeChat(me.role); const [mobileKind, setMobileKind] = useState("group"); const group = useMessageColumn("group"); const direct = useMessageColumn("direct"); const [actingKey, setActingKey] = useState(null); const [confirm, setConfirm] = useState<{ title: string; message: string; run: () => Promise; } | 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 = (

含全站大厅与普通群聊

void group.load(1, group.keyword.trim())} onClear={() => { group.setKeyword(""); void group.load(1, ""); }} placeholder="搜索内容、群名或用户…" ariaLabel="搜索群聊消息" />
); const directToolbar = (

一对一私信;按双方会话归档

void direct.load(1, direct.keyword.trim())} onClear={() => { direct.setKeyword(""); void direct.load(1, ""); }} placeholder="搜索内容或参与者…" ariaLabel="搜索私聊消息" />
); const groupBody = ( <> {group.error ?

{group.error}

: null} {group.loading && group.messages.length === 0 ? (
) : group.messages.length === 0 ? ( } title="暂无群聊消息" description="大厅与群聊消息会按时间出现在这里" /> ) : ( group.messages.map((m, idx) => { const key = `m-${m.id}`; const busy = actingKey === key; const recalled = !!m.recalled_at; return (
{m.sender_name || "?"} {recalled ? 已撤回 : null} {m.is_default ? ( 大厅 ) : ( {m.room_name} )} {formatRelative(m.created_at)}
{!recalled ? (
askRecall(m, group.markRecalled)} />
) : null}
); }) )} ); const directBody = ( <> {direct.error ?

{direct.error}

: null} {direct.loading && direct.messages.length === 0 ? (
) : direct.messages.length === 0 ? ( } 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 (
{recalled ? 已撤回 : null} {formatRelative(m.created_at)}
{!recalled ? (
askRecall(m, direct.markRecalled)} />
) : null}
); }) )} ); const groupCol = ( } title="群聊" count={!group.loading || group.total > 0 ? group.total : undefined} toolbar={groupToolbar} body={groupBody} pager={ void group.load(p, group.appliedQ)} className={pagerClass} /> } className={mobileKind === "group" ? "max-lg:flex" : "max-lg:hidden"} /> ); const directCol = ( } title="私聊" count={!direct.loading || direct.total > 0 ? direct.total : undefined} toolbar={directToolbar} body={directBody} pager={ void direct.load(p, direct.appliedQ)} className={pagerClass} /> } className={mobileKind === "direct" ? "max-lg:flex" : "max-lg:hidden"} /> ); return (
{canOversee ? (
) : null}
{groupCol} {canOversee ? directCol : null}
!confirmBusy && setConfirm(null)} onConfirm={() => { if (!confirm) return; setConfirmBusy(true); confirm .run() .catch((e) => toast(e instanceof Error ? e.message : "操作失败")) .finally(() => { setConfirmBusy(false); setConfirm(null); }); }} />
); }