后台站点设计UIUX优化
This commit is contained in:
@@ -1,6 +1,21 @@
|
||||
import {
|
||||
normalizeBrandLogoFit,
|
||||
normalizeBrandLogoSize,
|
||||
normalizeBrandMark,
|
||||
normalizeBrandURL,
|
||||
normalizeFooterLinks,
|
||||
normalizeKeywords,
|
||||
type BrandLogoFit,
|
||||
type BrandLogoSize,
|
||||
type BrandMark,
|
||||
type BrandSlot,
|
||||
type FooterLink,
|
||||
} from "./brand";
|
||||
import { CSRF_COOKIE } from "./cookies";
|
||||
import { emitForceLogout } from "./userEvents";
|
||||
|
||||
export type { FooterLink };
|
||||
|
||||
// API 基础配置
|
||||
// 注意:客户端请求使用相对路径 /api/*,走 Next.js rewrite 代理到后端,
|
||||
// 这样浏览器视为同源,Cookie 自动携带,无需处理 CORS。
|
||||
@@ -647,6 +662,21 @@ export interface PublicSettings {
|
||||
trust_reviewed_publish: boolean;
|
||||
site_name: string;
|
||||
site_description: string;
|
||||
/** 无 Logo 时页眉字标;空则回退显示名称 */
|
||||
site_wordmark: string;
|
||||
/** 浏览器标题后缀与页眉副标题;空则不显示 */
|
||||
site_slogan: string;
|
||||
site_keywords: string[];
|
||||
logo_light_url: string;
|
||||
logo_dark_url: string;
|
||||
favicon_url: string;
|
||||
/** 页眉形式:image_text 图加文字 / image 纯图 / text 纯文字 */
|
||||
brand_mark: BrandMark;
|
||||
/** 图槽画幅 */
|
||||
brand_logo_size: BrandLogoSize;
|
||||
/** 图槽铺法:cover 填充 / contain 适应 / repeat 平铺 / stretch 拉伸 */
|
||||
brand_logo_fit: BrandLogoFit;
|
||||
footer_links: FooterLink[];
|
||||
allow_register: boolean;
|
||||
/** 全站是否开放评论;关闭后前台不展示评论能力 */
|
||||
allow_comments: boolean;
|
||||
@@ -703,6 +733,16 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
|
||||
trust_reviewed_publish: true,
|
||||
site_name: "姜十三论坛",
|
||||
site_description: "姜十三论坛 - 技术分享与讨论社区",
|
||||
site_wordmark: "",
|
||||
site_slogan: "",
|
||||
site_keywords: [],
|
||||
logo_light_url: "",
|
||||
logo_dark_url: "",
|
||||
favicon_url: "",
|
||||
brand_mark: "image_text",
|
||||
brand_logo_size: "sq",
|
||||
brand_logo_fit: "contain",
|
||||
footer_links: [],
|
||||
allow_register: true,
|
||||
allow_comments: true,
|
||||
allow_messages: true,
|
||||
@@ -765,6 +805,16 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
|
||||
data?.site_description !== undefined
|
||||
? String(data.site_description).trim()
|
||||
: DEFAULT_PUBLIC_SETTINGS.site_description,
|
||||
site_wordmark: String(data?.site_wordmark ?? "").trim(),
|
||||
site_slogan: String(data?.site_slogan ?? "").trim(),
|
||||
site_keywords: normalizeKeywords(data?.site_keywords),
|
||||
logo_light_url: normalizeBrandURL("logo_light", data?.logo_light_url),
|
||||
logo_dark_url: normalizeBrandURL("logo_dark", data?.logo_dark_url),
|
||||
favicon_url: normalizeBrandURL("favicon", data?.favicon_url),
|
||||
brand_mark: normalizeBrandMark(data?.brand_mark),
|
||||
brand_logo_size: normalizeBrandLogoSize(data?.brand_logo_size),
|
||||
brand_logo_fit: normalizeBrandLogoFit(data?.brand_logo_fit),
|
||||
footer_links: normalizeFooterLinks(data?.footer_links),
|
||||
allow_register: data?.allow_register !== false,
|
||||
allow_comments: data?.allow_comments !== false,
|
||||
allow_messages: data?.allow_messages !== false,
|
||||
@@ -2141,6 +2191,16 @@ export type UpdateSiteSettingsBody = {
|
||||
trust_reviewed_publish?: boolean;
|
||||
site_name?: string;
|
||||
site_description?: string;
|
||||
site_wordmark?: string;
|
||||
site_slogan?: string;
|
||||
site_keywords?: string[];
|
||||
logo_light_url?: string;
|
||||
logo_dark_url?: string;
|
||||
favicon_url?: string;
|
||||
brand_mark?: BrandMark;
|
||||
brand_logo_size?: BrandLogoSize;
|
||||
brand_logo_fit?: BrandLogoFit;
|
||||
footer_links?: FooterLink[];
|
||||
allow_register?: boolean;
|
||||
allow_comments?: boolean;
|
||||
allow_messages?: boolean;
|
||||
@@ -2176,6 +2236,37 @@ export async function apiUpdateSiteSettings(body: UpdateSiteSettingsBody): Promi
|
||||
return normalizePublicSettings(data as Partial<PublicSettings>);
|
||||
}
|
||||
|
||||
/** 上传品牌图,只落盘返回 URL,不写入站点设置 */
|
||||
export async function apiUploadBrand(slot: BrandSlot, file: File): Promise<string> {
|
||||
const form = new FormData();
|
||||
form.append("slot", slot);
|
||||
form.append("file", file, file.name || "brand.png");
|
||||
const res = await fetchWithRefresh("/api/admin/upload/brand", {
|
||||
method: "POST",
|
||||
headers: clientHeaders(),
|
||||
body: form,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "上传品牌图失败");
|
||||
const url = String(data.url || "");
|
||||
if (!url) throw new Error("上传品牌图失败");
|
||||
return url;
|
||||
}
|
||||
|
||||
/** 把本人媒体库图片复制进品牌目录,不写入站点设置 */
|
||||
export async function apiBrandFromMedia(slot: BrandSlot, attachmentId: number): Promise<string> {
|
||||
const res = await fetchWithRefresh("/api/admin/upload/brand/from-media", {
|
||||
method: "POST",
|
||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ attachment_id: attachmentId, slot }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "选用媒体库图片失败");
|
||||
const url = String(data.url || "");
|
||||
if (!url) throw new Error("选用媒体库图片失败");
|
||||
return url;
|
||||
}
|
||||
|
||||
/** 上传站点背景并立即绑定前台或后台 */
|
||||
export async function apiUploadSiteBackground(
|
||||
surface: "site" | "admin",
|
||||
|
||||
169
frontend/lib/brand.ts
Normal file
169
frontend/lib/brand.ts
Normal 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);
|
||||
}
|
||||
@@ -43,6 +43,16 @@ export interface RtSettingsData {
|
||||
trust_reviewed_publish?: boolean;
|
||||
site_name?: string;
|
||||
site_description?: string;
|
||||
site_wordmark?: string;
|
||||
site_slogan?: string;
|
||||
site_keywords?: string[];
|
||||
logo_light_url?: string;
|
||||
logo_dark_url?: string;
|
||||
favicon_url?: string;
|
||||
brand_mark?: string;
|
||||
brand_logo_size?: string;
|
||||
brand_logo_fit?: string;
|
||||
footer_links?: { label: string; url: string; new_tab: boolean }[];
|
||||
allow_register?: boolean;
|
||||
allow_comments?: boolean;
|
||||
allow_messages?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user