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

@@ -4841,6 +4841,20 @@ export async function fetchAdminMediaLibrary(
return res.json();
}
/** 客户端管理端媒体库(带令牌刷新),品牌选图等后台场景用 */
export async function apiAdminMediaLibrary(): Promise<{
items: MediaLibraryItem[];
counts: Record<string, number>;
total: number;
}> {
const res = await fetchWithRefresh("/api/admin/media-library", {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "加载媒体库失败");
return data;
}
export async function apiAdminGetSidebar(): Promise<{
config: SidebarConfig;
widgets: SidebarWidgetRecord[];

View File

@@ -187,9 +187,3 @@ export function brandFileOk(slot: BrandSlot, file: File): string | null {
}
return null;
}
export function mediaURLFitsSlot(slot: BrandSlot, url: string): boolean {
const u = url.toLowerCase();
if (slot === "favicon") return /\.(png|webp|svg|ico)(\?|$)/.test(u);
return /\.(png|webp|svg)(\?|$)/.test(u);
}

View File

@@ -5,6 +5,11 @@ export const AVATAR_OUTPUT_SIZE = 320;
// 裁剪后 WebP 文件大小上限(与后端 service.AvatarMaxBytes 保持一致)
export const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
// 品牌 Logo 输出高度上限(与后端 brandMaxDim 对齐)
export const BRAND_MAX_DIM = 2048;
// 裁剪后品牌图大小上限(与后端 BrandLogoMaxBytes 对齐)
export const BRAND_MAX_BYTES = 2 * 1024 * 1024;
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
@@ -18,6 +23,25 @@ function canvasToWebp(canvas: HTMLCanvasElement, quality: number): Promise<Blob
return new Promise((resolve) => canvas.toBlob(resolve, "image/webp", quality));
}
/**
* 按裁剪区域导出 WebP。
* 自动按 0.92 → 0.65 降质量,直到满足大小上限;仍超限则抛出错误。
*/
async function exportWebp(
canvas: HTMLCanvasElement,
maxBytes: number
): Promise<Blob> {
let last: Blob | null = null;
for (const quality of [0.92, 0.85, 0.75, 0.65]) {
const blob = await canvasToWebp(canvas, quality);
if (!blob) throw new Error("当前浏览器不支持 WebP 导出,请使用最新版 Chrome/Edge/Safari");
last = blob;
if (blob.size <= maxBytes) return blob;
}
if (last && last.size <= maxBytes) return last;
throw new Error("裁剪后的图片仍超过大小限制,请缩小图片后再试");
}
/**
* 按裁剪区域导出正方形 WebP。
* 自动按 0.92 → 0.65 降质量,直到满足大小上限;仍超限则抛出错误。
@@ -47,13 +71,51 @@ export async function getCroppedAvatarWebp(
AVATAR_OUTPUT_SIZE
);
let last: Blob | null = null;
for (const quality of [0.92, 0.85, 0.75, 0.65]) {
const blob = await canvasToWebp(canvas, quality);
if (!blob) throw new Error("当前浏览器不支持 WebP 导出,请使用最新版 Chrome/Edge/Safari");
last = blob;
if (blob.size <= AVATAR_MAX_BYTES) return blob;
}
if (last && last.size <= AVATAR_MAX_BYTES) return last;
throw new Error("裁剪后的图片仍超过 2MB,请缩小图片后再试");
return exportWebp(canvas, AVATAR_MAX_BYTES);
}
/**
* 按裁剪区域导出任意比例 WebP(品牌 Logo 用)。
* 输出高度限制在 BRAND_MAX_DIM 以内,宽度按比例自适应。
*/
export async function getCroppedBrandWebp(
imageSrc: string,
pixelCrop: Area,
aspect: number
): Promise<Blob> {
const image = await loadImage(imageSrc);
const canvas = document.createElement("canvas");
// 以高度为基准,宽度按纵横比计算;若超宽则按宽度反推高度
let outH = pixelCrop.height;
let outW = Math.round(outH * aspect);
if (outW > BRAND_MAX_DIM) {
outW = BRAND_MAX_DIM;
outH = Math.round(outW / aspect);
}
if (outH > BRAND_MAX_DIM) {
outH = BRAND_MAX_DIM;
outW = Math.round(outH * aspect);
}
canvas.width = outW;
canvas.height = outH;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("当前浏览器不支持图片裁剪");
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
outW,
outH
);
return exportWebp(canvas, BRAND_MAX_BYTES);
}

View File

@@ -0,0 +1,117 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
DETAILS_DEFAULT_TITLE,
isDetailsOpen,
parseDetailsOpen,
parseVideoInput,
parseVideoLine,
splitContentSegments,
} from "./directiveBlocks.ts";
describe("parseDetailsOpen / isDetailsOpen", () => {
it("开标记整行匹配,标题缺省「点击展开」", () => {
assert.equal(isDetailsOpen("[details]"), true);
assert.equal(parseDetailsOpen("[details]"), DETAILS_DEFAULT_TITLE);
assert.equal(parseDetailsOpen("[details 点击展开看结论]"), "点击展开看结论");
assert.equal(isDetailsOpen("[details 标题] 后缀"), false);
assert.equal(isDetailsOpen("[details]"), true);
});
});
describe("parseVideoInput", () => {
it("BV 号与 B站链接", () => {
assert.deepEqual(parseVideoInput("BV1GJ411x7h7"), {
provider: "bilibili",
id: "BV1GJ411x7h7",
});
assert.deepEqual(parseVideoInput("https://www.bilibili.com/video/BV1GJ411x7h7/?p=1"), {
provider: "bilibili",
id: "BV1GJ411x7h7",
});
});
it("YouTube ID 与链接", () => {
assert.deepEqual(parseVideoInput("dQw4w9WgXcQ"), {
provider: "youtube",
id: "dQw4w9WgXcQ",
});
assert.deepEqual(parseVideoInput("https://youtu.be/dQw4w9WgXcQ"), {
provider: "youtube",
id: "dQw4w9WgXcQ",
});
assert.deepEqual(parseVideoInput("https://www.youtube.com/watch?v=dQw4w9WgXcQ"), {
provider: "youtube",
id: "dQw4w9WgXcQ",
});
});
it("非法输入返回 null", () => {
assert.equal(parseVideoInput(""), null);
assert.equal(parseVideoInput("hello world"), null);
assert.equal(parseVideoInput("https://evil.com/<script>"), null);
});
});
describe("parseVideoLine", () => {
it("整行短代码匹配且大小写不敏感", () => {
assert.deepEqual(parseVideoLine("[bilibili]BV1GJ411x7h7[/bilibili]"), {
provider: "bilibili",
id: "BV1GJ411x7h7",
});
assert.deepEqual(parseVideoLine("[BILIBILI]https://www.bilibili.com/video/BV1GJ411x7h7[/BILIBILI]"), {
provider: "bilibili",
id: "BV1GJ411x7h7",
});
assert.deepEqual(parseVideoLine("[youtube]dQw4w9WgXcQ[/youtube]"), {
provider: "youtube",
id: "dQw4w9WgXcQ",
});
});
it("标签与平台不一致或内容非法时返回 null", () => {
assert.equal(parseVideoLine("[bilibili]dQw4w9WgXcQ[/bilibili]"), null);
assert.equal(parseVideoLine("[youtube]BV1GJ411x7h7[/youtube]"), null);
assert.equal(parseVideoLine("[bilibili]随便[/bilibili]"), null);
});
});
describe("splitContentSegments: details/video", () => {
it("[details] 块拆分,正文保留 markdown", () => {
const segs = splitContentSegments(
"前文\n\n[details 结论]\n**加粗** 内容\n[/details]\n\n后文"
);
assert.deepEqual(
segs.map((s) => s.type),
["md", "details", "md"]
);
const d = segs[1];
assert.equal(d.type === "details" ? d.title : "", "结论");
assert.equal(d.type === "details" ? d.body : "", "**加粗** 内容");
});
it("details 嵌套开标签:外层按 md,内层与闭合配对(与 timeline 语义一致)", () => {
const segs = splitContentSegments("[details A]\n[details B]\n[/details]");
assert.deepEqual(segs.map((s) => s.type), ["md", "details"]);
});
it("未闭合的 details 整段按 md", () => {
const segs = splitContentSegments("正文\n\n[details A]\n没有闭合标记");
assert.deepEqual(segs.map((s) => s.type), ["md"]);
});
it("代码围栏内伪语法不拆", () => {
const segs = splitContentSegments("```\n[details 假的]\n[/details]\n```");
assert.deepEqual(segs.map((s) => s.type), ["md"]);
});
it("视频整行拆为 video 段;行内出现不拆", () => {
const segs = splitContentSegments(
"看这个:\n\n[bilibili]BV1GJ411x7h7[/bilibili]\n\n文中 [youtube]dQw4w9WgXcQ[/youtube] 不拆"
);
assert.deepEqual(
segs.map((s) => s.type),
["md", "video", "md"]
);
const v = segs[1];
assert.equal(v.type === "video" ? v.provider : "", "bilibili");
assert.equal(v.type === "video" ? v.id : "", "BV1GJ411x7h7");
});
});

View File

@@ -25,7 +25,9 @@ export type ContentSegment =
body: string;
}
| { type: "timeline"; items: TimelineItem[]; rawBody: string }
| { type: "entries"; items: EntryItem[]; rawBody: string };
| { type: "entries"; items: EntryItem[]; rawBody: string }
| { type: "details"; title: string; body: string }
| { type: "video"; provider: "bilibili" | "youtube"; id: string };
function isTimelineClose(line: string): boolean {
return line.trim().toLowerCase() === "[/timeline]";
@@ -35,6 +37,68 @@ function isTimelineOpen(trimmed: string): boolean {
return trimmed.toLowerCase() === "[timeline]";
}
/* ---------- [details] 折叠面板 ---------- */
const DETAILS_OPEN_RE = /^\[details(?:\s+([^\]]*))?\]$/i;
export const DETAILS_DEFAULT_TITLE = "点击展开";
function isDetailsClose(line: string): boolean {
return line.trim().toLowerCase() === "[/details]";
}
/** 开标记整行匹配:[details 标题文字];标题缺省「点击展开」 */
export function isDetailsOpen(trimmed: string): boolean {
return DETAILS_OPEN_RE.test(trimmed);
}
export function parseDetailsOpen(trimmed: string): string {
const m = trimmed.match(DETAILS_OPEN_RE);
const title = (m?.[1] ?? "").trim();
return title || DETAILS_DEFAULT_TITLE;
}
/* ---------- [bilibili] / [youtube] 视频嵌入(整行单标记) ---------- */
const VIDEO_LINE_RE = /^\[(bilibili|youtube)\]([^\[\]]+)\[\/\1\]$/i;
const BILI_BV_RE = /^BV[0-9A-Za-z]{8,12}$/;
const BILI_URL_BV_RE = /\/video\/(BV[0-9A-Za-z]{8,12})/;
const YT_ID_RE = /^[0-9A-Za-z_-]{6,20}$/;
/**
* 裸输入解析:BV 号 / B站视频链接 / YouTube ID / youtu.be、watch?v= 链接。
* 短代码行与编辑器「插入视频」表单共用;解析失败返回 null。
*/
export function parseVideoInput(
raw: string
): { provider: "bilibili" | "youtube"; id: string } | null {
const s = raw.trim();
if (!s || /[\s<>"']/.test(s)) return null;
const bvFromUrl = s.match(BILI_URL_BV_RE);
if (bvFromUrl) return { provider: "bilibili", id: bvFromUrl[1] };
if (BILI_BV_RE.test(s)) return { provider: "bilibili", id: s };
if (/youtu\.?be/i.test(s)) {
const m = s.match(/(?:youtu\.be\/|[?&]v=)([0-9A-Za-z_-]{6,20})/);
return m ? { provider: "youtube", id: m[1] } : null;
}
if (YT_ID_RE.test(s)) return { provider: "youtube", id: s };
return null;
}
/**
* 整行 `[bilibili]…[/bilibili]` / `[youtube]…[/youtube]`。
* 标签名与内容平台不一致、或内容解析失败时返回 null(该行按普通 Markdown 处理)。
*/
export function parseVideoLine(
trimmed: string
): { provider: "bilibili" | "youtube"; id: string } | null {
const m = trimmed.match(VIDEO_LINE_RE);
if (!m) return null;
const provider = m[1].toLowerCase() as "bilibili" | "youtube";
const parsed = parseVideoInput(m[2]);
if (!parsed || parsed.provider !== provider) return null;
return parsed;
}
/**
* 先拆时间线,再对普通 md/hide 段做 hide 拆分。
* 时间线与 hide 互不嵌套;解析失败时整段当普通 Markdown。
@@ -53,7 +117,11 @@ export function splitContentSegments(
}
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
const raw: Array<
{ type: "md"; text: string } | { type: "timeline"; body: string } | { type: "entries"; body: string }
| { type: "md"; text: string }
| { type: "timeline"; body: string }
| { type: "entries"; body: string }
| { type: "details"; title: string; body: string }
| { type: "video"; provider: "bilibili" | "youtube"; id: string }
> = [];
let buf: string[] = [];
let inCode = false;
@@ -137,6 +205,29 @@ export function splitContentSegments(
continue;
}
// [details 标题]…[/details] 折叠面板:嵌套开标签视为失败按 md 处理
if (isDetailsOpen(trimmed)) {
const hit = scanBlock(isDetailsOpen, isDetailsClose);
if (!hit) {
buf.push(lines[i]);
i++;
continue;
}
flushMd();
raw.push({ type: "details", title: parseDetailsOpen(trimmed), body: hit.body });
i = hit.end + 1;
continue;
}
// [bilibili]…[/bilibili] / [youtube]…[/youtube] 整行视频嵌入
const video = parseVideoLine(trimmed);
if (video) {
flushMd();
raw.push({ type: "video", ...video });
i++;
continue;
}
// [entries:off] 例外块:body 原样进 md 缓冲(标记行丢弃),
// 不再参与 entries/timeline 拆分;未闭合时整块当普通 Markdown
if (isEntriesOffOpen(trimmed)) {
@@ -174,6 +265,14 @@ export function splitContentSegments(
});
continue;
}
if (part.type === "details") {
out.push({ type: "details", title: part.title, body: part.body });
continue;
}
if (part.type === "video") {
out.push({ type: "video", provider: part.provider, id: part.id });
continue;
}
const hideSegs = splitHideSegments(part.text) as HideSegment[];
for (const s of hideSegs) {
if (s.type === "md") {

View File

@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
isValidColorValue,
splitInlineShortcodes,
} from "./inlineShortcodes.ts";
describe("isValidColorValue", () => {
it("放行 #hex 与英文颜色名", () => {
assert.equal(isValidColorValue("#e03131"), true);
assert.equal(isValidColorValue("#FFF"), true);
assert.equal(isValidColorValue("#ffffffff"), true);
assert.equal(isValidColorValue("tomato"), true);
});
it("拒绝注入向量与非法值", () => {
assert.equal(isValidColorValue("url(https://evil)", ), false);
assert.equal(isValidColorValue("red;padding:0"), false);
assert.equal(isValidColorValue("#GGG"), false);
assert.equal(isValidColorValue(""), false);
});
});
describe("splitInlineShortcodes", () => {
it("无短代码时返回 null", () => {
assert.equal(splitInlineShortcodes("普通文本 == 未闭合 [kbd]未闭合"), null);
});
it("==高亮==", () => {
assert.deepEqual(splitInlineShortcodes("前==高亮==后"), [
{ type: "text", value: "前" },
{ type: "mark", value: "高亮" },
{ type: "text", value: "后" },
]);
});
it("[kbd]", () => {
assert.deepEqual(splitInlineShortcodes("[kbd]Ctrl+C[/kbd]"), [
{ type: "kbd", value: "Ctrl+C" },
]);
});
it("[color=前景] 与 [bg=背景]", () => {
assert.deepEqual(splitInlineShortcodes("[color=#e03131]红字[/color]"), [
{ type: "color", value: "红字", fg: "#e03131" },
]);
assert.deepEqual(splitInlineShortcodes("[bg=#fff3bf]黄底[/bg]"), [
{ type: "color", value: "黄底", bg: "#fff3bf" },
]);
});
it("[color=前景 bg=背景] 组合", () => {
assert.deepEqual(splitInlineShortcodes("[color=#fff bg=#c92a2a]白字红底[/color]"), [
{ type: "color", value: "白字红底", fg: "#fff", bg: "#c92a2a" },
]);
});
it("非法颜色整段回退为原文(返回 null 表示无需改写)", () => {
assert.equal(splitInlineShortcodes("[color=url(https://evil)]x[/color]"), null);
assert.equal(splitInlineShortcodes("[bg=red;padding:0]x[/bg]"), null);
});
it("未闭合标记保持字面", () => {
assert.equal(splitInlineShortcodes("[color=#fff]未闭合"), null);
});
it("混合片段按顺序拆分", () => {
assert.deepEqual(splitInlineShortcodes("a==b==[kbd]C[/kbd]d"), [
{ type: "text", value: "a" },
{ type: "mark", value: "b" },
{ type: "kbd", value: "C" },
{ type: "text", value: "d" },
]);
});
it("大小写不敏感", () => {
assert.deepEqual(splitInlineShortcodes("[KBD]Esc[/KBD]"), [
{ type: "kbd", value: "Esc" },
]);
});
});

View File

@@ -0,0 +1,75 @@
/**
* 行内短代码解析(纯函数):
* ==高亮== / [kbd]Ctrl[/kbd] / [color=前景( bg=背景)]文字[/color] / [bg=背景]文字[/bg]
* 颜色值白名单(#hex 或 CSS 颜色名),非法值整段按普通文本回退,防 CSS 注入。
*/
export type InlineShortcodeSegment =
| { type: "text"; value: string }
| { type: "mark"; value: string }
| { type: "kbd"; value: string }
| { type: "color"; value: string; fg?: string; bg?: string };
export const COLOR_VALUE_RE = /^(#[0-9a-fA-F]{3,8}|[a-zA-Z]{1,32})$/;
export function isValidColorValue(v: string): boolean {
return COLOR_VALUE_RE.test(v);
}
/**
* 扫描顺序:[kbd] → [color] → [bg] → ==…==。
* 标签内容不含 [ 与换行,避免嵌套歧义;未闭合标记保持字面。
* 无任何短代码命中时返回 null(调用方保留原节点)。
*/
const SCAN_RE =
/\[kbd\]([^\[\]\n]+)\[\/kbd\]|\[color=([^\]\s\[]+)(?:\s+bg=([^\]\s\[]+))?\]([^\[\]\n]+)\[\/color\]|\[bg=([^\]\s\[]+)\]([^\[\]\n]+)\[\/bg\]|==([^=\n]+)==/gi;
export function splitInlineShortcodes(
text: string
): InlineShortcodeSegment[] | null {
if (!text) return null;
const re = new RegExp(SCAN_RE.source, "gi");
const out: InlineShortcodeSegment[] = [];
let last = 0;
let changed = false;
let m: RegExpExecArray | null;
while ((m = re.exec(text))) {
const [full, kbd, colorFg, colorBg, colorBody, bgVal, bgBody, markBody] = m;
const before = text.slice(last, m.index);
if (before) out.push({ type: "text", value: before });
last = m.index + full.length;
if (kbd !== undefined) {
out.push({ type: "kbd", value: kbd });
changed = true;
continue;
}
if (colorFg !== undefined) {
if (!isValidColorValue(colorFg) || (colorBg !== undefined && !isValidColorValue(colorBg))) {
out.push({ type: "text", value: full });
continue;
}
out.push(
colorBg
? { type: "color", value: colorBody, fg: colorFg, bg: colorBg }
: { type: "color", value: colorBody, fg: colorFg }
);
changed = true;
continue;
}
if (bgVal !== undefined) {
if (!isValidColorValue(bgVal)) {
out.push({ type: "text", value: full });
continue;
}
out.push({ type: "color", value: bgBody, bg: bgVal });
changed = true;
continue;
}
out.push({ type: "mark", value: markBody });
changed = true;
}
const rest = text.slice(last);
if (rest) out.push({ type: "text", value: rest });
return changed ? out : null;
}