Files
jiang13-bbs/frontend/lib/brand.ts
freefire 99c0f0319d feat: 品牌裁剪弹窗/Markdown 行内短代码与详情视频块等
新增:
- BrandCropModal 品牌图裁剪弹窗 + cropImage 工具
- Markdown 行内短代码(inlineShortcodes + remark 插件)
- MdDetails 折叠块、MdVideo 视频块组件
- directiveBlocks/inlineShortcodes 测试

其它:
- 聊天后端(handler/service chat)调整
- Markdown 渲染链路、编辑器、全局样式与品牌相关面板更新
2026-10-02 01:40:06 +08:00

190 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 站点品牌与页脚:公开设置字段的前端归一化(与 Go service/brand.go 对齐) */
export type FooterLink = {
label: string;
url: string;
new_tab: boolean;
/** 所属栏位:left / center / right;缺省 right */
align: FooterLinksAlign;
};
/** 页脚链接栏位 */
export type FooterLinksAlign = "left" | "center" | "right";
export const FOOTER_LINKS_ALIGNS: { id: FooterLinksAlign; label: string }[] = [
{ id: "left", label: "左" },
{ id: "center", label: "中" },
{ id: "right", label: "右" },
];
export function normalizeFooterLinksAlign(raw: unknown): FooterLinksAlign {
const s = String(raw ?? "").trim();
if (s === "left" || s === "center" || s === "right") return s;
return "right";
}
export type BrandSlot = "logo_light" | "logo_dark" | "favicon";
/** 页眉品牌形式 */
export type BrandMark = "image_text" | "image" | "text";
/** 图槽画幅。方标是默认;其余是页眉里常见的横版比例 */
export type BrandLogoSize = "sq" | "2x1" | "3x1" | "4x1" | "16x9";
export const BRAND_LOGO_SIZES: { id: BrandLogoSize; label: string; w: number; h: number }[] = [
{ id: "sq", label: "方标", w: 36, h: 36 },
{ id: "2x1", label: "2:1", w: 80, h: 40 },
{ id: "3x1", label: "3:1", w: 120, h: 40 },
{ id: "4x1", label: "4:1", w: 176, h: 44 },
{ id: "16x9", label: "16:9", w: 96, h: 54 },
];
export function normalizeBrandMark(raw: unknown): BrandMark {
const s = String(raw ?? "").trim();
if (s === "image" || s === "text" || s === "image_text") return s;
return "image_text";
}
export function normalizeBrandLogoSize(raw: unknown): BrandLogoSize {
const s = String(raw ?? "").trim();
if (s === "sq" || s === "2x1" || s === "3x1" || s === "4x1" || s === "16x9") return s;
return "sq";
}
export function brandLogoBox(size: BrandLogoSize): { w: number; h: number } {
const found = BRAND_LOGO_SIZES.find((item) => item.id === size);
return found ? { w: found.w, h: found.h } : { w: 36, h: 36 };
}
/** 图槽铺法,与背景图同一套:填充裁切、适应留边、平铺重复、拉伸变形 */
export type BrandLogoFit = "cover" | "contain" | "repeat" | "stretch";
export const BRAND_LOGO_FITS: { id: BrandLogoFit; label: string }[] = [
{ id: "cover", label: "填充" },
{ id: "contain", label: "适应" },
{ id: "repeat", label: "平铺" },
{ id: "stretch", label: "拉伸" },
];
export function normalizeBrandLogoFit(raw: unknown): BrandLogoFit {
const s = String(raw ?? "").trim();
if (s === "cover" || s === "contain" || s === "repeat" || s === "stretch") return s;
return "contain";
}
export const BRAND_LIMITS = {
wordmark: 16,
slogan: 32,
keywordCount: 16,
keywordRunes: 20,
footerLinks: 8,
footerLabel: 32,
footerURL: 200,
logoBytes: 2 * 1024 * 1024,
faviconBytes: 512 * 1024,
} as const;
const LOGO_URL_RE = /^\/uploads\/brand\/[0-9a-f]{32}\.(png|webp|svg)$/;
const FAVICON_URL_RE = /^\/uploads\/brand\/[0-9a-f]{32}\.(png|webp|svg|ico)$/;
export function runeCount(s: string): number {
return [...s].length;
}
/** 浏览器默认标题:有标语则为「名称 · 标语」,否则只有名称 */
export function siteDocumentTitle(name: string, slogan: string): string {
const n = name.trim() || "姜十三论坛";
const s = slogan.trim();
return s ? `${n} · ${s}` : n;
}
export function normalizeBrandURL(slot: BrandSlot, raw: unknown): string {
const u = String(raw ?? "")
.trim()
.toLowerCase();
if (!u) return "";
const re = slot === "favicon" ? FAVICON_URL_RE : LOGO_URL_RE;
return re.test(u) ? u : "";
}
/** 品牌图固定对外地址(与 Go service/brand.go 的 BrandAliasPath* 对齐):
* 友链等外部引用此地址,站点换图后地址不变 */
export function brandAliasPath(slot: BrandSlot): string {
if (slot === "logo_light") return "/api/brand/logo-light";
if (slot === "logo_dark") return "/api/brand/logo-dark";
return "/api/brand/favicon";
}
export function normalizeKeywords(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const item of raw) {
const parts = String(item ?? "").split(/[,,;;]/);
for (const part of parts) {
const k = part.trim();
if (!k || runeCount(k) > BRAND_LIMITS.keywordRunes) continue;
const key = k.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(k);
if (out.length >= BRAND_LIMITS.keywordCount) return out;
}
}
return out;
}
export function isExternalFooterURL(url: string): boolean {
return /^https?:\/\//i.test(url.trim());
}
/** 页脚地址:站内单斜杠路径,或 http(s) */
export function footerURLAllowed(raw: string): boolean {
const u = raw.trim();
if (!u || runeCount(u) > BRAND_LIMITS.footerURL) return false;
if (/\s/.test(u) || u.includes("\\")) return false;
if (/^(javascript|data):/i.test(u) || u.startsWith("//")) return false;
if (isExternalFooterURL(u)) return true;
return u.startsWith("/") && !u.startsWith("//");
}
export function normalizeFooterLinks(raw: unknown): FooterLink[] {
if (!Array.isArray(raw)) return [];
const out: FooterLink[] = [];
for (const item of raw) {
if (!item || typeof item !== "object") continue;
const rec = item as { label?: unknown; url?: unknown; new_tab?: unknown };
const label = String(rec.label ?? "").trim();
const url = String(rec.url ?? "").trim();
if (!label || runeCount(label) > BRAND_LIMITS.footerLabel) continue;
if (!footerURLAllowed(url)) continue;
const omitted = rec.new_tab === undefined || rec.new_tab === null;
const newTab = omitted ? isExternalFooterURL(url) : rec.new_tab !== false;
const align = normalizeFooterLinksAlign((rec as { align?: unknown }).align);
out.push({ label, url, new_tab: newTab, align });
if (out.length >= BRAND_LIMITS.footerLinks) break;
}
return out;
}
export function brandAccept(slot: BrandSlot): string {
if (slot === "favicon") return "image/png,image/webp,image/svg+xml,.ico,image/x-icon";
return "image/png,image/webp,image/svg+xml";
}
export function brandFileOk(slot: BrandSlot, file: File): string | null {
const name = file.name.toLowerCase();
const logo = slot !== "favicon";
const okExt = logo
? /\.(png|webp|svg)$/.test(name)
: /\.(png|webp|svg|ico)$/.test(name);
if (!okExt) {
return logo ? "Logo 仅支持 PNG / WebP / SVG" : "Favicon 仅支持 PNG / WebP / SVG / ICO";
}
const max = logo ? BRAND_LIMITS.logoBytes : BRAND_LIMITS.faviconBytes;
if (file.size > max) {
return logo ? "Logo 不能超过 2MB" : "Favicon 不能超过 512KB";
}
return null;
}