Files
jiang13-bbs/frontend/app/admin/messages/MessagesAdmin.tsx

544 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}