后台站点设计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

View File

@@ -0,0 +1,104 @@
import { brandLogoBox, type BrandLogoFit, type BrandLogoSize, type BrandMark } from "@/lib/brand";
/** 页眉品牌锁:图加文字 / 纯图 / 纯文字。图槽按画幅占位,铺法决定图怎么填进框。 */
export default function BrandLockup({
mode,
size = "sq",
fit = "contain",
name,
wordmark = "",
slogan = "",
logoLight = "",
logoDark = "",
frame = false,
}: {
mode: BrandMark;
size?: BrandLogoSize;
fit?: BrandLogoFit;
name: string;
wordmark?: string;
slogan?: string;
logoLight?: string;
logoDark?: string;
/** 后台预览画出画幅框,方便看非正方形占位 */
frame?: boolean;
}) {
const label = wordmark.trim() || name;
const line = slogan.trim();
const showImage = mode !== "text";
const showText = mode !== "image";
const box = brandLogoBox(size);
const src = logoLight || logoDark;
const both = !!(logoLight && logoDark && logoLight !== logoDark);
const useBox = showImage && (!!src || frame);
return (
<span className="j13-brand-lockup inline-flex items-center gap-2.5 min-w-0">
{showImage ? (
useBox ? (
<span
className="j13-brand-slot"
data-frame={frame ? "1" : undefined}
data-fit={src ? fit : undefined}
style={{ width: box.w, height: box.h }}
>
{src ? (
both ? (
<>
<Logo src={logoLight} fit={fit} extra="j13-brand-logo-light" />
<Logo src={logoDark} fit={fit} extra="j13-brand-logo-dark" />
</>
) : (
<Logo src={src} fit={fit} />
)
) : (
<Bubble h={box.h} />
)}
</span>
) : (
<Bubble h={36} />
)
) : null}
{showText ? (
<span className="leading-none min-w-0">
<span className="block font-extrabold text-[17px] tracking-tight truncate" style={{ color: "var(--ink)" }}>
{label}
</span>
{line ? (
<span className="block text-[10.5px] mt-0.5 truncate" style={{ color: "var(--ink-3)" }}>
{line}
</span>
) : null}
</span>
) : null}
</span>
);
}
function Logo({ src, fit, extra = "" }: { src: string; fit: BrandLogoFit; extra?: string }) {
if (fit === "repeat") {
return (
<span
className={`j13-brand-logo j13-brand-logo-tile ${extra}`}
style={{ backgroundImage: `url("${src.replace(/["\\\n\r]/g, "")}")` }}
/>
);
}
return (
/* eslint-disable-next-line @next/next/no-img-element */
<img src={src} alt="" className={`j13-brand-logo ${extra}`} />
);
}
function Bubble({ h }: { h: number }) {
return (
<span
className="j13-brand-bubble rounded-2xl flex items-center justify-center shrink-0 transition-transform duration-300 group-hover:rotate-[-6deg]"
style={{ width: h, height: h, background: "var(--accent)", color: "var(--accent-on)" }}
>
<svg width="50%" height="50%" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
</span>
);
}

View File

@@ -1,4 +1,5 @@
import pkg from "../package.json";
import type { FooterLink } from "@/lib/brand";
import FooterTiming from "./FooterTiming";
// 官方镜像构建会写入 NEXT_PUBLIC_APP_VERSION(可与脚手架 0.1.0 相同)。
@@ -9,8 +10,14 @@ function realVersion(pkgVersion: string): string {
return !pkgVersion || pkgVersion === "0.0.0" || pkgVersion === "0.1.0" ? "" : pkgVersion;
}
// 极简页脚:版权 + 实时性能指标(版本 / 页面加载 / 模板生成)
export default function Footer({ siteName = "姜十三社区" }: { siteName?: string }) {
// 极简页脚:版权 + 合规链接 + 实时性能指标
export default function Footer({
siteName = "姜十三社区",
links = [],
}: {
siteName?: string;
links?: FooterLink[];
}) {
const year = new Date().getFullYear();
const brand = siteName.trim() || "姜十三社区";
@@ -21,8 +28,22 @@ export default function Footer({ siteName = "姜十三社区" }: { siteName?: st
background: "var(--panel)",
}}
>
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 py-4 flex items-center justify-between gap-4">
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 py-4 flex items-center justify-between gap-x-4 gap-y-2 flex-wrap">
<span className="meta text-[12px] whitespace-nowrap">© {year} {brand}</span>
{links.length > 0 ? (
<nav aria-label="页脚链接" className="flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0">
{links.map((item) => (
<a
key={`${item.label}-${item.url}`}
href={item.url}
className="meta text-[12px] hover:underline"
{...(item.new_tab ? { target: "_blank", rel: "noopener noreferrer" } : {})}
>
{item.label}
</a>
))}
</nav>
) : null}
<FooterTiming version={realVersion(pkg.version)} />
</div>
</footer>

View File

@@ -29,7 +29,9 @@ import { onDocumentVisible } from "@/lib/visibilityReconcile";
import NotificationBell from "./NotificationBell";
import ThemeToggle from "./ThemeToggle";
import Avatar from "./Avatar";
import BrandLockup from "./BrandLockup";
import { useAllowMessages } from "./MessagesPolicyProvider";
import type { BrandLogoFit, BrandLogoSize, BrandMark } from "@/lib/brand";
export default function Header({
initialUser,
@@ -37,6 +39,13 @@ export default function Header({
initialChatUnread = 0,
initialTheme = "light",
siteName = "姜十三",
wordmark = "",
slogan = "",
logoLight = "",
logoDark = "",
brandMark = "image_text",
brandLogoSize = "sq",
brandLogoFit = "contain",
allowRegister = true,
}: {
initialUser: User | null;
@@ -44,6 +53,13 @@ export default function Header({
initialChatUnread?: number;
initialTheme?: "light" | "dark";
siteName?: string;
wordmark?: string;
slogan?: string;
logoLight?: string;
logoDark?: string;
brandMark?: BrandMark;
brandLogoSize?: BrandLogoSize;
brandLogoFit?: BrandLogoFit;
allowRegister?: boolean;
}) {
const router = useRouter();
@@ -205,23 +221,17 @@ export default function Header({
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 h-[var(--header-bar)] flex items-center gap-4 lg:gap-7">
{/* 左组:品牌 + 主导航 */}
<div className="flex items-center gap-3 lg:gap-5 shrink-0">
<Link href="/" className="flex items-center gap-2.5 group">
<span
className="w-9 h-9 rounded-2xl flex items-center justify-center transition-transform duration-300 group-hover:rotate-[-6deg]"
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
</span>
<span className="leading-none">
<span className="block font-extrabold text-[17px] tracking-tight" style={{ color: "var(--ink)" }}>
{siteName}
</span>
<span className="block text-[10.5px] mt-0.5" style={{ color: "var(--ink-3)" }}>
J13 社区
</span>
</span>
<Link href="/" className="flex items-center group min-w-0" aria-label={siteName}>
<BrandLockup
mode={brandMark}
size={brandLogoSize}
fit={brandLogoFit}
name={siteName}
wordmark={wordmark}
slogan={slogan}
logoLight={logoLight}
logoDark={logoDark}
/>
</Link>
<nav className="hidden lg:flex items-center gap-1.5 pl-3" style={{ borderLeft: "1px solid var(--line)" }}>
<NavPill href="/" active={pathname === "/"} icon={<Home size={15} />}>

View File

@@ -0,0 +1,183 @@
"use client";
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { apiGetSettings, type PublicSettings } from "@/lib/api";
import { siteDocumentTitle, normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark } from "@/lib/brand";
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
import { onDocumentVisible, VISIBLE_RECONCILE_GAP_MS } from "@/lib/visibilityReconcile";
export type SiteBrand = {
site_name: string;
site_wordmark: string;
site_slogan: string;
logo_light_url: string;
logo_dark_url: string;
favicon_url: string;
brand_mark: PublicSettings["brand_mark"];
brand_logo_size: PublicSettings["brand_logo_size"];
brand_logo_fit: PublicSettings["brand_logo_fit"];
footer_links: PublicSettings["footer_links"];
};
const SiteBrandContext = createContext<SiteBrand | null>(null);
function fromSettings(s: Pick<PublicSettings, keyof SiteBrand>): SiteBrand {
return {
site_name: s.site_name,
site_wordmark: s.site_wordmark,
site_slogan: s.site_slogan,
logo_light_url: s.logo_light_url,
logo_dark_url: s.logo_dark_url,
favicon_url: s.favicon_url,
brand_mark: s.brand_mark,
brand_logo_size: s.brand_logo_size,
brand_logo_fit: s.brand_logo_fit,
footer_links: s.footer_links,
};
}
function sameBrand(a: SiteBrand, b: SiteBrand): boolean {
return (
a.site_name === b.site_name &&
a.site_wordmark === b.site_wordmark &&
a.site_slogan === b.site_slogan &&
a.logo_light_url === b.logo_light_url &&
a.logo_dark_url === b.logo_dark_url &&
a.favicon_url === b.favicon_url &&
a.brand_mark === b.brand_mark &&
a.brand_logo_size === b.brand_logo_size &&
a.brand_logo_fit === b.brand_logo_fit &&
JSON.stringify(a.footer_links) === JSON.stringify(b.footer_links)
);
}
function applyFavicon(url: string) {
const existing = document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]');
if (!url) {
document.getElementById("j13-favicon")?.remove();
const hasDefault = [...existing].some((el) => el.id !== "j13-favicon" && el.href.includes("favicon"));
if (!hasDefault) {
const link = document.createElement("link");
link.rel = "icon";
link.href = "/favicon.ico";
document.head.appendChild(link);
}
return;
}
existing.forEach((el) => {
if (el.id !== "j13-favicon") el.remove();
});
let link = document.getElementById("j13-favicon") as HTMLLinkElement | null;
if (!link) {
link = document.createElement("link");
link.id = "j13-favicon";
link.rel = "icon";
document.head.appendChild(link);
}
if (link.getAttribute("href") !== url) link.href = url;
}
function retitle(prev: SiteBrand, next: SiteBrand) {
if (prev.site_name === next.site_name && prev.site_slogan === next.site_slogan) return;
const oldDefault = siteDocumentTitle(prev.site_name, prev.site_slogan);
const nextDefault = siteDocumentTitle(next.site_name, next.site_slogan);
const cur = document.title;
if (cur === oldDefault) {
document.title = nextDefault;
return;
}
const oldName = prev.site_name.trim() || "姜十三论坛";
const newName = next.site_name.trim() || "姜十三论坛";
const suffix = ` · ${oldName}`;
if (oldName !== newName && cur.endsWith(suffix)) {
document.title = cur.slice(0, -suffix.length) + ` · ${newName}`;
}
}
export function useSiteBrand(): SiteBrand {
const ctx = useContext(SiteBrandContext);
if (!ctx) {
return {
site_name: "姜十三论坛",
site_wordmark: "",
site_slogan: "",
logo_light_url: "",
logo_dark_url: "",
favicon_url: "",
brand_mark: "image_text",
brand_logo_size: "sq",
brand_logo_fit: "contain",
footer_links: [],
};
}
return ctx;
}
/** SSR 初值 + settings:changed:页眉/页脚/标题/favicon 不必整页刷新 */
export default function SiteBrandProvider({
initial,
children,
}: {
initial: PublicSettings;
children: ReactNode;
}) {
const [brand, setBrand] = useState<SiteBrand>(() => fromSettings(initial));
const brandRef = useRef(brand);
brandRef.current = brand;
const value = useMemo(() => brand, [brand]);
useEffect(() => {
const next = fromSettings(initial);
setBrand((prev) => {
if (sameBrand(prev, next)) return prev;
retitle(prev, next);
applyFavicon(next.favicon_url);
return next;
});
}, [initial]);
useEffect(() => {
const apply = (raw: Partial<PublicSettings> | RtSettingsData) => {
const prev = brandRef.current;
const next: SiteBrand = {
site_name: typeof raw.site_name === "string" && raw.site_name.trim() ? raw.site_name : prev.site_name,
site_wordmark: typeof raw.site_wordmark === "string" ? raw.site_wordmark : prev.site_wordmark,
site_slogan: typeof raw.site_slogan === "string" ? raw.site_slogan : prev.site_slogan,
logo_light_url: typeof raw.logo_light_url === "string" ? raw.logo_light_url : prev.logo_light_url,
logo_dark_url: typeof raw.logo_dark_url === "string" ? raw.logo_dark_url : prev.logo_dark_url,
favicon_url: typeof raw.favicon_url === "string" ? raw.favicon_url : prev.favicon_url,
brand_mark: normalizeBrandMark(raw.brand_mark ?? prev.brand_mark),
brand_logo_size: normalizeBrandLogoSize(raw.brand_logo_size ?? prev.brand_logo_size),
brand_logo_fit: normalizeBrandLogoFit(raw.brand_logo_fit ?? prev.brand_logo_fit),
footer_links: Array.isArray(raw.footer_links) ? raw.footer_links : prev.footer_links,
};
if (sameBrand(prev, next)) return;
retitle(prev, next);
applyFavicon(next.favicon_url);
brandRef.current = next;
setBrand(next);
};
const off = realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
if (data) apply(data);
});
let last = 0;
const offVisible = onDocumentVisible(() => {
const now = Date.now();
if (now - last < VISIBLE_RECONCILE_GAP_MS) return;
last = now;
void apiGetSettings()
.then(apply)
.catch(() => {});
}, VISIBLE_RECONCILE_GAP_MS);
return () => {
off();
offVisible();
};
}, []);
return <SiteBrandContext.Provider value={value}>{children}</SiteBrandContext.Provider>;
}

View File

@@ -6,6 +6,7 @@ import Header from "@/components/Header";
import Footer from "@/components/Footer";
import BackToTop from "@/components/BackToTop";
import type { User } from "@/lib/api";
import { useSiteBrand } from "@/components/SiteBrandProvider";
function pathIsAdmin(pathname: string | null) {
if (!pathname) return false;
@@ -39,7 +40,6 @@ export default function SiteChrome({
unread,
chatUnread,
theme,
siteName,
allowRegister,
children,
}: {
@@ -48,7 +48,6 @@ export default function SiteChrome({
unread: number;
chatUnread: number;
theme: "light" | "dark";
siteName: string;
allowRegister: boolean;
children: React.ReactNode;
}) {
@@ -80,6 +79,8 @@ export default function SiteChrome({
document.documentElement.dataset.j13Chrome = isAdmin ? "admin" : "site";
}, [isAdmin]);
const brand = useSiteBrand();
if (isAdmin) {
return <>{children}</>;
}
@@ -94,7 +95,14 @@ export default function SiteChrome({
initialUnread={unread}
initialChatUnread={chatUnread}
initialTheme={theme}
siteName={siteName}
siteName={brand.site_name}
wordmark={brand.site_wordmark}
slogan={brand.site_slogan}
logoLight={brand.logo_light_url}
logoDark={brand.logo_dark_url}
brandMark={brand.brand_mark}
brandLogoSize={brand.brand_logo_size}
brandLogoFit={brand.brand_logo_fit}
allowRegister={allowRegister}
/>
<main
@@ -104,7 +112,7 @@ export default function SiteChrome({
>
{children}
</main>
<Footer siteName={siteName} />
<Footer siteName={brand.site_name} links={brand.footer_links} />
{showBackToTop ? (
<BackToTop liftForMobileBar={pathIsPostDetail(pathname)} />
) : null}