Files
jiang13-bbs/frontend/components/BrandCropModal.tsx
freefire 46e0cdc0b3 feat: 媒体库 blob 去重存储与品牌/注册等前端调整
新增:
- blob_store 二进制去重存储 + blob_dedup 测试

其它:
- 后端 settings/auth/media_library/upload/operations 服务与 handler 调整
- 前端品牌(BrandLockup/BrandCropModal/BrandSeoPanel)、注册、媒体库、分析详情页、Header/SiteChrome 等更新
2026-10-03 02:38:11 +08:00

503 lines
19 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, 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>
);
}