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

@@ -18,6 +18,7 @@ import {
SiteDocAnnoNav,
} from "@/components/site-doc";
import { requestCanonical } from "@/lib/canonicalMeta";
import { pagePath, type UrlStyle } from "@/lib/urlStyle";
export async function generateMetadata(): Promise<Metadata> {
return {
@@ -30,14 +31,16 @@ export async function generateMetadata(): Promise<Metadata> {
function PageRow({
p,
showDivider,
urlStyle,
}: {
p: SitePageSummary;
showDivider: boolean;
urlStyle: UrlStyle;
}) {
const Icon = pageGlyph(p.slug);
return (
<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)]"
style={showDivider ? { borderTop: "1px solid var(--line)" } : undefined}
>
@@ -90,6 +93,7 @@ export default async function AboutIndexPage() {
]);
const announcements = overview?.announcements ?? [];
const siteLead = settings.site_description?.trim() || undefined;
const urlStyle = settings.url_style;
const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
const left = <SiteDocIdentity kind="about" lead={siteLead} />;
@@ -146,7 +150,7 @@ export default async function AboutIndexPage() {
<p className="meta mt-1.5">有新文档时会显示在这里</p>
</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>
</SiteDocShell>

View File

@@ -10,7 +10,9 @@ import {
} from "@/lib/api";
import { postTypeLabel } from "@/lib/postMeta";
import { formatRelative } from "@/lib/format";
import { postPath, userPath } from "@/lib/urlStyle";
import Avatar from "@/components/Avatar";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { AdminSegmented } from "@/components/admin";
import {
AdminChartCard,
@@ -67,6 +69,7 @@ export default function DashboardClient({
totalUsers?: number;
canUsers: boolean;
}) {
const urlStyle = useUrlStyle();
const [range, setRange] = useState<RangeKey>("7d");
const [data, setData] = useState(initialAnalytics);
const [error, setError] = useState<string | null>(null);
@@ -162,7 +165,7 @@ export default function DashboardClient({
newUsers.slice(0, 8).map((u) => (
<Link
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)]"
style={{ borderTop: "1px solid var(--line)" }}
>
@@ -213,7 +216,7 @@ export default function DashboardClient({
data.top_viewed.map((p, i) => (
<Link
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)]"
style={{ borderTop: "1px solid var(--line)" }}
>

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ import {
type SitePageInput,
} from "@/lib/api";
import { sitePagePublicPath, sitePagePublicURL } from "@/lib/sitePagePath";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { toast } from "@/lib/toast";
import MarkdownEditor from "@/components/MarkdownEditor";
import { AdminField, AdminSegmented, AdminStatusChip, AdminSwitch } from "@/components/admin";
@@ -129,6 +130,7 @@ export default function PageComposeClient({
}) {
const router = useRouter();
const searchParams = useSearchParams();
const urlStyle = useUrlStyle();
const status = readStatus(searchParams.get("status"));
const backHref = listHref(status);
const loaded = useMemo(() => toForm(initial), [initial]);
@@ -160,7 +162,7 @@ export default function PageComposeClient({
};
const slugPreview = normalizeSlug(form.slug);
const publicPath = sitePagePublicPath(slugPreview);
const publicPath = sitePagePublicPath(slugPreview, urlStyle);
const canViewOnline = savedPage?.published === true;
async function commit() {
@@ -252,7 +254,7 @@ export default function PageComposeClient({
setErrors((prev) => ({ ...prev, slug: nextErrors.slug }));
return;
}
const url = sitePagePublicURL(origin || window.location.origin, slug);
const url = sitePagePublicURL(origin || window.location.origin, slug, urlStyle);
try {
await navigator.clipboard.writeText(url);
toast("已复制访问地址", "ok");
@@ -280,7 +282,7 @@ export default function PageComposeClient({
}
if (pendingSave?.slugChange) {
confirmBits.push(
`访问地址将从 ${sitePagePublicPath(baseline.slug)} 改为 ${sitePagePublicPath(normalizeSlug(form.slug))}。系统不会把旧地址转到新地址,已经分享的旧链接会打不开。`
`访问地址将从 ${sitePagePublicPath(baseline.slug, urlStyle)} 改为 ${sitePagePublicPath(normalizeSlug(form.slug), urlStyle)}。系统不会把旧地址转到新地址,已经分享的旧链接会打不开。`
);
}
@@ -304,7 +306,7 @@ export default function PageComposeClient({
<div className="admin-doc-sticky-actions">
{canViewOnline && savedPage ? (
<a
href={sitePagePublicPath(savedPage.slug)}
href={sitePagePublicPath(savedPage.slug, urlStyle)}
target="_blank"
rel="noopener noreferrer"
className="btn btn-line btn-sm"
@@ -413,7 +415,7 @@ export default function PageComposeClient({
</div>
<p className="admin-doc-hint" id="page-slug-hint">
完整路径 {publicPath || "/p/"}。小写字母、数字和连字符,最多 64 字符。
{origin && slugPreview ? ` 线上地址 ${sitePagePublicURL(origin, slugPreview)}` : ""}
{origin && slugPreview ? ` 线上地址 ${sitePagePublicURL(origin, slugPreview, urlStyle)}` : ""}
</p>
{isEdit && baseline.published && slugPreview !== baseline.slug ? (
<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 { formatDateTime, formatRelative } from "@/lib/format";
import { sitePagePublicPath } from "@/lib/sitePagePath";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import {
@@ -50,6 +51,7 @@ export default function PagesAdmin({
}) {
const router = useRouter();
const searchParams = useSearchParams();
const urlStyle = useUrlStyle();
const statusFilter = readStatus(searchParams.get("status"));
const [list, setList] = useState(initial);
const [loadError] = useState(initError);
@@ -166,7 +168,7 @@ export default function PagesAdmin({
<tbody>
{filtered.map((p) => {
const updated = p.updated_at || "";
const path = sitePagePublicPath(p.slug);
const path = sitePagePublicPath(p.slug, urlStyle);
return (
<tr key={p.id}>
<td data-label="页面">

View File

@@ -9,6 +9,8 @@ import { pointReasonLabel } from "@/lib/points";
import { formatRelative } from "@/lib/format";
import { AdminPageHeader, AdminSegmented, AdminStatCard } from "@/components/admin";
import Avatar from "@/components/Avatar";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { userPath } from "@/lib/urlStyle";
import {
AdminChartCard,
DeltaBarChart,
@@ -326,10 +328,11 @@ function StockOverviewCard({ stats }: { stats: EconomyStats }) {
}
function LedgerUserLink({ user, compact }: { user: LedgerParty; compact?: boolean }) {
const urlStyle = useUrlStyle();
const name = partyName(user);
return (
<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"
title={`${name} @${user.username}`}
>

View File

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

View File

@@ -6,6 +6,7 @@ import useUnsavedGuard from "@/hooks/useUnsavedGuard";
import BasicIdentityPanel from "./BasicIdentityPanel";
import BrandSeoPanel from "./BrandSeoPanel";
import FooterLinksPanel from "./FooterLinksPanel";
import UrlStylePanel from "./UrlStylePanel";
import type { PublicSettings } from "@/lib/api";
export default function BasicSettings({ initial }: { initial: PublicSettings }) {
@@ -13,17 +14,20 @@ export default function BasicSettings({ initial }: { initial: PublicSettings })
const [basicDirty, setBasicDirty] = useState(false);
const [brandDirty, setBrandDirty] = 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 onBasicDirty = useCallback((d: boolean) => setBasicDirty(d), []);
const onBrandDirty = useCallback((d: boolean) => setBrandDirty(d), []);
const onSeoDirty = useCallback((d: boolean) => setSeoDirty(d), []);
const onUrlStyleDirty = useCallback((d: boolean) => setUrlStyleDirty(d), []);
return (
<div className="admin-settings-stack">
<BasicIdentityPanel initial={live} onSaved={setLive} onDirtyChange={onBasicDirty} />
<BrandSeoPanel initial={live} onSaved={setLive} onDirtyChange={onBrandDirty} />
<UrlStylePanel initial={live} onSaved={setLive} onDirtyChange={onUrlStyleDirty} />
<FooterLinksPanel initial={live} onSaved={setLive} onDirtyChange={onSeoDirty} />
<ConfirmDialog
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 { formatDate, formatRelative } from "@/lib/format";
import { realtime } from "@/lib/realtime";
import { userPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
import Modal from "@/components/Modal";
@@ -302,6 +304,7 @@ export default function UsersAdmin({
currentUserId: number;
currentUserRole: string;
}) {
const urlStyle = useUrlStyle();
const [users, setUsers] = useState<AdminUser[]>(initial?.users ?? []);
const [summary, setSummary] = useState<AdminUserSummary>(
initial?.summary ?? {
@@ -686,7 +689,7 @@ export default function UsersAdmin({
return (
<div className="flex items-center gap-1.5 flex-wrap">
{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} />
主页
</Link>
@@ -697,7 +700,7 @@ export default function UsersAdmin({
return (
<div className="flex items-center gap-1.5 flex-wrap">
{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} />
主页
</Link>
@@ -718,7 +721,7 @@ export default function UsersAdmin({
return (
<div className="flex items-center gap-1.5 flex-wrap">
{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} />
主页
</Link>

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ import {
} from "@/components/site-doc";
import { requestCanonical } from "@/lib/canonicalMeta";
import { canonicalPage } from "@/lib/siteUrl";
import { announcementPath, type UrlStyle } from "@/lib/urlStyle";
interface PageProps {
searchParams: Promise<{ page?: string }>;
@@ -42,11 +43,11 @@ function plainSummary(md: string, max = 120): string {
return text.length > max ? `${text.slice(0, max)}…` : text;
}
function FeaturedAnnouncement({ a }: { a: Announcement }) {
function FeaturedAnnouncement({ a, urlStyle }: { a: Announcement; urlStyle: UrlStyle }) {
const summary = plainSummary(a.content, 180);
return (
<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)]"
>
<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);
return (
<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)]"
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 siteLead = settings.site_description?.trim() || undefined;
const urlStyle = settings.url_style;
const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
// 首页置顶条目作头图;其余走列表行(置顶已在头图时不再重复)
@@ -178,9 +180,9 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
</div>
) : (
<>
{featured ? <FeaturedAnnouncement a={featured} /> : null}
{featured ? <FeaturedAnnouncement a={featured} urlStyle={urlStyle} /> : null}
{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 ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
import ChatUserCard, { type ChatUserCardTarget } from "@/components/ChatUserCard";
import { userPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
/** 同一房间已读:进行中去重 + 2s 内合并(滚动/新消息/Strict Mode 连打) */
const MARK_READ_GAP_MS = 2_000;
@@ -102,6 +104,7 @@ function RecallNotice({
meId: number;
members: ChatRoomMember[];
}) {
const urlStyle = useUrlStyle();
if (!message.recalled_at) return null;
const by =
message.recalled_by ||
@@ -137,7 +140,7 @@ function RecallNotice({
const nameNode =
uid > 0 ? (
<Link
href={`/u/${uid}`}
href={userPath(urlStyle, uid)}
className="transition-opacity hover:opacity-80"
style={nameStyle}
>
@@ -2020,6 +2023,7 @@ function MembersPanel({
onOpenCard: (m: ChatRoomMember) => void;
showTitle?: boolean;
}) {
const urlStyle = useUrlStyle();
return (
<div>
{showTitle && (
@@ -2072,7 +2076,7 @@ function MembersPanel({
<Avatar name={name} src={m.user.avatar || undefined} size={28} />
</button>
<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"
style={{ color: "var(--ink)" }}
>

View File

@@ -4590,11 +4590,13 @@ a.j13-mention-token:hover {
}
#settings-identity .admin-settings-actions,
#settings-brand .admin-settings-actions,
#settings-url-style .admin-settings-actions,
#settings-footer .admin-settings-actions {
grid-template-columns: minmax(0, 1fr);
}
#settings-identity .admin-settings-actions-inner,
#settings-brand .admin-settings-actions-inner,
#settings-url-style .admin-settings-actions-inner,
#settings-footer .admin-settings-actions-inner {
grid-column: 1;
}
@@ -4860,6 +4862,71 @@ a.j13-mention-token:hover {
}
}
/* 链接风格(伪静态)三选卡 */
.admin-url-style-card {
display: flex;
flex-direction: column;
align-items: stretch;
padding: 0.8rem 0.85rem;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--panel);
text-align: left;
cursor: pointer;
font: inherit;
color: inherit;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.admin-url-style-card.is-on {
border-color: color-mix(in srgb, var(--accent) 55%, var(--line));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent);
}
.admin-url-style-card:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.admin-url-style-dot {
width: 0.85rem;
height: 0.85rem;
border-radius: 999px;
border: 1px solid var(--line-2);
background: var(--panel-2);
flex-shrink: 0;
}
.admin-url-style-dot.is-on {
border-color: var(--accent);
background: radial-gradient(circle, var(--accent) 0 42%, transparent 46%);
}
.admin-url-style-examples {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-top: 0.6rem;
padding: 0.55rem 0.6rem;
border-radius: 8px;
background: var(--panel-2);
}
.admin-url-style-example {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.6rem;
font-size: 11px;
}
.admin-url-style-example code {
font-size: 11px;
color: var(--ink-2);
overflow-wrap: anywhere;
}
.admin-url-style-notes {
list-style: disc;
padding-left: 1.1rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
color: var(--ink-3);
}
@container settings-page (max-width: 1039px) {
.admin-settings-work {
grid-template-columns: minmax(0, 1fr);

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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