新增: - blob_store 二进制去重存储 + blob_dedup 测试 其它: - 后端 settings/auth/media_library/upload/operations 服务与 handler 调整 - 前端品牌(BrandLockup/BrandCropModal/BrandSeoPanel)、注册、媒体库、分析详情页、Header/SiteChrome 等更新
503 lines
19 KiB
TypeScript
503 lines
19 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useRef, useState, type CSSProperties } 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, Crop,
|
||
} from "lucide-react";
|
||
import {
|
||
apiMyMedia, apiUploadBrand,
|
||
type MediaAttachment,
|
||
} from "@/lib/api";
|
||
import { getCroppedBrandWebp, getFullBrandWebp } from "@/lib/cropImage";
|
||
import Modal from "@/components/Modal";
|
||
import BrandLockup from "@/components/BrandLockup";
|
||
import { AdminSegmented } from "@/components/admin";
|
||
import {
|
||
BRAND_LOGO_FITS,
|
||
BRAND_LOGO_SIZES,
|
||
BRAND_LOGO_SIZE_ASPECT,
|
||
brandFileOk,
|
||
brandLogoBox,
|
||
suggestLogoSize,
|
||
type BrandLogoFit,
|
||
type BrandLogoSize,
|
||
type BrandSlot,
|
||
} from "@/lib/brand";
|
||
|
||
// 源文件大小保护(防止手机相册原图撑爆浏览器内存;最终限制以裁剪后 2MB 为准)
|
||
const SOURCE_MAX_BYTES = 15 * 1024 * 1024;
|
||
|
||
interface BrandCropModalProps {
|
||
open: boolean;
|
||
slot: BrandSlot;
|
||
onClose: () => void;
|
||
/** 裁剪完成后返回新 URL */
|
||
onCropped: (url: string) => void;
|
||
/** 外部传入的文件:打开后直接进入裁剪 */
|
||
seedFile?: File | null;
|
||
/** 外部传入的 URL(如媒体库图片):打开后直接进入裁剪 */
|
||
seedUrl?: string;
|
||
/** 已消费 seedFile / seedUrl */
|
||
onSeedConsumed?: () => void;
|
||
/** 当前显示画幅(与工具栏同源,受控) */
|
||
logoSize?: BrandLogoSize;
|
||
/** 当前铺法(与工具栏同源,受控) */
|
||
logoFit?: BrandLogoFit;
|
||
/** 画幅变更回调(同步到父组件工具栏) */
|
||
onSizeChange?: (size: BrandLogoSize) => void;
|
||
/** 铺法变更回调(同步到父组件工具栏) */
|
||
onFitChange?: (fit: BrandLogoFit) => void;
|
||
}
|
||
|
||
export default function BrandCropModal({
|
||
open, slot, onClose, onCropped, seedFile, seedUrl, onSeedConsumed,
|
||
logoSize = "sq", logoFit = "contain", onSizeChange, onFitChange,
|
||
}: BrandCropModalProps) {
|
||
const [media, setMedia] = useState<MediaAttachment[]>([]);
|
||
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||
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 [dragOver, setDragOver] = useState(false);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
// 已识别过比例的图片(每张图进入裁剪时只自动推荐一次画幅)
|
||
const detectedSrcRef = useRef<string | null>(null);
|
||
|
||
// Favicon 固定方形,不参与画幅/铺法;auto=原图比例自由裁剪
|
||
const isFavicon = slot === "favicon";
|
||
const effectiveSize: BrandLogoSize = isFavicon ? "sq" : logoSize;
|
||
const isAuto = effectiveSize === "auto";
|
||
const cropAspect: number | undefined = isFavicon ? 1 : BRAND_LOGO_SIZE_ASPECT[effectiveSize];
|
||
|
||
// 打开时拉取媒体库
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setError("");
|
||
apiMyMedia()
|
||
.then((res) => setMedia(res.attachments || []))
|
||
.catch(() => setMedia([]));
|
||
}, [open]);
|
||
|
||
// 图片进入裁剪后,按原始比例自动推荐画幅(差距大→原图自适应),每张图仅一次
|
||
useEffect(() => {
|
||
if (!imageSrc || isFavicon || detectedSrcRef.current === imageSrc) return;
|
||
detectedSrcRef.current = imageSrc;
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
onSizeChange?.(suggestLogoSize(img.naturalWidth, img.naturalHeight));
|
||
};
|
||
img.src = imageSrc;
|
||
}, [imageSrc, isFavicon, onSizeChange]);
|
||
|
||
// 画幅变化时重置构图位置(换画幅=重新构图;缩放保留)
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setCrop({ x: 0, y: 0 });
|
||
}, [effectiveSize, open]);
|
||
|
||
// 关闭时复位并释放本地 blob URL
|
||
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);
|
||
setDragOver(false);
|
||
detectedSrcRef.current = null;
|
||
}
|
||
}, [open, slot]);
|
||
|
||
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 => {
|
||
const err = brandFileOk(slot, file);
|
||
if (err) return err;
|
||
if (file.size > SOURCE_MAX_BYTES) {
|
||
return "原图不能超过 15MB,裁剪后的品牌图需在 2MB 以内";
|
||
}
|
||
enterCrop(URL.createObjectURL(file), true);
|
||
return "";
|
||
}, [enterCrop, slot]);
|
||
|
||
// 外部传入:弹窗打开后立刻进入裁剪
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
if (seedFile) {
|
||
const errMsg = acceptImageFile(seedFile);
|
||
if (errMsg) setError(errMsg);
|
||
onSeedConsumed?.();
|
||
return;
|
||
}
|
||
if (seedUrl) {
|
||
enterCrop(seedUrl, false);
|
||
onSeedConsumed?.();
|
||
}
|
||
}, [open, seedFile, seedUrl, acceptImageFile, enterCrop, onSeedConsumed]);
|
||
|
||
/**
|
||
* 点击媒体库图片:直接进入裁剪界面
|
||
* 品牌图不做"直接选用",统一走裁剪后上传,保证比例一致
|
||
*/
|
||
const handlePick = (item: MediaAttachment) => {
|
||
if (busy) return;
|
||
enterCrop(item.url, false);
|
||
};
|
||
|
||
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 || busy) return;
|
||
if (!isAuto && !pixelCrop) return;
|
||
setBusy(true);
|
||
setError("");
|
||
try {
|
||
// 原图模式:整图直传不裁剪;固定画幅:按裁剪区域导出
|
||
const blob = isAuto
|
||
? await getFullBrandWebp(imageSrc)
|
||
: await getCroppedBrandWebp(imageSrc, pixelCrop!, cropAspect!);
|
||
const file = new File([blob], "brand.webp", { type: "image/webp" });
|
||
const url = await apiUploadBrand(slot, file);
|
||
onCropped(url);
|
||
onClose();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "上传失败,请重试");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const slotLabel = slot === "logo_light" ? "浅色 Logo" : slot === "logo_dark" ? "深色 Logo" : "Favicon";
|
||
// 裁剪区底色与槽位主题相反:浅色主题 Logo(通常为深色图)用浅底,深色主题 Logo 用深底
|
||
const cropBg = slot === "logo_dark" ? "#0c0d12" : "#f2f3f5";
|
||
|
||
return (
|
||
<Modal
|
||
open={open}
|
||
title={imageSrc ? `裁剪${slotLabel}` : `上传${slotLabel}`}
|
||
onClose={onClose}
|
||
busy={busy}
|
||
dismissOnOverlay={!busy}
|
||
maxWidthClass="max-w-[440px]"
|
||
panelClassName="p-5"
|
||
ariaLabel={`上传${slotLabel}`}
|
||
>
|
||
{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)" }}>
|
||
支持 PNG / WebP / SVG{slot === "favicon" ? " / ICO" : ""},裁剪后统一转为 WebP,大小不超过 2MB
|
||
</span>
|
||
</button>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept={slot === "favicon" ? "image/png,image/webp,image/svg+xml,.ico,image/x-icon" : "image/png,image/webp,image/svg+xml"}
|
||
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) => (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
onClick={() => handlePick(item)}
|
||
disabled={busy}
|
||
className="relative group aspect-square rounded-lg overflow-hidden transition-transform hover:scale-105 disabled:cursor-default"
|
||
style={{ boxShadow: "0 0 0 1px var(--line)" }}
|
||
data-tip="裁剪后设为品牌图"
|
||
>
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img
|
||
src={item.url}
|
||
alt="媒体库图片"
|
||
className="w-full h-full object-cover"
|
||
loading="lazy"
|
||
/>
|
||
<span
|
||
className="pointer-events-none absolute inset-0 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" }}
|
||
>
|
||
<Crop size={13} />
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<>
|
||
{/* 画幅:唯一的比例控件(同时决定裁剪比例与页眉图框);favicon 固定方形不展示 */}
|
||
{!isFavicon && (
|
||
<div className="mb-3">
|
||
<div className="flex items-center gap-1.5 mb-1.5">
|
||
<span className="text-[12px] font-semibold" style={{ color: "var(--ink-3)" }}>画幅</span>
|
||
<span className="text-[11px]" style={{ color: "var(--ink-3)" }}>已按图片比例推荐,可手动调整</span>
|
||
</div>
|
||
<div className="admin-brand-size-row" role="group" aria-label="显示画幅">
|
||
{BRAND_LOGO_SIZES.map((item) => {
|
||
const on = effectiveSize === item.id;
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
className={`admin-brand-size-chip${on ? " is-on" : ""}`}
|
||
onClick={() => onSizeChange?.(item.id)}
|
||
>
|
||
<span className="admin-brand-size-chip-label">{item.label}</span>
|
||
<span className="admin-brand-size-chip-px">
|
||
{item.w ? `${item.w}×${item.h}` : "自适应"}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 原图模式:整图直传,不裁剪;固定画幅:裁剪 */}
|
||
{isAuto ? (
|
||
<div
|
||
className="relative w-full h-72 rounded-xl overflow-hidden flex items-center justify-center p-4"
|
||
style={{ background: cropBg }}
|
||
>
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img
|
||
src={imageSrc || ""}
|
||
alt="整图预览"
|
||
className="max-w-full max-h-full object-contain"
|
||
style={{ borderRadius: 6 }}
|
||
/>
|
||
<span
|
||
className="absolute left-2.5 top-2.5 text-[11px] px-1.5 py-0.5 rounded"
|
||
style={{
|
||
background: "rgba(0,0,0,.45)",
|
||
color: "#fff",
|
||
}}
|
||
>
|
||
整图使用,不裁剪
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div
|
||
className="relative w-full h-72 rounded-xl overflow-hidden"
|
||
style={{ background: cropBg }}
|
||
>
|
||
<Cropper
|
||
image={imageSrc}
|
||
crop={crop}
|
||
zoom={zoom}
|
||
aspect={cropAspect}
|
||
cropShape="rect"
|
||
showGrid={true}
|
||
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>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setZoom(1);
|
||
setCrop({ x: 0, y: 0 });
|
||
}}
|
||
className="btn btn-line btn-sm shrink-0"
|
||
title="重置缩放与位置"
|
||
>
|
||
<RotateCcw size={13} /> 重置
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 铺法:仅固定画幅需要;原图模式图框随图,无需铺法 */}
|
||
{!isFavicon && (
|
||
isAuto ? (
|
||
<p className="text-[11.5px] mt-3" style={{ color: "var(--ink-3)" }}>
|
||
原图模式:整图使用不裁剪,页眉图框按图片原始比例自动给宽,图片完整不变形。
|
||
</p>
|
||
) : (
|
||
<div className="mt-3">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-[12px] font-semibold shrink-0" style={{ color: "var(--ink-3)" }}>铺法</span>
|
||
<AdminSegmented
|
||
role="group"
|
||
size="sm"
|
||
ariaLabel="适配方式"
|
||
value={logoFit}
|
||
onChange={(v) => onFitChange?.(v)}
|
||
options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))}
|
||
/>
|
||
</div>
|
||
<p className="text-[11.5px] mt-1.5" style={{ color: "var(--ink-3)" }}>
|
||
{logoFit === "contain"
|
||
? "适应:完整显示图片,比例不匹配时留空(推荐)"
|
||
: logoFit === "cover"
|
||
? "填充:裁切填满画幅"
|
||
: logoFit === "repeat"
|
||
? "平铺:重复铺满画幅"
|
||
: "拉伸:变形填满画幅"}
|
||
</p>
|
||
</div>
|
||
)
|
||
)}
|
||
|
||
{/* 页眉实时预览:深色 Logo 用深色衬底并局部反转文字变量 */}
|
||
<div
|
||
className="mt-3 rounded-xl px-3.5 py-2.5 flex items-center min-h-[56px]"
|
||
style={{
|
||
background: cropBg,
|
||
...(slot === "logo_dark"
|
||
? ({ "--ink": "#f5f6f8", "--ink-3": "rgba(255,255,255,.55)" } as CSSProperties)
|
||
: null),
|
||
}}
|
||
aria-label="页眉预览"
|
||
>
|
||
<BrandLockup
|
||
mode="image_text"
|
||
size={effectiveSize}
|
||
fit={logoFit}
|
||
name="站点名称"
|
||
wordmark=""
|
||
logoLight={imageSrc || ""}
|
||
logoDark=""
|
||
/>
|
||
</div>
|
||
|
||
{/* 输出说明 + 操作 */}
|
||
<div className="flex items-center justify-between mt-5">
|
||
<span className="text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||
{isAuto
|
||
? "输出 WebP · 高度 40px,宽度随原图比例"
|
||
: `输出 WebP · 图框 ${brandLogoBox(effectiveSize).w}×${brandLogoBox(effectiveSize).h}px`}
|
||
</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>
|
||
);
|
||
}
|