feat: 品牌裁剪弹窗/Markdown 行内短代码与详情视频块等

新增:
- BrandCropModal 品牌图裁剪弹窗 + cropImage 工具
- Markdown 行内短代码(inlineShortcodes + remark 插件)
- MdDetails 折叠块、MdVideo 视频块组件
- directiveBlocks/inlineShortcodes 测试

其它:
- 聊天后端(handler/service chat)调整
- Markdown 渲染链路、编辑器、全局样式与品牌相关面板更新
This commit is contained in:
2026-10-02 01:40:06 +08:00
parent 3e98052c16
commit 99c0f0319d
23 changed files with 2012 additions and 119 deletions

View File

@@ -0,0 +1,368 @@
"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>
);
}