"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 (
{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() {
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 (
{groupCol}
{directCol}
!confirmBusy && setConfirm(null)}
onConfirm={() => {
if (!confirm) return;
setConfirmBusy(true);
confirm
.run()
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
.finally(() => {
setConfirmBusy(false);
setConfirm(null);
});
}}
/>
);
}