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

@@ -18,13 +18,17 @@ interface AvatarCropModalProps {
currentAvatar: string;
/** 头像变更(新 URL)或被删除(空串)时触发 */
onChanged: (avatar: string) => void;
/** 外部拖入的图片:打开后直接进入裁剪 */
seedFile?: File | null;
/** 已消费 seedFile(成功或校验失败后均调用,便于父级清空) */
onSeedConsumed?: () => void;
}
// 源文件大小保护(防止手机相册原图撑爆浏览器内存;最终限制以裁剪后 2MB 为准)
const SOURCE_MAX_BYTES = 15 * 1024 * 1024;
export default function AvatarCropModal({
open, onClose, currentAvatar, onChanged,
open, onClose, currentAvatar, onChanged, seedFile, onSeedConsumed,
}: AvatarCropModalProps) {
const [media, setMedia] = useState<MediaAttachment[]>([]);
const [imageSrc, setImageSrc] = useState<string | null>(null); // blob: 或同源 /uploads URL
@@ -37,6 +41,7 @@ export default function AvatarCropModal({
const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
// 正在"直接选用"的历史头像 id(区别于裁剪上传,走 PUT /avatar/use)
const [usingId, setUsingId] = useState<number | null>(null);
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const confirmTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -64,6 +69,7 @@ export default function AvatarCropModal({
setBusy(false);
setConfirmDeleteId(null);
setUsingId(null);
setDragOver(false);
}
}, [open]);
@@ -85,13 +91,36 @@ export default function AvatarCropModal({
setPixelCrop(areaPixels);
}, []);
const enterCrop = (src: string, isBlob: boolean) => {
const enterCrop = useCallback((src: string, isBlob: boolean) => {
setError("");
setImageSrc(src);
setImageSrc((prev) => {
if (prev && prev.startsWith("blob:")) URL.revokeObjectURL(prev);
return src;
});
setSrcIsBlob(isBlob);
setCrop({ x: 0, y: 0 });
setZoom(1);
};
}, []);
/** 校验并进入裁剪;返回错误文案,成功返回空串 */
const acceptImageFile = useCallback((file: File): string => {
if (!file.type.startsWith("image/")) {
return "请选择图片文件(JPG / PNG / GIF / WebP)";
}
if (file.size > SOURCE_MAX_BYTES) {
return "原图不能超过 15MB,裁剪后的头像需在 2MB 以内";
}
enterCrop(URL.createObjectURL(file), true);
return "";
}, [enterCrop]);
// 外部拖入:弹窗打开后立刻进入裁剪
useEffect(() => {
if (!open || !seedFile) return;
const errMsg = acceptImageFile(seedFile);
if (errMsg) setError(errMsg);
onSeedConsumed?.();
}, [open, seedFile, acceptImageFile, onSeedConsumed]);
/**
* 点击媒体库图片:
@@ -128,15 +157,31 @@ export default function AvatarCropModal({
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
if (!file.type.startsWith("image/")) {
setError("请选择图片文件(JPG / PNG / GIF / WebP)");
return;
}
if (file.size > SOURCE_MAX_BYTES) {
setError("原图不能超过 15MB,裁剪后的头像需在 2MB 以内");
return;
}
enterCrop(URL.createObjectURL(file), true);
const errMsg = acceptImageFile(file);
if (errMsg) setError(errMsg);
};
const onDropZoneDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!busy) setDragOver(true);
};
const onDropZoneDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
};
const onDropZoneDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
if (busy) return;
const file = e.dataTransfer.files?.[0];
if (!file) return;
const errMsg = acceptImageFile(file);
if (errMsg) setError(errMsg);
};
const backToLibrary = () => {
@@ -233,14 +278,19 @@ export default function AvatarCropModal({
{!imageSrc ? (
<>
{/* 选择新图片 */}
{/* 选择新图片:点击或拖放 */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
onDragOver={onDropZoneDragOver}
onDragLeave={onDropZoneDragLeave}
onDrop={onDropZoneDrop}
className="w-full flex flex-col items-center justify-center gap-2 rounded-xl py-7 px-4 text-center transition-colors hover:border-[var(--accent)]"
style={{
border: "1.5px dashed var(--line-2)",
background: "var(--panel-2)",
border: `1.5px dashed ${dragOver ? "var(--accent)" : "var(--line-2)"}`,
background: dragOver
? "color-mix(in srgb, var(--accent) 10%, var(--panel-2))"
: "var(--panel-2)",
color: "var(--ink-2)",
}}
>
@@ -251,7 +301,7 @@ export default function AvatarCropModal({
<UploadCloud size={20} />
</span>
<span className="text-[13.5px] font-bold" style={{ color: "var(--ink)" }}>
点击选择图片
{dragOver ? "松开即可开始裁剪" : "点击选择或拖放图片"}
</span>
<span className="text-[12px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
支持 JPG / PNG / GIF / WebP,裁剪后统一转为 WebP,大小不超过 2MB

View File

@@ -11,6 +11,9 @@ interface AvatarPickerProps {
name: string;
src: string;
size?: number;
/** 外部拖入的文件:非空时打开裁剪弹窗并进入裁剪 */
incomingFile?: File | null;
onIncomingConsumed?: () => void;
}
/**
@@ -23,10 +26,13 @@ interface AvatarPickerProps {
* 另外监听广播,使页面上若存在多个 picker 实例也保持一致;
* 并随 src prop(SSR 校正)同步,避免 useState 初始值造成数据漂移。
*/
export default function AvatarPicker({ name, src, size = 56 }: AvatarPickerProps) {
export default function AvatarPicker({
name, src, size = 56, incomingFile, onIncomingConsumed,
}: AvatarPickerProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [current, setCurrent] = useState(src);
const [seedFile, setSeedFile] = useState<File | null>(null);
// SSR props 校正(router.refresh 后服务端数据回流)
useEffect(() => setCurrent(src), [src]);
@@ -36,13 +42,26 @@ export default function AvatarPicker({ name, src, size = 56 }: AvatarPickerProps
if (typeof patch.avatar === "string") setCurrent(patch.avatar);
}), []);
// 外层拖入:打开弹窗并交给裁剪流程
useEffect(() => {
if (!incomingFile) return;
setSeedFile(incomingFile);
setOpen(true);
}, [incomingFile]);
const handleChanged = (avatar: string) => {
setCurrent(avatar);
setOpen(false);
setSeedFile(null);
emitUserUpdate({ avatar });
router.refresh();
};
const handleSeedConsumed = () => {
setSeedFile(null);
onIncomingConsumed?.();
};
return (
<>
<button
@@ -66,9 +85,14 @@ export default function AvatarPicker({ name, src, size = 56 }: AvatarPickerProps
<AvatarCropModal
open={open}
onClose={() => setOpen(false)}
onClose={() => {
setOpen(false);
setSeedFile(null);
}}
currentAvatar={current}
onChanged={handleChanged}
seedFile={seedFile}
onSeedConsumed={handleSeedConsumed}
/>
</>
);

View File

@@ -27,6 +27,7 @@ import { isStaff } from "@/lib/roles";
import { onUserUpdate, onForceLogout } from "@/lib/userEvents";
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
import type { RtChatUnreadData } from "@/lib/realtime";
import { onDocumentVisible } from "@/lib/visibilityReconcile";
import NotificationBell from "./NotificationBell";
import Avatar from "./Avatar";
@@ -112,29 +113,26 @@ export default function Header({
return off;
}, [user, pathname]);
// 会话内已读后主动对账(ChatRoomClient 派发)
// 会话内已读 / 回前台:合并对账(短时间多次 markRead 只打一次 summary)
useEffect(() => {
if (!user) return;
const onSync = () => {
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const pullSummary = () => {
apiChatUnreadSummary()
.then((res) => setChatUnread(res.total || 0))
.catch(() => {});
};
const onSync = () => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(pullSummary, 250);
};
window.addEventListener("j13-chat-unread-sync", onSync);
return () => window.removeEventListener("j13-chat-unread-sync", onSync);
}, [user]);
// 切回标签页时重新对账未读
useEffect(() => {
if (!user) return;
const onVisible = () => {
if (document.visibilityState !== "visible") return;
apiChatUnreadSummary()
.then((res) => setChatUnread(res.total || 0))
.catch(() => {});
const offVisible = onDocumentVisible(pullSummary);
return () => {
if (debounceTimer) clearTimeout(debounceTimer);
window.removeEventListener("j13-chat-unread-sync", onSync);
offVisible();
};
document.addEventListener("visibilitychange", onVisible);
return () => document.removeEventListener("visibilitychange", onVisible);
}, [user]);
const handleSearch = (e: React.FormEvent) => {

View File

@@ -12,17 +12,21 @@ import {
type NotificationItem,
} from "@/lib/api";
import { realtime, RT_NOTIFICATION_NEW } from "@/lib/realtime";
import { onDocumentVisible } from "@/lib/visibilityReconcile";
export default function NotificationBell({ initialUnread = 0 }: { initialUnread?: number }) {
const router = useRouter();
const [open, setOpen] = useState(false);
// SSR 直出未读数,红点不再挂载后才弹出;挂载后轮询自然校正
// SSR 直出未读数,红点不再挂载后才弹出;WS/回前台校准
const [unread, setUnread] = useState(initialUnread);
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [loading, setLoading] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const openRef = useRef(open);
openRef.current = open;
const loadNotificationsRef = useRef<() => void>(() => {});
// 未读数:WS 推送后拉取校准;回前台再对账(无定时轮询)
// 未读数:首屏用 SSR;WS 推送 / 回前台校准(无定时轮询;open 变化不重订)
useEffect(() => {
let cancelled = false;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
@@ -40,22 +44,18 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
debounceTimer = setTimeout(fetchCount, 200);
};
fetchCount();
const off = realtime.on(RT_NOTIFICATION_NEW, () => {
fetchCountDebounced();
if (open) loadNotifications();
if (openRef.current) loadNotificationsRef.current();
});
const onVisible = () => {
if (document.visibilityState === "visible") fetchCount();
};
document.addEventListener("visibilitychange", onVisible);
const offVisible = onDocumentVisible(fetchCount);
return () => {
cancelled = true;
if (debounceTimer) clearTimeout(debounceTimer);
off();
document.removeEventListener("visibilitychange", onVisible);
offVisible();
};
}, [open]);
}, []);
// 点击外部关闭下拉
useEffect(() => {
@@ -78,6 +78,9 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
}
setLoading(false);
};
loadNotificationsRef.current = () => {
void loadNotifications();
};
const handleToggle = () => {
const next = !open;

View File

@@ -5,13 +5,17 @@ import { usePathname } from "next/navigation";
import { apiGetSettings } from "@/lib/api";
import { applyAccentTheme } from "@/lib/theme";
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
import {
VISIBLE_RECONCILE_GAP_MS,
onDocumentVisible,
} from "@/lib/visibilityReconcile";
// 站点主题色同步器:
// 主题色由管理员在后台修改后,其他用户已打开的标签页不会因软导航重新执行根布局
// SSR,本组件通过 WS 推送 + 标签页重新可见/获焦时探测公开设置,发现变化即热替换
// SSR,本组件通过 WS 推送 + 标签页重新可见时探测公开设置,发现变化即热替换
// accent CSS 变量(复用 lib/theme 的运行时 style 节点),无需 F5 即可换肤。
// 焦点/可见性事件可能短时间连续触发,限制实际请求间隔
const MIN_FETCH_GAP_MS = 10_000;
const MIN_FETCH_GAP_MS = VISIBLE_RECONCILE_GAP_MS;
export default function ThemeSync({ initialAccent }: { initialAccent: string }) {
const pathname = usePathname();
@@ -56,19 +60,15 @@ export default function ThemeSync({ initialAccent }: { initialAccent: string })
if (data) apply((data.accent || "").trim().toLowerCase());
});
const onActive = () => {
if (document.visibilityState === "visible") void sync();
};
document.addEventListener("visibilitychange", onActive);
window.addEventListener("focus", onActive);
// 仅 visibility:window focus 与 visibility 常连发,统一走限频即可
const offVisible = onDocumentVisible(() => void sync(), MIN_FETCH_GAP_MS);
// 离开外观页等 pathname 变化时立刻对齐一次(受 MIN_FETCH_GAP 限频)
void sync();
return () => {
cancelled = true;
offPush();
document.removeEventListener("visibilitychange", onActive);
window.removeEventListener("focus", onActive);
offVisible();
};
}, [pathname]);