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

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