新增: - BrandCropModal 品牌图裁剪弹窗 + cropImage 工具 - Markdown 行内短代码(inlineShortcodes + remark 插件) - MdDetails 折叠块、MdVideo 视频块组件 - directiveBlocks/inlineShortcodes 测试 其它: - 聊天后端(handler/service chat)调整 - Markdown 渲染链路、编辑器、全局样式与品牌相关面板更新
133 lines
4.5 KiB
TypeScript
133 lines
4.5 KiB
TypeScript
"use client";
|
||
|
||
import { Images, Loader2 } from "lucide-react";
|
||
import { useEffect, useState } from "react";
|
||
import Modal from "@/components/Modal";
|
||
import { apiAdminMediaLibrary, apiMyMedia } from "@/lib/api";
|
||
import { normalizeBrandURL, type BrandSlot } from "@/lib/brand";
|
||
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
|
||
|
||
const SLOT_LABEL: Record<BrandSlot, string> = {
|
||
logo_light: "浅色 Logo",
|
||
logo_dark: "暗色 Logo",
|
||
favicon: "Favicon",
|
||
};
|
||
|
||
/** 选图条目:本人附件带 attachmentId;品牌目录历史图仅 URL(已在品牌目录,无需复制) */
|
||
export type BrandPickItem = {
|
||
key: string;
|
||
url: string;
|
||
attachmentId: number | null;
|
||
};
|
||
|
||
export default function BrandMediaPicker({
|
||
open,
|
||
slot,
|
||
onClose,
|
||
onPick,
|
||
}: {
|
||
open: boolean;
|
||
slot: BrandSlot;
|
||
onClose: () => void;
|
||
onPick: (item: BrandPickItem) => void;
|
||
}) {
|
||
const [items, setItems] = useState<BrandPickItem[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState("");
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
let cancelled = false;
|
||
setLoading(true);
|
||
setError("");
|
||
// 本人附件 + 媒体库盘点里的品牌目录历史图(品牌图不落附件,需合并展示)
|
||
Promise.all([apiMyMedia(), apiAdminMediaLibrary().catch(() => null)])
|
||
.then(([mediaRes, libRes]) => {
|
||
if (cancelled) return;
|
||
if (mediaRes.error) {
|
||
setError(mediaRes.error);
|
||
setItems([]);
|
||
return;
|
||
}
|
||
const out: BrandPickItem[] = (mediaRes.attachments || []).map((a) => ({
|
||
key: `att-${a.id}`,
|
||
url: a.url,
|
||
attachmentId: a.id,
|
||
}));
|
||
const seen = new Set(out.map((i) => i.url));
|
||
for (const it of libRes?.items || []) {
|
||
if (it.category !== "brand") continue;
|
||
if (!normalizeBrandURL(slot, it.url)) continue;
|
||
if (seen.has(it.url)) continue;
|
||
seen.add(it.url);
|
||
out.unshift({ key: `brand-${it.url}`, url: it.url, attachmentId: null });
|
||
}
|
||
setItems(out);
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setError("加载媒体库失败");
|
||
})
|
||
.finally(() => {
|
||
if (!cancelled) setLoading(false);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [open, slot]);
|
||
|
||
return (
|
||
<Modal
|
||
open={open}
|
||
title={`从媒体库选择 · ${SLOT_LABEL[slot]}`}
|
||
description="位图选中后进入裁剪并转成 WebP;SVG / ICO 与历史品牌图直接引用,之后删除原图不会影响 Logo。"
|
||
onClose={onClose}
|
||
maxWidthClass="max-w-lg"
|
||
dismissOnOverlay
|
||
>
|
||
{loading ? (
|
||
<p className="meta text-[13px] flex items-center gap-2 py-6 justify-center">
|
||
<Loader2 size={14} className="animate-spin" /> 加载中
|
||
</p>
|
||
) : error ? (
|
||
<p className="text-[13px] py-4" style={{ color: "var(--danger, #b42318)" }}>
|
||
{error}
|
||
</p>
|
||
) : items.length === 0 ? (
|
||
<p className="meta text-[13px] py-6 text-center">媒体库还没有图片,先上传一张吧。</p>
|
||
) : (
|
||
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2">
|
||
{items.map((item) => (
|
||
<button
|
||
key={item.key}
|
||
type="button"
|
||
onClick={() => {
|
||
onPick(item);
|
||
onClose();
|
||
}}
|
||
className="relative group aspect-square rounded-xl overflow-hidden"
|
||
style={{ boxShadow: "0 0 0 1px var(--line)", background: "var(--panel-2)" }}
|
||
data-tip="用作草稿预览,点保存后才生效"
|
||
>
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img
|
||
src={isOwnImage(item.url) ? variantURL(item.url, 160) : item.url}
|
||
srcSet={isOwnImage(item.url) ? widthSrcSet(item.url, [96, 160, 256, 384]) : undefined}
|
||
sizes="(max-width:520px) 23vw, 96px"
|
||
alt={`${SLOT_LABEL[slot]}候选图`}
|
||
className="w-full h-full object-contain"
|
||
loading="lazy"
|
||
/>
|
||
<span
|
||
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
|
||
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
|
||
>
|
||
<Images size={14} />
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
);
|
||
}
|