后台站点设计UIUX优化

This commit is contained in:
2026-09-19 04:14:13 +08:00
parent a8efb9ecef
commit 2b3d122e56
24 changed files with 2952 additions and 306 deletions

169
frontend/lib/brand.ts Normal file
View File

@@ -0,0 +1,169 @@
/** 站点品牌与页脚:公开设置字段的前端归一化(与 Go service/brand.go 对齐) */
export type FooterLink = {
label: string;
url: string;
new_tab: boolean;
};
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 : "";
}
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;
out.push({ label, url, new_tab: newTab });
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;
}
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);
}