Files
jiang13-bbs/frontend/components/AvatarCropModal.tsx
freefire 40bbad2c82 公开页补齐自引用 Canonical 与按类型的结构化数据,图片补上说明文字。
后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 00:11:24 +08:00

450 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Cropper, { type Area } from "react-easy-crop";
import "react-easy-crop/react-easy-crop.css";
import {
UploadCloud, ZoomIn, Check, Loader2, Images, RotateCcw, Trash2, AlertTriangle, Crop,
} from "lucide-react";
import {
apiMyMedia, apiUploadAvatar, apiUseAvatar, apiDeleteAttachment,
type MediaAttachment,
} from "@/lib/api";
import { getCroppedAvatarWebp } from "@/lib/cropImage";
import Modal from "@/components/Modal";
interface AvatarCropModalProps {
open: boolean;
onClose: () => void;
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, seedFile, onSeedConsumed,
}: AvatarCropModalProps) {
const [media, setMedia] = useState<MediaAttachment[]>([]);
const [imageSrc, setImageSrc] = useState<string | null>(null); // blob: 或同源 /uploads URL
const [srcIsBlob, setSrcIsBlob] = useState(false);
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [pixelCrop, setPixelCrop] = useState<Area | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
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);
// 打开时拉取媒体库
useEffect(() => {
if (!open) return;
setError("");
apiMyMedia()
.then((res) => setMedia(res.attachments || []))
.catch(() => setMedia([]));
}, [open]);
// 关闭时复位并释放本地 blob URL(媒体库的同源 URL 不能 revoke)
useEffect(() => {
if (!open) {
setImageSrc((prev) => {
if (prev && prev.startsWith("blob:")) URL.revokeObjectURL(prev);
return null;
});
setSrcIsBlob(false);
setCrop({ x: 0, y: 0 });
setZoom(1);
setPixelCrop(null);
setError("");
setBusy(false);
setConfirmDeleteId(null);
setUsingId(null);
setDragOver(false);
}
}, [open]);
useEffect(() => () => {
if (confirmTimer.current) clearTimeout(confirmTimer.current);
}, []);
const onCropComplete = useCallback((_: Area, areaPixels: Area) => {
setPixelCrop(areaPixels);
}, []);
const enterCrop = useCallback((src: string, isBlob: boolean) => {
setError("");
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]);
/**
* 点击媒体库图片:
* - 成品头像(kind=avatar,本就是合规正方形 WebP):直接选用已有文件,
* 走 PUT /avatar/use,绝不重新裁剪上传产生重复文件;
* - 其他图片(kind=image,如帖子插图,可能非正方形):进入裁剪界面,
* 裁剪后才生成新的头像文件上传。
*/
const handlePick = async (item: MediaAttachment) => {
if (busy || item.url === currentAvatar) return;
if (item.kind === "image") {
enterCrop(item.url, false);
return;
}
setBusy(true);
setUsingId(item.id);
setError("");
try {
const res = await apiUseAvatar(item.url);
if (!res.url) {
setError(res.error || "设置失败,请重试");
return;
}
onChanged(res.url);
} catch {
setError("网络错误,请重试");
} finally {
setBusy(false);
setUsingId(null);
}
};
const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
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 = () => {
if (imageSrc && srcIsBlob) URL.revokeObjectURL(imageSrc);
setImageSrc(null);
setSrcIsBlob(false);
setError("");
};
const confirmUpload = async () => {
if (!imageSrc || !pixelCrop || busy) return;
setBusy(true);
setError("");
try {
// 浏览器端裁剪并以 WebP 导出(质量自适应,直到 ≤ 2MB)
const blob = await getCroppedAvatarWebp(imageSrc, pixelCrop);
const res = await apiUploadAvatar(blob);
if (!res.url) {
setError(res.error || "上传失败,请重试");
return;
}
onChanged(res.url); // 父组件负责关窗、广播与刷新
} catch (err) {
setError(err instanceof Error ? err.message : "上传失败,请重试");
} finally {
setBusy(false);
}
};
const handleDelete = async (item: MediaAttachment) => {
if (busy) return;
// 两阶段确认:第一次点击进入红色确认态,3.5 秒内再点才执行
if (confirmDeleteId !== item.id) {
setConfirmDeleteId(item.id);
if (confirmTimer.current) clearTimeout(confirmTimer.current);
confirmTimer.current = setTimeout(() => setConfirmDeleteId(null), 3500);
return;
}
if (confirmTimer.current) clearTimeout(confirmTimer.current);
setConfirmDeleteId(null);
setBusy(true);
setError("");
try {
const res = await apiDeleteAttachment(item.id);
if (res.ok === undefined && res.error) {
setError(res.error);
return;
}
setMedia((prev) => prev.filter((m) => m.id !== item.id));
// 删的正是当前头像:清空(回到字母头像)并关闭弹窗
if (item.url === currentAvatar) {
onChanged("");
}
} catch {
setError("网络错误,请重试");
} finally {
setBusy(false);
}
};
return (
<Modal
open={open}
title={imageSrc ? "裁剪头像" : "更换头像"}
onClose={onClose}
busy={busy}
dismissOnOverlay={!busy}
maxWidthClass="max-w-[440px]"
panelClassName="p-5"
ariaLabel="更换头像"
>
{error && <div className="alert-error mb-4">{error}</div>}
{!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 ${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)",
}}
>
<span
className="inline-flex w-11 h-11 items-center justify-center rounded-full"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<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
</span>
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={onFileChange}
/>
{/* 媒体库:本人上传过的全部图片(头像 + 帖子插图) */}
{media.length > 0 && (
<div className="mt-5">
<p
className="flex items-center gap-1.5 text-[12px] font-bold tracking-[0.04em] mb-2.5"
style={{ color: "var(--ink-3)" }}
>
<Images size={13} /> 我的图片(历史头像点击直接使用,其他图片点击后裁剪;右上角可删除)
</p>
<div className="grid grid-cols-6 gap-2">
{media.map((item) => {
const active = item.url === currentAvatar;
const confirming = confirmDeleteId === item.id;
return (
<div
key={item.id}
// self-center:禁用网格项默认的 align-self:stretch,
// 否则它会与 aspect-ratio 冲突把容器拉高,光环变成竖椭圆
className="relative group rounded-full justify-self-center self-center w-full"
style={{
aspectRatio: "1 / 1",
boxShadow: active
? "0 0 0 2px var(--panel), 0 0 0 4px var(--accent)"
: "0 0 0 1px var(--line)",
}}
>
<button
type="button"
onClick={() => handlePick(item)}
disabled={busy || active}
data-tip={
active
? "当前头像"
: item.kind === "image"
? "裁剪后设为头像"
: "直接设为头像(不会重复上传)"
}
// absolute inset-0:以正方形容器为确定包含块铺满,
// 避免 h-full 在 aspect-ratio 尺寸上的百分比解析歧义
className="absolute inset-0 rounded-full overflow-hidden bg-[var(--panel-2)] transition-transform hover:scale-105 disabled:cursor-default disabled:hover:scale-100"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.url}
alt={item.kind === "avatar" ? "历史头像" : "我的图片"}
className="w-full h-full object-cover"
loading="lazy"
/>
</button>
{/* hover 指示:成品头像=直接使用,其他图片=裁剪;选用中显示 spinner */}
{!active && (
<span
className="pointer-events-none absolute inset-0 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
style={{ background: "color-mix(in srgb, var(--ink) 45%, transparent)", color: "#fff" }}
>
{usingId === item.id ? (
<Loader2 size={14} className="animate-spin" />
) : item.kind === "image" ? (
<Crop size={13} />
) : (
<Check size={14} strokeWidth={2.6} />
)}
</span>
)}
{/* 当前头像勾选标记 */}
{active && (
<span
className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-full"
style={{ background: "color-mix(in srgb, var(--accent) 35%, transparent)", color: "#fff" }}
>
<Check size={15} strokeWidth={3} />
</span>
)}
{/* 删除按钮(两阶段确认) */}
{!active && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDelete(item);
}}
disabled={busy}
aria-label={confirming ? "再次点击确认删除" : "删除图片"}
data-tip={confirming ? "再次点击确认彻底删除" : "彻底删除"}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
style={{
background: confirming ? "var(--red, #dc2626)" : "var(--panel)",
color: confirming ? "#fff" : "var(--ink-2)",
border: "1px solid " + (confirming ? "var(--red, #dc2626)" : "var(--line-2)"),
boxShadow: "0 1px 4px rgba(0,0,0,0.18)",
}}
>
{confirming ? <AlertTriangle size={11} /> : <Trash2 size={10.5} />}
</button>
)}
</div>
);
})}
</div>
</div>
)}
</>
) : (
<>
{/* 裁剪区 */}
<div
className="relative w-full h-72 rounded-xl overflow-hidden"
style={{ background: "#0c0d12" }}
>
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={1}
cropShape="round"
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
/>
</div>
{/* 缩放 */}
<div className="flex items-center gap-3 mt-4">
<span style={{ color: "var(--ink-3)" }}><ZoomIn size={15} /></span>
<input
type="range"
min={1}
max={3}
step={0.01}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
className="flex-1 h-1.5 rounded-full appearance-none cursor-pointer"
style={{ accentColor: "var(--accent)", background: "var(--panel-3)" }}
aria-label="缩放"
/>
<span style={{ color: "var(--ink-3)" }}><ZoomIn size={19} /></span>
</div>
{/* 输出说明 + 操作 */}
<div className="flex items-center justify-between mt-5">
<span className="text-[12px]" style={{ color: "var(--ink-3)" }}>
输出 320×320 WebP
</span>
<div className="flex items-center gap-2">
<button type="button" onClick={backToLibrary} disabled={busy} className="btn btn-line btn-sm">
<RotateCcw size={13.5} /> 重选
</button>
<button type="button" onClick={confirmUpload} disabled={busy} className="btn btn-primary btn-sm">
{busy ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
{busy ? "上传中" : "确认上传"}
</button>
</div>
</div>
</>
)}
</Modal>
);
}