feat: 可配置站点 URL 风格

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

View File

@@ -245,8 +245,12 @@ func (h *Handlers) SiteState(c *gin.Context) {
c.JSON(503, gin.H{"error": "状态暂不可用"}) c.JSON(503, gin.H{"error": "状态暂不可用"})
return return
} }
urlStyle, e := h.Setting.UrlStyle()
if e != nil {
urlStyle = service.DefaultUrlStyle
}
c.Header("Cache-Control", "no-store") 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) { func (h *Handlers) SendEmailCode(c *gin.Context) {
var req struct { var req struct {

View File

@@ -75,6 +75,8 @@ type updateSettingsRequest struct {
Levels *[]model.LevelDef `json:"levels"` Levels *[]model.LevelDef `json:"levels"`
LevelsFx *bool `json:"levels_fx"` LevelsFx *bool `json:"levels_fx"`
UrlStyle *string `json:"url_style"`
} }
func settingsPayload(saved service.PublicSiteSettings) gin.H { func settingsPayload(saved service.PublicSiteSettings) gin.H {
@@ -132,6 +134,8 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
"levels": saved.Levels, "levels": saved.Levels,
"levels_fx": saved.LevelsFx, "levels_fx": saved.LevelsFx,
"url_style": saved.UrlStyle,
} }
} }
@@ -154,7 +158,8 @@ func (req *updateSettingsRequest) hasAny() bool {
req.PointsReplyReward != nil || req.PointsReplyDailyCap != nil || req.PointsReplyReward != nil || req.PointsReplyDailyCap != nil ||
req.PointsRecommendReward != nil || req.Levels != nil || req.LevelsFx != nil || req.PointsRecommendReward != nil || req.Levels != nil || req.LevelsFx != nil ||
req.PostEditLockHours != nil || req.CommentEditLockHours != nil || req.PostEditLockHours != nil || req.CommentEditLockHours != nil ||
req.NecroReplyAfterHours != nil || req.NecroReplyPenalty != nil req.NecroReplyAfterHours != nil || req.NecroReplyPenalty != nil ||
req.UrlStyle != nil
} }
// AdminGetSettings 超管读取站点设置(与公开 payload 字段一致) // AdminGetSettings 超管读取站点设置(与公开 payload 字段一致)
@@ -537,6 +542,16 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
return 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() saved, err := h.Setting.Public()
if err != nil { if err != nil {

View File

@@ -80,6 +80,9 @@ const (
// SettingKeyLevelsFx 等级特效动画总开关(流光/渐变动画);缺行视为开启(默认开) // SettingKeyLevelsFx 等级特效动画总开关(流光/渐变动画);缺行视为开启(默认开)
SettingKeyLevelsFx = "levels_fx" SettingKeyLevelsFx = "levels_fx"
// SettingKeyUrlStyle 详情页伪静态风格;缺行视为 default(标准动态路由风格)
SettingKeyUrlStyle = "url_style"
) )
const ( const (
@@ -112,6 +115,9 @@ const (
DefaultPointsCheckinBase = 5 DefaultPointsCheckinBase = 5
DefaultPointsStreakEveryDays = 7 DefaultPointsStreakEveryDays = 7
MaxPointsRule = 100 // 各积分规则值/上限的统一上界 MaxPointsRule = 100 // 各积分规则值/上限的统一上界
// DefaultUrlStyle 标准动态路由风格(/post/123)
DefaultUrlStyle = "default"
) )
// DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页) // DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页)
@@ -127,6 +133,9 @@ var DefaultAttachmentExts = []string{
var attachmentExtRe = regexp.MustCompile(`^[a-z0-9]{1,16}$`) var attachmentExtRe = regexp.MustCompile(`^[a-z0-9]{1,16}$`)
// UrlStyles 伪静态风格白名单,与前端 lib/urlStyle.ts 保持一致
var UrlStyles = []string{"default", "html", "discuz"}
// ErrInvalidAccent 主题色格式非法 // ErrInvalidAccent 主题色格式非法
var ErrInvalidAccent = errors.New("主题色格式不正确,应为 #RRGGBB") var ErrInvalidAccent = errors.New("主题色格式不正确,应为 #RRGGBB")
@@ -194,6 +203,9 @@ type PublicSiteSettings struct {
Levels []model.LevelDef `json:"levels"` Levels []model.LevelDef `json:"levels"`
// LevelsFx 等级特效动画总开关(流光/渐变动画;关闭后徽章退化为静态配色) // LevelsFx 等级特效动画总开关(流光/渐变动画;关闭后徽章退化为静态配色)
LevelsFx bool `json:"levels_fx"` LevelsFx bool `json:"levels_fx"`
// UrlStyle 详情页伪静态风格:default(/post/123)、html(/post-123.html)、discuz(/thread-123.html)
UrlStyle string `json:"url_style"`
} }
// SettingService 站点级键值设置 // SettingService 站点级键值设置
@@ -297,6 +309,7 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
BrandLogoSize: BrandLogoSizeSQ, BrandLogoSize: BrandLogoSizeSQ,
BrandLogoFit: BrandLogoFitContain, BrandLogoFit: BrandLogoFitContain,
FooterLinks: []FooterLink{}, FooterLinks: []FooterLink{},
UrlStyle: DefaultUrlStyle,
} }
accent, err := s.AccentColor() accent, err := s.AccentColor()
if err != nil { if err != nil {
@@ -494,12 +507,50 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
return out, err return out, err
} }
out.BgAdminMode = adminMode out.BgAdminMode = adminMode
urlStyle, err := s.UrlStyle()
if err != nil {
return out, err
}
out.UrlStyle = urlStyle
if err := s.fillBrand(&out); err != nil { if err := s.fillBrand(&out); err != nil {
return out, err return out, err
} }
return out, nil 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);未配置时返回空串,表示走前端默认色 // AccentColor 返回配置的主题色(小写 #rrggbb);未配置时返回空串,表示走前端默认色
func (s *SettingService) AccentColor() (string, error) { func (s *SettingService) AccentColor() (string, error) {
v, found, err := s.getValue(SettingKeyAccent) v, found, err := s.getValue(SettingKeyAccent)

View File

@@ -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)
}
}

View File

@@ -18,6 +18,7 @@ import {
SiteDocAnnoNav, SiteDocAnnoNav,
} from "@/components/site-doc"; } from "@/components/site-doc";
import { requestCanonical } from "@/lib/canonicalMeta"; import { requestCanonical } from "@/lib/canonicalMeta";
import { pagePath, type UrlStyle } from "@/lib/urlStyle";
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
return { return {
@@ -30,14 +31,16 @@ export async function generateMetadata(): Promise<Metadata> {
function PageRow({ function PageRow({
p, p,
showDivider, showDivider,
urlStyle,
}: { }: {
p: SitePageSummary; p: SitePageSummary;
showDivider: boolean; showDivider: boolean;
urlStyle: UrlStyle;
}) { }) {
const Icon = pageGlyph(p.slug); const Icon = pageGlyph(p.slug);
return ( return (
<Link <Link
href={`/p/${p.slug}`} href={pagePath(urlStyle, p.slug)}
className="block px-5 sm:px-6 py-4 sm:py-5 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]" className="block px-5 sm:px-6 py-4 sm:py-5 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
style={showDivider ? { borderTop: "1px solid var(--line)" } : undefined} style={showDivider ? { borderTop: "1px solid var(--line)" } : undefined}
> >
@@ -90,6 +93,7 @@ export default async function AboutIndexPage() {
]); ]);
const announcements = overview?.announcements ?? []; const announcements = overview?.announcements ?? [];
const siteLead = settings.site_description?.trim() || undefined; const siteLead = settings.site_description?.trim() || undefined;
const urlStyle = settings.url_style;
const canManagePages = !!me.user && isAdminOrAbove(me.user.role); const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
const left = <SiteDocIdentity kind="about" lead={siteLead} />; const left = <SiteDocIdentity kind="about" lead={siteLead} />;
@@ -146,7 +150,7 @@ export default async function AboutIndexPage() {
<p className="meta mt-1.5">有新文档时会显示在这里</p> <p className="meta mt-1.5">有新文档时会显示在这里</p>
</div> </div>
) : ( ) : (
pages.map((p, i) => <PageRow key={p.id} p={p} showDivider={i > 0} />) pages.map((p, i) => <PageRow key={p.id} p={p} showDivider={i > 0} urlStyle={urlStyle} />)
)} )}
</div> </div>
</SiteDocShell> </SiteDocShell>

View File

@@ -10,7 +10,9 @@ import {
} from "@/lib/api"; } from "@/lib/api";
import { postTypeLabel } from "@/lib/postMeta"; import { postTypeLabel } from "@/lib/postMeta";
import { formatRelative } from "@/lib/format"; import { formatRelative } from "@/lib/format";
import { postPath, userPath } from "@/lib/urlStyle";
import Avatar from "@/components/Avatar"; import Avatar from "@/components/Avatar";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { AdminSegmented } from "@/components/admin"; import { AdminSegmented } from "@/components/admin";
import { import {
AdminChartCard, AdminChartCard,
@@ -67,6 +69,7 @@ export default function DashboardClient({
totalUsers?: number; totalUsers?: number;
canUsers: boolean; canUsers: boolean;
}) { }) {
const urlStyle = useUrlStyle();
const [range, setRange] = useState<RangeKey>("7d"); const [range, setRange] = useState<RangeKey>("7d");
const [data, setData] = useState(initialAnalytics); const [data, setData] = useState(initialAnalytics);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -162,7 +165,7 @@ export default function DashboardClient({
newUsers.slice(0, 8).map((u) => ( newUsers.slice(0, 8).map((u) => (
<Link <Link
key={u.id} key={u.id}
href={`/u/${u.id}`} href={userPath(urlStyle, u.id)}
className="flex items-center gap-3 px-4 sm:px-5 py-3 transition-colors hover:bg-[var(--panel-2)]" className="flex items-center gap-3 px-4 sm:px-5 py-3 transition-colors hover:bg-[var(--panel-2)]"
style={{ borderTop: "1px solid var(--line)" }} style={{ borderTop: "1px solid var(--line)" }}
> >
@@ -213,7 +216,7 @@ export default function DashboardClient({
data.top_viewed.map((p, i) => ( data.top_viewed.map((p, i) => (
<Link <Link
key={p.id} key={p.id}
href={`/post/${p.id}`} href={postPath(urlStyle, p.id)}
className="flex items-center gap-3 px-4 sm:px-5 py-3 transition-colors hover:bg-[var(--panel-2)]" className="flex items-center gap-3 px-4 sm:px-5 py-3 transition-colors hover:bg-[var(--panel-2)]"
style={{ borderTop: "1px solid var(--line)" }} style={{ borderTop: "1px solid var(--line)" }}
> >

View File

@@ -13,6 +13,7 @@ import {
import { formatDateTime, formatRelative } from "@/lib/format"; import { formatDateTime, formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog"; import ConfirmDialog from "@/components/ConfirmDialog";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { import {
AdminEmpty, AdminEmpty,
AdminPageHeader, AdminPageHeader,
@@ -62,6 +63,7 @@ export default function AnnouncementAdmin({
}) { }) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const urlStyle = useUrlStyle();
const statusFilter = readStatus(searchParams.get("status")); const statusFilter = readStatus(searchParams.get("status"));
const [list, setList] = useState(initial); const [list, setList] = useState(initial);
const [loadError, setLoadError] = useState(initError); const [loadError, setLoadError] = useState(initError);
@@ -215,7 +217,7 @@ export default function AnnouncementAdmin({
<span className={`j13-anno-tag is-${a.tag_color}`} title={a.tag}> <span className={`j13-anno-tag is-${a.tag_color}`} title={a.tag}>
{a.tag} {a.tag}
</span> </span>
{a.published ? <code>{announcementPublicPath(a.id)}</code> : null} {a.published ? <code>{announcementPublicPath(a.id, urlStyle)}</code> : null}
</p> </p>
{summary ? <p className="admin-doc-excerpt" title={summary}>{summary}</p> : null} {summary ? <p className="admin-doc-excerpt" title={summary}>{summary}</p> : null}
</td> </td>
@@ -241,7 +243,7 @@ export default function AnnouncementAdmin({
</Link> </Link>
{a.published ? ( {a.published ? (
<a <a
href={announcementPublicPath(a.id)} href={announcementPublicPath(a.id, urlStyle)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="admin-doc-textbtn" className="admin-doc-textbtn"

View File

@@ -15,6 +15,7 @@ import { toast } from "@/lib/toast";
import MarkdownEditor from "@/components/MarkdownEditor"; import MarkdownEditor from "@/components/MarkdownEditor";
import { AdminField, AdminSegmented, AdminStatusChip, AdminSwitch } from "@/components/admin"; import { AdminField, AdminSegmented, AdminStatusChip, AdminSwitch } from "@/components/admin";
import ConfirmDialog from "@/components/ConfirmDialog"; import ConfirmDialog from "@/components/ConfirmDialog";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import useUnsavedGuard from "@/hooks/useUnsavedGuard"; import useUnsavedGuard from "@/hooks/useUnsavedGuard";
import { ANNOUNCEMENT_COLORS, announcementPublicPath } from "./announcementColors"; import { ANNOUNCEMENT_COLORS, announcementPublicPath } from "./announcementColors";
@@ -117,6 +118,7 @@ export default function AnnouncementComposeClient({
}) { }) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const urlStyle = useUrlStyle();
const status = readStatus(searchParams.get("status")); const status = readStatus(searchParams.get("status"));
const backHref = listHref(status); const backHref = listHref(status);
const loaded = useMemo(() => toForm(initial), [initial]); const loaded = useMemo(() => toForm(initial), [initial]);
@@ -146,7 +148,7 @@ export default function AnnouncementComposeClient({
}; };
const canViewOnline = saved?.published === true && saved.id > 0; const canViewOnline = saved?.published === true && saved.id > 0;
const publicPath = saved ? announcementPublicPath(saved.id) : ""; const publicPath = saved ? announcementPublicPath(saved.id, urlStyle) : "";
async function commit() { async function commit() {
if (saving) return; if (saving) return;
@@ -227,7 +229,7 @@ export default function AnnouncementComposeClient({
async function copyAddress() { async function copyAddress() {
if (!saved) return; 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 { try {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
toast("已复制访问地址", "ok"); toast("已复制访问地址", "ok");

View File

@@ -1,4 +1,5 @@
import type { AnnouncementColor } from "@/lib/api"; import type { AnnouncementColor } from "@/lib/api";
import { announcementPath, type UrlStyle } from "@/lib/urlStyle";
/** 标签色板:与后端 AnnouncementColor 白名单一致(12 色) */ /** 标签色板:与后端 AnnouncementColor 白名单一致(12 色) */
export const ANNOUNCEMENT_COLORS: { value: AnnouncementColor; label: string }[] = [ export const ANNOUNCEMENT_COLORS: { value: AnnouncementColor; label: string }[] = [
@@ -17,6 +18,6 @@ export const ANNOUNCEMENT_COLORS: { value: AnnouncementColor; label: string }[]
]; ];
/** 公告公开路径。前台是 /announcement/[id],列表和线上查看用同一条。 */ /** 公告公开路径。前台是 /announcement/[id],列表和线上查看用同一条。 */
export function announcementPublicPath(id: number): string { export function announcementPublicPath(id: number, urlStyle: UrlStyle = "default"): string {
return `/announcement/${id}`; return announcementPath(urlStyle, id);
} }

View File

@@ -42,9 +42,11 @@ import {
} from "@/lib/api"; } from "@/lib/api";
import { canModerateAuthor } from "@/lib/roles"; import { canModerateAuthor } from "@/lib/roles";
import { formatRelative } from "@/lib/format"; import { formatRelative } from "@/lib/format";
import { postPath } from "@/lib/urlStyle";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime"; import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
import Avatar from "@/components/Avatar"; import Avatar from "@/components/Avatar";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import AutolinkText from "@/components/AutolinkText"; import AutolinkText from "@/components/AutolinkText";
import ConfirmDialog from "@/components/ConfirmDialog"; import ConfirmDialog from "@/components/ConfirmDialog";
import StaffDeletePostModal from "@/components/StaffDeletePostModal"; import StaffDeletePostModal from "@/components/StaffDeletePostModal";
@@ -278,6 +280,7 @@ export default function ContentAdmin({
canPurge: boolean; canPurge: boolean;
viewerRole: string; viewerRole: string;
}) { }) {
const urlStyle = useUrlStyle();
// 窄屏分段:lg 以下只显示一栏;PC 双栏同时可见 // 窄屏分段:lg 以下只显示一栏;PC 双栏同时可见
const [mobileKind, setMobileKind] = useState<ContentKind>(initialKind); const [mobileKind, setMobileKind] = useState<ContentKind>(initialKind);
const [counts, setCounts] = useState(initialCounts); const [counts, setCounts] = useState(initialCounts);
@@ -549,7 +552,7 @@ export default function ContentAdmin({
</p> </p>
) : ( ) : (
<Link <Link
href={`/post/${p.id}`} href={postPath(urlStyle, p.id)}
className="mt-1 block text-[13px] font-medium leading-snug hover:underline line-clamp-2" className="mt-1 block text-[13px] font-medium leading-snug hover:underline line-clamp-2"
style={{ color: "var(--ink)" }} style={{ color: "var(--ink)" }}
data-tip={p.title} data-tip={p.title}
@@ -689,7 +692,7 @@ export default function ContentAdmin({
c.post_title c.post_title
) : ( ) : (
<Link <Link
href={`/post/${c.post_id}#comment-${c.id}`} href={`${postPath(urlStyle, c.post_id)}#comment-${c.id}`}
className="hover:underline" className="hover:underline"
style={{ color: "var(--accent)" }} style={{ color: "var(--accent)" }}
> >

View File

@@ -11,6 +11,7 @@ import {
type SitePageInput, type SitePageInput,
} from "@/lib/api"; } from "@/lib/api";
import { sitePagePublicPath, sitePagePublicURL } from "@/lib/sitePagePath"; import { sitePagePublicPath, sitePagePublicURL } from "@/lib/sitePagePath";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import MarkdownEditor from "@/components/MarkdownEditor"; import MarkdownEditor from "@/components/MarkdownEditor";
import { AdminField, AdminSegmented, AdminStatusChip, AdminSwitch } from "@/components/admin"; import { AdminField, AdminSegmented, AdminStatusChip, AdminSwitch } from "@/components/admin";
@@ -129,6 +130,7 @@ export default function PageComposeClient({
}) { }) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const urlStyle = useUrlStyle();
const status = readStatus(searchParams.get("status")); const status = readStatus(searchParams.get("status"));
const backHref = listHref(status); const backHref = listHref(status);
const loaded = useMemo(() => toForm(initial), [initial]); const loaded = useMemo(() => toForm(initial), [initial]);
@@ -160,7 +162,7 @@ export default function PageComposeClient({
}; };
const slugPreview = normalizeSlug(form.slug); const slugPreview = normalizeSlug(form.slug);
const publicPath = sitePagePublicPath(slugPreview); const publicPath = sitePagePublicPath(slugPreview, urlStyle);
const canViewOnline = savedPage?.published === true; const canViewOnline = savedPage?.published === true;
async function commit() { async function commit() {
@@ -252,7 +254,7 @@ export default function PageComposeClient({
setErrors((prev) => ({ ...prev, slug: nextErrors.slug })); setErrors((prev) => ({ ...prev, slug: nextErrors.slug }));
return; return;
} }
const url = sitePagePublicURL(origin || window.location.origin, slug); const url = sitePagePublicURL(origin || window.location.origin, slug, urlStyle);
try { try {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
toast("已复制访问地址", "ok"); toast("已复制访问地址", "ok");
@@ -280,7 +282,7 @@ export default function PageComposeClient({
} }
if (pendingSave?.slugChange) { if (pendingSave?.slugChange) {
confirmBits.push( 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({
<div className="admin-doc-sticky-actions"> <div className="admin-doc-sticky-actions">
{canViewOnline && savedPage ? ( {canViewOnline && savedPage ? (
<a <a
href={sitePagePublicPath(savedPage.slug)} href={sitePagePublicPath(savedPage.slug, urlStyle)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="btn btn-line btn-sm" className="btn btn-line btn-sm"
@@ -413,7 +415,7 @@ export default function PageComposeClient({
</div> </div>
<p className="admin-doc-hint" id="page-slug-hint"> <p className="admin-doc-hint" id="page-slug-hint">
完整路径 {publicPath || "/p/"}。小写字母、数字和连字符,最多 64 字符。 完整路径 {publicPath || "/p/"}。小写字母、数字和连字符,最多 64 字符。
{origin && slugPreview ? ` 线上地址 ${sitePagePublicURL(origin, slugPreview)}` : ""} {origin && slugPreview ? ` 线上地址 ${sitePagePublicURL(origin, slugPreview, urlStyle)}` : ""}
</p> </p>
{isEdit && baseline.published && slugPreview !== baseline.slug ? ( {isEdit && baseline.published && slugPreview !== baseline.slug ? (
<p className="admin-doc-hint">保存后访问地址会改变。旧链接不会自动跳转。</p> <p className="admin-doc-hint">保存后访问地址会改变。旧链接不会自动跳转。</p>

View File

@@ -7,6 +7,7 @@ import { Ellipsis, ExternalLink, FileText, Plus } from "lucide-react";
import { apiAdminDeleteSitePage, type SitePage } from "@/lib/api"; import { apiAdminDeleteSitePage, type SitePage } from "@/lib/api";
import { formatDateTime, formatRelative } from "@/lib/format"; import { formatDateTime, formatRelative } from "@/lib/format";
import { sitePagePublicPath } from "@/lib/sitePagePath"; import { sitePagePublicPath } from "@/lib/sitePagePath";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog"; import ConfirmDialog from "@/components/ConfirmDialog";
import { import {
@@ -50,6 +51,7 @@ export default function PagesAdmin({
}) { }) {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const urlStyle = useUrlStyle();
const statusFilter = readStatus(searchParams.get("status")); const statusFilter = readStatus(searchParams.get("status"));
const [list, setList] = useState(initial); const [list, setList] = useState(initial);
const [loadError] = useState(initError); const [loadError] = useState(initError);
@@ -166,7 +168,7 @@ export default function PagesAdmin({
<tbody> <tbody>
{filtered.map((p) => { {filtered.map((p) => {
const updated = p.updated_at || ""; const updated = p.updated_at || "";
const path = sitePagePublicPath(p.slug); const path = sitePagePublicPath(p.slug, urlStyle);
return ( return (
<tr key={p.id}> <tr key={p.id}>
<td data-label="页面"> <td data-label="页面">

View File

@@ -9,6 +9,8 @@ import { pointReasonLabel } from "@/lib/points";
import { formatRelative } from "@/lib/format"; import { formatRelative } from "@/lib/format";
import { AdminPageHeader, AdminSegmented, AdminStatCard } from "@/components/admin"; import { AdminPageHeader, AdminSegmented, AdminStatCard } from "@/components/admin";
import Avatar from "@/components/Avatar"; import Avatar from "@/components/Avatar";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { userPath } from "@/lib/urlStyle";
import { import {
AdminChartCard, AdminChartCard,
DeltaBarChart, DeltaBarChart,
@@ -326,10 +328,11 @@ function StockOverviewCard({ stats }: { stats: EconomyStats }) {
} }
function LedgerUserLink({ user, compact }: { user: LedgerParty; compact?: boolean }) { function LedgerUserLink({ user, compact }: { user: LedgerParty; compact?: boolean }) {
const urlStyle = useUrlStyle();
const name = partyName(user); const name = partyName(user);
return ( return (
<Link <Link
href={`/u/${user.id}`} href={userPath(urlStyle, user.id)}
className="flex items-center gap-1.5 min-w-0 max-w-[128px] rounded-md hover:bg-[var(--panel-2)] -mx-0.5 px-0.5 py-0.5 transition-colors" className="flex items-center gap-1.5 min-w-0 max-w-[128px] rounded-md hover:bg-[var(--panel-2)] -mx-0.5 px-0.5 py-0.5 transition-colors"
title={`${name} @${user.username}`} title={`${name} @${user.username}`}
> >

View File

@@ -17,6 +17,8 @@ import { AdminPageHeader, AdminPager, AdminSearchField, AdminStepper } from "@/c
import { pointReasonLabel } from "@/lib/points"; import { pointReasonLabel } from "@/lib/points";
import { formatRelative } from "@/lib/format"; import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { userPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
const MAX_DELTA = 100000; // 与后端 adminAdjustMaxDelta 一致 const MAX_DELTA = 100000; // 与后端 adminAdjustMaxDelta 一致
@@ -72,6 +74,7 @@ function SortHeader({
* 扣分仅扣余额不降级,理由必填)与「流水」弹窗(分页查看该用户积分流水)。 * 扣分仅扣余额不降级,理由必填)与「流水」弹窗(分页查看该用户积分流水)。
*/ */
export default function UserPointsTab() { export default function UserPointsTab() {
const urlStyle = useUrlStyle();
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [appliedQ, setAppliedQ] = useState(""); const [appliedQ, setAppliedQ] = useState("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -209,7 +212,7 @@ export default function UserPointsTab() {
{users.map((u) => ( {users.map((u) => (
<tr key={u.id} style={{ borderBottom: "1px solid var(--line)" }}> <tr key={u.id} style={{ borderBottom: "1px solid var(--line)" }}>
<td className="px-4 py-2.5"> <td className="px-4 py-2.5">
<Link href={`/u/${u.id}`} className="flex items-center gap-2.5 min-w-0 group"> <Link href={userPath(urlStyle, u.id)} className="flex items-center gap-2.5 min-w-0 group">
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={30} /> <Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={30} />
<span className="min-w-0"> <span className="min-w-0">
<span <span

View File

@@ -6,6 +6,7 @@ import useUnsavedGuard from "@/hooks/useUnsavedGuard";
import BasicIdentityPanel from "./BasicIdentityPanel"; import BasicIdentityPanel from "./BasicIdentityPanel";
import BrandSeoPanel from "./BrandSeoPanel"; import BrandSeoPanel from "./BrandSeoPanel";
import FooterLinksPanel from "./FooterLinksPanel"; import FooterLinksPanel from "./FooterLinksPanel";
import UrlStylePanel from "./UrlStylePanel";
import type { PublicSettings } from "@/lib/api"; import type { PublicSettings } from "@/lib/api";
export default function BasicSettings({ initial }: { initial: PublicSettings }) { export default function BasicSettings({ initial }: { initial: PublicSettings }) {
@@ -13,17 +14,20 @@ export default function BasicSettings({ initial }: { initial: PublicSettings })
const [basicDirty, setBasicDirty] = useState(false); const [basicDirty, setBasicDirty] = useState(false);
const [brandDirty, setBrandDirty] = useState(false); const [brandDirty, setBrandDirty] = useState(false);
const [seoDirty, setSeoDirty] = useState(false); const [seoDirty, setSeoDirty] = useState(false);
const dirty = basicDirty || brandDirty || seoDirty; const [urlStyleDirty, setUrlStyleDirty] = useState(false);
const dirty = basicDirty || brandDirty || seoDirty || urlStyleDirty;
const leave = useUnsavedGuard(dirty); const leave = useUnsavedGuard(dirty);
const onBasicDirty = useCallback((d: boolean) => setBasicDirty(d), []); const onBasicDirty = useCallback((d: boolean) => setBasicDirty(d), []);
const onBrandDirty = useCallback((d: boolean) => setBrandDirty(d), []); const onBrandDirty = useCallback((d: boolean) => setBrandDirty(d), []);
const onSeoDirty = useCallback((d: boolean) => setSeoDirty(d), []); const onSeoDirty = useCallback((d: boolean) => setSeoDirty(d), []);
const onUrlStyleDirty = useCallback((d: boolean) => setUrlStyleDirty(d), []);
return ( return (
<div className="admin-settings-stack"> <div className="admin-settings-stack">
<BasicIdentityPanel initial={live} onSaved={setLive} onDirtyChange={onBasicDirty} /> <BasicIdentityPanel initial={live} onSaved={setLive} onDirtyChange={onBasicDirty} />
<BrandSeoPanel initial={live} onSaved={setLive} onDirtyChange={onBrandDirty} /> <BrandSeoPanel initial={live} onSaved={setLive} onDirtyChange={onBrandDirty} />
<UrlStylePanel initial={live} onSaved={setLive} onDirtyChange={onUrlStyleDirty} />
<FooterLinksPanel initial={live} onSaved={setLive} onDirtyChange={onSeoDirty} /> <FooterLinksPanel initial={live} onSaved={setLive} onDirtyChange={onSeoDirty} />
<ConfirmDialog <ConfirmDialog
open={leave.leaveOpen} open={leave.leaveOpen}

View File

@@ -0,0 +1,123 @@
"use client";
import { useEffect, useState } from "react";
import { AdminSettingsActions, AdminSettingsGroup } from "@/components/admin";
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
import { announcementPath, leaderboardPath, pagePath, postPath, userPath, type UrlStyle } from "@/lib/urlStyle";
import { toast } from "@/lib/toast";
/** 三档风格展示信息:示例 id/slug 固定,方便站长对比形态 */
const STYLE_OPTIONS: { value: UrlStyle; label: string; desc: string }[] = [
{ value: "default", label: "标准动态", desc: "框架默认路由,带路径分段" },
{ value: "html", label: "静态页 .html", desc: "常见 CMS 伪静态形态" },
{ value: "discuz", label: "Discuz 风格", desc: "经典论坛 thread- 形态" },
];
function samplePaths(style: UrlStyle) {
return [
{ kind: "帖子", path: postPath(style, 123) },
{ kind: "用户", path: userPath(style, 5) },
{ kind: "公告", path: announcementPath(style, 1) },
{ kind: "自建页", path: pagePath(style, "about") },
{ kind: "排行榜", path: leaderboardPath(style) },
];
}
export default function UrlStylePanel({
initial,
onSaved,
onDirtyChange,
}: {
initial: PublicSettings;
onSaved?: (next: PublicSettings) => void;
onDirtyChange?: (dirty: boolean) => void;
}) {
const [style, setStyle] = useState<UrlStyle>(initial.url_style);
const [savedStyle, setSavedStyle] = useState<UrlStyle>(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 (
<AdminSettingsGroup
id="settings-url-style"
title="链接风格(伪静态)"
description="帖子、用户主页、公告等详情页的 URL 形态。"
footer={
<AdminSettingsActions
dirty={dirty}
saving={saving}
saveLabel="保存"
error={error}
onSave={() => void save()}
onDiscard={discard}
/>
}
>
<div className="grid gap-3 sm:grid-cols-3" role="radiogroup" aria-label="链接风格">
{STYLE_OPTIONS.map((opt) => {
const on = style === opt.value;
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={on}
className={`admin-url-style-card${on ? " is-on" : ""}`}
onClick={() => setStyle(opt.value)}
>
<span className="flex items-center justify-between gap-2">
<span className="text-[13.5px] font-semibold">{opt.label}</span>
<span className={`admin-url-style-dot${on ? " is-on" : ""}`} aria-hidden />
</span>
<span className="meta mt-0.5 block text-[11.5px]">{opt.desc}</span>
<span className="admin-url-style-examples">
{samplePaths(opt.value).map((s) => (
<span key={s.kind} className="admin-url-style-example">
<span className="meta">{s.kind}</span>
<code>{s.path}</code>
</span>
))}
</span>
</button>
);
})}
</div>
<ul className="admin-url-style-notes meta mt-3 text-[12px]">
<li>保存后全站前台链接立即按新风格生成;旧风格链接自动 301 到新风格,收录与外链不断链。</li>
<li>301 会被浏览器长期缓存,切换前请想清楚;建议长期固定一种风格,不要频繁来回切换。</li>
<li>自建页与排行榜同样参与伪静态;自建页 slug 含中文时自动 URL 编码,地址栏多数浏览器仍显示中文。</li>
</ul>
</AdminSettingsGroup>
);
}

View File

@@ -36,6 +36,8 @@ import {
import { isOwner, ROLES } from "@/lib/roles"; import { isOwner, ROLES } from "@/lib/roles";
import { formatDate, formatRelative } from "@/lib/format"; import { formatDate, formatRelative } from "@/lib/format";
import { realtime } from "@/lib/realtime"; import { realtime } from "@/lib/realtime";
import { userPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import Avatar from "@/components/Avatar"; import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge"; import RoleBadge from "@/components/RoleBadge";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
@@ -302,6 +304,7 @@ export default function UsersAdmin({
currentUserId: number; currentUserId: number;
currentUserRole: string; currentUserRole: string;
}) { }) {
const urlStyle = useUrlStyle();
const [users, setUsers] = useState<AdminUser[]>(initial?.users ?? []); const [users, setUsers] = useState<AdminUser[]>(initial?.users ?? []);
const [summary, setSummary] = useState<AdminUserSummary>( const [summary, setSummary] = useState<AdminUserSummary>(
initial?.summary ?? { initial?.summary ?? {
@@ -686,7 +689,7 @@ export default function UsersAdmin({
return ( return (
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
{auditBtn} {auditBtn}
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm gap-1" aria-label={`查看 ${u.nickname} 的个人主页`}> <Link href={userPath(urlStyle, u.id)} className="btn btn-line btn-sm gap-1" aria-label={`查看 ${u.nickname} 的个人主页`}>
<ExternalLink size={13} /> <ExternalLink size={13} />
主页 主页
</Link> </Link>
@@ -697,7 +700,7 @@ export default function UsersAdmin({
return ( return (
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
{auditBtn} {auditBtn}
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm gap-1" aria-label={`查看 ${u.nickname} 的个人主页`}> <Link href={userPath(urlStyle, u.id)} className="btn btn-line btn-sm gap-1" aria-label={`查看 ${u.nickname} 的个人主页`}>
<ExternalLink size={13} /> <ExternalLink size={13} />
主页 主页
</Link> </Link>
@@ -718,7 +721,7 @@ export default function UsersAdmin({
return ( return (
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
{auditBtn} {auditBtn}
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm gap-1" aria-label={`查看 ${u.nickname} 的个人主页`}> <Link href={userPath(urlStyle, u.id)} className="btn btn-line btn-sm gap-1" aria-label={`查看 ${u.nickname} 的个人主页`}>
<ExternalLink size={13} /> <ExternalLink size={13} />
主页 主页
</Link> </Link>

View File

@@ -14,7 +14,9 @@ import {
} from "@/lib/api"; } from "@/lib/api";
import { formatDate, formatRelative } from "@/lib/format"; import { formatDate, formatRelative } from "@/lib/format";
import Avatar from "@/components/Avatar"; import Avatar from "@/components/Avatar";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import RoleBadge from "@/components/RoleBadge"; import RoleBadge from "@/components/RoleBadge";
import { postPath, userPath } from "@/lib/urlStyle";
import { import {
AdminEmpty, AdminEmpty,
AdminPager, AdminPager,
@@ -72,6 +74,7 @@ export default function UserAuditClient({
initialPostsTotal: number; initialPostsTotal: number;
initError: string; initError: string;
}) { }) {
const urlStyle = useUrlStyle();
const [profile] = useState(initial); const [profile] = useState(initial);
const [tab, setTab] = useState<TabKey>("posts"); const [tab, setTab] = useState<TabKey>("posts");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -231,7 +234,7 @@ export default function UserAuditClient({
</p> </p>
</div> </div>
<Link <Link
href={`/u/${u.id}`} href={userPath(urlStyle, u.id)}
className="btn btn-line btn-sm gap-1.5 shrink-0" className="btn btn-line btn-sm gap-1.5 shrink-0"
target="_blank" target="_blank"
> >
@@ -318,7 +321,7 @@ export default function UserAuditClient({
</p> </p>
) : ( ) : (
<Link <Link
href={`/post/${p.id}`} href={postPath(urlStyle, p.id)}
className="text-[14px] font-medium hover:underline" className="text-[14px] font-medium hover:underline"
style={{ color: "var(--ink)" }} style={{ color: "var(--ink)" }}
> >
@@ -353,7 +356,7 @@ export default function UserAuditClient({
<span className="text-[11.5px] meta">帖子 #{c.post_id}</span> <span className="text-[11.5px] meta">帖子 #{c.post_id}</span>
) : ( ) : (
<Link <Link
href={`/post/${c.post_id}#comment-${c.id}`} href={`${postPath(urlStyle, c.post_id)}#comment-${c.id}`}
className="text-[11.5px] hover:underline" className="text-[11.5px] hover:underline"
style={{ color: "var(--accent)" }} style={{ color: "var(--accent)" }}
> >

View File

@@ -17,6 +17,7 @@ import {
SiteDocMasthead, SiteDocMasthead,
} from "@/components/site-doc"; } from "@/components/site-doc";
import { requestCanonical } from "@/lib/canonicalMeta"; import { requestCanonical } from "@/lib/canonicalMeta";
import { announcementPath } from "@/lib/urlStyle";
import { webPageJsonLd } from "@/lib/jsonLd"; import { webPageJsonLd } from "@/lib/jsonLd";
import JsonLd from "@/components/JsonLd"; import JsonLd from "@/components/JsonLd";
@@ -28,6 +29,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
const { id } = await params; const { id } = await params;
const data = await fetchAnnouncementDetail(id).catch(() => null); const data = await fetchAnnouncementDetail(id).catch(() => null);
if (!data) return { title: "公告不存在" }; if (!data) return { title: "公告不存在" };
const urlStyle = (await getPublicSettingsCached()).url_style;
return { return {
title: data.announcement.title, title: data.announcement.title,
description: `站点公告 · ${data.announcement.tag}`, description: `站点公告 · ${data.announcement.tag}`,
@@ -37,7 +39,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
type: "article", type: "article",
publishedTime: data.announcement.created_at, 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 announcements = overview?.announcements ?? [];
const canManagePages = !!me.user && isAdminOrAbove(me.user.role); const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
const siteLead = settings.site_description?.trim() || undefined; const siteLead = settings.site_description?.trim() || undefined;
const urlStyle = settings.url_style;
const toc = extractToc(a.content); const toc = extractToc(a.content);
const headingToc = markdownHeadingToc(toc); const headingToc = markdownHeadingToc(toc);
const readingMinutes = estimateReadingMinutes(a.content, a.title.length); const readingMinutes = estimateReadingMinutes(a.content, a.title.length);
@@ -77,7 +80,7 @@ export default async function AnnouncementPage({ params }: PageProps) {
const jsonLd = webPageJsonLd({ const jsonLd = webPageJsonLd({
type: "Article", type: "Article",
name: a.title, name: a.title,
path: `/announcement/${a.id}`, path: announcementPath(urlStyle, a.id),
datePublished: a.created_at, datePublished: a.created_at,
dateModified: a.updated_at, dateModified: a.updated_at,
articleSection: "站点公告", articleSection: "站点公告",

View File

@@ -15,6 +15,7 @@ import {
} from "@/components/site-doc"; } from "@/components/site-doc";
import { requestCanonical } from "@/lib/canonicalMeta"; import { requestCanonical } from "@/lib/canonicalMeta";
import { canonicalPage } from "@/lib/siteUrl"; import { canonicalPage } from "@/lib/siteUrl";
import { announcementPath, type UrlStyle } from "@/lib/urlStyle";
interface PageProps { interface PageProps {
searchParams: Promise<{ page?: string }>; searchParams: Promise<{ page?: string }>;
@@ -42,11 +43,11 @@ function plainSummary(md: string, max = 120): string {
return text.length > max ? `${text.slice(0, max)}…` : text; 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); const summary = plainSummary(a.content, 180);
return ( return (
<Link <Link
href={`/announcement/${a.id}`} href={announcementPath(urlStyle, a.id)}
className="block px-5 sm:px-6 pt-5 sm:pt-6 pb-5 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]" className="block px-5 sm:px-6 pt-5 sm:pt-6 pb-5 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
> >
<div className="flex items-center gap-2 flex-wrap mb-3"> <div className="flex items-center gap-2 flex-wrap mb-3">
@@ -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); const summary = plainSummary(a.content);
return ( return (
<Link <Link
href={`/announcement/${a.id}`} href={announcementPath(urlStyle, a.id)}
className="block px-5 sm:px-6 py-4 sm:py-5 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]" className="block px-5 sm:px-6 py-4 sm:py-5 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
style={showDivider ? { borderTop: "1px solid var(--line)" } : undefined} style={showDivider ? { borderTop: "1px solid var(--line)" } : undefined}
> >
@@ -131,6 +132,7 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
]); ]);
const sidebarPages = overview?.sidebar_pages ?? []; const sidebarPages = overview?.sidebar_pages ?? [];
const siteLead = settings.site_description?.trim() || undefined; const siteLead = settings.site_description?.trim() || undefined;
const urlStyle = settings.url_style;
const canManagePages = !!me.user && isAdminOrAbove(me.user.role); const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
// 首页置顶条目作头图;其余走列表行(置顶已在头图时不再重复) // 首页置顶条目作头图;其余走列表行(置顶已在头图时不再重复)
@@ -178,9 +180,9 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
</div> </div>
) : ( ) : (
<> <>
{featured ? <FeaturedAnnouncement a={featured} /> : null} {featured ? <FeaturedAnnouncement a={featured} urlStyle={urlStyle} /> : null}
{rest.map((a, i) => ( {rest.map((a, i) => (
<AnnouncementRow key={a.id} a={a} showDivider={!!featured || i > 0} /> <AnnouncementRow key={a.id} a={a} showDivider={!!featured || i > 0} urlStyle={urlStyle} />
))} ))}
</> </>
)} )}

View File

@@ -56,6 +56,8 @@ import AutolinkText from "@/components/AutolinkText";
import ConfirmDialog from "@/components/ConfirmDialog"; import ConfirmDialog from "@/components/ConfirmDialog";
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu"; import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
import ChatUserCard, { type ChatUserCardTarget } from "@/components/ChatUserCard"; import ChatUserCard, { type ChatUserCardTarget } from "@/components/ChatUserCard";
import { userPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
/** 同一房间已读:进行中去重 + 2s 内合并(滚动/新消息/Strict Mode 连打) */ /** 同一房间已读:进行中去重 + 2s 内合并(滚动/新消息/Strict Mode 连打) */
const MARK_READ_GAP_MS = 2_000; const MARK_READ_GAP_MS = 2_000;
@@ -102,6 +104,7 @@ function RecallNotice({
meId: number; meId: number;
members: ChatRoomMember[]; members: ChatRoomMember[];
}) { }) {
const urlStyle = useUrlStyle();
if (!message.recalled_at) return null; if (!message.recalled_at) return null;
const by = const by =
message.recalled_by || message.recalled_by ||
@@ -137,7 +140,7 @@ function RecallNotice({
const nameNode = const nameNode =
uid > 0 ? ( uid > 0 ? (
<Link <Link
href={`/u/${uid}`} href={userPath(urlStyle, uid)}
className="transition-opacity hover:opacity-80" className="transition-opacity hover:opacity-80"
style={nameStyle} style={nameStyle}
> >
@@ -2020,6 +2023,7 @@ function MembersPanel({
onOpenCard: (m: ChatRoomMember) => void; onOpenCard: (m: ChatRoomMember) => void;
showTitle?: boolean; showTitle?: boolean;
}) { }) {
const urlStyle = useUrlStyle();
return ( return (
<div> <div>
{showTitle && ( {showTitle && (
@@ -2072,7 +2076,7 @@ function MembersPanel({
<Avatar name={name} src={m.user.avatar || undefined} size={28} /> <Avatar name={name} src={m.user.avatar || undefined} size={28} />
</button> </button>
<Link <Link
href={`/u/${m.user.id}`} href={userPath(urlStyle, m.user.id)}
className="flex-1 min-w-0 truncate text-[13px] hover:text-[var(--accent)] transition-colors" className="flex-1 min-w-0 truncate text-[13px] hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }} style={{ color: "var(--ink)" }}
> >

View File

@@ -4590,11 +4590,13 @@ a.j13-mention-token:hover {
} }
#settings-identity .admin-settings-actions, #settings-identity .admin-settings-actions,
#settings-brand .admin-settings-actions, #settings-brand .admin-settings-actions,
#settings-url-style .admin-settings-actions,
#settings-footer .admin-settings-actions { #settings-footer .admin-settings-actions {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
#settings-identity .admin-settings-actions-inner, #settings-identity .admin-settings-actions-inner,
#settings-brand .admin-settings-actions-inner, #settings-brand .admin-settings-actions-inner,
#settings-url-style .admin-settings-actions-inner,
#settings-footer .admin-settings-actions-inner { #settings-footer .admin-settings-actions-inner {
grid-column: 1; 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) { @container settings-page (max-width: 1039px) {
.admin-settings-work { .admin-settings-work {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);

View File

@@ -26,6 +26,7 @@ import {
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData"; import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { authCookieHeader } from "@/lib/cookies"; import { authCookieHeader } from "@/lib/cookies";
import { requestCanonical } from "@/lib/canonicalMeta"; import { requestCanonical } from "@/lib/canonicalMeta";
import { userPath, leaderboardPath, type UrlStyle } from "@/lib/urlStyle";
import Avatar from "@/components/Avatar"; import Avatar from "@/components/Avatar";
import LevelBadge from "@/components/LevelBadge"; import LevelBadge from "@/components/LevelBadge";
@@ -55,12 +56,13 @@ function normMetric(v?: string): LeaderboardMetric {
} }
/** 榜单链接:默认参数省略,保持 URL 干净 */ /** 榜单链接:默认参数省略,保持 URL 干净 */
function lbHref(period: LeaderboardPeriod, metric: LeaderboardMetric): string { function lbHref(urlStyle: UrlStyle, period: LeaderboardPeriod, metric: LeaderboardMetric): string {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (period !== "all") q.set("period", period); if (period !== "all") q.set("period", period);
if (metric !== "all") q.set("metric", metric); if (metric !== "all") q.set("metric", metric);
const s = q.toString(); 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<Met
return { return {
title: "排行榜", title: "排行榜",
description: settings.site_description, description: settings.site_description,
...(await requestCanonical("/leaderboard")), ...(await requestCanonical(leaderboardPath(settings.url_style))),
}; };
} }
@@ -116,17 +118,18 @@ function RankMedal({ place }: { place: 1 | 2 | 3 }) {
} }
/** 领奖台卡片:渐变金属环头像 + 名字/等级 + 分数大数字 */ /** 领奖台卡片:渐变金属环头像 + 名字/等级 + 分数大数字 */
function PodiumCard({ e, place, period, metric }: { function PodiumCard({ e, place, period, metric, urlStyle }: {
e: LeaderboardEntry; e: LeaderboardEntry;
place: 1 | 2 | 3; place: 1 | 2 | 3;
period: LeaderboardPeriod; period: LeaderboardPeriod;
metric: LeaderboardMetric; metric: LeaderboardMetric;
urlStyle: UrlStyle;
}) { }) {
const m = METAL[place]; const m = METAL[place];
const first = place === 1; const first = place === 1;
return ( return (
<Link <Link
href={`/u/${e.id}`} href={userPath(urlStyle, e.id)}
className={`group relative flex flex-col items-center text-center rounded-[22px] transition-transform hover:-translate-y-0.5 ${ className={`group relative flex flex-col items-center text-center rounded-[22px] transition-transform hover:-translate-y-0.5 ${
first ? "pt-7 pb-6 px-3 sm:px-5" : "pt-6 pb-5 px-2 sm:px-4" first ? "pt-7 pb-6 px-3 sm:px-5" : "pt-6 pb-5 px-2 sm:px-4"
}`} }`}
@@ -185,14 +188,15 @@ function PodiumCard({ e, place, period, metric }: {
} }
/** 4 名起的列表行 */ /** 4 名起的列表行 */
function RankRow({ e, period, metric }: { function RankRow({ e, period, metric, urlStyle }: {
e: LeaderboardEntry; e: LeaderboardEntry;
period: LeaderboardPeriod; period: LeaderboardPeriod;
metric: LeaderboardMetric; metric: LeaderboardMetric;
urlStyle: UrlStyle;
}) { }) {
return ( return (
<Link <Link
href={`/u/${e.id}`} href={userPath(urlStyle, e.id)}
className="group flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3.5 transition-colors hover:bg-[var(--panel-2)]" className="group flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3.5 transition-colors hover:bg-[var(--panel-2)]"
style={{ borderTop: "1px solid var(--line)" }} style={{ borderTop: "1px solid var(--line)" }}
> >
@@ -419,6 +423,7 @@ export default async function LeaderboardPage({ searchParams }: PageProps) {
const metric = normMetric(sp.metric); const metric = normMetric(sp.metric);
const cookie = authCookieHeader(await cookies()); const cookie = authCookieHeader(await cookies());
const settings = await getPublicSettingsCached(); const settings = await getPublicSettingsCached();
const urlStyle = settings.url_style;
const [meUser] = await Promise.all([getMeCached(cookie || undefined)]); const [meUser] = await Promise.all([getMeCached(cookie || undefined)]);
const authed = !!meUser; const authed = !!meUser;
@@ -506,7 +511,7 @@ export default async function LeaderboardPage({ searchParams }: PageProps) {
{PERIODS.map((p) => ( {PERIODS.map((p) => (
<Link <Link
key={p.key} key={p.key}
href={lbHref(p.key, metric)} href={lbHref(urlStyle, p.key, metric)}
className="px-5 sm:px-6 py-2 rounded-full text-[14px] font-semibold transition-colors" className="px-5 sm:px-6 py-2 rounded-full text-[14px] font-semibold transition-colors"
style={ style={
period === p.key period === p.key
@@ -529,7 +534,7 @@ export default async function LeaderboardPage({ searchParams }: PageProps) {
return ( return (
<Link <Link
key={m.key} key={m.key}
href={lbHref(period, m.key)} href={lbHref(urlStyle, period, m.key)}
className="px-3 py-1.5 rounded-full text-[12.5px] font-medium transition-colors" className="px-3 py-1.5 rounded-full text-[12.5px] font-medium transition-colors"
style={ style={
active active
@@ -571,15 +576,15 @@ export default async function LeaderboardPage({ searchParams }: PageProps) {
<> <>
{/* 领奖台:DOM 顺序 2-1-3,冠军居中放大 */} {/* 领奖台:DOM 顺序 2-1-3,冠军居中放大 */}
<div className="mt-7 grid grid-cols-3 gap-2.5 sm:gap-4 items-start"> <div className="mt-7 grid grid-cols-3 gap-2.5 sm:gap-4 items-start">
{podium[1] && <PodiumCard e={podium[1]} place={2} period={period} metric={metric} />} {podium[1] && <PodiumCard e={podium[1]} place={2} period={period} metric={metric} urlStyle={urlStyle} />}
{podium[0] && <PodiumCard e={podium[0]} place={1} period={period} metric={metric} />} {podium[0] && <PodiumCard e={podium[0]} place={1} period={period} metric={metric} urlStyle={urlStyle} />}
{podium[2] && <PodiumCard e={podium[2]} place={3} period={period} metric={metric} />} {podium[2] && <PodiumCard e={podium[2]} place={3} period={period} metric={metric} urlStyle={urlStyle} />}
</div> </div>
{rest.length > 0 && ( {rest.length > 0 && (
<div className="panel overflow-hidden mt-4"> <div className="panel overflow-hidden mt-4">
{rest.map((e) => ( {rest.map((e) => (
<RankRow key={e.id} e={e} period={period} metric={metric} /> <RankRow key={e.id} e={e} period={period} metric={metric} urlStyle={urlStyle} />
))} ))}
</div> </div>
)} )}

View File

@@ -5,12 +5,15 @@ import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { Heart, MessageCircle, CheckCheck, Bell, CheckCircle2, XCircle, AtSign, ClipboardList, Award, AlertTriangle, UserPlus } from "lucide-react"; 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 { apiMarkRead, apiMarkAllRead, type NotificationsResponse, type NotificationItem } from "@/lib/api";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { postPath, userPath } from "@/lib/urlStyle";
/** /**
* 通知列表交互层:数据由服务端 SSR 直出,这里只承担已读标记等客户端操作。 * 通知列表交互层:数据由服务端 SSR 直出,这里只承担已读标记等客户端操作。
*/ */
export default function NotificationsClient({ initialData }: { initialData: NotificationsResponse }) { export default function NotificationsClient({ initialData }: { initialData: NotificationsResponse }) {
const router = useRouter(); const router = useRouter();
const urlStyle = useUrlStyle();
const [notifications, setNotifications] = useState<NotificationItem[]>(initialData.notifications || []); const [notifications, setNotifications] = useState<NotificationItem[]>(initialData.notifications || []);
const handleClick = async (n: NotificationItem) => { const handleClick = async (n: NotificationItem) => {
@@ -24,16 +27,16 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
if (n.type === "mention" && n.room_id) { if (n.type === "mention" && n.room_id) {
router.push(`/chat/${n.room_id}`); router.push(`/chat/${n.room_id}`);
} else if (n.type === "pending_review") { } 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") { } else if (n.type === "badge") {
return; return;
} else if (n.type === "follow") { } else if (n.type === "follow") {
router.push(`/u/${n.actor_id}`); router.push(userPath(urlStyle, n.actor_id));
} else if (n.comment_id > 0) { } 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 { } 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
) : ( ) : (
<> <>
<Link <Link
href={`/u/${n.actor.id}`} href={userPath(urlStyle, n.actor.id)}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="font-semibold hover:text-[var(--accent)] transition-colors" className="font-semibold hover:text-[var(--accent)] transition-colors"
> >

View File

@@ -17,6 +17,7 @@ import {
SiteDocMasthead, SiteDocMasthead,
} from "@/components/site-doc"; } from "@/components/site-doc";
import { requestCanonical } from "@/lib/canonicalMeta"; import { requestCanonical } from "@/lib/canonicalMeta";
import { pagePath } from "@/lib/urlStyle";
import { webPageJsonLd } from "@/lib/jsonLd"; import { webPageJsonLd } from "@/lib/jsonLd";
import JsonLd from "@/components/JsonLd"; import JsonLd from "@/components/JsonLd";
@@ -29,6 +30,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
const data = await fetchSitePageBySlug(slug).catch(() => null); const data = await fetchSitePageBySlug(slug).catch(() => null);
if (!data) return { title: "页面不存在" }; if (!data) return { title: "页面不存在" };
const p = data.page; const p = data.page;
const urlStyle = (await getPublicSettingsCached()).url_style;
return { return {
title: p.title, title: p.title,
description: p.excerpt || p.title, description: p.excerpt || p.title,
@@ -38,7 +40,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
type: "article", type: "article",
publishedTime: p.created_at, 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 announcements = overview?.announcements ?? [];
const canManagePages = !!me.user && isAdminOrAbove(me.user.role); const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
const siteLead = settings.site_description?.trim() || undefined; const siteLead = settings.site_description?.trim() || undefined;
const urlStyle = settings.url_style;
const toc = extractToc(p.content); const toc = extractToc(p.content);
const headingToc = markdownHeadingToc(toc); const headingToc = markdownHeadingToc(toc);
const readingMinutes = estimateReadingMinutes(p.content, p.title.length); const readingMinutes = estimateReadingMinutes(p.content, p.title.length);
@@ -76,7 +79,7 @@ export default async function SitePagePublic({ params }: PageProps) {
const jsonLd = webPageJsonLd({ const jsonLd = webPageJsonLd({
name: p.title, name: p.title,
description: p.excerpt || undefined, description: p.excerpt || undefined,
path: `/p/${p.slug}`, path: pagePath(urlStyle, p.slug),
datePublished: p.created_at, datePublished: p.created_at,
dateModified: p.updated_at, dateModified: p.updated_at,
}); });

View File

@@ -195,6 +195,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
items: postsData.posts.map((p) => ({ id: p.id, name: p.title })), items: postsData.posts.map((p) => ({ id: p.id, name: p.title })),
page, page,
pageSize: 20, pageSize: 20,
urlStyle: publicSettings.url_style,
}); });
// 搜索态:单栏占满,无 Tabs/侧栏 // 搜索态:单栏占满,无 Tabs/侧栏

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { authCookieHeader } from "@/lib/cookies"; 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 已融合到用户主页 /u/[id]?tab=settings
// 未登录跳登录页,登录后跳自己的主页 settings tab // 未登录跳登录页,登录后跳自己的主页 settings tab
@@ -11,5 +12,6 @@ export default async function SettingsRedirect() {
if (!me.user) { if (!me.user) {
redirect(`/login?redirect=${encodeURIComponent(`/settings`)}`); 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`);
} }

View File

@@ -6,6 +6,8 @@ import {
fetchPublishedSitePages, fetchPublishedSitePages,
} from "@/lib/api"; } from "@/lib/api";
import { canonicalPath, resolveSiteOrigin } from "@/lib/siteUrl"; 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<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
await connection(); await connection();
@@ -14,6 +16,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
console.error("[seo] SITE_URL 缺失或非法,已跳过 sitemap"); console.error("[seo] SITE_URL 缺失或非法,已跳过 sitemap");
return []; return [];
} }
const style = (await getPublicSettingsCached()).url_style;
const url = (pathname: string) => `${origin}${canonicalPath(pathname)}`; const url = (pathname: string) => `${origin}${canonicalPath(pathname)}`;
const entries: MetadataRoute.Sitemap = [ const entries: MetadataRoute.Sitemap = [
@@ -25,7 +28,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
priority: 0.7, priority: 0.7,
}, },
{ {
url: url("/leaderboard"), url: url(leaderboardPath(style)),
lastModified: new Date(), lastModified: new Date(),
changeFrequency: "daily", changeFrequency: "daily",
priority: 0.5, priority: 0.5,
@@ -42,7 +45,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const { posts } = await fetchPosts(1, 100); const { posts } = await fetchPosts(1, 100);
for (const post of posts) { for (const post of posts) {
entries.push({ entries.push({
url: url(`/post/${post.id}`), url: url(postPath(style, post.id)),
lastModified: new Date(post.created_at), lastModified: new Date(post.created_at),
changeFrequency: "weekly", changeFrequency: "weekly",
priority: 0.6, priority: 0.6,
@@ -56,7 +59,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const { announcements } = await fetchAnnouncementsPage(1, 100); const { announcements } = await fetchAnnouncementsPage(1, 100);
for (const a of announcements) { for (const a of announcements) {
entries.push({ entries.push({
url: url(`/announcement/${a.id}`), url: url(announcementPath(style, a.id)),
lastModified: new Date(a.updated_at || a.created_at), lastModified: new Date(a.updated_at || a.created_at),
changeFrequency: "weekly", changeFrequency: "weekly",
priority: 0.5, priority: 0.5,
@@ -70,7 +73,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const { pages } = await fetchPublishedSitePages(); const { pages } = await fetchPublishedSitePages();
for (const p of pages) { for (const p of pages) {
entries.push({ entries.push({
url: url(`/p/${p.slug}`), url: url(pagePath(style, p.slug)),
lastModified: new Date(p.updated_at || p.created_at), lastModified: new Date(p.updated_at || p.created_at),
changeFrequency: "monthly", changeFrequency: "monthly",
priority: 0.4, priority: 0.4,

View File

@@ -12,6 +12,8 @@ import { formatRelative } from "@/lib/format";
import type { import type {
UserProfile, UserCommentsResponse, UserFavoritesResponse, UserFollowingResponse, PostListItem, UserProfile, UserCommentsResponse, UserFavoritesResponse, UserFollowingResponse, PostListItem,
} from "@/lib/api"; } from "@/lib/api";
import { postPath, userPath } from "@/lib/urlStyle";
import { getPublicSettingsCached } from "@/lib/serverData";
/** 本页卡片统一阴影;用 color-mix 让暗色主题自动加深,避免写死浅色阴影 */ /** 本页卡片统一阴影;用 color-mix 让暗色主题自动加深,避免写死浅色阴影 */
const CARD_SHADOW = "0 1px 3px color-mix(in srgb, #000 18%, transparent)"; 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; const pinned = post.pinned > 0;
return ( return (
<Link <Link
href={`/post/${post.id}`} href={postPath(urlStyle, post.id)}
className={`${ITEM_BASE} ${ className={`${ITEM_BASE} ${
pinned pinned
? "bg-[color-mix(in_srgb,#fde68a_22%,var(--panel))] border-[color-mix(in_srgb,#fde68a_70%,var(--line))] hover:bg-[color-mix(in_srgb,#fde68a_30%,var(--panel))]" ? "bg-[color-mix(in_srgb,#fde68a_22%,var(--panel))] border-[color-mix(in_srgb,#fde68a_70%,var(--line))] hover:bg-[color-mix(in_srgb,#fde68a_30%,var(--panel))]"
@@ -139,7 +142,7 @@ function ErrorState({ retryHref }: { retryHref: string }) {
* tab 与分页全部走 URL(?tab=xxx&page=N&sort=xxx),刷新/后退/分享链接均保持一致。 * tab 与分页全部走 URL(?tab=xxx&page=N&sort=xxx),刷新/后退/分享链接均保持一致。
* Tab 切换栏已移至 page.tsx 统一渲染(含 settings / security),此组件仅负责列表类 tab。 * Tab 切换栏已移至 page.tsx 统一渲染(含 settings / security),此组件仅负责列表类 tab。
*/ */
export default function UserProfileContent({ export default async function UserProfileContent({
userId, userId,
profile, profile,
tab, tab,
@@ -168,6 +171,7 @@ export default function UserProfileContent({
isOwner: boolean; isOwner: boolean;
allowComments?: boolean; allowComments?: boolean;
}) { }) {
const urlStyle = (await getPublicSettingsCached()).url_style;
const displayName = profile.user.nickname || profile.user.username; const displayName = profile.user.nickname || profile.user.username;
const paginationQuery: Record<string, string> = const paginationQuery: Record<string, string> =
tab === "comments" ? { tab: "comments" } tab === "comments" ? { tab: "comments" }
@@ -189,7 +193,7 @@ export default function UserProfileContent({
{SORTS.map((s) => ( {SORTS.map((s) => (
<Link <Link
key={s.key} key={s.key}
href={s.key === "new" ? `/u/${userId}` : `/u/${userId}?sort=${s.key}`} href={s.key === "new" ? userPath(urlStyle, Number(userId)) : `${userPath(urlStyle, Number(userId))}?sort=${s.key}`}
className="j13-chip" className="j13-chip"
aria-current={sort === s.key ? "page" : undefined} aria-current={sort === s.key ? "page" : undefined}
> >
@@ -215,13 +219,13 @@ export default function UserProfileContent({
total={profile.posts_total} total={profile.posts_total}
page={page} page={page}
size={20} size={20}
basePath={`/u/${userId}`} basePath={userPath(urlStyle, Number(userId))}
query={paginationQuery} query={paginationQuery}
/> />
</div> </div>
) : tab === "favorites" ? ( ) : tab === "favorites" ? (
favoriteError ? ( favoriteError ? (
<ErrorState retryHref={`/u/${userId}?tab=favorites`} /> <ErrorState retryHref={`${userPath(urlStyle, Number(userId))}?tab=favorites`} />
) : favoriteData && favoriteData.posts.length > 0 ? ( ) : favoriteData && favoriteData.posts.length > 0 ? (
<div> <div>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2"> <div className="mb-3 flex flex-wrap items-center justify-between gap-2">
@@ -238,7 +242,7 @@ export default function UserProfileContent({
total={favoriteData.total} total={favoriteData.total}
page={favoriteData.page} page={favoriteData.page}
size={20} size={20}
basePath={`/u/${userId}`} basePath={userPath(urlStyle, Number(userId))}
query={paginationQuery} query={paginationQuery}
/> />
</div> </div>
@@ -250,7 +254,7 @@ export default function UserProfileContent({
) )
) : tab === "following" ? ( ) : tab === "following" ? (
followingError ? ( followingError ? (
<ErrorState retryHref={`/u/${userId}?tab=following`} /> <ErrorState retryHref={`${userPath(urlStyle, Number(userId))}?tab=following`} />
) : followingData && followingData.users.length > 0 ? ( ) : followingData && followingData.users.length > 0 ? (
<div> <div>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2"> <div className="mb-3 flex flex-wrap items-center justify-between gap-2">
@@ -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))]" 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 }} style={{ boxShadow: CARD_SHADOW }}
> >
<Link href={`/u/${fu.id}`} className="flex min-w-0 flex-1 items-center gap-3"> <Link href={userPath(urlStyle, fu.id)} className="flex min-w-0 flex-1 items-center gap-3">
<Avatar name={fu.nickname || fu.username} src={fu.avatar || undefined} size={40} /> <Avatar name={fu.nickname || fu.username} src={fu.avatar || undefined} size={40} />
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
@@ -291,7 +295,7 @@ export default function UserProfileContent({
total={followingData.total} total={followingData.total}
page={followingData.page} page={followingData.page}
size={20} size={20}
basePath={`/u/${userId}`} basePath={userPath(urlStyle, Number(userId))}
query={paginationQuery} query={paginationQuery}
/> />
</div> </div>
@@ -302,14 +306,14 @@ export default function UserProfileContent({
/> />
) )
) : commentError ? ( ) : commentError ? (
<ErrorState retryHref={`/u/${userId}?tab=comments`} /> <ErrorState retryHref={`${userPath(urlStyle, Number(userId))}?tab=comments`} />
) : commentData && (commentData.comments?.length ?? 0) > 0 ? ( ) : commentData && (commentData.comments?.length ?? 0) > 0 ? (
<div> <div>
<div className="panel overflow-hidden"> <div className="panel overflow-hidden">
{(commentData.comments ?? []).map((c) => ( {(commentData.comments ?? []).map((c) => (
<Link <Link
key={c.id} key={c.id}
href={`/post/${c.post_id}#comment-${c.id}`} href={`${postPath(urlStyle, c.post_id)}#comment-${c.id}`}
className="group block px-4 sm:px-5 py-4 border-t transition-colors hover:bg-[var(--item-hover-bg)] focus-visible:bg-[var(--item-hover-bg)]" className="group block px-4 sm:px-5 py-4 border-t transition-colors hover:bg-[var(--item-hover-bg)] focus-visible:bg-[var(--item-hover-bg)]"
style={{ borderColor: "var(--line)" }} style={{ borderColor: "var(--line)" }}
> >
@@ -335,7 +339,7 @@ export default function UserProfileContent({
total={commentData.total} total={commentData.total}
page={commentData.page} page={commentData.page}
size={20} size={20}
basePath={`/u/${userId}`} basePath={userPath(urlStyle, Number(userId))}
query={paginationQuery} query={paginationQuery}
/> />
</div> </div>

View File

@@ -28,6 +28,7 @@ import FollowButton from "@/components/FollowButton";
import StaffUserMenu from "@/components/StaffUserMenu"; import StaffUserMenu from "@/components/StaffUserMenu";
import { requestCanonical } from "@/lib/canonicalMeta"; import { requestCanonical } from "@/lib/canonicalMeta";
import { absoluteCanonical, canonicalPage } from "@/lib/siteUrl"; import { absoluteCanonical, canonicalPage } from "@/lib/siteUrl";
import { userPath } from "@/lib/urlStyle";
import { profileJsonLd } from "@/lib/jsonLd"; import { profileJsonLd } from "@/lib/jsonLd";
import JsonLd from "@/components/JsonLd"; import JsonLd from "@/components/JsonLd";
@@ -52,7 +53,9 @@ export async function generateMetadata({ params, searchParams }: UserPageProps):
const tab = parseTab(sp.tab); const tab = parseTab(sp.tab);
try { try {
const data = await fetchUserProfile(id, 1); 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 title = data.user.nickname || data.user.username;
const description = allowComments const description = allowComments
? `查看 ${data.user.nickname} 的资料、发帖与评论` ? `查看 ${data.user.nickname} 的资料、发帖与评论`
@@ -71,7 +74,7 @@ export async function generateMetadata({ params, searchParams }: UserPageProps):
return { return {
title, title,
description, description,
...(await requestCanonical(`/u/${data.user.id}`, query)), ...(await requestCanonical(userPath(urlStyle, data.user.id), query)),
}; };
} catch { } catch {
return { title: "用户不存在" }; return { title: "用户不存在" };
@@ -93,6 +96,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
const me = await getMeCached(cookie || undefined); const me = await getMeCached(cookie || undefined);
const settings = await getPublicSettingsCached(); const settings = await getPublicSettingsCached();
const allowComments = settings.allow_comments !== false; const allowComments = settings.allow_comments !== false;
const urlStyle = settings.url_style;
const commentsRequireLogin = settings.comments_require_login === true; const commentsRequireLogin = settings.comments_require_login === true;
const allowMessages = settings.allow_messages !== false; const allowMessages = settings.allow_messages !== false;
const canReadComments = allowComments && (!commentsRequireLogin || !!me.user); const canReadComments = allowComments && (!commentsRequireLogin || !!me.user);
@@ -206,7 +210,8 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
] ]
: []; : [];
const visibleNav = [...mainNavItems, ...settingsNavItems]; 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<string, string | number> = {}; const profileQuery: Record<string, string | number> = {};
if (canReadComments && effectiveTab === "comments") profileQuery.tab = "comments"; if (canReadComments && effectiveTab === "comments") profileQuery.tab = "comments";
@@ -222,7 +227,8 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
name: u.nickname || u.username, name: u.nickname || u.username,
username: u.username, username: u.username,
signature: (u.signature || "").trim() || undefined, signature: (u.signature || "").trim() || undefined,
pageUrl: absoluteCanonical(`/u/${u.id}`, profileQuery), pageUrl: absoluteCanonical(userPath(urlStyle, u.id), profileQuery),
urlStyle,
}); });
/** 本页卡片统一阴影;暗色随 color-mix 自动加深 */ /** 本页卡片统一阴影;暗色随 color-mix 自动加深 */
@@ -255,12 +261,12 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
{/* 本人操作按钮 / 访客私聊:绝对定位在卡片右上角 */} {/* 本人操作按钮 / 访客私聊:绝对定位在卡片右上角 */}
{isOwner ? ( {isOwner ? (
<div className="absolute top-3 right-3 z-20 flex items-center gap-2"> <div className="absolute top-3 right-3 z-20 flex items-center gap-2">
<Link href={`/u/${id}?tab=settings`} className="btn btn-line btn-sm"> <Link href={`${userPath(urlStyle, Number(id))}?tab=settings`} className="btn btn-line btn-sm">
<PenLine size={13.5} /> <PenLine size={13.5} />
<span className="hidden sm:inline">编辑资料</span> <span className="hidden sm:inline">编辑资料</span>
</Link> </Link>
<Link <Link
href={`/u/${id}?tab=security`} href={`${userPath(urlStyle, Number(id))}?tab=security`}
aria-label="安全设置" aria-label="安全设置"
data-tip="安全设置" data-tip="安全设置"
className="inline-flex w-8 h-8 items-center justify-center rounded-full border transition-colors duration-150 hover:border-[var(--accent)] hover:text-[var(--accent)]" className="inline-flex w-8 h-8 items-center justify-center rounded-full border transition-colors duration-150 hover:border-[var(--accent)] hover:text-[var(--accent)]"

View File

@@ -1,6 +1,8 @@
import Link from "next/link"; import Link from "next/link";
import { CheckCircle2, CircleHelp, Coins } from "lucide-react"; import { CheckCircle2, CircleHelp, Coins } from "lucide-react";
import type { AcceptedAnswer } from "@/lib/api"; import type { AcceptedAnswer } from "@/lib/api";
import { userPath } from "@/lib/urlStyle";
import { getPublicSettingsCached } from "@/lib/serverData";
import Avatar from "@/components/Avatar"; import Avatar from "@/components/Avatar";
import MarkdownBody from "@/components/MarkdownBody"; import MarkdownBody from "@/components/MarkdownBody";
@@ -24,6 +26,8 @@ export default async function AcceptedAnswerCard({
const answerLabel = isQuestion ? "最佳答案" : "采纳答案"; const answerLabel = isQuestion ? "最佳答案" : "采纳答案";
const endTime = solvedAt || answer.created_at; const endTime = solvedAt || answer.created_at;
const endLabel = formatEndsAt(endTime); const endLabel = formatEndsAt(endTime);
const urlStyle = (await getPublicSettingsCached()).url_style;
const authorHref = author ? userPath(urlStyle, author.id) : "";
return ( return (
<section <section
@@ -51,12 +55,12 @@ export default async function AcceptedAnswerCard({
</span> </span>
{author ? ( {author ? (
<> <>
<Link href={`/u/${author.id}`} className="shrink-0" aria-label={name}> <Link href={authorHref} className="shrink-0" aria-label={name}>
<Avatar name={name} src={author.avatar || undefined} size={32} /> <Avatar name={name} src={author.avatar || undefined} size={32} />
</Link> </Link>
<div className="min-w-0 flex flex-col gap-0.5"> <div className="min-w-0 flex flex-col gap-0.5">
<Link <Link
href={`/u/${author.id}`} href={authorHref}
className="truncate text-[13.5px] font-semibold hover:text-[var(--accent)] transition-colors" className="truncate text-[13.5px] font-semibold hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }} style={{ color: "var(--ink)" }}
> >

View File

@@ -11,6 +11,8 @@ import LevelBadge from "@/components/LevelBadge";
import BadgeChip from "@/components/BadgeChip"; import BadgeChip from "@/components/BadgeChip";
import ImageLightbox from "@/components/markdown/ImageLightbox"; import ImageLightbox from "@/components/markdown/ImageLightbox";
import { useAllowMessages } from "@/components/MessagesPolicyProvider"; 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 { apiGetUserProfile, apiOpenDirectChat, type UserProfile, type User } from "@/lib/api";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
@@ -36,6 +38,7 @@ export default function ChatUserCard({
}) { }) {
const router = useRouter(); const router = useRouter();
const allowMessages = useAllowMessages(); const allowMessages = useAllowMessages();
const urlStyle = useUrlStyle();
const [profile, setProfile] = useState<UserProfile | null>(null); const [profile, setProfile] = useState<UserProfile | null>(null);
const [dmBusy, setDmBusy] = useState(false); const [dmBusy, setDmBusy] = useState(false);
const [avatarOpen, setAvatarOpen] = useState(false); const [avatarOpen, setAvatarOpen] = useState(false);
@@ -194,7 +197,7 @@ export default function ChatUserCard({
{/* 操作区 */} {/* 操作区 */}
<div className="mt-4 flex items-center gap-2"> <div className="mt-4 flex items-center gap-2">
<Link href={`/u/${user.id}`} className="btn btn-line btn-sm flex-1 justify-center"> <Link href={userPath(urlStyle, user.id)} className="btn btn-line btn-sm flex-1 justify-center">
用户主页 用户主页
</Link> </Link>
{allowMessages && !isSelf && ( {allowMessages && !isSelf && (

View File

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

View File

@@ -5,6 +5,8 @@ import Link from "next/link";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import Avatar from "@/components/Avatar"; import Avatar from "@/components/Avatar";
import { userPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
@@ -265,6 +267,7 @@ export default function EditHistoryModal({
fetchPage: FetchHistoryPage; fetchPage: FetchHistoryPage;
onClose: () => void; onClose: () => void;
}) { }) {
const urlStyle = useUrlStyle();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -366,12 +369,12 @@ export default function EditHistoryModal({
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }} style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
> >
<div className="flex items-center gap-2 mb-2.5"> <div className="flex items-center gap-2 mb-2.5">
<Link href={`/u/${it.editor.id}`} className="shrink-0"> <Link href={userPath(urlStyle, it.editor.id)} className="shrink-0">
<Avatar name={name} src={it.editor.avatar || undefined} size={28} /> <Avatar name={name} src={it.editor.avatar || undefined} size={28} />
</Link> </Link>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<Link <Link
href={`/u/${it.editor.id}`} href={userPath(urlStyle, it.editor.id)}
className="text-[13px] hover:text-[var(--accent)]" className="text-[13px] hover:text-[var(--accent)]"
style={{ color: "var(--ink)" }} style={{ color: "var(--ink)" }}
> >

View File

@@ -21,6 +21,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api"; import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies"; import { hasAuthCookieHint } from "@/lib/cookies";
import { userPath } from "@/lib/urlStyle";
import { canAccessAdminMessages, isStaff } from "@/lib/roles"; import { canAccessAdminMessages, isStaff } from "@/lib/roles";
import { onUserUpdate, onForceLogout } from "@/lib/userEvents"; import { onUserUpdate, onForceLogout } from "@/lib/userEvents";
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime"; import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
@@ -33,6 +34,8 @@ import LevelBadge from "./LevelBadge";
import BadgeChip from "./BadgeChip"; import BadgeChip from "./BadgeChip";
import BrandLockup from "./BrandLockup"; import BrandLockup from "./BrandLockup";
import { useAllowMessages } from "./MessagesPolicyProvider"; import { useAllowMessages } from "./MessagesPolicyProvider";
import { useUrlStyle } from "./SiteBrandProvider";
import { leaderboardPath } from "@/lib/urlStyle";
import type { BrandLogoFit, BrandLogoSize, BrandMark } from "@/lib/brand"; import type { BrandLogoFit, BrandLogoSize, BrandMark } from "@/lib/brand";
export default function Header({ export default function Header({
@@ -62,6 +65,8 @@ export default function Header({
}) { }) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const urlStyle = useUrlStyle();
const lbHref = leaderboardPath(urlStyle);
const allowMessages = useAllowMessages(); const allowMessages = useAllowMessages();
const hideComposeNav = const hideComposeNav =
pathname === "/compose" || pathname === "/compose" ||
@@ -239,8 +244,8 @@ export default function Header({
首页 首页
</NavPill> </NavPill>
<NavPill <NavPill
href="/leaderboard" href={lbHref}
active={pathname?.startsWith("/leaderboard") ?? false} active={!!pathname && (pathname === lbHref || pathname.startsWith(`${lbHref}/`))}
icon={<Trophy size={15} />} icon={<Trophy size={15} />}
> >
排行榜 排行榜
@@ -410,6 +415,7 @@ function NavPill({
* 把零散的图标按钮收拢为一个语义明确的控件;移动端只显示头像 * 把零散的图标按钮收拢为一个语义明确的控件;移动端只显示头像
*/ */
function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) { function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
const urlStyle = useUrlStyle();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@@ -493,7 +499,7 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
<Link href="/notifications" onClick={() => setOpen(false)} className={itemCls} role="menuitem"> <Link href="/notifications" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<Bell size={15} style={{ color: "var(--ink-3)" }} /> 通知中心 <Bell size={15} style={{ color: "var(--ink-3)" }} /> 通知中心
</Link> </Link>
<Link href={`/u/${user.id}`} onClick={() => setOpen(false)} className={itemCls} role="menuitem"> <Link href={userPath(urlStyle, user.id)} onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<UserIcon size={15} style={{ color: "var(--ink-3)" }} /> 个人主页 <UserIcon size={15} style={{ color: "var(--ink-3)" }} /> 个人主页
</Link> </Link>
<Link href="/settings" onClick={() => setOpen(false)} className={itemCls} role="menuitem"> <Link href="/settings" onClick={() => setOpen(false)} className={itemCls} role="menuitem">

View File

@@ -40,6 +40,8 @@ import ImageInsertModal from "@/components/ImageInsertModal";
import TimelineEditor from "@/components/TimelineEditor"; import TimelineEditor from "@/components/TimelineEditor";
import { resolveTimelineBlockForEdit } from "@/lib/directiveBlocks"; import { resolveTimelineBlockForEdit } from "@/lib/directiveBlocks";
import { SHIKI_LANG_IDS } from "@/components/markdown/shikiHighlighter"; import { SHIKI_LANG_IDS } from "@/components/markdown/shikiHighlighter";
import { postPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
type MarkdownEditorProps = { type MarkdownEditorProps = {
value: string; value: string;
@@ -157,6 +159,7 @@ export default function MarkdownEditor({
onUploadingChange, onUploadingChange,
}: MarkdownEditorProps) { }: MarkdownEditorProps) {
const taRef = useRef<HTMLTextAreaElement>(null); const taRef = useRef<HTMLTextAreaElement>(null);
const urlStyle = useUrlStyle();
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null); const toolbarRef = useRef<HTMLDivElement>(null);
const [mode, setMode] = useState<EditorMode>("write"); const [mode, setMode] = useState<EditorMode>("write");
@@ -472,7 +475,7 @@ export default function MarkdownEditor({
/** 选中站内帖子:回填 URL 与文字,用户可再编辑后点「插入」 */ /** 选中站内帖子:回填 URL 与文字,用户可再编辑后点「插入」 */
const pickPostLink = (p: PostListItem) => { const pickPostLink = (p: PostListItem) => {
setLinkUrl(`/post/${p.id}`); setLinkUrl(postPath(urlStyle, p.id));
setLinkText(p.title); setLinkText(p.title);
}; };

View File

@@ -12,7 +12,9 @@ import {
} from "lucide-react"; } from "lucide-react";
import { apiChatUnreadSummary, type User } from "@/lib/api"; import { apiChatUnreadSummary, type User } from "@/lib/api";
import { useAllowMessages } from "@/components/MessagesPolicyProvider"; import { useAllowMessages } from "@/components/MessagesPolicyProvider";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { onDocumentVisible } from "@/lib/visibilityReconcile"; import { onDocumentVisible } from "@/lib/visibilityReconcile";
import { parseEntityPath, userPath, leaderboardPath } from "@/lib/urlStyle";
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime"; import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
import type { RtChatUnreadData } from "@/lib/realtime"; import type { RtChatUnreadData } from "@/lib/realtime";
@@ -30,6 +32,8 @@ export default function MobileTabBar({
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
const allowMessages = useAllowMessages(); const allowMessages = useAllowMessages();
const urlStyle = useUrlStyle();
const lbHref = leaderboardPath(urlStyle);
const [chatUnread, setChatUnread] = useState(initialChatUnread); const [chatUnread, setChatUnread] = useState(initialChatUnread);
// 登录态经 router.refresh() 下发新 props 时校正徽标 // 登录态经 router.refresh() 下发新 props 时校正徽标
@@ -96,10 +100,10 @@ export default function MobileTabBar({
active={isActive((p) => p === "/")} active={isActive((p) => p === "/")}
/> />
<TabLink <TabLink
href="/leaderboard" href={lbHref}
icon={<Trophy size={20} />} icon={<Trophy size={20} />}
label="排行榜" label="排行榜"
active={isActive((p) => p.startsWith("/leaderboard"))} active={!!pathname && (pathname === lbHref || pathname.startsWith(`${lbHref}/`))}
/> />
<TabLink <TabLink
href={user ? "/compose" : "/login"} href={user ? "/compose" : "/login"}
@@ -118,11 +122,18 @@ export default function MobileTabBar({
/> />
)} )}
<TabLink <TabLink
href={user ? `/u/${user.id}` : "/login"} href={user ? userPath(urlStyle, user.id) : "/login"}
icon={<UserIcon size={20} />} icon={<UserIcon size={20} />}
label="我的" label="我的"
active={ 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)}/`)
);
})
} }
/> />
</div> </div>

View File

@@ -13,9 +13,12 @@ import {
} from "@/lib/api"; } from "@/lib/api";
import { realtime, RT_NOTIFICATION_NEW } from "@/lib/realtime"; import { realtime, RT_NOTIFICATION_NEW } from "@/lib/realtime";
import { onDocumentVisible } from "@/lib/visibilityReconcile"; import { onDocumentVisible } from "@/lib/visibilityReconcile";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { postPath, userPath } from "@/lib/urlStyle";
export default function NotificationBell({ initialUnread = 0 }: { initialUnread?: number }) { export default function NotificationBell({ initialUnread = 0 }: { initialUnread?: number }) {
const router = useRouter(); const router = useRouter();
const urlStyle = useUrlStyle();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// SSR 直出未读数,红点不再挂载后才弹出;WS/回前台校准 // SSR 直出未读数,红点不再挂载后才弹出;WS/回前台校准
const [unread, setUnread] = useState(initialUnread); const [unread, setUnread] = useState(initialUnread);
@@ -103,16 +106,16 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
if (n.type === "mention" && n.room_id) { if (n.type === "mention" && n.room_id) {
router.push(`/chat/${n.room_id}`); router.push(`/chat/${n.room_id}`);
} else if (n.type === "pending_review") { } 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") { } else if (n.type === "badge") {
return; return;
} else if (n.type === "follow") { } else if (n.type === "follow") {
router.push(`/u/${n.actor_id}`); router.push(userPath(urlStyle, n.actor_id));
} else if (n.comment_id > 0) { } 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 { } else {
router.push(`/post/${n.post_id}`); router.push(postPath(urlStyle, n.post_id));
} }
}; };

View File

@@ -1,6 +1,8 @@
import Link from "next/link"; import Link from "next/link";
import { Pin } from "lucide-react"; import { Pin } from "lucide-react";
import type { PostListItem } from "@/lib/api"; import type { PostListItem } from "@/lib/api";
import { postPath } from "@/lib/urlStyle";
import { getPublicSettingsCached } from "@/lib/serverData";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import { IconComment, IconEye } from "./Icons"; import { IconComment, IconEye } from "./Icons";
@@ -15,7 +17,7 @@ function formatTime(dateStr: string) {
* 管理员置顶区:独立于普通帖子列表。 * 管理员置顶区:独立于普通帖子列表。
* 陶土色语义(与 badge-pin 一致),紧凑排列,一眼识别为公告性质内容。 * 陶土色语义(与 badge-pin 一致),紧凑排列,一眼识别为公告性质内容。
*/ */
export default function PinnedPosts({ export default async function PinnedPosts({
posts, posts,
showBoard = true, showBoard = true,
}: { }: {
@@ -23,6 +25,7 @@ export default function PinnedPosts({
showBoard?: boolean; showBoard?: boolean;
}) { }) {
if (posts.length === 0) return null; if (posts.length === 0) return null;
const urlStyle = (await getPublicSettingsCached()).url_style;
return ( return (
<section className="mb-6" aria-label="置顶讨论"> <section className="mb-6" aria-label="置顶讨论">
@@ -43,7 +46,7 @@ export default function PinnedPosts({
{posts.map((p, i) => ( {posts.map((p, i) => (
<Link <Link
key={p.id} key={p.id}
href={`/post/${p.id}`} href={postPath(urlStyle, p.id)}
className="group relative flex items-center gap-3 sm:gap-3.5 px-4 sm:px-5 py-3.5 transition-colors hover:bg-[var(--item-hover-bg)] focus-visible:bg-[var(--item-hover-bg)] j13-fade-up" className="group relative flex items-center gap-3 sm:gap-3.5 px-4 sm:px-5 py-3.5 transition-colors hover:bg-[var(--item-hover-bg)] focus-visible:bg-[var(--item-hover-bg)] j13-fade-up"
style={{ style={{
...(i > 0 ? { borderTop: "1px solid var(--line)" } : {}), ...(i > 0 ? { borderTop: "1px solid var(--line)" } : {}),

View File

@@ -12,6 +12,8 @@ import {
import { fileExtBadge, fileKind } from "@/lib/fileKind"; import { fileExtBadge, fileKind } from "@/lib/fileKind";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import { postPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
function formatSize(n: number) { function formatSize(n: number) {
if (n < 1024) return `${n} B`; if (n < 1024) return `${n} B`;
@@ -86,7 +88,8 @@ export default function PostAttachments({
loggedIn: boolean; loggedIn: boolean;
}) { }) {
const router = useRouter(); 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<number | null>(null); const [busyId, setBusyId] = useState<number | null>(null);
const [pending, setPending] = useState<PostAttachment | null>(null); const [pending, setPending] = useState<PostAttachment | null>(null);
const [unlockedIds, setUnlockedIds] = useState<number[]>([]); const [unlockedIds, setUnlockedIds] = useState<number[]>([]);

View File

@@ -35,6 +35,8 @@ import DeadlinePicker, {
} from "@/components/DeadlinePicker"; } from "@/components/DeadlinePicker";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime"; import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
import { postPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
const TITLE_MAX = 256; const TITLE_MAX = 256;
/** 帖子正文字符上限(与后端 handler/post.go 校验一致) */ /** 帖子正文字符上限(与后端 handler/post.go 校验一致) */
@@ -179,6 +181,7 @@ export type PostComposerProps = CreateProps | EditProps;
export default function PostComposer(props: PostComposerProps) { export default function PostComposer(props: PostComposerProps) {
const router = useRouter(); const router = useRouter();
const urlStyle = useUrlStyle();
const isCreate = props.mode === "create"; const isCreate = props.mode === "create";
const [boards, setBoards] = useState<Board[]>([]); const [boards, setBoards] = useState<Board[]>([]);
@@ -828,7 +831,7 @@ export default function PostComposer(props: PostComposerProps) {
} else { } else {
toast("发布成功", "ok"); toast("发布成功", "ok");
} }
router.push(`/post/${res.post.id}`); router.push(postPath(urlStyle, res.post.id));
} else { } else {
const mapped = mapPostError(res.error || "发帖失败"); const mapped = mapPostError(res.error || "发帖失败");
reportSubmitError( reportSubmitError(

View File

@@ -18,6 +18,8 @@ import {
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import Avatar from "@/components/Avatar"; import Avatar from "@/components/Avatar";
import { useAllowComments } from "@/components/CommentsPolicyProvider"; import { useAllowComments } from "@/components/CommentsPolicyProvider";
import { postPath, userPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
export default function PostInteractPanels({ export default function PostInteractPanels({
postId, postId,
@@ -286,6 +288,7 @@ function PollPanel({
loggedIn: boolean; loggedIn: boolean;
}) { }) {
const router = useRouter(); const router = useRouter();
const urlStyle = useUrlStyle();
const [sel, setSel] = useState<number[]>(state.my_options || []); const [sel, setSel] = useState<number[]>(state.my_options || []);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const showResults = state.closed || (state.my_options && state.my_options.length > 0); const showResults = state.closed || (state.my_options && state.my_options.length > 0);
@@ -418,7 +421,7 @@ function PollPanel({
<ul className="j13-poll-voters"> <ul className="j13-poll-voters">
{namedVoters.map((v) => ( {namedVoters.map((v) => (
<li key={v.id}> <li key={v.id}>
<Link href={`/u/${v.id}`} className="j13-poll-voter"> <Link href={userPath(urlStyle, v.id)} className="j13-poll-voter">
<Avatar name={v.nickname || v.username} src={v.avatar || undefined} size={16} /> <Avatar name={v.nickname || v.username} src={v.avatar || undefined} size={16} />
{v.nickname || v.username} {v.nickname || v.username}
</Link> </Link>
@@ -445,7 +448,7 @@ function PollPanel({
)} )}
{!loggedIn && !state.closed && ( {!loggedIn && !state.closed && (
<Link <Link
href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`} href={`/login?redirect=${encodeURIComponent(postPath(urlStyle, postId))}`}
className="btn btn-line btn-sm" className="btn btn-line btn-sm"
> >
登录后投票 登录后投票
@@ -559,6 +562,7 @@ function LotteryPanel({
state: LotteryState; state: LotteryState;
}) { }) {
const router = useRouter(); const router = useRouter();
const urlStyle = useUrlStyle();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const act = async (fn: () => Promise<{ post?: unknown; error?: string }>, ok: string) => { const act = async (fn: () => Promise<{ post?: unknown; error?: string }>, ok: string) => {
@@ -651,7 +655,7 @@ function LotteryPanel({
className="j13-lottery-winner-avatar" className="j13-lottery-winner-avatar"
/> />
<div className="j13-lottery-winner-body"> <div className="j13-lottery-winner-body">
<Link href={`/u/${w.id}`} className="j13-lottery-winner-name"> <Link href={userPath(urlStyle, w.id)} className="j13-lottery-winner-name">
{w.nickname || w.username} {w.nickname || w.username}
</Link> </Link>
{w.prize_name ? ( {w.prize_name ? (

View File

@@ -3,6 +3,7 @@ import { ArrowLeft } from "lucide-react";
import type { PostListItem } from "@/lib/api"; import type { PostListItem } from "@/lib/api";
import { formatRelative } from "@/lib/format"; import { formatRelative } from "@/lib/format";
import { getPublicSettingsCached } from "@/lib/serverData"; import { getPublicSettingsCached } from "@/lib/serverData";
import { postPath, userPath } from "@/lib/urlStyle";
import { normalizePostType, postTypeLabel, typeStatusLabel, typeStatusClass } from "@/lib/postMeta"; import { normalizePostType, postTypeLabel, typeStatusLabel, typeStatusClass } from "@/lib/postMeta";
import LikeButton from "./LikeButton"; import LikeButton from "./LikeButton";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
@@ -32,7 +33,9 @@ export default async function PostRow({
showViews?: boolean; showViews?: boolean;
showLike?: 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 lastReply = post.last_reply;
const replied = const replied =
allowComments && allowComments &&
@@ -56,7 +59,7 @@ export default async function PostRow({
}} }}
> >
<Link <Link
href={`/u/${post.user.id}`} href={userPath(urlStyle, post.user.id)}
className="shrink-0 above-link" className="shrink-0 above-link"
aria-label={authorName} aria-label={authorName}
tabIndex={-1} tabIndex={-1}
@@ -71,7 +74,7 @@ export default async function PostRow({
{post.recommended && <span className="badge-rec shrink-0">推荐</span>} {post.recommended && <span className="badge-rec shrink-0">推荐</span>}
<div className="min-w-0 flex items-center gap-2 flex-1 overflow-hidden"> <div className="min-w-0 flex items-center gap-2 flex-1 overflow-hidden">
<Link <Link
href={`/post/${post.id}`} href={postPath(urlStyle, post.id)}
className="stretch-link min-w-0 shrink" className="stretch-link min-w-0 shrink"
aria-label={post.title} aria-label={post.title}
> >
@@ -98,7 +101,7 @@ export default async function PostRow({
<span className="j13-board-tag">{post.board.name}</span> <span className="j13-board-tag">{post.board.name}</span>
)} )}
<Link <Link
href={`/u/${post.user.id}`} href={userPath(urlStyle, post.user.id)}
className="above-link font-medium hover:text-[var(--accent)] transition-colors" className="above-link font-medium hover:text-[var(--accent)] transition-colors"
> >
{authorName} {authorName}
@@ -109,7 +112,7 @@ export default async function PostRow({
<span className="inline-flex items-center gap-1 min-w-0"> <span className="inline-flex items-center gap-1 min-w-0">
<ArrowLeft size={13} className="shrink-0" aria-hidden /> <ArrowLeft size={13} className="shrink-0" aria-hidden />
<Link <Link
href={`/u/${lastReply!.user.id}`} href={userPath(urlStyle, lastReply!.user.id)}
className="above-link font-medium hover:text-[var(--accent)] transition-colors truncate max-w-[8em]" className="above-link font-medium hover:text-[var(--accent)] transition-colors truncate max-w-[8em]"
> >
{lastReply!.user.nickname || lastReply!.user.username} {lastReply!.user.nickname || lastReply!.user.username}
@@ -128,7 +131,7 @@ export default async function PostRow({
<div className="flex items-center gap-3 sm:gap-4 shrink-0"> <div className="flex items-center gap-3 sm:gap-4 shrink-0">
{allowComments ? ( {allowComments ? (
<Link <Link
href={`/post/${post.id}`} href={postPath(urlStyle, post.id)}
tabIndex={-1} tabIndex={-1}
aria-label={`${post.comment_count} 条评论`} aria-label={`${post.comment_count} 条评论`}
className={`${showViews ? "hidden sm:inline-flex" : "inline-flex"} above-link items-center gap-1.5 meta tabular-nums text-[13.5px] hover:text-[var(--accent)] transition-colors`} className={`${showViews ? "hidden sm:inline-flex" : "inline-flex"} above-link items-center gap-1.5 meta tabular-nums text-[13.5px] hover:text-[var(--accent)] transition-colors`}

View File

@@ -4,6 +4,8 @@ import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Ban, ExternalLink, Loader2, RotateCcw, ShieldCheck, Users as UsersIcon } from "lucide-react"; import { Ban, ExternalLink, Loader2, RotateCcw, ShieldCheck, Users as UsersIcon } from "lucide-react";
import { apiAdminSetUserBan, apiAdminSetUserRole, apiAdminSetUserMessages, type Board } from "@/lib/api"; import { apiAdminSetUserBan, apiAdminSetUserRole, apiAdminSetUserMessages, type Board } from "@/lib/api";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { userPath } from "@/lib/urlStyle";
import { ROLES, ROLE_META, roleMeta } from "@/lib/roles"; import { ROLES, ROLE_META, roleMeta } from "@/lib/roles";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
@@ -407,6 +409,7 @@ function IdentityHeader({
displayName: string; displayName: string;
currentMetaLabel: string; currentMetaLabel: string;
}) { }) {
const urlStyle = useUrlStyle();
const metaBits: string[] = [`ID ${target.id}`]; const metaBits: string[] = [`ID ${target.id}`];
if (typeof target.points === "number") { if (typeof target.points === "number") {
metaBits.push(`积分 ${target.points.toLocaleString("zh-CN")}`); metaBits.push(`积分 ${target.points.toLocaleString("zh-CN")}`);
@@ -472,7 +475,7 @@ function IdentityHeader({
<span className="sr-only">当前角色 {currentMetaLabel}</span> <span className="sr-only">当前角色 {currentMetaLabel}</span>
</p> </p>
<Link <Link
href={`/u/${target.id}`} href={userPath(urlStyle, target.id)}
className="mt-2.5 inline-flex items-center gap-1 text-[12.5px] font-medium hover:underline" className="mt-2.5 inline-flex items-center gap-1 text-[12.5px] font-medium hover:underline"
style={{ color: "var(--accent)" }} style={{ color: "var(--accent)" }}
> >

View File

@@ -3,6 +3,7 @@
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { apiGetSettings, type PublicSettings } from "@/lib/api"; import { apiGetSettings, type PublicSettings } from "@/lib/api";
import { siteDocumentTitle, normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark, normalizeFooterLinks } from "@/lib/brand"; 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 { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
import { onDocumentVisible, VISIBLE_RECONCILE_GAP_MS } from "@/lib/visibilityReconcile"; 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_size: PublicSettings["brand_logo_size"];
brand_logo_fit: PublicSettings["brand_logo_fit"]; brand_logo_fit: PublicSettings["brand_logo_fit"];
footer_links: PublicSettings["footer_links"]; footer_links: PublicSettings["footer_links"];
url_style: UrlStyle;
}; };
const SiteBrandContext = createContext<SiteBrand | null>(null); const SiteBrandContext = createContext<SiteBrand | null>(null);
function fromSettings(s: Pick<PublicSettings, keyof SiteBrand>): SiteBrand { function fromSettings(s: PublicSettings): SiteBrand {
return { return {
site_name: s.site_name, site_name: s.site_name,
site_wordmark: s.site_wordmark, site_wordmark: s.site_wordmark,
@@ -33,6 +35,7 @@ function fromSettings(s: Pick<PublicSettings, keyof SiteBrand>): SiteBrand {
brand_logo_size: s.brand_logo_size, brand_logo_size: s.brand_logo_size,
brand_logo_fit: s.brand_logo_fit, brand_logo_fit: s.brand_logo_fit,
footer_links: s.footer_links, 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_mark === b.brand_mark &&
a.brand_logo_size === b.brand_logo_size && a.brand_logo_size === b.brand_logo_size &&
a.brand_logo_fit === b.brand_logo_fit && a.brand_logo_fit === b.brand_logo_fit &&
a.url_style === b.url_style &&
JSON.stringify(a.footer_links) === JSON.stringify(b.footer_links) JSON.stringify(a.footer_links) === JSON.stringify(b.footer_links)
); );
} }
@@ -97,11 +101,17 @@ export function useSiteBrand(): SiteBrand {
brand_logo_size: "sq", brand_logo_size: "sq",
brand_logo_fit: "contain", brand_logo_fit: "contain",
footer_links: [], footer_links: [],
url_style: "default",
}; };
} }
return ctx; return ctx;
} }
/** 详情页伪静态风格(SSR 初值与水合一致,设置保存后 realtime 热更) */
export function useUrlStyle(): UrlStyle {
return useSiteBrand().url_style;
}
/** SSR 初值 + settings:changed:页眉/页脚/标题/favicon 不必整页刷新 */ /** SSR 初值 + settings:changed:页眉/页脚/标题/favicon 不必整页刷新 */
export default function SiteBrandProvider({ export default function SiteBrandProvider({
initial, initial,
@@ -142,6 +152,7 @@ export default function SiteBrandProvider({
footer_links: Array.isArray(raw.footer_links) footer_links: Array.isArray(raw.footer_links)
? normalizeFooterLinks(raw.footer_links) ? normalizeFooterLinks(raw.footer_links)
: prev.footer_links, : prev.footer_links,
url_style: raw.url_style !== undefined ? normalizeUrlStyle(raw.url_style) : prev.url_style,
}; };
if (sameBrand(prev, next)) return; if (sameBrand(prev, next)) return;
retitle(prev, next); retitle(prev, next);

View File

@@ -48,6 +48,8 @@ import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge"; import RoleBadge from "@/components/RoleBadge";
import NotificationBell from "@/components/NotificationBell"; import NotificationBell from "@/components/NotificationBell";
import ThemeToggle from "@/components/ThemeToggle"; import ThemeToggle from "@/components/ThemeToggle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { userPath } from "@/lib/urlStyle";
interface NavItem { interface NavItem {
href: string; href: string;
@@ -294,6 +296,7 @@ function AdminRailNav({
} }
function AdminUserMenu({ user, onLogout }: { user: User; onLogout: () => void }) { function AdminUserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
const urlStyle = useUrlStyle();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@@ -375,7 +378,7 @@ function AdminUserMenu({ user, onLogout }: { user: User; onLogout: () => void })
</div> </div>
</div> </div>
<div className="p-1.5"> <div className="p-1.5">
<Link href={`/u/${user.id}`} onClick={() => setOpen(false)} className={itemCls} role="menuitem"> <Link href={userPath(urlStyle, user.id)} onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<UserIcon size={15} style={{ color: "var(--ink-3)" }} /> 个人主页 <UserIcon size={15} style={{ color: "var(--ink-3)" }} /> 个人主页
</Link> </Link>
<Link href="/settings" onClick={() => setOpen(false)} className={itemCls} role="menuitem"> <Link href="/settings" onClick={() => setOpen(false)} className={itemCls} role="menuitem">

View File

@@ -24,10 +24,13 @@ import LevelBadge from "@/components/LevelBadge";
import BadgeChip from "@/components/BadgeChip"; import BadgeChip from "@/components/BadgeChip";
import { PulseCheckin } from "./CheckinCard"; import { PulseCheckin } from "./CheckinCard";
import { formatCompact } from "@/lib/format"; import { formatCompact } from "@/lib/format";
import { getPublicSettingsCached } from "@/lib/serverData";
import { postPath, userPath } from "@/lib/urlStyle";
/* ---------- 1. 板块管理员 ---------- */ /* ---------- 1. 板块管理员 ---------- */
export function Moderators({ items }: { items: BoardModerator[] }) { export async function Moderators({ items }: { items: BoardModerator[] }) {
const urlStyle = (await getPublicSettingsCached()).url_style;
return ( return (
<section className="j13-module" aria-label="板块管理员"> <section className="j13-module" aria-label="板块管理员">
<p className="j13-module-title mb-2"> <p className="j13-module-title mb-2">
@@ -41,7 +44,7 @@ export function Moderators({ items }: { items: BoardModerator[] }) {
const name = u.nickname || u.username; const name = u.nickname || u.username;
return ( return (
<li key={u.id}> <li key={u.id}>
<Link href={`/u/${u.id}`} className="j13-raillink" data-tip={name}> <Link href={userPath(urlStyle, u.id)} className="j13-raillink" data-tip={name}>
<Avatar name={name} src={u.avatar || undefined} size={30} /> <Avatar name={name} src={u.avatar || undefined} size={30} />
<span className="min-w-0 truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}> <span className="min-w-0 truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}>
{name} {name}
@@ -163,7 +166,8 @@ export function BoardPulse({
/* ---------- 4. 本板热门 ---------- */ /* ---------- 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 ( return (
<section className="j13-module" aria-label="本板热门"> <section className="j13-module" aria-label="本板热门">
<p className="j13-module-title mb-1.5"> <p className="j13-module-title mb-1.5">
@@ -175,7 +179,7 @@ export function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allo
<ol className="j13-ranklist"> <ol className="j13-ranklist">
{posts.map((p, i) => ( {posts.map((p, i) => (
<li key={p.id}> <li key={p.id}>
<Link href={`/post/${p.id}`} className="j13-rank" data-tip={p.title}> <Link href={postPath(urlStyle, p.id)} className="j13-rank" data-tip={p.title}>
<span className="j13-rank-no">{i + 1}</span> <span className="j13-rank-no">{i + 1}</span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="j13-rank-title truncate"> <span className="j13-rank-title truncate">
@@ -203,7 +207,8 @@ export function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allo
/* ---------- 5. 本板活跃 ---------- */ /* ---------- 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 ( return (
<section className="j13-module" aria-label="本板活跃成员"> <section className="j13-module" aria-label="本板活跃成员">
<p className="j13-module-title mb-2"> <p className="j13-module-title mb-2">
@@ -223,7 +228,7 @@ export function BoardActive({ users, allowComments }: { users: ActiveUser[]; all
const name = u.nickname || u.username; const name = u.nickname || u.username;
return ( return (
<li key={u.id}> <li key={u.id}>
<Link href={`/u/${u.id}`} className="j13-raillink" data-tip={name}> <Link href={userPath(urlStyle, u.id)} className="j13-raillink" data-tip={name}>
<Avatar name={name} src={u.avatar || undefined} size={30} /> <Avatar name={name} src={u.avatar || undefined} size={30} />
<span className="flex-1 min-w-0"> <span className="flex-1 min-w-0">
<span <span

View File

@@ -29,6 +29,8 @@ import type {
} from "@/lib/api"; } from "@/lib/api";
import Avatar from "@/components/Avatar"; import Avatar from "@/components/Avatar";
import { AdText } from "@/components/AdText"; import { AdText } from "@/components/AdText";
import { getPublicSettingsCached } from "@/lib/serverData";
import { announcementPath, postPath, userPath, pagePath } from "@/lib/urlStyle";
import { PulseCheckin } from "./CheckinCard"; import { PulseCheckin } from "./CheckinCard";
import { FRIEND_LINKS } from "@/lib/friendLinks"; import { FRIEND_LINKS } from "@/lib/friendLinks";
import { formatCompact, formatShortDate } from "@/lib/format"; import { formatCompact, formatShortDate } from "@/lib/format";
@@ -136,7 +138,7 @@ export function PulseCard({
/* ---------- 关于本站(侧栏页目录,置于右栏最底) ---------- */ /* ---------- 关于本站(侧栏页目录,置于右栏最底) ---------- */
export function AboutSite({ export async function AboutSite({
pages, pages,
siteDescription, siteDescription,
canManagePages, canManagePages,
@@ -147,6 +149,7 @@ export function AboutSite({
}) { }) {
if (pages.length === 0 && !canManagePages) return null; if (pages.length === 0 && !canManagePages) return null;
const lead = siteDescription?.trim() ?? ""; const lead = siteDescription?.trim() ?? "";
const urlStyle = (await getPublicSettingsCached()).url_style;
return ( return (
<section className="j13-module j13-about" aria-label="关于本站"> <section className="j13-module j13-about" aria-label="关于本站">
@@ -160,7 +163,7 @@ export function AboutSite({
const Icon = pageGlyph(p.slug); const Icon = pageGlyph(p.slug);
return ( return (
<li key={p.id}> <li key={p.id}>
<Link href={`/p/${p.slug}`} className="j13-about-item"> <Link href={pagePath(urlStyle, p.slug)} className="j13-about-item">
<span className="j13-about-ico" aria-hidden="true"> <span className="j13-about-ico" aria-hidden="true">
<Icon size={15} strokeWidth={2} /> <Icon size={15} strokeWidth={2} />
</span> </span>
@@ -181,13 +184,14 @@ export function AboutSite({
/* ---------- 站点公告 ---------- */ /* ---------- 站点公告 ---------- */
export function Announcements({ export async function Announcements({
items, items,
canManageAnnouncements, canManageAnnouncements,
}: { }: {
items: AnnouncementSummary[]; items: AnnouncementSummary[];
canManageAnnouncements?: boolean; canManageAnnouncements?: boolean;
}) { }) {
const urlStyle = (await getPublicSettingsCached()).url_style;
return ( return (
<section className="j13-module" aria-label="站点公告"> <section className="j13-module" aria-label="站点公告">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@@ -218,7 +222,7 @@ export function Announcements({
{items.map((a) => ( {items.map((a) => (
<li key={a.id}> <li key={a.id}>
<Link <Link
href={`/announcement/${a.id}`} href={announcementPath(urlStyle, a.id)}
className="j13-anno-item" className="j13-anno-item"
data-tip={a.title} data-tip={a.title}
> >
@@ -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 ( return (
<section className="j13-module" aria-label="热门讨论"> <section className="j13-module" aria-label="热门讨论">
<p className="j13-module-title mb-1.5"> <p className="j13-module-title mb-1.5">
@@ -266,7 +271,7 @@ export function HotRanking({ posts, allowComments }: { posts: PostListItem[]; al
<ol className="j13-ranklist"> <ol className="j13-ranklist">
{posts.map((p, i) => ( {posts.map((p, i) => (
<li key={p.id}> <li key={p.id}>
<Link href={`/post/${p.id}`} className="j13-rank" data-tip={p.title}> <Link href={postPath(urlStyle, p.id)} className="j13-rank" data-tip={p.title}>
<span className="j13-rank-no">{i + 1}</span> <span className="j13-rank-no">{i + 1}</span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="j13-rank-title truncate"> <span className="j13-rank-title truncate">
@@ -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; if (users.length === 0) return null;
const urlStyle = (await getPublicSettingsCached()).url_style;
return ( return (
<section className="j13-module" aria-label="最新注册"> <section className="j13-module" aria-label="最新注册">
<p className="j13-module-title mb-3"> <p className="j13-module-title mb-3">
@@ -307,7 +313,7 @@ export function NewUsers({ users }: { users: NewUser[] }) {
return ( return (
<li key={u.id}> <li key={u.id}>
<Link <Link
href={`/u/${u.id}`} href={userPath(urlStyle, u.id)}
className="flex flex-col items-center gap-1.5 rounded-lg px-1 py-1 transition-colors hover:bg-[var(--item-hover-bg)] focus-visible:bg-[var(--item-hover-bg)]" className="flex flex-col items-center gap-1.5 rounded-lg px-1 py-1 transition-colors hover:bg-[var(--item-hover-bg)] focus-visible:bg-[var(--item-hover-bg)]"
> >
<Avatar name={name} src={u.avatar} size={38} /> <Avatar name={name} src={u.avatar} size={38} />
@@ -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 ( return (
<section className="j13-module" aria-label="本周活跃成员"> <section className="j13-module" aria-label="本周活跃成员">
<p className="j13-module-title mb-2"> <p className="j13-module-title mb-2">
@@ -346,7 +353,7 @@ export function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; all
const name = u.nickname || u.username; const name = u.nickname || u.username;
return ( return (
<li key={u.id}> <li key={u.id}>
<Link href={`/u/${u.id}`} className="j13-raillink" data-tip={name}> <Link href={userPath(urlStyle, u.id)} className="j13-raillink" data-tip={name}>
<Avatar name={name} src={u.avatar} size={30} /> <Avatar name={name} src={u.avatar} size={30} />
<span className="flex-1 min-w-0"> <span className="flex-1 min-w-0">
<span <span

View File

@@ -7,6 +7,8 @@ import { Bookmark, Home, ShieldCheck } from "lucide-react";
import type { BoardWithCount, User } from "@/lib/api"; import type { BoardWithCount, User } from "@/lib/api";
import { isStaff } from "@/lib/roles"; import { isStaff } from "@/lib/roles";
import BoardIcon, { boardColor } from "@/components/BoardIcon"; import BoardIcon, { boardColor } from "@/components/BoardIcon";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { userPath } from "@/lib/urlStyle";
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台 // 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
function boardHref(id: number) { function boardHref(id: number) {
@@ -23,6 +25,7 @@ export function WorkspaceNavPanel({
user?: User | null; user?: User | null;
}) { }) {
const params = useSearchParams(); const params = useSearchParams();
const urlStyle = useUrlStyle();
const activeBoard = Number(params.get("board")) || 0; const activeBoard = Number(params.get("board")) || 0;
const showAdmin = !!user && isStaff(user.role); const showAdmin = !!user && isStaff(user.role);
@@ -46,7 +49,7 @@ export function WorkspaceNavPanel({
</Link> </Link>
{user ? ( {user ? (
<Link href={`/u/${user.id}?tab=favorites`} className="j13-bcard"> <Link href={`${userPath(urlStyle, user.id)}?tab=favorites`} className="j13-bcard">
<span <span
className="j13-bcard-ico" className="j13-bcard-ico"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }} style={{ background: "var(--accent-soft)", color: "var(--accent)" }}

View File

@@ -9,6 +9,8 @@ import { apiUnlockPost, apiUnlockPostPassword } from "@/lib/api";
import { CSRF_COOKIE } from "@/lib/cookies"; import { CSRF_COOKIE } from "@/lib/cookies";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { hideKindLabel, type HideKind } from "@/lib/hideBlocks"; import { hideKindLabel, type HideKind } from "@/lib/hideBlocks";
import { postPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
/** 若浏览器尚无 CSRF cookie,请求公开接口以触发后端签发 */ /** 若浏览器尚无 CSRF cookie,请求公开接口以触发后端签发 */
async function ensureGuestCSRF() { async function ensureGuestCSRF() {
@@ -44,6 +46,7 @@ export default function MdHideBlock({
children, children,
}: MdHideBlockProps) { }: MdHideBlockProps) {
const router = useRouter(); const router = useRouter();
const urlStyle = useUrlStyle();
const pwdErrId = useId(); const pwdErrId = useId();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [pwd, setPwd] = useState(""); const [pwd, setPwd] = useState("");
@@ -51,7 +54,9 @@ export default function MdHideBlock({
const label = hideKindLabel(kind); const label = hideKindLabel(kind);
const loginTo = const loginTo =
loginHref || loginHref ||
(postId ? `/login?redirect=${encodeURIComponent(`/post/${postId}`)}` : "/login"); (postId
? `/login?redirect=${encodeURIComponent(postPath(urlStyle, postId))}`
: "/login");
if (locked) { if (locked) {
const unlockPoints = async () => { const unlockPoints = async () => {
@@ -145,7 +150,7 @@ export default function MdHideBlock({
<Link <Link
href={ href={
postId postId
? `/login?redirect=${encodeURIComponent(`/post/${postId}#comment-input`)}` ? `/login?redirect=${encodeURIComponent(`${postPath(urlStyle, postId)}#comment-input`)}`
: loginTo : loginTo
} }
className="btn btn-primary" className="btn btn-primary"

View File

@@ -6,6 +6,8 @@ import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge"; import RoleBadge from "@/components/RoleBadge";
import LevelBadge from "@/components/LevelBadge"; import LevelBadge from "@/components/LevelBadge";
import BadgeChip from "@/components/BadgeChip"; import BadgeChip from "@/components/BadgeChip";
import { getPublicSettingsCached } from "@/lib/serverData";
import { postPath, userPath } from "@/lib/urlStyle";
// formatYMD 帖子详情页左栏「作者其他帖」日期 // formatYMD 帖子详情页左栏「作者其他帖」日期
function formatYMD(dateStr: string) { function formatYMD(dateStr: string) {
@@ -54,7 +56,7 @@ export function BoardCard({ board }: { board: Board }) {
); );
} }
export function AuthorRailCard({ export async function AuthorRailCard({
profile, profile,
allowComments, allowComments,
boardNames, boardNames,
@@ -66,6 +68,7 @@ export function AuthorRailCard({
boardNames?: Record<number, string>; boardNames?: Record<number, string>;
boardColors?: Record<number, number>; boardColors?: Record<number, number>;
}) { }) {
const urlStyle = (await getPublicSettingsCached()).url_style;
const u = profile.user; const u = profile.user;
const signature = (u.signature || "").trim(); const signature = (u.signature || "").trim();
const { post_count, comment_count, points, streak } = profile.stats; const { post_count, comment_count, points, streak } = profile.stats;
@@ -80,13 +83,13 @@ export function AuthorRailCard({
<User size={15} style={{ color: "var(--accent)" }} /> 作者 <User size={15} style={{ color: "var(--accent)" }} /> 作者
</p> </p>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link href={`/u/${u.id}`} className="shrink-0" aria-label={u.nickname}> <Link href={userPath(urlStyle, u.id)} className="shrink-0" aria-label={u.nickname}>
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={48} /> <Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={48} />
</Link> </Link>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-1.5 min-w-0"> <div className="flex items-center gap-1.5 min-w-0">
<Link <Link
href={`/u/${u.id}`} href={userPath(urlStyle, u.id)}
className="block font-semibold text-[14.5px] truncate hover:text-[var(--accent)] transition-colors" className="block font-semibold text-[14.5px] truncate hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }} style={{ color: "var(--ink)" }}
> >
@@ -160,15 +163,16 @@ export function AuthorRailCard({
<Flame size={12.5} /> 连签 {streak} 天 <Flame size={12.5} /> 连签 {streak} 天
</p> </p>
)} )}
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm w-full justify-center mt-3"> <Link href={userPath(urlStyle, u.id)} className="btn btn-line btn-sm w-full justify-center mt-3">
访问主页 访问主页
</Link> </Link>
</section> </section>
); );
} }
export function AuthorPosts({ posts }: { posts: PostListItem[] }) { export async function AuthorPosts({ posts }: { posts: PostListItem[] }) {
if (posts.length === 0) return null; if (posts.length === 0) return null;
const urlStyle = (await getPublicSettingsCached()).url_style;
return ( return (
<section className="j13-module flex min-h-0 flex-col" aria-labelledby="author-posts-title"> <section className="j13-module flex min-h-0 flex-col" aria-labelledby="author-posts-title">
<p className="j13-module-title mb-1 shrink-0" id="author-posts-title"> <p className="j13-module-title mb-1 shrink-0" id="author-posts-title">
@@ -178,7 +182,7 @@ export function AuthorPosts({ posts }: { posts: PostListItem[] }) {
{posts.map((p) => ( {posts.map((p) => (
<li key={p.id}> <li key={p.id}>
<Link <Link
href={`/post/${p.id}`} href={postPath(urlStyle, p.id)}
data-tip={p.title} data-tip={p.title}
className="block rounded-[10px] px-2 py-2 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]" className="block rounded-[10px] px-2 py-2 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
> >

View File

@@ -2,9 +2,11 @@ import Link from "next/link";
import { Megaphone, Pin } from "lucide-react"; import { Megaphone, Pin } from "lucide-react";
import type { AnnouncementSummary } from "@/lib/api"; import type { AnnouncementSummary } from "@/lib/api";
import { formatShortDate } from "@/lib/format"; import { formatShortDate } from "@/lib/format";
import { getPublicSettingsCached } from "@/lib/serverData";
import { announcementPath } from "@/lib/urlStyle";
/** 近期公告目录(高亮当前 id) */ /** 近期公告目录(高亮当前 id) */
export default function SiteDocAnnoNav({ export default async function SiteDocAnnoNav({
items, items,
currentId, currentId,
showAllLink = true, showAllLink = true,
@@ -13,6 +15,7 @@ export default function SiteDocAnnoNav({
currentId?: number; currentId?: number;
showAllLink?: boolean; showAllLink?: boolean;
}) { }) {
const urlStyle = (await getPublicSettingsCached()).url_style;
return ( return (
<section className="j13-module flex min-h-0 flex-col" aria-label="近期公告"> <section className="j13-module flex min-h-0 flex-col" aria-label="近期公告">
<div className="flex items-center justify-between gap-2 shrink-0"> <div className="flex items-center justify-between gap-2 shrink-0">
@@ -37,7 +40,7 @@ export default function SiteDocAnnoNav({
return ( return (
<li key={a.id}> <li key={a.id}>
<Link <Link
href={`/announcement/${a.id}`} href={announcementPath(urlStyle, a.id)}
className="j13-anno-item" className="j13-anno-item"
data-tip={a.title} data-tip={a.title}
aria-current={current ? "page" : undefined} aria-current={current ? "page" : undefined}

View File

@@ -2,10 +2,12 @@ import Link from "next/link";
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import type { SidebarPageItem } from "@/lib/api"; import type { SidebarPageItem } from "@/lib/api";
import { pageGlyph } from "@/lib/pageGlyph"; import { pageGlyph } from "@/lib/pageGlyph";
import { getPublicSettingsCached } from "@/lib/serverData";
import { pagePath } from "@/lib/urlStyle";
import AboutSiteHeader from "./AboutSiteHeader"; import AboutSiteHeader from "./AboutSiteHeader";
/** 侧栏单页兄弟目录(高亮当前 slug);标题栏与首页「关于本站」一致 */ /** 侧栏单页兄弟目录(高亮当前 slug);标题栏与首页「关于本站」一致 */
export default function SiteDocPageNav({ export default async function SiteDocPageNav({
pages, pages,
currentSlug, currentSlug,
title = "文档目录", title = "文档目录",
@@ -17,6 +19,7 @@ export default function SiteDocPageNav({
canManage?: boolean; canManage?: boolean;
}) { }) {
if (pages.length === 0 && !canManage) return null; if (pages.length === 0 && !canManage) return null;
const urlStyle = (await getPublicSettingsCached()).url_style;
return ( return (
<section className="j13-module j13-about flex min-h-0 flex-col" aria-label={title}> <section className="j13-module j13-about flex min-h-0 flex-col" aria-label={title}>
@@ -31,7 +34,7 @@ export default function SiteDocPageNav({
return ( return (
<li key={p.id}> <li key={p.id}>
<Link <Link
href={`/p/${p.slug}`} href={pagePath(urlStyle, p.slug)}
className="j13-about-item" className="j13-about-item"
aria-current={current ? "page" : undefined} aria-current={current ? "page" : undefined}
style={ style={

View File

@@ -13,6 +13,7 @@ import {
type FooterLinksAlign, type FooterLinksAlign,
} from "./brand"; } from "./brand";
import { DEFAULT_LEVEL_DEFS, LEVEL_COLOR_KEYS, MAX_LEVEL_COUNT, type LevelDef } from "./levels"; import { DEFAULT_LEVEL_DEFS, LEVEL_COLOR_KEYS, MAX_LEVEL_COUNT, type LevelDef } from "./levels";
import { normalizeUrlStyle, type UrlStyle } from "./urlStyle";
import { CSRF_COOKIE, hasAuthCookieHint } from "./cookies"; import { CSRF_COOKIE, hasAuthCookieHint } from "./cookies";
import { publishPublicMetaSnapshot } from "./publicMetaSnapshot"; import { publishPublicMetaSnapshot } from "./publicMetaSnapshot";
import { emitForceLogout } from "./userEvents"; import { emitForceLogout } from "./userEvents";
@@ -1009,6 +1010,8 @@ export interface PublicSettings {
levels: LevelDef[]; levels: LevelDef[];
/** 等级特效动画总开关(流光动画);缺省开 */ /** 等级特效动画总开关(流光动画);缺省开 */
levels_fx: boolean; levels_fx: boolean;
/** 详情页伪静态风格;缺省 default(标准 /post/123) */
url_style: UrlStyle;
} }
/** 与后端 DefaultAttachmentExts 保持同步 */ /** 与后端 DefaultAttachmentExts 保持同步 */
@@ -1073,6 +1076,7 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
necro_reply_penalty: 0, necro_reply_penalty: 0,
levels: [...DEFAULT_LEVEL_DEFS], levels: [...DEFAULT_LEVEL_DEFS],
levels_fx: true, levels_fx: true,
url_style: "default",
}; };
function normalizeAttachmentExts(raw: unknown): string[] { function normalizeAttachmentExts(raw: unknown): string[] {
@@ -1160,6 +1164,7 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
bg_admin_mode: typeof data?.bg_admin_mode === "string" ? data.bg_admin_mode : "cover", bg_admin_mode: typeof data?.bg_admin_mode === "string" ? data.bg_admin_mode : "cover",
...normalizePointsSettings(data), ...normalizePointsSettings(data),
...normalizeLockSettings(data), ...normalizeLockSettings(data),
url_style: normalizeUrlStyle(data?.url_style),
levels: normalizeLevels(data?.levels), levels: normalizeLevels(data?.levels),
levels_fx: data?.levels_fx !== false, levels_fx: data?.levels_fx !== false,
}; };
@@ -3361,6 +3366,7 @@ export type UpdateSiteSettingsBody = {
footer_links?: FooterLink[]; footer_links?: FooterLink[];
allow_register?: boolean; allow_register?: boolean;
allow_comments?: boolean; allow_comments?: boolean;
url_style?: UrlStyle;
comments_require_login?: boolean; comments_require_login?: boolean;
allow_messages?: boolean; allow_messages?: boolean;
post_cooldown_hours?: number; post_cooldown_hours?: number;

View File

@@ -1,4 +1,5 @@
import { absoluteCanonical, resolveSiteOrigin, type QueryBag } from "./siteUrl.ts"; import { absoluteCanonical, resolveSiteOrigin, type QueryBag } from "./siteUrl.ts";
import { postPath, userPath, type UrlStyle } from "./urlStyle.ts";
/** 防止用户正文里的 </script> 提前闭合 JSON-LD 脚本 */ /** 防止用户正文里的 </script> 提前闭合 JSON-LD 脚本 */
export function serializeJsonLd(data: unknown): string { export function serializeJsonLd(data: unknown): string {
@@ -15,8 +16,8 @@ export type JsonLdPerson = {
name: string; name: string;
}; };
function personNode(person: JsonLdPerson) { function personNode(person: JsonLdPerson, urlStyle: UrlStyle = "default") {
const url = person.id ? absoluteCanonical(`/u/${person.id}`) : undefined; const url = person.id ? absoluteCanonical(userPath(urlStyle, person.id)) : undefined;
return { return {
"@type": "Person" as const, "@type": "Person" as const,
name: person.name, name: person.name,
@@ -52,7 +53,9 @@ export function listingJsonLd(input: {
items: { id: number; name: string }[]; items: { id: number; name: string }[];
page: number; page: number;
pageSize: number; pageSize: number;
urlStyle?: UrlStyle;
}): Record<string, unknown> | null { }): Record<string, unknown> | null {
const urlStyle = input.urlStyle ?? "default";
const site = websiteNode(input.siteName, input.siteDescription); const site = websiteNode(input.siteName, input.siteDescription);
const pageUrl = absoluteCanonical(input.path, input.query); const pageUrl = absoluteCanonical(input.path, input.query);
const origin = resolveSiteOrigin(); const origin = resolveSiteOrigin();
@@ -75,7 +78,7 @@ export function listingJsonLd(input: {
"@type": "ListItem", "@type": "ListItem",
position: (input.page - 1) * input.pageSize + i + 1, position: (input.page - 1) * input.pageSize + i + 1,
name: item.name, name: item.name,
url: `${origin}/post/${item.id}`, url: `${origin}${postPath(urlStyle, item.id)}`,
})), })),
}, },
}, },
@@ -105,8 +108,10 @@ export function questionJsonLd(input: {
pageUrl?: string; pageUrl?: string;
accepted?: JsonLdVisibleComment | null; accepted?: JsonLdVisibleComment | null;
answers: JsonLdVisibleComment[]; answers: JsonLdVisibleComment[];
urlStyle?: UrlStyle;
}): Record<string, unknown> | null { }): Record<string, unknown> | null {
const entityUrl = absoluteCanonical(`/post/${input.postId}`); const urlStyle = input.urlStyle ?? "default";
const entityUrl = absoluteCanonical(postPath(urlStyle, input.postId));
const origin = resolveSiteOrigin(); const origin = resolveSiteOrigin();
const site = websiteId(); const site = websiteId();
if (!entityUrl || !origin || !site) return null; if (!entityUrl || !origin || !site) return null;
@@ -120,7 +125,7 @@ export function questionJsonLd(input: {
text: a.content, text: a.content,
dateCreated: a.created_at, dateCreated: a.created_at,
url: commentUrl(entityUrl, a.id), url: commentUrl(entityUrl, a.id),
...(a.author ? { author: personNode(a.author) } : {}), ...(a.author ? { author: personNode(a.author, urlStyle) } : {}),
}); });
return { return {
"@context": "https://schema.org", "@context": "https://schema.org",
@@ -137,7 +142,7 @@ export function questionJsonLd(input: {
...(input.text ? { text: input.text } : {}), ...(input.text ? { text: input.text } : {}),
dateCreated: input.createdAt, dateCreated: input.createdAt,
...(input.updatedAt ? { dateModified: input.updatedAt } : {}), ...(input.updatedAt ? { dateModified: input.updatedAt } : {}),
author: personNode(input.author), author: personNode(input.author, urlStyle),
...(input.accepted && input.accepted.content.trim() ...(input.accepted && input.accepted.content.trim()
? { acceptedAnswer: answerNode(input.accepted) } ? { acceptedAnswer: answerNode(input.accepted) }
: {}), : {}),
@@ -158,8 +163,10 @@ export function discussionJsonLd(input: {
commentCount?: number; commentCount?: number;
viewCount?: number; viewCount?: number;
comments: JsonLdVisibleComment[]; comments: JsonLdVisibleComment[];
urlStyle?: UrlStyle;
}): Record<string, unknown> | null { }): Record<string, unknown> | null {
const entityUrl = absoluteCanonical(`/post/${input.postId}`); const urlStyle = input.urlStyle ?? "default";
const entityUrl = absoluteCanonical(postPath(urlStyle, input.postId));
const site = websiteId(); const site = websiteId();
if (!entityUrl || !site) return null; if (!entityUrl || !site) return null;
const pageUrl = input.pageUrl || entityUrl; const pageUrl = input.pageUrl || entityUrl;
@@ -174,7 +181,7 @@ export function discussionJsonLd(input: {
datePublished: input.createdAt, datePublished: input.createdAt,
...(input.updatedAt ? { dateModified: input.updatedAt } : {}), ...(input.updatedAt ? { dateModified: input.updatedAt } : {}),
inLanguage: "zh-CN", inLanguage: "zh-CN",
author: personNode(input.author), author: personNode(input.author, urlStyle),
isPartOf: { "@id": site }, isPartOf: { "@id": site },
...(pageUrl !== entityUrl ...(pageUrl !== entityUrl
? { ? {
@@ -202,7 +209,7 @@ export function discussionJsonLd(input: {
text: c.content, text: c.content,
dateCreated: c.created_at, dateCreated: c.created_at,
url: commentUrl(entityUrl, c.id), 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; username: string;
signature?: string; signature?: string;
pageUrl?: string; pageUrl?: string;
urlStyle?: UrlStyle;
}): Record<string, unknown> | null { }): Record<string, unknown> | null {
const personUrl = absoluteCanonical(`/u/${input.id}`); const personUrl = absoluteCanonical(userPath(input.urlStyle ?? "default", input.id));
if (!personUrl) return null; if (!personUrl) return null;
const pageUrl = input.pageUrl || personUrl; const pageUrl = input.pageUrl || personUrl;
return { return {

View File

@@ -61,6 +61,7 @@ export interface RtSettingsData {
footer_links?: { label: string; url: string; new_tab: boolean; align?: string }[]; footer_links?: { label: string; url: string; new_tab: boolean; align?: string }[];
allow_register?: boolean; allow_register?: boolean;
allow_comments?: boolean; allow_comments?: boolean;
url_style?: string;
comments_require_login?: boolean; comments_require_login?: boolean;
allow_messages?: boolean; allow_messages?: boolean;
post_cooldown_hours?: number; post_cooldown_hours?: number;

View File

@@ -1,13 +1,13 @@
/** 单页公开路径。前台路由是 /p/[slug],列表、复制和线上查看必须用同一条。 */ import { pagePath, type UrlStyle } from "./urlStyle.ts";
export function sitePagePublicPath(slug: string): string {
const clean = slug.trim(); /** 单页公开路径。前台路由是 /p/[slug](伪静态风格跟随站点设置),列表、复制和线上查看必须用同一条。 */
if (!clean) return "/p/"; export function sitePagePublicPath(slug: string, style: UrlStyle = "default"): string {
return `/p/${encodeURIComponent(clean)}`; return pagePath(style, slug);
} }
/** 用当前页面的 origin 拼绝对地址,不写死域名。origin 为空时只返回路径。 */ /** 用当前页面的 origin 拼绝对地址,不写死域名。origin 为空时只返回路径。 */
export function sitePagePublicURL(origin: string, slug: string): string { export function sitePagePublicURL(origin: string, slug: string, style: UrlStyle = "default"): string {
const path = sitePagePublicPath(slug); const path = sitePagePublicPath(slug, style);
const base = origin.replace(/\/$/, ""); const base = origin.replace(/\/$/, "");
return base ? `${base}${path}` : path; return base ? `${base}${path}` : path;
} }

View File

@@ -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);
});
});

192
frontend/lib/urlStyle.ts Normal file
View File

@@ -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<Exclude<UrlStyle, "default">, Record<EntityKind, (id: number) => 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<EntityKind, (id: number) => string> = {
post: (id) => `/post/${id}`,
user: (id) => `/u/${id}`,
announcement: (id) => `/announcement/${id}`,
};
const PAGE_TEMPLATES: Record<Exclude<UrlStyle, "default">, (slug: string) => string> = {
html: (slug) => `/p-${slug}.html`,
discuz: (slug) => `/page-${slug}.html`,
};
const LEADERBOARD_TEMPLATES: Record<Exclude<UrlStyle, "default">, 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<UrlStyle, "default">; kind: EntityKind; id: number }
| { style: Exclude<UrlStyle, "default">; kind: "page"; slug: string }
| { style: Exclude<UrlStyle, "default">; kind: "leaderboard" };
// 精确锚定:id 仅纯数字,天然排除 /post/123/edit 这类子路径与伪造路径;
// page 的 slug 捕获保持 URL 原样(encoded),rewrite 落点直接复用
const PRETTY_PATTERNS: { re: RegExp; style: Exclude<UrlStyle, "default">; 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;
}
}

View File

@@ -1,5 +1,16 @@
import { NextResponse, type NextRequest } from "next/server"; import { NextResponse, type NextRequest } from "next/server";
import { TOKEN_COOKIE, REFRESH_COOKIE, CSRF_COOKIE } from "@/lib/cookies"; 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 登录态保障: // SSR 登录态保障:
// access token(j13_token,15 分钟)过期后,页面/RSC 请求到达时先在此静默轮转, // 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()]; return [pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()];
} }
/** 透传 pathname 给根布局,用于 /admin 与公开站 chrome 分叉 */ /** 透传 pathname 给根布局,用于 /admin 与公开站 chrome 分叉;styleRewrite 时透传改写后的内部路径 */
function withPathname(req: NextRequest, init?: { request?: { headers: Headers } }) { function withPathname(req: NextRequest, init?: { request?: { headers: Headers } }, styleRewrite?: URL) {
const headers = new Headers(init?.request?.headers ?? req.headers); const headers = new Headers(init?.request?.headers ?? req.headers);
headers.set("x-pathname", req.nextUrl.pathname); headers.set("x-pathname", (styleRewrite ?? req.nextUrl).pathname);
return NextResponse.next({ request: { headers } }); const res = styleRewrite
? NextResponse.rewrite(styleRewrite, { request: { headers } })
: NextResponse.next({ request: { headers } });
return res;
} }
// RFC 9309 规定爬虫文件必须是小写 /robots.txt。部分 SEO 检测工具会请求 // RFC 9309 规定爬虫文件必须是小写 /robots.txt。部分 SEO 检测工具会请求
@@ -147,16 +161,91 @@ function rewriteRobotsCase(req: NextRequest): NextResponse | null {
return NextResponse.rewrite(url); 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) { async function sessionMiddleware(req: NextRequest) {
const robotsRewrite = rewriteRobotsCase(req); const robotsRewrite = rewriteRobotsCase(req);
if (robotsRewrite) return robotsRewrite; 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 refreshToken = req.cookies.get(REFRESH_COOKIE)?.value;
const accessToken = req.cookies.get(TOKEN_COOKIE)?.value; const accessToken = req.cookies.get(TOKEN_COOKIE)?.value;
// 游客、access 仍有效、或服务端地址未配置:直接放行 // 游客、access 仍有效、或服务端地址未配置:直接放行
if (!refreshToken || !isAccessTokenExpired(accessToken) || !API_BASE) { if (!refreshToken || !isAccessTokenExpired(accessToken) || !API_BASE) {
return withPathname(req); return withPathname(req, undefined, styleRewrite);
} }
const csrf = req.cookies.get(CSRF_COOKIE)?.value ?? ""; const csrf = req.cookies.get(CSRF_COOKIE)?.value ?? "";
@@ -175,7 +264,7 @@ async function sessionMiddleware(req: NextRequest) {
const headers = new Headers(req.headers); const headers = new Headers(req.headers);
headers.set("Cookie", [...jar].map(([k, v]) => `${k}=${v}`).join("; ")); 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 等属性全部以后端为准 // 原样透传,HttpOnly/Path/SameSite/Secure 等属性全部以后端为准
for (const sc of setCookies) res.headers.append("Set-Cookie", sc); for (const sc of setCookies) res.headers.append("Set-Cookie", sc);
return res; return res;
@@ -183,7 +272,7 @@ async function sessionMiddleware(req: NextRequest) {
if (setCookies.length > 0) { if (setCookies.length > 0) {
// refresh 已失效:透传后端的清 cookie 指令,避免之后每次请求都白轮转 // refresh 已失效:透传后端的清 cookie 指令,避免之后每次请求都白轮转
const res = withPathname(req); const res = withPathname(req, undefined, styleRewrite);
for (const sc of setCookies) res.headers.append("Set-Cookie", sc); for (const sc of setCookies) res.headers.append("Set-Cookie", sc);
return res; return res;
} }
@@ -191,7 +280,7 @@ async function sessionMiddleware(req: NextRequest) {
// 后端不可达:降级匿名渲染,客户端 fetchWithRefresh 仍可兜底 // 后端不可达:降级匿名渲染,客户端 fetchWithRefresh 仍可兜底
} }
return withPathname(req); return withPathname(req, undefined, styleRewrite);
} }
export const config = { export const config = {
@@ -219,6 +308,7 @@ type SiteMaintState = {
retry_after: number; retry_after: number;
}; };
bypass?: boolean; bypass?: boolean;
url_style?: string;
}; };
// 维护态短缓存:Next 每次页面/RSC/预取都会进 middleware,开发态尤其密。 // 维护态短缓存:Next 每次页面/RSC/预取都会进 middleware,开发态尤其密。

View File

@@ -7,7 +7,7 @@
"build": "next build", "build": "next build",
"start": "next start -p 3000", "start": "next start -p 3000",
"lint": "next lint", "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": { "dependencies": {
"hast-util-to-jsx-runtime": "^2.3.6", "hast-util-to-jsx-runtime": "^2.3.6",