Files
jiang13-bbs/frontend/components/BrandCropModal.tsx
freefire 99c0f0319d feat: 品牌裁剪弹窗/Markdown 行内短代码与详情视频块等
新增:
- BrandCropModal 品牌图裁剪弹窗 + cropImage 工具
- Markdown 行内短代码(inlineShortcodes + remark 插件)
- MdDetails 折叠块、MdVideo 视频块组件
- directiveBlocks/inlineShortcodes 测试

其它:
- 聊天后端(handler/service chat)调整
- Markdown 渲染链路、编辑器、全局样式与品牌相关面板更新
2026-10-02 01:40:06 +08:00

369 lines
13 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, Crop,
} from "lucide-react";
import {
apiMyMedia, apiUploadBrand,
type MediaAttachment,
} from "@/lib/api";
import { getCroppedBrandWebp } from "@/lib/cropImage";
import Modal from "@/components/Modal";
import { brandFileOk, type BrandSlot, type BrandLogoSize } 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;
onClose: () => void;
/** 裁剪完成后返回新 URL */
onCropped: (url: string) => void;
/** 外部传入的文件:打开后直接进入裁剪 */
seedFile?: File | null;
/** 外部传入的 URL(如媒体库图片):打开后直接进入裁剪 */
seedUrl?: string;
/** 已消费 seedFile / seedUrl */
onSeedConsumed?: () => void;
}
export default function BrandCropModal({
open, slot, onClose, onCropped, seedFile, seedUrl, onSeedConsumed,
}: 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 [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);
// 打开时拉取媒体库
useEffect(() => {
if (!open) return;
setError("");
apiMyMedia()
.then((res) => setMedia(res.attachments || []))
.catch(() => setMedia([]));
}, [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);
setAspect(SLOT_ASPECT[slot] || 1);
setError("");
setBusy(false);
setDragOver(false);
}
}, [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 || !pixelCrop || busy) return;
setBusy(true);
setError("");
try {
// 浏览器端裁剪并以 WebP 导出(质量自适应,直到 ≤ 2MB)
const blob = await getCroppedBrandWebp(imageSrc, pixelCrop, aspect);
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>
)}
</>
) : (
<>
{/* 比例选择 */}
<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)]"
}`}
>
{opt.label}
</button>
))}
</div>
{/* 裁剪区 */}
<div
className="relative w-full h-72 rounded-xl overflow-hidden"
style={{ background: cropBg }}
>
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={aspect}
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>
</div>
{/* 输出说明 + 操作 */}
<div className="flex items-center justify-between mt-5">
<span className="text-[12px]" style={{ color: "var(--ink-3)" }}>
输出 WebP,高度 ≤ 2048px
</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>
);
}