feat: 品牌裁剪弹窗/Markdown 行内短代码与详情视频块等
新增: - BrandCropModal 品牌图裁剪弹窗 + cropImage 工具 - Markdown 行内短代码(inlineShortcodes + remark 插件) - MdDetails 折叠块、MdVideo 视频块组件 - directiveBlocks/inlineShortcodes 测试 其它: - 聊天后端(handler/service chat)调整 - Markdown 渲染链路、编辑器、全局样式与品牌相关面板更新
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user