Pārlūkot izejas kodu

Upgrade book catalog structure

zhaozhi 2 nedēļas atpakaļ
vecāks
revīzija
b07fb5d88a

+ 95 - 5
app/globals.css

@@ -1641,6 +1641,91 @@ textarea {
   gap: 12px 26px;
 }
 
+.reader-catalog-groups {
+  display: grid;
+  gap: 20px;
+}
+
+.reader-catalog__section {
+  display: grid;
+  gap: 12px;
+}
+
+.reader-catalog__section-title {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  color: #4f433a;
+}
+
+.reader-catalog__section-title strong {
+  font-size: 1.02rem;
+  font-weight: 700;
+}
+
+.reader-catalog__kind {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 46px;
+  padding: 4px 10px;
+  border-radius: 999px;
+  font-size: 0.82rem;
+  font-weight: 700;
+}
+
+.reader-catalog__kind--lore {
+  background: rgba(108, 126, 164, 0.12);
+  color: #55698c;
+}
+
+.reader-catalog__kind--novel {
+  background: rgba(186, 95, 57, 0.12);
+  color: #ba5f39;
+}
+
+.reader-entry-badges {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+  margin-bottom: 14px;
+}
+
+.reader-entry-badge {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 32px;
+  padding: 0 14px;
+  border-radius: 999px;
+  font-size: 0.88rem;
+  font-weight: 700;
+}
+
+.reader-entry-badge--lore {
+  background: rgba(92, 117, 168, 0.12);
+  color: #5870a1;
+}
+
+.reader-entry-badge--novel {
+  background: rgba(186, 95, 57, 0.12);
+  color: #ba5f39;
+}
+
+.reader-entry-badge--section {
+  background: rgba(91, 71, 56, 0.08);
+  color: #66584d;
+}
+
+.reader-qq-header--lore h1 {
+  font-size: clamp(2.25rem, 4vw, 3.2rem);
+}
+
+.reader-qq-content--lore p {
+  max-width: 56em;
+  margin-bottom: 1.1em;
+}
+
 .reader-catalog__item {
   display: grid;
   gap: 8px;
@@ -2487,10 +2572,10 @@ textarea {
     position: fixed;
     left: 0;
     right: 0;
-    bottom: 0;
+    bottom: calc(76px + env(safe-area-inset-bottom));
     display: block;
-    max-height: min(72dvh, 680px);
-    padding: 18px 18px 28px;
+    max-height: min(calc(74dvh - env(safe-area-inset-bottom)), 620px);
+    padding: 18px 18px 18px;
     border-radius: 26px 26px 0 0;
     box-shadow: 0 -20px 40px rgba(28, 21, 16, 0.2);
     transform: translateY(102%);
@@ -2512,6 +2597,10 @@ textarea {
     background: inherit;
   }
 
+  .reader-catalog-mobile-sheet .reader-catalog__grid {
+    padding-bottom: 12px;
+  }
+
   .reader-catalog-inline {
     display: none;
   }
@@ -3082,8 +3171,9 @@ textarea {
   }
 
   .reader-catalog-mobile-sheet {
-    max-height: 78dvh;
-    padding: 16px 16px 24px;
+    bottom: calc(74px + env(safe-area-inset-bottom));
+    max-height: calc(76dvh - env(safe-area-inset-bottom));
+    padding: 16px 16px 16px;
   }
 }
 

+ 11 - 5
app/library/page.tsx

@@ -1,12 +1,17 @@
 import Link from "next/link";
-import { demoBooks } from "@/data/demo-books";
+import { getBooks, getBookStructureStats } from "@/lib/book-catalog";
+
+export default async function LibraryPage() {
+  const books = await getBooks();
 
-export default function LibraryPage() {
   return (
     <main className="page-shell">
       <section className="library-shell">
         <div className="library-grid">
-          {demoBooks.map((book) => (
+          {books.map((book) => {
+            const stats = getBookStructureStats(book);
+
+            return (
             <article className="book-card" key={book.id}>
               <div className="book-cover" style={{ ["--cover" as string]: book.coverStyle }}>
                 <div className="book-cover__chip">{book.category}</div>
@@ -17,7 +22,8 @@ export default function LibraryPage() {
               <div className="book-body">
                 <div className="chip-row">
                   <span className="chip">{book.wordCount}</span>
-                  <span className="chip">{book.chapters.length} 章演示内容</span>
+                  <span className="chip">{stats.loreCount} 条资料</span>
+                  <span className="chip">{stats.novelCount} 条正文</span>
                 </div>
                 <p>{book.description}</p>
                 <Link className="button button--primary" href={`/reader/${book.id}`}>
@@ -25,7 +31,7 @@ export default function LibraryPage() {
                 </Link>
               </div>
             </article>
-          ))}
+          )})}
         </div>
       </section>
     </main>

+ 7 - 5
app/reader/[bookId]/page.tsx

@@ -1,6 +1,6 @@
 import { notFound } from "next/navigation";
 import { ReaderView } from "@/components/reader/reader-view";
-import { getBookById } from "@/data/demo-books";
+import { getBookById, getBookEntries } from "@/lib/book-catalog";
 
 type ReaderPageProps = {
   params: Promise<{
@@ -8,6 +8,7 @@ type ReaderPageProps = {
   }>;
   searchParams?: Promise<{
     chapter?: string;
+    entry?: string;
     width?: string;
     theme?: string;
     font?: string;
@@ -17,16 +18,17 @@ type ReaderPageProps = {
 export default async function ReaderPage({ params, searchParams }: ReaderPageProps) {
   const { bookId } = await params;
   const resolvedSearchParams = searchParams ? await searchParams : undefined;
-  const book = getBookById(bookId);
+  const book = await getBookById(bookId);
 
   if (!book) {
     notFound();
   }
 
-  const rawChapterIndex = Number(resolvedSearchParams?.chapter ?? "0");
+  const entries = getBookEntries(book);
+  const rawEntryIndex = Number(resolvedSearchParams?.entry ?? resolvedSearchParams?.chapter ?? "0");
   const chapterIndex =
-    Number.isFinite(rawChapterIndex) && rawChapterIndex >= 0 && rawChapterIndex < book.chapters.length
-      ? rawChapterIndex
+    Number.isFinite(rawEntryIndex) && rawEntryIndex >= 0 && rawEntryIndex < entries.length
+      ? rawEntryIndex
       : 0;
 
   return (

+ 64 - 31
components/reader/reader-view.tsx

@@ -2,6 +2,7 @@
 
 import Link from "next/link";
 import { CSSProperties, useEffect, useMemo, useRef, useState } from "react";
+import { getBookEntries } from "@/lib/book-helpers";
 import { Book } from "@/types/book";
 
 type ReaderViewProps = {
@@ -15,7 +16,7 @@ type ReaderViewProps = {
 const themeOptions = [
   { key: "warm", label: "暖米", page: "#f7f0e4", stage: "#d8d0c4", text: "#2b241e", catalog: "#e6ddd0" },
   { key: "mist", label: "浅灰", page: "#efede7", stage: "#d6d4cf", text: "#2a2723", catalog: "#dfddd7" },
-  { key: "sepia", label: "茶", page: "#eee0cf", stage: "#d4c3b1", text: "#35281f", catalog: "#dfcdbb" }
+  { key: "sepia", label: "茶", page: "#eee0cf", stage: "#d4c3b1", text: "#35281f", catalog: "#dfcdbb" }
 ] as const;
 
 const fontOptions = [
@@ -37,6 +38,11 @@ export function ReaderView({
   initialFontKey,
   initialWidthKey
 }: ReaderViewProps) {
+  const entries = useMemo(() => getBookEntries(book), [book]);
+  const entry = entries[chapterIndex];
+  const prevChapterIndex = chapterIndex > 0 ? chapterIndex - 1 : null;
+  const nextChapterIndex = chapterIndex < entries.length - 1 ? chapterIndex + 1 : null;
+
   const initialThemeIndex = Math.max(0, themeOptions.findIndex((item) => item.key === initialThemeKey));
   const initialFontIndex = Math.max(0, fontOptions.findIndex((item) => item.key === initialFontKey));
   const initialWidthIndex = Math.max(0, widthOptions.findIndex((item) => item.key === initialWidthKey));
@@ -66,9 +72,7 @@ export function ReaderView({
 
   useEffect(() => {
     const stage = stageRef.current;
-    if (!stage) {
-      return;
-    }
+    if (!stage) return;
 
     stage.scrollTo({ top: 0, behavior: "auto" });
     setCatalogOpen(false);
@@ -89,13 +93,11 @@ export function ReaderView({
     };
   }, [chapterIndex, widthIndex, fontIndex, themeIndex]);
 
-  const chapter = book.chapters[chapterIndex];
-  const prevChapterIndex = chapterIndex > 0 ? chapterIndex - 1 : null;
-  const nextChapterIndex = chapterIndex < book.chapters.length - 1 ? chapterIndex + 1 : null;
   const currentTheme = themeOptions[themeIndex];
   const currentFont = fontOptions[fontIndex];
   const currentWidth = widthOptions[widthIndex];
-  const chapterWords = chapter.content.join("").length;
+  const entryWords = entry.content.join("").length;
+  const isLoreEntry = entry.kind === "lore";
 
   const shellWidthStyle = useMemo(
     () =>
@@ -118,18 +120,39 @@ export function ReaderView({
         </button>
       </div>
 
-      <div className="reader-catalog__grid">
-        {book.chapters.map((item, index) => (
-          <Link
-            key={item.id}
-            href={`/reader/${book.id}?chapter=${index}`}
-            className={`reader-catalog__item${index === chapterIndex ? " reader-catalog__item--active" : ""}`}
-            onClick={() => setCatalogOpen(false)}
-          >
-            <span>第 {index + 1} 章</span>
-            <strong>{item.title}</strong>
-          </Link>
-        ))}
+      <div className="reader-catalog-groups">
+        {book.sections
+          .slice()
+          .sort((a, b) => a.order - b.order)
+          .map((section) => {
+            const sectionEntries = entries.filter((item) => item.sectionId === section.id);
+
+            return (
+              <section className="reader-catalog__section" key={section.id}>
+                <div className="reader-catalog__section-title">
+                  <span className={`reader-catalog__kind reader-catalog__kind--${section.kind}`}>
+                    {section.kind === "lore" ? "资料" : "正文"}
+                  </span>
+                  <strong>{section.title}</strong>
+                </div>
+                <div className="reader-catalog__grid">
+                  {sectionEntries.map((item, index) => (
+                    <Link
+                      key={item.id}
+                      href={`/reader/${book.id}?entry=${item.flatIndex}`}
+                      className={`reader-catalog__item${item.flatIndex === chapterIndex ? " reader-catalog__item--active" : ""}`}
+                      onClick={() => setCatalogOpen(false)}
+                    >
+                      <span>
+                        {section.kind === "lore" ? `资料 ${index + 1}` : `第 ${index + 1} 章`}
+                      </span>
+                      <strong>{item.title}</strong>
+                    </Link>
+                  ))}
+                </div>
+              </section>
+            );
+          })}
       </div>
     </>
   );
@@ -170,7 +193,7 @@ export function ReaderView({
         </button>
 
         {prevChapterIndex !== null ? (
-          <Link className="reader-mobile-bar__action" href={`/reader/${book.id}?chapter=${prevChapterIndex}`}>
+          <Link className="reader-mobile-bar__action" href={`/reader/${book.id}?entry=${prevChapterIndex}`}>
             上一章
           </Link>
         ) : (
@@ -178,7 +201,7 @@ export function ReaderView({
         )}
 
         {nextChapterIndex !== null ? (
-          <Link className="reader-mobile-bar__action" href={`/reader/${book.id}?chapter=${nextChapterIndex}`}>
+          <Link className="reader-mobile-bar__action" href={`/reader/${book.id}?entry=${nextChapterIndex}`}>
             下一章
           </Link>
         ) : (
@@ -219,18 +242,28 @@ export function ReaderView({
             className={`reader-qq-paper${catalogOpen ? " reader-qq-paper--desktop-hidden" : ""}`}
             style={{ background: currentTheme.page }}
           >
-            <header className="reader-qq-header">
-              <h1>{chapter.title}</h1>
+            <header className={`reader-qq-header${isLoreEntry ? " reader-qq-header--lore" : ""}`}>
+              <div className="reader-entry-badges">
+                <span className={`reader-entry-badge reader-entry-badge--${entry.kind}`}>
+                  {isLoreEntry ? "资料" : "正文"}
+                </span>
+                <span className="reader-entry-badge reader-entry-badge--section">{entry.sectionTitle}</span>
+              </div>
+              <h1>{entry.title}</h1>
               <div className="reader-qq-meta">
                 <span>书名:{book.title}</span>
                 <span>作者:{book.author}</span>
-                <span>本章字数:{chapterWords} 字</span>
+                <span>当前:{entry.sectionTitle}</span>
+                <span>本节字数:{entryWords} 字</span>
                 <span>总字数:{book.wordCount}</span>
               </div>
             </header>
 
-            <article className="reader-qq-content" style={{ color: currentTheme.text }}>
-              {chapter.content.map((paragraph) => (
+            <article
+              className={`reader-qq-content${isLoreEntry ? " reader-qq-content--lore" : ""}`}
+              style={{ color: currentTheme.text }}
+            >
+              {entry.content.map((paragraph) => (
                 <p key={paragraph} style={{ fontSize: currentFont.size, lineHeight: currentFont.lineHeight }}>
                   {paragraph}
                 </p>
@@ -243,19 +276,19 @@ export function ReaderView({
               </button>
 
               {prevChapterIndex !== null ? (
-                <Link className="reader-qq-footer__button" href={`/reader/${book.id}?chapter=${prevChapterIndex}`}>
+                <Link className="reader-qq-footer__button" href={`/reader/${book.id}?entry=${prevChapterIndex}`}>
                   上一章
                 </Link>
               ) : (
-                <span className="reader-qq-footer__button reader-qq-footer__button--disabled">已是第一</span>
+                <span className="reader-qq-footer__button reader-qq-footer__button--disabled">已是第一</span>
               )}
 
               {nextChapterIndex !== null ? (
-                <Link className="reader-qq-footer__button" href={`/reader/${book.id}?chapter=${nextChapterIndex}`}>
+                <Link className="reader-qq-footer__button" href={`/reader/${book.id}?entry=${nextChapterIndex}`}>
                   下一章
                 </Link>
               ) : (
-                <span className="reader-qq-footer__button reader-qq-footer__button--disabled">已是最后一</span>
+                <span className="reader-qq-footer__button reader-qq-footer__button--disabled">已是最后一</span>
               )}
             </footer>
           </div>

+ 154 - 64
data/demo-books.ts

@@ -1,38 +1,141 @@
-import { Book } from "@/types/book";
+import { Book, BookEntry, BookEntryKind, BookSection } from "@/types/book";
+
+function makeEntries(sectionId: string, kind: BookEntryKind, titles: string[][]): BookEntry[] {
+  return titles.map(([title, ...content], index) => ({
+    id: `${sectionId}-${index + 1}`,
+    title,
+    kind,
+    order: index + 1,
+    content
+  }));
+}
+
+function makeSection(
+  id: string,
+  title: string,
+  kind: BookEntryKind,
+  order: number,
+  titles: string[][]
+): BookSection {
+  return {
+    id,
+    title,
+    kind,
+    order,
+    entries: makeEntries(id, kind, titles)
+  };
+}
 
 export const demoBooks: Book[] = [
   {
+    id: "memory-catcher",
+    title: "记忆捕手",
+    author: "墨尘",
+    category: "悬疑",
+    description:
+      "一间只在深夜营业的记忆事务所,一群替人打捞记忆碎片的人,以及越来越像真实案件的委托记录。",
+    wordCount: "约 6 万字(演示)",
+    coverStyle: "linear-gradient(135deg, #7089a8, #2e405a)",
+    sections: [
+      makeSection("mc-lore-world", "世界观设定", "lore", 1, [
+        [
+          "资料一 事务所规则",
+          "记忆事务所只在每天凌晨零点到五点营业,所有委托都需要经过双重验证,且不得跨越自然死亡边界回收记忆。",
+          "每次进入记忆深层前,执行者必须留下现实锚点,否则在高损耗片段中极易失去自我定位。"
+        ],
+        [
+          "资料二 回收协议",
+          "记忆回收分为取样、修复、归还三个阶段,资料类委托和正文类案件都必须标记来源与完整度。",
+          "当完整度低于百分之三十时,系统会自动建议多人协同解析。"
+        ]
+      ]),
+      makeSection("mc-lore-roles", "人物档案", "lore", 2, [
+        [
+          "资料三 林夜",
+          "林夜是事务所的主检索员,擅长在受损记忆中建立临时路径。",
+          "他最大的弱点是不擅长拒绝任何一份“看起来还能救回来”的委托。"
+        ],
+        [
+          "资料四 深洲事务所",
+          "事务所位于深洲旧城区地下一层,常年由蓝色工作灯照明,主工作台正对一面由十五块屏幕拼成的监控墙。",
+          "多数外来委托都会先被收录为资料条目,再决定是否转入正式案件。"
+        ]
+      ]),
+      makeSection("mc-novel-volume-1", "正文 第一卷", "novel", 100, [
+        [
+          "第一章 数据废墟",
+          "深洲事务所的地下工作室被蓝色光芒覆盖,十五块屏幕全部亮起,数据流在每一个像素中涌动。",
+          "林夜坐在工作台中央,接口头环已经戴好,眼前是一份来自匿名客户的委托:在七十二小时内找回一段被人为删除的童年记忆。",
+          "这类工作本该只是技术活,可委托附件里多出了一段视频。视频结尾,有一只沾着雨水的手按住镜头,像是隔着时间朝他们打招呼。"
+        ],
+        [
+          "第二章 深网潜入",
+          "林夜坐在工作台中央,接口头环已经戴好,双手悬停在键盘上方,等待深网桥接程序完成最后一次校验。",
+          "任务目标是一段被藏进深层节点的私人记忆。它没有文件名,也没有索引,像一枚被扔进黑潮里的钉子,只能靠微弱的访问残响定位。",
+          "副屏上,系统给出了三条不太乐观的提示:来源匿名、时间戳错乱、回收通道不稳定。"
+        ],
+        [
+          "第三章 雨巷回声",
+          "连接建立后,第一段回收到的不是画面,而是一阵潮湿的雨声。",
+          "雨点顺着一条老旧的石板巷子落下,远处有人撑着黑伞停在路灯下,像在等谁,又像已经等了很久。",
+          "系统记录显示,这段记忆在过去七年里被人为删除过四次,但每次都会在不同的深夜重新出现。"
+        ],
+        [
+          "第四章 失真坐标",
+          "他们终于在失真层里找到了坐标,却发现坐标指向的是一座早已拆除的旧车站。",
+          "记忆里的广播还在播报过期的列车时刻,玻璃穹顶上方积着雨,候车厅里坐满了看不清脸的人。"
+        ],
+        [
+          "第五章 双重委托",
+          "清晨六点,他们刚从深层断开,门铃便响了。",
+          "来的人戴着深灰色帽子,把同样一份委托重新放在桌上,连措辞都和昨晚的一字不差,只在签名处多了一个新的名字。"
+        ],
+        [
+          "第六章 无名乘客",
+          "第二次进入时,他们看见候车厅角落多了一个孩子。",
+          "孩子穿着被雨打湿的校服,手里攥着半张车票,车票背面写着一行几乎被水泡开的字:不要相信站台上的广播。"
+        ],
+        [
+          "第七章 镜像证词",
+          "为了验证委托来源,他们调出了另一位委托人的证词记录。",
+          "两份证词在前半段完全一致,只在最后三分钟出现了分叉。"
+        ],
+        [
+          "第八章 灯箱之后",
+          "林夜在站台广告灯箱后找到了一扇隐藏维护门。",
+          "门后不是管道井,而是一间布满旧磁带和纸质档案的小房间。"
+        ],
+        [
+          "第九章 回收协议",
+          "事务所服务器在当晚收到一份未签名的回收协议。",
+          "协议条款极短,只要求他们在二十四小时内交出目标记忆的完整副本。"
+        ],
+        [
+          "第十章 七号出口",
+          "他们决定在现实中去找那座被封闭的七号出口。",
+          "清晨的围挡后满是积水和灰尘,可在围挡缝隙后,林夜看见了一盏仍旧亮着的引导灯。"
+        ]
+      ])
+    ]
+  },
+  {
     id: "lan-archive",
     title: "局域网夜航",
     author: "程未远",
     category: "近未来",
-    description:
-      "一座封闭园区、几台常亮的旧电脑,以及深夜里不断跳出的陌生消息。故事从一个内部聊天室开始,也从那里逐渐偏离日常。",
+    description: "一座封闭园区、几台常亮的旧电脑,以及深夜里不断跳出的陌生消息。",
     wordCount: "12.8 万字",
     coverStyle: "linear-gradient(135deg, #b86143, #5c2d23)",
-    chapters: [
-      {
-        id: "chapter-1",
-        title: "第一章 机房尽头的灯",
-        content: [
-          "凌晨两点十三分,园区办公楼里只剩下二层最靠里的机房还亮着灯。陈泊把最后一台旧主机的风扇罩扣回去,抬头时,玻璃门上映出他略显疲惫的脸。",
-          "楼道里很安静,安静得能听见交换机持续不断的细密电流声。那声音像潮水一样贴着墙面蔓延,穿过半开的门缝,也穿过他刚刚搭起来的局域网聊天页。",
-          "页面很简单,甚至称得上粗糙。左边是在线设备,右边是聊天消息,底部一个输入框,可以发文字,也可以拖图片和文件进去。原本它只是为了方便他在几台电脑之间互传资料。",
-          "可就在他准备关机的时候,聊天室里跳出了一条新消息。发送者是一台他从未见过的设备,名字显示为“Unknown-Edge-1.27-H3M2”。",
-          "那条消息只有八个字:你终于把门打开了。",
-          "陈泊盯着屏幕,手指停在键盘上方,迟迟没有落下。他确认过,这个局域网没有接外网,也没有其他人知道访问地址。可消息提示音仍旧在房间里轻轻回荡,像是谁站在走廊深处,耐心地敲了第二次门。"
-        ]
-      },
-      {
-        id: "chapter-2",
-        title: "第二章 没有登记的设备",
-        content: [
-          "他先检查 DHCP 记录,又翻了交换机最近一小时的地址分配表,结果一片空白。那台设备没有登记,没有接入记录,也没有任何真实存在的迹象。",
-          "可聊天页里的头像气泡还停在那里,像一粒不合时宜的灰尘。陈泊尝试发出一句“你是谁”,对方却在三秒后回了一张图片。",
-          "图片内容是这间机房。准确地说,是五分钟前的机房。画面里他正背对镜头,弯腰整理电源线,而取景角度来自房间最深处、那排早就废弃的机柜之间。",
-          "他猛地转身,那里只有黑暗,和一排没有通电的指示灯。"
-        ]
-      }
+    sections: [
+      makeSection("lan-lore", "设定资料", "lore", 1, [
+        ["资料一 园区网络", "园区网络与公网完全隔离,所有访问记录由本地交换系统统一收敛。"],
+        ["资料二 旧机房", "二层最靠里的旧机房仍保留着上一代局域网控制台与部分废弃机柜。"]
+      ]),
+      makeSection("lan-novel", "正文", "novel", 100, [
+        ["第一章 机房尽头的灯", "凌晨两点十三分,园区办公楼里只剩下二层最靠里的机房还亮着灯。"],
+        ["第二章 没有登记的设备", "他先检查 DHCP 记录,又翻了交换机最近一小时的地址分配表,结果一片空白。"],
+        ["第三章 反向截图", "对方没有回答,只发来一张刚刚拍下的机房照片。"]
+      ])
     ]
   },
   {
@@ -40,20 +143,16 @@ export const demoBooks: Book[] = [
     title: "旧书铺茶事",
     author: "沈南絮",
     category: "日常",
-    description:
-      "一间开在老街口的旧书铺,白天卖书,傍晚煮茶。每位进门的客人都带来一本书的故事,也带走一点安静。",
+    description: "一间开在老街口的旧书铺,白天卖书,傍晚煮茶。",
     wordCount: "8.4 万字",
     coverStyle: "linear-gradient(135deg, #8f6a3d, #314438)",
-    chapters: [
-      {
-        id: "chapter-1",
-        title: "第一章 雨落在旧街口",
-        content: [
-          "傍晚五点,雨刚刚落下来,青石板路被洗得发亮。书铺门口挂着一盏老式铜灯,灯光穿过潮润的空气,把门前那块“今日新到旧书”的小黑板照得有些发暖。",
-          "许青辞把最后一箱旧书搬进门,抬手拂去肩上的水珠。门口风铃轻轻一响,有位撑着深蓝长伞的姑娘走了进来,鞋尖落在木地板上,带来一点很轻的湿意。",
-          "她没有立刻说话,只在靠窗的架子前停住,像在辨认一本已经很久没见过的旧书。"
-        ]
-      }
+    sections: [
+      makeSection("tea-lore", "店铺札记", "lore", 1, [
+        ["资料一 书铺门铃", "门铃是一枚老铜铃,雨天响起来比晴天更轻一些。"]
+      ]),
+      makeSection("tea-novel", "正文", "novel", 100, [
+        ["第一章 雨落在旧街口", "傍晚五点,雨刚刚落下来,青石板路被洗得发亮。"]
+      ])
     ]
   },
   {
@@ -61,41 +160,33 @@ export const demoBooks: Book[] = [
     title: "星港来信",
     author: "林观海",
     category: "科幻",
-    description:
-      "远航舰队返程之前,地球收到一封延迟了十九年的星际邮件。它改变了一个港口城市,也改变了一群等待的人。",
+    description: "远航舰队返程之前,地球收到一封延迟了十九年的星际邮件。",
     wordCount: "15.1 万字",
     coverStyle: "linear-gradient(135deg, #355c7d, #121c2e)",
-    chapters: [
-      {
-        id: "chapter-1",
-        title: "第一章 邮件抵达前夜",
-        content: [
-          "海港城的夜总是比内陆亮一些。悬在码头上方的轨道灯像一排静止的流星,把泊位、塔吊和远处沉默的深空接收站一起照成银白色。",
-          "路昭从监测塔的楼梯上走下来时,终端忽然震了一下。屏幕上只有一句系统提示:深空民用信道存在待解包数据,请立即确认。",
-          "他看着那行字,脚步不由自主地慢了半拍。因为那条信道已经沉寂了十九年,久到所有人都默认它再也不会亮起。"
-        ]
-      }
+    sections: [
+      makeSection("star-lore", "星港档案", "lore", 1, [
+        ["资料一 深空信道", "民用深空信道十九年未曾亮起,因此被默认彻底沉寂。"]
+      ]),
+      makeSection("star-novel", "正文", "novel", 100, [
+        ["第一章 邮件抵达前夜", "海港城的夜总是比内陆亮一些。"]
+      ])
     ]
   },
   {
     id: "north-city",
-    title: "北城旧事",
+    title: "北城旧事簿",
     author: "顾行舟",
     category: "都市",
-    description:
-      "一座旧城的黄昏、几条重复经过的街道、以及那些看似平常却慢慢累积出重量的日子。",
+    description: "一座旧城的黄昏、几条重复经过的街道,以及那些看似平常却慢慢累积出重量的日子。",
     wordCount: "10.2 万字",
     coverStyle: "linear-gradient(135deg, #7c5a43, #28384f)",
-    chapters: [
-      {
-        id: "chapter-1",
-        title: "第一章 天桥下的晚风",
-        content: [
-          "晚高峰刚过,天桥下的人群还没散完。霓虹广告在灰蓝色的天幕里逐渐亮起来,把路口照得像刚从雨里捞出来一样湿润。",
-          "周临川站在报刊亭旁边,手里拿着一杯已经不热的豆浆,抬头看见远处那座旧商场外墙上的灯牌又坏了一角。",
-          "北城从来不擅长把一切都修得整整齐齐,可也正因如此,很多细小的故事才有了藏身的缝隙。"
-        ]
-      }
+    sections: [
+      makeSection("north-lore", "旧城资料", "lore", 1, [
+        ["资料一 北城地标", "天桥、报刊亭和旧商场灯牌,是北城许多故事共同的入口。"]
+      ]),
+      makeSection("north-novel", "正文", "novel", 100, [
+        ["第一章 天桥下的晚风", "晚高峰刚过,天桥下的人群还没散完。"]
+      ])
     ]
   }
 ];
@@ -103,4 +194,3 @@ export const demoBooks: Book[] = [
 export function getBookById(bookId: string) {
   return demoBooks.find((book) => book.id === bookId);
 }
-

+ 76 - 0
docs/book-catalog.example.json

@@ -0,0 +1,76 @@
+[
+  {
+    "id": "your-book-id",
+    "title": "你的作品名",
+    "author": "作者名",
+    "category": "分类",
+    "description": "作品简介",
+    "wordCount": "约 12 万字",
+    "coverStyle": "linear-gradient(135deg, #7b5c44, #2e405a)",
+    "sections": [
+      {
+        "id": "lore-world",
+        "title": "世界观设定",
+        "kind": "lore",
+        "order": 1,
+        "entries": [
+          {
+            "id": "lore-world-1",
+            "title": "资料一 世界规则",
+            "kind": "lore",
+            "order": 1,
+            "content": [
+              "这里放第一段设定资料。",
+              "这里放第二段设定资料。"
+            ]
+          }
+        ]
+      },
+      {
+        "id": "lore-roles",
+        "title": "人物档案",
+        "kind": "lore",
+        "order": 2,
+        "entries": [
+          {
+            "id": "lore-roles-1",
+            "title": "资料二 主角档案",
+            "kind": "lore",
+            "order": 1,
+            "content": [
+              "这里放角色资料。",
+              "也可以拆成多条资料。"
+            ]
+          }
+        ]
+      },
+      {
+        "id": "novel-volume-1",
+        "title": "正文 第一卷",
+        "kind": "novel",
+        "order": 100,
+        "entries": [
+          {
+            "id": "novel-volume-1-1",
+            "title": "第一章 章节名",
+            "kind": "novel",
+            "order": 1,
+            "content": [
+              "这里放正文第一段。",
+              "这里放正文第二段。"
+            ]
+          },
+          {
+            "id": "novel-volume-1-2",
+            "title": "第二章 下一节",
+            "kind": "novel",
+            "order": 2,
+            "content": [
+              "这里继续放正文内容。"
+            ]
+          }
+        ]
+      }
+    ]
+  }
+]

+ 107 - 0
docs/book-catalog.md

@@ -0,0 +1,107 @@
+# 正式书库目录说明
+
+部署到正式环境后,网站会优先读取:
+
+```text
+storage/books/catalog.json
+```
+
+如果这个文件不存在,网站才会回退到代码里的演示书数据。
+
+## 推荐结构
+
+每一本书建议按下面的结构组织:
+
+1. 设定资料分区
+2. 正文分卷分区
+
+也就是:
+
+- 世界观设定
+- 人物档案
+- 势力组织
+- 时间线
+- 地图地点
+- 正文 第一卷
+- 正文 第二卷
+
+这样目录里就能同时看到资料库和小说正文,而且顺序稳定,不会打乱。
+
+## 顶层字段
+
+每本书需要这些字段:
+
+- `id`
+- `title`
+- `author`
+- `category`
+- `description`
+- `wordCount`
+- `coverStyle`
+- `sections`
+
+## sections 字段
+
+`sections` 是分区数组,每个分区需要:
+
+- `id`
+- `title`
+- `kind`
+- `order`
+- `entries`
+
+说明:
+
+- `kind` 只能是:
+  - `lore`:资料
+  - `novel`:正文
+- `order` 用来控制目录顺序
+  - 资料建议从 `1` 开始
+  - 正文建议从 `100` 开始
+
+## entries 字段
+
+每个分区下面的 `entries` 是具体条目,每条需要:
+
+- `id`
+- `title`
+- `kind`
+- `order`
+- `content`
+
+说明:
+
+- `kind` 要和所属分区一致
+- `order` 控制分区内顺序
+- `content` 是字符串数组,每一项就是一段内容
+
+## 推荐排序规则
+
+为了避免目录混乱,建议固定按下面方式编号:
+
+- 资料分区:
+  - 世界观设定:`order: 1`
+  - 人物档案:`order: 2`
+  - 势力组织:`order: 3`
+  - 时间线:`order: 4`
+- 正文分区:
+  - 第一卷:`order: 100`
+  - 第二卷:`order: 200`
+  - 第三卷:`order: 300`
+
+这样即使后面继续补资料,也不会把正文顺序打乱。
+
+## 示例文件
+
+完整示例见:
+
+[book-catalog.example.json](C:\Users\LA\Documents\New%20project\docs\book-catalog.example.json)
+
+## 正式使用建议
+
+正式部署时建议:
+
+1. 只把正式书数据放到 `storage/books/catalog.json`
+2. 不要直接改代码里的 `demo` 书
+3. 由 `ops` 或内容维护流程负责更新 `storage/books/catalog.json`
+4. 更新书库后重启服务或走热更新刷新内容

+ 41 - 0
lib/book-catalog.ts

@@ -0,0 +1,41 @@
+import { promises as fs } from "fs";
+import path from "path";
+import { demoBooks } from "@/data/demo-books";
+import { Book } from "@/types/book";
+export { getBookEntries, getBookContentCount, getBookStructureStats } from "@/lib/book-helpers";
+
+const storageCatalogPath = path.join(process.cwd(), "storage", "books", "catalog.json");
+
+function normalizeBooks(books: Book[]) {
+  return books
+    .map((book) => ({
+      ...book,
+      sections: [...book.sections]
+        .sort((a, b) => a.order - b.order)
+        .map((section) => ({
+          ...section,
+          entries: [...section.entries].sort((a, b) => a.order - b.order)
+        }))
+    }))
+    .sort((a, b) => a.title.localeCompare(b.title, "zh-CN"));
+}
+
+export async function getBooks() {
+  try {
+    const raw = await fs.readFile(storageCatalogPath, "utf8");
+    const parsed = JSON.parse(raw) as Book[];
+
+    if (Array.isArray(parsed) && parsed.length > 0) {
+      return normalizeBooks(parsed);
+    }
+  } catch {
+    // Fall back to bundled demo books when no formal catalog exists yet.
+  }
+
+  return normalizeBooks(demoBooks);
+}
+
+export async function getBookById(bookId: string) {
+  const books = await getBooks();
+  return books.find((book) => book.id === bookId);
+}

+ 40 - 0
lib/book-helpers.ts

@@ -0,0 +1,40 @@
+import { Book, BookEntryKind } from "@/types/book";
+
+export type FlattenedBookEntry = {
+  id: string;
+  title: string;
+  kind: BookEntryKind;
+  order: number;
+  content: string[];
+  sectionId: string;
+  sectionTitle: string;
+  sectionKind: BookEntryKind;
+  flatIndex: number;
+};
+
+export function getBookEntries(book: Book): FlattenedBookEntry[] {
+  return book.sections.flatMap((section) =>
+    section.entries.map((entry) => ({
+      ...entry,
+      sectionId: section.id,
+      sectionTitle: section.title,
+      sectionKind: section.kind,
+      flatIndex: 0
+    }))
+  ).map((entry, index) => ({
+    ...entry,
+    flatIndex: index
+  }));
+}
+
+export function getBookContentCount(book: Book) {
+  return getBookEntries(book).length;
+}
+
+export function getBookStructureStats(book: Book) {
+  const entries = getBookEntries(book);
+  return {
+    loreCount: entries.filter((item) => item.kind === "lore").length,
+    novelCount: entries.filter((item) => item.kind === "novel").length
+  };
+}

+ 14 - 3
types/book.ts

@@ -1,9 +1,21 @@
-export type Chapter = {
+export type BookEntryKind = "lore" | "novel";
+
+export type BookEntry = {
   id: string;
   title: string;
+  kind: BookEntryKind;
+  order: number;
   content: string[];
 };
 
+export type BookSection = {
+  id: string;
+  title: string;
+  kind: BookEntryKind;
+  order: number;
+  entries: BookEntry[];
+};
+
 export type Book = {
   id: string;
   title: string;
@@ -12,6 +24,5 @@ export type Book = {
   description: string;
   wordCount: string;
   coverStyle: string;
-  chapters: Chapter[];
+  sections: BookSection[];
 };
-