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

@@ -1,6 +1,8 @@
import Link from "next/link";
import { CheckCircle2, CircleHelp, Coins } from "lucide-react";
import type { AcceptedAnswer } from "@/lib/api";
import { userPath } from "@/lib/urlStyle";
import { getPublicSettingsCached } from "@/lib/serverData";
import Avatar from "@/components/Avatar";
import MarkdownBody from "@/components/MarkdownBody";
@@ -24,6 +26,8 @@ export default async function AcceptedAnswerCard({
const answerLabel = isQuestion ? "最佳答案" : "采纳答案";
const endTime = solvedAt || answer.created_at;
const endLabel = formatEndsAt(endTime);
const urlStyle = (await getPublicSettingsCached()).url_style;
const authorHref = author ? userPath(urlStyle, author.id) : "";
return (
<section
@@ -51,12 +55,12 @@ export default async function AcceptedAnswerCard({
</span>
{author ? (
<>
<Link href={`/u/${author.id}`} className="shrink-0" aria-label={name}>
<Link href={authorHref} className="shrink-0" aria-label={name}>
<Avatar name={name} src={author.avatar || undefined} size={32} />
</Link>
<div className="min-w-0 flex flex-col gap-0.5">
<Link
href={`/u/${author.id}`}
href={authorHref}
className="truncate text-[13.5px] font-semibold hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>

View File

@@ -11,6 +11,8 @@ import LevelBadge from "@/components/LevelBadge";
import BadgeChip from "@/components/BadgeChip";
import ImageLightbox from "@/components/markdown/ImageLightbox";
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { userPath } from "@/lib/urlStyle";
import { apiGetUserProfile, apiOpenDirectChat, type UserProfile, type User } from "@/lib/api";
import { toast } from "@/lib/toast";
@@ -36,6 +38,7 @@ export default function ChatUserCard({
}) {
const router = useRouter();
const allowMessages = useAllowMessages();
const urlStyle = useUrlStyle();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [dmBusy, setDmBusy] = useState(false);
const [avatarOpen, setAvatarOpen] = useState(false);
@@ -194,7 +197,7 @@ export default function ChatUserCard({
{/* 操作区 */}
<div className="mt-4 flex items-center gap-2">
<Link href={`/u/${user.id}`} className="btn btn-line btn-sm flex-1 justify-center">
<Link href={userPath(urlStyle, user.id)} className="btn btn-line btn-sm flex-1 justify-center">
用户主页
</Link>
{allowMessages && !isSelf && (

View File

@@ -41,6 +41,8 @@ import DeleteCommentModal, { type DeleteCommentPayload } from "@/components/Dele
import EditHistoryModal from "@/components/EditHistoryModal";
import { scrollFlashTo } from "@/lib/flash";
import { fetchCommentLocation } from "@/lib/api";
import { postPath, userPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
interface CommentSectionProps {
postId: string;
@@ -215,6 +217,7 @@ export default function CommentSection({
}: CommentSectionProps) {
const winnerCommentIdSet = new Set(winnerCommentIds.filter((id) => id > 0));
const router = useRouter();
const urlStyle = useUrlStyle();
const commentsOpen = useAllowComments();
const commentsRequireLogin = useCommentsRequireLogin();
const staffViewer = !!user && isStaff(user.role);
@@ -329,7 +332,7 @@ export default function CommentSection({
if (cancelled || floor <= 0) return;
const destPage = Math.max(1, Math.ceil(floor / size));
if (destPage === page) return; // 同页却无节点(异常数据),放弃
router.replace(`/post/${postId}?page=${destPage}#comment-${id}`, { scroll: false });
router.replace(`${postPath(urlStyle, Number(postId))}?page=${destPage}#comment-${id}`, { scroll: false });
} catch {
/* 定位失败静默:停留在当前页 */
}
@@ -359,15 +362,15 @@ export default function CommentSection({
if (destPage !== page) {
setAwayHref(
destPage > 1
? `/post/${postId}?page=${destPage}#comment-${res.comment.id}`
: `/post/${postId}#comment-${res.comment.id}`
? `${postPath(urlStyle, Number(postId))}?page=${destPage}#comment-${res.comment.id}`
: `${postPath(urlStyle, Number(postId))}#comment-${res.comment.id}`
);
} else {
setAwayHref(null);
}
router.refresh();
} else if (res.error === "未登录") {
router.push(`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`);
router.push(`/login?redirect=${encodeURIComponent(postPath(urlStyle, Number(postId)))}`);
} else {
const msg = res.error || "评论失败";
setFloorError(msg);
@@ -415,7 +418,7 @@ export default function CommentSection({
router.refresh();
focusCommentControl(target.id, "reply");
} else if (res.error === "未登录") {
router.push(`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`);
router.push(`/login?redirect=${encodeURIComponent(postPath(urlStyle, Number(postId)))}`);
} else {
const msg = res.error || "回复失败";
setReplyError(msg);
@@ -929,7 +932,7 @@ export default function CommentSection({
<MosaicAvatar size={isFloor ? 34 : 32} />
) : author ? (
<Link
href={`/u/${author.id}`}
href={userPath(urlStyle, author.id)}
className={isFloor ? "shrink-0 mt-0.5" : "shrink-0"}
aria-label={displayName}
>
@@ -942,7 +945,7 @@ export default function CommentSection({
const nameRow = showMosaic ? (
<MosaicName />
) : author ? (
<Link href={`/u/${author.id}`} className="group">
<Link href={userPath(urlStyle, author.id)} className="group">
<span
className={`truncate group-hover:text-[var(--accent)] transition-colors ${
isFloor ? "text-sm" : "cmt-reply-name"
@@ -980,7 +983,7 @@ export default function CommentSection({
{c.staff_deleted && deleter ? (
<div className="cmt-tombstone-mod">
<span>处理人</span>
<Link href={`/u/${deleter.id}`} aria-label={`查看管理员 ${deleterName}`}>
<Link href={userPath(urlStyle, deleter.id)} aria-label={`查看管理员 ${deleterName}`}>
<Avatar name={deleterName} src={deleter.avatar || undefined} size={20} />
{deleterName}
</Link>
@@ -1088,7 +1091,7 @@ export default function CommentSection({
<div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24">
<div className="flex gap-3">
{author ? (
<Link href={`/u/${author.id}`} className="shrink-0" aria-label={authorName}>
<Link href={userPath(urlStyle, author.id)} className="shrink-0" aria-label={authorName}>
<Avatar name={authorName} src={author.avatar || undefined} size={32} />
</Link>
) : (
@@ -1098,7 +1101,7 @@ export default function CommentSection({
<div className="cmt-meta">
<div className="cmt-meta-lead">
{author ? (
<Link href={`/u/${author.id}`} className="cmt-name-link group">
<Link href={userPath(urlStyle, author.id)} className="cmt-name-link group">
<span className="cmt-name group-hover:text-[var(--accent)] transition-colors">
{authorName}
</span>
@@ -1189,7 +1192,7 @@ export default function CommentSection({
<div key={c.id} id={`comment-${c.id}`} className="cmt-item scroll-mt-24">
<div className="flex gap-3">
{author ? (
<Link href={`/u/${author.id}`} className="shrink-0" aria-label={authorName}>
<Link href={userPath(urlStyle, author.id)} className="shrink-0" aria-label={authorName}>
<Avatar name={authorName} src={author.avatar || undefined} size={34} />
</Link>
) : (
@@ -1199,7 +1202,7 @@ export default function CommentSection({
<div className="cmt-meta">
<div className="cmt-meta-lead">
{author ? (
<Link href={`/u/${author.id}`} className="cmt-name-link group">
<Link href={userPath(urlStyle, author.id)} className="cmt-name-link group">
<span className="cmt-name group-hover:text-[var(--accent)] transition-colors">
{authorName}
</span>
@@ -1302,7 +1305,7 @@ export default function CommentSection({
return <CommentsUnsupported />;
}
if (commentsRequireLogin && !user) {
return <CommentsLoginRequired redirect={`/post/${postId}#comments`} />;
return <CommentsLoginRequired redirect={`${postPath(urlStyle, Number(postId))}#comments`} />;
}
return (
@@ -1386,7 +1389,7 @@ export default function CommentSection({
<span className="text-sm" style={{ color: "var(--ink-2)" }}>
登录后参与讨论
</span>
<Link href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`} className="btn btn-line btn-sm">
<Link href={`/login?redirect=${encodeURIComponent(postPath(urlStyle, Number(postId)))}`} className="btn btn-line btn-sm">
去登录
</Link>
</div>
@@ -1405,7 +1408,7 @@ export default function CommentSection({
)}
</div>
<Pagination total={total} page={page} size={size} basePath={`/post/${postId}`} />
<Pagination total={total} page={page} size={size} basePath={postPath(urlStyle, Number(postId))} />
<ConfirmDialog
open={rejectTargetId != null}

View File

@@ -5,6 +5,8 @@ import Link from "next/link";
import { Loader2 } from "lucide-react";
import Modal from "@/components/Modal";
import Avatar from "@/components/Avatar";
import { userPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
const PAGE_SIZE = 10;
@@ -265,6 +267,7 @@ export default function EditHistoryModal({
fetchPage: FetchHistoryPage;
onClose: () => void;
}) {
const urlStyle = useUrlStyle();
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState("");
@@ -366,12 +369,12 @@ export default function EditHistoryModal({
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
>
<div className="flex items-center gap-2 mb-2.5">
<Link href={`/u/${it.editor.id}`} className="shrink-0">
<Link href={userPath(urlStyle, it.editor.id)} className="shrink-0">
<Avatar name={name} src={it.editor.avatar || undefined} size={28} />
</Link>
<div className="min-w-0 flex-1">
<Link
href={`/u/${it.editor.id}`}
href={userPath(urlStyle, it.editor.id)}
className="text-[13px] hover:text-[var(--accent)]"
style={{ color: "var(--ink)" }}
>

View File

@@ -21,6 +21,7 @@ import {
} from "lucide-react";
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
import { userPath } from "@/lib/urlStyle";
import { canAccessAdminMessages, isStaff } from "@/lib/roles";
import { onUserUpdate, onForceLogout } from "@/lib/userEvents";
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
@@ -33,6 +34,8 @@ import LevelBadge from "./LevelBadge";
import BadgeChip from "./BadgeChip";
import BrandLockup from "./BrandLockup";
import { useAllowMessages } from "./MessagesPolicyProvider";
import { useUrlStyle } from "./SiteBrandProvider";
import { leaderboardPath } from "@/lib/urlStyle";
import type { BrandLogoFit, BrandLogoSize, BrandMark } from "@/lib/brand";
export default function Header({
@@ -62,6 +65,8 @@ export default function Header({
}) {
const router = useRouter();
const pathname = usePathname();
const urlStyle = useUrlStyle();
const lbHref = leaderboardPath(urlStyle);
const allowMessages = useAllowMessages();
const hideComposeNav =
pathname === "/compose" ||
@@ -239,8 +244,8 @@ export default function Header({
首页
</NavPill>
<NavPill
href="/leaderboard"
active={pathname?.startsWith("/leaderboard") ?? false}
href={lbHref}
active={!!pathname && (pathname === lbHref || pathname.startsWith(`${lbHref}/`))}
icon={<Trophy size={15} />}
>
排行榜
@@ -410,6 +415,7 @@ function NavPill({
* 把零散的图标按钮收拢为一个语义明确的控件;移动端只显示头像
*/
function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
const urlStyle = useUrlStyle();
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@@ -493,7 +499,7 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
<Link href="/notifications" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<Bell size={15} style={{ color: "var(--ink-3)" }} /> 通知中心
</Link>
<Link href={`/u/${user.id}`} onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<Link href={userPath(urlStyle, user.id)} onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<UserIcon size={15} style={{ color: "var(--ink-3)" }} /> 个人主页
</Link>
<Link href="/settings" onClick={() => setOpen(false)} className={itemCls} role="menuitem">

View File

@@ -40,6 +40,8 @@ import ImageInsertModal from "@/components/ImageInsertModal";
import TimelineEditor from "@/components/TimelineEditor";
import { resolveTimelineBlockForEdit } from "@/lib/directiveBlocks";
import { SHIKI_LANG_IDS } from "@/components/markdown/shikiHighlighter";
import { postPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
type MarkdownEditorProps = {
value: string;
@@ -157,6 +159,7 @@ export default function MarkdownEditor({
onUploadingChange,
}: MarkdownEditorProps) {
const taRef = useRef<HTMLTextAreaElement>(null);
const urlStyle = useUrlStyle();
const fileRef = useRef<HTMLInputElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const [mode, setMode] = useState<EditorMode>("write");
@@ -472,7 +475,7 @@ export default function MarkdownEditor({
/** 选中站内帖子:回填 URL 与文字,用户可再编辑后点「插入」 */
const pickPostLink = (p: PostListItem) => {
setLinkUrl(`/post/${p.id}`);
setLinkUrl(postPath(urlStyle, p.id));
setLinkText(p.title);
};

View File

@@ -12,7 +12,9 @@ import {
} from "lucide-react";
import { apiChatUnreadSummary, type User } from "@/lib/api";
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { onDocumentVisible } from "@/lib/visibilityReconcile";
import { parseEntityPath, userPath, leaderboardPath } from "@/lib/urlStyle";
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
import type { RtChatUnreadData } from "@/lib/realtime";
@@ -30,6 +32,8 @@ export default function MobileTabBar({
}) {
const pathname = usePathname();
const allowMessages = useAllowMessages();
const urlStyle = useUrlStyle();
const lbHref = leaderboardPath(urlStyle);
const [chatUnread, setChatUnread] = useState(initialChatUnread);
// 登录态经 router.refresh() 下发新 props 时校正徽标
@@ -96,10 +100,10 @@ export default function MobileTabBar({
active={isActive((p) => p === "/")}
/>
<TabLink
href="/leaderboard"
href={lbHref}
icon={<Trophy size={20} />}
label="排行榜"
active={isActive((p) => p.startsWith("/leaderboard"))}
active={!!pathname && (pathname === lbHref || pathname.startsWith(`${lbHref}/`))}
/>
<TabLink
href={user ? "/compose" : "/login"}
@@ -118,11 +122,18 @@ export default function MobileTabBar({
/>
)}
<TabLink
href={user ? `/u/${user.id}` : "/login"}
href={user ? userPath(urlStyle, user.id) : "/login"}
icon={<UserIcon size={20} />}
label="我的"
active={
!!user && isActive((p) => p === `/u/${user.id}` || p.startsWith(`/u/${user.id}/`))
!!user &&
isActive((p) => {
const parsed = parseEntityPath(p);
return (
(parsed?.kind === "user" && parsed.id === user.id) ||
p.startsWith(`${userPath(urlStyle, user.id)}/`)
);
})
}
/>
</div>

View File

@@ -13,9 +13,12 @@ import {
} from "@/lib/api";
import { realtime, RT_NOTIFICATION_NEW } from "@/lib/realtime";
import { onDocumentVisible } from "@/lib/visibilityReconcile";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { postPath, userPath } from "@/lib/urlStyle";
export default function NotificationBell({ initialUnread = 0 }: { initialUnread?: number }) {
const router = useRouter();
const urlStyle = useUrlStyle();
const [open, setOpen] = useState(false);
// SSR 直出未读数,红点不再挂载后才弹出;WS/回前台校准
const [unread, setUnread] = useState(initialUnread);
@@ -103,16 +106,16 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
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));
}
};

View File

@@ -1,6 +1,8 @@
import Link from "next/link";
import { Pin } from "lucide-react";
import type { PostListItem } from "@/lib/api";
import { postPath } from "@/lib/urlStyle";
import { getPublicSettingsCached } from "@/lib/serverData";
import Avatar from "./Avatar";
import { IconComment, IconEye } from "./Icons";
@@ -15,7 +17,7 @@ function formatTime(dateStr: string) {
* 管理员置顶区:独立于普通帖子列表。
* 陶土色语义(与 badge-pin 一致),紧凑排列,一眼识别为公告性质内容。
*/
export default function PinnedPosts({
export default async function PinnedPosts({
posts,
showBoard = true,
}: {
@@ -23,6 +25,7 @@ export default function PinnedPosts({
showBoard?: boolean;
}) {
if (posts.length === 0) return null;
const urlStyle = (await getPublicSettingsCached()).url_style;
return (
<section className="mb-6" aria-label="置顶讨论">
@@ -43,7 +46,7 @@ export default function PinnedPosts({
{posts.map((p, i) => (
<Link
key={p.id}
href={`/post/${p.id}`}
href={postPath(urlStyle, p.id)}
className="group relative flex items-center gap-3 sm:gap-3.5 px-4 sm:px-5 py-3.5 transition-colors hover:bg-[var(--item-hover-bg)] focus-visible:bg-[var(--item-hover-bg)] j13-fade-up"
style={{
...(i > 0 ? { borderTop: "1px solid var(--line)" } : {}),

View File

@@ -12,6 +12,8 @@ import {
import { fileExtBadge, fileKind } from "@/lib/fileKind";
import { toast } from "@/lib/toast";
import Modal from "@/components/Modal";
import { postPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
function formatSize(n: number) {
if (n < 1024) return `${n} B`;
@@ -86,7 +88,8 @@ export default function PostAttachments({
loggedIn: boolean;
}) {
const router = useRouter();
const loginHref = `/login?redirect=${encodeURIComponent(`/post/${postId}`)}`;
const urlStyle = useUrlStyle();
const loginHref = `/login?redirect=${encodeURIComponent(postPath(urlStyle, postId))}`;
const [busyId, setBusyId] = useState<number | null>(null);
const [pending, setPending] = useState<PostAttachment | null>(null);
const [unlockedIds, setUnlockedIds] = useState<number[]>([]);

View File

@@ -35,6 +35,8 @@ import DeadlinePicker, {
} from "@/components/DeadlinePicker";
import Modal from "@/components/Modal";
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
import { postPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
const TITLE_MAX = 256;
/** 帖子正文字符上限(与后端 handler/post.go 校验一致) */
@@ -179,6 +181,7 @@ export type PostComposerProps = CreateProps | EditProps;
export default function PostComposer(props: PostComposerProps) {
const router = useRouter();
const urlStyle = useUrlStyle();
const isCreate = props.mode === "create";
const [boards, setBoards] = useState<Board[]>([]);
@@ -828,7 +831,7 @@ export default function PostComposer(props: PostComposerProps) {
} else {
toast("发布成功", "ok");
}
router.push(`/post/${res.post.id}`);
router.push(postPath(urlStyle, res.post.id));
} else {
const mapped = mapPostError(res.error || "发帖失败");
reportSubmitError(

View File

@@ -18,6 +18,8 @@ import {
import { toast } from "@/lib/toast";
import Avatar from "@/components/Avatar";
import { useAllowComments } from "@/components/CommentsPolicyProvider";
import { postPath, userPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
export default function PostInteractPanels({
postId,
@@ -286,6 +288,7 @@ function PollPanel({
loggedIn: boolean;
}) {
const router = useRouter();
const urlStyle = useUrlStyle();
const [sel, setSel] = useState<number[]>(state.my_options || []);
const [busy, setBusy] = useState(false);
const showResults = state.closed || (state.my_options && state.my_options.length > 0);
@@ -418,7 +421,7 @@ function PollPanel({
<ul className="j13-poll-voters">
{namedVoters.map((v) => (
<li key={v.id}>
<Link href={`/u/${v.id}`} className="j13-poll-voter">
<Link href={userPath(urlStyle, v.id)} className="j13-poll-voter">
<Avatar name={v.nickname || v.username} src={v.avatar || undefined} size={16} />
{v.nickname || v.username}
</Link>
@@ -445,7 +448,7 @@ function PollPanel({
)}
{!loggedIn && !state.closed && (
<Link
href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`}
href={`/login?redirect=${encodeURIComponent(postPath(urlStyle, postId))}`}
className="btn btn-line btn-sm"
>
登录后投票
@@ -559,6 +562,7 @@ function LotteryPanel({
state: LotteryState;
}) {
const router = useRouter();
const urlStyle = useUrlStyle();
const [busy, setBusy] = useState(false);
const act = async (fn: () => Promise<{ post?: unknown; error?: string }>, ok: string) => {
@@ -651,7 +655,7 @@ function LotteryPanel({
className="j13-lottery-winner-avatar"
/>
<div className="j13-lottery-winner-body">
<Link href={`/u/${w.id}`} className="j13-lottery-winner-name">
<Link href={userPath(urlStyle, w.id)} className="j13-lottery-winner-name">
{w.nickname || w.username}
</Link>
{w.prize_name ? (

View File

@@ -3,6 +3,7 @@ import { ArrowLeft } from "lucide-react";
import type { PostListItem } from "@/lib/api";
import { formatRelative } from "@/lib/format";
import { getPublicSettingsCached } from "@/lib/serverData";
import { postPath, userPath } from "@/lib/urlStyle";
import { normalizePostType, postTypeLabel, typeStatusLabel, typeStatusClass } from "@/lib/postMeta";
import LikeButton from "./LikeButton";
import Avatar from "./Avatar";
@@ -32,7 +33,9 @@ export default async function PostRow({
showViews?: boolean;
showLike?: boolean;
}) {
const allowComments = (await getPublicSettingsCached()).allow_comments !== false;
const settings = await getPublicSettingsCached();
const allowComments = settings.allow_comments !== false;
const urlStyle = settings.url_style;
const lastReply = post.last_reply;
const replied =
allowComments &&
@@ -56,7 +59,7 @@ export default async function PostRow({
}}
>
<Link
href={`/u/${post.user.id}`}
href={userPath(urlStyle, post.user.id)}
className="shrink-0 above-link"
aria-label={authorName}
tabIndex={-1}
@@ -71,7 +74,7 @@ export default async function PostRow({
{post.recommended && <span className="badge-rec shrink-0">推荐</span>}
<div className="min-w-0 flex items-center gap-2 flex-1 overflow-hidden">
<Link
href={`/post/${post.id}`}
href={postPath(urlStyle, post.id)}
className="stretch-link min-w-0 shrink"
aria-label={post.title}
>
@@ -98,7 +101,7 @@ export default async function PostRow({
<span className="j13-board-tag">{post.board.name}</span>
)}
<Link
href={`/u/${post.user.id}`}
href={userPath(urlStyle, post.user.id)}
className="above-link font-medium hover:text-[var(--accent)] transition-colors"
>
{authorName}
@@ -109,7 +112,7 @@ export default async function PostRow({
<span className="inline-flex items-center gap-1 min-w-0">
<ArrowLeft size={13} className="shrink-0" aria-hidden />
<Link
href={`/u/${lastReply!.user.id}`}
href={userPath(urlStyle, lastReply!.user.id)}
className="above-link font-medium hover:text-[var(--accent)] transition-colors truncate max-w-[8em]"
>
{lastReply!.user.nickname || lastReply!.user.username}
@@ -128,7 +131,7 @@ export default async function PostRow({
<div className="flex items-center gap-3 sm:gap-4 shrink-0">
{allowComments ? (
<Link
href={`/post/${post.id}`}
href={postPath(urlStyle, post.id)}
tabIndex={-1}
aria-label={`${post.comment_count} 条评论`}
className={`${showViews ? "hidden sm:inline-flex" : "inline-flex"} above-link items-center gap-1.5 meta tabular-nums text-[13.5px] hover:text-[var(--accent)] transition-colors`}

View File

@@ -4,6 +4,8 @@ import { useEffect, useState } from "react";
import Link from "next/link";
import { Ban, ExternalLink, Loader2, RotateCcw, ShieldCheck, Users as UsersIcon } from "lucide-react";
import { apiAdminSetUserBan, apiAdminSetUserRole, apiAdminSetUserMessages, type Board } from "@/lib/api";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { userPath } from "@/lib/urlStyle";
import { ROLES, ROLE_META, roleMeta } from "@/lib/roles";
import { formatDate } from "@/lib/format";
import { toast } from "@/lib/toast";
@@ -407,6 +409,7 @@ function IdentityHeader({
displayName: string;
currentMetaLabel: string;
}) {
const urlStyle = useUrlStyle();
const metaBits: string[] = [`ID ${target.id}`];
if (typeof target.points === "number") {
metaBits.push(`积分 ${target.points.toLocaleString("zh-CN")}`);
@@ -472,7 +475,7 @@ function IdentityHeader({
<span className="sr-only">当前角色 {currentMetaLabel}</span>
</p>
<Link
href={`/u/${target.id}`}
href={userPath(urlStyle, target.id)}
className="mt-2.5 inline-flex items-center gap-1 text-[12.5px] font-medium hover:underline"
style={{ color: "var(--accent)" }}
>

View File

@@ -3,6 +3,7 @@
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { apiGetSettings, type PublicSettings } from "@/lib/api";
import { siteDocumentTitle, normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark, normalizeFooterLinks } from "@/lib/brand";
import { normalizeUrlStyle, type UrlStyle } from "@/lib/urlStyle";
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
import { onDocumentVisible, VISIBLE_RECONCILE_GAP_MS } from "@/lib/visibilityReconcile";
@@ -17,11 +18,12 @@ export type SiteBrand = {
brand_logo_size: PublicSettings["brand_logo_size"];
brand_logo_fit: PublicSettings["brand_logo_fit"];
footer_links: PublicSettings["footer_links"];
url_style: UrlStyle;
};
const SiteBrandContext = createContext<SiteBrand | null>(null);
function fromSettings(s: Pick<PublicSettings, keyof SiteBrand>): SiteBrand {
function fromSettings(s: PublicSettings): SiteBrand {
return {
site_name: s.site_name,
site_wordmark: s.site_wordmark,
@@ -33,6 +35,7 @@ function fromSettings(s: Pick<PublicSettings, keyof SiteBrand>): SiteBrand {
brand_logo_size: s.brand_logo_size,
brand_logo_fit: s.brand_logo_fit,
footer_links: s.footer_links,
url_style: s.url_style,
};
}
@@ -47,6 +50,7 @@ function sameBrand(a: SiteBrand, b: SiteBrand): boolean {
a.brand_mark === b.brand_mark &&
a.brand_logo_size === b.brand_logo_size &&
a.brand_logo_fit === b.brand_logo_fit &&
a.url_style === b.url_style &&
JSON.stringify(a.footer_links) === JSON.stringify(b.footer_links)
);
}
@@ -97,11 +101,17 @@ export function useSiteBrand(): SiteBrand {
brand_logo_size: "sq",
brand_logo_fit: "contain",
footer_links: [],
url_style: "default",
};
}
return ctx;
}
/** 详情页伪静态风格(SSR 初值与水合一致,设置保存后 realtime 热更) */
export function useUrlStyle(): UrlStyle {
return useSiteBrand().url_style;
}
/** SSR 初值 + settings:changed:页眉/页脚/标题/favicon 不必整页刷新 */
export default function SiteBrandProvider({
initial,
@@ -142,6 +152,7 @@ export default function SiteBrandProvider({
footer_links: Array.isArray(raw.footer_links)
? normalizeFooterLinks(raw.footer_links)
: prev.footer_links,
url_style: raw.url_style !== undefined ? normalizeUrlStyle(raw.url_style) : prev.url_style,
};
if (sameBrand(prev, next)) return;
retitle(prev, next);

View File

@@ -48,6 +48,8 @@ import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
import NotificationBell from "@/components/NotificationBell";
import ThemeToggle from "@/components/ThemeToggle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { userPath } from "@/lib/urlStyle";
interface NavItem {
href: string;
@@ -294,6 +296,7 @@ function AdminRailNav({
}
function AdminUserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
const urlStyle = useUrlStyle();
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@@ -375,7 +378,7 @@ function AdminUserMenu({ user, onLogout }: { user: User; onLogout: () => void })
</div>
</div>
<div className="p-1.5">
<Link href={`/u/${user.id}`} onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<Link href={userPath(urlStyle, user.id)} onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<UserIcon size={15} style={{ color: "var(--ink-3)" }} /> 个人主页
</Link>
<Link href="/settings" onClick={() => setOpen(false)} className={itemCls} role="menuitem">

View File

@@ -24,10 +24,13 @@ import LevelBadge from "@/components/LevelBadge";
import BadgeChip from "@/components/BadgeChip";
import { PulseCheckin } from "./CheckinCard";
import { formatCompact } from "@/lib/format";
import { getPublicSettingsCached } from "@/lib/serverData";
import { postPath, userPath } from "@/lib/urlStyle";
/* ---------- 1. 板块管理员 ---------- */
export function Moderators({ items }: { items: BoardModerator[] }) {
export async function Moderators({ items }: { items: BoardModerator[] }) {
const urlStyle = (await getPublicSettingsCached()).url_style;
return (
<section className="j13-module" aria-label="板块管理员">
<p className="j13-module-title mb-2">
@@ -41,7 +44,7 @@ export function Moderators({ items }: { items: BoardModerator[] }) {
const name = u.nickname || u.username;
return (
<li key={u.id}>
<Link href={`/u/${u.id}`} className="j13-raillink" data-tip={name}>
<Link href={userPath(urlStyle, u.id)} className="j13-raillink" data-tip={name}>
<Avatar name={name} src={u.avatar || undefined} size={30} />
<span className="min-w-0 truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}>
{name}
@@ -163,7 +166,8 @@ export function BoardPulse({
/* ---------- 4. 本板热门 ---------- */
export function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
export async function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
const urlStyle = (await getPublicSettingsCached()).url_style;
return (
<section className="j13-module" aria-label="本板热门">
<p className="j13-module-title mb-1.5">
@@ -175,7 +179,7 @@ export function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allo
<ol className="j13-ranklist">
{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 truncate">
@@ -203,7 +207,8 @@ export function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allo
/* ---------- 5. 本板活跃 ---------- */
export function BoardActive({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
export async function BoardActive({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
const urlStyle = (await getPublicSettingsCached()).url_style;
return (
<section className="j13-module" aria-label="本板活跃成员">
<p className="j13-module-title mb-2">
@@ -223,7 +228,7 @@ export function BoardActive({ users, allowComments }: { users: ActiveUser[]; all
const name = u.nickname || u.username;
return (
<li key={u.id}>
<Link href={`/u/${u.id}`} className="j13-raillink" data-tip={name}>
<Link href={userPath(urlStyle, u.id)} className="j13-raillink" data-tip={name}>
<Avatar name={name} src={u.avatar || undefined} size={30} />
<span className="flex-1 min-w-0">
<span

View File

@@ -29,6 +29,8 @@ import type {
} from "@/lib/api";
import Avatar from "@/components/Avatar";
import { AdText } from "@/components/AdText";
import { getPublicSettingsCached } from "@/lib/serverData";
import { announcementPath, postPath, userPath, pagePath } from "@/lib/urlStyle";
import { PulseCheckin } from "./CheckinCard";
import { FRIEND_LINKS } from "@/lib/friendLinks";
import { formatCompact, formatShortDate } from "@/lib/format";
@@ -136,7 +138,7 @@ export function PulseCard({
/* ---------- 关于本站(侧栏页目录,置于右栏最底) ---------- */
export function AboutSite({
export async function AboutSite({
pages,
siteDescription,
canManagePages,
@@ -147,6 +149,7 @@ export function AboutSite({
}) {
if (pages.length === 0 && !canManagePages) return null;
const lead = siteDescription?.trim() ?? "";
const urlStyle = (await getPublicSettingsCached()).url_style;
return (
<section className="j13-module j13-about" aria-label="关于本站">
@@ -160,7 +163,7 @@ export function AboutSite({
const Icon = pageGlyph(p.slug);
return (
<li key={p.id}>
<Link href={`/p/${p.slug}`} className="j13-about-item">
<Link href={pagePath(urlStyle, p.slug)} className="j13-about-item">
<span className="j13-about-ico" aria-hidden="true">
<Icon size={15} strokeWidth={2} />
</span>
@@ -181,13 +184,14 @@ export function AboutSite({
/* ---------- 站点公告 ---------- */
export function Announcements({
export async function Announcements({
items,
canManageAnnouncements,
}: {
items: AnnouncementSummary[];
canManageAnnouncements?: boolean;
}) {
const urlStyle = (await getPublicSettingsCached()).url_style;
return (
<section className="j13-module" aria-label="站点公告">
<div className="flex items-center justify-between gap-2">
@@ -218,7 +222,7 @@ export function Announcements({
{items.map((a) => (
<li key={a.id}>
<Link
href={`/announcement/${a.id}`}
href={announcementPath(urlStyle, a.id)}
className="j13-anno-item"
data-tip={a.title}
>
@@ -254,7 +258,8 @@ export function Announcements({
/* ---------- 热门榜 ---------- */
export function HotRanking({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
export async function HotRanking({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
const urlStyle = (await getPublicSettingsCached()).url_style;
return (
<section className="j13-module" aria-label="热门讨论">
<p className="j13-module-title mb-1.5">
@@ -266,7 +271,7 @@ export function HotRanking({ posts, allowComments }: { posts: PostListItem[]; al
<ol className="j13-ranklist">
{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 truncate">
@@ -294,8 +299,9 @@ export function HotRanking({ posts, allowComments }: { posts: PostListItem[]; al
/* ---------- 最新注册 ---------- */
export function NewUsers({ users }: { users: NewUser[] }) {
export async function NewUsers({ users }: { users: NewUser[] }) {
if (users.length === 0) return null;
const urlStyle = (await getPublicSettingsCached()).url_style;
return (
<section className="j13-module" aria-label="最新注册">
<p className="j13-module-title mb-3">
@@ -307,7 +313,7 @@ export function NewUsers({ users }: { users: NewUser[] }) {
return (
<li key={u.id}>
<Link
href={`/u/${u.id}`}
href={userPath(urlStyle, u.id)}
className="flex flex-col items-center gap-1.5 rounded-lg px-1 py-1 transition-colors hover:bg-[var(--item-hover-bg)] focus-visible:bg-[var(--item-hover-bg)]"
>
<Avatar name={name} src={u.avatar} size={38} />
@@ -329,7 +335,8 @@ export function NewUsers({ users }: { users: NewUser[] }) {
/* ---------- 活跃成员(本周发帖 / 回复) ---------- */
export function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
export async function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
const urlStyle = (await getPublicSettingsCached()).url_style;
return (
<section className="j13-module" aria-label="本周活跃成员">
<p className="j13-module-title mb-2">
@@ -346,7 +353,7 @@ export function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; all
const name = u.nickname || u.username;
return (
<li key={u.id}>
<Link href={`/u/${u.id}`} className="j13-raillink" data-tip={name}>
<Link href={userPath(urlStyle, u.id)} className="j13-raillink" data-tip={name}>
<Avatar name={name} src={u.avatar} size={30} />
<span className="flex-1 min-w-0">
<span

View File

@@ -7,6 +7,8 @@ import { Bookmark, Home, ShieldCheck } from "lucide-react";
import type { BoardWithCount, User } from "@/lib/api";
import { isStaff } from "@/lib/roles";
import BoardIcon, { boardColor } from "@/components/BoardIcon";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { userPath } from "@/lib/urlStyle";
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
function boardHref(id: number) {
@@ -23,6 +25,7 @@ export function WorkspaceNavPanel({
user?: User | null;
}) {
const params = useSearchParams();
const urlStyle = useUrlStyle();
const activeBoard = Number(params.get("board")) || 0;
const showAdmin = !!user && isStaff(user.role);
@@ -46,7 +49,7 @@ export function WorkspaceNavPanel({
</Link>
{user ? (
<Link href={`/u/${user.id}?tab=favorites`} className="j13-bcard">
<Link href={`${userPath(urlStyle, user.id)}?tab=favorites`} className="j13-bcard">
<span
className="j13-bcard-ico"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}

View File

@@ -9,6 +9,8 @@ import { apiUnlockPost, apiUnlockPostPassword } from "@/lib/api";
import { CSRF_COOKIE } from "@/lib/cookies";
import { toast } from "@/lib/toast";
import { hideKindLabel, type HideKind } from "@/lib/hideBlocks";
import { postPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
/** 若浏览器尚无 CSRF cookie,请求公开接口以触发后端签发 */
async function ensureGuestCSRF() {
@@ -44,6 +46,7 @@ export default function MdHideBlock({
children,
}: MdHideBlockProps) {
const router = useRouter();
const urlStyle = useUrlStyle();
const pwdErrId = useId();
const [busy, setBusy] = useState(false);
const [pwd, setPwd] = useState("");
@@ -51,7 +54,9 @@ export default function MdHideBlock({
const label = hideKindLabel(kind);
const loginTo =
loginHref ||
(postId ? `/login?redirect=${encodeURIComponent(`/post/${postId}`)}` : "/login");
(postId
? `/login?redirect=${encodeURIComponent(postPath(urlStyle, postId))}`
: "/login");
if (locked) {
const unlockPoints = async () => {
@@ -145,7 +150,7 @@ export default function MdHideBlock({
<Link
href={
postId
? `/login?redirect=${encodeURIComponent(`/post/${postId}#comment-input`)}`
? `/login?redirect=${encodeURIComponent(`${postPath(urlStyle, postId)}#comment-input`)}`
: loginTo
}
className="btn btn-primary"

View File

@@ -6,6 +6,8 @@ import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
import LevelBadge from "@/components/LevelBadge";
import BadgeChip from "@/components/BadgeChip";
import { getPublicSettingsCached } from "@/lib/serverData";
import { postPath, userPath } from "@/lib/urlStyle";
// formatYMD 帖子详情页左栏「作者其他帖」日期
function formatYMD(dateStr: string) {
@@ -54,7 +56,7 @@ export function BoardCard({ board }: { board: Board }) {
);
}
export function AuthorRailCard({
export async function AuthorRailCard({
profile,
allowComments,
boardNames,
@@ -66,6 +68,7 @@ export function AuthorRailCard({
boardNames?: Record<number, string>;
boardColors?: Record<number, number>;
}) {
const urlStyle = (await getPublicSettingsCached()).url_style;
const u = profile.user;
const signature = (u.signature || "").trim();
const { post_count, comment_count, points, streak } = profile.stats;
@@ -80,13 +83,13 @@ export function AuthorRailCard({
<User size={15} style={{ color: "var(--accent)" }} /> 作者
</p>
<div className="flex items-center gap-3">
<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={48} />
</Link>
<div className="min-w-0">
<div className="flex items-center gap-1.5 min-w-0">
<Link
href={`/u/${u.id}`}
href={userPath(urlStyle, u.id)}
className="block font-semibold text-[14.5px] truncate hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
@@ -160,15 +163,16 @@ export function AuthorRailCard({
<Flame size={12.5} /> 连签 {streak} 天
</p>
)}
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm w-full justify-center mt-3">
<Link href={userPath(urlStyle, u.id)} className="btn btn-line btn-sm w-full justify-center mt-3">
访问主页
</Link>
</section>
);
}
export function AuthorPosts({ posts }: { posts: PostListItem[] }) {
export async function AuthorPosts({ posts }: { posts: PostListItem[] }) {
if (posts.length === 0) return null;
const urlStyle = (await getPublicSettingsCached()).url_style;
return (
<section className="j13-module flex min-h-0 flex-col" aria-labelledby="author-posts-title">
<p className="j13-module-title mb-1 shrink-0" id="author-posts-title">
@@ -178,7 +182,7 @@ export function AuthorPosts({ posts }: { posts: PostListItem[] }) {
{posts.map((p) => (
<li key={p.id}>
<Link
href={`/post/${p.id}`}
href={postPath(urlStyle, p.id)}
data-tip={p.title}
className="block rounded-[10px] px-2 py-2 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
>

View File

@@ -2,9 +2,11 @@ import Link from "next/link";
import { Megaphone, Pin } from "lucide-react";
import type { AnnouncementSummary } from "@/lib/api";
import { formatShortDate } from "@/lib/format";
import { getPublicSettingsCached } from "@/lib/serverData";
import { announcementPath } from "@/lib/urlStyle";
/** 近期公告目录(高亮当前 id) */
export default function SiteDocAnnoNav({
export default async function SiteDocAnnoNav({
items,
currentId,
showAllLink = true,
@@ -13,6 +15,7 @@ export default function SiteDocAnnoNav({
currentId?: number;
showAllLink?: boolean;
}) {
const urlStyle = (await getPublicSettingsCached()).url_style;
return (
<section className="j13-module flex min-h-0 flex-col" aria-label="近期公告">
<div className="flex items-center justify-between gap-2 shrink-0">
@@ -37,7 +40,7 @@ export default function SiteDocAnnoNav({
return (
<li key={a.id}>
<Link
href={`/announcement/${a.id}`}
href={announcementPath(urlStyle, a.id)}
className="j13-anno-item"
data-tip={a.title}
aria-current={current ? "page" : undefined}

View File

@@ -2,10 +2,12 @@ import Link from "next/link";
import { ChevronRight } from "lucide-react";
import type { SidebarPageItem } from "@/lib/api";
import { pageGlyph } from "@/lib/pageGlyph";
import { getPublicSettingsCached } from "@/lib/serverData";
import { pagePath } from "@/lib/urlStyle";
import AboutSiteHeader from "./AboutSiteHeader";
/** 侧栏单页兄弟目录(高亮当前 slug);标题栏与首页「关于本站」一致 */
export default function SiteDocPageNav({
export default async function SiteDocPageNav({
pages,
currentSlug,
title = "文档目录",
@@ -17,6 +19,7 @@ export default function SiteDocPageNav({
canManage?: boolean;
}) {
if (pages.length === 0 && !canManage) return null;
const urlStyle = (await getPublicSettingsCached()).url_style;
return (
<section className="j13-module j13-about flex min-h-0 flex-col" aria-label={title}>
@@ -31,7 +34,7 @@ export default function SiteDocPageNav({
return (
<li key={p.id}>
<Link
href={`/p/${p.slug}`}
href={pagePath(urlStyle, p.slug)}
className="j13-about-item"
aria-current={current ? "page" : undefined}
style={