feat: 交付官方 Docker 运行时,外观改背景图并下线自定义 CSS/JS
站点/后台分轨背景与用户列表排序一并落地;生产 CORS 改走 SITE_URL,健康检查带版本号。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import pkg from "../package.json";
|
||||
import FooterTiming from "./FooterTiming";
|
||||
|
||||
// 仅把明确发布过的版本视为"真实版本";脚手架默认号视为未设计,传空显示占位
|
||||
function realVersion(v: string): string {
|
||||
return !v || v === "0.0.0" || v === "0.1.0" ? "" : v;
|
||||
// 官方镜像构建会写入 NEXT_PUBLIC_APP_VERSION(可与脚手架 0.1.0 相同)。
|
||||
// 未注入时,脚手架默认号仍当未发布,页脚显示占位。
|
||||
function realVersion(pkgVersion: string): string {
|
||||
const injected = process.env.NEXT_PUBLIC_APP_VERSION?.trim();
|
||||
if (injected) return injected;
|
||||
return !pkgVersion || pkgVersion === "0.0.0" || pkgVersion === "0.1.0" ? "" : pkgVersion;
|
||||
}
|
||||
|
||||
// 极简页脚:版权 + 实时性能指标(版本 / 页面加载 / 模板生成)
|
||||
@@ -12,7 +15,13 @@ export default function Footer({ siteName = "姜十三社区" }: { siteName?: st
|
||||
const brand = siteName.trim() || "姜十三社区";
|
||||
|
||||
return (
|
||||
<footer className="mt-14" style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<footer
|
||||
className="mt-14"
|
||||
style={{
|
||||
borderTop: "1px solid var(--line)",
|
||||
background: "var(--panel)",
|
||||
}}
|
||||
>
|
||||
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 py-4 flex items-center justify-between gap-4">
|
||||
<span className="meta text-[12px] whitespace-nowrap">© {year} {brand}</span>
|
||||
<FooterTiming version={realVersion(pkg.version)} />
|
||||
|
||||
@@ -199,9 +199,7 @@ export default function Header({
|
||||
className="sticky top-0 z-50 border-b"
|
||||
style={{
|
||||
borderColor: "var(--line)",
|
||||
background: "color-mix(in srgb, var(--panel) 86%, transparent)",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
background: "var(--panel)",
|
||||
}}
|
||||
>
|
||||
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 h-[var(--header-bar)] flex items-center gap-4 lg:gap-7">
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function PinnedPosts({
|
||||
<Link
|
||||
key={p.id}
|
||||
href={`/post/${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(--panel-2)] focus-visible:bg-[var(--panel-2)] j13-fade-up"
|
||||
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)" } : {}),
|
||||
animationDelay: `${i * 35}ms`,
|
||||
|
||||
@@ -45,7 +45,7 @@ export default async function PostRow({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex items-center px-4 sm:px-5 border-t cursor-pointer transition-colors hover:bg-[var(--panel-2)] focus-within:bg-[var(--panel-2)] j13-fade-up ${
|
||||
className={`group relative flex items-center px-4 sm:px-5 border-t cursor-pointer transition-colors hover:bg-[var(--item-hover-bg)] focus-within:bg-[var(--item-hover-bg)] j13-fade-up ${
|
||||
compact ? "gap-2.5 sm:gap-3" : "gap-3.5 sm:gap-4"
|
||||
}`}
|
||||
style={{
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Ban, Loader2, RotateCcw, ShieldCheck, Users as UsersIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Ban, ExternalLink, Loader2, RotateCcw, ShieldCheck, Users as UsersIcon } from "lucide-react";
|
||||
import { apiAdminSetUserBan, apiAdminSetUserRole, type Board } from "@/lib/api";
|
||||
import { ROLES, ROLE_META } from "@/lib/roles";
|
||||
import { ROLES, ROLE_META, roleMeta } from "@/lib/roles";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { toast } from "@/lib/toast";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import Modal from "@/components/Modal";
|
||||
import RoleBadge from "@/components/RoleBadge";
|
||||
|
||||
/** 角色与账号弹窗所需的最小用户形状(后台列表项 / 公开资料均可) */
|
||||
export interface RoleAccountTarget {
|
||||
@@ -15,6 +19,10 @@ export interface RoleAccountTarget {
|
||||
role: string;
|
||||
board_ids?: number[];
|
||||
banned: boolean;
|
||||
avatar?: string;
|
||||
email?: string;
|
||||
points?: number;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
interface RoleOption {
|
||||
@@ -125,9 +133,7 @@ export default function RoleAccountModal({
|
||||
if (willUnban) parts.push("已解封");
|
||||
toast(parts.join(" · ") || "已保存", "ok");
|
||||
onSaved({
|
||||
id: target.id,
|
||||
username: target.username,
|
||||
nickname: target.nickname,
|
||||
...target,
|
||||
role: nextRole,
|
||||
board_ids: nextBoards,
|
||||
banned: nextBanned,
|
||||
@@ -140,26 +146,20 @@ export default function RoleAccountModal({
|
||||
};
|
||||
|
||||
const saveLabel = willBan ? "保存并封禁" : willUnban ? "保存并解封" : "保存";
|
||||
const displayName = target.nickname || target.username;
|
||||
const currentMeta = roleMeta(target.role);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title="角色与账号"
|
||||
description={
|
||||
<>
|
||||
<span className="truncate block">
|
||||
@{target.username}({target.nickname})
|
||||
</span>
|
||||
<span className="block mt-1">
|
||||
角色决定后台入口与前台管理能力;封禁立即强制下线。站长内容档案仍走列表入口。
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
header={<IdentityHeader target={target} displayName={displayName} currentMetaLabel={currentMeta.label} />}
|
||||
onClose={onClose}
|
||||
busy={acting}
|
||||
maxWidthClass="max-w-[500px]"
|
||||
panelClassName="p-6 max-h-[88vh] overflow-y-auto"
|
||||
ariaLabel="角色与账号"
|
||||
maxWidthClass="max-w-[540px]"
|
||||
panelClassName="p-6 max-h-[88vh] flex flex-col overflow-hidden"
|
||||
bodyClassName="overflow-y-auto flex-1 min-h-0 -mx-1 px-1"
|
||||
ariaLabel={`设置 ${displayName} 的角色与账号`}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-line" onClick={onClose} disabled={acting}>
|
||||
@@ -177,7 +177,7 @@ export default function RoleAccountModal({
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="mb-2 text-[12.5px]" style={{ color: "var(--ink)" }}>
|
||||
<p className="mb-2.5 text-[12px] font-medium tracking-wide" style={{ color: "var(--ink-3)" }}>
|
||||
管理角色
|
||||
</p>
|
||||
<div className="flex flex-col gap-2" role="radiogroup" aria-label="管理角色">
|
||||
@@ -231,7 +231,7 @@ export default function RoleAccountModal({
|
||||
|
||||
{role === ROLES.BOARD_ADMIN && (
|
||||
<div className="mt-4">
|
||||
<p className="text-[12.5px] mb-2" style={{ color: "var(--ink)" }}>
|
||||
<p className="text-[12px] font-medium tracking-wide mb-2" style={{ color: "var(--ink-3)" }}>
|
||||
授权板块 <span style={{ color: "var(--danger)" }}>*</span>
|
||||
</p>
|
||||
{boards.length === 0 ? (
|
||||
@@ -280,7 +280,7 @@ export default function RoleAccountModal({
|
||||
)}
|
||||
|
||||
<div className="mt-5 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<p className="text-[12.5px] mb-2" style={{ color: "var(--ink)" }}>
|
||||
<p className="text-[12px] font-medium tracking-wide mb-2.5" style={{ color: "var(--ink-3)" }}>
|
||||
账号状态
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2" role="radiogroup" aria-label="账号状态">
|
||||
@@ -289,17 +289,17 @@ export default function RoleAccountModal({
|
||||
role="radio"
|
||||
aria-checked={!banned}
|
||||
onClick={() => setBanned(false)}
|
||||
className="rounded-xl px-3 py-2.5 text-left transition-colors"
|
||||
className="rounded-xl px-3.5 py-3 text-left transition-colors"
|
||||
style={{
|
||||
border: `1.5px solid ${!banned ? "var(--ok)" : "var(--line)"}`,
|
||||
background: !banned ? "var(--ok-soft)" : "var(--panel-2)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex items-center gap-1.5 text-[13px] font-medium"
|
||||
className="flex items-center gap-1.5 text-[13.5px] font-medium"
|
||||
style={{ color: !banned ? "var(--ok)" : "var(--ink)" }}
|
||||
>
|
||||
<RotateCcw size={14} /> 正常
|
||||
<RotateCcw size={15} /> 正常
|
||||
</span>
|
||||
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
|
||||
可登录与互动
|
||||
@@ -310,17 +310,17 @@ export default function RoleAccountModal({
|
||||
role="radio"
|
||||
aria-checked={banned}
|
||||
onClick={() => setBanned(true)}
|
||||
className="rounded-xl px-3 py-2.5 text-left transition-colors"
|
||||
className="rounded-xl px-3.5 py-3 text-left transition-colors"
|
||||
style={{
|
||||
border: `1.5px solid ${banned ? "var(--danger)" : "var(--line)"}`,
|
||||
background: banned ? "var(--danger-soft)" : "var(--panel-2)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex items-center gap-1.5 text-[13px] font-medium"
|
||||
className="flex items-center gap-1.5 text-[13.5px] font-medium"
|
||||
style={{ color: banned ? "var(--danger)" : "var(--ink)" }}
|
||||
>
|
||||
<Ban size={14} /> 封禁
|
||||
<Ban size={15} /> 封禁
|
||||
</span>
|
||||
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
|
||||
强制下线,禁止登录
|
||||
@@ -328,7 +328,7 @@ export default function RoleAccountModal({
|
||||
</button>
|
||||
</div>
|
||||
{willBan && (
|
||||
<p className="mt-2 text-[12px] leading-relaxed" style={{ color: "var(--danger)" }}>
|
||||
<p className="mt-2.5 text-[12px] leading-relaxed" style={{ color: "var(--danger)" }}>
|
||||
封禁后立即强制下线,无法登录、发帖与回复;已发布内容保留。角色变更与封禁将一并保存。
|
||||
</p>
|
||||
)}
|
||||
@@ -336,3 +336,89 @@ export default function RoleAccountModal({
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function IdentityHeader({
|
||||
target,
|
||||
displayName,
|
||||
currentMetaLabel,
|
||||
}: {
|
||||
target: RoleAccountTarget;
|
||||
displayName: string;
|
||||
currentMetaLabel: string;
|
||||
}) {
|
||||
const metaBits: string[] = [`ID ${target.id}`];
|
||||
if (typeof target.points === "number") {
|
||||
metaBits.push(`积分 ${target.points.toLocaleString("zh-CN")}`);
|
||||
}
|
||||
if (target.created_at) {
|
||||
metaBits.push(`注册 ${formatDate(target.created_at)}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p
|
||||
className="text-[11px] font-medium uppercase tracking-[0.14em] mb-3"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
角色与账号
|
||||
</p>
|
||||
<div
|
||||
className="flex items-start gap-4 min-w-0 rounded-2xl p-3.5"
|
||||
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
|
||||
>
|
||||
{/* 描边用 box-shadow,勿把实色垫在默认头像后面:.av-* 底是半透明,会透出垫色把字母对比打翻 */}
|
||||
<span
|
||||
className="inline-flex items-center justify-center shrink-0 rounded-full"
|
||||
style={{
|
||||
boxShadow: target.banned
|
||||
? "0 0 0 3px var(--panel-2), 0 0 0 4.5px var(--danger)"
|
||||
: "0 0 0 3px var(--panel-2), 0 0 0 4.5px color-mix(in srgb, var(--accent) 38%, transparent)",
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
<Avatar
|
||||
name={displayName}
|
||||
src={target.avatar || undefined}
|
||||
size={68}
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<p
|
||||
className="text-[18px] sm:text-[20px] font-extrabold tracking-tight leading-tight truncate"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{displayName}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[13px] truncate" style={{ color: "var(--ink-2)" }}>
|
||||
@{target.username}
|
||||
{target.email ? ` · ${target.email}` : ""}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-1.5 flex-wrap">
|
||||
<RoleBadge role={target.role} boardIds={target.board_ids} />
|
||||
<span
|
||||
className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium leading-[1.6]"
|
||||
style={
|
||||
target.banned
|
||||
? { background: "var(--danger-soft)", color: "var(--danger)" }
|
||||
: { background: "var(--ok-soft)", color: "var(--ok)" }
|
||||
}
|
||||
>
|
||||
{target.banned ? "已封禁" : "正常"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-[11.5px] tabular-nums leading-relaxed" style={{ color: "var(--ink-3)" }}>
|
||||
{metaBits.join(" · ")}
|
||||
<span className="sr-only">当前角色 {currentMetaLabel}</span>
|
||||
</p>
|
||||
<Link
|
||||
href={`/u/${target.id}`}
|
||||
className="mt-2.5 inline-flex items-center gap-1 text-[12.5px] font-medium hover:underline"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
查看个人主页 <ExternalLink size={12} aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@ export default function SiteChrome({
|
||||
// 首屏跟 SSR;之后在渲染期直接读 pathname,软导航无 Header 闪动
|
||||
const isAdmin = mounted ? pathIsAdmin(pathname) : initialIsAdmin;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
document.documentElement.dataset.j13Chrome = isAdmin ? "admin" : "site";
|
||||
}, [isAdmin]);
|
||||
|
||||
if (isAdmin) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ export interface StaffUserTarget {
|
||||
board_ids?: number[];
|
||||
/** 有权访客资料接口下发;未知时按未封禁处理 */
|
||||
banned?: boolean;
|
||||
avatar?: string;
|
||||
created_at?: string;
|
||||
points?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,6 +130,9 @@ export default function StaffUserMenu({
|
||||
role: target.role,
|
||||
board_ids: target.board_ids,
|
||||
banned,
|
||||
avatar: target.avatar,
|
||||
created_at: target.created_at,
|
||||
points: target.points,
|
||||
};
|
||||
|
||||
const itemCls =
|
||||
|
||||
@@ -4,29 +4,33 @@ import { useEffect, useRef } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { apiGetSettings } from "@/lib/api";
|
||||
import { applyAccentTheme } from "@/lib/theme";
|
||||
import { applyCustomCSS } from "@/lib/customCode";
|
||||
import {
|
||||
applySiteBackground,
|
||||
bgConfigFromSettings,
|
||||
type SiteBgConfig,
|
||||
type SiteBgInput,
|
||||
} from "@/lib/bg";
|
||||
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
|
||||
import {
|
||||
VISIBLE_RECONCILE_GAP_MS,
|
||||
onDocumentVisible,
|
||||
} from "@/lib/visibilityReconcile";
|
||||
|
||||
// 站点外观同步器:
|
||||
// 主题色 / 自定义 CSS 由管理员在后台修改后,其他用户已打开的标签页不会因软导航重新执行根布局
|
||||
// SSR,本组件通过 WS 推送 + 标签页重新可见时探测公开设置,发现变化即热替换。
|
||||
// 自定义 JS 只在整页加载时执行,不在此热替换(避免重复绑定)。
|
||||
// 站点主题色 / 背景图同步器:
|
||||
// 管理员保存后,其他已打开标签页不会因软导航重跑根布局 SSR。
|
||||
// WS 推送 + 标签页重新可见时探测公开设置,热替换 accent 与背景。
|
||||
const MIN_FETCH_GAP_MS = VISIBLE_RECONCILE_GAP_MS;
|
||||
|
||||
export default function ThemeSync({
|
||||
initialAccent,
|
||||
initialCustomCSS,
|
||||
initialBg,
|
||||
}: {
|
||||
initialAccent: string;
|
||||
initialCustomCSS: string;
|
||||
initialBg: SiteBgConfig;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const currentRef = useRef((initialAccent || "").trim().toLowerCase());
|
||||
const cssRef = useRef((initialCustomCSS || "").trim());
|
||||
const bgRef = useRef(initialBg);
|
||||
const lastFetchAtRef = useRef(0);
|
||||
const inFlightRef = useRef(false);
|
||||
|
||||
@@ -42,12 +46,11 @@ export default function ThemeSync({
|
||||
applyAccentTheme(next || null);
|
||||
};
|
||||
|
||||
const applyCSS = (next: string) => {
|
||||
const applyBg = (raw: SiteBgInput) => {
|
||||
if (cancelled) return;
|
||||
const text = (next || "").trim();
|
||||
if (text === cssRef.current) return;
|
||||
cssRef.current = text;
|
||||
applyCustomCSS(text);
|
||||
const next = bgConfigFromSettings(raw);
|
||||
bgRef.current = next;
|
||||
applySiteBackground(next);
|
||||
};
|
||||
|
||||
const sync = async () => {
|
||||
@@ -57,10 +60,10 @@ export default function ThemeSync({
|
||||
lastFetchAtRef.current = now;
|
||||
inFlightRef.current = true;
|
||||
try {
|
||||
const settings = await apiGetSettings();
|
||||
const s = await apiGetSettings();
|
||||
if (cancelled) return;
|
||||
applyAccent((settings.accent || "").trim().toLowerCase());
|
||||
applyCSS(settings.custom_css || "");
|
||||
applyAccent((s.accent || "").trim().toLowerCase());
|
||||
applyBg(s);
|
||||
} catch {
|
||||
// 同步失败静默,下次回前台再试
|
||||
} finally {
|
||||
@@ -71,7 +74,7 @@ export default function ThemeSync({
|
||||
const offPush = realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
|
||||
if (!data) return;
|
||||
applyAccent((data.accent || "").trim().toLowerCase());
|
||||
if (typeof data.custom_css === "string") applyCSS(data.custom_css);
|
||||
applyBg(data);
|
||||
});
|
||||
|
||||
const offVisible = onDocumentVisible(() => void sync(), MIN_FETCH_GAP_MS);
|
||||
|
||||
@@ -46,20 +46,27 @@ export function PulseCheckin({ initial }: { initial?: CheckinStatus | null }) {
|
||||
<p className="text-[12.5px] leading-tight" style={{ color: "var(--ink)" }}>
|
||||
每日签到
|
||||
</p>
|
||||
<p className="meta mt-0.5 truncate text-[11px]">
|
||||
{guest
|
||||
? "登录后每日签到领积分"
|
||||
: checked
|
||||
? `连签 ${status?.streak ?? 0} 天 · 余额 ${status?.total_points ?? 0}`
|
||||
: `连签 ${status?.streak ?? 0} 天 · 今日 +${status?.today_points ?? 5}`}
|
||||
{!guest && (
|
||||
<p className="meta mt-0.5 flex min-w-0 items-center gap-1 text-[11px]">
|
||||
<span className="min-w-0 truncate">
|
||||
{guest
|
||||
? "登录后每日签到领积分"
|
||||
: checked
|
||||
? `连签 ${status?.streak ?? 0} 天 · 余额 ${status?.total_points ?? 0}`
|
||||
: `连签 ${status?.streak ?? 0} 天 · 今日 +${status?.today_points ?? 5}`}
|
||||
</span>
|
||||
{!guest ? (
|
||||
<>
|
||||
{" · "}
|
||||
<Link href="/points" className="hover:text-[var(--accent)] transition-colors">
|
||||
<span className="shrink-0" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<Link
|
||||
href="/points"
|
||||
className="j13-checkin-flow shrink-0 rounded-sm px-0.5 -mx-0.5 hover:text-[var(--accent)] transition-colors"
|
||||
>
|
||||
流水
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
{guest ? (
|
||||
|
||||
@@ -296,7 +296,7 @@ function NewUsers({ users }: { users: NewUser[] }) {
|
||||
<li key={u.id}>
|
||||
<Link
|
||||
href={`/u/${u.id}`}
|
||||
className="flex flex-col items-center gap-1.5 rounded-lg px-1 py-1 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
|
||||
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} />
|
||||
<span
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { PenSquare, Home } from "lucide-react";
|
||||
import { PenSquare, Home, LayoutGrid } from "lucide-react";
|
||||
import type { BoardWithCount, User } from "@/lib/api";
|
||||
|
||||
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
|
||||
@@ -10,7 +10,7 @@ function boardHref(id: number) {
|
||||
return `/?board=${id}`;
|
||||
}
|
||||
|
||||
// 桌面左栏:板块导航
|
||||
// 桌面左栏:整块模块,与右栏 j13-module 对齐
|
||||
export function WorkspaceNav({ boards }: { boards: BoardWithCount[]; user?: User | null }) {
|
||||
const params = useSearchParams();
|
||||
const activeBoard = Number(params.get("board")) || 0;
|
||||
@@ -18,57 +18,57 @@ export function WorkspaceNav({ boards }: { boards: BoardWithCount[]; user?: User
|
||||
return (
|
||||
// sticky 必须挂在 flex 子项上:父级 items-start 会把内层缩成内容高,失去粘滞行程
|
||||
<nav
|
||||
className="hidden lg:block w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)] j13-rail-scroll"
|
||||
className="hidden lg:block w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)]"
|
||||
aria-label="工作台导航"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* 「全部讨论」是重置筛选的动作入口,与板块目录分区展示;卡片样式与下方板块一致 */}
|
||||
<div className="space-y-0.5">
|
||||
<Link href="/" className="j13-bcard" aria-current={activeBoard === 0 ? "page" : undefined}>
|
||||
<section className="j13-module flex max-h-[var(--rail-max-h)] flex-col overflow-hidden">
|
||||
<p className="j13-module-title shrink-0">
|
||||
<LayoutGrid size={15} style={{ color: "var(--accent)" }} aria-hidden />
|
||||
板块目录
|
||||
{boards.length > 0 ? (
|
||||
<span className="ml-auto meta text-[11.5px] font-medium tracking-normal tabular-nums">
|
||||
{boards.length} 个
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
|
||||
<div className="j13-rail-scroll j13-board-list mt-2.5 min-h-0 flex-1">
|
||||
<Link
|
||||
href="/"
|
||||
className="j13-bcard"
|
||||
aria-current={activeBoard === 0 ? "page" : undefined}
|
||||
>
|
||||
<span
|
||||
className="j13-bcard-ico"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Home size={16} />
|
||||
</span>
|
||||
<span className="j13-bcard-name flex-1 truncate text-[14px]">
|
||||
全部讨论
|
||||
<Home size={15} />
|
||||
</span>
|
||||
<span className="j13-bcard-name flex-1 truncate">全部讨论</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 板块目录:纯分区标题;下方卡片仅在中栏筛选该板帖子流 */}
|
||||
<div>
|
||||
<div className="px-1 mb-2 flex items-center justify-between text-[11.5px] font-medium tracking-[0.08em] uppercase text-[var(--ink-3)]">
|
||||
<span>板块目录</span>
|
||||
{boards.length > 0 && (
|
||||
<span className="tracking-normal tabular-nums">{boards.length} 个</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{boards.map((b, i) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={boardHref(b.id)}
|
||||
className="j13-bcard"
|
||||
aria-current={activeBoard === b.id ? "page" : undefined}
|
||||
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
|
||||
>
|
||||
<span className={`av-${i % 8} j13-bcard-ico`} aria-hidden="true">
|
||||
{b.name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 flex items-center justify-between gap-2">
|
||||
<span className="j13-bcard-name truncate text-[14px]">
|
||||
{b.name}
|
||||
</span>
|
||||
<span className="meta shrink-0 text-[11px] tabular-nums">{b.post_count} 篇</span>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{boards.length > 0 ? <div className="j13-board-split" role="separator" /> : null}
|
||||
|
||||
{boards.map((b, i) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={boardHref(b.id)}
|
||||
className="j13-bcard"
|
||||
aria-current={activeBoard === b.id ? "page" : undefined}
|
||||
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
|
||||
>
|
||||
<span className={`av-${i % 8} j13-bcard-ico`} aria-hidden="true">
|
||||
{b.name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 flex items-center justify-between gap-2">
|
||||
<span className="j13-bcard-name truncate">{b.name}</span>
|
||||
<span className="meta shrink-0 text-[11px] tabular-nums">{b.post_count} 篇</span>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,17 @@ import { ChevronRight } from "lucide-react";
|
||||
|
||||
export type Crumb = { href?: string; label: string };
|
||||
|
||||
/** 位置指引:桌面叠在中栏卡片上方的内边距里,不拉低卡片、不对齐到左栏 */
|
||||
export default function SiteDocBreadcrumb({ items }: { items: Crumb[] }) {
|
||||
/** 位置指引:嵌在正文面板顶部,不单独成块 */
|
||||
export default function SiteDocBreadcrumb({
|
||||
items,
|
||||
className,
|
||||
}: {
|
||||
items: Crumb[];
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<nav
|
||||
className="meta mb-2 flex items-center gap-1 flex-wrap text-[12.5px] leading-none lg:absolute lg:bottom-full lg:left-0 lg:right-0 lg:mb-0 lg:pb-2 lg:pl-1"
|
||||
className={`j13-page-crumb meta flex items-center gap-1 flex-wrap text-[12.5px] leading-none ${className ?? ""}`}
|
||||
aria-label="面包屑"
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function SiteDocShell({
|
||||
left?: ReactNode;
|
||||
right?: ReactNode;
|
||||
children: ReactNode;
|
||||
/** 中栏卡片上方的位置指引;桌面绝对定位,不占栅格高度 */
|
||||
/** 中栏正文上方的位置指引(流式排布,不再绝对定位) */
|
||||
breadcrumb?: ReactNode;
|
||||
/** 窄屏下沉到正文之后的侧栏副本 */
|
||||
mobileExtras?: ReactNode;
|
||||
@@ -29,7 +29,7 @@ export default function SiteDocShell({
|
||||
{left}
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 relative">
|
||||
<div className="min-w-0">
|
||||
{breadcrumb}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user