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

@@ -26,6 +26,7 @@ import {
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { authCookieHeader } from "@/lib/cookies";
import { requestCanonical } from "@/lib/canonicalMeta";
import { userPath, leaderboardPath, type UrlStyle } from "@/lib/urlStyle";
import Avatar from "@/components/Avatar";
import LevelBadge from "@/components/LevelBadge";
@@ -55,12 +56,13 @@ function normMetric(v?: string): LeaderboardMetric {
}
/** 榜单链接:默认参数省略,保持 URL 干净 */
function lbHref(period: LeaderboardPeriod, metric: LeaderboardMetric): string {
function lbHref(urlStyle: UrlStyle, period: LeaderboardPeriod, metric: LeaderboardMetric): string {
const q = new URLSearchParams();
if (period !== "all") q.set("period", period);
if (metric !== "all") q.set("metric", metric);
const s = q.toString();
return s ? `/leaderboard?${s}` : "/leaderboard";
const base = leaderboardPath(urlStyle);
return s ? `${base}?${s}` : base;
}
/** 右侧分数标签:总榜-全部=累计积分,周/月-全部=贡献值,细分=对应指标名 */
@@ -75,7 +77,7 @@ export async function generateMetadata({ searchParams }: PageProps): Promise<Met
return {
title: "排行榜",
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;
place: 1 | 2 | 3;
period: LeaderboardPeriod;
metric: LeaderboardMetric;
urlStyle: UrlStyle;
}) {
const m = METAL[place];
const first = place === 1;
return (
<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 ${
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 名起的列表行 */
function RankRow({ e, period, metric }: {
function RankRow({ e, period, metric, urlStyle }: {
e: LeaderboardEntry;
period: LeaderboardPeriod;
metric: LeaderboardMetric;
urlStyle: UrlStyle;
}) {
return (
<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)]"
style={{ borderTop: "1px solid var(--line)" }}
>
@@ -419,6 +423,7 @@ export default async function LeaderboardPage({ searchParams }: PageProps) {
const metric = normMetric(sp.metric);
const cookie = authCookieHeader(await cookies());
const settings = await getPublicSettingsCached();
const urlStyle = settings.url_style;
const [meUser] = await Promise.all([getMeCached(cookie || undefined)]);
const authed = !!meUser;
@@ -506,7 +511,7 @@ export default async function LeaderboardPage({ searchParams }: PageProps) {
{PERIODS.map((p) => (
<Link
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"
style={
period === p.key
@@ -529,7 +534,7 @@ export default async function LeaderboardPage({ searchParams }: PageProps) {
return (
<Link
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"
style={
active
@@ -571,15 +576,15 @@ export default async function LeaderboardPage({ searchParams }: PageProps) {
<>
{/* 领奖台:DOM 顺序 2-1-3,冠军居中放大 */}
<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[0] && <PodiumCard e={podium[0]} place={1} period={period} metric={metric} />}
{podium[2] && <PodiumCard e={podium[2]} place={3} 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} urlStyle={urlStyle} />}
{podium[2] && <PodiumCard e={podium[2]} place={3} period={period} metric={metric} urlStyle={urlStyle} />}
</div>
{rest.length > 0 && (
<div className="panel overflow-hidden mt-4">
{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>
)}