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

@@ -7,6 +7,7 @@ import {
AdminSettingsActions,
AdminSettingsGroup,
} from "@/components/admin";
import BrandCropModal from "@/components/BrandCropModal";
import BrandLockup from "@/components/BrandLockup";
import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api";
import {
@@ -15,6 +16,7 @@ import {
brandAccept,
brandAliasPath,
brandFileOk,
normalizeBrandURL,
type BrandLogoFit,
type BrandLogoSize,
type BrandMark,
@@ -77,6 +79,9 @@ export default function BrandSeoPanel({
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const [picker, setPicker] = useState<BrandSlot | null>(null);
const [cropSlot, setCropSlot] = useState<BrandSlot | null>(null);
const [cropSeedFile, setCropSeedFile] = useState<File | null>(null);
const [cropSeedUrl, setCropSeedUrl] = useState("");
const [copiedSlot, setCopiedSlot] = useState<BrandSlot | null>(null);
const blobs = useRef<string[]>([]);
const saveGen = useRef(0);
@@ -123,6 +128,18 @@ export default function BrandSeoPanel({
return draft.committed;
};
const openCrop = (slot: BrandSlot, file: File | null, url: string) => {
setCropSlot(slot);
setCropSeedFile(file);
setCropSeedUrl(url);
};
const closeCrop = () => {
setCropSlot(null);
setCropSeedFile(null);
setCropSeedUrl("");
};
const discard = () => {
[light, dark, favicon].forEach((d) => {
if (d.blob) URL.revokeObjectURL(d.blob);
@@ -237,8 +254,8 @@ export default function BrandSeoPanel({
// 预览槽(≤56px 高,宽标可能更宽):本站图走 160/384 密度变体
// eslint-disable-next-line @next/next/no-img-element
<img
src={isOwnImage(preview) ? variantURL(preview, pickVariantWidth(160)) : preview}
srcSet={isOwnImage(preview) ? densitySrcSet(preview, 160) : undefined}
src={isOwnImage(preview) ? variantURL(preview, pickVariantWidth(384)) : preview}
srcSet={isOwnImage(preview) ? densitySrcSet(preview, 384) : undefined}
alt=""
/>
) : (
@@ -277,7 +294,14 @@ export default function BrandSeoPanel({
toast(err);
return;
}
replaceFile(slot, file);
// SVG / ICO 无需裁剪,直接走原流程
const isVector = file.name.toLowerCase().endsWith(".svg");
const isIco = file.name.toLowerCase().endsWith(".ico");
if (isVector || isIco) {
replaceFile(slot, file);
return;
}
openCrop(slot, file, "");
}}
/>
</label>
@@ -418,11 +442,50 @@ export default function BrandSeoPanel({
onClose={() => setPicker(null)}
onPick={(item) => {
if (!picker) return;
const [prev, set] = drafts[picker];
if (prev.blob) URL.revokeObjectURL(prev.blob);
set({ ...prev, file: null, blob: "", mediaId: item.id, mediaUrl: item.url });
const slot = picker;
setPicker(null);
// 已是品牌目录的历史图(含裁剪产物):直接引用,不再复制
const brandURL = normalizeBrandURL(slot, item.url);
if (brandURL) {
const [prev, set] = drafts[slot];
if (prev.blob) URL.revokeObjectURL(prev.blob);
set({ committed: brandURL, file: null, blob: "", mediaId: null, mediaUrl: "" });
return;
}
const lower = item.url.toLowerCase();
const isVector = lower.endsWith(".svg");
const isIco = lower.endsWith(".ico");
// SVG / ICO 不可裁剪,复制一份进品牌目录后引用
if ((isVector || isIco) && item.attachmentId != null) {
const [prev, set] = drafts[slot];
if (prev.blob) URL.revokeObjectURL(prev.blob);
set({ ...prev, file: null, blob: "", mediaId: item.attachmentId, mediaUrl: item.url });
return;
}
openCrop(slot, null, item.url);
}}
/>
{cropSlot ? (
<BrandCropModal
open
slot={cropSlot}
seedFile={cropSeedFile}
seedUrl={cropSeedUrl}
onClose={closeCrop}
onSeedConsumed={() => {
setCropSeedFile(null);
setCropSeedUrl("");
}}
onCropped={(url) => {
const [, set] = drafts[cropSlot];
set((prev) => {
if (prev.blob) URL.revokeObjectURL(prev.blob);
return { committed: url, file: null, blob: "", mediaId: null, mediaUrl: "" };
});
closeCrop();
}}
/>
) : null}
</AdminSettingsGroup>
);
}