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

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