后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。 Co-authored-by: Cursor <cursoragent@cursor.com>
450 lines
17 KiB
TypeScript
450 lines
17 KiB
TypeScript
"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>
|
||
);
|
||
}
|