544 lines
16 KiB
TypeScript
544 lines
16 KiB
TypeScript
"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>
|
||
);
|
||
}
|