feat: 可配置站点 URL 风格

- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接
- 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配
- 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成
- middleware 与相关组件适配
This commit is contained in:
2026-09-27 04:01:42 +08:00
parent 1d862a0dd3
commit 7a44c1f2cf
64 changed files with 1183 additions and 200 deletions

View File

@@ -1,6 +1,8 @@
"use client";
import PostComposer from "@/components/PostComposer";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { postPath } from "@/lib/urlStyle";
import type { PostAttachment } from "@/lib/api";
type Props = {
@@ -34,6 +36,7 @@ export default function EditPostClient({
boardColorIndex = 0,
boardIcon,
}: Props) {
const urlStyle = useUrlStyle();
return (
<PostComposer
mode="edit"
@@ -50,7 +53,7 @@ export default function EditPostClient({
boardId={boardId}
boardColorIndex={boardColorIndex}
boardIcon={boardIcon}
cancelHref={`/post/${postId}`}
cancelHref={postPath(urlStyle, postId)}
/>
);
}

View File

@@ -1,9 +1,10 @@
import { cookies } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { fetchPostDetail } from "@/lib/api";
import { canModerateBoard } from "@/lib/roles";
import { postPath } from "@/lib/urlStyle";
import EditPostClient from "./EditPostClient";
interface PageProps {
@@ -29,7 +30,8 @@ export default async function EditPostPage({ params }: PageProps) {
me.user.id === post.user.id ||
canModerateBoard(me.user, post.board_id);
if (!allowed) {
redirect(`/post/${id}`);
const urlStyle = (await getPublicSettingsCached()).url_style;
redirect(postPath(urlStyle, post.id));
}
return (

View File

@@ -46,6 +46,7 @@ import { normalizePostType, postTypeLabel, contentAccessLabel, typeStatusLabel,
import { extractToc, estimateReadingMinutes, markdownHeadingToc } from "@/lib/toc";
import { requestCanonical } from "@/lib/canonicalMeta";
import { absoluteCanonical, canonicalPage } from "@/lib/siteUrl";
import { postPath, userPath, type UrlStyle } from "@/lib/urlStyle";
import { discussionJsonLd, questionJsonLd, type JsonLdVisibleComment } from "@/lib/jsonLd";
import JsonLd from "@/components/JsonLd";
@@ -73,6 +74,7 @@ export async function generateMetadata({ params, searchParams }: PageProps): Pro
? publicBody.slice(0, 160)
: post.access_hint || "部分内容需满足条件后可见";
const page = canonicalPage(sp.page);
const urlStyle = (await getPublicSettingsCached()).url_style;
return {
title: post.title,
description: desc,
@@ -81,7 +83,7 @@ export async function generateMetadata({ params, searchParams }: PageProps): Pro
description: desc,
type: "article",
},
...(await requestCanonical(`/post/${post.id}`, page ? { page } : undefined)),
...(await requestCanonical(postPath(urlStyle, post.id), page ? { page } : undefined)),
};
} catch {
return { title: "帖子不存在" };
@@ -107,12 +109,14 @@ function RelatedPosts({
boardId,
uid = "related",
allowComments,
urlStyle,
}: {
posts: PostListItem[];
boardId: number;
// 右栏与移动端内联实例同时存在,id 必须区分以免 aria-labelledby 指向隐藏副本
uid?: string;
allowComments: boolean;
urlStyle: UrlStyle;
}) {
if (posts.length === 0) return null;
const titleId = `${uid}-title`;
@@ -127,7 +131,7 @@ function RelatedPosts({
<ol>
{posts.map((p, i) => (
<li key={p.id}>
<Link href={`/post/${p.id}`} className="j13-rank" data-tip={p.title}>
<Link href={postPath(urlStyle, p.id)} className="j13-rank" data-tip={p.title}>
<span className="j13-rank-no">{i + 1}</span>
<span className="min-w-0 flex-1">
<span className="j13-rank-title line-clamp-2">
@@ -165,12 +169,14 @@ function AuthorCardStacked({
allowComments,
boardNames,
boardColors,
urlStyle,
}: {
profile: UserProfile;
allowComments: boolean;
/** 板块 id → 名称/配色:板块管理员徽章展示「{板块名}板块管理员」并对齐板块色 */
boardNames?: Record<number, string>;
boardColors?: Record<number, number>;
urlStyle: UrlStyle;
}) {
const u = profile.user;
const signature = (u.signature || "").trim();
@@ -184,13 +190,13 @@ function AuthorCardStacked({
关于作者
</p>
<div className="flex items-start gap-4 flex-wrap">
<Link href={`/u/${u.id}`} className="shrink-0" aria-label={u.nickname}>
<Link href={userPath(urlStyle, u.id)} className="shrink-0" aria-label={u.nickname}>
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={52} />
</Link>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<Link
href={`/u/${u.id}`}
href={userPath(urlStyle, u.id)}
className="font-semibold text-[15px] hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
@@ -231,7 +237,7 @@ function AuthorCardStacked({
</p>
) : null}
</div>
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm shrink-0 self-center">
<Link href={userPath(urlStyle, u.id)} className="btn btn-line btn-sm shrink-0 self-center">
访问主页
</Link>
</div>
@@ -307,6 +313,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
// 站点设置已在 layout 缓存;关评或「登录可见」且游客时不拉评论树,避免首屏泄露
const publicSettings = await getPublicSettingsCached();
const allowComments = publicSettings.allow_comments !== false;
const urlStyle = publicSettings.url_style;
const commentsRequireLogin = publicSettings.comments_require_login === true;
const emptyComments: CommentsResponse = {
comments: [],
@@ -401,9 +408,9 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
}
: null;
const entityUrl = absoluteCanonical(`/post/${post.id}`);
const entityUrl = absoluteCanonical(postPath(urlStyle, post.id));
const pageUrl =
absoluteCanonical(`/post/${post.id}`, commentPage > 1 ? { page: commentPage } : undefined) ||
absoluteCanonical(postPath(urlStyle, post.id), commentPage > 1 ? { page: commentPage } : undefined) ||
entityUrl;
const authorName = post.user.nickname || post.user.username;
const visibleFloors: JsonLdVisibleComment[] = canFetchComments
@@ -443,6 +450,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
author: { id: post.user.id, name: authorName },
postId: post.id,
pageUrl,
urlStyle,
accepted: acceptedForLd,
answers: visibleFloors,
})
@@ -454,6 +462,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
author: { id: post.user.id, name: authorName },
postId: post.id,
pageUrl,
urlStyle,
commentCount: canFetchComments ? post.comment_count : undefined,
viewCount: post.view_count,
comments: visibleFloors,
@@ -546,11 +555,11 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
style={{ borderBottom: "1px solid var(--line)" }}
>
<div className="flex items-center gap-3 min-w-0">
<Link href={`/u/${post.user.id}`} className="shrink-0" aria-label={post.user.nickname}>
<Link href={userPath(urlStyle, post.user.id)} className="shrink-0" aria-label={post.user.nickname}>
<Avatar name={post.user.nickname || post.user.username} src={post.user.avatar || undefined} size={40} />
</Link>
<Link
href={`/u/${post.user.id}`}
href={userPath(urlStyle, post.user.id)}
className="min-w-0 truncate text-[15px] font-semibold hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
@@ -696,6 +705,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
allowComments={allowComments}
boardNames={boardNames}
boardColors={boardColors}
urlStyle={urlStyle}
/>
</div>
)}
@@ -754,6 +764,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
boardId={post.board_id}
uid="related-inline"
allowComments={allowComments}
urlStyle={urlStyle}
/>
</div>
</article>
@@ -766,7 +777,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
aria-label="阅读辅助"
>
<SidebarRenderer blocks={sidebarLists.right} widgets={sidebar.widgets} props={sidebarProps} />
<RelatedPosts posts={related} boardId={post.board_id} allowComments={allowComments} />
<RelatedPosts posts={related} boardId={post.board_id} allowComments={allowComments} urlStyle={urlStyle} />
</aside>
)}
</div>