-
+
setBasicDirty(d), []);
const onBrandDirty = useCallback((d: boolean) => setBrandDirty(d), []);
const onSeoDirty = useCallback((d: boolean) => setSeoDirty(d), []);
+ const onUrlStyleDirty = useCallback((d: boolean) => setUrlStyleDirty(d), []);
return (
+
void;
+ onDirtyChange?: (dirty: boolean) => void;
+}) {
+ const [style, setStyle] = useState(initial.url_style);
+ const [savedStyle, setSavedStyle] = useState(initial.url_style);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+
+ const dirty = style !== savedStyle;
+
+ useEffect(() => {
+ onDirtyChange?.(dirty);
+ }, [dirty, onDirtyChange]);
+
+ const discard = () => {
+ setStyle(savedStyle);
+ setError("");
+ };
+
+ const save = async () => {
+ if (saving || !dirty) return;
+ setSaving(true);
+ setError("");
+ try {
+ const res = await apiUpdateSiteSettings({ url_style: style });
+ setSavedStyle(res.url_style);
+ setStyle(res.url_style);
+ onSaved?.(res);
+ toast("已保存,全站链接风格已切换", "ok");
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : "保存失败";
+ setError(msg);
+ toast(msg);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+ void save()}
+ onDiscard={discard}
+ />
+ }
+ >
+
+ {STYLE_OPTIONS.map((opt) => {
+ const on = style === opt.value;
+ return (
+
+ );
+ })}
+
+
+
+ - 保存后全站前台链接立即按新风格生成;旧风格链接自动 301 到新风格,收录与外链不断链。
+ - 301 会被浏览器长期缓存,切换前请想清楚;建议长期固定一种风格,不要频繁来回切换。
+ - 自建页与排行榜同样参与伪静态;自建页 slug 含中文时自动 URL 编码,地址栏多数浏览器仍显示中文。
+
+
+ );
+}
diff --git a/frontend/app/admin/users/UsersAdmin.tsx b/frontend/app/admin/users/UsersAdmin.tsx
index fa8300c..52cec30 100644
--- a/frontend/app/admin/users/UsersAdmin.tsx
+++ b/frontend/app/admin/users/UsersAdmin.tsx
@@ -36,6 +36,8 @@ import {
import { isOwner, ROLES } from "@/lib/roles";
import { formatDate, formatRelative } from "@/lib/format";
import { realtime } from "@/lib/realtime";
+import { userPath } from "@/lib/urlStyle";
+import { useUrlStyle } from "@/components/SiteBrandProvider";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
import Modal from "@/components/Modal";
@@ -302,6 +304,7 @@ export default function UsersAdmin({
currentUserId: number;
currentUserRole: string;
}) {
+ const urlStyle = useUrlStyle();
const [users, setUsers] = useState(initial?.users ?? []);
const [summary, setSummary] = useState(
initial?.summary ?? {
@@ -686,7 +689,7 @@ export default function UsersAdmin({
return (
{auditBtn}
-
+
主页
@@ -697,7 +700,7 @@ export default function UsersAdmin({
return (
{auditBtn}
-
+
主页
@@ -718,7 +721,7 @@ export default function UsersAdmin({
return (
{auditBtn}
-
+
主页
diff --git a/frontend/app/admin/users/[id]/UserAuditClient.tsx b/frontend/app/admin/users/[id]/UserAuditClient.tsx
index ed78273..1c1ffc8 100644
--- a/frontend/app/admin/users/[id]/UserAuditClient.tsx
+++ b/frontend/app/admin/users/[id]/UserAuditClient.tsx
@@ -14,7 +14,9 @@ import {
} from "@/lib/api";
import { formatDate, formatRelative } from "@/lib/format";
import Avatar from "@/components/Avatar";
+import { useUrlStyle } from "@/components/SiteBrandProvider";
import RoleBadge from "@/components/RoleBadge";
+import { postPath, userPath } from "@/lib/urlStyle";
import {
AdminEmpty,
AdminPager,
@@ -72,6 +74,7 @@ export default function UserAuditClient({
initialPostsTotal: number;
initError: string;
}) {
+ const urlStyle = useUrlStyle();
const [profile] = useState(initial);
const [tab, setTab] = useState("posts");
const [loading, setLoading] = useState(false);
@@ -231,7 +234,7 @@ export default function UserAuditClient({
@@ -318,7 +321,7 @@ export default function UserAuditClient({
) : (
@@ -353,7 +356,7 @@ export default function UserAuditClient({
帖子 #{c.post_id}
) : (
diff --git a/frontend/app/announcement/[id]/page.tsx b/frontend/app/announcement/[id]/page.tsx
index 8929287..e2766ed 100644
--- a/frontend/app/announcement/[id]/page.tsx
+++ b/frontend/app/announcement/[id]/page.tsx
@@ -17,6 +17,7 @@ import {
SiteDocMasthead,
} from "@/components/site-doc";
import { requestCanonical } from "@/lib/canonicalMeta";
+import { announcementPath } from "@/lib/urlStyle";
import { webPageJsonLd } from "@/lib/jsonLd";
import JsonLd from "@/components/JsonLd";
@@ -28,6 +29,7 @@ export async function generateMetadata({ params }: PageProps): Promise
const { id } = await params;
const data = await fetchAnnouncementDetail(id).catch(() => null);
if (!data) return { title: "公告不存在" };
+ const urlStyle = (await getPublicSettingsCached()).url_style;
return {
title: data.announcement.title,
description: `站点公告 · ${data.announcement.tag}`,
@@ -37,7 +39,7 @@ export async function generateMetadata({ params }: PageProps): Promise
type: "article",
publishedTime: data.announcement.created_at,
},
- ...(await requestCanonical(`/announcement/${data.announcement.id}`)),
+ ...(await requestCanonical(announcementPath(urlStyle, data.announcement.id))),
};
}
@@ -59,6 +61,7 @@ export default async function AnnouncementPage({ params }: PageProps) {
const announcements = overview?.announcements ?? [];
const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
const siteLead = settings.site_description?.trim() || undefined;
+ const urlStyle = settings.url_style;
const toc = extractToc(a.content);
const headingToc = markdownHeadingToc(toc);
const readingMinutes = estimateReadingMinutes(a.content, a.title.length);
@@ -77,7 +80,7 @@ export default async function AnnouncementPage({ params }: PageProps) {
const jsonLd = webPageJsonLd({
type: "Article",
name: a.title,
- path: `/announcement/${a.id}`,
+ path: announcementPath(urlStyle, a.id),
datePublished: a.created_at,
dateModified: a.updated_at,
articleSection: "站点公告",
diff --git a/frontend/app/announcements/page.tsx b/frontend/app/announcements/page.tsx
index b8ee272..16bbc95 100644
--- a/frontend/app/announcements/page.tsx
+++ b/frontend/app/announcements/page.tsx
@@ -15,6 +15,7 @@ import {
} from "@/components/site-doc";
import { requestCanonical } from "@/lib/canonicalMeta";
import { canonicalPage } from "@/lib/siteUrl";
+import { announcementPath, type UrlStyle } from "@/lib/urlStyle";
interface PageProps {
searchParams: Promise<{ page?: string }>;
@@ -42,11 +43,11 @@ function plainSummary(md: string, max = 120): string {
return text.length > max ? `${text.slice(0, max)}…` : text;
}
-function FeaturedAnnouncement({ a }: { a: Announcement }) {
+function FeaturedAnnouncement({ a, urlStyle }: { a: Announcement; urlStyle: UrlStyle }) {
const summary = plainSummary(a.content, 180);
return (
@@ -77,11 +78,11 @@ function FeaturedAnnouncement({ a }: { a: Announcement }) {
);
}
-function AnnouncementRow({ a, showDivider }: { a: Announcement; showDivider: boolean }) {
+function AnnouncementRow({ a, showDivider, urlStyle }: { a: Announcement; showDivider: boolean; urlStyle: UrlStyle }) {
const summary = plainSummary(a.content);
return (
@@ -131,6 +132,7 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
]);
const sidebarPages = overview?.sidebar_pages ?? [];
const siteLead = settings.site_description?.trim() || undefined;
+ const urlStyle = settings.url_style;
const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
// 首页置顶条目作头图;其余走列表行(置顶已在头图时不再重复)
@@ -178,9 +180,9 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
) : (
<>
- {featured ? : null}
+ {featured ? : null}
{rest.map((a, i) => (
- 0} />
+ 0} urlStyle={urlStyle} />
))}
>
)}
diff --git a/frontend/app/chat/[id]/ChatRoomClient.tsx b/frontend/app/chat/[id]/ChatRoomClient.tsx
index 701e841..cbccd94 100644
--- a/frontend/app/chat/[id]/ChatRoomClient.tsx
+++ b/frontend/app/chat/[id]/ChatRoomClient.tsx
@@ -56,6 +56,8 @@ import AutolinkText from "@/components/AutolinkText";
import ConfirmDialog from "@/components/ConfirmDialog";
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
import ChatUserCard, { type ChatUserCardTarget } from "@/components/ChatUserCard";
+import { userPath } from "@/lib/urlStyle";
+import { useUrlStyle } from "@/components/SiteBrandProvider";
/** 同一房间已读:进行中去重 + 2s 内合并(滚动/新消息/Strict Mode 连打) */
const MARK_READ_GAP_MS = 2_000;
@@ -102,6 +104,7 @@ function RecallNotice({
meId: number;
members: ChatRoomMember[];
}) {
+ const urlStyle = useUrlStyle();
if (!message.recalled_at) return null;
const by =
message.recalled_by ||
@@ -137,7 +140,7 @@ function RecallNotice({
const nameNode =
uid > 0 ? (
@@ -2020,6 +2023,7 @@ function MembersPanel({
onOpenCard: (m: ChatRoomMember) => void;
showTitle?: boolean;
}) {
+ const urlStyle = useUrlStyle();
return (
{showTitle && (
@@ -2072,7 +2076,7 @@ function MembersPanel({
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index df34a17..8787849 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -4590,11 +4590,13 @@ a.j13-mention-token:hover {
}
#settings-identity .admin-settings-actions,
#settings-brand .admin-settings-actions,
+ #settings-url-style .admin-settings-actions,
#settings-footer .admin-settings-actions {
grid-template-columns: minmax(0, 1fr);
}
#settings-identity .admin-settings-actions-inner,
#settings-brand .admin-settings-actions-inner,
+ #settings-url-style .admin-settings-actions-inner,
#settings-footer .admin-settings-actions-inner {
grid-column: 1;
}
@@ -4860,6 +4862,71 @@ a.j13-mention-token:hover {
}
}
+ /* 链接风格(伪静态)三选卡 */
+ .admin-url-style-card {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ padding: 0.8rem 0.85rem;
+ border-radius: 12px;
+ border: 1px solid var(--line);
+ background: var(--panel);
+ text-align: left;
+ cursor: pointer;
+ font: inherit;
+ color: inherit;
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
+ }
+ .admin-url-style-card.is-on {
+ border-color: color-mix(in srgb, var(--accent) 55%, var(--line));
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent);
+ }
+ .admin-url-style-card:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+ }
+ .admin-url-style-dot {
+ width: 0.85rem;
+ height: 0.85rem;
+ border-radius: 999px;
+ border: 1px solid var(--line-2);
+ background: var(--panel-2);
+ flex-shrink: 0;
+ }
+ .admin-url-style-dot.is-on {
+ border-color: var(--accent);
+ background: radial-gradient(circle, var(--accent) 0 42%, transparent 46%);
+ }
+ .admin-url-style-examples {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ margin-top: 0.6rem;
+ padding: 0.55rem 0.6rem;
+ border-radius: 8px;
+ background: var(--panel-2);
+ }
+ .admin-url-style-example {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.6rem;
+ font-size: 11px;
+ }
+ .admin-url-style-example code {
+ font-size: 11px;
+ color: var(--ink-2);
+ overflow-wrap: anywhere;
+ }
+ .admin-url-style-notes {
+ list-style: disc;
+ padding-left: 1.1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ color: var(--ink-3);
+ }
+
@container settings-page (max-width: 1039px) {
.admin-settings-work {
grid-template-columns: minmax(0, 1fr);
diff --git a/frontend/app/leaderboard/page.tsx b/frontend/app/leaderboard/page.tsx
index 9b1d35f..dd58278 100644
--- a/frontend/app/leaderboard/page.tsx
+++ b/frontend/app/leaderboard/page.tsx
@@ -26,6 +26,7 @@ import {
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { authCookieHeader } from "@/lib/cookies";
import { requestCanonical } from "@/lib/canonicalMeta";
+import { userPath, leaderboardPath, type UrlStyle } from "@/lib/urlStyle";
import Avatar from "@/components/Avatar";
import LevelBadge from "@/components/LevelBadge";
@@ -55,12 +56,13 @@ function normMetric(v?: string): LeaderboardMetric {
}
/** 榜单链接:默认参数省略,保持 URL 干净 */
-function lbHref(period: LeaderboardPeriod, metric: LeaderboardMetric): string {
+function lbHref(urlStyle: UrlStyle, period: LeaderboardPeriod, metric: LeaderboardMetric): string {
const q = new URLSearchParams();
if (period !== "all") q.set("period", period);
if (metric !== "all") q.set("metric", metric);
const s = q.toString();
- return s ? `/leaderboard?${s}` : "/leaderboard";
+ const base = leaderboardPath(urlStyle);
+ return s ? `${base}?${s}` : base;
}
/** 右侧分数标签:总榜-全部=累计积分,周/月-全部=贡献值,细分=对应指标名 */
@@ -75,7 +77,7 @@ export async function generateMetadata({ searchParams }: PageProps): Promise
@@ -419,6 +423,7 @@ export default async function LeaderboardPage({ searchParams }: PageProps) {
const metric = normMetric(sp.metric);
const cookie = authCookieHeader(await cookies());
const settings = await getPublicSettingsCached();
+ const urlStyle = settings.url_style;
const [meUser] = await Promise.all([getMeCached(cookie || undefined)]);
const authed = !!meUser;
@@ -506,7 +511,7 @@ export default async function LeaderboardPage({ searchParams }: PageProps) {
{PERIODS.map((p) => (
{/* 领奖台:DOM 顺序 2-1-3,冠军居中放大 */}
- {podium[1] && }
- {podium[0] && }
- {podium[2] && }
+ {podium[1] && }
+ {podium[0] && }
+ {podium[2] && }
{rest.length > 0 && (
{rest.map((e) => (
-
+
))}
)}
diff --git a/frontend/app/notifications/NotificationsClient.tsx b/frontend/app/notifications/NotificationsClient.tsx
index b2c924c..4ba7c4d 100644
--- a/frontend/app/notifications/NotificationsClient.tsx
+++ b/frontend/app/notifications/NotificationsClient.tsx
@@ -5,12 +5,15 @@ import { useRouter } from "next/navigation";
import Link from "next/link";
import { Heart, MessageCircle, CheckCheck, Bell, CheckCircle2, XCircle, AtSign, ClipboardList, Award, AlertTriangle, UserPlus } from "lucide-react";
import { apiMarkRead, apiMarkAllRead, type NotificationsResponse, type NotificationItem } from "@/lib/api";
+import { useUrlStyle } from "@/components/SiteBrandProvider";
+import { postPath, userPath } from "@/lib/urlStyle";
/**
* 通知列表交互层:数据由服务端 SSR 直出,这里只承担已读标记等客户端操作。
*/
export default function NotificationsClient({ initialData }: { initialData: NotificationsResponse }) {
const router = useRouter();
+ const urlStyle = useUrlStyle();
const [notifications, setNotifications] = useState(initialData.notifications || []);
const handleClick = async (n: NotificationItem) => {
@@ -24,16 +27,16 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
if (n.type === "mention" && n.room_id) {
router.push(`/chat/${n.room_id}`);
} else if (n.type === "pending_review") {
- router.push(n.post_id ? `/post/${n.post_id}` : "/admin/content?status=pending");
+ router.push(n.post_id ? postPath(urlStyle, n.post_id) : "/admin/content?status=pending");
} else if (n.type === "badge") {
return;
} else if (n.type === "follow") {
- router.push(`/u/${n.actor_id}`);
+ router.push(userPath(urlStyle, n.actor_id));
} else if (n.comment_id > 0) {
// 评论/回复/点赞等:直达评论所在楼层(跨页由帖子页深链定位换页)
- router.push(`/post/${n.post_id}#comment-${n.comment_id}`);
+ router.push(`${postPath(urlStyle, n.post_id)}#comment-${n.comment_id}`);
} else {
- router.push(`/post/${n.post_id}`);
+ router.push(postPath(urlStyle, n.post_id));
}
};
@@ -151,7 +154,7 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
) : (
<>
e.stopPropagation()}
className="font-semibold hover:text-[var(--accent)] transition-colors"
>
diff --git a/frontend/app/p/[slug]/page.tsx b/frontend/app/p/[slug]/page.tsx
index 4a854c0..aee8d03 100644
--- a/frontend/app/p/[slug]/page.tsx
+++ b/frontend/app/p/[slug]/page.tsx
@@ -17,6 +17,7 @@ import {
SiteDocMasthead,
} from "@/components/site-doc";
import { requestCanonical } from "@/lib/canonicalMeta";
+import { pagePath } from "@/lib/urlStyle";
import { webPageJsonLd } from "@/lib/jsonLd";
import JsonLd from "@/components/JsonLd";
@@ -29,6 +30,7 @@ export async function generateMetadata({ params }: PageProps): Promise
const data = await fetchSitePageBySlug(slug).catch(() => null);
if (!data) return { title: "页面不存在" };
const p = data.page;
+ const urlStyle = (await getPublicSettingsCached()).url_style;
return {
title: p.title,
description: p.excerpt || p.title,
@@ -38,7 +40,7 @@ export async function generateMetadata({ params }: PageProps): Promise
type: "article",
publishedTime: p.created_at,
},
- ...(await requestCanonical(`/p/${p.slug}`)),
+ ...(await requestCanonical(pagePath(urlStyle, p.slug))),
};
}
@@ -58,6 +60,7 @@ export default async function SitePagePublic({ params }: PageProps) {
const announcements = overview?.announcements ?? [];
const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
const siteLead = settings.site_description?.trim() || undefined;
+ const urlStyle = settings.url_style;
const toc = extractToc(p.content);
const headingToc = markdownHeadingToc(toc);
const readingMinutes = estimateReadingMinutes(p.content, p.title.length);
@@ -76,7 +79,7 @@ export default async function SitePagePublic({ params }: PageProps) {
const jsonLd = webPageJsonLd({
name: p.title,
description: p.excerpt || undefined,
- path: `/p/${p.slug}`,
+ path: pagePath(urlStyle, p.slug),
datePublished: p.created_at,
dateModified: p.updated_at,
});
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index 5a20c46..2e8240f 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -195,6 +195,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
items: postsData.posts.map((p) => ({ id: p.id, name: p.title })),
page,
pageSize: 20,
+ urlStyle: publicSettings.url_style,
});
// 搜索态:单栏占满,无 Tabs/侧栏
diff --git a/frontend/app/post/[id]/edit/EditPostClient.tsx b/frontend/app/post/[id]/edit/EditPostClient.tsx
index 527b6b8..7cf07ce 100644
--- a/frontend/app/post/[id]/edit/EditPostClient.tsx
+++ b/frontend/app/post/[id]/edit/EditPostClient.tsx
@@ -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 (
);
}
diff --git a/frontend/app/post/[id]/edit/page.tsx b/frontend/app/post/[id]/edit/page.tsx
index ecb1dcf..9ec4fe1 100644
--- a/frontend/app/post/[id]/edit/page.tsx
+++ b/frontend/app/post/[id]/edit/page.tsx
@@ -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 (
diff --git a/frontend/app/post/[id]/page.tsx b/frontend/app/post/[id]/page.tsx
index 9136e1f..ba8eb34 100644
--- a/frontend/app/post/[id]/page.tsx
+++ b/frontend/app/post/[id]/page.tsx
@@ -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({
{posts.map((p, i) => (
-
-
+
{i + 1}
@@ -165,12 +169,14 @@ function AuthorCardStacked({
allowComments,
boardNames,
boardColors,
+ urlStyle,
}: {
profile: UserProfile;
allowComments: boolean;
/** 板块 id → 名称/配色:板块管理员徽章展示「{板块名}板块管理员」并对齐板块色 */
boardNames?: Record;
boardColors?: Record;
+ urlStyle: UrlStyle;
}) {
const u = profile.user;
const signature = (u.signature || "").trim();
@@ -184,13 +190,13 @@ function AuthorCardStacked({
关于作者
-
+
@@ -231,7 +237,7 @@ function AuthorCardStacked({
) : null}
-
+
访问主页
@@ -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)" }}
>
-
+
@@ -696,6 +705,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
allowComments={allowComments}
boardNames={boardNames}
boardColors={boardColors}
+ urlStyle={urlStyle}
/>
)}
@@ -754,6 +764,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
boardId={post.board_id}
uid="related-inline"
allowComments={allowComments}
+ urlStyle={urlStyle}
/>
@@ -766,7 +777,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
aria-label="阅读辅助"
>
-
+
)}
diff --git a/frontend/app/settings/page.tsx b/frontend/app/settings/page.tsx
index 029d18a..e1c3590 100644
--- a/frontend/app/settings/page.tsx
+++ b/frontend/app/settings/page.tsx
@@ -1,7 +1,8 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { authCookieHeader } from "@/lib/cookies";
-import { getMeCached } from "@/lib/serverData";
+import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
+import { userPath } from "@/lib/urlStyle";
// /settings 已融合到用户主页 /u/[id]?tab=settings
// 未登录跳登录页,登录后跳自己的主页 settings tab
@@ -11,5 +12,6 @@ export default async function SettingsRedirect() {
if (!me.user) {
redirect(`/login?redirect=${encodeURIComponent(`/settings`)}`);
}
- redirect(`/u/${me.user.id}?tab=settings`);
+ const urlStyle = (await getPublicSettingsCached()).url_style;
+ redirect(`${userPath(urlStyle, me.user.id)}?tab=settings`);
}
diff --git a/frontend/app/sitemap.ts b/frontend/app/sitemap.ts
index 26854da..6c2f368 100644
--- a/frontend/app/sitemap.ts
+++ b/frontend/app/sitemap.ts
@@ -6,6 +6,8 @@ import {
fetchPublishedSitePages,
} from "@/lib/api";
import { canonicalPath, resolveSiteOrigin } from "@/lib/siteUrl";
+import { announcementPath, postPath, pagePath, leaderboardPath } from "@/lib/urlStyle";
+import { getPublicSettingsCached } from "@/lib/serverData";
export default async function sitemap(): Promise {
await connection();
@@ -14,6 +16,7 @@ export default async function sitemap(): Promise {
console.error("[seo] SITE_URL 缺失或非法,已跳过 sitemap");
return [];
}
+ const style = (await getPublicSettingsCached()).url_style;
const url = (pathname: string) => `${origin}${canonicalPath(pathname)}`;
const entries: MetadataRoute.Sitemap = [
@@ -25,7 +28,7 @@ export default async function sitemap(): Promise {
priority: 0.7,
},
{
- url: url("/leaderboard"),
+ url: url(leaderboardPath(style)),
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.5,
@@ -42,7 +45,7 @@ export default async function sitemap(): Promise {
const { posts } = await fetchPosts(1, 100);
for (const post of posts) {
entries.push({
- url: url(`/post/${post.id}`),
+ url: url(postPath(style, post.id)),
lastModified: new Date(post.created_at),
changeFrequency: "weekly",
priority: 0.6,
@@ -56,7 +59,7 @@ export default async function sitemap(): Promise {
const { announcements } = await fetchAnnouncementsPage(1, 100);
for (const a of announcements) {
entries.push({
- url: url(`/announcement/${a.id}`),
+ url: url(announcementPath(style, a.id)),
lastModified: new Date(a.updated_at || a.created_at),
changeFrequency: "weekly",
priority: 0.5,
@@ -70,7 +73,7 @@ export default async function sitemap(): Promise {
const { pages } = await fetchPublishedSitePages();
for (const p of pages) {
entries.push({
- url: url(`/p/${p.slug}`),
+ url: url(pagePath(style, p.slug)),
lastModified: new Date(p.updated_at || p.created_at),
changeFrequency: "monthly",
priority: 0.4,
diff --git a/frontend/app/u/[id]/UserProfileContent.tsx b/frontend/app/u/[id]/UserProfileContent.tsx
index 98b15fe..3605fb1 100644
--- a/frontend/app/u/[id]/UserProfileContent.tsx
+++ b/frontend/app/u/[id]/UserProfileContent.tsx
@@ -12,6 +12,8 @@ import { formatRelative } from "@/lib/format";
import type {
UserProfile, UserCommentsResponse, UserFavoritesResponse, UserFollowingResponse, PostListItem,
} from "@/lib/api";
+import { postPath, userPath } from "@/lib/urlStyle";
+import { getPublicSettingsCached } from "@/lib/serverData";
/** 本页卡片统一阴影;用 color-mix 让暗色主题自动加深,避免写死浅色阴影 */
const CARD_SHADOW = "0 1px 3px color-mix(in srgb, #000 18%, transparent)";
@@ -26,11 +28,12 @@ const SORTS: { key: string; label: string }[] = [
];
/** 帖子卡(发帖列表与收藏列表共用) */
-function PostCardItem({ post, allowComments }: { post: PostListItem; allowComments?: boolean }) {
+async function PostCardItem({ post, allowComments }: { post: PostListItem; allowComments?: boolean }) {
+ const urlStyle = (await getPublicSettingsCached()).url_style;
const pinned = post.pinned > 0;
return (
=
tab === "comments" ? { tab: "comments" }
@@ -189,7 +193,7 @@ export default function UserProfileContent({
{SORTS.map((s) => (
@@ -215,13 +219,13 @@ export default function UserProfileContent({
total={profile.posts_total}
page={page}
size={20}
- basePath={`/u/${userId}`}
+ basePath={userPath(urlStyle, Number(userId))}
query={paginationQuery}
/>
) : tab === "favorites" ? (
favoriteError ? (
-
+
) : favoriteData && favoriteData.posts.length > 0 ? (
@@ -238,7 +242,7 @@ export default function UserProfileContent({
total={favoriteData.total}
page={favoriteData.page}
size={20}
- basePath={`/u/${userId}`}
+ basePath={userPath(urlStyle, Number(userId))}
query={paginationQuery}
/>
@@ -250,7 +254,7 @@ export default function UserProfileContent({
)
) : tab === "following" ? (
followingError ? (
-
+
) : followingData && followingData.users.length > 0 ? (
@@ -265,7 +269,7 @@ export default function UserProfileContent({
className="group flex items-center gap-3 rounded-xl border px-5 py-4 transition-[background-color,box-shadow,border-color] duration-150 bg-[var(--panel)] border-[var(--line)] hover:bg-[color-mix(in_srgb,var(--ink)_3.5%,var(--panel))]"
style={{ boxShadow: CARD_SHADOW }}
>
-
+
@@ -291,7 +295,7 @@ export default function UserProfileContent({
total={followingData.total}
page={followingData.page}
size={20}
- basePath={`/u/${userId}`}
+ basePath={userPath(urlStyle, Number(userId))}
query={paginationQuery}
/>
@@ -302,14 +306,14 @@ export default function UserProfileContent({
/>
)
) : commentError ? (
-
+
) : commentData && (commentData.comments?.length ?? 0) > 0 ? (
{(commentData.comments ?? []).map((c) => (
@@ -335,7 +339,7 @@ export default function UserProfileContent({
total={commentData.total}
page={commentData.page}
size={20}
- basePath={`/u/${userId}`}
+ basePath={userPath(urlStyle, Number(userId))}
query={paginationQuery}
/>
diff --git a/frontend/app/u/[id]/page.tsx b/frontend/app/u/[id]/page.tsx
index c998e13..1d97ecf 100644
--- a/frontend/app/u/[id]/page.tsx
+++ b/frontend/app/u/[id]/page.tsx
@@ -28,6 +28,7 @@ import FollowButton from "@/components/FollowButton";
import StaffUserMenu from "@/components/StaffUserMenu";
import { requestCanonical } from "@/lib/canonicalMeta";
import { absoluteCanonical, canonicalPage } from "@/lib/siteUrl";
+import { userPath } from "@/lib/urlStyle";
import { profileJsonLd } from "@/lib/jsonLd";
import JsonLd from "@/components/JsonLd";
@@ -52,7 +53,9 @@ export async function generateMetadata({ params, searchParams }: UserPageProps):
const tab = parseTab(sp.tab);
try {
const data = await fetchUserProfile(id, 1);
- const allowComments = (await getPublicSettingsCached()).allow_comments !== false;
+ const pub = await getPublicSettingsCached();
+ const allowComments = pub.allow_comments !== false;
+ const urlStyle = pub.url_style;
const title = data.user.nickname || data.user.username;
const description = allowComments
? `查看 ${data.user.nickname} 的资料、发帖与评论`
@@ -71,7 +74,7 @@ export async function generateMetadata({ params, searchParams }: UserPageProps):
return {
title,
description,
- ...(await requestCanonical(`/u/${data.user.id}`, query)),
+ ...(await requestCanonical(userPath(urlStyle, data.user.id), query)),
};
} catch {
return { title: "用户不存在" };
@@ -93,6 +96,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
const me = await getMeCached(cookie || undefined);
const settings = await getPublicSettingsCached();
const allowComments = settings.allow_comments !== false;
+ const urlStyle = settings.url_style;
const commentsRequireLogin = settings.comments_require_login === true;
const allowMessages = settings.allow_messages !== false;
const canReadComments = allowComments && (!commentsRequireLogin || !!me.user);
@@ -206,7 +210,8 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
]
: [];
const visibleNav = [...mainNavItems, ...settingsNavItems];
- const tabHref = (t: UserTab) => (t === "posts" ? `/u/${id}` : `/u/${id}?tab=${t}`);
+ const tabHref = (t: UserTab) =>
+ t === "posts" ? userPath(urlStyle, Number(id)) : `${userPath(urlStyle, Number(id))}?tab=${t}`;
const profileQuery: Record = {};
if (canReadComments && effectiveTab === "comments") profileQuery.tab = "comments";
@@ -222,7 +227,8 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
name: u.nickname || u.username,
username: u.username,
signature: (u.signature || "").trim() || undefined,
- pageUrl: absoluteCanonical(`/u/${u.id}`, profileQuery),
+ pageUrl: absoluteCanonical(userPath(urlStyle, u.id), profileQuery),
+ urlStyle,
});
/** 本页卡片统一阴影;暗色随 color-mix 自动加深 */
@@ -255,12 +261,12 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
{/* 本人操作按钮 / 访客私聊:绝对定位在卡片右上角 */}
{isOwner ? (
-
+
编辑资料
{author ? (
<>
-
+
diff --git a/frontend/components/ChatUserCard.tsx b/frontend/components/ChatUserCard.tsx
index 160ba1d..45fd3b7 100644
--- a/frontend/components/ChatUserCard.tsx
+++ b/frontend/components/ChatUserCard.tsx
@@ -11,6 +11,8 @@ import LevelBadge from "@/components/LevelBadge";
import BadgeChip from "@/components/BadgeChip";
import ImageLightbox from "@/components/markdown/ImageLightbox";
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
+import { useUrlStyle } from "@/components/SiteBrandProvider";
+import { userPath } from "@/lib/urlStyle";
import { apiGetUserProfile, apiOpenDirectChat, type UserProfile, type User } from "@/lib/api";
import { toast } from "@/lib/toast";
@@ -36,6 +38,7 @@ export default function ChatUserCard({
}) {
const router = useRouter();
const allowMessages = useAllowMessages();
+ const urlStyle = useUrlStyle();
const [profile, setProfile] = useState (null);
const [dmBusy, setDmBusy] = useState(false);
const [avatarOpen, setAvatarOpen] = useState(false);
@@ -194,7 +197,7 @@ export default function ChatUserCard({
{/* 操作区 */}
-
+
用户主页
{allowMessages && !isSelf && (
diff --git a/frontend/components/CommentSection.tsx b/frontend/components/CommentSection.tsx
index 2abd467..d3fbbc8 100644
--- a/frontend/components/CommentSection.tsx
+++ b/frontend/components/CommentSection.tsx
@@ -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({
) : author ? (
@@ -942,7 +945,7 @@ export default function CommentSection({
const nameRow = showMosaic ? (
) : author ? (
-
+
处理人
-
+
{deleterName}
@@ -1088,7 +1091,7 @@ export default function CommentSection({
|