fix: 空评论列表 500,并完善撤回审计与角色说明
空切片 JSON 统一为数组;撤回保留正文供站长阅读,成员侧居中提示与重新编辑;头像支持拖放;角色弹窗补全职责说明并汇入封禁。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user