fix: 空评论列表 500,并完善撤回审计与角色说明
空切片 JSON 统一为数组;撤回保留正文供站长阅读,成员侧居中提示与重新编辑;头像支持拖放;角色弹窗补全职责说明并汇入封禁。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -31,6 +31,7 @@ import {
|
||||
apiRecallChatMessage,
|
||||
type ChatMessage,
|
||||
type ChatRoomMember,
|
||||
type User,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
realtime,
|
||||
@@ -49,6 +50,30 @@ import Avatar from "@/components/Avatar";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
|
||||
|
||||
/** 同一房间已读:进行中去重 + 2s 内合并(滚动/新消息/Strict Mode 连打) */
|
||||
const MARK_READ_GAP_MS = 2_000;
|
||||
const markReadInflight = new Set<number>();
|
||||
const markReadLastAt = new Map<number, number>();
|
||||
|
||||
function requestMarkChatRead(roomId: number): void {
|
||||
const now = Date.now();
|
||||
const last = markReadLastAt.get(roomId) || 0;
|
||||
if (markReadInflight.has(roomId) || now - last < MARK_READ_GAP_MS) return;
|
||||
markReadInflight.add(roomId);
|
||||
markReadLastAt.set(roomId, now);
|
||||
apiMarkChatRead(roomId, 0)
|
||||
.then(() => {
|
||||
window.dispatchEvent(new Event("j13-chat-unread-sync"));
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
markReadInflight.delete(roomId);
|
||||
});
|
||||
}
|
||||
|
||||
/** 成员列表:并发去重(进房 effect 与面板刷新) */
|
||||
const membersInflight = new Set<number>();
|
||||
|
||||
type RightPanel = null | "members" | "settings" | "invite";
|
||||
|
||||
type ConfirmState = {
|
||||
@@ -59,18 +84,84 @@ type ConfirmState = {
|
||||
action: () => Promise<void> | void;
|
||||
} | null;
|
||||
|
||||
function recallLabel(m: ChatMessage, meId: number): string {
|
||||
if (!m.recalled_at) return "";
|
||||
// 操作者不是发送者 → 管理员/监管撤回
|
||||
if (m.recalled_by && m.recalled_by !== m.sender_id) {
|
||||
return "管理员撤回了此消息";
|
||||
/** 撤回提示:本人 / 他人撤回 / 管理员代撤 */
|
||||
function RecallNotice({
|
||||
message,
|
||||
meId,
|
||||
members,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
meId: number;
|
||||
members: ChatRoomMember[];
|
||||
}) {
|
||||
if (!message.recalled_at) return null;
|
||||
const by =
|
||||
message.recalled_by ||
|
||||
message.recaller?.id ||
|
||||
message.sender_id;
|
||||
// 操作者不是原发送者 → 监管代撤
|
||||
const adminRecall = !!(by && by !== message.sender_id);
|
||||
|
||||
if (by === meId) {
|
||||
return (
|
||||
<span>
|
||||
{adminRecall ? "你撤回了一条成员消息" : "你撤回了一条消息"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (m.sender_id === meId || m.recalled_by === meId) {
|
||||
return "你撤回了一条消息";
|
||||
|
||||
let recaller: User | null | undefined =
|
||||
message.recaller && message.recaller.id
|
||||
? message.recaller
|
||||
: by === message.sender_id
|
||||
? message.sender
|
||||
: null;
|
||||
if ((!recaller || !recaller.id) && by > 0) {
|
||||
const hit = members.find((x) => x.user_id === by);
|
||||
if (hit) recaller = hit.user;
|
||||
}
|
||||
return "此消息已撤回";
|
||||
const name =
|
||||
(recaller?.nickname || recaller?.username || "").trim() || "有人";
|
||||
const uid = recaller?.id || by;
|
||||
const nameStyle = {
|
||||
color: "color-mix(in srgb, var(--accent) 72%, var(--ink))",
|
||||
} as const;
|
||||
const nameNode =
|
||||
uid > 0 ? (
|
||||
<Link
|
||||
href={`/u/${uid}`}
|
||||
className="font-semibold transition-opacity hover:opacity-80"
|
||||
style={nameStyle}
|
||||
title={`查看 ${name} 的主页`}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-semibold" style={nameStyle}>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (adminRecall) {
|
||||
return (
|
||||
<span>
|
||||
管理员
|
||||
{nameNode}
|
||||
撤回了一条成员消息
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span>
|
||||
{nameNode}
|
||||
撤回了一条消息
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 撤回后「重新编辑」有效窗口(接近微信体验) */
|
||||
const RE_EDIT_MS = 2 * 60 * 1000;
|
||||
|
||||
interface Props {
|
||||
roomId: number;
|
||||
roomName: string;
|
||||
@@ -236,6 +327,10 @@ export default function ChatRoomClient({
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
/** 从选择器确认插入的 @,在输入区以 token 整体展示/删除 */
|
||||
const [mentionTokens, setMentionTokens] = useState<Set<string>>(() => new Set());
|
||||
/** 本人撤回后的可重新编辑草稿(仅本端内存,不展示正文) */
|
||||
const [reEditDrafts, setReEditDrafts] = useState<
|
||||
Record<number, { text: string; until: number }>
|
||||
>({});
|
||||
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
@@ -250,6 +345,40 @@ export default function ChatRoomClient({
|
||||
const oversee = canOverseeChat(meRole);
|
||||
const canManageInvite = !isDirect && (isOwner || oversee);
|
||||
|
||||
const rememberReEdit = useCallback((id: number, text: string) => {
|
||||
const t = text.trim();
|
||||
if (!t) return;
|
||||
setReEditDrafts((prev) => ({
|
||||
...prev,
|
||||
[id]: { text: t, until: Date.now() + RE_EDIT_MS },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const takeReEditDraft = useCallback((id: number): string | null => {
|
||||
const d = reEditDrafts[id];
|
||||
if (!d || Date.now() > d.until) return null;
|
||||
return d.text;
|
||||
}, [reEditDrafts]);
|
||||
|
||||
// 到期后清掉「重新编辑」,避免按钮残留
|
||||
useEffect(() => {
|
||||
const ids = Object.keys(reEditDrafts);
|
||||
if (ids.length === 0) return;
|
||||
const nearest = Math.min(...Object.values(reEditDrafts).map((d) => d.until));
|
||||
const wait = Math.max(0, nearest - Date.now()) + 50;
|
||||
const t = setTimeout(() => {
|
||||
setReEditDrafts((prev) => {
|
||||
const now = Date.now();
|
||||
const next: typeof prev = {};
|
||||
for (const [k, v] of Object.entries(prev)) {
|
||||
if (v.until > now) next[Number(k)] = v;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, wait);
|
||||
return () => clearTimeout(t);
|
||||
}, [reEditDrafts]);
|
||||
|
||||
const scrollToBottom = useCallback((smooth = false) => {
|
||||
const el = listRef.current;
|
||||
if (!el) return;
|
||||
@@ -261,34 +390,36 @@ export default function ChatRoomClient({
|
||||
|
||||
const refreshMembers = useCallback(async () => {
|
||||
if (!canView) return;
|
||||
if (membersInflight.has(roomId)) return;
|
||||
membersInflight.add(roomId);
|
||||
try {
|
||||
const res = await apiListChatMembers(roomId);
|
||||
setMembers(res.members || []);
|
||||
if (res.members) setMemberCount(res.members.length);
|
||||
} catch {
|
||||
// 静默:@ 功能降级为纯文本
|
||||
} finally {
|
||||
membersInflight.delete(roomId);
|
||||
}
|
||||
}, [roomId, canView]);
|
||||
|
||||
const markReadToLatest = useCallback(() => {
|
||||
if (!isRealMember) return;
|
||||
apiMarkChatRead(roomId, 0)
|
||||
.then(() => {
|
||||
window.dispatchEvent(new Event("j13-chat-unread-sync"));
|
||||
})
|
||||
.catch(() => {});
|
||||
requestMarkChatRead(roomId);
|
||||
}, [roomId, isRealMember]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canView) return;
|
||||
realtime.joinRoom(roomId);
|
||||
refreshMembers();
|
||||
void refreshMembers();
|
||||
requestAnimationFrame(() => scrollToBottom(false));
|
||||
markReadToLatest();
|
||||
return () => {
|
||||
realtime.leaveRoom(roomId);
|
||||
};
|
||||
}, [roomId, canView, refreshMembers, scrollToBottom, markReadToLatest]);
|
||||
// 只随房间切换订阅/首拉;避免回调身份变化重复打 members/read
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [roomId, canView]);
|
||||
|
||||
useEffect(() => {
|
||||
const off = realtime.on<RtChatMessageData>(RT_CHAT_MESSAGE, (data) => {
|
||||
@@ -315,21 +446,34 @@ export default function ChatRoomClient({
|
||||
const off = realtime.on<RtChatRecalledData>(RT_CHAT_RECALLED, (data) => {
|
||||
if (!data || data.room_id !== roomId) return;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === data.id
|
||||
? {
|
||||
...m,
|
||||
content: "",
|
||||
recalled_at: data.recalled_at || new Date().toISOString(),
|
||||
recalled_by: data.recalled_by,
|
||||
sender_id: data.sender_id || m.sender_id,
|
||||
}
|
||||
: m
|
||||
)
|
||||
prev.map((m) => {
|
||||
if (m.id !== data.id) return m;
|
||||
// 本人撤回:草稿留给「重新编辑」,气泡不展示正文
|
||||
if (m.sender_id === meId && m.content) {
|
||||
rememberReEdit(m.id, m.content);
|
||||
}
|
||||
return {
|
||||
...m,
|
||||
// 仅监管界面保留正文;普通成员(含撤回者自己)气泡不展示
|
||||
content: oversee ? m.content : "",
|
||||
recalled_at: data.recalled_at || new Date().toISOString(),
|
||||
recalled_by: data.recalled_by,
|
||||
sender_id: data.sender_id || m.sender_id,
|
||||
recaller: data.recaller
|
||||
? {
|
||||
id: data.recaller.id,
|
||||
username: data.recaller.username,
|
||||
nickname: data.recaller.nickname,
|
||||
avatar: data.recaller.avatar,
|
||||
role: data.recaller.role || "user",
|
||||
}
|
||||
: m.recaller,
|
||||
};
|
||||
})
|
||||
);
|
||||
});
|
||||
return off;
|
||||
}, [roomId]);
|
||||
}, [roomId, oversee, meId, rememberReEdit]);
|
||||
|
||||
useEffect(() => {
|
||||
const off = realtime.on<RtChatMembershipData>(
|
||||
@@ -359,7 +503,7 @@ export default function ChatRoomClient({
|
||||
try {
|
||||
const before = messages[0].id;
|
||||
const res = await apiListChatMessages(roomId, before, 30);
|
||||
if (res.messages.length > 0) {
|
||||
if ((res.messages || []).length > 0) {
|
||||
setMessages((prev) => [...res.messages, ...prev]);
|
||||
setHasMore(res.has_more);
|
||||
requestAnimationFrame(() => {
|
||||
@@ -672,6 +816,7 @@ export default function ChatRoomClient({
|
||||
};
|
||||
|
||||
const handleRecall = (mid: number) => {
|
||||
const snapshot = messages.find((m) => m.id === mid);
|
||||
setCtxMenu(null);
|
||||
setConfirm({
|
||||
title: "撤回消息",
|
||||
@@ -680,14 +825,18 @@ export default function ChatRoomClient({
|
||||
danger: true,
|
||||
action: async () => {
|
||||
const { message } = await apiRecallChatMessage(roomId, mid);
|
||||
const body = (message.content || snapshot?.content || "").trim();
|
||||
const mine = (snapshot?.sender_id ?? message.sender_id) === meId;
|
||||
if (mine && body) rememberReEdit(mid, body);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === mid
|
||||
? {
|
||||
...m,
|
||||
content: "",
|
||||
content: oversee ? body || m.content : "",
|
||||
recalled_at: message.recalled_at || new Date().toISOString(),
|
||||
recalled_by: message.recalled_by,
|
||||
recaller: message.recaller || m.recaller,
|
||||
}
|
||||
: m
|
||||
)
|
||||
@@ -697,6 +846,24 @@ export default function ChatRoomClient({
|
||||
});
|
||||
};
|
||||
|
||||
const handleReEdit = (mid: number) => {
|
||||
const text = takeReEditDraft(mid);
|
||||
if (!text) {
|
||||
toast("重新编辑已过期");
|
||||
return;
|
||||
}
|
||||
setReplyTo(null);
|
||||
setContent(text);
|
||||
requestAnimationFrame(() => {
|
||||
const el = inputRef.current;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.setSelectionRange(text.length, text.length);
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
|
||||
});
|
||||
};
|
||||
|
||||
const scrollToMsg = (id: number) => {
|
||||
const el = msgRefs.current.get(id);
|
||||
el?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
@@ -925,6 +1092,114 @@ export default function ChatRoomClient({
|
||||
new Date(m.created_at).getTime() -
|
||||
new Date(prev.created_at).getTime() <
|
||||
5 * 60_000;
|
||||
const reEditText = mine ? takeReEditDraft(m.id) : null;
|
||||
|
||||
// 普通成员:居中系统提示(本人可重新编辑);不展示原文
|
||||
if (recalled && !(oversee && m.content)) {
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
ref={(node) => {
|
||||
if (node) msgRefs.current.set(m.id, node);
|
||||
else msgRefs.current.delete(m.id);
|
||||
}}
|
||||
className="flex justify-center px-2 py-0.5"
|
||||
>
|
||||
<p
|
||||
className="text-[12px] leading-relaxed text-center max-w-[90%]"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
<RecallNotice
|
||||
message={m}
|
||||
meId={meId}
|
||||
members={members}
|
||||
/>
|
||||
{reEditText ? (
|
||||
<>
|
||||
{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleReEdit(m.id)}
|
||||
className="font-semibold transition-opacity hover:opacity-80"
|
||||
style={{
|
||||
color:
|
||||
"color-mix(in srgb, var(--accent) 72%, var(--ink))",
|
||||
}}
|
||||
>
|
||||
重新编辑
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 站长/超管:单气泡呈现撤回原文(可读,非双气泡)
|
||||
if (recalled && oversee && m.content) {
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
ref={(node) => {
|
||||
if (node) msgRefs.current.set(m.id, node);
|
||||
else msgRefs.current.delete(m.id);
|
||||
}}
|
||||
className={`flex gap-2.5 ${mine ? "flex-row-reverse" : ""}`}
|
||||
>
|
||||
<div className="w-8 shrink-0">
|
||||
<Avatar
|
||||
name={m.sender.nickname || m.sender.username}
|
||||
src={m.sender.avatar || undefined}
|
||||
size={32}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-col min-w-0 max-w-[78%] ${
|
||||
mine ? "items-end" : "items-start"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{!isDirect && m.sender_id === ownerId && (
|
||||
<Crown size={11} style={{ color: "var(--gold)" }} />
|
||||
)}
|
||||
<span
|
||||
className="text-[12px] font-semibold truncate"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
>
|
||||
{m.sender.nickname}
|
||||
</span>
|
||||
<span className="meta text-[10.5px]">
|
||||
{formatTime(m.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="px-3.5 py-2.5 rounded-2xl text-[14px] leading-[1.55] break-words whitespace-pre-wrap select-text w-full"
|
||||
style={{
|
||||
background: "var(--panel-2)",
|
||||
border:
|
||||
"1px solid color-mix(in srgb, var(--line) 80%, var(--ink-3))",
|
||||
color: "var(--ink)",
|
||||
borderBottomRightRadius: mine ? 4 : undefined,
|
||||
borderBottomLeftRadius: mine ? undefined : 4,
|
||||
opacity: 0.92,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="inline-flex items-center gap-1 mb-1.5 text-[11px] font-semibold"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
<Undo2 size={11} strokeWidth={2.4} />
|
||||
已撤回
|
||||
</div>
|
||||
<div style={{ color: "var(--ink-2)" }}>
|
||||
{highlightMentions(m.content, members)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
@@ -964,68 +1239,56 @@ export default function ChatRoomClient({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{recalled ? (
|
||||
<div
|
||||
className="px-3 py-2 rounded-xl text-[13px] italic"
|
||||
style={{
|
||||
background: "var(--panel-2)",
|
||||
color: "var(--ink-3)",
|
||||
}}
|
||||
>
|
||||
{recallLabel(m, meId)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="px-3.5 py-2.5 rounded-2xl text-[14px] leading-[1.55] break-words whitespace-pre-wrap select-text"
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openMsgMenu(m, e.clientX, e.clientY);
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
const t = e.touches[0];
|
||||
if (!t) return;
|
||||
clearLongPress();
|
||||
longPressTimer.current = setTimeout(() => {
|
||||
openMsgMenu(m, t.clientX, t.clientY);
|
||||
}, 480);
|
||||
}}
|
||||
onTouchEnd={clearLongPress}
|
||||
onTouchMove={clearLongPress}
|
||||
style={
|
||||
mine
|
||||
? {
|
||||
background:
|
||||
"color-mix(in srgb, var(--accent) 14%, var(--panel))",
|
||||
color: "var(--ink)",
|
||||
borderBottomRightRadius: 4,
|
||||
}
|
||||
: {
|
||||
background: "var(--tint)",
|
||||
color: "var(--ink)",
|
||||
borderBottomLeftRadius: 4,
|
||||
}
|
||||
}
|
||||
>
|
||||
{!!m.reply_to_id && m.reply_snap && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollToMsg(m.reply_to_id!)}
|
||||
className="block w-full text-left mb-1.5 px-2 py-1.5 rounded-lg text-[12px] leading-snug"
|
||||
style={{
|
||||
background: mine
|
||||
? "color-mix(in srgb, var(--accent) 10%, transparent)"
|
||||
: "var(--bg)",
|
||||
borderLeft: "3px solid var(--accent)",
|
||||
color: "var(--ink-2)",
|
||||
}}
|
||||
>
|
||||
{m.reply_snap}
|
||||
</button>
|
||||
)}
|
||||
{highlightMentions(m.content, members)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="px-3.5 py-2.5 rounded-2xl text-[14px] leading-[1.55] break-words whitespace-pre-wrap select-text"
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openMsgMenu(m, e.clientX, e.clientY);
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
const t = e.touches[0];
|
||||
if (!t) return;
|
||||
clearLongPress();
|
||||
longPressTimer.current = setTimeout(() => {
|
||||
openMsgMenu(m, t.clientX, t.clientY);
|
||||
}, 480);
|
||||
}}
|
||||
onTouchEnd={clearLongPress}
|
||||
onTouchMove={clearLongPress}
|
||||
style={
|
||||
mine
|
||||
? {
|
||||
background:
|
||||
"color-mix(in srgb, var(--accent) 14%, var(--panel))",
|
||||
color: "var(--ink)",
|
||||
borderBottomRightRadius: 4,
|
||||
}
|
||||
: {
|
||||
background: "var(--tint)",
|
||||
color: "var(--ink)",
|
||||
borderBottomLeftRadius: 4,
|
||||
}
|
||||
}
|
||||
>
|
||||
{!!m.reply_to_id && m.reply_snap && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollToMsg(m.reply_to_id!)}
|
||||
className="block w-full text-left mb-1.5 px-2 py-1.5 rounded-lg text-[12px] leading-snug"
|
||||
style={{
|
||||
background: mine
|
||||
? "color-mix(in srgb, var(--accent) 10%, transparent)"
|
||||
: "var(--bg)",
|
||||
borderLeft: "3px solid var(--accent)",
|
||||
color: "var(--ink-2)",
|
||||
}}
|
||||
>
|
||||
{m.reply_snap}
|
||||
</button>
|
||||
)}
|
||||
{highlightMentions(m.content, members)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user