完善站点运营设置:分路由管理、登录可见评论、邮件模板内联与关闭注册 SSR。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
40
frontend/lib/publicMetaSnapshot.ts
Normal file
40
frontend/lib/publicMetaSnapshot.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user