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