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

@@ -33,12 +33,14 @@ import {
History,
Ellipsis,
Smile,
LayoutList,
} from "lucide-react";
import { apiSearchPosts, apiUploadPostImage, type PostListItem } from "@/lib/api";
import { toast } from "@/lib/toast";
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
import ImageInsertModal from "@/components/ImageInsertModal";
import TimelineEditor from "@/components/TimelineEditor";
import { ENTRIES_TEMPLATE, ENTRIES_TEMPLATE_AUTO } from "@/lib/entryBlocks";
import EmojiPicker from "@/components/EmojiPicker";
import { resolveTimelineBlockForEdit } from "@/lib/directiveBlocks";
import { SHIKI_LANG_IDS } from "@/components/markdown/shikiHighlighter";
@@ -79,6 +81,8 @@ type MarkdownEditorProps = {
/** 提交失败等原因,显示在编辑器内 */
error?: string;
onUploadingChange?: (uploading: boolean) => void;
/** 书级「全章节条目卡模式」:插入免壳模板、预览按隐式条目渲染 */
entriesAuto?: boolean;
};
type EditorMode = "write" | "preview";
@@ -159,6 +163,7 @@ export default function MarkdownEditor({
maxChars,
error,
onUploadingChange,
entriesAuto = false,
}: MarkdownEditorProps) {
const taRef = useRef<HTMLTextAreaElement>(null);
const urlStyle = useUrlStyle();
@@ -212,10 +217,9 @@ export default function MarkdownEditor({
}, [uploading]);
useEffect(() => {
if (!isComment) return;
const el = toolbarRef.current;
if (!el) return;
const apply = () => setToolNarrow(el.clientWidth < 520);
const apply = () => setToolNarrow(el.clientWidth < (isComment ? 520 : 640));
apply();
const ro = new ResizeObserver(apply);
ro.observe(el);
@@ -742,6 +746,180 @@ export default function MarkdownEditor({
l.toLowerCase().includes(codeQuery.trim().toLowerCase())
);
// 代码块语言弹层:常规时挂在行内按钮下,窄屏收进「更多」后挂在其锚点下(居中防溢出)
const codePopover =
popover === "code" ? (
<div
className={`j13-md-popover j13-md-popover-wide${toolNarrow ? " j13-md-popover-center" : ""}`}
role="dialog"
aria-label="选择语言"
>
<input
autoFocus
className="j13-md-popover-input"
placeholder="搜索语言…"
value={codeQuery}
onChange={(e) => setCodeQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && filteredLangs[0]) {
e.preventDefault();
insertCodeBlock(filteredLangs[0]);
}
}}
/>
<div className="j13-md-popover-list">
{filteredLangs.map((lang) => (
<button key={lang} type="button" onClick={() => insertCodeBlock(lang)}>
{lang}
</button>
))}
{filteredLangs.length === 0 && (
<p className="meta px-2 py-3 text-center">无匹配语言</p>
)}
</div>
</div>
) : null;
// 隐藏内容弹层:同上,窄屏时改挂在「更多」锚点下
const hidePopover =
allowHide && popover === "hide" ? (
<div
className={`j13-md-popover j13-md-popover-form${toolNarrow ? " j13-md-popover-center" : ""}`}
role="dialog"
aria-label="插入隐藏内容"
>
{hideStep === "menu" ? (
<>
<button type="button" className="j13-md-menu-item" onClick={() => insertHide("login")}>
登录可见
</button>
<button
type="button"
className="j13-md-menu-item"
disabled={!allowReplyHide}
data-tip={allowReplyHide ? undefined : "当前内容不可评论,无法使用回复可见"}
aria-disabled={!allowReplyHide}
onClick={() => {
if (!allowReplyHide) return;
insertHide("reply");
}}
>
回复可见
{!allowReplyHide ? (
<span className="j13-md-menu-item-hint">不可用</span>
) : null}
</button>
<button
type="button"
className="j13-md-menu-item"
onClick={() => setHideStep("points")}
>
积分可见…
</button>
<button
type="button"
className="j13-md-menu-item"
onClick={() => setHideStep("password")}
>
密码可见…
</button>
</>
) : hideStep === "points" ? (
<>
<label className="j13-md-field">
<span>所需积分</span>
<input
autoFocus
type="number"
min={1}
max={100000}
value={hidePoints}
onChange={(e) => setHidePoints(Number(e.target.value) || 1)}
className="j13-md-popover-input"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (hidePoints >= 1 && hidePoints <= 100000) {
insertHide("points", hidePoints);
}
}
}}
/>
</label>
<div className="j13-md-popover-footer">
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setHideStep("menu")}
>
返回
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => {
if (hidePoints < 1 || hidePoints > 100000) {
toast("积分须在 1–100000", "error");
return;
}
insertHide("points", hidePoints);
}}
>
插入
</button>
</div>
</>
) : (
<>
<label className="j13-md-field">
<span>查看密码(不含空格,1–64 字)</span>
<input
autoFocus
type="text"
value={hidePassword}
maxLength={64}
placeholder="例如:secret"
onChange={(e) => setHidePassword(e.target.value)}
className="j13-md-popover-input"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const p = hidePassword.trim();
if (p && !/\s/.test(p)) {
insertHide("password", undefined, p);
}
}
}}
/>
</label>
<div className="j13-md-popover-footer">
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setHideStep("menu")}
>
返回
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => {
const p = hidePassword.trim();
if (!p || /\s/.test(p) || p.length > 64) {
toast("密码须为 1–64 字符且不含空格", "error");
return;
}
insertHide("password", undefined, p);
}}
>
插入
</button>
</div>
</>
)}
</div>
) : null;
const ToolBtn = ({
label,
icon,
@@ -774,7 +952,11 @@ export default function MarkdownEditor({
const Sep = () => <span className="j13-md-sep" aria-hidden />;
return (
<div className={`j13-md-editor${fullscreen ? " is-fullscreen" : ""}${isComment ? " is-comment" : ""}`}>
<div
className={`j13-md-editor${fullscreen ? " is-fullscreen" : ""}${
isComment ? " is-comment" : ""
}${toolNarrow ? " is-narrow" : ""}`}
>
<div className="j13-md-toolbar" ref={toolbarRef}>
<div className="j13-md-tool-groups flex items-center gap-0.5 flex-1 flex-wrap min-w-0">
<ToolBtn
@@ -782,21 +964,21 @@ export default function MarkdownEditor({
icon={<Bold size={15} />}
onClick={() => runWrap({ before: "**", after: "**", placeholder: "粗体" })}
/>
{!isComment && (
{!isComment && !toolNarrow && (
<ToolBtn
label="斜体 Ctrl+I"
icon={<Italic size={15} />}
onClick={() => runWrap({ before: "*", after: "*", placeholder: "斜体" })}
/>
)}
{!isComment && (
{!isComment && !toolNarrow && (
<ToolBtn
label="删除线"
icon={<Strikethrough size={15} />}
onClick={() => runWrap({ before: "~~", after: "~~", placeholder: "删除线" })}
/>
)}
{!isComment && <Sep />}
{!isComment && !toolNarrow && <Sep />}
{!isComment && (
<div className="j13-md-popover-anchor relative">
<ToolBtn
@@ -838,8 +1020,8 @@ export default function MarkdownEditor({
)}
</div>
)}
{(!isComment || !toolNarrow) && <Sep />}
{(!isComment || !toolNarrow) && (
{!toolNarrow && <Sep />}
{!toolNarrow && (
<ToolBtn
label="引用"
icon={<Quote size={15} />}
@@ -848,7 +1030,7 @@ export default function MarkdownEditor({
}
/>
)}
{(!isComment || !toolNarrow) && (
{!toolNarrow && (
<ToolBtn
label="无序列表"
icon={<List size={15} />}
@@ -857,15 +1039,15 @@ export default function MarkdownEditor({
}
/>
)}
{!isComment && (
{!isComment && !toolNarrow && (
<ToolBtn
label="有序列表"
icon={<ListOrdered size={15} />}
onClick={insertOrderedList}
/>
)}
{!isComment && <ToolBtn label="表格" icon={<Table size={15} />} onClick={insertTable} />}
{!isComment && (
{!isComment && !toolNarrow && <ToolBtn label="表格" icon={<Table size={15} />} onClick={insertTable} />}
{!isComment && !toolNarrow && (
<ToolBtn
label="分割线"
icon={<Minus size={15} />}
@@ -878,7 +1060,7 @@ export default function MarkdownEditor({
icon={<Code size={15} />}
onClick={() => runWrap({ before: "`", after: "`", placeholder: "code" })}
/>
{(!isComment || !toolNarrow) && (
{!toolNarrow && (
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="代码块 Ctrl+Shift+C"
@@ -886,37 +1068,7 @@ export default function MarkdownEditor({
active={popover === "code"}
onClick={() => openPopover("code")}
/>
{popover === "code" && (
<div className="j13-md-popover j13-md-popover-wide" role="dialog" aria-label="选择语言">
<input
autoFocus
className="j13-md-popover-input"
placeholder="搜索语言…"
value={codeQuery}
onChange={(e) => setCodeQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && filteredLangs[0]) {
e.preventDefault();
insertCodeBlock(filteredLangs[0]);
}
}}
/>
<div className="j13-md-popover-list">
{filteredLangs.map((lang) => (
<button
key={lang}
type="button"
onClick={() => insertCodeBlock(lang)}
>
{lang}
</button>
))}
{filteredLangs.length === 0 && (
<p className="meta px-2 py-3 text-center">无匹配语言</p>
)}
</div>
</div>
)}
{codePopover}
</div>
)}
<Sep />
@@ -1008,6 +1160,7 @@ export default function MarkdownEditor({
</div>
)}
</div>
{(isComment || !toolNarrow) && (
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="表情"
@@ -1021,6 +1174,7 @@ export default function MarkdownEditor({
</div>
)}
</div>
)}
<div className="j13-md-popover-anchor relative">
<ToolBtn
label={uploading ? "上传中…" : "图片"}
@@ -1030,12 +1184,12 @@ export default function MarkdownEditor({
onClick={openImageModal}
/>
</div>
{isComment && (
{(isComment || toolNarrow) && (
<div className="j13-md-popover-anchor relative">
<button
ref={moreBtnRef}
type="button"
className={`j13-md-tool${popover === "more" || (toolNarrow && popover === "code") ? " is-active" : ""}`}
className={`j13-md-tool${popover === "more" || (toolNarrow && (popover === "code" || popover === "emoji" || popover === "hide")) ? " is-active" : ""}`}
data-tip="更多工具"
aria-label="更多工具"
aria-haspopup="menu"
@@ -1060,55 +1214,49 @@ export default function MarkdownEditor({
)}
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "*", after: "*", placeholder: "斜体" }); setPopover(null); }}>斜体</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "~~", after: "~~", placeholder: "删除线" }); setPopover(null); }}>删除线</button>
{!isComment && (
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("emoji")}>表情</button>
)}
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "## ", after: "", placeholder: "二级标题" }); setPopover(null); }}>二级标题</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "### ", after: "", placeholder: "三级标题" }); setPopover(null); }}>三级标题</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertOrderedList(); setPopover(null); }}>有序列表</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertTable(); setPopover(null); }}>表格</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertAtCursor("---"); setPopover(null); }}>分隔线</button>
{!isComment && allowHide && (
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("hide")}>隐藏内容</button>
)}
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); openTimelineEditor(); }}>时间线</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertAtCursor(entriesAuto ? ENTRIES_TEMPLATE_AUTO : ENTRIES_TEMPLATE); setPopover(null); }}>条目卡</button>
{toolNarrow && (
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); toggleFullscreen(); }}>{fullscreen ? "退出全屏" : "全屏写作"}</button>
)}
</div>
)}
{toolNarrow && popover === "code" && (
<div className="j13-md-popover j13-md-popover-wide" role="dialog" aria-label="选择语言">
<input
autoFocus
className="j13-md-popover-input"
placeholder="搜索语言…"
value={codeQuery}
onChange={(e) => setCodeQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && filteredLangs[0]) {
e.preventDefault();
insertCodeBlock(filteredLangs[0]);
}
}}
/>
<div className="j13-md-popover-list">
{filteredLangs.map((lang) => (
<button key={lang} type="button" onClick={() => insertCodeBlock(lang)}>
{lang}
</button>
))}
{filteredLangs.length === 0 && (
<p className="meta px-2 py-3 text-center">无匹配语言</p>
)}
</div>
{toolNarrow && codePopover}
{toolNarrow && popover === "emoji" && !isComment && (
<div className="j13-md-popover j13-md-emoji-popover j13-md-popover-center" role="dialog" aria-label="插入表情">
<EmojiPicker onPick={pickEmoji} />
</div>
)}
{toolNarrow && hidePopover}
</div>
)}
{!isComment && <Sep />}
{!isComment && (
{!isComment && !toolNarrow && <Sep />}
{!isComment && !toolNarrow && (
<ToolBtn
label="时间线"
icon={<History size={15} />}
onClick={openTimelineEditor}
/>
)}
{allowHide ? (
{!isComment && !toolNarrow && (
<ToolBtn
label="条目卡"
icon={<LayoutList size={15} />}
onClick={() => insertAtCursor(entriesAuto ? ENTRIES_TEMPLATE_AUTO : ENTRIES_TEMPLATE)}
/>
)}
{allowHide && !toolNarrow ? (
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="隐藏内容"
@@ -1116,139 +1264,7 @@ export default function MarkdownEditor({
active={popover === "hide"}
onClick={() => openPopover("hide")}
/>
{popover === "hide" && (
<div className="j13-md-popover j13-md-popover-form" role="dialog" aria-label="插入隐藏内容">
{hideStep === "menu" ? (
<>
<button type="button" className="j13-md-menu-item" onClick={() => insertHide("login")}>
登录可见
</button>
<button
type="button"
className="j13-md-menu-item"
disabled={!allowReplyHide}
data-tip={allowReplyHide ? undefined : "当前内容不可评论,无法使用回复可见"}
aria-disabled={!allowReplyHide}
onClick={() => {
if (!allowReplyHide) return;
insertHide("reply");
}}
>
回复可见
{!allowReplyHide ? (
<span className="j13-md-menu-item-hint">不可用</span>
) : null}
</button>
<button
type="button"
className="j13-md-menu-item"
onClick={() => setHideStep("points")}
>
积分可见…
</button>
<button
type="button"
className="j13-md-menu-item"
onClick={() => setHideStep("password")}
>
密码可见…
</button>
</>
) : hideStep === "points" ? (
<>
<label className="j13-md-field">
<span>所需积分</span>
<input
autoFocus
type="number"
min={1}
max={100000}
value={hidePoints}
onChange={(e) => setHidePoints(Number(e.target.value) || 1)}
className="j13-md-popover-input"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (hidePoints >= 1 && hidePoints <= 100000) {
insertHide("points", hidePoints);
}
}
}}
/>
</label>
<div className="j13-md-popover-footer">
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setHideStep("menu")}
>
返回
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => {
if (hidePoints < 1 || hidePoints > 100000) {
toast("积分须在 1–100000", "error");
return;
}
insertHide("points", hidePoints);
}}
>
插入
</button>
</div>
</>
) : (
<>
<label className="j13-md-field">
<span>查看密码(不含空格,1–64 字)</span>
<input
autoFocus
type="text"
value={hidePassword}
maxLength={64}
placeholder="例如:secret"
onChange={(e) => setHidePassword(e.target.value)}
className="j13-md-popover-input"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const p = hidePassword.trim();
if (p && !/\s/.test(p)) {
insertHide("password", undefined, p);
}
}
}}
/>
</label>
<div className="j13-md-popover-footer">
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setHideStep("menu")}
>
返回
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => {
const p = hidePassword.trim();
if (!p || /\s/.test(p) || p.length > 64) {
toast("密码须为 1–64 字符且不含空格", "error");
return;
}
insertHide("password", undefined, p);
}}
>
插入
</button>
</div>
</>
)}
</div>
)}
{hidePopover}
</div>
) : null}
</div>
@@ -1281,7 +1297,7 @@ export default function MarkdownEditor({
<Eye size={12} /> 预览
</button>
</div>
{(!isComment || !toolNarrow) && (
{!toolNarrow && (
<button
type="button"
className="j13-md-tool"
@@ -1354,7 +1370,7 @@ export default function MarkdownEditor({
预览
</div>
{deferredValue.trim() ? (
<MarkdownBodyClient content={deferredValue} />
<MarkdownBodyClient content={deferredValue} entriesAuto={entriesAuto} />
) : (
<p className="meta text-center py-16">暂无内容可预览</p>
)}