feat: 媒体库 blob 去重存储与品牌/注册等前端调整
新增: - blob_store 二进制去重存储 + blob_dedup 测试 其它: - 后端 settings/auth/media_library/upload/operations 服务与 handler 调整 - 前端品牌(BrandLockup/BrandCropModal/BrandSeoPanel)、注册、媒体库、分析详情页、Header/SiteChrome 等更新
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
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 {
|
||||
@@ -10,29 +10,25 @@ import {
|
||||
apiMyMedia, apiUploadBrand,
|
||||
type MediaAttachment,
|
||||
} from "@/lib/api";
|
||||
import { getCroppedBrandWebp } from "@/lib/cropImage";
|
||||
import { getCroppedBrandWebp, getFullBrandWebp } from "@/lib/cropImage";
|
||||
import Modal from "@/components/Modal";
|
||||
import { brandFileOk, type BrandSlot, type BrandLogoSize } from "@/lib/brand";
|
||||
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;
|
||||
|
||||
// 品牌图槽位到默认裁剪比例的映射
|
||||
const SLOT_ASPECT: Record<BrandSlot, number> = {
|
||||
logo_light: 1, // 方标 1:1
|
||||
logo_dark: 1, // 方标 1:1
|
||||
favicon: 1, // Favicon 1:1
|
||||
};
|
||||
|
||||
// 品牌图比例选项(与 BRAND_LOGO_SIZES 对应)
|
||||
const ASPECT_OPTIONS: { id: BrandLogoSize; label: string; aspect: number }[] = [
|
||||
{ id: "sq", label: "1:1", aspect: 1 },
|
||||
{ id: "2x1", label: "2:1", aspect: 2 },
|
||||
{ id: "3x1", label: "3:1", aspect: 3 },
|
||||
{ id: "4x1", label: "4:1", aspect: 4 },
|
||||
{ id: "16x9", label: "16:9", aspect: 16 / 9 },
|
||||
];
|
||||
|
||||
interface BrandCropModalProps {
|
||||
open: boolean;
|
||||
slot: BrandSlot;
|
||||
@@ -45,10 +41,19 @@ interface BrandCropModalProps {
|
||||
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);
|
||||
@@ -56,11 +61,18 @@ export default function BrandCropModal({
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [pixelCrop, setPixelCrop] = useState<Area | null>(null);
|
||||
const [aspect, setAspect] = useState<number>(SLOT_ASPECT[slot] || 1);
|
||||
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(() => {
|
||||
@@ -71,6 +83,23 @@ export default function BrandCropModal({
|
||||
.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) {
|
||||
@@ -82,10 +111,10 @@ export default function BrandCropModal({
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
setPixelCrop(null);
|
||||
setAspect(SLOT_ASPECT[slot] || 1);
|
||||
setError("");
|
||||
setBusy(false);
|
||||
setDragOver(false);
|
||||
detectedSrcRef.current = null;
|
||||
}
|
||||
}, [open, slot]);
|
||||
|
||||
@@ -178,12 +207,15 @@ export default function BrandCropModal({
|
||||
};
|
||||
|
||||
const confirmUpload = async () => {
|
||||
if (!imageSrc || !pixelCrop || busy) return;
|
||||
if (!imageSrc || busy) return;
|
||||
if (!isAuto && !pixelCrop) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
// 浏览器端裁剪并以 WebP 导出(质量自适应,直到 ≤ 2MB)
|
||||
const blob = await getCroppedBrandWebp(imageSrc, pixelCrop, aspect);
|
||||
// 原图模式:整图直传不裁剪;固定画幅:按裁剪区域导出
|
||||
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);
|
||||
@@ -292,64 +324,166 @@ export default function BrandCropModal({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 比例选择 */}
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
<span className="text-[12px]" style={{ color: "var(--ink-3)" }}>比例</span>
|
||||
{ASPECT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => setAspect(opt.aspect)}
|
||||
className={`px-2.5 py-1 rounded-md text-[12px] font-medium transition-colors ${
|
||||
aspect === opt.aspect
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "bg-[var(--panel-2)] text-[var(--ink-2)] hover:bg-[var(--panel-3)]"
|
||||
}`}
|
||||
{/* 画幅:唯一的比例控件(同时决定裁剪比例与页眉图框);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",
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
整图使用,不裁剪
|
||||
</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="relative w-full h-72 rounded-xl overflow-hidden"
|
||||
style={{ background: cropBg }}
|
||||
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="页眉预览"
|
||||
>
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={aspect}
|
||||
cropShape="rect"
|
||||
showGrid={true}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropComplete}
|
||||
<BrandLockup
|
||||
mode="image_text"
|
||||
size={effectiveSize}
|
||||
fit={logoFit}
|
||||
name="站点名称"
|
||||
wordmark=""
|
||||
logoLight={imageSrc || ""}
|
||||
logoDark=""
|
||||
/>
|
||||
</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)" }}>
|
||||
输出 WebP,高度 ≤ 2048px
|
||||
{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">
|
||||
|
||||
Reference in New Issue
Block a user