|
-
- {p.title}
-
+ {p.deleted ? (
+
+ {p.title}
+
+ ) : (
+
+ {p.title}
+
+ )}
{path}
{p.excerpt ? {p.excerpt} : null}
|
-
- {p.published ? "已发布" : "草稿"}
-
+ {p.deleted ? (
+ 已删除
+ ) : (
+
+ {p.published ? "已发布" : "草稿"}
+
+ )}
|
{placeLabel(p)} |
@@ -200,10 +223,12 @@ export default function PagesAdmin({
|
) : null}
@@ -277,15 +302,17 @@ export default function PagesAdmin({
deletingId == null && setDeleteTarget(null)}
onConfirm={() => {
if (deleteTarget) void remove(deleteTarget);
diff --git a/frontend/app/admin/settings/BasicSettings.tsx b/frontend/app/admin/settings/BasicSettings.tsx
index c7a2837..a9f802c 100644
--- a/frontend/app/admin/settings/BasicSettings.tsx
+++ b/frontend/app/admin/settings/BasicSettings.tsx
@@ -6,6 +6,8 @@ import useUnsavedGuard from "@/hooks/useUnsavedGuard";
import BasicIdentityPanel from "./BasicIdentityPanel";
import BrandSeoPanel from "./BrandSeoPanel";
import FooterLinksPanel from "./FooterLinksPanel";
+import HeaderNavPanel from "./HeaderNavPanel";
+import MobileTabBarPanel from "./MobileTabBarPanel";
import UrlStylePanel from "./UrlStylePanel";
import type { PublicSettings } from "@/lib/api";
@@ -15,19 +17,25 @@ export default function BasicSettings({ initial }: { initial: PublicSettings })
const [brandDirty, setBrandDirty] = useState(false);
const [seoDirty, setSeoDirty] = useState(false);
const [urlStyleDirty, setUrlStyleDirty] = useState(false);
- const dirty = basicDirty || brandDirty || seoDirty || urlStyleDirty;
+ const [mobileTabsDirty, setMobileTabsDirty] = useState(false);
+ const [headerNavDirty, setHeaderNavDirty] = useState(false);
+ const dirty = basicDirty || brandDirty || seoDirty || urlStyleDirty || mobileTabsDirty || headerNavDirty;
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), []);
+ const onMobileTabsDirty = useCallback((d: boolean) => setMobileTabsDirty(d), []);
+ const onHeaderNavDirty = useCallback((d: boolean) => setHeaderNavDirty(d), []);
return (
+
+
(null);
+ const [copiedSlot, setCopiedSlot] = useState(null);
const blobs = useRef([]);
const saveGen = useRef(0);
@@ -203,6 +205,21 @@ export default function BrandSeoPanel({
const slotDraft = (id: BrandSlot) =>
id === "logo_dark" ? dark : id === "favicon" ? favicon : light;
+ // 已保存的槽位 URL:存在才展示固定地址(未保存过无图可引用)
+ const savedOf = (slot: BrandSlot) =>
+ slot === "logo_dark" ? saved.dark : slot === "favicon" ? saved.favicon : saved.light;
+
+ const copyAlias = async (slot: BrandSlot) => {
+ try {
+ await navigator.clipboard.writeText(`${window.location.origin}${brandAliasPath(slot)}`);
+ setCopiedSlot(slot);
+ toast("已复制固定地址", "ok");
+ window.setTimeout(() => setCopiedSlot((cur) => (cur === slot ? null : cur)), 1500);
+ } catch {
+ toast("复制失败,请手动复制");
+ }
+ };
+
const renderSlot = (slot: BrandSlot, label: string, swatch: "light" | "dark" | "neutral", compact?: boolean) => {
const draft = slotDraft(slot);
const preview = previewOf(draft);
@@ -224,6 +241,18 @@ export default function BrandSeoPanel({
{label}
+ {savedOf(slot) ? (
+
+ ) : null}
{showReplaceWarning ? (
@@ -300,7 +356,7 @@ export default function TimelineEditor({
setItems((prev) => [...prev, emptyItem()])}
+ onClick={() => setItems((prev) => [emptyItem(), ...prev])}
>
添加
@@ -408,6 +464,7 @@ export default function TimelineEditor({
title="删除条目"
message="确定删除这条时间线项目?"
danger
+ zIndexClass="z-[1300]"
confirmLabel="删除"
onCancel={() => setDeleteIdx(null)}
onConfirm={() => {
diff --git a/frontend/components/admin/AdminShell.tsx b/frontend/components/admin/AdminShell.tsx
index 59cddc8..d5a8dd7 100644
--- a/frontend/components/admin/AdminShell.tsx
+++ b/frontend/components/admin/AdminShell.tsx
@@ -22,6 +22,7 @@ import {
Settings,
ChevronDown,
FileText,
+ BookOpen,
CircleDollarSign,
Columns3,
PanelLeft,
@@ -29,6 +30,7 @@ import {
Award,
Layers,
Images,
+ Link2,
} from "lucide-react";
import type { User } from "@/lib/api";
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
@@ -108,6 +110,18 @@ const NAV_GROUPS: NavGroup[] = [
icon: FileText,
visible: (u) => isAdminOrAbove(u.role),
},
+ {
+ href: "/admin/library",
+ label: "书库管理",
+ icon: BookOpen,
+ visible: (u) => isAdminOrAbove(u.role),
+ },
+ {
+ href: "/admin/friend-links",
+ label: "友情链接",
+ icon: Link2,
+ visible: (u) => isSuperOrOwner(u.role),
+ },
],
},
{
diff --git a/frontend/components/admin/charts.tsx b/frontend/components/admin/charts.tsx
index 549f04e..0e0ad6e 100644
--- a/frontend/components/admin/charts.tsx
+++ b/frontend/components/admin/charts.tsx
@@ -66,10 +66,14 @@ export function AdminChartCard({
{actions}
- {/* 捕获阶段拦截 mousedown:冒泡阶段太晚,浏览器已开始 SVG 多柱选区黑框 */}
+ {/* 捕获阶段拦截 SVG 内 mousedown:冒泡阶段太晚,浏览器已开始选区黑框;非 SVG(输入框/表格)不拦截,否则无法聚焦输入 */}
+ {/* overflow-hidden 会沿自身 padding box 裁剪:px/p- 相同值负 margin 外扩裁剪边界 3px(内容宽度不变),
+ 避免贴边子元素(如明细工具条首个搜索框)的 2px 聚焦光环被裁掉 */}
e.preventDefault()}
+ className="overflow-hidden px-[3px] -mx-[3px] [&_svg]:select-none [&_svg]:outline-none [&_.recharts-surface]:outline-none [&_.recharts-tooltip-cursor]:hidden"
+ onMouseDownCapture={(e) => {
+ if ((e.target as Element).closest("svg")) e.preventDefault();
+ }}
>
{children}
diff --git a/frontend/components/home/CommunityPanels.tsx b/frontend/components/home/CommunityPanels.tsx
index 3fcaaec..b4a79f4 100644
--- a/frontend/components/home/CommunityPanels.tsx
+++ b/frontend/components/home/CommunityPanels.tsx
@@ -26,13 +26,13 @@ import type {
SidebarPageItem,
PublicAdItem,
PublicSponsorItem,
+ FriendLink,
} 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";
import { pageGlyph } from "@/lib/pageGlyph";
import { AboutSiteHeader } from "@/components/site-doc";
@@ -396,38 +396,53 @@ export async function ActiveUsers({ users, allowComments }: { users: ActiveUser[
/* ---------- 友情链接 ---------- */
-export function FriendLinks() {
- if (FRIEND_LINKS.length === 0) return null;
+export function FriendLinks({ links }: { links: FriendLink[] }) {
+ // 块显隐由侧栏布局配置控制;列表为空时仍渲染(保留「申请」入口),仅展示占位提示
return (
友情链接
-
- 申请
-
+
+
+ 全部
+
+
+ 申请
+
+
-
- {FRIEND_LINKS.map((l) =>
- l.href.startsWith("http") ? (
+ {links.length === 0 ? (
+ 暂无友链,欢迎申请
+ ) : (
+
+ ))}
+
+ )}
);
}
@@ -452,6 +467,7 @@ type PanelsProps = {
sponsors?: PublicSponsorItem[];
sponsorsPanelTitle?: string;
sponsorsEnabled?: boolean;
+ friendLinks?: FriendLink[];
};
export function AdsPanel({
@@ -615,6 +631,7 @@ function CommunityPanelsBody({
sponsors = [],
sponsorsPanelTitle = "赞助商",
sponsorsEnabled = true,
+ friendLinks = [],
}: PanelsProps) {
return (
<>
@@ -625,7 +642,7 @@ function CommunityPanelsBody({
-
+
(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
+ const [drawer, setDrawer] = useState<"left" | "right" | null>(null);
+ useDrawerChrome(drawer !== null, () => setDrawer(null));
const updateScrollState = useCallback(() => {
const el = scrollRef.current;
@@ -151,43 +166,88 @@ export function WorkspaceNavMobile({ boards }: { boards: BoardWithCount[] }) {
});
}, [activeBoard, boards]);
+ const triggerBtn =
+ "relative inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border transition-colors hover:bg-[var(--panel-2)] active:scale-95 " +
+ // 视觉 28px、触控热区扩到 44px(HIG 最小触控目标),仅扩大命中范围不改变外观
+ "before:absolute before:-inset-2 before:rounded-full before:content-['']";
+
return (
-
-
-
+ {left ? (
+ setDrawer("left")}
+ aria-haspopup="dialog"
+ aria-label="打开导航抽屉"
+ >
+
+
+ ) : null}
+
+
+
- 全部
-
- {boards.map((b) => (
-
- {b.name}
+ 全部
- ))}
+ {boards.map((b) => (
+
+
+ {b.name}
+
+ ))}
+
+ {/* 渐隐滑动指示:遮罩与背景同色,pointer-events-none 不挡触控 */}
+
+
- {/* 渐隐滑动指示:遮罩与背景同色,pointer-events-none 不挡触控 */}
-
-
+
+ {right ? (
+ setDrawer("right")}
+ aria-haspopup="dialog"
+ aria-label="打开面板抽屉"
+ >
+
+
+ ) : null}
+
+ {/* 左右抽屉:内容由服务端渲染后传入;lg 起隐藏(桌面用常驻侧栏) */}
+ {left ? (
+ setDrawer(null)}>
+ {left}
+
+ ) : null}
+ {right ? (
+ setDrawer(null)}>
+ {right}
+
+ ) : null}
);
}
diff --git a/frontend/components/markdown/MdEntries.tsx b/frontend/components/markdown/MdEntries.tsx
new file mode 100644
index 0000000..687edbd
--- /dev/null
+++ b/frontend/components/markdown/MdEntries.tsx
@@ -0,0 +1,197 @@
+import { ChevronRight } from "lucide-react";
+import type { ReactNode } from "react";
+import { renderMarkdownToReact } from "@/components/markdown/renderMarkdown";
+import { ENTRY_TITLE_NUM_RE, type EntryItem } from "@/lib/entryBlocks";
+
+/** 标签 chip:"主:次" 或 "主=次" 形式渲染主次两段,否则单段;不做语义配色,保持字段通用 */
+function EntryTag({ tag }: { tag: string }) {
+ const idx = tag.search(/[::=]/);
+ const main = idx >= 0 ? tag.slice(0, idx) : tag;
+ const sub = idx >= 0 ? tag.slice(idx + 1) : "";
+ return (
+
+
+ {main}
+
+ {sub ? (
+ {sub}
+ ) : null}
+
+ );
+}
+
+/**
+ * 条目卡纯展示视图:编号标题 + 标签 chips + 高亮摘要 + 通用字段行 + 折叠来源。
+ * 字段名不限定(成本/收益/备注只是书写约定),任何书籍可定义自己的字段;
+ * markdown 片段由调用方注入(服务端 renderMarkdownToReact / 客户端 ReactMarkdown 预览)。
+ */
+export function MdEntriesView({
+ items,
+ renderMd,
+ titleIds,
+}: {
+ items: EntryItem[];
+ renderMd: (text: string) => ReactNode;
+ /** 与目录共享的标题锚点 id(按条目顺序;缺省不挂 id) */
+ titleIds?: (string | undefined)[];
+}) {
+ return (
+
+ {items.map((it, i) => (
+
+ {!it.plain ? (
+
+ {ENTRY_TITLE_NUM_RE.test(it.title) ? null : (
+
+ {i + 1}
+
+ )}
+ {it.title}
+
+ ) : null}
+
+ {it.tags.length > 0 ? (
+
+ {it.tags.map((tag, ti) => (
+
+ ))}
+
+ ) : null}
+
+ {it.summary?.trim() ? (
+
+ {renderMd(it.summary)}
+
+ ) : null}
+
+ {it.fields.length > 0 ? (
+
+ {it.fields.map((f, fi) => (
+
+ -
+ {f.label}
+
+ -
+ {f.value.trim() ? renderMd(f.value) : null}
+
+
+ ))}
+
+ ) : null}
+
+ {it.sources.length > 0 ? (
+
+
+
+ 来源 · {it.sources.length} 条
+
+
+ {it.sources.map((s, si) => (
+
+ ))}
+
+
+ ) : null}
+
+ ))}
+
+ );
+}
+
+/** 服务端条目卡:markdown 片段走站点统一渲染管线(Shiki 等) */
+export default async function MdEntries({
+ items,
+ titleIds,
+}: {
+ items: EntryItem[];
+ titleIds?: (string | undefined)[];
+}) {
+ const cache = new Map ();
+ await Promise.all(
+ items.flatMap((it) => {
+ const put = async (text?: string) => {
+ if (!text?.trim() || cache.has(text)) return;
+ cache.set(text, await renderMarkdownToReact(text));
+ };
+ return [put(it.summary), ...it.fields.map((f) => put(f.value))];
+ })
+ );
+ return cache.get(t) ?? null} />;
+}
diff --git a/frontend/components/markdown/MdTimeline.tsx b/frontend/components/markdown/MdTimeline.tsx
index 43afd41..656e84b 100644
--- a/frontend/components/markdown/MdTimeline.tsx
+++ b/frontend/components/markdown/MdTimeline.tsx
@@ -48,34 +48,44 @@ export default function MdTimeline({ items, renderBody }: MdTimelineProps) {
style={{ scrollMarginTop: 90 }}
aria-labelledby={`${timelineDayId(g.date)}-label`}
>
-
-
- {g.items.length}
-
- {g.items.map((it) => {
- const idx = flat++;
+ {g.items.map((it, idx) => {
+ const flatIdx = flat++;
const { kind, scope, rest } = splitTitlePrefix(it.title);
const bodyNode = renderBody
- ? renderBody(it, idx)
+ ? renderBody(it, flatIdx)
: it.body.trim()
? {it.body}
: null;
+ const isFirstInDay = idx === 0;
return (
- -
+
-
+ {isFirstInDay ? (
+
+
+ {g.items.length}
+
+ ) : null}
+ {rest}
{kind ? (
{kind}
{scope ? ({scope}) : null}
) : null}
- {rest}
{it.sourceUrl ? (
) : null;
case "friend_links":
- return ;
+ // 空列表也渲染(保留申请入口);显隐由侧栏布局配置控制
+ return ;
case "about_site":
return p.sidebarPages || p.canManagePages ? (
+ {/* 移动端:左右抽屉(导航 / 目录),lg 起隐藏;两侧皆空时组件自身不渲染 */}
+
+
|