feat: 友链/书库/移动端导航/排行榜等多模块功能

新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
2026-09-30 16:30:07 +08:00
parent e487bc2e92
commit 4f3f3a265c
105 changed files with 12371 additions and 852 deletions

View File

@@ -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 (
<span
className="inline-flex items-baseline gap-1 rounded-full px-2.5 py-0.5 text-[11.5px] leading-5"
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
>
<span className="font-semibold" style={{ color: "var(--ink-2)" }}>
{main}
</span>
{sub ? (
<span style={{ color: "var(--ink-3)" }}>{sub}</span>
) : null}
</span>
);
}
/**
* 条目卡纯展示视图:编号标题 + 标签 chips + 高亮摘要 + 通用字段行 + 折叠来源。
* 字段名不限定(成本/收益/备注只是书写约定),任何书籍可定义自己的字段;
* markdown 片段由调用方注入(服务端 renderMarkdownToReact / 客户端 ReactMarkdown 预览)。
*/
export function MdEntriesView({
items,
renderMd,
titleIds,
}: {
items: EntryItem[];
renderMd: (text: string) => ReactNode;
/** 与目录共享的标题锚点 id(按条目顺序;缺省不挂 id) */
titleIds?: (string | undefined)[];
}) {
return (
<div className="my-4 -mx-2 flex flex-col gap-4 sm:-mx-3">
{items.map((it, i) => (
<article
key={i}
className="j13-entry-card rounded-2xl px-3 py-4 sm:px-4 sm:py-5"
style={{
border: "1px solid var(--line)",
// 极浅灰卡底:panel-2 半量混入 panel,深浅色模式各自成立
background: "color-mix(in srgb, var(--panel-2) 50%, var(--panel))",
}}
>
{!it.plain ? (
<h2
id={titleIds?.[i]}
className="flex items-baseline gap-2.5"
style={{
color: "var(--ink)",
scrollMarginTop: 90,
// 二级标题字号(移动端经 --entry-title-size 收小,见 globals.css);
// 覆盖 .prose-content h2 的外边距与底边线(卡内不需要)
fontSize: "var(--entry-title-size, 1.4rem)",
fontWeight: 700,
lineHeight: 1.35,
margin: 0,
padding: 0,
border: "none",
}}
>
{ENTRY_TITLE_NUM_RE.test(it.title) ? null : (
<span
className="tabular-nums text-[12.5px] font-semibold shrink-0"
style={{ color: "var(--ink-3)" }}
>
{i + 1}
</span>
)}
<span className="min-w-0">{it.title}</span>
</h2>
) : null}
{it.tags.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{it.tags.map((tag, ti) => (
<EntryTag key={ti} tag={tag} />
))}
</div>
) : null}
{it.summary?.trim() ? (
<div
className="j13-entry-summary mt-3 rounded-xl px-3.5 py-3 text-[14px] leading-relaxed [&>p]:my-0"
style={{
background: "var(--accent-soft)",
borderLeft: "3px solid var(--accent)",
color: "var(--ink)",
}}
>
{renderMd(it.summary)}
</div>
) : null}
{it.fields.length > 0 ? (
<dl className="mt-3 flex flex-col gap-2.5">
{it.fields.map((f, fi) => (
<div
key={fi}
className="grid grid-cols-1 gap-y-0.5 sm:grid-cols-[76px_minmax(0,1fr)] sm:gap-x-3 sm:gap-y-0"
>
<dt className="text-[12.5px] leading-[22px]" style={{ color: "var(--ink-3)" }}>
{f.label}
</dt>
<dd
className="min-w-0 text-[14px] leading-relaxed [&>p]:my-0 [&>ul]:my-1 [&>ol]:my-1"
style={{
// 正文用正常墨色;备注/来源类说明性字段保持浅灰
color:
f.label === "备注" || f.label === "来源"
? "var(--ink-2)"
: "var(--ink)",
}}
>
{f.value.trim() ? renderMd(f.value) : null}
</dd>
</div>
))}
</dl>
) : null}
{it.sources.length > 0 ? (
<details className="group mt-3">
<summary
className="inline-flex cursor-pointer select-none list-none items-center gap-1 text-[12.5px] font-medium [&::-webkit-details-marker]:hidden"
style={{ color: "var(--ink-2)" }}
>
<ChevronRight
size={13}
className="transition-transform group-open:rotate-90"
aria-hidden
/>
来源 · {it.sources.length} 条
</summary>
<div className="mt-2 flex flex-col gap-2">
{it.sources.map((s, si) => (
<div
key={si}
className="flex gap-2 text-[12.5px] leading-relaxed"
style={{ color: "var(--ink-2)" }}
>
<span className="tabular-nums shrink-0" style={{ color: "var(--ink-3)" }}>
{si + 1}.
</span>
<div className="min-w-0">
<span>{s.text}</span>
{s.url ? (
<a
href={s.url}
target="_blank"
rel="noopener noreferrer nofollow"
className="block break-all hover:text-[var(--accent)]"
style={{ color: "var(--ink-3)" }}
>
{s.url}
</a>
) : null}
</div>
</div>
))}
</div>
</details>
) : null}
</article>
))}
</div>
);
}
/** 服务端条目卡:markdown 片段走站点统一渲染管线(Shiki 等) */
export default async function MdEntries({
items,
titleIds,
}: {
items: EntryItem[];
titleIds?: (string | undefined)[];
}) {
const cache = new Map<string, ReactNode>();
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 <MdEntriesView items={items} titleIds={titleIds} renderMd={(t) => cache.get(t) ?? null} />;
}