完善站点运营设置:分路由管理、登录可见评论、邮件模板内联与关闭注册 SSR。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-23 00:17:00 +08:00
parent acde7ee288
commit 6f054a903c
98 changed files with 8239 additions and 2073 deletions

View File

@@ -10,11 +10,13 @@ import {
type BrandMark,
type BrandSlot,
type FooterLink,
type FooterLinksAlign,
} from "./brand";
import { CSRF_COOKIE } from "./cookies";
import { publishPublicMetaSnapshot } from "./publicMetaSnapshot";
import { emitForceLogout } from "./userEvents";
export type { FooterLink };
export type { FooterLink, FooterLinksAlign };
// API 基础配置
// 注意:客户端请求使用相对路径 /api/*,走 Next.js rewrite 代理到后端,
@@ -684,6 +686,8 @@ export interface PublicSettings {
allow_register: boolean;
/** 全站是否开放评论;关闭后前台不展示评论能力 */
allow_comments: boolean;
/** 评论仅登录可见;缺省 false(游客可读) */
comments_require_login: boolean;
/** 全站是否开放私聊/群聊;关闭后前台不展示消息入口 */
allow_messages: boolean;
post_cooldown_hours: number;
@@ -749,6 +753,7 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
footer_links: [],
allow_register: true,
allow_comments: true,
comments_require_login: false,
allow_messages: true,
post_cooldown_hours: 24,
code_block_auto_fold: true,
@@ -821,6 +826,7 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
footer_links: normalizeFooterLinks(data?.footer_links),
allow_register: data?.allow_register !== false,
allow_comments: data?.allow_comments !== false,
comments_require_login: data?.comments_require_login === true,
allow_messages: data?.allow_messages !== false,
post_cooldown_hours:
typeof data?.post_cooldown_hours === "number" && data.post_cooldown_hours >= 0
@@ -857,10 +863,52 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
export async function fetchPublicSettings(): Promise<PublicSettings> {
try {
const res = await fetch(`${API_BASE}/api/settings`, ssrInit());
if (!res.ok) return { ...DEFAULT_PUBLIC_SETTINGS };
return normalizePublicSettings((await res.json()) as Partial<PublicSettings>);
if (!res.ok) {
const fallback = { ...DEFAULT_PUBLIC_SETTINGS };
publishPublicMetaSnapshot(fallback);
return fallback;
}
const normalized = normalizePublicSettings((await res.json()) as Partial<PublicSettings>);
publishPublicMetaSnapshot(normalized);
return normalized;
} catch {
return { ...DEFAULT_PUBLIC_SETTINGS };
const fallback = { ...DEFAULT_PUBLIC_SETTINGS };
publishPublicMetaSnapshot(fallback);
return fallback;
}
}
/** 公开站点运行态(注册/找回/维护等);SSR 与客户端同源字段 */
export type PublicSiteState = {
allow_register: boolean;
register_notice: string;
verify_email: boolean;
password_reset: boolean;
site_url: string;
};
const DEFAULT_SITE_STATE: PublicSiteState = {
allow_register: true,
register_notice: "",
verify_email: false,
password_reset: false,
site_url: "",
};
export async function fetchSiteState(): Promise<PublicSiteState> {
try {
const res = await fetch(`${API_BASE}/api/site-state`, ssrInit());
if (!res.ok) return { ...DEFAULT_SITE_STATE };
const data = (await res.json()) as Partial<PublicSiteState>;
return {
allow_register: data.allow_register !== false,
register_notice: typeof data.register_notice === "string" ? data.register_notice : "",
verify_email: data.verify_email === true,
password_reset: data.password_reset === true,
site_url: typeof data.site_url === "string" ? data.site_url : "",
};
} catch {
return { ...DEFAULT_SITE_STATE };
}
}
@@ -954,12 +1002,12 @@ export async function apiLogin(username: string, password: string) {
return data;
}
export async function apiRegister(username: string, email: string, password: string) {
export async function apiRegister(username: string, email: string, password: string, code = "") {
const res = await fetch("/api/register", {
method: "POST",
credentials: "include",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ username, email, password }),
body: JSON.stringify({ username, email, password, code }),
});
return res.json();
}
@@ -1878,19 +1926,6 @@ export async function apiAdminDeleteSitePage(id: number): Promise<void> {
}
}
export async function apiGetAdminSettings(): Promise<
PublicSettings & { timeline_git_import?: string }
> {
const res = await fetchWithRefresh("/api/admin/settings", { headers: clientHeaders() });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取设置失败");
return {
...normalizePublicSettings(data as Partial<PublicSettings>),
timeline_git_import:
typeof data.timeline_git_import === "string" ? data.timeline_git_import : undefined,
};
}
// ===== 用户管理(管理员) =====
// 后台用户列表项:email/最近活跃/登录 IP 仅管理员接口返回
@@ -2223,6 +2258,7 @@ export type UpdateSiteSettingsBody = {
footer_links?: FooterLink[];
allow_register?: boolean;
allow_comments?: boolean;
comments_require_login?: boolean;
allow_messages?: boolean;
post_cooldown_hours?: number;
code_block_auto_fold?: boolean;
@@ -2241,7 +2277,6 @@ export type UpdateSiteSettingsBody = {
bg_site_mode?: string;
bg_admin_url?: string;
bg_admin_mode?: string;
timeline_git_import?: string;
};
// 更新站点设置(字段可选);accent 传空串恢复默认主题色
@@ -2980,3 +3015,10 @@ export async function apiChatUnreadSummary(): Promise<ChatUnreadSummaryResponse>
if (!res.ok) return { total: 0, rooms: [] };
return res.json();
}
export async function apiOperations<T = Record<string, unknown>>(path: string, method = "GET", body?: unknown): Promise<T> {
const res = await fetchWithRefresh(path, { method, cache: "no-store", headers: clientHeaders({ "Content-Type": "application/json" }), ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "操作失败,请重试");
return data as T;
}

View File

@@ -4,8 +4,25 @@ 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";
/** 页眉品牌形式 */
@@ -135,7 +152,8 @@ export function normalizeFooterLinks(raw: unknown): FooterLink[] {
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 });
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;

View File

@@ -0,0 +1,40 @@
/** 进程内公开元信息快照:供根 layout 同步 generateMetadata,避免 await 触发标题闪成 localhost */
export type PublicMetaSnapshot = {
site_name: string;
site_slogan: string;
site_description: string;
site_keywords: string[];
favicon_url: string;
};
const DEFAULT_SNAP: PublicMetaSnapshot = {
site_name: "姜十三论坛",
site_slogan: "",
site_description: "姜十三论坛 - 技术分享与讨论社区",
site_keywords: [],
favicon_url: "",
};
let snap: PublicMetaSnapshot = { ...DEFAULT_SNAP };
export function publishPublicMetaSnapshot(
s: Partial<PublicMetaSnapshot> | null | undefined,
): void {
if (!s) return;
const name = typeof s.site_name === "string" ? s.site_name.trim() : "";
snap = {
site_name: name || DEFAULT_SNAP.site_name,
site_slogan: typeof s.site_slogan === "string" ? s.site_slogan.trim() : snap.site_slogan,
site_description:
typeof s.site_description === "string" && s.site_description.trim()
? s.site_description.trim()
: snap.site_description,
site_keywords: Array.isArray(s.site_keywords) ? s.site_keywords : snap.site_keywords,
favicon_url: typeof s.favicon_url === "string" ? s.favicon_url.trim() : snap.favicon_url,
};
}
export function peekPublicMetaSnapshot(): PublicMetaSnapshot {
return snap;
}

View File

@@ -52,9 +52,10 @@ export interface RtSettingsData {
brand_mark?: string;
brand_logo_size?: string;
brand_logo_fit?: string;
footer_links?: { label: string; url: string; new_tab: boolean }[];
footer_links?: { label: string; url: string; new_tab: boolean; align?: string }[];
allow_register?: boolean;
allow_comments?: boolean;
comments_require_login?: boolean;
allow_messages?: boolean;
post_cooldown_hours?: number;
code_block_auto_fold?: boolean;

View File

@@ -1,5 +1,5 @@
import { cache } from "react";
import { fetchMe, fetchPublicSettings } from "./api";
import { fetchMe, fetchPublicSettings, fetchSiteState } from "./api";
// 同一 SSR 请求内去重:layout 与页面可能同时需要当前用户,
// 相同 Cookie 头参数下只产生一次 /api/me 调用。
@@ -7,3 +7,6 @@ export const getMeCached = cache((cookieHeader?: string) => fetchMe(cookieHeader
// 同请求内去重:layout 注入主题 CSS 变量、后台页读取当前配置共用
export const getPublicSettingsCached = cache(() => fetchPublicSettings());
// 同请求内去重:注册/找回密码页读取开放状态与提示文案
export const getSiteStateCached = cache(() => fetchSiteState());