fix: 空评论列表 500,并完善撤回审计与角色说明

空切片 JSON 统一为数组;撤回保留正文供站长阅读,成员侧居中提示与重新编辑;头像支持拖放;角色弹窗补全职责说明并汇入封禁。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 00:23:55 +08:00
parent 42c395a864
commit 8c5de13899
29 changed files with 835 additions and 405 deletions

View File

@@ -22,6 +22,7 @@ import {
type RoleUserLike,
} from "@/lib/roles";
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
import { onDocumentVisible } from "@/lib/visibilityReconcile";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
@@ -85,14 +86,11 @@ function usePendingCounts(enabled: boolean) {
};
load();
const off = realtime.on(RT_MODERATION_CHANGED, () => load());
const onVisible = () => {
if (document.visibilityState === "visible") load();
};
document.addEventListener("visibilitychange", onVisible);
const offVisible = onDocumentVisible(load);
return () => {
alive = false;
off();
document.removeEventListener("visibilitychange", onVisible);
offVisible();
};
}, [enabled]);
return counts.posts + counts.comments;

View File

@@ -32,7 +32,7 @@ import {
type Board,
type LoginLog,
} from "@/lib/api";
import { isOwner, ROLES } from "@/lib/roles";
import { isOwner, ROLES, ROLE_META } from "@/lib/roles";
import { formatDate, formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import { realtime } from "@/lib/realtime";
@@ -60,7 +60,6 @@ interface RoleOption {
value: string;
title: string;
desc: string;
icon: React.ReactNode;
token: string;
}
@@ -131,9 +130,7 @@ export default function UsersAdmin({
const [loading, setLoading] = useState(false);
const [error, setError] = useState(initError);
const [actingId, setActingId] = useState<number | null>(null);
// 封禁二次确认弹窗;null 关闭
const [banTarget, setBanTarget] = useState<AdminUser | null>(null);
// 角色授权弹窗 / 登录历史弹窗
// 角色与账号弹窗 / 登录历史弹窗
const [roleTarget, setRoleTarget] = useState<AdminUser | null>(null);
const [logsTarget, setLogsTarget] = useState<AdminUser | null>(null);
@@ -412,42 +409,33 @@ export default function UsersAdmin({
</Link>
<button
type="button"
title="设置管理角色"
aria-label={`设置 ${u.username} 的管理角色`}
title="角色与账号"
aria-label={`设置 ${u.username} 的角色与账号状态`}
disabled={busy}
onClick={() => setRoleTarget(u)}
className={ghostBtn}
style={u.role === ROLES.USER ? { color: "var(--ink-3)" } : { color: "var(--accent)" }}
style={
u.banned
? { color: "var(--danger)" }
: u.role === ROLES.USER
? { color: "var(--ink-3)" }
: { color: "var(--accent)" }
}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--accent-soft)";
e.currentTarget.style.color = "var(--accent)";
e.currentTarget.style.background = u.banned ? "var(--danger-soft)" : "var(--accent-soft)";
e.currentTarget.style.color = u.banned ? "var(--danger)" : "var(--accent)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "";
e.currentTarget.style.color = u.role === ROLES.USER ? "var(--ink-3)" : "var(--accent)";
e.currentTarget.style.color = u.banned
? "var(--danger)"
: u.role === ROLES.USER
? "var(--ink-3)"
: "var(--accent)";
}}
>
<ShieldCheck size={15} />
</button>
<button
type="button"
title={u.banned ? "解封" : "封禁"}
aria-label={u.banned ? `解封 ${u.username}` : `封禁 ${u.username}`}
disabled={busy}
onClick={() => setBanTarget(u)}
className={ghostBtn}
style={u.banned ? { color: "var(--danger)" } : { color: "var(--ink-3)" }}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--danger-soft)";
e.currentTarget.style.color = "var(--danger)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "";
e.currentTarget.style.color = u.banned ? "var(--danger)" : "var(--ink-3)";
}}
>
{u.banned ? <RotateCcw size={15} /> : <Ban size={15} />}
</button>
</div>
);
};
@@ -475,7 +463,7 @@ export default function UsersAdmin({
用户管理
</h1>
<p className="meta mt-1.5 text-[12.5px]">
检索全部成员,配置管理角色与板块授权;站长账号受保护,操作即时生效
检索全部成员;在「角色与账号」中配置管理角色、板块授权与封禁;站长账号受保护
</p>
</div>
@@ -738,24 +726,16 @@ export default function UsersAdmin({
disabled={actingId === u.id}
onClick={() => setRoleTarget(u)}
className="btn btn-line btn-sm"
style={u.role !== ROLES.USER ? { color: "var(--accent)", borderColor: "var(--accent)" } : undefined}
>
<ShieldCheck size={13} />
角色权限
</button>
<button
type="button"
disabled={actingId === u.id}
onClick={() => setBanTarget(u)}
className="btn btn-sm"
style={
u.banned
? { background: "var(--ok-soft)", color: "var(--ok)" }
: { background: "var(--danger-soft)", color: "var(--danger)" }
? { color: "var(--danger)", borderColor: "var(--danger)" }
: u.role !== ROLES.USER
? { color: "var(--accent)", borderColor: "var(--accent)" }
: undefined
}
>
{u.banned ? <RotateCcw size={13} /> : <Ban size={13} />}
{u.banned ? "解封" : "封禁"}
<ShieldCheck size={13} />
角色与账号
</button>
</>
)}
@@ -811,33 +791,12 @@ export default function UsersAdmin({
setActing={(v) => setActingId(v ? roleTarget?.id ?? null : null)}
/>
<BanConfirmDialog
target={banTarget}
busy={actingId === banTarget?.id}
onCancel={() => setBanTarget(null)}
onConfirm={async () => {
const u = banTarget;
if (!u) return;
setActingId(u.id);
try {
await apiAdminSetUserBan(u.id, !u.banned);
toast(u.banned ? "已解封" : "已封禁", "ok");
setBanTarget(null);
await reload();
} catch (e) {
toast(e instanceof Error ? e.message : "操作失败");
} finally {
setActingId(null);
}
}}
/>
<LoginLogsDialog target={logsTarget} onClose={() => setLogsTarget(null)} />
</div>
);
}
// ===== 角色授权弹窗 =====
// ===== 角色与账号弹窗(角色授权 + 封禁状态) =====
function RoleAssignDialog({
target,
@@ -856,47 +815,35 @@ function RoleAssignDialog({
onSaved: () => void;
setActing: (v: boolean) => void;
}) {
// 可授予角色(按等级从高到低);超管选项仅站长可见
const options: RoleOption[] = [
{
value: ROLES.SUPER_ADMIN,
title: "超级管理员",
desc: "可操作全站后台:用户权限、公告、外观设置、内容审核;站长账号除外",
icon: <ShieldCheck size={18} />,
token: "--accent",
},
{
value: ROLES.ADMIN,
title: "管理员",
desc: "可管理公告,审核全站帖子与评论,可置顶/加精",
icon: <ShieldCheck size={18} />,
token: "--accent",
},
{
value: ROLES.BOARD_ADMIN,
title: "板块管理员",
desc: "仅可审核被授权板块的帖子与评论",
icon: <ShieldCheck size={18} />,
token: "--accent",
},
{
value: ROLES.USER,
title: "普通用户",
desc: "无后台权限;发帖与评论需通过审核",
icon: <UsersIcon size={18} />,
token: "--ink-3",
},
].filter((o) => canGrantSuper || o.value !== ROLES.SUPER_ADMIN);
const grantKeys = [
ROLES.SUPER_ADMIN,
ROLES.ADMIN,
ROLES.BOARD_ADMIN,
ROLES.USER,
] as const;
const options: RoleOption[] = grantKeys
.filter((k) => canGrantSuper || k !== ROLES.SUPER_ADMIN)
.map((k) => {
const meta = ROLE_META[k];
return {
value: k,
title: meta.label,
desc: meta.grantDesc || "",
token: meta.colorToken,
};
});
const [role, setRole] = useState<string>(ROLES.USER);
const [boardIds, setBoardIds] = useState<number[]>([]);
const [banned, setBanned] = useState(false);
const [err, setErr] = useState("");
// 每次打开按目标当前角色/授权初始化
useEffect(() => {
if (target) {
setRole(target.role === ROLES.OWNER ? ROLES.USER : target.role);
setBoardIds(target.board_ids ?? []);
setBanned(!!target.banned);
setErr("");
}
}, [target]);
@@ -916,15 +863,36 @@ function RoleAssignDialog({
setBoardIds((prev) => (prev.includes(id) ? prev.filter((b) => b !== id) : [...prev, id]));
};
const sameBoards =
JSON.stringify([...(target.board_ids ?? [])].sort((a, b) => a - b)) ===
JSON.stringify([...boardIds].sort((a, b) => a - b));
const roleChanged = role !== target.role || (role === ROLES.BOARD_ADMIN && !sameBoards);
const banChanged = banned !== !!target.banned;
const willBan = banChanged && banned;
const willUnban = banChanged && !banned;
const submit = async () => {
if (role === ROLES.BOARD_ADMIN && boardIds.length === 0) {
setErr("请至少选择一个授权板块");
return;
}
if (!roleChanged && !banChanged) {
onClose();
return;
}
setActing(true);
try {
await apiAdminSetUserRole(target.id, role, role === ROLES.BOARD_ADMIN ? boardIds : []);
toast("角色权限已更新", "ok");
if (roleChanged) {
await apiAdminSetUserRole(target.id, role, role === ROLES.BOARD_ADMIN ? boardIds : []);
}
if (banChanged) {
await apiAdminSetUserBan(target.id, banned);
}
const parts: string[] = [];
if (roleChanged) parts.push("角色已更新");
if (willBan) parts.push("已封禁");
if (willUnban) parts.push("已解封");
toast(parts.join(" · ") || "已保存", "ok");
onSaved();
} catch (e) {
toast(e instanceof Error ? e.message : "操作失败");
@@ -933,6 +901,8 @@ function RoleAssignDialog({
}
};
const saveLabel = willBan ? "保存并封禁" : willUnban ? "保存并解封" : "保存";
return (
<div
className="fixed inset-0 z-[1000] flex items-center justify-center p-4"
@@ -940,17 +910,17 @@ function RoleAssignDialog({
onClick={() => !acting && onClose()}
>
<div
className="j13-toast-in panel w-full max-w-[460px] rounded-2xl p-6 max-h-[88vh] overflow-y-auto"
className="j13-toast-in panel w-full max-w-[500px] rounded-2xl p-6 max-h-[88vh] overflow-y-auto"
style={{ boxShadow: "var(--shadow-lg)" }}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="设置管理角色"
aria-label="角色与账号"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-[16px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
设置管理角色
角色与账号
</h3>
<p className="meta mt-1 text-[12.5px] truncate">
@{target.username}({target.nickname})
@@ -968,7 +938,14 @@ function RoleAssignDialog({
</button>
</div>
<div className="mt-4 flex flex-col gap-2" role="radiogroup" aria-label="管理角色">
<p className="mt-3 text-[12px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
角色决定后台入口与前台管理能力;封禁立即强制下线。登录历史、站长内容档案仍走列表其他入口。
</p>
<p className="mt-4 mb-2 text-[12.5px] font-bold" style={{ color: "var(--ink)" }}>
管理角色
</p>
<div className="flex flex-col gap-2" role="radiogroup" aria-label="管理角色">
{options.map((o) => {
const active = role === o.value;
return (
@@ -981,7 +958,7 @@ function RoleAssignDialog({
setRole(o.value);
setErr("");
}}
className="text-left rounded-xl p-3 flex items-start gap-3 transition-colors"
className="text-left rounded-xl p-3.5 flex items-start gap-3 transition-colors"
style={{
border: `1.5px solid ${active ? "var(--accent)" : "var(--line)"}`,
background: active ? "var(--accent-soft)" : "var(--panel-2)",
@@ -995,13 +972,13 @@ function RoleAssignDialog({
}}
aria-hidden="true"
>
{o.icon}
{o.value === ROLES.USER ? <UsersIcon size={18} /> : <ShieldCheck size={18} />}
</span>
<span className="min-w-0 flex-1">
<span className="block text-[13.5px] font-bold" style={{ color: "var(--ink)" }}>
{o.title}
</span>
<span className="block mt-0.5 text-[12px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
<span className="block mt-1 text-[12px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
{o.desc}
</span>
</span>
@@ -1017,7 +994,6 @@ function RoleAssignDialog({
})}
</div>
{/* 板块授权(仅板块管理员) */}
{role === ROLES.BOARD_ADMIN && (
<div className="mt-4">
<p className="text-[12.5px] font-bold mb-2" style={{ color: "var(--ink)" }}>
@@ -1060,111 +1036,89 @@ function RoleAssignDialog({
})}
</div>
)}
{err && <p className="mt-2 text-[12px] font-semibold" style={{ color: "var(--danger)" }}>{err}</p>}
{err && (
<p className="mt-2 text-[12px] font-semibold" style={{ color: "var(--danger)" }}>
{err}
</p>
)}
</div>
)}
<div className="mt-5 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
<p className="text-[12.5px] font-bold mb-2" style={{ color: "var(--ink)" }}>
账号状态
</p>
<div className="grid grid-cols-2 gap-2" role="radiogroup" aria-label="账号状态">
<button
type="button"
role="radio"
aria-checked={!banned}
onClick={() => setBanned(false)}
className="rounded-xl px-3 py-2.5 text-left transition-colors"
style={{
border: `1.5px solid ${!banned ? "var(--ok)" : "var(--line)"}`,
background: !banned ? "var(--ok-soft)" : "var(--panel-2)",
}}
>
<span
className="flex items-center gap-1.5 text-[13px] font-bold"
style={{ color: !banned ? "var(--ok)" : "var(--ink)" }}
>
<RotateCcw size={14} /> 正常
</span>
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
可登录与互动
</span>
</button>
<button
type="button"
role="radio"
aria-checked={banned}
onClick={() => setBanned(true)}
className="rounded-xl px-3 py-2.5 text-left transition-colors"
style={{
border: `1.5px solid ${banned ? "var(--danger)" : "var(--line)"}`,
background: banned ? "var(--danger-soft)" : "var(--panel-2)",
}}
>
<span
className="flex items-center gap-1.5 text-[13px] font-bold"
style={{ color: banned ? "var(--danger)" : "var(--ink)" }}
>
<Ban size={14} /> 封禁
</span>
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
强制下线,禁止登录
</span>
</button>
</div>
{willBan && (
<p className="mt-2 text-[12px] leading-relaxed" style={{ color: "var(--danger)" }}>
封禁后立即强制下线,无法登录、发帖与回复;已发布内容保留。角色变更与封禁将一并保存。
</p>
)}
</div>
<div className="flex items-center justify-end gap-2 mt-5">
<button type="button" className="btn btn-line" onClick={onClose} disabled={acting}>
取消
</button>
<button type="button" className="btn" onClick={submit} disabled={acting}>
{acting && <Loader2 size={15} className="animate-spin" />}
保存角色
</button>
</div>
</div>
</div>
);
}
// ===== 封禁确认弹窗 =====
function BanConfirmDialog({
target,
busy,
onCancel,
onConfirm,
}: {
target: AdminUser | null;
busy: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
useEffect(() => {
if (!target) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !busy) onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [target, busy, onCancel]);
if (!target) return null;
const banning = !target.banned;
return (
<div
className="fixed inset-0 z-[1000] flex items-center justify-center p-4"
style={{ background: "color-mix(in srgb, var(--ink) 55%, transparent)" }}
onClick={() => !busy && onCancel()}
>
<div
className="j13-toast-in panel w-full max-w-[400px] rounded-2xl p-6"
style={{ boxShadow: "var(--shadow-lg)" }}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
<div className="flex items-start gap-4">
<span
className="w-12 h-12 rounded-full flex items-center justify-center shrink-0"
style={{
background: banning ? "var(--danger-soft)" : "var(--ok-soft)",
color: banning ? "var(--danger)" : "var(--ok)",
}}
aria-hidden="true"
<button
type="button"
className={willBan ? "btn btn-danger" : "btn"}
onClick={submit}
disabled={acting}
>
{banning ? <Ban size={23} /> : <RotateCcw size={21} />}
</span>
<div className="min-w-0 flex-1">
<h3 className="text-[16px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
{banning ? "封禁账号" : "解除封禁"}
</h3>
<p className="meta mt-1.5 text-[13px] leading-relaxed">
{banning
? `封禁后 @${target.username} 将被立即强制下线,无法登录、发帖与回复;其已发布内容保留。`
: `解除后 @${target.username} 可以重新登录,并恢复发帖、回复等全部功能。`}
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-5">
<button type="button" className="btn btn-line" onClick={onCancel} disabled={busy} autoFocus>
取消
{acting && <Loader2 size={15} className="animate-spin" />}
{saveLabel}
</button>
{banning ? (
<button type="button" className="btn btn-danger" onClick={onConfirm} disabled={busy}>
{busy && <Loader2 size={15} className="animate-spin" />}
确认封禁
</button>
) : (
<button
type="button"
className="btn"
style={{ background: "var(--ok-soft)", color: "var(--ok)" }}
onClick={onConfirm}
disabled={busy}
>
{busy && <Loader2 size={15} className="animate-spin" />}
解除封禁
</button>
)}
</div>
</div>
</div>
);
}
// ===== 登录历史弹窗 =====
function LoginLogsDialog({ target, onClose }: { target: AdminUser | null; onClose: () => void }) {

View File

@@ -22,6 +22,7 @@ import {
} from "@/lib/api";
import { realtime, RT_CHAT_UNREAD, RT_CHAT_MEMBERSHIP } from "@/lib/realtime";
import type { RtChatUnreadData, RtChatMembershipData } from "@/lib/realtime";
import { onDocumentVisible } from "@/lib/visibilityReconcile";
import { toast } from "@/lib/toast";
import Avatar from "@/components/Avatar";
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
@@ -114,28 +115,35 @@ export default function ChatShell({
setRooms(sortRooms(initialRooms));
}, [initialRooms]);
const refreshRooms = useCallback(async () => {
const roomsInFlight = useRef(false);
const lastRoomsFetchAt = useRef(0);
const refreshRooms = useCallback(async (opts?: { force?: boolean }) => {
const now = Date.now();
if (!opts?.force) {
if (roomsInFlight.current) return;
// 切房/回前台可合并;建群、被邀请等走 force
if (now - lastRoomsFetchAt.current < 2_000) return;
}
roomsInFlight.current = true;
lastRoomsFetchAt.current = now;
try {
const res = await apiListChatRooms(1, 50, "", "joined");
setRooms(sortRooms(res.rooms || []));
} catch {
// 静默
} finally {
roomsInFlight.current = false;
}
}, []);
// 每次进入消息壳都用 HTTP 拉最新排序(避免首页 Link 预取/路由缓存导致新消息会话不置顶)
// 进入消息壳 / 切会话:HTTP 拉最新排序(避开预取缓存)
useEffect(() => {
refreshRooms();
void refreshRooms();
}, [refreshRooms, activeRoomId]);
// 从其他标签页切回时也对账一次
useEffect(() => {
const onVisible = () => {
if (document.visibilityState === "visible") refreshRooms();
};
document.addEventListener("visibilitychange", onVisible);
return () => document.removeEventListener("visibilitychange", onVisible);
}, [refreshRooms]);
// 从其他标签页切回时对账(10s 限频 + 上方 2s 合并)
useEffect(() => onDocumentVisible(() => void refreshRooms()), [refreshRooms]);
// 实时未读:更新侧栏角标与预览,按置顶规则重排(不盲目置顶到最前)
useEffect(() => {
@@ -145,7 +153,7 @@ export default function ChatShell({
setRooms((prev) => {
const idx = prev.findIndex((r) => r.id === data.room_id);
if (idx < 0) {
refreshRooms();
void refreshRooms({ force: true });
return prev;
}
const next = [...prev];
@@ -179,7 +187,7 @@ export default function ChatShell({
const off = realtime.on<RtChatMembershipData>(RT_CHAT_MEMBERSHIP, (data) => {
if (!data) return;
if (data.status === "invited" || data.status === "dissolved" || data.status === "kicked") {
refreshRooms();
void refreshRooms({ force: true });
}
});
return off;
@@ -230,7 +238,7 @@ export default function ChatShell({
setQuery("");
setUserHits([]);
router.push(`/chat/${room.id}`);
refreshRooms();
void refreshRooms({ force: true });
} catch (e) {
toast(e instanceof Error ? e.message : "无法发起私聊");
}
@@ -524,7 +532,7 @@ export default function ChatShell({
setShowCreate(false);
toast("群已创建", "ok");
router.push(`/chat/${id}`);
refreshRooms();
void refreshRooms({ force: true });
}}
/>
)}

View File

@@ -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>
);

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useCallback, useState } from "react";
import { useRouter } from "next/navigation";
import { Check } from "lucide-react";
import { apiUpdateProfile, type User } from "@/lib/api";
@@ -23,6 +23,34 @@ export default function ProfileForm({ user }: ProfileFormProps) {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [avatarDragOver, setAvatarDragOver] = useState(false);
const [incomingAvatarFile, setIncomingAvatarFile] = useState<File | null>(null);
const clearIncomingAvatar = useCallback(() => setIncomingAvatarFile(null), []);
const handleAvatarDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.types.includes("Files")) setAvatarDragOver(true);
};
const handleAvatarDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// 仅在离开整块卡片时取消高亮(避免子元素冒泡闪烁)
const related = e.relatedTarget as Node | null;
if (related && e.currentTarget.contains(related)) return;
setAvatarDragOver(false);
};
const handleAvatarDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setAvatarDragOver(false);
const file = e.dataTransfer.files?.[0];
if (!file) return;
setIncomingAvatarFile(file);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -72,20 +100,32 @@ export default function ProfileForm({ user }: ProfileFormProps) {
)}
{error && <div className="alert-error" role="alert">{error}</div>}
{/* 头像:点击上传/裁剪,或从历史图片中选择 */}
{/* 头像:点击上传/裁剪,或拖放图片到本区域 */}
<div
className="flex items-center gap-4 p-4 rounded-xl"
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
className="flex items-center gap-4 p-4 rounded-xl transition-colors"
style={{
background: avatarDragOver
? "color-mix(in srgb, var(--accent) 12%, var(--panel-2))"
: "var(--panel-2)",
border: `1px solid ${avatarDragOver ? "var(--accent)" : "var(--line)"}`,
}}
onDragOver={handleAvatarDragOver}
onDragLeave={handleAvatarDragLeave}
onDrop={handleAvatarDrop}
>
<AvatarPicker
name={nickname || user.username}
src={user.avatar || ""}
size={56}
incomingFile={incomingAvatarFile}
onIncomingConsumed={clearIncomingAvatar}
/>
<div className="flex-1 min-w-0">
<p className="text-[13.5px] font-bold" style={{ color: "var(--ink)" }}>头像</p>
<p className="text-[12px] mt-1" style={{ color: "var(--ink-3)" }}>
点击头像上传新图片,支持拖动裁剪;统一转为 WebP,裁剪后不超过 2MB
{avatarDragOver
? "松开即可上传并裁剪"
: "点击头像或拖放图片到此处;支持拖动裁剪,统一转为 WebP,裁剪后不超过 2MB"}
</p>
</div>
</div>

View File

@@ -28,7 +28,7 @@ export default function UserProfileContent({
<div>
{tab === "posts" ? (
<div>
{profile.posts.length > 0 ? (
{Array.isArray(profile.posts) && profile.posts.length > 0 ? (
<div className="panel overflow-hidden p-0">
{profile.posts.map((post, i) => (
<Link
@@ -83,10 +83,10 @@ export default function UserProfileContent({
<p className="meta mt-1.5 mb-4">网络异常,请稍后重试</p>
<Link href={`/u/${userId}?tab=comments`} className="btn btn-line btn-sm">重新加载</Link>
</div>
) : commentData && commentData.comments.length > 0 ? (
) : commentData && (commentData.comments?.length ?? 0) > 0 ? (
<div>
<div className="panel overflow-hidden p-0">
{commentData.comments.map((c, i) => (
{(commentData.comments ?? []).map((c, i) => (
<Link
key={c.id}
href={`/post/${c.post_id}`}