Compare commits

...

5 Commits

Author SHA1 Message Date
kang
d10679ea51 修 Ch7 hint 字段 ASCII 单引号提前终止字符串 —— JS 语法错误导致 CHAPTERS 数组后续代码全部挂掉
现象:Console 报 Unexpected identifier '你是修理工吗'。
原因:Ch7(n=-7)hint 里用了 ASCII 单引号 '你是修理工吗?...' 作为内部引用,外层字符串也是 ASCII 单引号包裹——被提前终止。
修:改为中文双引号 "你是修理工吗?..."。

这才是'一直载入中'的真正原因:JS 报错 → renderChapterList/renderGallery/loadNovel 从未被执行到 → reader-content 一直停在初始的'载入中 …'。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:58:12 +08:00
481b47b0b2 auto-save 2026-04-18 15:54 (~1) 2026-04-18 15:55:26 +08:00
kang
33c60aa08a 修阅读'一直载入中':novel.md gzip + 章节索引 + loading 状态
问题定位:
- novel.md 285KB 线上 Content-Type 被 nginx 默认识别为 application/octet-stream,gzip_types 无法命中,未压缩直传
- 用户网络慢时全本下载数秒甚至更久,一直'载入中 …'
- 附带问题:renderNovel 的 sections forEach idx 和 CHAPTERS 数组错位——'终章之后·当世界再次变聪明'这个非章节 ## heading 被 skip 掉后,之后所有第二部章节的 CHAPTERS[idx] 索引都偏移一位,映射到错误的章节图(不崩溃但显示错)

修复:
1. Dockerfile nginx conf 加 location = /novel.md 用 default_type 'text/markdown; charset=utf-8',覆盖默认 octet-stream,让 gzip_types 命中。gzip_comp_level 6 / gzip_min_length 512 / gzip_vary on。预计 285KB → ~90KB
2. renderNovel 里 ch 的解析从 CHAPTERS[idx] 改为 CHAPTERS.find(c => c.n === chN),按语义 n 精确匹配(主部 1-20 + 0 终章 / 第二部 -1 到 -8)。不再受 section 索引漂移影响
3. loadNovel 细化 loading 文案'正在下载全本 …' → '正在渲染章节 …';await setTimeout(0) 让浏览器先 paint;失败时补兜底直链'直接打开 novel.md'
4. imgPath 从 './images/${ch.img}' 加防守 (ch && ch.img),第二部 img=null 的章节不渲染 broken img src

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:50:40 +08:00
15b2812507 auto-save 2026-04-18 15:48 (~2) 2026-04-18 15:48:56 +08:00
d46152ccb8 auto-save 2026-04-18 15:40 (~1) 2026-04-18 15:42:03 +08:00
3 changed files with 68 additions and 13 deletions

View File

@@ -475,6 +475,41 @@
"message": "auto-save 2026-04-18 15:34 (~1)",
"hash": "c3cacbb",
"files_changed": 1
},
{
"ts": "2026-04-18T15:40:38+08:00",
"type": "commit",
"message": "worklog.json auto-save tail",
"hash": "3e10890",
"files_changed": 1
},
{
"ts": "2026-04-18T15:42:03+08:00",
"type": "commit",
"message": "auto-save 2026-04-18 15:40 (~1)",
"hash": "d46152c",
"files_changed": 1
},
{
"ts": "2026-04-18T15:48:56+08:00",
"type": "commit",
"message": "auto-save 2026-04-18 15:48 (~2)",
"hash": "15b2812",
"files_changed": 2
},
{
"ts": "2026-04-18T15:50:40+08:00",
"type": "commit",
"message": "修阅读'一直载入中'novel.md gzip + 章节索引 + loading 状态",
"hash": "33c60aa",
"files_changed": 3
},
{
"ts": "2026-04-18T15:55:26+08:00",
"type": "commit",
"message": "auto-save 2026-04-18 15:54 (~1)",
"hash": "481b47b",
"files_changed": 1
}
]
}

View File

@@ -16,7 +16,13 @@ RUN printf 'server {\n\
access_log off;\n\
}\n\
\n\
# html / md: always revalidate\n\
# novel.md specifically — force text/markdown so gzip triggers\n\
location = /novel.md {\n\
default_type "text/markdown; charset=utf-8";\n\
add_header Cache-Control "public, max-age=0, must-revalidate";\n\
}\n\
\n\
# html / md catch-all: always revalidate\n\
location ~* \\.(html|md)$ {\n\
add_header Cache-Control "public, max-age=0, must-revalidate";\n\
}\n\
@@ -26,8 +32,10 @@ RUN printf 'server {\n\
}\n\
\n\
gzip on;\n\
gzip_types text/plain text/markdown text/css application/javascript application/json image/svg+xml;\n\
gzip_min_length 1024;\n\
gzip_comp_level 6;\n\
gzip_types text/plain text/markdown text/css application/javascript application/json image/svg+xml text/html;\n\
gzip_min_length 512;\n\
gzip_vary on;\n\
}\n' > /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@@ -838,7 +838,7 @@ const CHAPTERS = [
{ n: -4, title: '那一夜,他关掉了屏幕', img: null, volume: '第二部 · 起笔', hint: '审查会前夜:读完沈陌整本笔记 + 想起二十三年前老总监那封被遗忘的邮件"替我看住这套东西" + 关掉屏幕不写答案直接去睡 + 苏青禾那句"我信你"' },
{ n: -5, title: '让光回到所有看见的人身上', img: null, volume: '第二部 · 起笔', hint: '审查会现场:帆布包里三本东西摆上评议桌。有条件通过。请许幼宁+沈陌上台站到他两侧。请联盟找回那位说"让他自己醒醒"的安全侧负责人旁听。走出大厅下起春雨,他发给苏青禾三个字"我回家"' },
{ n: -6, title: '那封邮件的作者', img: null, volume: '第二部 · 起笔', hint: '老总监周以谦被找到:潮屿海边阳台,"那封邮件我后来很多年都后悔发过。那不是上级该对下级说的话。那是一个认输的人在找替身"。拒绝上台:不是所有被按下去的人都需要被带回舞台。你那根刺这次我看见你真的开始把它收起来了' },
{ n: -7, title: '她记得的那碗面', img: null, volume: '第二部 · 起笔', hint: '北方县城养老院:母亲轻度阿尔茨海默'你是修理工吗我家那台老电视机最近总是雪花'。他不纠正她。她用讲给陌生人的语气讲他小时候。临别她清了一瞬'阿舟你最近辛苦了吧'。他去县城街角小餐馆吃葱花挂面——这辈子第一次为自己流眼泪' },
{ n: -7, title: '她记得的那碗面', img: null, volume: '第二部 · 起笔', hint: '北方县城养老院:母亲轻度阿尔茨海默"你是修理工吗我家那台老电视机最近总是雪花"。他不纠正她。她用讲给陌生人的语气讲他小时候。临别她清了一瞬"阿舟,你最近辛苦了吧"。他去县城街角小餐馆吃葱花挂面——这辈子第一次为自己流眼泪' },
{ n: -8, title: '接下来这二十年,换我来写', img: null, volume: '第二部 · 收束', hint: '七年流速切片v0.7 全球落地 / 周以谦病了看够了 / 沈陌女儿入学挑《操作系统基础 I》/ 母亲走了他在老屋自己煮一碗葱花面 / 城中村被拆成空地他没进去 / 沈陌接替他坐上评议席 / 71 岁出书《那三个夜晚和之后的事》扉页三个名字 / 沈陌新笔记第一页"接下来这二十年,换我来写" / 阳台摇椅"反正我也在" —— 全书·完' },
];
@@ -922,13 +922,21 @@ function closeLightbox() {
}
async function loadNovel() {
const container = document.getElementById('reader-content');
try {
const resp = await fetch('./novel.md');
container.innerHTML = '<div class="loading">正在下载全本 …</div>';
const resp = await fetch('./novel.md', { cache: 'no-cache' });
if (!resp.ok) throw new Error('HTTP ' + resp.status);
container.innerHTML = '<div class="loading">正在渲染章节 …</div>';
const text = await resp.text();
// Yield to the browser so the loading message paints before heavy work
await new Promise(r => setTimeout(r, 0));
renderNovel(text);
} catch (e) {
document.getElementById('reader-content').innerHTML =
'<div class="loading" style="color: var(--red)">小说载入失败:' + e.message + '</div>';
console.error('loadNovel failed:', e);
container.innerHTML =
'<div class="loading" style="color: var(--red)">小说载入失败:' + e.message +
'。<a href="./novel.md" style="color: var(--gold); text-decoration: underline;">直接打开 novel.md</a></div>';
}
}
@@ -944,24 +952,28 @@ function renderNovel(md) {
let m = heading.match(/^(第(\d+)章|终章)\s+(.+)$/);
let isPart2 = false;
let num, title;
const cnMap = { '一': '01', '二': '02', '三': '03', '四': '04', '五': '05', '六': '06', '七': '07', '八': '08', '九': '09', '十': '10' };
const cnToInt = { '一':1,'二':2,'三':3,'四':4,'五':5,'六':6,'七':7,'八':8,'九':9,'十':10 };
let kicker, chN;
if (m) {
const isEpilogue = !m[2];
num = isEpilogue ? '终' : m[2].padStart(2, '0');
title = m[3];
// kicker assigned below
var kicker = isEpilogue ? 'EPILOGUE' : 'CHAPTER ' + num;
kicker = isEpilogue ? 'EPILOGUE' : 'CHAPTER ' + num;
chN = isEpilogue ? 0 : parseInt(m[2]);
} else {
const m2 = heading.match(/^第二部\s*·\s*第(.+?)章\s*·\s*(.+)$/);
if (!m2) return;
isPart2 = true;
const cnMap = { '一': '01', '二': '02', '三': '03', '四': '04', '五': '05', '六': '06', '七': '07', '八': '08', '九': '09', '十': '10' };
const arabic = cnMap[m2[1]] || m2[1];
num = 'II · ' + arabic;
title = m2[2];
var kicker = 'PART II · CHAPTER ' + arabic;
kicker = 'PART II · CHAPTER ' + arabic;
chN = -(cnToInt[m2[1]] || parseInt(m2[1])); // -1 = Part II Ch1, -2 = Part II Ch2, ...
}
const ch = CHAPTERS[idx];
const imgPath = ch ? `./images/${ch.img}` : '';
// Match chapter by semantic n (immune to section index drift caused by non-chapter ## headings)
const ch = CHAPTERS.find(c => c.n === chN);
const imgPath = (ch && ch.img) ? `./images/${ch.img}` : '';
const paragraphs = body.split(/\n\s*\n/).map(p => {
const line = p.trim();
if (!line) return '';