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

@@ -3,8 +3,8 @@
import { Images, Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import Modal from "@/components/Modal";
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
import { mediaURLFitsSlot, type BrandSlot } from "@/lib/brand";
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> = {
@@ -13,6 +13,13 @@ const SLOT_LABEL: Record<BrandSlot, string> = {
favicon: "Favicon",
};
/** 选图条目:本人附件带 attachmentId;品牌目录历史图仅 URL(已在品牌目录,无需复制) */
export type BrandPickItem = {
key: string;
url: string;
attachmentId: number | null;
};
export default function BrandMediaPicker({
open,
slot,
@@ -22,9 +29,9 @@ export default function BrandMediaPicker({
open: boolean;
slot: BrandSlot;
onClose: () => void;
onPick: (item: MediaAttachment) => void;
onPick: (item: BrandPickItem) => void;
}) {
const [items, setItems] = useState<MediaAttachment[]>([]);
const [items, setItems] = useState<BrandPickItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
@@ -33,16 +40,29 @@ export default function BrandMediaPicker({
let cancelled = false;
setLoading(true);
setError("");
void apiMyMedia()
.then((res) => {
// 本人附件 + 媒体库盘点里的品牌目录历史图(品牌图不落附件,需合并展示)
Promise.all([apiMyMedia(), apiAdminMediaLibrary().catch(() => null)])
.then(([mediaRes, libRes]) => {
if (cancelled) return;
if (res.error) {
setError(res.error);
if (mediaRes.error) {
setError(mediaRes.error);
setItems([]);
return;
}
const all = res.attachments || [];
setItems(all.filter((item) => mediaURLFitsSlot(slot, item.url)));
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("加载媒体库失败");
@@ -59,7 +79,7 @@ export default function BrandMediaPicker({
<Modal
open={open}
title={`从媒体库选择 · ${SLOT_LABEL[slot]}`}
description="只列出格式符合的图片。保存后会复制一份到品牌目录,之后删除原图不会影响 Logo。"
description="位图选中后进入裁剪并转成 WebP;SVG / ICO 与历史品牌图直接引用,之后删除原图不会影响 Logo。"
onClose={onClose}
maxWidthClass="max-w-lg"
dismissOnOverlay
@@ -73,12 +93,12 @@ export default function BrandMediaPicker({
{error}
</p>
) : items.length === 0 ? (
<p className="meta text-[13px] py-6 text-center">没有可用图片。Logo 需要 PNG / WebP / SVG,Favicon 还可选 ICO。</p>
<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.id}
key={item.key}
type="button"
onClick={() => {
onPick(item);