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

@@ -41,6 +41,8 @@ import DeleteCommentModal, { type DeleteCommentPayload } from "@/components/Dele
import EditHistoryModal from "@/components/EditHistoryModal";
import { scrollFlashTo } from "@/lib/flash";
import { fetchCommentLocation } from "@/lib/api";
import { postPath, userPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
interface CommentSectionProps {
postId: string;
@@ -215,6 +217,7 @@ export default function CommentSection({
}: CommentSectionProps) {
const winnerCommentIdSet = new Set(winnerCommentIds.filter((id) => id > 0));
const router = useRouter();
const urlStyle = useUrlStyle();
const commentsOpen = useAllowComments();
const commentsRequireLogin = useCommentsRequireLogin();
const staffViewer = !!user && isStaff(user.role);
@@ -329,7 +332,7 @@ export default function CommentSection({
if (cancelled || floor <= 0) return;
const destPage = Math.max(1, Math.ceil(floor / size));
if (destPage === page) return; // 同页却无节点(异常数据),放弃
router.replace(`/post/${postId}?page=${destPage}#comment-${id}`, { scroll: false });
router.replace(`${postPath(urlStyle, Number(postId))}?page=${destPage}#comment-${id}`, { scroll: false });
} catch {
/* 定位失败静默:停留在当前页 */
}
@@ -359,15 +362,15 @@ export default function CommentSection({
if (destPage !== page) {
setAwayHref(
destPage > 1
? `/post/${postId}?page=${destPage}#comment-${res.comment.id}`
: `/post/${postId}#comment-${res.comment.id}`
? `${postPath(urlStyle, Number(postId))}?page=${destPage}#comment-${res.comment.id}`
: `${postPath(urlStyle, Number(postId))}#comment-${res.comment.id}`
);
} else {
setAwayHref(null);
}
router.refresh();
} else if (res.error === "未登录") {
router.push(`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`);
router.push(`/login?redirect=${encodeURIComponent(postPath(urlStyle, Number(postId)))}`);
} else {
const msg = res.error || "评论失败";
setFloorError(msg);
@@ -415,7 +418,7 @@ export default function CommentSection({
router.refresh();
focusCommentControl(target.id, "reply");
} else if (res.error === "未登录") {
router.push(`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`);
router.push(`/login?redirect=${encodeURIComponent(postPath(urlStyle, Number(postId)))}`);
} else {
const msg = res.error || "回复失败";
setReplyError(msg);
@@ -929,7 +932,7 @@ export default function CommentSection({
<MosaicAvatar size={isFloor ? 34 : 32} />
) : author ? (
<Link
href={`/u/${author.id}`}
href={userPath(urlStyle, author.id)}
className={isFloor ? "shrink-0 mt-0.5" : "shrink-0"}
aria-label={displayName}
>
@@ -942,7 +945,7 @@ export default function CommentSection({
const nameRow = showMosaic ? (
<MosaicName />
) : author ? (
<Link href={`/u/${author.id}`} className="group">
<Link href={userPath(urlStyle, author.id)} className="group">
<span
className={`truncate group-hover:text-[var(--accent)] transition-colors ${
isFloor ? "text-sm" : "cmt-reply-name"
@@ -980,7 +983,7 @@ export default function CommentSection({
{c.staff_deleted && deleter ? (
<div className="cmt-tombstone-mod">
<span>处理人</span>
<Link href={`/u/${deleter.id}`} aria-label={`查看管理员 ${deleterName}`}>
<Link href={userPath(urlStyle, deleter.id)} aria-label={`查看管理员 ${deleterName}`}>
<Avatar name={deleterName} src={deleter.avatar || undefined} size={20} />
{deleterName}
</Link>
@@ -1088,7 +1091,7 @@ export default function CommentSection({
<div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24">
<div className="flex gap-3">
{author ? (
<Link href={`/u/${author.id}`} className="shrink-0" aria-label={authorName}>
<Link href={userPath(urlStyle, author.id)} className="shrink-0" aria-label={authorName}>
<Avatar name={authorName} src={author.avatar || undefined} size={32} />
</Link>
) : (
@@ -1098,7 +1101,7 @@ export default function CommentSection({
<div className="cmt-meta">
<div className="cmt-meta-lead">
{author ? (
<Link href={`/u/${author.id}`} className="cmt-name-link group">
<Link href={userPath(urlStyle, author.id)} className="cmt-name-link group">
<span className="cmt-name group-hover:text-[var(--accent)] transition-colors">
{authorName}
</span>
@@ -1189,7 +1192,7 @@ export default function CommentSection({
<div key={c.id} id={`comment-${c.id}`} className="cmt-item scroll-mt-24">
<div className="flex gap-3">
{author ? (
<Link href={`/u/${author.id}`} className="shrink-0" aria-label={authorName}>
<Link href={userPath(urlStyle, author.id)} className="shrink-0" aria-label={authorName}>
<Avatar name={authorName} src={author.avatar || undefined} size={34} />
</Link>
) : (
@@ -1199,7 +1202,7 @@ export default function CommentSection({
<div className="cmt-meta">
<div className="cmt-meta-lead">
{author ? (
<Link href={`/u/${author.id}`} className="cmt-name-link group">
<Link href={userPath(urlStyle, author.id)} className="cmt-name-link group">
<span className="cmt-name group-hover:text-[var(--accent)] transition-colors">
{authorName}
</span>
@@ -1302,7 +1305,7 @@ export default function CommentSection({
return <CommentsUnsupported />;
}
if (commentsRequireLogin && !user) {
return <CommentsLoginRequired redirect={`/post/${postId}#comments`} />;
return <CommentsLoginRequired redirect={`${postPath(urlStyle, Number(postId))}#comments`} />;
}
return (
@@ -1386,7 +1389,7 @@ export default function CommentSection({
<span className="text-sm" style={{ color: "var(--ink-2)" }}>
登录后参与讨论
</span>
<Link href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`} className="btn btn-line btn-sm">
<Link href={`/login?redirect=${encodeURIComponent(postPath(urlStyle, Number(postId)))}`} className="btn btn-line btn-sm">
去登录
</Link>
</div>
@@ -1405,7 +1408,7 @@ export default function CommentSection({
)}
</div>
<Pagination total={total} page={page} size={size} basePath={`/post/${postId}`} />
<Pagination total={total} page={page} size={size} basePath={postPath(urlStyle, Number(postId))} />
<ConfirmDialog
open={rejectTargetId != null}