Files
jiang13-bbs/frontend/lib/brand.ts
freefire 46e0cdc0b3 feat: 媒体库 blob 去重存储与品牌/注册等前端调整
新增:
- blob_store 二进制去重存储 + blob_dedup 测试

其它:
- 后端 settings/auth/media_library/upload/operations 服务与 handler 调整
- 前端品牌(BrandLockup/BrandCropModal/BrandSeoPanel)、注册、媒体库、分析详情页、Header/SiteChrome 等更新
2026-10-03 02:38:11 +08:00

226 lines
7.8 KiB
TypeScript
Raw Permalink 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";
/** 图槽画幅。方标是默认;其余是页眉里常见的横版比例;auto=原图比例自适应宽度 */
export type BrandLogoSize = "sq" | "2x1" | "3x1" | "4x1" | "16x9" | "auto";
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 },
{ id: "auto", label: "原图", w: 0, h: 40 },
];
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" || s === "auto") return s;
return "sq";
}
/** 各固定画幅的宽高比(auto 不在此表,代表自由比例) */
export const BRAND_LOGO_SIZE_ASPECT: Partial<Record<BrandLogoSize, number>> = {
sq: 1,
"2x1": 2,
"3x1": 3,
"4x1": 4,
"16x9": 16 / 9,
};
/**
* 根据图片原始比例推荐画幅:
* 与某个固定画幅差距 ≤15% 时推荐该画幅,否则推荐 auto(原图自适应)。
*/
export function suggestLogoSize(naturalW: number, naturalH: number): BrandLogoSize {
if (!naturalW || !naturalH) return "auto";
const ratio = naturalW / naturalH;
let best: BrandLogoSize = "sq";
let bestDelta = Infinity;
for (const [id, a] of Object.entries(BRAND_LOGO_SIZE_ASPECT) as [BrandLogoSize, number][]) {
const delta = Math.abs(ratio - a) / a;
if (delta < bestDelta) {
bestDelta = delta;
best = id;
}
}
return bestDelta <= 0.15 ? best : "auto";
}
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";
}
/** 标语是否在页眉显示;缺省 true(与后端缺省一致) */
export function normalizeBrandSloganVisible(raw: unknown): boolean {
if (typeof raw === "boolean") return raw;
if (typeof raw === "string") return raw.trim().toLowerCase() !== "false";
return true;
}
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;
}