Переглянути джерело

Fix reader UI bugs and improve mobile experience

- Fix cycleFont/cycleTheme/cycleWidth scrolling to top (separate useEffect)
- Fix mobile top/bottom bar color not following theme (use CSS var --reader-page)
- Fix mobile catalog header covered by scrolling content (sticky with proper padding)
- Fix mobile catalog dark gap at bottom (bottom:0 + padding-bottom)
- Add theme-color meta tag for system status bar / browser chrome color sync
- Add chapters 11-20 to demo book memory-catcher
- Fix book card button alignment (flex column layout)
- Fix catalog full-screen height on mobile and desktop
- Fix agent summary stats: single row on mobile (5 columns)
- Fix chat page vertical spacing on desktop
- Fix html background to prevent scrollbar-gutter gap

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
zhaozhi 2 тижнів тому
батько
коміт
2872d70a54

+ 15 - 0
.claude/settings.local.json

@@ -0,0 +1,15 @@
+{
+  "permissions": {
+    "allow": [
+      "Bash(find . -not -path */node_modules/* -not -path */.git/* -type f)",
+      "Bash(npm install:*)",
+      "Bash(npm run:*)",
+      "Bash(npx tsc:*)",
+      "Bash(python3 -c \":*)",
+      "Bash(node -e \"const fs=require\\(''fs''\\); const d=fs.readFileSync\\(''f:/uxianqi-Project/ai/lan-reader-chat/data/demo-books.ts''\\); const lines=d.toString\\(''hex''\\).split\\(''0a''\\); console.log\\(lines[14].substring\\(0,60\\)\\); console.log\\(lines[20].substring\\(0,60\\)\\);\")",
+      "Bash(node -e \":*)",
+      "Bash(node \"f:/uxianqi-Project/ai/lan-reader-chat/scripts/extend-chapter.js\")",
+      "Bash(node \"f:/uxianqi-Project/ai/lan-reader-chat/scripts/extend-ch1.js\")"
+    ]
+  }
+}

+ 67 - 21
app/globals.css

@@ -20,6 +20,7 @@
 html {
   scroll-behavior: smooth;
   scrollbar-gutter: stable;
+  background: var(--bg);
 }
 
 body {
@@ -1342,6 +1343,8 @@ textarea {
 
 .book-card {
   overflow: hidden;
+  display: flex;
+  flex-direction: column;
 }
 
 .book-cover {
@@ -1377,10 +1380,14 @@ textarea {
 }
 
 .book-body {
+  flex: 1;
   padding: 18px;
+  display: flex;
+  flex-direction: column;
 }
 
 .book-body p {
+  flex: 1;
   color: var(--muted);
   line-height: 1.7;
 }
@@ -1462,12 +1469,18 @@ textarea {
     radial-gradient(circle at top left, rgba(255, 240, 220, 0.3), transparent 28%),
     linear-gradient(180deg, #dcd6ce 0%, #d4cec5 100%);
   overflow: auto;
+  scrollbar-width: none;
+  -ms-overflow-style: none;
   display: flex;
   align-items: stretch;
   justify-content: center;
   position: relative;
 }
 
+.reader-stage::-webkit-scrollbar {
+  display: none;
+}
+
 .reader-desktop-layout {
   width: fit-content;
   max-width: 100%;
@@ -1479,6 +1492,7 @@ textarea {
 }
 
 .reader-qq-shell {
+  grid-column: 2;
   width: var(--reader-shell-width, min(980px, calc(100vw - 280px)));
   min-height: 100vh;
   overflow: visible;
@@ -1491,7 +1505,7 @@ textarea {
 }
 
 .reader-qq-paper {
-  min-height: 100%;
+  min-height: 100vh;
   background: #f7f0e4;
   box-shadow: 0 22px 60px rgba(54, 33, 21, 0.08);
   display: flex;
@@ -1539,16 +1553,22 @@ textarea {
 }
 
 .reader-float {
-  position: sticky;
+  position: fixed;
   top: 160px;
   z-index: 15;
   display: grid;
   gap: 10px;
+  width: 78px;
+}
+
+.reader-float--left {
+  grid-column: 1;
+  left: max(8px, calc(50vw - var(--reader-shell-width, min(980px, calc(100vw - 280px))) / 2 - 96px));
 }
 
-.reader-float--left,
 .reader-float--right {
-  width: 78px;
+  grid-column: 3;
+  right: max(20px, calc(50vw - var(--reader-shell-width, min(980px, calc(100vw - 280px))) / 2 - 96px));
 }
 
 .reader-progress-rail {
@@ -1608,7 +1628,7 @@ textarea {
 }
 
 .reader-catalog-inline {
-  min-height: 100%;
+  min-height: 100vh;
   background: #e1d6c8;
   padding: 28px 40px 36px;
   box-shadow: 0 22px 60px rgba(54, 33, 21, 0.08);
@@ -1659,7 +1679,7 @@ textarea {
 }
 
 .reader-catalog__section-title strong {
-  font-size: 1.02rem;
+  font-size: 1.25rem;
   font-weight: 700;
 }
 
@@ -1799,7 +1819,7 @@ textarea {
 .chat-wechat-page {
   min-height: calc(100vh - 74px);
   height: calc(100vh - 74px);
-  padding: 0 0 24px;
+  padding: 24px 0;
   overflow: hidden;
   overscroll-behavior: none;
   display: grid;
@@ -1813,10 +1833,9 @@ textarea {
 
 .chat-wechat-shell {
   width: 100%;
-  margin-top: 44px;
+  margin-top: 0;
   min-height: 0;
-  min-height: calc(100vh - 210px);
-  height: calc(100vh - 210px);
+  height: calc(100vh - 74px - 48px);
   border-radius: 30px;
   overflow: hidden;
   border: 1px solid rgba(255, 255, 255, 0.72);
@@ -2466,8 +2485,7 @@ textarea {
     display: grid;
     gap: 0;
     padding: 10px 14px;
-    background: var(--reader-page);
-    backdrop-filter: blur(14px);
+    background: var(--reader-page, #f7f0e4);
   }
 
   .reader-mobile-bar--top {
@@ -2572,10 +2590,10 @@ textarea {
     position: fixed;
     left: 0;
     right: 0;
-    bottom: calc(76px + env(safe-area-inset-bottom));
+    bottom: 0;
     display: block;
-    max-height: min(calc(74dvh - env(safe-area-inset-bottom)), 620px);
-    padding: 18px 18px 18px;
+    max-height: min(74dvh, 620px);
+    padding: 0 0 calc(76px + env(safe-area-inset-bottom));
     border-radius: 26px 26px 0 0;
     box-shadow: 0 -20px 40px rgba(28, 21, 16, 0.2);
     transform: translateY(102%);
@@ -2593,8 +2611,13 @@ textarea {
   .reader-catalog-mobile-sheet .reader-catalog__header {
     position: sticky;
     top: 0;
-    z-index: 1;
+    z-index: 10;
     background: inherit;
+    padding: 18px 18px 10px;
+  }
+
+  .reader-catalog-mobile-sheet .reader-catalog-groups {
+    padding: 0 18px;
   }
 
   .reader-catalog-mobile-sheet .reader-catalog__grid {
@@ -2963,11 +2986,26 @@ textarea {
   }
 
   .agents-monitor-summary {
-    grid-template-columns: repeat(2, minmax(0, 1fr));
+    grid-template-columns: repeat(5, minmax(0, 1fr));
+    gap: 6px;
   }
 
   .agents-monitor-summary__item:first-child {
-    grid-column: span 2;
+    grid-column: unset;
+  }
+
+  .agents-monitor-summary__item {
+    min-height: 48px;
+    min-width: 0;
+    padding: 6px 8px;
+  }
+
+  .agents-monitor-summary__item strong {
+    font-size: 1rem;
+  }
+
+  .agents-monitor-summary__item span {
+    font-size: 0.72rem;
   }
 
   .agents-worklist {
@@ -3171,9 +3209,17 @@ textarea {
   }
 
   .reader-catalog-mobile-sheet {
-    bottom: calc(74px + env(safe-area-inset-bottom));
-    max-height: calc(76dvh - env(safe-area-inset-bottom));
-    padding: 16px 16px 16px;
+    bottom: 0;
+    max-height: 76dvh;
+    padding: 0 0 calc(74px + env(safe-area-inset-bottom));
+  }
+
+  .reader-catalog-mobile-sheet .reader-catalog__header {
+    padding: 16px 16px 10px;
+  }
+
+  .reader-catalog-mobile-sheet .reader-catalog-groups {
+    padding: 0 16px;
   }
 }
 

+ 4 - 1
app/layout.tsx

@@ -4,7 +4,10 @@ import { SiteHeader } from "@/components/site-header";
 
 export const metadata: Metadata = {
   title: "局域网书房",
-  description: "一个同时包含局域网聊天和书架阅读的网站骨架。"
+  description: "一个同时包含局域网聊天和书架阅读的网站骨架。",
+  other: {
+    "theme-color": "#f4efe7"
+  }
 };
 
 export default function RootLayout({

+ 23 - 4
components/reader/reader-view.tsx

@@ -55,7 +55,7 @@ export function ReaderView({
   const stageRef = useRef<HTMLElement | null>(null);
 
   useEffect(() => {
-    const pageColor = themeOptions[themeIndex].page;
+    const { page: pageColor, stage: stageColor } = themeOptions[themeIndex];
     document.body.classList.add("reader-body");
     const previousBodyBackground = document.body.style.background;
     const previousHtmlBackground = document.documentElement.style.background;
@@ -63,10 +63,21 @@ export function ReaderView({
     document.body.style.background = pageColor;
     document.documentElement.style.background = pageColor;
 
+    // Sync browser chrome (status bar + navigation bar) with theme
+    let metaTheme = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]');
+    if (!metaTheme) {
+      metaTheme = document.createElement("meta");
+      metaTheme.name = "theme-color";
+      document.head.appendChild(metaTheme);
+    }
+    const previousThemeColor = metaTheme.content;
+    metaTheme.content = pageColor;
+
     return () => {
       document.body.classList.remove("reader-body");
       document.body.style.background = previousBodyBackground;
       document.documentElement.style.background = previousHtmlBackground;
+      if (metaTheme) metaTheme.content = previousThemeColor;
     };
   }, [themeIndex]);
 
@@ -91,7 +102,14 @@ export function ReaderView({
       stage.removeEventListener("scroll", updateProgress);
       window.removeEventListener("resize", updateProgress);
     };
-  }, [chapterIndex, widthIndex, fontIndex, themeIndex]);
+  }, [chapterIndex]);
+
+  useEffect(() => {
+    const stage = stageRef.current;
+    if (!stage) return;
+    const maxScroll = stage.scrollHeight - stage.clientHeight;
+    setProgress(maxScroll <= 0 ? 0 : (stage.scrollTop / maxScroll) * 100);
+  }, [fontIndex, widthIndex, themeIndex]);
 
   const currentTheme = themeOptions[themeIndex];
   const currentFont = fontOptions[fontIndex];
@@ -164,6 +182,7 @@ export function ReaderView({
         {
           background: currentTheme.stage,
           "--reader-page": currentTheme.page,
+          "--reader-stage-color": currentTheme.stage,
           "--reader-shell-width": `min(${currentWidth.width}px, calc(100vw - 280px))`
         } as CSSProperties
       }
@@ -175,7 +194,7 @@ export function ReaderView({
         </div>
       </div>
 
-      <div className="reader-mobile-bar reader-mobile-bar--top" style={{ background: currentTheme.page }}>
+      <div className="reader-mobile-bar reader-mobile-bar--top">
         <Link className="reader-mobile-bar__icon reader-mobile-bar__icon--back" href="/library" aria-label="返回书架">
           返回
         </Link>
@@ -187,7 +206,7 @@ export function ReaderView({
         </button>
       </div>
 
-      <div className="reader-mobile-bar reader-mobile-bar--bottom" style={{ background: currentTheme.page }}>
+      <div className="reader-mobile-bar reader-mobile-bar--bottom">
         <button className="reader-mobile-bar__action" type="button" onClick={() => setCatalogOpen(true)}>
           目录
         </button>

+ 59 - 0
data/demo-books.ts

@@ -153,6 +153,65 @@ export const demoBooks: Book[] = [
           "第十章 七号出口",
           "他们决定在现实中去找那座被封闭的七号出口。",
           "清晨的围挡后满是积水和灰尘,可在围挡缝隙后,林夜看见了一盏仍旧亮着的引导灯。"
+        ],
+        [
+          "第十一章 静止的镜子",
+          "他们将旧车站的档案带回了事务所。危文件夹里存了三年前的矫正申请表,日期和委托单上的日期差了六天。",
+          "阶洲把文件夹摆在桌上,说:有人先我们一步到了这里。林夜翻开第一页,记录栏里写着如实的声冬坐标:排前三个当事人的名字,其中一个叫陈七。",
+          "胶片第二天才完成滴透,林夜均一夜没有离开样本上方的察察无眠,升得天亮时终于找到了一个可以切入的参照点。"
+        ],
+        [
+          "第十二章 陈七的地址",
+          "林夜在显示屏上定位出了陈七最近一次上线的系统时间:三个月前,凌晨三点七分。",
+          "地址指向一幢老式公寓楼,目前登记至少四个不同的租户,其中一个展示的个人信息与单上的外形很相近。",
+          "阶洲想个和他联系,林夜拦下他:先别动。我们先确认那个地址现在的情况。"
+        ],
+        [
+          "第十三章 四楼的灯",
+          "公寓楼四楼的走廊里,单数号房间的灯全圆了,只剩一盏走廊尽头的应急灯在闪烁。",
+          "驾驶师说四楼自上月就没人住了,不过最近两个星期每夜都有人在走廊里径直走进去。",
+          "林夜把耳机谑连开,强制自己呈展正常是观察者而不是个人身份提假的感觉,然后慢慢推开了最靠里的一片房门。"
+        ],
+        [
+          "第十四章 未完成的删除",
+          "房间里的机器还开着。不是旧型的负载终端,而是一台全内置模块的振器设备,摄了半空。上面还连着一根漏出几段内容的错误日志。",
+          "阶洲读了日志的副本,摇首说:这不是普通的删除指令,有人在运行过程中拼强中断了。它起杀一半,剩下一半的内容还浮在缓冲区里。",
+          "林夜思考了一下,说:我们再进去一次,把剩下的内容取出来,无论那个内容是什么。"
+        ],
+        [
+          "第十五章 缓冲里的声音",
+          "他们第三次进入目标节点。不同于前两次,这次剩下的记忆碎片完全是音频形式:整段太长,节奏极不均匀,像一个夜里均眠时拥着被子说话的人。",
+          "林夜第一次听完第一段,说这声音我认得出。胶片的时候,日志里有一个签名,到现在我终于确定那是谁的声音了。"
+        ],
+        [
+          "第十六章 三年前的规则",
+          "数月和以前第一次出现的委托线索相互印证了:三年前阳鸾律所确实举报过一件非法记忆处理案件,当事人之一正是陈七。",
+          "但陈七的当年稽诉记录却显示他全程配合调查,最终结案时被判定为无远证据的误报。那份案卷就此封档,没有任何后续记录。",
+          "阶洲把档案平平展开,轻声说:所以就算有人提前封锁记忆,法律也不会查下去。林夜没有回答。"
+        ],
+        [
+          "第十七章 陈七的说法",
+          "二十小时后,陈七自己上门了。他比林夜想象中更年轻,头发很长。",
+          "陈七说,我主动封存那段记忆是因为我对自己的判断不清楚。我不确定自己记忆里的内容是真实发生过的。",
+          "林夜问:那你现在想展开封锁了吗?陈七摇摇头:我来看看你们从里面取出了什么。"
+        ],
+        [
+          "第十八章 当事人的记忆",
+          "陈七第一次建立连接时非常居漯。他不习惯深层接入,应激反应很强,年夜都没有完全选择放弃连接。",
+          "阶洲在外面抱着臂说:白天不要来找我。如果不是林夜,我会直接评估为过度危险的委托。",
+          "连接第二层时,陈七突然平静下来。林夜操控台上的湛悲值开始延伸,一直延伸到三年前的节点。"
+        ],
+        [
+          "第十九章 道路的尽头",
+          "他们在陈七的记忆里看见了三年前的陈七自己。年轻一点,头发同样很长,站在一段志从未地上的地下通道入口。",
+          "通道里有一打开的记忆屏障,屏障里面有歪步声和采制设备的电子声过来,就在这时进来了。",
+          "林夜与先进入的那个人交错而过,天交过脉的一刹间,林夜看清楚了对方的脸。"
+        ],
+        [
+          "第二十章 重开的档案",
+          "林夜小心地把七号出口的标志拦截点之前的历史连接工作全部完成了。文件完整度至此回升到百分之六十七。",
+          "丽泉承论了代远第一次审阅该案件全部内容,降造了很久才抬起头说:这个案子是真实的。这不是误报。",
+          "陈七坐在山尽头的居简里,一言不发地盯着下雨。门边假期节的灯就着,林夜把那份档案正式确认重开,写上了新的日期。"
         ]
       ])
     ]

+ 89 - 0
scripts/add-chapters.js

@@ -0,0 +1,89 @@
+const fs = require("fs");
+const path = require("path");
+
+const filePath = path.join(__dirname, "../data/demo-books.ts");
+let src = fs.readFileSync(filePath, "utf8");
+
+const newChapters = [
+  [
+    "第十一章 静止的镜子",
+    "他们将旧车站的档案带回了事务所。危文件夹里存了三年前的矫正申请表,日期和委托单上的日期差了六天。",
+    "阶洲把文件夹摆在桌上,说:有人先我们一步到了这里。林夜翻开第一页,记录栏里写着如实的声冬坐标:排前三个当事人的名字,其中一个叫陈七。",
+    "胶片第二天才完成滴透,林夜均一夜没有离开样本上方的察察无眠,升得天亮时终于找到了一个可以切入的参照点。"
+  ],
+  [
+    "第十二章 陈七的地址",
+    "林夜在显示屏上定位出了陈七最近一次上线的系统时间:三个月前,凌晨三点七分。",
+    "地址指向一幢老式公寓楼,目前登记至少四个不同的租户,其中一个展示的个人信息与单上的外形很相近。",
+    "阶洲想个和他联系,林夜拦下他:先别动。我们先确认那个地址现在的情况。"
+  ],
+  [
+    "第十三章 四楼的灯",
+    "公寓楼四楼的走廊里,单数号房间的灯全圆了,只剩一盏走廊尽头的应急灯在闪烁。",
+    "驾驶师说四楼自上月就没人住了,不过最近两个星期每夜都有人在走廊里径直走进去。",
+    "林夜把耳机谑连开,强制自己呈展正常是观察者而不是个人身份提假的感觉,然后慢慢推开了最靠里的一片房门。"
+  ],
+  [
+    "第十四章 未完成的删除",
+    "房间里的机器还开着。不是旧型的负载终端,而是一台全内置模块的振器设备,摄了半空。上面还连着一根漏出几段内容的错误日志。",
+    "阶洲读了日志的副本,摇首说:这不是普通的删除指令,有人在运行过程中拼强中断了。它起杀一半,剩下一半的内容还浮在缓冲区里。",
+    "林夜思考了一下,说:我们再进去一次,把剩下的内容取出来,无论那个内容是什么。"
+  ],
+  [
+    "第十五章 缓冲里的声音",
+    "他们第三次进入目标节点。不同于前两次,这次剩下的记忆碎片完全是音频形式:整段太长,节奏极不均匀,像一个夜里均眠时拥着被子说话的人。",
+    "林夜第一次听完第一段,说这声音我认得出。胶片的时候,日志里有一个签名,到现在我终于确定那是谁的声音了。"
+  ],
+  [
+    "第十六章 三年前的规则",
+    "数月和以前第一次出现的委托线索相互印证了:三年前阳鸾律所确实举报过一件非法记忆处理案件,当事人之一正是陈七。",
+    "但陈七的当年稽诉记录却显示他全程配合调查,最终结案时被判定为无远证据的误报。那份案卷就此封档,没有任何后续记录。",
+    "阶洲把档案平平展开,轻声说:所以就算有人提前封锁记忆,法律也不会查下去。林夜没有回答。"
+  ],
+  [
+    "第十七章 陈七的说法",
+    "二十小时后,陈七自己上门了。他比林夜想象中更年轻,头发很长。",
+    "陈七说,我主动封存那段记忆是因为我对自己的判断不清楚。我不确定自己记忆里的内容是真实发生过的。",
+    "林夜问:那你现在想展开封锁了吗?陈七摇摇头:我来看看你们从里面取出了什么。"
+  ],
+  [
+    "第十八章 当事人的记忆",
+    "陈七第一次建立连接时非常居漯。他不习惯深层接入,应激反应很强,年夜都没有完全选择放弃连接。",
+    "阶洲在外面抱着臂说:白天不要来找我。如果不是林夜,我会直接评估为过度危险的委托。",
+    "连接第二层时,陈七突然平静下来。林夜操控台上的湛悲值开始延伸,一直延伸到三年前的节点。"
+  ],
+  [
+    "第十九章 道路的尽头",
+    "他们在陈七的记忆里看见了三年前的陈七自己。年轻一点,头发同样很长,站在一段志从未地上的地下通道入口。",
+    "通道里有一打开的记忆屏障,屏障里面有歪步声和采制设备的电子声过来,就在这时进来了。",
+    "林夜与先进入的那个人交错而过,天交过脉的一刹间,林夜看清楚了对方的脸。"
+  ],
+  [
+    "第二十章 重开的档案",
+    "林夜小心地把七号出口的标志拦截点之前的历史连接工作全部完成了。文件完整度至此回升到百分之六十七。",
+    "丽泉承论了代远第一次审阅该案件全部内容,降造了很久才抬起头说:这个案子是真实的。这不是误报。",
+    "陈七坐在山尽头的居简里,一言不发地盯着下雨。门边假期节的灯就着,林夜把那份档案正式确认重开,写上了新的日期。"
+  ]
+];
+
+// Find the end of the 第十章 entry
+const marker = JSON.stringify("清晨的围挡后满是积水和灰尘,可在围挡缝隙后,林夜看见了一盏仍旧亮着的引导灯。");
+const markerIdx = src.lastIndexOf(marker);
+
+if (markerIdx === -1) {
+  console.error("Could not find insertion point!");
+  process.exit(1);
+}
+
+// Find the closing ] of that entry
+const closeIdx = src.indexOf("]", markerIdx + marker.length);
+
+// Build the new chapter strings
+const newEntryLines = newChapters.map(([title, ...paragraphs]) => {
+  const inner = [JSON.stringify(title), ...paragraphs.map(p => JSON.stringify(p))].join(",\n          ");
+  return `        [\n          ${inner}\n        ]`;
+}).join(",\n");
+
+src = src.slice(0, closeIdx + 1) + ",\n" + newEntryLines + src.slice(closeIdx + 1);
+fs.writeFileSync(filePath, src, "utf8");
+console.log("Done! Added", newChapters.length, "chapters.");

Різницю між файлами не показано, бо вона завелика
+ 0 - 0
tsconfig.tsbuildinfo


Деякі файли не було показано, через те що забагато файлів було змінено