From 7a44c1f2cf8d96ea492af752f70924b726d585ac Mon Sep 17 00:00:00 2001 From: freefire Date: Sun, 27 Sep 2026 04:01:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8F=AF=E9=85=8D=E7=BD=AE=E7=AB=99?= =?UTF-8?q?=E7=82=B9=20URL=20=E9=A3=8E=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接 - 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配 - 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成 - middleware 与相关组件适配 --- backend/handler/operations.go | 6 +- backend/handler/setting.go | 17 +- backend/service/setting.go | 51 +++++ backend/service/url_style_test.go | 112 ++++++++++ frontend/app/about/page.tsx | 8 +- frontend/app/admin/DashboardClient.tsx | 7 +- .../admin/announcements/AnnouncementAdmin.tsx | 6 +- .../AnnouncementComposeClient.tsx | 6 +- .../admin/announcements/announcementColors.ts | 5 +- frontend/app/admin/content/ContentAdmin.tsx | 7 +- .../app/admin/pages/PageComposeClient.tsx | 12 +- frontend/app/admin/pages/PagesAdmin.tsx | 4 +- frontend/app/admin/points/EconomyOverview.tsx | 5 +- frontend/app/admin/points/UserPointsTab.tsx | 5 +- frontend/app/admin/settings/BasicSettings.tsx | 6 +- frontend/app/admin/settings/UrlStylePanel.tsx | 123 +++++++++++ frontend/app/admin/users/UsersAdmin.tsx | 9 +- .../app/admin/users/[id]/UserAuditClient.tsx | 9 +- frontend/app/announcement/[id]/page.tsx | 7 +- frontend/app/announcements/page.tsx | 14 +- frontend/app/chat/[id]/ChatRoomClient.tsx | 8 +- frontend/app/globals.css | 67 ++++++ frontend/app/leaderboard/page.tsx | 31 +-- .../app/notifications/NotificationsClient.tsx | 13 +- frontend/app/p/[slug]/page.tsx | 7 +- frontend/app/page.tsx | 1 + .../app/post/[id]/edit/EditPostClient.tsx | 5 +- frontend/app/post/[id]/edit/page.tsx | 6 +- frontend/app/post/[id]/page.tsx | 31 ++- frontend/app/settings/page.tsx | 6 +- frontend/app/sitemap.ts | 11 +- frontend/app/u/[id]/UserProfileContent.tsx | 30 +-- frontend/app/u/[id]/page.tsx | 18 +- frontend/components/AcceptedAnswerCard.tsx | 8 +- frontend/components/ChatUserCard.tsx | 5 +- frontend/components/CommentSection.tsx | 33 +-- frontend/components/EditHistoryModal.tsx | 7 +- frontend/components/Header.tsx | 12 +- frontend/components/MarkdownEditor.tsx | 5 +- frontend/components/MobileTabBar.tsx | 19 +- frontend/components/NotificationBell.tsx | 11 +- frontend/components/PinnedPosts.tsx | 7 +- frontend/components/PostAttachments.tsx | 5 +- frontend/components/PostComposer.tsx | 5 +- frontend/components/PostInteractPanels.tsx | 10 +- frontend/components/PostRow.tsx | 15 +- frontend/components/RoleAccountModal.tsx | 5 +- frontend/components/SiteBrandProvider.tsx | 13 +- frontend/components/admin/AdminShell.tsx | 5 +- frontend/components/home/BoardPanels.tsx | 17 +- frontend/components/home/CommunityPanels.tsx | 27 ++- frontend/components/home/WorkspaceNav.tsx | 5 +- frontend/components/markdown/MdHideBlock.tsx | 9 +- frontend/components/post/PostRailBlocks.tsx | 16 +- .../components/site-doc/SiteDocAnnoNav.tsx | 7 +- .../components/site-doc/SiteDocPageNav.tsx | 7 +- frontend/lib/api.ts | 6 + frontend/lib/jsonLd.ts | 28 ++- frontend/lib/realtime.ts | 1 + frontend/lib/sitePagePath.ts | 14 +- frontend/lib/urlStyle.test.ts | 128 ++++++++++++ frontend/lib/urlStyle.ts | 192 ++++++++++++++++++ frontend/middleware.ts | 106 +++++++++- frontend/package.json | 2 +- 64 files changed, 1183 insertions(+), 200 deletions(-) create mode 100644 backend/service/url_style_test.go create mode 100644 frontend/app/admin/settings/UrlStylePanel.tsx create mode 100644 frontend/lib/urlStyle.test.ts create mode 100644 frontend/lib/urlStyle.ts diff --git a/backend/handler/operations.go b/backend/handler/operations.go index 5f5ba2a..6582943 100644 --- a/backend/handler/operations.go +++ b/backend/handler/operations.go @@ -245,8 +245,12 @@ func (h *Handlers) SiteState(c *gin.Context) { c.JSON(503, gin.H{"error": "状态暂不可用"}) return } + urlStyle, e := h.Setting.UrlStyle() + if e != nil { + urlStyle = service.DefaultUrlStyle + } c.Header("Cache-Control", "no-store") - c.JSON(200, gin.H{"allow_register": cfg.AllowRegister, "register_notice": cfg.RegisterNotice, "verify_email": cfg.VerifyEmail, "password_reset": cfg.PasswordReset, "maintenance": m, "bypass": h.administrator(c), "site_url": h.Cfg.SiteURL}) + c.JSON(200, gin.H{"allow_register": cfg.AllowRegister, "register_notice": cfg.RegisterNotice, "verify_email": cfg.VerifyEmail, "password_reset": cfg.PasswordReset, "maintenance": m, "bypass": h.administrator(c), "site_url": h.Cfg.SiteURL, "url_style": urlStyle}) } func (h *Handlers) SendEmailCode(c *gin.Context) { var req struct { diff --git a/backend/handler/setting.go b/backend/handler/setting.go index fb80d78..ce6dbb9 100644 --- a/backend/handler/setting.go +++ b/backend/handler/setting.go @@ -75,6 +75,8 @@ type updateSettingsRequest struct { Levels *[]model.LevelDef `json:"levels"` LevelsFx *bool `json:"levels_fx"` + + UrlStyle *string `json:"url_style"` } func settingsPayload(saved service.PublicSiteSettings) gin.H { @@ -132,6 +134,8 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H { "levels": saved.Levels, "levels_fx": saved.LevelsFx, + + "url_style": saved.UrlStyle, } } @@ -154,7 +158,8 @@ func (req *updateSettingsRequest) hasAny() bool { req.PointsReplyReward != nil || req.PointsReplyDailyCap != nil || req.PointsRecommendReward != nil || req.Levels != nil || req.LevelsFx != nil || req.PostEditLockHours != nil || req.CommentEditLockHours != nil || - req.NecroReplyAfterHours != nil || req.NecroReplyPenalty != nil + req.NecroReplyAfterHours != nil || req.NecroReplyPenalty != nil || + req.UrlStyle != nil } // AdminGetSettings 超管读取站点设置(与公开 payload 字段一致) @@ -537,6 +542,16 @@ func (h *Handlers) UpdateSettings(c *gin.Context) { return } } + if req.UrlStyle != nil { + if err := h.Setting.SetUrlStyle(*req.UrlStyle); err != nil { + if errors.Is(err, service.ErrInvalidSiteSetting) { + c.JSON(http.StatusBadRequest, gin.H{"error": "链接风格无效"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } saved, err := h.Setting.Public() if err != nil { diff --git a/backend/service/setting.go b/backend/service/setting.go index 0289a2e..ec4f13d 100644 --- a/backend/service/setting.go +++ b/backend/service/setting.go @@ -80,6 +80,9 @@ const ( // SettingKeyLevelsFx 等级特效动画总开关(流光/渐变动画);缺行视为开启(默认开) SettingKeyLevelsFx = "levels_fx" + + // SettingKeyUrlStyle 详情页伪静态风格;缺行视为 default(标准动态路由风格) + SettingKeyUrlStyle = "url_style" ) const ( @@ -112,6 +115,9 @@ const ( DefaultPointsCheckinBase = 5 DefaultPointsStreakEveryDays = 7 MaxPointsRule = 100 // 各积分规则值/上限的统一上界 + + // DefaultUrlStyle 标准动态路由风格(/post/123) + DefaultUrlStyle = "default" ) // DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页) @@ -127,6 +133,9 @@ var DefaultAttachmentExts = []string{ var attachmentExtRe = regexp.MustCompile(`^[a-z0-9]{1,16}$`) +// UrlStyles 伪静态风格白名单,与前端 lib/urlStyle.ts 保持一致 +var UrlStyles = []string{"default", "html", "discuz"} + // ErrInvalidAccent 主题色格式非法 var ErrInvalidAccent = errors.New("主题色格式不正确,应为 #RRGGBB") @@ -194,6 +203,9 @@ type PublicSiteSettings struct { Levels []model.LevelDef `json:"levels"` // LevelsFx 等级特效动画总开关(流光/渐变动画;关闭后徽章退化为静态配色) LevelsFx bool `json:"levels_fx"` + + // UrlStyle 详情页伪静态风格:default(/post/123)、html(/post-123.html)、discuz(/thread-123.html) + UrlStyle string `json:"url_style"` } // SettingService 站点级键值设置 @@ -297,6 +309,7 @@ func (s *SettingService) Public() (PublicSiteSettings, error) { BrandLogoSize: BrandLogoSizeSQ, BrandLogoFit: BrandLogoFitContain, FooterLinks: []FooterLink{}, + UrlStyle: DefaultUrlStyle, } accent, err := s.AccentColor() if err != nil { @@ -494,12 +507,50 @@ func (s *SettingService) Public() (PublicSiteSettings, error) { return out, err } out.BgAdminMode = adminMode + urlStyle, err := s.UrlStyle() + if err != nil { + return out, err + } + out.UrlStyle = urlStyle + if err := s.fillBrand(&out); err != nil { return out, err } return out, nil } +// UrlStyle 返回详情页伪静态风格;缺行或值非法时回落 default +func (s *SettingService) UrlStyle() (string, error) { + v, found, err := s.getValue(SettingKeyUrlStyle) + if err != nil { + return DefaultUrlStyle, err + } + if !found { + return DefaultUrlStyle, nil + } + style := strings.TrimSpace(strings.ToLower(v)) + for _, valid := range UrlStyles { + if style == valid { + return style, nil + } + } + return DefaultUrlStyle, nil +} + +// SetUrlStyle 校验并保存伪静态风格;传 default 表示恢复标准风格(删除该键) +func (s *SettingService) SetUrlStyle(style string) error { + style = strings.TrimSpace(strings.ToLower(style)) + if style == DefaultUrlStyle { + return s.deleteKey(SettingKeyUrlStyle) + } + for _, valid := range UrlStyles { + if style == valid { + return s.putValue(SettingKeyUrlStyle, style) + } + } + return ErrInvalidSiteSetting +} + // AccentColor 返回配置的主题色(小写 #rrggbb);未配置时返回空串,表示走前端默认色 func (s *SettingService) AccentColor() (string, error) { v, found, err := s.getValue(SettingKeyAccent) diff --git a/backend/service/url_style_test.go b/backend/service/url_style_test.go new file mode 100644 index 0000000..03c38cb --- /dev/null +++ b/backend/service/url_style_test.go @@ -0,0 +1,112 @@ +package service + +import ( + "testing" + + "github.com/freefire/jiang13-bbs/model" + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func newUrlStyleTestService(t *testing.T) *SettingService { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.SiteSetting{}); err != nil { + t.Fatalf("migrate: %v", err) + } + return NewSettingService(db) +} + +func TestUrlStyleDefaultWhenMissing(t *testing.T) { + s := newUrlStyleTestService(t) + got, err := s.UrlStyle() + if err != nil { + t.Fatal(err) + } + if got != DefaultUrlStyle { + t.Fatalf("missing row should fall back to %q, got %q", DefaultUrlStyle, got) + } + pub, err := s.Public() + if err != nil { + t.Fatal(err) + } + if pub.UrlStyle != DefaultUrlStyle { + t.Fatalf("Public().UrlStyle = %q, want %q", pub.UrlStyle, DefaultUrlStyle) + } +} + +func TestSetUrlStyleRoundTrip(t *testing.T) { + s := newUrlStyleTestService(t) + for _, style := range []string{"html", "discuz"} { + if err := s.SetUrlStyle(style); err != nil { + t.Fatalf("SetUrlStyle(%q): %v", style, err) + } + got, err := s.UrlStyle() + if err != nil { + t.Fatal(err) + } + if got != style { + t.Fatalf("UrlStyle() = %q, want %q", got, style) + } + pub, err := s.Public() + if err != nil { + t.Fatal(err) + } + if pub.UrlStyle != style { + t.Fatalf("Public().UrlStyle = %q, want %q", pub.UrlStyle, style) + } + } +} + +func TestSetUrlStyleDefaultDeletesKey(t *testing.T) { + s := newUrlStyleTestService(t) + if err := s.SetUrlStyle("html"); err != nil { + t.Fatal(err) + } + // 传 default 应删键,恢复「缺行=默认」语义(大小写与空白容错) + if err := s.SetUrlStyle(" DEFAULT "); err != nil { + t.Fatal(err) + } + got, err := s.UrlStyle() + if err != nil { + t.Fatal(err) + } + if got != DefaultUrlStyle { + t.Fatalf("after reset got %q, want %q", got, DefaultUrlStyle) + } + var rows int64 + s.db.Where("key = ?", SettingKeyUrlStyle).Model(&model.SiteSetting{}).Count(&rows) + if rows != 0 { + t.Fatalf("default should delete the row, rows=%d", rows) + } +} + +func TestSetUrlStyleRejectsInvalid(t *testing.T) { + s := newUrlStyleTestService(t) + for _, bad := range []string{"", "pretty", "HTML5", "thread", "../etc"} { + if err := s.SetUrlStyle(bad); err == nil { + t.Fatalf("SetUrlStyle(%q) should fail", bad) + } + } + // 非法值不应落库 + if _, err := s.UrlStyle(); err != nil { + t.Fatal(err) + } +} + +func TestUrlStyleInvalidStoredValueFallsBack(t *testing.T) { + s := newUrlStyleTestService(t) + if err := s.putValue(SettingKeyUrlStyle, "nonsense"); err != nil { + t.Fatal(err) + } + got, err := s.UrlStyle() + if err != nil { + t.Fatal(err) + } + if got != DefaultUrlStyle { + t.Fatalf("invalid stored value should fall back to %q, got %q", DefaultUrlStyle, got) + } +} diff --git a/frontend/app/about/page.tsx b/frontend/app/about/page.tsx index 4084635..9117d9f 100644 --- a/frontend/app/about/page.tsx +++ b/frontend/app/about/page.tsx @@ -18,6 +18,7 @@ import { SiteDocAnnoNav, } from "@/components/site-doc"; import { requestCanonical } from "@/lib/canonicalMeta"; +import { pagePath, type UrlStyle } from "@/lib/urlStyle"; export async function generateMetadata(): Promise { return { @@ -30,14 +31,16 @@ export async function generateMetadata(): Promise { function PageRow({ p, showDivider, + urlStyle, }: { p: SitePageSummary; showDivider: boolean; + urlStyle: UrlStyle; }) { const Icon = pageGlyph(p.slug); return ( @@ -90,6 +93,7 @@ export default async function AboutIndexPage() { ]); const announcements = overview?.announcements ?? []; const siteLead = settings.site_description?.trim() || undefined; + const urlStyle = settings.url_style; const canManagePages = !!me.user && isAdminOrAbove(me.user.role); const left = ; @@ -146,7 +150,7 @@ export default async function AboutIndexPage() {

有新文档时会显示在这里

) : ( - pages.map((p, i) => 0} />) + pages.map((p, i) => 0} urlStyle={urlStyle} />) )} diff --git a/frontend/app/admin/DashboardClient.tsx b/frontend/app/admin/DashboardClient.tsx index e2ce11a..a8abf08 100644 --- a/frontend/app/admin/DashboardClient.tsx +++ b/frontend/app/admin/DashboardClient.tsx @@ -10,7 +10,9 @@ import { } from "@/lib/api"; import { postTypeLabel } from "@/lib/postMeta"; import { formatRelative } from "@/lib/format"; +import { postPath, userPath } from "@/lib/urlStyle"; import Avatar from "@/components/Avatar"; +import { useUrlStyle } from "@/components/SiteBrandProvider"; import { AdminSegmented } from "@/components/admin"; import { AdminChartCard, @@ -67,6 +69,7 @@ export default function DashboardClient({ totalUsers?: number; canUsers: boolean; }) { + const urlStyle = useUrlStyle(); const [range, setRange] = useState("7d"); const [data, setData] = useState(initialAnalytics); const [error, setError] = useState(null); @@ -162,7 +165,7 @@ export default function DashboardClient({ newUsers.slice(0, 8).map((u) => ( @@ -213,7 +216,7 @@ export default function DashboardClient({ data.top_viewed.map((p, i) => ( diff --git a/frontend/app/admin/announcements/AnnouncementAdmin.tsx b/frontend/app/admin/announcements/AnnouncementAdmin.tsx index f3f4aa5..a659b57 100644 --- a/frontend/app/admin/announcements/AnnouncementAdmin.tsx +++ b/frontend/app/admin/announcements/AnnouncementAdmin.tsx @@ -13,6 +13,7 @@ import { import { formatDateTime, formatRelative } from "@/lib/format"; import { toast } from "@/lib/toast"; import ConfirmDialog from "@/components/ConfirmDialog"; +import { useUrlStyle } from "@/components/SiteBrandProvider"; import { AdminEmpty, AdminPageHeader, @@ -62,6 +63,7 @@ export default function AnnouncementAdmin({ }) { const router = useRouter(); const searchParams = useSearchParams(); + const urlStyle = useUrlStyle(); const statusFilter = readStatus(searchParams.get("status")); const [list, setList] = useState(initial); const [loadError, setLoadError] = useState(initError); @@ -215,7 +217,7 @@ export default function AnnouncementAdmin({ {a.tag} - {a.published ? {announcementPublicPath(a.id)} : null} + {a.published ? {announcementPublicPath(a.id, urlStyle)} : null}

{summary ?

{summary}

: null} @@ -241,7 +243,7 @@ export default function AnnouncementAdmin({ {a.published ? ( toForm(initial), [initial]); @@ -146,7 +148,7 @@ export default function AnnouncementComposeClient({ }; const canViewOnline = saved?.published === true && saved.id > 0; - const publicPath = saved ? announcementPublicPath(saved.id) : ""; + const publicPath = saved ? announcementPublicPath(saved.id, urlStyle) : ""; async function commit() { if (saving) return; @@ -227,7 +229,7 @@ export default function AnnouncementComposeClient({ async function copyAddress() { if (!saved) return; - const url = `${(origin || window.location.origin).replace(/\/$/, "")}${announcementPublicPath(saved.id)}`; + const url = `${(origin || window.location.origin).replace(/\/$/, "")}${announcementPublicPath(saved.id, urlStyle)}`; try { await navigator.clipboard.writeText(url); toast("已复制访问地址", "ok"); diff --git a/frontend/app/admin/announcements/announcementColors.ts b/frontend/app/admin/announcements/announcementColors.ts index ed018d8..2466cea 100644 --- a/frontend/app/admin/announcements/announcementColors.ts +++ b/frontend/app/admin/announcements/announcementColors.ts @@ -1,4 +1,5 @@ import type { AnnouncementColor } from "@/lib/api"; +import { announcementPath, type UrlStyle } from "@/lib/urlStyle"; /** 标签色板:与后端 AnnouncementColor 白名单一致(12 色) */ export const ANNOUNCEMENT_COLORS: { value: AnnouncementColor; label: string }[] = [ @@ -17,6 +18,6 @@ export const ANNOUNCEMENT_COLORS: { value: AnnouncementColor; label: string }[] ]; /** 公告公开路径。前台是 /announcement/[id],列表和线上查看用同一条。 */ -export function announcementPublicPath(id: number): string { - return `/announcement/${id}`; +export function announcementPublicPath(id: number, urlStyle: UrlStyle = "default"): string { + return announcementPath(urlStyle, id); } diff --git a/frontend/app/admin/content/ContentAdmin.tsx b/frontend/app/admin/content/ContentAdmin.tsx index 2942782..88be815 100644 --- a/frontend/app/admin/content/ContentAdmin.tsx +++ b/frontend/app/admin/content/ContentAdmin.tsx @@ -42,9 +42,11 @@ import { } from "@/lib/api"; import { canModerateAuthor } from "@/lib/roles"; import { formatRelative } from "@/lib/format"; +import { postPath } from "@/lib/urlStyle"; import { toast } from "@/lib/toast"; import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime"; import Avatar from "@/components/Avatar"; +import { useUrlStyle } from "@/components/SiteBrandProvider"; import AutolinkText from "@/components/AutolinkText"; import ConfirmDialog from "@/components/ConfirmDialog"; import StaffDeletePostModal from "@/components/StaffDeletePostModal"; @@ -278,6 +280,7 @@ export default function ContentAdmin({ canPurge: boolean; viewerRole: string; }) { + const urlStyle = useUrlStyle(); // 窄屏分段:lg 以下只显示一栏;PC 双栏同时可见 const [mobileKind, setMobileKind] = useState(initialKind); const [counts, setCounts] = useState(initialCounts); @@ -549,7 +552,7 @@ export default function ContentAdmin({

) : ( diff --git a/frontend/app/admin/pages/PageComposeClient.tsx b/frontend/app/admin/pages/PageComposeClient.tsx index 3d4f5d7..d2d9381 100644 --- a/frontend/app/admin/pages/PageComposeClient.tsx +++ b/frontend/app/admin/pages/PageComposeClient.tsx @@ -11,6 +11,7 @@ import { type SitePageInput, } from "@/lib/api"; import { sitePagePublicPath, sitePagePublicURL } from "@/lib/sitePagePath"; +import { useUrlStyle } from "@/components/SiteBrandProvider"; import { toast } from "@/lib/toast"; import MarkdownEditor from "@/components/MarkdownEditor"; import { AdminField, AdminSegmented, AdminStatusChip, AdminSwitch } from "@/components/admin"; @@ -129,6 +130,7 @@ export default function PageComposeClient({ }) { const router = useRouter(); const searchParams = useSearchParams(); + const urlStyle = useUrlStyle(); const status = readStatus(searchParams.get("status")); const backHref = listHref(status); const loaded = useMemo(() => toForm(initial), [initial]); @@ -160,7 +162,7 @@ export default function PageComposeClient({ }; const slugPreview = normalizeSlug(form.slug); - const publicPath = sitePagePublicPath(slugPreview); + const publicPath = sitePagePublicPath(slugPreview, urlStyle); const canViewOnline = savedPage?.published === true; async function commit() { @@ -252,7 +254,7 @@ export default function PageComposeClient({ setErrors((prev) => ({ ...prev, slug: nextErrors.slug })); return; } - const url = sitePagePublicURL(origin || window.location.origin, slug); + const url = sitePagePublicURL(origin || window.location.origin, slug, urlStyle); try { await navigator.clipboard.writeText(url); toast("已复制访问地址", "ok"); @@ -280,7 +282,7 @@ export default function PageComposeClient({ } if (pendingSave?.slugChange) { confirmBits.push( - `访问地址将从 ${sitePagePublicPath(baseline.slug)} 改为 ${sitePagePublicPath(normalizeSlug(form.slug))}。系统不会把旧地址转到新地址,已经分享的旧链接会打不开。` + `访问地址将从 ${sitePagePublicPath(baseline.slug, urlStyle)} 改为 ${sitePagePublicPath(normalizeSlug(form.slug), urlStyle)}。系统不会把旧地址转到新地址,已经分享的旧链接会打不开。` ); } @@ -304,7 +306,7 @@ export default function PageComposeClient({
{canViewOnline && savedPage ? (

完整路径 {publicPath || "/p/"}。小写字母、数字和连字符,最多 64 字符。 - {origin && slugPreview ? ` 线上地址 ${sitePagePublicURL(origin, slugPreview)}` : ""} + {origin && slugPreview ? ` 线上地址 ${sitePagePublicURL(origin, slugPreview, urlStyle)}` : ""}

{isEdit && baseline.published && slugPreview !== baseline.slug ? (

保存后访问地址会改变。旧链接不会自动跳转。

diff --git a/frontend/app/admin/pages/PagesAdmin.tsx b/frontend/app/admin/pages/PagesAdmin.tsx index 6f9dab9..6c03b7a 100644 --- a/frontend/app/admin/pages/PagesAdmin.tsx +++ b/frontend/app/admin/pages/PagesAdmin.tsx @@ -7,6 +7,7 @@ import { Ellipsis, ExternalLink, FileText, Plus } from "lucide-react"; import { apiAdminDeleteSitePage, type SitePage } from "@/lib/api"; import { formatDateTime, formatRelative } from "@/lib/format"; import { sitePagePublicPath } from "@/lib/sitePagePath"; +import { useUrlStyle } from "@/components/SiteBrandProvider"; import { toast } from "@/lib/toast"; import ConfirmDialog from "@/components/ConfirmDialog"; import { @@ -50,6 +51,7 @@ export default function PagesAdmin({ }) { const router = useRouter(); const searchParams = useSearchParams(); + const urlStyle = useUrlStyle(); const statusFilter = readStatus(searchParams.get("status")); const [list, setList] = useState(initial); const [loadError] = useState(initError); @@ -166,7 +168,7 @@ export default function PagesAdmin({ {filtered.map((p) => { const updated = p.updated_at || ""; - const path = sitePagePublicPath(p.slug); + const path = sitePagePublicPath(p.slug, urlStyle); return ( diff --git a/frontend/app/admin/points/EconomyOverview.tsx b/frontend/app/admin/points/EconomyOverview.tsx index b163b75..5a0e6ec 100644 --- a/frontend/app/admin/points/EconomyOverview.tsx +++ b/frontend/app/admin/points/EconomyOverview.tsx @@ -9,6 +9,8 @@ import { pointReasonLabel } from "@/lib/points"; import { formatRelative } from "@/lib/format"; import { AdminPageHeader, AdminSegmented, AdminStatCard } from "@/components/admin"; import Avatar from "@/components/Avatar"; +import { useUrlStyle } from "@/components/SiteBrandProvider"; +import { userPath } from "@/lib/urlStyle"; import { AdminChartCard, DeltaBarChart, @@ -326,10 +328,11 @@ function StockOverviewCard({ stats }: { stats: EconomyStats }) { } function LedgerUserLink({ user, compact }: { user: LedgerParty; compact?: boolean }) { + const urlStyle = useUrlStyle(); const name = partyName(user); return ( diff --git a/frontend/app/admin/points/UserPointsTab.tsx b/frontend/app/admin/points/UserPointsTab.tsx index ed199b7..2b610e2 100644 --- a/frontend/app/admin/points/UserPointsTab.tsx +++ b/frontend/app/admin/points/UserPointsTab.tsx @@ -17,6 +17,8 @@ import { AdminPageHeader, AdminPager, AdminSearchField, AdminStepper } from "@/c import { pointReasonLabel } from "@/lib/points"; import { formatRelative } from "@/lib/format"; import { toast } from "@/lib/toast"; +import { userPath } from "@/lib/urlStyle"; +import { useUrlStyle } from "@/components/SiteBrandProvider"; const PAGE_SIZE = 20; const MAX_DELTA = 100000; // 与后端 adminAdjustMaxDelta 一致 @@ -72,6 +74,7 @@ function SortHeader({ * 扣分仅扣余额不降级,理由必填)与「流水」弹窗(分页查看该用户积分流水)。 */ export default function UserPointsTab() { + const urlStyle = useUrlStyle(); const [keyword, setKeyword] = useState(""); const [appliedQ, setAppliedQ] = useState(""); const [page, setPage] = useState(1); @@ -209,7 +212,7 @@ export default function UserPointsTab() { {users.map((u) => ( - + 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) => (
  1. - + {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({
{author ? ( - + ) : ( @@ -1098,7 +1101,7 @@ export default function CommentSection({
{author ? ( - + {authorName} @@ -1189,7 +1192,7 @@ export default function CommentSection({
{author ? ( - + ) : ( @@ -1199,7 +1202,7 @@ export default function CommentSection({
{author ? ( - + {authorName} @@ -1302,7 +1305,7 @@ export default function CommentSection({ return ; } if (commentsRequireLogin && !user) { - return ; + return ; } return ( @@ -1386,7 +1389,7 @@ export default function CommentSection({ 登录后参与讨论 - + 去登录
@@ -1405,7 +1408,7 @@ export default function CommentSection({ )}
- + void; }) { + const urlStyle = useUrlStyle(); const [loading, setLoading] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(""); @@ -366,12 +369,12 @@ export default function EditHistoryModal({ style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }} >
- +
diff --git a/frontend/components/Header.tsx b/frontend/components/Header.tsx index ef73904..cf33b2d 100644 --- a/frontend/components/Header.tsx +++ b/frontend/components/Header.tsx @@ -21,6 +21,7 @@ import { } from "lucide-react"; import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api"; import { hasAuthCookieHint } from "@/lib/cookies"; +import { userPath } from "@/lib/urlStyle"; import { canAccessAdminMessages, isStaff } from "@/lib/roles"; import { onUserUpdate, onForceLogout } from "@/lib/userEvents"; import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime"; @@ -33,6 +34,8 @@ import LevelBadge from "./LevelBadge"; import BadgeChip from "./BadgeChip"; import BrandLockup from "./BrandLockup"; import { useAllowMessages } from "./MessagesPolicyProvider"; +import { useUrlStyle } from "./SiteBrandProvider"; +import { leaderboardPath } from "@/lib/urlStyle"; import type { BrandLogoFit, BrandLogoSize, BrandMark } from "@/lib/brand"; export default function Header({ @@ -62,6 +65,8 @@ export default function Header({ }) { const router = useRouter(); const pathname = usePathname(); + const urlStyle = useUrlStyle(); + const lbHref = leaderboardPath(urlStyle); const allowMessages = useAllowMessages(); const hideComposeNav = pathname === "/compose" || @@ -239,8 +244,8 @@ export default function Header({ 首页 } > 排行榜 @@ -410,6 +415,7 @@ function NavPill({ * 把零散的图标按钮收拢为一个语义明确的控件;移动端只显示头像 */ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) { + const urlStyle = useUrlStyle(); const [open, setOpen] = useState(false); const menuRef = useRef(null); @@ -493,7 +499,7 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) { setOpen(false)} className={itemCls} role="menuitem"> 通知中心 - setOpen(false)} className={itemCls} role="menuitem"> + setOpen(false)} className={itemCls} role="menuitem"> 个人主页 setOpen(false)} className={itemCls} role="menuitem"> diff --git a/frontend/components/MarkdownEditor.tsx b/frontend/components/MarkdownEditor.tsx index 099390b..3df9327 100644 --- a/frontend/components/MarkdownEditor.tsx +++ b/frontend/components/MarkdownEditor.tsx @@ -40,6 +40,8 @@ import ImageInsertModal from "@/components/ImageInsertModal"; import TimelineEditor from "@/components/TimelineEditor"; import { resolveTimelineBlockForEdit } from "@/lib/directiveBlocks"; import { SHIKI_LANG_IDS } from "@/components/markdown/shikiHighlighter"; +import { postPath } from "@/lib/urlStyle"; +import { useUrlStyle } from "@/components/SiteBrandProvider"; type MarkdownEditorProps = { value: string; @@ -157,6 +159,7 @@ export default function MarkdownEditor({ onUploadingChange, }: MarkdownEditorProps) { const taRef = useRef(null); + const urlStyle = useUrlStyle(); const fileRef = useRef(null); const toolbarRef = useRef(null); const [mode, setMode] = useState("write"); @@ -472,7 +475,7 @@ export default function MarkdownEditor({ /** 选中站内帖子:回填 URL 与文字,用户可再编辑后点「插入」 */ const pickPostLink = (p: PostListItem) => { - setLinkUrl(`/post/${p.id}`); + setLinkUrl(postPath(urlStyle, p.id)); setLinkText(p.title); }; diff --git a/frontend/components/MobileTabBar.tsx b/frontend/components/MobileTabBar.tsx index cff19fd..9ba0fb7 100644 --- a/frontend/components/MobileTabBar.tsx +++ b/frontend/components/MobileTabBar.tsx @@ -12,7 +12,9 @@ import { } from "lucide-react"; import { apiChatUnreadSummary, type User } from "@/lib/api"; import { useAllowMessages } from "@/components/MessagesPolicyProvider"; +import { useUrlStyle } from "@/components/SiteBrandProvider"; import { onDocumentVisible } from "@/lib/visibilityReconcile"; +import { parseEntityPath, userPath, leaderboardPath } from "@/lib/urlStyle"; import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime"; import type { RtChatUnreadData } from "@/lib/realtime"; @@ -30,6 +32,8 @@ export default function MobileTabBar({ }) { const pathname = usePathname(); const allowMessages = useAllowMessages(); + const urlStyle = useUrlStyle(); + const lbHref = leaderboardPath(urlStyle); const [chatUnread, setChatUnread] = useState(initialChatUnread); // 登录态经 router.refresh() 下发新 props 时校正徽标 @@ -96,10 +100,10 @@ export default function MobileTabBar({ active={isActive((p) => p === "/")} /> } label="排行榜" - active={isActive((p) => p.startsWith("/leaderboard"))} + active={!!pathname && (pathname === lbHref || pathname.startsWith(`${lbHref}/`))} /> )} } label="我的" active={ - !!user && isActive((p) => p === `/u/${user.id}` || p.startsWith(`/u/${user.id}/`)) + !!user && + isActive((p) => { + const parsed = parseEntityPath(p); + return ( + (parsed?.kind === "user" && parsed.id === user.id) || + p.startsWith(`${userPath(urlStyle, user.id)}/`) + ); + }) } />
diff --git a/frontend/components/NotificationBell.tsx b/frontend/components/NotificationBell.tsx index 174d597..ad72d9a 100644 --- a/frontend/components/NotificationBell.tsx +++ b/frontend/components/NotificationBell.tsx @@ -13,9 +13,12 @@ import { } from "@/lib/api"; import { realtime, RT_NOTIFICATION_NEW } from "@/lib/realtime"; import { onDocumentVisible } from "@/lib/visibilityReconcile"; +import { useUrlStyle } from "@/components/SiteBrandProvider"; +import { postPath, userPath } from "@/lib/urlStyle"; export default function NotificationBell({ initialUnread = 0 }: { initialUnread?: number }) { const router = useRouter(); + const urlStyle = useUrlStyle(); const [open, setOpen] = useState(false); // SSR 直出未读数,红点不再挂载后才弹出;WS/回前台校准 const [unread, setUnread] = useState(initialUnread); @@ -103,16 +106,16 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread? 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)); } }; diff --git a/frontend/components/PinnedPosts.tsx b/frontend/components/PinnedPosts.tsx index 5e833e9..1deea5d 100644 --- a/frontend/components/PinnedPosts.tsx +++ b/frontend/components/PinnedPosts.tsx @@ -1,6 +1,8 @@ import Link from "next/link"; import { Pin } from "lucide-react"; import type { PostListItem } from "@/lib/api"; +import { postPath } from "@/lib/urlStyle"; +import { getPublicSettingsCached } from "@/lib/serverData"; import Avatar from "./Avatar"; import { IconComment, IconEye } from "./Icons"; @@ -15,7 +17,7 @@ function formatTime(dateStr: string) { * 管理员置顶区:独立于普通帖子列表。 * 陶土色语义(与 badge-pin 一致),紧凑排列,一眼识别为公告性质内容。 */ -export default function PinnedPosts({ +export default async function PinnedPosts({ posts, showBoard = true, }: { @@ -23,6 +25,7 @@ export default function PinnedPosts({ showBoard?: boolean; }) { if (posts.length === 0) return null; + const urlStyle = (await getPublicSettingsCached()).url_style; return (
@@ -43,7 +46,7 @@ export default function PinnedPosts({ {posts.map((p, i) => ( 0 ? { borderTop: "1px solid var(--line)" } : {}), diff --git a/frontend/components/PostAttachments.tsx b/frontend/components/PostAttachments.tsx index 42a298c..ce37c7a 100644 --- a/frontend/components/PostAttachments.tsx +++ b/frontend/components/PostAttachments.tsx @@ -12,6 +12,8 @@ import { import { fileExtBadge, fileKind } from "@/lib/fileKind"; import { toast } from "@/lib/toast"; import Modal from "@/components/Modal"; +import { postPath } from "@/lib/urlStyle"; +import { useUrlStyle } from "@/components/SiteBrandProvider"; function formatSize(n: number) { if (n < 1024) return `${n} B`; @@ -86,7 +88,8 @@ export default function PostAttachments({ loggedIn: boolean; }) { const router = useRouter(); - const loginHref = `/login?redirect=${encodeURIComponent(`/post/${postId}`)}`; + const urlStyle = useUrlStyle(); + const loginHref = `/login?redirect=${encodeURIComponent(postPath(urlStyle, postId))}`; const [busyId, setBusyId] = useState(null); const [pending, setPending] = useState(null); const [unlockedIds, setUnlockedIds] = useState([]); diff --git a/frontend/components/PostComposer.tsx b/frontend/components/PostComposer.tsx index 2bd42d0..5547393 100644 --- a/frontend/components/PostComposer.tsx +++ b/frontend/components/PostComposer.tsx @@ -35,6 +35,8 @@ import DeadlinePicker, { } from "@/components/DeadlinePicker"; import Modal from "@/components/Modal"; import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime"; +import { postPath } from "@/lib/urlStyle"; +import { useUrlStyle } from "@/components/SiteBrandProvider"; const TITLE_MAX = 256; /** 帖子正文字符上限(与后端 handler/post.go 校验一致) */ @@ -179,6 +181,7 @@ export type PostComposerProps = CreateProps | EditProps; export default function PostComposer(props: PostComposerProps) { const router = useRouter(); + const urlStyle = useUrlStyle(); const isCreate = props.mode === "create"; const [boards, setBoards] = useState([]); @@ -828,7 +831,7 @@ export default function PostComposer(props: PostComposerProps) { } else { toast("发布成功", "ok"); } - router.push(`/post/${res.post.id}`); + router.push(postPath(urlStyle, res.post.id)); } else { const mapped = mapPostError(res.error || "发帖失败"); reportSubmitError( diff --git a/frontend/components/PostInteractPanels.tsx b/frontend/components/PostInteractPanels.tsx index 0a91a0e..3e666ba 100644 --- a/frontend/components/PostInteractPanels.tsx +++ b/frontend/components/PostInteractPanels.tsx @@ -18,6 +18,8 @@ import { import { toast } from "@/lib/toast"; import Avatar from "@/components/Avatar"; import { useAllowComments } from "@/components/CommentsPolicyProvider"; +import { postPath, userPath } from "@/lib/urlStyle"; +import { useUrlStyle } from "@/components/SiteBrandProvider"; export default function PostInteractPanels({ postId, @@ -286,6 +288,7 @@ function PollPanel({ loggedIn: boolean; }) { const router = useRouter(); + const urlStyle = useUrlStyle(); const [sel, setSel] = useState(state.my_options || []); const [busy, setBusy] = useState(false); const showResults = state.closed || (state.my_options && state.my_options.length > 0); @@ -418,7 +421,7 @@ function PollPanel({
    {namedVoters.map((v) => (
  • - + {v.nickname || v.username} @@ -445,7 +448,7 @@ function PollPanel({ )} {!loggedIn && !state.closed && ( 登录后投票 @@ -559,6 +562,7 @@ function LotteryPanel({ state: LotteryState; }) { const router = useRouter(); + const urlStyle = useUrlStyle(); const [busy, setBusy] = useState(false); const act = async (fn: () => Promise<{ post?: unknown; error?: string }>, ok: string) => { @@ -651,7 +655,7 @@ function LotteryPanel({ className="j13-lottery-winner-avatar" />
    - + {w.nickname || w.username} {w.prize_name ? ( diff --git a/frontend/components/PostRow.tsx b/frontend/components/PostRow.tsx index e6cf028..c8e6320 100644 --- a/frontend/components/PostRow.tsx +++ b/frontend/components/PostRow.tsx @@ -3,6 +3,7 @@ import { ArrowLeft } from "lucide-react"; import type { PostListItem } from "@/lib/api"; import { formatRelative } from "@/lib/format"; import { getPublicSettingsCached } from "@/lib/serverData"; +import { postPath, userPath } from "@/lib/urlStyle"; import { normalizePostType, postTypeLabel, typeStatusLabel, typeStatusClass } from "@/lib/postMeta"; import LikeButton from "./LikeButton"; import Avatar from "./Avatar"; @@ -32,7 +33,9 @@ export default async function PostRow({ showViews?: boolean; showLike?: boolean; }) { - const allowComments = (await getPublicSettingsCached()).allow_comments !== false; + const settings = await getPublicSettingsCached(); + const allowComments = settings.allow_comments !== false; + const urlStyle = settings.url_style; const lastReply = post.last_reply; const replied = allowComments && @@ -56,7 +59,7 @@ export default async function PostRow({ }} > 推荐}
    @@ -98,7 +101,7 @@ export default async function PostRow({ {post.board.name} )} {authorName} @@ -109,7 +112,7 @@ export default async function PostRow({ {lastReply!.user.nickname || lastReply!.user.username} @@ -128,7 +131,7 @@ export default async function PostRow({
    {allowComments ? ( 当前角色 {currentMetaLabel}

    diff --git a/frontend/components/SiteBrandProvider.tsx b/frontend/components/SiteBrandProvider.tsx index ac18cc8..bf624de 100644 --- a/frontend/components/SiteBrandProvider.tsx +++ b/frontend/components/SiteBrandProvider.tsx @@ -3,6 +3,7 @@ import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { apiGetSettings, type PublicSettings } from "@/lib/api"; import { siteDocumentTitle, normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark, normalizeFooterLinks } from "@/lib/brand"; +import { normalizeUrlStyle, type UrlStyle } from "@/lib/urlStyle"; import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime"; import { onDocumentVisible, VISIBLE_RECONCILE_GAP_MS } from "@/lib/visibilityReconcile"; @@ -17,11 +18,12 @@ export type SiteBrand = { brand_logo_size: PublicSettings["brand_logo_size"]; brand_logo_fit: PublicSettings["brand_logo_fit"]; footer_links: PublicSettings["footer_links"]; + url_style: UrlStyle; }; const SiteBrandContext = createContext(null); -function fromSettings(s: Pick): SiteBrand { +function fromSettings(s: PublicSettings): SiteBrand { return { site_name: s.site_name, site_wordmark: s.site_wordmark, @@ -33,6 +35,7 @@ function fromSettings(s: Pick): SiteBrand { brand_logo_size: s.brand_logo_size, brand_logo_fit: s.brand_logo_fit, footer_links: s.footer_links, + url_style: s.url_style, }; } @@ -47,6 +50,7 @@ function sameBrand(a: SiteBrand, b: SiteBrand): boolean { a.brand_mark === b.brand_mark && a.brand_logo_size === b.brand_logo_size && a.brand_logo_fit === b.brand_logo_fit && + a.url_style === b.url_style && JSON.stringify(a.footer_links) === JSON.stringify(b.footer_links) ); } @@ -97,11 +101,17 @@ export function useSiteBrand(): SiteBrand { brand_logo_size: "sq", brand_logo_fit: "contain", footer_links: [], + url_style: "default", }; } return ctx; } +/** 详情页伪静态风格(SSR 初值与水合一致,设置保存后 realtime 热更) */ +export function useUrlStyle(): UrlStyle { + return useSiteBrand().url_style; +} + /** SSR 初值 + settings:changed:页眉/页脚/标题/favicon 不必整页刷新 */ export default function SiteBrandProvider({ initial, @@ -142,6 +152,7 @@ export default function SiteBrandProvider({ footer_links: Array.isArray(raw.footer_links) ? normalizeFooterLinks(raw.footer_links) : prev.footer_links, + url_style: raw.url_style !== undefined ? normalizeUrlStyle(raw.url_style) : prev.url_style, }; if (sameBrand(prev, next)) return; retitle(prev, next); diff --git a/frontend/components/admin/AdminShell.tsx b/frontend/components/admin/AdminShell.tsx index dfd6c02..59cddc8 100644 --- a/frontend/components/admin/AdminShell.tsx +++ b/frontend/components/admin/AdminShell.tsx @@ -48,6 +48,8 @@ import Avatar from "@/components/Avatar"; import RoleBadge from "@/components/RoleBadge"; import NotificationBell from "@/components/NotificationBell"; import ThemeToggle from "@/components/ThemeToggle"; +import { useUrlStyle } from "@/components/SiteBrandProvider"; +import { userPath } from "@/lib/urlStyle"; interface NavItem { href: string; @@ -294,6 +296,7 @@ function AdminRailNav({ } function AdminUserMenu({ user, onLogout }: { user: User; onLogout: () => void }) { + const urlStyle = useUrlStyle(); const [open, setOpen] = useState(false); const menuRef = useRef(null); @@ -375,7 +378,7 @@ function AdminUserMenu({ user, onLogout }: { user: User; onLogout: () => void })
    - setOpen(false)} className={itemCls} role="menuitem"> + setOpen(false)} className={itemCls} role="menuitem"> 个人主页 setOpen(false)} className={itemCls} role="menuitem"> diff --git a/frontend/components/home/BoardPanels.tsx b/frontend/components/home/BoardPanels.tsx index 578f335..17d72e0 100644 --- a/frontend/components/home/BoardPanels.tsx +++ b/frontend/components/home/BoardPanels.tsx @@ -24,10 +24,13 @@ import LevelBadge from "@/components/LevelBadge"; import BadgeChip from "@/components/BadgeChip"; import { PulseCheckin } from "./CheckinCard"; import { formatCompact } from "@/lib/format"; +import { getPublicSettingsCached } from "@/lib/serverData"; +import { postPath, userPath } from "@/lib/urlStyle"; /* ---------- 1. 板块管理员 ---------- */ -export function Moderators({ items }: { items: BoardModerator[] }) { +export async function Moderators({ items }: { items: BoardModerator[] }) { + const urlStyle = (await getPublicSettingsCached()).url_style; return (

    @@ -41,7 +44,7 @@ export function Moderators({ items }: { items: BoardModerator[] }) { const name = u.nickname || u.username; return (

  • - + {name} @@ -163,7 +166,8 @@ export function BoardPulse({ /* ---------- 4. 本板热门 ---------- */ -export function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) { +export async function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) { + const urlStyle = (await getPublicSettingsCached()).url_style; return (

    @@ -175,7 +179,7 @@ export function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allo

      {posts.map((p, i) => (
    1. - + {i + 1} @@ -203,7 +207,8 @@ export function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allo /* ---------- 5. 本板活跃 ---------- */ -export function BoardActive({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) { +export async function BoardActive({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) { + const urlStyle = (await getPublicSettingsCached()).url_style; return (

      @@ -223,7 +228,7 @@ export function BoardActive({ users, allowComments }: { users: ActiveUser[]; all const name = u.nickname || u.username; return (

    2. - + @@ -160,7 +163,7 @@ export function AboutSite({ const Icon = pageGlyph(p.slug); return (
    3. - + @@ -181,13 +184,14 @@ export function AboutSite({ /* ---------- 站点公告 ---------- */ -export function Announcements({ +export async function Announcements({ items, canManageAnnouncements, }: { items: AnnouncementSummary[]; canManageAnnouncements?: boolean; }) { + const urlStyle = (await getPublicSettingsCached()).url_style; return (
      @@ -218,7 +222,7 @@ export function Announcements({ {items.map((a) => (
    4. @@ -254,7 +258,8 @@ export function Announcements({ /* ---------- 热门榜 ---------- */ -export function HotRanking({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) { +export async function HotRanking({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) { + const urlStyle = (await getPublicSettingsCached()).url_style; return (

      @@ -266,7 +271,7 @@ export function HotRanking({ posts, allowComments }: { posts: PostListItem[]; al

        {posts.map((p, i) => (
      1. - + {i + 1} @@ -294,8 +299,9 @@ export function HotRanking({ posts, allowComments }: { posts: PostListItem[]; al /* ---------- 最新注册 ---------- */ -export function NewUsers({ users }: { users: NewUser[] }) { +export async function NewUsers({ users }: { users: NewUser[] }) { if (users.length === 0) return null; + const urlStyle = (await getPublicSettingsCached()).url_style; return (

        @@ -307,7 +313,7 @@ export function NewUsers({ users }: { users: NewUser[] }) { return (

      2. @@ -329,7 +335,8 @@ export function NewUsers({ users }: { users: NewUser[] }) { /* ---------- 活跃成员(本周发帖 / 回复) ---------- */ -export function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) { +export async function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) { + const urlStyle = (await getPublicSettingsCached()).url_style; return (

        @@ -346,7 +353,7 @@ export function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; all const name = u.nickname || u.username; return (

      3. - + {user ? ( - + { @@ -145,7 +150,7 @@ export default function MdHideBlock({ ; boardColors?: Record; }) { + const urlStyle = (await getPublicSettingsCached()).url_style; const u = profile.user; const signature = (u.signature || "").trim(); const { post_count, comment_count, points, streak } = profile.stats; @@ -80,13 +83,13 @@ export function AuthorRailCard({ 作者

        - +
        @@ -160,15 +163,16 @@ export function AuthorRailCard({ 连签 {streak} 天

        )} - + 访问主页
      4. ); } -export function AuthorPosts({ posts }: { posts: PostListItem[] }) { +export async function AuthorPosts({ posts }: { posts: PostListItem[] }) { if (posts.length === 0) return null; + const urlStyle = (await getPublicSettingsCached()).url_style; return (

        @@ -178,7 +182,7 @@ export function AuthorPosts({ posts }: { posts: PostListItem[] }) { {posts.map((p) => (

      5. diff --git a/frontend/components/site-doc/SiteDocAnnoNav.tsx b/frontend/components/site-doc/SiteDocAnnoNav.tsx index 2e2de47..271c89f 100644 --- a/frontend/components/site-doc/SiteDocAnnoNav.tsx +++ b/frontend/components/site-doc/SiteDocAnnoNav.tsx @@ -2,9 +2,11 @@ import Link from "next/link"; import { Megaphone, Pin } from "lucide-react"; import type { AnnouncementSummary } from "@/lib/api"; import { formatShortDate } from "@/lib/format"; +import { getPublicSettingsCached } from "@/lib/serverData"; +import { announcementPath } from "@/lib/urlStyle"; /** 近期公告目录(高亮当前 id) */ -export default function SiteDocAnnoNav({ +export default async function SiteDocAnnoNav({ items, currentId, showAllLink = true, @@ -13,6 +15,7 @@ export default function SiteDocAnnoNav({ currentId?: number; showAllLink?: boolean; }) { + const urlStyle = (await getPublicSettingsCached()).url_style; return (
        @@ -37,7 +40,7 @@ export default function SiteDocAnnoNav({ return (
      6. @@ -31,7 +34,7 @@ export default function SiteDocPageNav({ return (
      7. | null | undefine bg_admin_mode: typeof data?.bg_admin_mode === "string" ? data.bg_admin_mode : "cover", ...normalizePointsSettings(data), ...normalizeLockSettings(data), + url_style: normalizeUrlStyle(data?.url_style), levels: normalizeLevels(data?.levels), levels_fx: data?.levels_fx !== false, }; @@ -3361,6 +3366,7 @@ export type UpdateSiteSettingsBody = { footer_links?: FooterLink[]; allow_register?: boolean; allow_comments?: boolean; + url_style?: UrlStyle; comments_require_login?: boolean; allow_messages?: boolean; post_cooldown_hours?: number; diff --git a/frontend/lib/jsonLd.ts b/frontend/lib/jsonLd.ts index 5afa3f9..357dc22 100644 --- a/frontend/lib/jsonLd.ts +++ b/frontend/lib/jsonLd.ts @@ -1,4 +1,5 @@ import { absoluteCanonical, resolveSiteOrigin, type QueryBag } from "./siteUrl.ts"; +import { postPath, userPath, type UrlStyle } from "./urlStyle.ts"; /** 防止用户正文里的 提前闭合 JSON-LD 脚本 */ export function serializeJsonLd(data: unknown): string { @@ -15,8 +16,8 @@ export type JsonLdPerson = { name: string; }; -function personNode(person: JsonLdPerson) { - const url = person.id ? absoluteCanonical(`/u/${person.id}`) : undefined; +function personNode(person: JsonLdPerson, urlStyle: UrlStyle = "default") { + const url = person.id ? absoluteCanonical(userPath(urlStyle, person.id)) : undefined; return { "@type": "Person" as const, name: person.name, @@ -52,7 +53,9 @@ export function listingJsonLd(input: { items: { id: number; name: string }[]; page: number; pageSize: number; + urlStyle?: UrlStyle; }): Record | null { + const urlStyle = input.urlStyle ?? "default"; const site = websiteNode(input.siteName, input.siteDescription); const pageUrl = absoluteCanonical(input.path, input.query); const origin = resolveSiteOrigin(); @@ -75,7 +78,7 @@ export function listingJsonLd(input: { "@type": "ListItem", position: (input.page - 1) * input.pageSize + i + 1, name: item.name, - url: `${origin}/post/${item.id}`, + url: `${origin}${postPath(urlStyle, item.id)}`, })), }, }, @@ -105,8 +108,10 @@ export function questionJsonLd(input: { pageUrl?: string; accepted?: JsonLdVisibleComment | null; answers: JsonLdVisibleComment[]; + urlStyle?: UrlStyle; }): Record | null { - const entityUrl = absoluteCanonical(`/post/${input.postId}`); + const urlStyle = input.urlStyle ?? "default"; + const entityUrl = absoluteCanonical(postPath(urlStyle, input.postId)); const origin = resolveSiteOrigin(); const site = websiteId(); if (!entityUrl || !origin || !site) return null; @@ -120,7 +125,7 @@ export function questionJsonLd(input: { text: a.content, dateCreated: a.created_at, url: commentUrl(entityUrl, a.id), - ...(a.author ? { author: personNode(a.author) } : {}), + ...(a.author ? { author: personNode(a.author, urlStyle) } : {}), }); return { "@context": "https://schema.org", @@ -137,7 +142,7 @@ export function questionJsonLd(input: { ...(input.text ? { text: input.text } : {}), dateCreated: input.createdAt, ...(input.updatedAt ? { dateModified: input.updatedAt } : {}), - author: personNode(input.author), + author: personNode(input.author, urlStyle), ...(input.accepted && input.accepted.content.trim() ? { acceptedAnswer: answerNode(input.accepted) } : {}), @@ -158,8 +163,10 @@ export function discussionJsonLd(input: { commentCount?: number; viewCount?: number; comments: JsonLdVisibleComment[]; + urlStyle?: UrlStyle; }): Record | null { - const entityUrl = absoluteCanonical(`/post/${input.postId}`); + const urlStyle = input.urlStyle ?? "default"; + const entityUrl = absoluteCanonical(postPath(urlStyle, input.postId)); const site = websiteId(); if (!entityUrl || !site) return null; const pageUrl = input.pageUrl || entityUrl; @@ -174,7 +181,7 @@ export function discussionJsonLd(input: { datePublished: input.createdAt, ...(input.updatedAt ? { dateModified: input.updatedAt } : {}), inLanguage: "zh-CN", - author: personNode(input.author), + author: personNode(input.author, urlStyle), isPartOf: { "@id": site }, ...(pageUrl !== entityUrl ? { @@ -202,7 +209,7 @@ export function discussionJsonLd(input: { text: c.content, dateCreated: c.created_at, url: commentUrl(entityUrl, c.id), - ...(c.author ? { author: personNode(c.author) } : {}), + ...(c.author ? { author: personNode(c.author, urlStyle) } : {}), })), } : {}), @@ -215,8 +222,9 @@ export function profileJsonLd(input: { username: string; signature?: string; pageUrl?: string; + urlStyle?: UrlStyle; }): Record | null { - const personUrl = absoluteCanonical(`/u/${input.id}`); + const personUrl = absoluteCanonical(userPath(input.urlStyle ?? "default", input.id)); if (!personUrl) return null; const pageUrl = input.pageUrl || personUrl; return { diff --git a/frontend/lib/realtime.ts b/frontend/lib/realtime.ts index 4b41a96..89a8e9b 100644 --- a/frontend/lib/realtime.ts +++ b/frontend/lib/realtime.ts @@ -61,6 +61,7 @@ export interface RtSettingsData { footer_links?: { label: string; url: string; new_tab: boolean; align?: string }[]; allow_register?: boolean; allow_comments?: boolean; + url_style?: string; comments_require_login?: boolean; allow_messages?: boolean; post_cooldown_hours?: number; diff --git a/frontend/lib/sitePagePath.ts b/frontend/lib/sitePagePath.ts index 0d92411..5100ed4 100644 --- a/frontend/lib/sitePagePath.ts +++ b/frontend/lib/sitePagePath.ts @@ -1,13 +1,13 @@ -/** 单页公开路径。前台路由是 /p/[slug],列表、复制和线上查看必须用同一条。 */ -export function sitePagePublicPath(slug: string): string { - const clean = slug.trim(); - if (!clean) return "/p/"; - return `/p/${encodeURIComponent(clean)}`; +import { pagePath, type UrlStyle } from "./urlStyle.ts"; + +/** 单页公开路径。前台路由是 /p/[slug](伪静态风格跟随站点设置),列表、复制和线上查看必须用同一条。 */ +export function sitePagePublicPath(slug: string, style: UrlStyle = "default"): string { + return pagePath(style, slug); } /** 用当前页面的 origin 拼绝对地址,不写死域名。origin 为空时只返回路径。 */ -export function sitePagePublicURL(origin: string, slug: string): string { - const path = sitePagePublicPath(slug); +export function sitePagePublicURL(origin: string, slug: string, style: UrlStyle = "default"): string { + const path = sitePagePublicPath(slug, style); const base = origin.replace(/\/$/, ""); return base ? `${base}${path}` : path; } diff --git a/frontend/lib/urlStyle.test.ts b/frontend/lib/urlStyle.test.ts new file mode 100644 index 0000000..a6b8a4f --- /dev/null +++ b/frontend/lib/urlStyle.test.ts @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + announcementPath, + entityPath, + leaderboardPath, + matchPrettyPath, + matchStandardPath, + normalizeUrlStyle, + pagePath, + parseEntityPath, + postPath, + userPath, +} from "./urlStyle.ts"; + +describe("normalizeUrlStyle", () => { + it("合法值与容错", () => { + assert.equal(normalizeUrlStyle("default"), "default"); + assert.equal(normalizeUrlStyle(" HTML "), "html"); + assert.equal(normalizeUrlStyle("DISCUZ"), "discuz"); + assert.equal(normalizeUrlStyle("pretty"), "default"); + assert.equal(normalizeUrlStyle(undefined), "default"); + assert.equal(normalizeUrlStyle(42), "default"); + }); +}); + +describe("路径构造器", () => { + it("三风格帖子路径", () => { + assert.equal(postPath("default", 123), "/post/123"); + assert.equal(postPath("html", 123), "/post-123.html"); + assert.equal(postPath("discuz", 123), "/thread-123.html"); + }); + it("三风格用户路径", () => { + assert.equal(userPath("default", 5), "/u/5"); + assert.equal(userPath("html", 5), "/user-5.html"); + assert.equal(userPath("discuz", 5), "/space-uid-5.html"); + }); + it("三风格公告路径", () => { + assert.equal(announcementPath("default", 1), "/announcement/1"); + assert.equal(announcementPath("html", 1), "/announcement-1.html"); + assert.equal(announcementPath("discuz", 1), "/notice-1.html"); + }); + it("非法 id 返回空串", () => { + assert.equal(postPath("default", 0), ""); + assert.equal(postPath("html", -1), ""); + assert.equal(userPath("discuz", Number.NaN), ""); + }); + it("自建页三风格并编码 slug", () => { + assert.equal(pagePath("default", "about"), "/p/about"); + assert.equal(pagePath("html", "about"), "/p-about.html"); + assert.equal(pagePath("discuz", "使用指南"), `/page-${encodeURIComponent("使用指南")}.html`); + assert.equal(pagePath("html", "使用指南"), `/p-${encodeURIComponent("使用指南")}.html`); + assert.equal(pagePath("default", " "), "/p/"); + }); + it("排行榜三风格", () => { + assert.equal(leaderboardPath("default"), "/leaderboard"); + assert.equal(leaderboardPath("html"), "/leaderboard.html"); + assert.equal(leaderboardPath("discuz"), "/rank.html"); + }); + it("entityPath 与标准路径", () => { + assert.equal(entityPath("discuz", "post", 9), "/thread-9.html"); + assert.equal(entityPath("default", "user", 9), "/u/9"); + assert.equal(entityPath("html", "announcement", 9), "/announcement-9.html"); + }); +}); + +describe("matchPrettyPath", () => { + it("命中各风格模板", () => { + assert.deepEqual(matchPrettyPath("/post-12.html"), { style: "html", kind: "post", id: 12 }); + assert.deepEqual(matchPrettyPath("/user-3.html"), { style: "html", kind: "user", id: 3 }); + assert.deepEqual(matchPrettyPath("/announcement-7.html"), { style: "html", kind: "announcement", id: 7 }); + assert.deepEqual(matchPrettyPath("/thread-12.html"), { style: "discuz", kind: "post", id: 12 }); + assert.deepEqual(matchPrettyPath("/space-uid-3.html"), { style: "discuz", kind: "user", id: 3 }); + assert.deepEqual(matchPrettyPath("/notice-7.html"), { style: "discuz", kind: "announcement", id: 7 }); + assert.deepEqual(matchPrettyPath("/p-about.html"), { style: "html", kind: "page", slug: "about" }); + assert.deepEqual(matchPrettyPath("/leaderboard.html"), { style: "html", kind: "leaderboard" }); + assert.deepEqual(matchPrettyPath("/rank.html"), { style: "discuz", kind: "leaderboard" }); + }); + it("page slug 保持 encoded 原样", () => { + const enc = encodeURIComponent("使用指南"); + assert.deepEqual(matchPrettyPath(`/page-${enc}.html`), { style: "discuz", kind: "page", slug: enc }); + assert.deepEqual(matchPrettyPath(`/p-${enc}.html`), { style: "html", kind: "page", slug: enc }); + }); + it("排除子路径与伪造路径", () => { + assert.equal(matchPrettyPath("/post/12"), null); + assert.equal(matchPrettyPath("/post/12/edit"), null); + assert.equal(matchPrettyPath("/post-abc.html"), null); + assert.equal(matchPrettyPath("/thread-12.htmlx"), null); + assert.equal(matchPrettyPath("/thread-12.html/edit"), null); + assert.equal(matchPrettyPath("/thread--12.html"), null); + assert.equal(matchPrettyPath("/post.html"), null); + assert.equal(matchPrettyPath("/p-.html"), null); + assert.equal(matchPrettyPath("/leaderboardx.html"), null); + assert.equal(matchPrettyPath("/rank.htmlx"), null); + assert.equal(matchPrettyPath("/rank"), null); + }); +}); + +describe("matchStandardPath / parseEntityPath", () => { + it("标准路径精确匹配", () => { + assert.deepEqual(matchStandardPath("/post/12"), { kind: "post", id: 12 }); + assert.deepEqual(matchStandardPath("/u/5"), { kind: "user", id: 5 }); + assert.deepEqual(matchStandardPath("/announcement/7"), { kind: "announcement", id: 7 }); + assert.deepEqual(matchStandardPath("/p/about"), { kind: "page", slug: "about" }); + assert.deepEqual(matchStandardPath("/leaderboard"), { kind: "leaderboard" }); + assert.deepEqual(matchStandardPath("/leaderboard/"), { kind: "leaderboard" }); + assert.equal(matchStandardPath("/post/12/edit"), null); + assert.equal(matchStandardPath("/post/abc"), null); + assert.equal(matchStandardPath("/u/5/followers"), null); + assert.equal(matchStandardPath("/p/"), null); + assert.equal(matchStandardPath("/p/a/b"), null); + }); + it("parseEntityPath 兼容标准与全部模板", () => { + assert.deepEqual(parseEntityPath("/post/12"), { kind: "post", id: 12 }); + assert.deepEqual(parseEntityPath("/thread-12.html"), { kind: "post", id: 12 }); + assert.deepEqual(parseEntityPath("/post-12.html"), { kind: "post", id: 12 }); + assert.deepEqual(parseEntityPath("/space-uid-5.html"), { kind: "user", id: 5 }); + assert.deepEqual(parseEntityPath("/p/about"), { kind: "page", slug: "about" }); + assert.deepEqual(parseEntityPath("/p-about.html"), { kind: "page", slug: "about" }); + assert.deepEqual(parseEntityPath(`/page-${encodeURIComponent("使用指南")}.html`), { + kind: "page", + slug: "使用指南", + }); + assert.deepEqual(parseEntityPath("/rank.html"), { kind: "leaderboard" }); + assert.equal(parseEntityPath("/chat/5"), null); + assert.equal(parseEntityPath("/"), null); + }); +}); diff --git a/frontend/lib/urlStyle.ts b/frontend/lib/urlStyle.ts new file mode 100644 index 0000000..88b2b41 --- /dev/null +++ b/frontend/lib/urlStyle.ts @@ -0,0 +1,192 @@ +/** + * 详情页伪静态风格的单一事实源: + * middleware(改写/301)、URL 构造器、后台设置、测试共用本模块。 + * 风格枚举与后端 service.UrlStyles 保持一致:default / html / discuz。 + * + * 各风格模板(自建页 slug 与排行榜也参与;slug 非 ASCII 时按 encodeURIComponent 编码): + * default: /post/123 /u/5 /announcement/1 /p/about /leaderboard + * html: /post-123.html /user-5.html /announcement-1.html /p-about.html /leaderboard.html + * discuz: /thread-123.html /space-uid-5.html /notice-1.html /page-about.html /rank.html + */ + +export type UrlStyle = "default" | "html" | "discuz"; +export type EntityKind = "post" | "user" | "announcement"; +/** 全部可反解的实体类型;page 的 id 是 slug(字符串),leaderboard 是固定单页无 id */ +export type PageKind = EntityKind | "page" | "leaderboard"; + +export const URL_STYLES: readonly UrlStyle[] = ["default", "html", "discuz"]; + +export function normalizeUrlStyle(v: unknown): UrlStyle { + const s = String(v ?? "").trim().toLowerCase(); + return (URL_STYLES as readonly string[]).includes(s) ? (s as UrlStyle) : "default"; +} + +const PRETTY_TEMPLATES: Record, Record string>> = { + html: { + post: (id) => `/post-${id}.html`, + user: (id) => `/user-${id}.html`, + announcement: (id) => `/announcement-${id}.html`, + }, + discuz: { + post: (id) => `/thread-${id}.html`, + user: (id) => `/space-uid-${id}.html`, + announcement: (id) => `/notice-${id}.html`, + }, +}; + +const STANDARD_TEMPLATES: Record string> = { + post: (id) => `/post/${id}`, + user: (id) => `/u/${id}`, + announcement: (id) => `/announcement/${id}`, +}; + +const PAGE_TEMPLATES: Record, (slug: string) => string> = { + html: (slug) => `/p-${slug}.html`, + discuz: (slug) => `/page-${slug}.html`, +}; + +const LEADERBOARD_TEMPLATES: Record, string> = { + html: "/leaderboard.html", + discuz: "/rank.html", +}; + +/** id 非法(≤0/非有限数)时返回空串,调用方按异常路径处理 */ +function guardId(id: number): number | null { + return Number.isFinite(id) && id > 0 ? id : null; +} + +/** 帖子详情路径;default 与 pretty 风格共用 */ +export function postPath(style: UrlStyle, id: number): string { + const n = guardId(id); + if (n == null) return ""; + return style === "default" ? STANDARD_TEMPLATES.post(n) : PRETTY_TEMPLATES[style].post(n); +} + +/** 用户主页路径;?tab= 等查询参数由调用方拼接 */ +export function userPath(style: UrlStyle, id: number): string { + const n = guardId(id); + if (n == null) return ""; + return style === "default" ? STANDARD_TEMPLATES.user(n) : PRETTY_TEMPLATES[style].user(n); +} + +/** 公告详情路径 */ +export function announcementPath(style: UrlStyle, id: number): string { + const n = guardId(id); + if (n == null) return ""; + return style === "default" ? STANDARD_TEMPLATES.announcement(n) : PRETTY_TEMPLATES[style].announcement(n); +} + +/** 自建页路径:slug 走当前风格,非 ASCII 由 encodeURIComponent 编码 */ +export function pagePath(style: UrlStyle, slug: string): string { + const enc = encodeURIComponent(slug.trim()); + if (!enc) return "/p/"; + return joinPagePath(style, enc); +} + +/** 自建页路径(slug 已是 URL 编码形态,如来自路径反解);内部/ middleware 共用 */ +export function pagePathFromSlug(style: UrlStyle, encodedSlug: string): string { + return joinPagePath(style, encodedSlug); +} + +function joinPagePath(style: UrlStyle, enc: string): string { + return style === "default" ? `/p/${enc}` : PAGE_TEMPLATES[style](enc); +} + +/** 排行榜路径(固定单页) */ +export function leaderboardPath(style: UrlStyle): string { + return style === "default" ? "/leaderboard" : LEADERBOARD_TEMPLATES[style]; +} + +/** 实体对应风格下的标准/pretty 路径(内部与 middleware 共用) */ +export function entityPath(style: UrlStyle, kind: EntityKind, id: number): string { + if (kind === "post") return postPath(style, id); + if (kind === "user") return userPath(style, id); + return announcementPath(style, id); +} + +/** 标准内部路由路径(middleware rewrite 的落点) */ +export function standardEntityPath(kind: EntityKind, id: number): string { + return STANDARD_TEMPLATES[kind](id); +} + +export type PrettyMatch = + | { style: Exclude; kind: EntityKind; id: number } + | { style: Exclude; kind: "page"; slug: string } + | { style: Exclude; kind: "leaderboard" }; + +// 精确锚定:id 仅纯数字,天然排除 /post/123/edit 这类子路径与伪造路径; +// page 的 slug 捕获保持 URL 原样(encoded),rewrite 落点直接复用 +const PRETTY_PATTERNS: { re: RegExp; style: Exclude; kind: PageKind }[] = [ + { re: /^\/post-(\d+)\.html$/, style: "html", kind: "post" }, + { re: /^\/user-(\d+)\.html$/, style: "html", kind: "user" }, + { re: /^\/announcement-(\d+)\.html$/, style: "html", kind: "announcement" }, + { re: /^\/p-([^/]+)\.html$/, style: "html", kind: "page" }, + { re: /^\/leaderboard\.html$/, style: "html", kind: "leaderboard" }, + { re: /^\/thread-(\d+)\.html$/, style: "discuz", kind: "post" }, + { re: /^\/space-uid-(\d+)\.html$/, style: "discuz", kind: "user" }, + { re: /^\/notice-(\d+)\.html$/, style: "discuz", kind: "announcement" }, + { re: /^\/page-([^/]+)\.html$/, style: "discuz", kind: "page" }, + { re: /^\/rank\.html$/, style: "discuz", kind: "leaderboard" }, +]; + +const STANDARD_PATTERNS: { re: RegExp; kind: PageKind }[] = [ + { re: /^\/post\/(\d+)$/, kind: "post" }, + { re: /^\/u\/(\d+)$/, kind: "user" }, + { re: /^\/announcement\/(\d+)$/, kind: "announcement" }, + { re: /^\/p\/([^/]+)$/, kind: "page" }, + { re: /^\/leaderboard\/?$/, kind: "leaderboard" }, +]; + +/** 匹配任一伪静态风格路径(不含标准风格);命中返回风格/实体/id;page 的 slug 保持 encoded 原样 */ +export function matchPrettyPath(pathname: string): PrettyMatch | null { + for (const p of PRETTY_PATTERNS) { + const m = p.re.exec(pathname); + if (!m) continue; + if (p.kind === "page") return { style: p.style, kind: "page", slug: m[1] }; + if (p.kind === "leaderboard") return { style: p.style, kind: "leaderboard" }; + return { style: p.style, kind: p.kind, id: Number(m[1]) }; + } + return null; +} + +/** 匹配标准实体路径;page 的 slug 保持 encoded 原样(与 URL 一致,rewrite 直接复用) */ +export function matchStandardPath(pathname: string): StandardMatch | null { + for (const p of STANDARD_PATTERNS) { + const m = p.re.exec(pathname); + if (!m) continue; + if (p.kind === "page") return { kind: "page", slug: m[1] }; + if (p.kind === "leaderboard") return { kind: "leaderboard" }; + return { kind: p.kind, id: Number(m[1]) }; + } + return null; +} + +export type StandardMatch = + | { kind: EntityKind; id: number } + | { kind: "page"; slug: string } + | { kind: "leaderboard" }; + +/** + * 反解任意实体路径(标准 + 全部伪静态风格),供 isActive 前缀判断、面包屑等 + * "从路径认实体"的场景使用。page 的 slug 解码为原始文本;数字实体统一 { kind, id }。 + */ +export function parseEntityPath(pathname: string): StandardMatch | null { + const standard = matchStandardPath(pathname); + if (standard) { + return standard.kind === "page" ? { kind: "page", slug: decodeSlug(standard.slug) } : standard; + } + const pretty = matchPrettyPath(pathname); + if (!pretty) return null; + if (pretty.kind === "page") return { kind: "page", slug: decodeSlug(pretty.slug) }; + if (pretty.kind === "leaderboard") return { kind: "leaderboard" }; + return { kind: pretty.kind, id: pretty.id }; +} + +/** 解码 URL 段;非法序列(手动输入的坏编码)保持原样 */ +function decodeSlug(slug: string): string { + try { + return decodeURIComponent(slug); + } catch { + return slug; + } +} diff --git a/frontend/middleware.ts b/frontend/middleware.ts index 3090de4..c04fbdf 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -1,5 +1,16 @@ import { NextResponse, type NextRequest } from "next/server"; import { TOKEN_COOKIE, REFRESH_COOKIE, CSRF_COOKIE } from "@/lib/cookies"; +import { + entityPath, + leaderboardPath, + matchPrettyPath, + matchStandardPath, + normalizeUrlStyle, + pagePathFromSlug, + standardEntityPath, + type PageKind, + type UrlStyle, +} from "@/lib/urlStyle"; // SSR 登录态保障: // access token(j13_token,15 分钟)过期后,页面/RSC 请求到达时先在此静默轮转, @@ -129,11 +140,14 @@ function parseCookiePair(setCookie: string): [string, string] | null { return [pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()]; } -/** 透传 pathname 给根布局,用于 /admin 与公开站 chrome 分叉 */ -function withPathname(req: NextRequest, init?: { request?: { headers: Headers } }) { +/** 透传 pathname 给根布局,用于 /admin 与公开站 chrome 分叉;styleRewrite 时透传改写后的内部路径 */ +function withPathname(req: NextRequest, init?: { request?: { headers: Headers } }, styleRewrite?: URL) { const headers = new Headers(init?.request?.headers ?? req.headers); - headers.set("x-pathname", req.nextUrl.pathname); - return NextResponse.next({ request: { headers } }); + headers.set("x-pathname", (styleRewrite ?? req.nextUrl).pathname); + const res = styleRewrite + ? NextResponse.rewrite(styleRewrite, { request: { headers } }) + : NextResponse.next({ request: { headers } }); + return res; } // RFC 9309 规定爬虫文件必须是小写 /robots.txt。部分 SEO 检测工具会请求 @@ -147,16 +161,91 @@ function rewriteRobotsCase(req: NextRequest): NextResponse | null { return NextResponse.rewrite(url); } +// ---- 伪静态 URL 风格改写(模板定义见 lib/urlStyle.ts)---- +// 风格来自 /api/site-state(与维护态共用 3s 短缓存): +// - 非 default:当前风格 pretty 路径 → rewrite 回标准内部路由(继续走 token 轮转, +// 不得提前 return);标准路径或其它风格 pretty 路径 → 301 到当前风格(SEO 收敛, +// query 原样保留;301 优先于维护态 503,对爬虫语义正确)。 +// - default:任何 pretty 路径 → 301 回标准路径。 + +/** 廉价预判:只对可能命中的路径拉 site-state,/admin、/login 等不产生额外请求 */ +function couldBeEntityPath(pathname: string): boolean { + return ( + pathname.startsWith("/post/") || + pathname.startsWith("/u/") || + pathname.startsWith("/announcement/") || + pathname.startsWith("/p/") || + pathname.startsWith("/leaderboard") || + pathname.endsWith(".html") + ); +} + +type UrlStyleAction = { type: "pass" } | { type: "redirect"; target: string } | { type: "rewrite"; target: string }; + +/** 反解结果 → 标准内部路由路径(rewrite 落点);page 的 slug 保持 encoded 原样 */ +function standardTarget(m: { kind: PageKind; id?: number; slug?: string }): string { + if (m.kind === "page") return `/p/${m.slug ?? ""}`; + if (m.kind === "leaderboard") return "/leaderboard"; + return standardEntityPath(m.kind, m.id ?? 0); +} + +/** 反解结果 → 对应风格路径(301 目标);page 用 encoded slug 直接拼,避免二次编码 */ +function styledPath(style: UrlStyle, m: { kind: PageKind; id?: number; slug?: string }): string { + if (m.kind === "page") return pagePathFromSlug(style, m.slug ?? ""); + if (m.kind === "leaderboard") return leaderboardPath(style); + return entityPath(style, m.kind, m.id ?? 0); +} + +function resolveUrlStyleAction(style: UrlStyle, pathname: string): UrlStyleAction { + const pretty = matchPrettyPath(pathname); + const standard = pretty ? null : matchStandardPath(pathname); + if (!pretty && !standard) return { type: "pass" }; + + if (style === "default") { + if (!pretty) return { type: "pass" }; + return { type: "redirect", target: standardTarget(pretty) }; + } + + if (pretty) { + if (pretty.style === style) { + return { type: "rewrite", target: standardTarget(pretty) }; + } + return { type: "redirect", target: styledPath(style, pretty) }; + } + // 标准路径 → 301 到当前风格 + return { type: "redirect", target: styledPath(style, standard!) }; +} + +/** 301 目标只换 pathname,origin 与 query(?tab=/?page=)原样保留 */ +function urlStyleRedirect(req: NextRequest, targetPath: string): NextResponse { + const url = req.nextUrl.clone(); + url.pathname = targetPath; + return NextResponse.redirect(url, 301); +} + async function sessionMiddleware(req: NextRequest) { const robotsRewrite = rewriteRobotsCase(req); if (robotsRewrite) return robotsRewrite; + // 伪静态风格改写:rewrite 产物继续走下方 token 轮转(styleRewrite 传给 withPathname), + // 301 直接返回(优先于维护态,语义正确) + let styleRewrite: URL | undefined; + if (couldBeEntityPath(req.nextUrl.pathname)) { + const state = await getCachedPublicSiteState(); + const action = resolveUrlStyleAction(normalizeUrlStyle(state?.url_style), req.nextUrl.pathname); + if (action.type === "redirect") return urlStyleRedirect(req, action.target); + if (action.type === "rewrite") { + styleRewrite = req.nextUrl.clone(); + styleRewrite.pathname = action.target; + } + } + const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value; const accessToken = req.cookies.get(TOKEN_COOKIE)?.value; // 游客、access 仍有效、或服务端地址未配置:直接放行 if (!refreshToken || !isAccessTokenExpired(accessToken) || !API_BASE) { - return withPathname(req); + return withPathname(req, undefined, styleRewrite); } const csrf = req.cookies.get(CSRF_COOKIE)?.value ?? ""; @@ -175,7 +264,7 @@ async function sessionMiddleware(req: NextRequest) { const headers = new Headers(req.headers); headers.set("Cookie", [...jar].map(([k, v]) => `${k}=${v}`).join("; ")); - const res = withPathname(req, { request: { headers } }); + const res = withPathname(req, { request: { headers } }, styleRewrite); // 原样透传,HttpOnly/Path/SameSite/Secure 等属性全部以后端为准 for (const sc of setCookies) res.headers.append("Set-Cookie", sc); return res; @@ -183,7 +272,7 @@ async function sessionMiddleware(req: NextRequest) { if (setCookies.length > 0) { // refresh 已失效:透传后端的清 cookie 指令,避免之后每次请求都白轮转 - const res = withPathname(req); + const res = withPathname(req, undefined, styleRewrite); for (const sc of setCookies) res.headers.append("Set-Cookie", sc); return res; } @@ -191,7 +280,7 @@ async function sessionMiddleware(req: NextRequest) { // 后端不可达:降级匿名渲染,客户端 fetchWithRefresh 仍可兜底 } - return withPathname(req); + return withPathname(req, undefined, styleRewrite); } export const config = { @@ -219,6 +308,7 @@ type SiteMaintState = { retry_after: number; }; bypass?: boolean; + url_style?: string; }; // 维护态短缓存:Next 每次页面/RSC/预取都会进 middleware,开发态尤其密。 diff --git a/frontend/package.json b/frontend/package.json index 6ccdd14..fe32487 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,7 @@ "build": "next build", "start": "next start -p 3000", "lint": "next lint", - "test": "node --experimental-strip-types --test lib/siteUrl.test.ts lib/jsonLd.test.ts lib/widgetCode.test.ts lib/smartDiff.test.ts" + "test": "node --experimental-strip-types --test lib/siteUrl.test.ts lib/jsonLd.test.ts lib/urlStyle.test.ts lib/widgetCode.test.ts lib/smartDiff.test.ts" }, "dependencies": { "hast-util-to-jsx-runtime": "^2.3.6",