feat: 可配置站点 URL 风格
- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接 - 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配 - 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成 - middleware 与相关组件适配
This commit is contained in:
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user