完善站点运营设置:分路由管理、登录可见评论、邮件模板内联与关闭注册 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

@@ -25,8 +25,9 @@ import {
import { canModerateBoard, canStaffSoftDelete, canPurgeContent } from "@/lib/roles";
import { toast } from "@/lib/toast";
import { formatCommentTime, formatDateTime } from "@/lib/format";
import { useAllowComments } from "@/components/CommentsPolicyProvider";
import { useAllowComments, useCommentsRequireLogin } from "@/components/CommentsPolicyProvider";
import CommentsUnsupported from "@/components/CommentsUnsupported";
import CommentsLoginRequired from "@/components/CommentsLoginRequired";
import Avatar from "./Avatar";
import Pagination from "./Pagination";
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
@@ -197,6 +198,7 @@ export default function CommentSection({
const winnerCommentIdSet = new Set(winnerCommentIds.filter((id) => id > 0));
const router = useRouter();
const commentsOpen = useAllowComments();
const commentsRequireLogin = useCommentsRequireLogin();
const [content, setContent] = useState("");
// 内联回复目标:同一时间只有一个回复框;草稿按目标评论隔离
const [replyTarget, setReplyTarget] = useState<{
@@ -1177,6 +1179,9 @@ export default function CommentSection({
if (!commentsOpen) {
return <CommentsUnsupported />;
}
if (commentsRequireLogin && !user) {
return <CommentsLoginRequired redirect={`/post/${postId}#comments`} />;
}
return (
<section id="comments" className="panel p-6 sm:p-8 mt-6 scroll-mt-24" aria-label="评论区">

View File

@@ -0,0 +1,30 @@
import Link from "next/link";
import { LogIn } from "lucide-react";
/** 站点开启「登录才可见评论」时,游客看到的评论区占位 */
export default function CommentsLoginRequired({ redirect }: { redirect: string }) {
const href = `/login?redirect=${encodeURIComponent(redirect)}`;
return (
<section className="panel p-6 sm:p-8 mt-6 text-center" aria-label="评论">
<span
className="mx-auto mb-3 flex h-11 w-11 items-center justify-center rounded-xl"
style={{
background: "color-mix(in srgb, var(--accent) 16%, transparent)",
color: "var(--accent)",
}}
aria-hidden
>
<LogIn size={22} />
</span>
<p className="text-[15px] font-semibold" style={{ color: "var(--ink)" }}>
登录后查看评论
</p>
<p className="meta mt-1.5 text-[12.5px] leading-relaxed">
本站评论仅对登录用户开放。
</p>
<Link href={href} className="btn btn-primary btn-sm mt-5 inline-flex">
去登录
</Link>
</section>
);
}

View File

@@ -11,42 +11,68 @@ import {
import { useRouter } from "next/navigation";
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
const AllowCommentsContext = createContext(true);
type CommentsPolicy = {
open: boolean;
requireLogin: boolean;
};
const CommentsPolicyContext = createContext<CommentsPolicy>({
open: true,
requireLogin: false,
});
/** 前台是否开放评论(SSR 初值 + 后台开关热更新) */
export function useAllowComments(): boolean {
return useContext(AllowCommentsContext);
return useContext(CommentsPolicyContext).open;
}
/** 评论是否仅登录可见 */
export function useCommentsRequireLogin(): boolean {
return useContext(CommentsPolicyContext).requireLogin;
}
/**
* 根布局注入:关闭评论后前台立即隐藏评论区/数量;开启则刷新以拉回评论。
* 根布局注入:关闭评论后前台立即隐藏评论区/数量;
* 「登录可见」切换时刷新以重拉/清空评论树。
*/
export default function CommentsPolicyProvider({
allowComments,
requireLogin = false,
children,
}: {
allowComments: boolean;
requireLogin?: boolean;
children: ReactNode;
}) {
const router = useRouter();
const [open, setOpen] = useState(allowComments);
const openRef = useRef(open);
openRef.current = open;
const [policy, setPolicy] = useState<CommentsPolicy>({
open: allowComments,
requireLogin,
});
const policyRef = useRef(policy);
policyRef.current = policy;
useEffect(() => {
setOpen(allowComments);
}, [allowComments]);
setPolicy({ open: allowComments, requireLogin });
}, [allowComments, requireLogin]);
useEffect(() => {
return realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
if (typeof data?.allow_comments !== "boolean") return;
if (openRef.current === data.allow_comments) return;
setOpen(data.allow_comments);
if (!data) return;
const prev = policyRef.current;
const nextOpen =
typeof data.allow_comments === "boolean" ? data.allow_comments : prev.open;
const nextRequire =
typeof data.comments_require_login === "boolean"
? data.comments_require_login
: prev.requireLogin;
if (nextOpen === prev.open && nextRequire === prev.requireLogin) return;
setPolicy({ open: nextOpen, requireLogin: nextRequire });
router.refresh();
});
}, [router]);
return (
<AllowCommentsContext.Provider value={open}>{children}</AllowCommentsContext.Provider>
<CommentsPolicyContext.Provider value={policy}>{children}</CommentsPolicyContext.Provider>
);
}

View File

@@ -1,5 +1,5 @@
import pkg from "../package.json";
import type { FooterLink } from "@/lib/brand";
import type { FooterLink, FooterLinksAlign } from "@/lib/brand";
import FooterTiming from "./FooterTiming";
// 官方镜像构建会写入 NEXT_PUBLIC_APP_VERSION(可与脚手架 0.1.0 相同)。
@@ -10,7 +10,29 @@ function realVersion(pkgVersion: string): string {
return !pkgVersion || pkgVersion === "0.0.0" || pkgVersion === "0.1.0" ? "" : pkgVersion;
}
// 极简页脚:左侧版权 / 版本 / 耗时,右侧备案等链接
function zoneLinks(links: FooterLink[], align: FooterLinksAlign): FooterLink[] {
return links.filter((item) => (item.align || "right") === align);
}
function LinkGroup({ items }: { items: FooterLink[] }) {
if (items.length === 0) return null;
return (
<div className="site-footer-links">
{items.map((item) => (
<a
key={`${item.align}-${item.label}-${item.url}`}
href={item.url}
className="meta text-[12px] hover:underline"
{...(item.new_tab ? { target: "_blank", rel: "noopener noreferrer" } : {})}
>
{item.label}
</a>
))}
</div>
);
}
// 极简页脚:版权 / 版本 / 耗时 + 左 / 中 / 右三栏链接
export default function Footer({
siteName = "姜十三社区",
links = [],
@@ -20,34 +42,33 @@ export default function Footer({
}) {
const year = new Date().getFullYear();
const brand = siteName.trim() || "姜十三社区";
const visibleLinks = links.filter((item) => item.label.trim() && item.url.trim());
const visible = links.filter((item) => item.label.trim() && item.url.trim());
const left = zoneLinks(visible, "left");
const center = zoneLinks(visible, "center");
const right = zoneLinks(visible, "right");
return (
<footer
className="site-footer"
style={{
borderTop: "1px solid var(--line)",
background: "var(--panel)",
}}
>
<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">
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap min-w-0">
<span className="meta text-[12px] whitespace-nowrap">© {year} {brand}</span>
<FooterTiming version={realVersion(pkg.version)} />
<div className="site-footer-inner max-w-[1440px] mx-auto px-4 sm:px-6 py-4">
<div className="site-footer-zone site-footer-zone-left">
<div className="site-footer-meta">
<span className="meta text-[12px] whitespace-nowrap">© {year} {brand}</span>
<FooterTiming version={realVersion(pkg.version)} />
</div>
<LinkGroup items={left} />
</div>
<div className="site-footer-zone site-footer-zone-center">
<LinkGroup items={center} />
</div>
<div className="site-footer-zone site-footer-zone-right">
<LinkGroup items={right} />
</div>
{visibleLinks.length > 0 ? (
<nav aria-label="页脚链接" className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1 min-w-0 ml-auto">
{visibleLinks.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}
</div>
</footer>
);

View File

@@ -46,7 +46,6 @@ export default function Header({
brandMark = "image_text",
brandLogoSize = "sq",
brandLogoFit = "contain",
allowRegister = true,
}: {
initialUser: User | null;
initialUnread?: number;
@@ -60,7 +59,6 @@ export default function Header({
brandMark?: BrandMark;
brandLogoSize?: BrandLogoSize;
brandLogoFit?: BrandLogoFit;
allowRegister?: boolean;
}) {
const router = useRouter();
const pathname = usePathname();
@@ -334,11 +332,9 @@ export default function Header({
<Link href="/login" className="btn btn-line btn-sm">
<LogIn size={14} /> 登录
</Link>
{allowRegister && (
<Link href="/register" className="btn btn-primary btn-sm">
<UserPlus size={14} /> 注册
</Link>
)}
<Link href="/register" className="btn btn-primary btn-sm">
<UserPlus size={14} /> 注册
</Link>
</div>
)}
<button
@@ -402,7 +398,7 @@ export default function Header({
</MobileLink>
)}
{!user && <MobileLink href="/login">登录</MobileLink>}
{!user && allowRegister && <MobileLink href="/register">注册</MobileLink>}
{!user && <MobileLink href="/register">注册</MobileLink>}
</div>
<div className="pt-3 mt-2 border-t flex items-center justify-between" style={{ borderColor: "var(--line)" }}>
<ThemeToggle

View File

@@ -0,0 +1,17 @@
"use client";
import {useEffect,useState} from "react";
import {usePathname} from "next/navigation";
import {apiOperations} from "@/lib/api";
export default function OperationalBanner(){
const path=usePathname();
const [notice,setNotice]=useState("");
useEffect(()=>{
let active=true;
const refresh=()=>{if(document.visibilityState!=="visible")return; void apiOperations<{maintenance:{mode:string;title:string;message:string}} >("/api/site-state").then(s=>{if(active)setNotice(s.maintenance.mode==="readonly" ? (s.maintenance.message || "站点暂时只读,可以浏览,暂不能提交或修改内容。") : "");}).catch(()=>{});};
refresh();const timer=setInterval(refresh,30000);
document.addEventListener("visibilitychange",refresh);
return ()=>{active=false;clearInterval(timer);document.removeEventListener("visibilitychange",refresh);};
},[path]);
if(path.startsWith("/admin") || !notice)return null;
return <div role="status" className="panel mx-auto my-3 w-full max-w-5xl p-4 text-sm"><strong>只读模式:</strong>{notice}</div>;
}

View File

@@ -2,7 +2,7 @@
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 { siteDocumentTitle, normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark, normalizeFooterLinks } from "@/lib/brand";
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
import { onDocumentVisible, VISIBLE_RECONCILE_GAP_MS } from "@/lib/visibilityReconcile";
@@ -52,29 +52,18 @@ function sameBrand(a: SiteBrand, b: SiteBrand): boolean {
}
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();
});
// 只管理 #j13-favicon,禁止 remove 其它 icon 节点:
// Next Metadata 注入的 <link rel="icon"> 由 React 持有,手动删会导致 removeChild 空指针。
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;
const href = url || "/favicon.ico";
if (link.getAttribute("href") !== href) link.href = href;
// 挪到 head 末尾,优先于 SSR 注入的 icon
document.head.appendChild(link);
}
function retitle(prev: SiteBrand, next: SiteBrand) {
@@ -132,7 +121,7 @@ export default function SiteBrandProvider({
setBrand((prev) => {
if (sameBrand(prev, next)) return prev;
retitle(prev, next);
applyFavicon(next.favicon_url);
if (prev.favicon_url !== next.favicon_url) applyFavicon(next.favicon_url);
return next;
});
}, [initial]);
@@ -150,11 +139,13 @@ export default function SiteBrandProvider({
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,
footer_links: Array.isArray(raw.footer_links)
? normalizeFooterLinks(raw.footer_links)
: prev.footer_links,
};
if (sameBrand(prev, next)) return;
retitle(prev, next);
applyFavicon(next.favicon_url);
if (prev.favicon_url !== next.favicon_url) applyFavicon(next.favicon_url);
brandRef.current = next;
setBrand(next);
};

View File

@@ -40,7 +40,6 @@ export default function SiteChrome({
unread,
chatUnread,
theme,
allowRegister,
children,
}: {
initialIsAdmin: boolean;
@@ -48,7 +47,6 @@ export default function SiteChrome({
unread: number;
chatUnread: number;
theme: "light" | "dark";
allowRegister: boolean;
children: React.ReactNode;
}) {
const pathname = usePathname();
@@ -103,7 +101,6 @@ export default function SiteChrome({
brandMark={brand.brand_mark}
brandLogoSize={brand.brand_logo_size}
brandLogoFit={brand.brand_logo_fit}
allowRegister={allowRegister}
/>
<main
id="main"

View File

@@ -95,7 +95,7 @@ export function AdminSettingsRow({
<div className="admin-settings-row-control">
{children}
{hintBelow != null && hintBelow !== "" ? (
<p className="admin-settings-row-hint is-below">{hintBelow}</p>
<div className="admin-settings-row-hint is-below">{hintBelow}</div>
) : null}
{error != null && error !== "" ? (
<p className="admin-settings-row-error" id={errorId} role="alert">