Files
jiang13-bbs/frontend/components/markdown/MdEntries.tsx
freefire 4f3f3a265c feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00

198 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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} />;
}