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

@@ -7,6 +7,7 @@ import {
GitBranch,
Loader2,
Plus,
Tag,
Trash2,
} from "lucide-react";
import Modal from "@/components/Modal";
@@ -19,7 +20,7 @@ import {
serializeTimelineBlock,
type TimelineItem,
} from "@/lib/timelineBlocks";
import { apiTimelineFromGit, type TimelineGitItem } from "@/lib/api";
import { apiTimelineFromGit, apiTimelineFromReleases, type TimelineGitItem } from "@/lib/api";
import { toast } from "@/lib/toast";
type ImportMode = "replace" | "append";
@@ -108,11 +109,34 @@ export default function TimelineEditor({
);
};
const runImport = async () => {
const urls = urlsText
const collectURLs = () =>
urlsText
.split(/\n/)
.map((l) => l.trim())
.filter(Boolean);
const applyImported = (mapped: TimelineItem[], errorHint: string) => {
if (mapped.length === 0) {
setImportError(errorHint);
return false;
}
setItems((prev) => {
if (importMode === "append") {
const seen = new Set(
prev.map((p) => p.sourceUrl || `${p.date}|${p.title}`).filter(Boolean)
);
const extra = mapped.filter(
(m) => !m.sourceUrl || !seen.has(m.sourceUrl)
);
return [...prev.filter((p) => p.title.trim() || p.body.trim()), ...extra];
}
return mapped;
});
return true;
};
const runImportCommits = async () => {
const urls = collectURLs();
if (urls.length === 0) {
setImportError("请粘贴至少一条提交记录页面 URL");
return;
@@ -122,23 +146,10 @@ export default function TimelineEditor({
try {
const res = await apiTimelineFromGit({ urls, follow_pages: followPages });
const mapped = (res.items || []).map(fromGitItem);
if (mapped.length === 0) {
setImportError(res.error || "未能解析出提交;请检查 URL 或改为手填");
if (!applyImported(mapped, res.error || "未能解析出提交;请检查 URL 或改为手填")) {
if (res.warning) toast(res.warning);
return;
}
setItems((prev) => {
if (importMode === "append") {
const seen = new Set(
prev.map((p) => p.sourceUrl || `${p.date}|${p.title}`).filter(Boolean)
);
const extra = mapped.filter(
(m) => !m.sourceUrl || !seen.has(m.sourceUrl)
);
return [...prev.filter((p) => p.title.trim() || p.body.trim()), ...extra];
}
return mapped;
});
if (res.warning) toast(res.warning, "ok");
else toast(`已导入 ${mapped.length} 条,请点「插入/更新时间线」写入正文`, "ok");
if (res.error) setImportError(res.error);
@@ -149,6 +160,31 @@ export default function TimelineEditor({
}
};
const runImportReleases = async () => {
const urls = collectURLs();
if (urls.length === 0) {
setImportError("请粘贴至少一条 Release 页面 URL");
return;
}
setImporting(true);
setImportError(null);
try {
const res = await apiTimelineFromReleases({ urls });
const mapped = (res.items || []).map(fromGitItem);
if (!applyImported(mapped, res.error || "未能解析出 Release;请检查 URL 或改为手填")) {
if (res.warning) toast(res.warning);
return;
}
if (res.warning) toast(res.warning, "ok");
else toast(`已从 Release 导入 ${mapped.length} 条,请点「插入/更新时间线」写入正文`, "ok");
if (res.error) setImportError(res.error);
} catch (e) {
setImportError(e instanceof Error ? e.message : "导入失败,请手填时间线");
} finally {
setImporting(false);
}
};
const handleInsert = () => {
const kept = items.filter((it) => it.selected !== false);
if (kept.length === 0) {
@@ -222,7 +258,7 @@ export default function TimelineEditor({
</p>
</div>
<p className="meta text-[12px] mb-2 leading-relaxed">
粘贴 Gitea / GitHub 的 commits 列表页 URL,每行一条。默认自动拉取后续页,也可只贴某几页。
粘贴 Gitea / GitHub 的 commits 或 releases 列表页 URL,每行一条。点「导入提交」拉取提交记录,点「导入 Release」拉取发布日志。
</p>
<textarea
value={urlsText}
@@ -231,7 +267,7 @@ export default function TimelineEditor({
setImportError(null);
}}
rows={3}
placeholder={"https://github.com/org/repo/commits/main\nhttps://git.example.com/user/repo/commits/branch/main"}
placeholder={"https://github.com/org/repo/commits/main\nhttps://git.iioio.com/freefire/jiang13-bbs/releases"}
className="j13-tl-urls"
disabled={importing}
/>
@@ -260,20 +296,40 @@ export default function TimelineEditor({
<option value="append">追加</option>
</select>
</div>
<button
type="button"
className="btn btn-line btn-sm ml-auto"
disabled={importing}
onClick={() => void runImport()}
>
{importing ? (
<>
<Loader2 size={14} className="animate-spin" /> 导入中…
</>
) : (
"导入"
)}
</button>
<div className="ml-auto flex items-center gap-1.5">
<button
type="button"
className="btn btn-line btn-sm"
disabled={importing}
onClick={() => void runImportCommits()}
>
{importing ? (
<>
<Loader2 size={14} className="animate-spin" /> 导入中…
</>
) : (
<>
<GitBranch size={14} /> 导入提交
</>
)}
</button>
<button
type="button"
className="btn btn-line btn-sm"
disabled={importing}
onClick={() => void runImportReleases()}
>
{importing ? (
<>
<Loader2 size={14} className="animate-spin" /> 导入中…
</>
) : (
<>
<Tag size={14} /> 导入 Release
</>
)}
</button>
</div>
</div>
{showReplaceWarning ? (
<p className="j13-tl-banner is-warn">
@@ -300,7 +356,7 @@ export default function TimelineEditor({
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setItems((prev) => [...prev, emptyItem()])}
onClick={() => setItems((prev) => [emptyItem(), ...prev])}
>
<Plus size={14} /> 添加
</button>
@@ -408,6 +464,7 @@ export default function TimelineEditor({
title="删除条目"
message="确定删除这条时间线项目?"
danger
zIndexClass="z-[1300]"
confirmLabel="删除"
onCancel={() => setDeleteIdx(null)}
onConfirm={() => {