feat: 可配置站点 URL 风格
- 新增 urlStyle 工具与测试:postPath/userPath/sitePagePath 统一生成链接 - 管理端新增 UrlStylePanel 设置面板,settings 服务与 handler 适配 - 全站路由链接与 JSON-LD/sitemap 切换为统一 URL 生成 - middleware 与相关组件适配
This commit is contained in:
@@ -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)" }}
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)" }}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="页面">
|
||||
|
||||
@@ -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}`}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
123
frontend/app/admin/settings/UrlStylePanel.tsx
Normal file
123
frontend/app/admin/settings/UrlStylePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)" }}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user