dsh-novel-write-engine 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -8,8 +8,41 @@ window.__ModuleLoader__.load({
8
8
  // src/constants.ts
9
9
  const PANEL_ID = "novel-workshop";
10
10
  const PANEL_ROUTE = "/api/novel-write-engine/panel";
11
+ // src/next-reasons.ts
12
+ /**
13
+ * Chinese one-line explanation for the tool each engine state recommends.
14
+ * Shared by the panel and slash commands so both surfaces speak the same
15
+ * language; unknown tools fall back to the engine's own reason.
16
+ */
17
+ const NEXT_DETAILS = {
18
+ novel_init: "先建立一本书的项目根目录。",
19
+ novel_guide: "按当前状态查看下一步该做什么。",
20
+ novel_status: "查看当前章节、阶段、阻塞与下一步。",
21
+ novel_brief: "先领取当前阶段的任务书,再提交内容。",
22
+ novel_blueprint: "补全书名之外的前提、主题、视角与卷结构。",
23
+ novel_outline: "规划卷与章卡;严格模式下每章必须先有卡。",
24
+ novel_outline_import: "从 Markdown、YAML 或 JSON 导入现成大纲。",
25
+ novel_worldbook: "写设定、规则、势力、地点与角色小传。",
26
+ novel_search: "跨正文、摘要、大纲、世界观与台账搜索。",
27
+ novel_doctor: "检查项目、日志、索引、正文与规划是否一致。",
28
+ novel_report: "查看全书进度、质量、节奏、角色与伏笔。",
29
+ novel_plan_world: "为新章节写下世界观约束与角色关系。",
30
+ novel_plan_chapter: "把本章拆成可执行的节拍与断章点。",
31
+ novel_write_draft: "按任务书写出本章初稿。",
32
+ novel_review: "按十五维给本章打分并标出问题。",
33
+ novel_revise: "处理评审意见后提交修订。",
34
+ novel_check: "核对场景、角色、因果与声音一致性。",
35
+ novel_archive: "通过终审后归档本章。",
36
+ novel_reopen: "从归档备份重开上一章。",
37
+ novel_rollback: "回退到更早阶段重建当前章。",
38
+ novel_amend: "按明确的修复理由修正一份状态文档。",
39
+ novel_audit: "先查看事务日志,确认并恢复损坏的历史。",
40
+ novel_export: "导出成书或书级提案。",
41
+ novel_diff: "对比当前正文与上一版或给定文本。",
42
+ novel_word_count: "按引擎字数口径核对正文长度。"
43
+ };
11
44
  // src/client/viewmodel.ts
12
- const PANEL_SCHEMA_VERSION = 1;
45
+ const PANEL_SCHEMA_VERSION = 2;
13
46
  const PHASE_LABELS = {
14
47
  P1: "世界观",
15
48
  P2: "章节计划",
@@ -37,19 +70,18 @@ window.__ModuleLoader__.load({
37
70
  qimao: "七猫小说",
38
71
  jinjiang: "晋江文学城"
39
72
  };
40
- const NEXT_DETAILS = {
41
- novel_init: "先建立一本书的项目根目录。",
42
- novel_plan_world: "为新章节写下世界观约束与角色关系。",
43
- novel_plan_chapter: "把本章拆成可执行的节拍与断章点。",
44
- novel_write_draft: "按任务书写出本章初稿。",
45
- novel_review: "按十五维给本章打分并标出问题。",
46
- novel_revise: "处理评审意见后提交修订。",
47
- novel_check: "核对场景、角色、因果与声音一致性。",
48
- novel_archive: "通过终审后归档本章。"
49
- };
50
73
  const TOOL_LABELS = {
51
74
  novel_init: "创建项目",
52
75
  novel_status: "查看状态",
76
+ novel_guide: "上手引导",
77
+ novel_blueprint: "书级蓝图",
78
+ novel_outline: "大纲章卡",
79
+ novel_outline_import: "导入大纲",
80
+ novel_worldbook: "世界观",
81
+ novel_search: "全书搜索",
82
+ novel_doctor: "项目体检",
83
+ novel_report: "全书报告",
84
+ novel_reopen: "重开章节",
53
85
  novel_brief: "领取任务书",
54
86
  novel_plan_world: "补全世界观",
55
87
  novel_plan_chapter: "规划本章",
@@ -84,6 +116,36 @@ window.__ModuleLoader__.load({
84
116
  averageScore: 0,
85
117
  blocking: 0
86
118
  },
119
+ book: {
120
+ present: false,
121
+ strict: false,
122
+ premise: "",
123
+ target: null,
124
+ planned: 0,
125
+ outlineTotal: 0,
126
+ archivedCards: 0,
127
+ coverage: 0,
128
+ volumes: [],
129
+ worldbook: {
130
+ characters: 0,
131
+ locations: 0,
132
+ factions: 0,
133
+ rules: 0,
134
+ glossary: 0,
135
+ backstory: 0
136
+ }
137
+ },
138
+ quality: {
139
+ averageScore: 0,
140
+ scores: [],
141
+ trend: 0
142
+ },
143
+ health: {
144
+ ok: true,
145
+ blocking: 0,
146
+ stateErrors: 0
147
+ },
148
+ activity: [],
87
149
  nextTitle: "",
88
150
  nextDetail: "",
89
151
  nextTool: "",
@@ -102,6 +164,10 @@ window.__ModuleLoader__.load({
102
164
  const stageLabel = (stage) => STAGE_LABELS[stage] ?? stage;
103
165
  const platformLabel = (platform) => PLATFORM_LABELS[platform] ?? platform;
104
166
  const toolLabel = (tool) => TOOL_LABELS[tool] ?? "继续";
167
+ const volumeLabel = (summary) => {
168
+ if (summary.volume === void 0) return "";
169
+ return ` · ${summary.volume_name === void 0 ? `第${summary.volume}卷` : summary.volume_name}${summary.act === void 0 ? "" : ` · ${summary.act}${summary.act_progress === void 0 ? "" : ` ${summary.act_progress}`}`}`;
170
+ };
105
171
  /**
106
172
  * Project the wire payload into strings. Version mismatch wins over everything
107
173
  * else: a stale panel must never render data from another schema as if it were
@@ -146,14 +212,15 @@ window.__ModuleLoader__.load({
146
212
  };
147
213
  });
148
214
  const scored = rows.filter((row) => row.tone === "ok");
149
- const average = scored.length === 0 ? 0 : scored.reduce((sum, row) => sum + row.scoreValue, 0) / scored.length;
215
+ const rowAverage = scored.length === 0 ? 0 : scored.reduce((sum, row) => sum + row.scoreValue, 0) / scored.length;
216
+ const average = summary.averageScore !== void 0 && Number.isFinite(summary.averageScore) ? summary.averageScore : rowAverage;
150
217
  const next = summary.next;
151
218
  const finished = summary.finished === true;
152
219
  const doneLabel = summary.stage === "done" ? finished ? "全书完成" : "本章已归档" : stageLabel(summary.stage);
153
220
  return {
154
221
  state: "ready",
155
222
  title: `《${summary.title}》`,
156
- subtitle: summary.stage === "done" ? `${summary.genre} · ${platformLabel(summary.platform)} · ${finished ? `共 ${summary.totalChapters} 章` : `下一章 第 ${summary.chapter} 章`}` : `${summary.genre} · ${platformLabel(summary.platform)} · 第 ${summary.chapter} 章`,
223
+ subtitle: summary.stage === "done" ? `${summary.genre} · ${platformLabel(summary.platform)} · ${finished ? `共 ${summary.totalChapters} 章` : `下一章 第 ${summary.chapter} 章`}${volumeLabel(summary)}` : `${summary.genre} · ${platformLabel(summary.platform)} · 第 ${summary.chapter} 章${volumeLabel(summary)}`,
157
224
  stageLabel: doneLabel,
158
225
  finished,
159
226
  revisionLabel: `第 ${summary.revision} 次修订`,
@@ -170,6 +237,41 @@ window.__ModuleLoader__.load({
170
237
  averageScore: Math.round(average * 10) / 10,
171
238
  blocking: blocking.length
172
239
  },
240
+ book: {
241
+ present: summary.book?.outline.present === true,
242
+ strict: summary.book?.strict === true,
243
+ premise: summary.book?.premise ?? "",
244
+ target: summary.book?.target ?? null,
245
+ planned: summary.book?.outline.planned ?? 0,
246
+ outlineTotal: summary.book?.outline.total ?? 0,
247
+ archivedCards: summary.book?.outline.archived ?? 0,
248
+ coverage: summary.book?.outline.coverage ?? 0,
249
+ volumes: (summary.book?.outline.volumes ?? []).map((volume) => ({
250
+ title: volume.title,
251
+ range: `第${volume.start}-${volume.end}章`,
252
+ planned: volume.planned,
253
+ archived: volume.archived
254
+ })),
255
+ worldbook: summary.book?.worldbook ?? {
256
+ characters: 0,
257
+ locations: 0,
258
+ factions: 0,
259
+ rules: 0,
260
+ glossary: 0,
261
+ backstory: 0
262
+ }
263
+ },
264
+ quality: {
265
+ averageScore: summary.quality?.averageScore ?? average,
266
+ scores: summary.quality?.scores ?? [],
267
+ trend: summary.quality?.trend ?? 0
268
+ },
269
+ health: {
270
+ ok: summary.health?.ok ?? blocking.length === 0,
271
+ blocking: summary.health?.blocking ?? blocking.length,
272
+ stateErrors: summary.health?.stateErrors ?? 0
273
+ },
274
+ activity: summary.activity ?? [],
173
275
  nextTitle: finished ? "" : next === void 0 ? "状态已同步" : toolLabel(next.tool),
174
276
  nextDetail: finished ? "" : next === void 0 ? "没有待执行的下一步,刷新查看最新状态。" : NEXT_DETAILS[next.tool] ?? next.reason,
175
277
  nextTool: next?.tool ?? "",
@@ -308,7 +410,119 @@ window.__ModuleLoader__.load({
308
410
  }
309
411
  .nwe-refresh:hover:not(:disabled) { border-color: color-mix(in srgb, var(--nwe-gold) 65%, var(--nwe-border)); transform: translateY(-1px); }
310
412
  .nwe-refresh:disabled { cursor: wait; opacity: .55; }
413
+ .nwe-actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
414
+ .nwe-help {
415
+ display: inline-flex;
416
+ align-items: center;
417
+ justify-content: center;
418
+ gap: 6px;
419
+ min-height: 36px;
420
+ padding: 8px 11px;
421
+ border: 1px solid var(--nwe-border);
422
+ border-radius: 10px;
423
+ background: color-mix(in srgb, var(--nwe-raised) 76%, transparent);
424
+ color: var(--nwe-muted);
425
+ font: inherit;
426
+ font-size: 12px;
427
+ cursor: pointer;
428
+ transition: border-color .18s ease, color .18s ease, transform .18s ease;
429
+ }
430
+ .nwe-help:hover { border-color: color-mix(in srgb, var(--nwe-gold) 60%, var(--nwe-border)); color: var(--nwe-ink); transform: translateY(-1px); }
431
+ .nwe-help svg { flex: 0 0 auto; }
311
432
  .nwe-root :focus-visible { outline: 2px solid var(--nwe-gold); outline-offset: 3px; }
433
+ .nwe-modal-backdrop {
434
+ position: fixed;
435
+ inset: 0;
436
+ z-index: 120;
437
+ display: flex;
438
+ align-items: center;
439
+ justify-content: center;
440
+ padding: clamp(12px, 3.5vw, 42px);
441
+ background: color-mix(in srgb, #080a0d 76%, transparent);
442
+ backdrop-filter: blur(7px);
443
+ }
444
+ .nwe-modal {
445
+ display: flex;
446
+ flex-direction: column;
447
+ width: min(860px, 100%);
448
+ max-height: min(88vh, 920px);
449
+ overflow: hidden;
450
+ border: 1px solid color-mix(in srgb, var(--nwe-gold) 24%, var(--nwe-border));
451
+ border-radius: 20px;
452
+ background: var(--nwe-surface);
453
+ box-shadow: 0 30px 90px -24px #000;
454
+ }
455
+ .nwe-modal-head {
456
+ display: flex;
457
+ align-items: flex-start;
458
+ justify-content: space-between;
459
+ gap: 18px;
460
+ flex: 0 0 auto;
461
+ padding: 21px 24px 17px;
462
+ border-bottom: 1px solid var(--nwe-border);
463
+ background: linear-gradient(135deg, color-mix(in srgb, var(--nwe-gold) 10%, var(--nwe-raised)), var(--nwe-surface) 72%);
464
+ }
465
+ .nwe-modal-heading { min-width: 0; }
466
+ .nwe-modal-title {
467
+ margin: 7px 0 5px;
468
+ font-family: "Noto Serif SC", "Songti SC", Georgia, serif;
469
+ font-size: clamp(21px, 2.5vw, 27px);
470
+ font-weight: 600;
471
+ line-height: 1.25;
472
+ }
473
+ .nwe-modal-sub { margin: 0; color: var(--nwe-muted); font-size: 12px; line-height: 1.6; }
474
+ .nwe-modal-close {
475
+ display: grid;
476
+ place-items: center;
477
+ flex: 0 0 auto;
478
+ width: 34px;
479
+ height: 34px;
480
+ border: 1px solid var(--nwe-border);
481
+ border-radius: 9px;
482
+ background: color-mix(in srgb, var(--nwe-raised) 82%, transparent);
483
+ color: var(--nwe-muted);
484
+ font: inherit;
485
+ font-size: 20px;
486
+ line-height: 1;
487
+ cursor: pointer;
488
+ }
489
+ .nwe-modal-close:hover { border-color: color-mix(in srgb, var(--nwe-gold) 55%, var(--nwe-border)); color: var(--nwe-ink); }
490
+ .nwe-modal-body { overflow-y: auto; overscroll-behavior: contain; padding: 20px 24px 26px; }
491
+ .nwe-tutorial-intro { padding: 13px 15px; border: 1px solid color-mix(in srgb, var(--nwe-jade) 28%, var(--nwe-border)); border-radius: 12px; background: color-mix(in srgb, var(--nwe-jade) 6%, transparent); }
492
+ .nwe-tutorial-intro p { margin: 0; font-size: 13px; line-height: 1.75; }
493
+ .nwe-tutorial-intro p + p { margin-top: 7px; }
494
+ .nwe-tutorial-block { margin-top: 22px; }
495
+ .nwe-tutorial-block h3 {
496
+ margin: 0 0 9px;
497
+ font-family: "Noto Serif SC", "Songti SC", Georgia, serif;
498
+ font-size: 16px;
499
+ font-weight: 600;
500
+ }
501
+ .nwe-tutorial-block > p { margin: 0 0 10px; color: var(--nwe-muted); font-size: 12px; line-height: 1.7; }
502
+ .nwe-tutorial-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
503
+ .nwe-tutorial-card { padding: 11px 12px; border: 1px solid var(--nwe-border); border-radius: 11px; background: color-mix(in srgb, var(--nwe-raised) 52%, transparent); }
504
+ .nwe-tutorial-card strong { display: block; color: var(--nwe-gold); font-size: 12px; }
505
+ .nwe-tutorial-card span { display: block; margin-top: 4px; color: var(--nwe-muted); font-size: 11px; line-height: 1.65; }
506
+ .nwe-steps { display: flex; flex-direction: column; gap: 8px; margin: 0; padding: 0; list-style: none; }
507
+ .nwe-step { padding: 12px 14px; border: 1px solid var(--nwe-border); border-left: 2px solid color-mix(in srgb, var(--nwe-gold) 58%, var(--nwe-border)); border-radius: 11px; background: color-mix(in srgb, var(--nwe-surface) 86%, var(--nwe-raised) 14%); }
508
+ .nwe-step-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
509
+ .nwe-step-no { color: var(--nwe-gold); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; letter-spacing: .06em; }
510
+ .nwe-step h4 { margin: 0; font-size: 13px; font-weight: 600; }
511
+ .nwe-step-tool { margin-left: auto; padding: 2px 6px; border: 1px solid var(--nwe-border); border-radius: 5px; color: var(--nwe-gold); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; white-space: nowrap; }
512
+ .nwe-step p { margin: 6px 0 0; color: var(--nwe-muted); font-size: 12px; line-height: 1.7; }
513
+ .nwe-step p strong { color: var(--nwe-ink); font-weight: 600; }
514
+ .nwe-tutorial-note { margin: 12px 0 0; padding: 10px 12px; border-radius: 9px; background: color-mix(in srgb, var(--nwe-gold) 8%, transparent); color: var(--nwe-muted); font-size: 11px; line-height: 1.7; }
515
+ .nwe-tutorial-note strong { color: var(--nwe-gold); }
516
+ .nwe-faq { display: flex; flex-direction: column; gap: 8px; margin: 0; padding: 0; list-style: none; }
517
+ .nwe-faq li { padding: 10px 12px; border: 1px solid var(--nwe-border); border-radius: 10px; }
518
+ .nwe-faq strong { display: block; color: var(--nwe-ink); font-size: 12px; }
519
+ .nwe-faq p { margin: 4px 0 0; color: var(--nwe-muted); font-size: 12px; line-height: 1.65; }
520
+ .nwe-prompt { overflow-x: auto; margin: 0; padding: 12px 14px; border: 1px solid var(--nwe-border); border-radius: 10px; background: color-mix(in srgb, #080a0d 24%, var(--nwe-surface)); color: var(--nwe-ink); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; line-height: 1.75; white-space: pre-wrap; }
521
+ .nwe-checklist { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
522
+ .nwe-checklist li { display: flex; gap: 7px; color: var(--nwe-muted); font-size: 12px; line-height: 1.6; }
523
+ .nwe-checklist li::before { content: "✓"; color: var(--nwe-jade); font-weight: 700; }
524
+ .nwe-modal-foot { flex: 0 0 auto; padding: 10px 24px; border-top: 1px solid var(--nwe-border); color: var(--nwe-muted); font-size: 10px; }
525
+ .nwe-help-label { white-space: nowrap; }
312
526
  .nwe-stats {
313
527
  display: grid;
314
528
  grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -379,6 +593,25 @@ window.__ModuleLoader__.load({
379
593
  .nwe-phase.is-active .nwe-dot { border-color: var(--nwe-gold); background: var(--nwe-gold); color: #19150d; box-shadow: 0 0 0 4px color-mix(in srgb, var(--nwe-gold) 16%, transparent); }
380
594
  .nwe-phase-label { overflow: hidden; max-width: 100%; color: var(--nwe-muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
381
595
  .nwe-phase.is-active .nwe-phase-label { color: var(--nwe-ink); font-weight: 600; }
596
+ .nwe-book-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); gap: 10px; margin-top: 12px; }
597
+ .nwe-book-card { padding: 14px 16px; border: 1px solid var(--nwe-border); border-radius: 14px; background: color-mix(in srgb, var(--nwe-surface) 92%, transparent); }
598
+ .nwe-book-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
599
+ .nwe-book-title { font-family: "Noto Serif SC", "Songti SC", Georgia, serif; font-size: 14px; font-weight: 600; }
600
+ .nwe-book-note { color: var(--nwe-muted); font-size: 10px; }
601
+ .nwe-progress { overflow: hidden; height: 7px; margin-top: 10px; border-radius: 99px; background: var(--nwe-border); }
602
+ .nwe-progress > span { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--nwe-jade), var(--nwe-gold)); }
603
+ .nwe-progress-legend { display: flex; justify-content: space-between; margin-top: 5px; color: var(--nwe-muted); font-size: 10px; font-variant-numeric: tabular-nums; }
604
+ .nwe-volume { display: flex; justify-content: space-between; gap: 8px; margin-top: 7px; color: var(--nwe-muted); font-size: 11px; }
605
+ .nwe-volume strong { overflow: hidden; color: var(--nwe-ink); font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
606
+ .nwe-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
607
+ .nwe-chip { padding: 3px 8px; border: 1px solid var(--nwe-border); border-radius: 999px; color: var(--nwe-muted); font-size: 10px; font-variant-numeric: tabular-nums; }
608
+ .nwe-bars { display: flex; align-items: flex-end; gap: 5px; height: 54px; margin-top: 10px; }
609
+ .nwe-bar { flex: 1 1 0; min-width: 4px; border-radius: 4px 4px 2px 2px; background: linear-gradient(180deg, var(--nwe-gold), color-mix(in srgb, var(--nwe-gold) 35%, transparent)); }
610
+ .nwe-health { display: flex; align-items: center; gap: 8px; margin-top: 10px; color: var(--nwe-muted); font-size: 11px; }
611
+ .nwe-health.is-bad { color: var(--nwe-rose); }
612
+ .nwe-activity { display: flex; flex-direction: column; gap: 6px; margin: 10px 0 0; padding: 0; list-style: none; }
613
+ .nwe-activity li { display: grid; grid-template-columns: 74px minmax(0, 1fr) auto; gap: 8px; color: var(--nwe-muted); font-size: 11px; }
614
+ .nwe-activity code { color: var(--nwe-ink); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; }
382
615
  .nwe-next {
383
616
  display: flex;
384
617
  align-items: flex-start;
@@ -465,8 +698,18 @@ window.__ModuleLoader__.load({
465
698
  @media (max-width: 760px) {
466
699
  .nwe-root { padding: 14px; }
467
700
  .nwe-hero { flex-direction: column; padding: 18px; }
468
- .nwe-refresh { width: 100%; justify-content: center; }
701
+ .nwe-actions { width: 100%; }
702
+ .nwe-refresh { flex: 1; justify-content: center; }
703
+ .nwe-help { flex: 0 0 auto; }
704
+ .nwe-modal-backdrop { align-items: flex-end; padding: 8px; }
705
+ .nwe-modal { width: 100%; max-height: 92vh; border-radius: 16px; }
706
+ .nwe-modal-head { padding: 17px 18px 14px; }
707
+ .nwe-modal-body { padding: 16px 18px 22px; }
708
+ .nwe-modal-foot { padding: 9px 18px; }
709
+ .nwe-tutorial-cards { grid-template-columns: 1fr; }
710
+ .nwe-step-tool { margin-left: 0; }
469
711
  .nwe-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
712
+ .nwe-book-grid { grid-template-columns: 1fr; }
470
713
  .nwe-phases { grid-template-columns: repeat(3, minmax(0, 1fr)); row-gap: 12px; padding: 12px; }
471
714
  .nwe-phase:nth-child(3n)::after { display: none; }
472
715
  .nwe-chapter summary { grid-template-columns: 38px minmax(0, 1fr) 18px; gap: 10px; }
@@ -500,6 +743,107 @@ window.__ModuleLoader__.load({
500
743
  const refreshIcon = () => icon(14, "M20 11a8.1 8.1 0 0 0-15.5-4M4 4v5h5M4 13a8.1 8.1 0 0 0 15.5 4M20 20v-5h-5");
501
744
  const arrowIcon = () => icon(14, "M5 12h13M13 6l6 6-6 6");
502
745
  const stateIcon = () => icon(22, "M4 5.5A2.5 2.5 0 0 1 6.5 3H20v15H6.5A2.5 2.5 0 0 0 4 20.5V5.5Z");
746
+ const helpIcon = () => (0, react.createElement)("svg", {
747
+ width: 15,
748
+ height: 15,
749
+ viewBox: "0 0 24 24",
750
+ fill: "none",
751
+ "aria-hidden": "true"
752
+ }, (0, react.createElement)("circle", {
753
+ cx: 12,
754
+ cy: 12,
755
+ r: 8.6,
756
+ stroke: "currentColor",
757
+ strokeWidth: 1.7
758
+ }), (0, react.createElement)("path", {
759
+ d: "M9.7 9.1a2.5 2.5 0 0 1 4.8.8c0 1.8-2.4 2-2.4 3.8M12 16.8v.1",
760
+ stroke: "currentColor",
761
+ strokeWidth: 1.7,
762
+ strokeLinecap: "round",
763
+ strokeLinejoin: "round"
764
+ }));
765
+ const TUTORIAL_STEPS = [
766
+ {
767
+ title: "建一本书的项目",
768
+ tool: "novel_init",
769
+ body: "在对话里说清书名、类型、平台、作者、每章字数区间、总章数和项目根目录;模型会调用 novel_init。项目根目录建议用绝对路径,例如 /home/me/novels/星海。",
770
+ note: "类型是固定的 12 类,平台只能是 fanqie / qidian / qimao / jinjiang。有多个项目时显式传 project_root,避免把新书写进旧书目录。"
771
+ },
772
+ {
773
+ title: "写书级蓝图与卷结构",
774
+ tool: "novel_blueprint",
775
+ body: "让模型先定 premise(故事前提)、logline(一句话卖点)、themes(主题)、tone(基调)、pov(视角)、audience(读者)、selling_points(卖点)、ending(结局方向)和 volumes(每卷起止章、目标、弧线、高潮)。",
776
+ note: "蓝图不是正文,可以后补。严格大纲是可选开关:strict:true 时每章必须先有章卡;不开启也能先写,但章卡一旦存在,标题、场景、目标字数和 cast 仍会校验。"
777
+ },
778
+ {
779
+ title: "排章卡,或导入现成大纲",
780
+ tool: "novel_outline / novel_outline_import",
781
+ body: "逐章写卡:num、title、scene_type、goal、conflict、hook、cast、target_words。手里已有 Markdown、YAML 或 JSON 大纲时,先用 novel_outline_import 的 dry_run:true 预览,再正式导入。",
782
+ note: "章卡是 P2/P3 的合同。卡被归档后不能直接改;要重写已归档章,先 novel_reopen 并给出 reason。"
783
+ },
784
+ {
785
+ title: "建世界观设定集",
786
+ tool: "novel_worldbook",
787
+ body: "写 setting(时代/地点)、rules(世界规则)、factions(势力)、locations(地点)、characters(角色)、glossary(术语)、backstory(前史)。可以按段增量更新,不必一次填满。",
788
+ note: "人物名、地点名和规则名要前后一致。P1/P2 的角色卡与会话上下文会从这里取;不要手工改 state/ 里的台账。"
789
+ },
790
+ {
791
+ title: "P1:领取世界观任务书,写本章约束",
792
+ tool: "novel_brief {stage:\"P1\"} → novel_plan_world",
793
+ body: "先领取 P1 任务书,再提交本章 objective(目标)、constraints(限制)、characters(候选角色)、locations(候选地点)、continuity_notes(连续性说明)。",
794
+ note: "每个阶段提交前都要先领当前任务书;任务书 digest 过期时 PG-01 会挡住。不要跳过 brief 直接写。"
795
+ },
796
+ {
797
+ title: "P2:领取章节任务书,拆本章节拍",
798
+ tool: "novel_brief {stage:\"P2\"} → novel_plan_chapter",
799
+ body: "写 title、scene_type、narrator、5–8 个 beats(节拍)、opening(开场)、ending(收束)、target_words(目标字数区间),可选 cast(1–8 人)。",
800
+ note: "beats 要能落到动作和冲突上,不要写“推进剧情”这类空话。章卡存在时,标题、场景、目标字数和 cast 会按卡校验。"
801
+ },
802
+ {
803
+ title: "P3:领取初稿任务书,写正文",
804
+ tool: "novel_brief {stage:\"P3\"} → novel_write_draft",
805
+ body: "按任务书写正文,并提交 chapter_title、scene_type、narrator 和 summary。summary 要覆盖事件、出场人物、伏笔、新设定、资源变化、开头、衔接、钩子强度、爽点和推进度。",
806
+ note: "正文只有 novel_write_draft 和 novel_revise 两个写入口;novel_amend 只修状态文档。字数以引擎返回为准,不要自己估算或复述旧数字。"
807
+ },
808
+ {
809
+ title: "P4:领取评审任务书,做十五维评审",
810
+ tool: "novel_brief {stage:\"P4\"} → novel_review",
811
+ body: "按十五个维度打分,total 必须等于 150 减去全部扣分;issues 每条写清维度、具体段落、问题和修法;proof 提交开头、中段、结尾三段逐字引用。",
812
+ note: "有 blocker 时不能进 P6,要先走 P5 修订;没有 blocker 才能继续。不要为了过关乱填总分或引用。"
813
+ },
814
+ {
815
+ title: "P5:只修 blocker 和有价值的意见",
816
+ tool: "novel_brief {stage:\"P5\"} → novel_revise",
817
+ body: "按 P4 的问题清单修正文,保留 issue 的状态与证据,提交修订后的正文和三段新引用。修订后回到 P4 重新评审。",
818
+ note: "blocker 不能 waived 或降级;正文一改,旧评审和旧 check 就失效,必须重走 P4/P6。"
819
+ },
820
+ {
821
+ title: "P6:机检 + 五个语义项",
822
+ tool: "novel_brief {stage:\"P6\"} → novel_check",
823
+ body: "引擎先跑格式/完整性机检,模型再提交五个语义项:L2-SCENE-ANCHOR、L2-CHARACTER-STATE、L2-CAUSALITY、L2-VOICE、L2-POV;每项都要有正文逐字证据。",
824
+ note: "五条证据必须来自不同句子,不能共用同一段。只跑 content 或 integrity 一个 scope 不算完整通过,不能归档。"
825
+ },
826
+ {
827
+ title: "P8:领取终审任务书,一次事务归档",
828
+ tool: "novel_brief {stage:\"P8\"} → novel_archive",
829
+ body: "归档必须带 state_delta:本章出现的每个角色、世界元素、伏笔、时间线事件和关系,都要声明 created、updated 或 no_change;no_change 也要有原因。",
830
+ note: "归档是引擎的一次事务,成功后面板才会出现已归档章节。下一章仍从 P1 开始;total_chapters 全部归档后,全书才算完成。"
831
+ },
832
+ {
833
+ title: "循环下一章,最后导出全书",
834
+ tool: "novel_report / novel_doctor / novel_export",
835
+ body: "每章重复 P1→P8。随时用 novel_report 看进度和质量,novel_doctor 体检,novel_search 查正文/大纲/台账;完成后导出 md、txt、epub、outline、worldbook 或 proposal。",
836
+ note: "改已归档章要先 novel_reopen;项目坏了先 novel_state 看 raw,再按 next 用 novel_amend 修,不要手改 chapters/ 或 state/。"
837
+ }
838
+ ];
839
+ const TUTORIAL_PROMPT = `请用「网文工坊」引擎写一本小说:
840
+ 书名《星海旧梦》,都市现代,番茄平台,每章 2200–2800 字,共 120 章,项目根目录用绝对路径。
841
+ 先做书级蓝图、第一卷章卡和世界观设定,再写第一章。
842
+ 每章严格按 novel_brief P1 → plan_world → brief P2 → plan_chapter →
843
+ brief P3 → write_draft → brief P4 → review → 有 blocker 就修订 →
844
+ brief P6 → check → brief P8 → archive 的顺序推进。
845
+ 正文只通过 write_draft/revise 写;不要直接改 state/、chapters/ 或 outline/。
846
+ 每次提交前先领取当前阶段任务书,失败时按返回的 code 和 blocking 一次改完。`;
503
847
  const stat = (label, value, unit) => (0, react.createElement)("div", {
504
848
  className: "nwe-stat",
505
849
  key: label
@@ -526,6 +870,54 @@ window.__ModuleLoader__.load({
526
870
  className: "nwe-dot",
527
871
  "aria-hidden": "true"
528
872
  }, phase.done ? "✓" : ""), (0, react.createElement)("span", { className: "nwe-phase-label" }, phase.label))));
873
+ const bookSection = (view) => {
874
+ const total = view.book.outlineTotal;
875
+ const target = view.book.target;
876
+ const denominator = target ?? total;
877
+ const percent = denominator === 0 ? 0 : Math.min(100, Math.round(view.book.archivedCards / denominator * 100));
878
+ const chips = [
879
+ ["角色", view.book.worldbook.characters],
880
+ ["地点", view.book.worldbook.locations],
881
+ ["势力", view.book.worldbook.factions],
882
+ ["规则", view.book.worldbook.rules],
883
+ ["术语", view.book.worldbook.glossary],
884
+ ["前史", view.book.worldbook.backstory]
885
+ ];
886
+ const bars = view.quality.scores.slice(-10);
887
+ return (0, react.createElement)("section", {
888
+ className: "nwe-section",
889
+ "aria-label": "全书规划与质量"
890
+ }, (0, react.createElement)("div", { className: "nwe-section-head" }, (0, react.createElement)("h2", { className: "nwe-section-title" }, "全书规划"), (0, react.createElement)("span", { className: "nwe-section-note" }, view.book.present ? `${view.book.strict ? "严格大纲" : "宽松大纲"} · 覆盖率 ${Math.round(view.book.coverage * 100)}%` : "还没有章卡")), (0, react.createElement)("div", { className: "nwe-book-grid" }, (0, react.createElement)("div", { className: "nwe-book-card" }, (0, react.createElement)("div", { className: "nwe-book-head" }, (0, react.createElement)("span", { className: "nwe-book-title" }, view.book.premise === "" ? "全书进度" : view.book.premise), (0, react.createElement)("span", { className: "nwe-book-note" }, `${view.book.planned} 计划 / ${view.book.archivedCards} 归档`)), (0, react.createElement)("div", {
891
+ className: "nwe-progress",
892
+ role: "progressbar",
893
+ "aria-valuemin": 0,
894
+ "aria-valuemax": 100,
895
+ "aria-valuenow": percent,
896
+ "aria-label": "全书归档进度"
897
+ }, (0, react.createElement)("span", { style: { width: `${percent}%` } })), (0, react.createElement)("div", { className: "nwe-progress-legend" }, (0, react.createElement)("span", null, target === null ? `目标未设置 · 共 ${total} 张章卡` : `目标 ${target} 章 · 共 ${total} 张章卡`), (0, react.createElement)("span", null, `${percent}% 已归档`)), ...view.book.volumes.slice(0, 8).map((volume) => (0, react.createElement)("div", {
898
+ className: "nwe-volume",
899
+ key: `${volume.title}-${volume.range}`
900
+ }, (0, react.createElement)("strong", null, volume.title), (0, react.createElement)("span", null, `${volume.range} · ${volume.planned} 计划 / ${volume.archived} 归档`))), ...view.book.volumes.length > 8 ? [(0, react.createElement)("div", { className: "nwe-volume" }, (0, react.createElement)("span", null, `还有 ${view.book.volumes.length - 8} 卷`))] : []), (0, react.createElement)("div", { className: "nwe-book-card" }, (0, react.createElement)("div", { className: "nwe-book-head" }, (0, react.createElement)("span", { className: "nwe-book-title" }, "世界观"), (0, react.createElement)("span", { className: "nwe-book-note" }, `${chips.reduce((sum, [, value]) => sum + value, 0)} 条`)), (0, react.createElement)("div", { className: "nwe-chips" }, ...chips.map(([label, value]) => (0, react.createElement)("span", {
901
+ className: "nwe-chip",
902
+ key: label
903
+ }, `${label} ${value}`))), (0, react.createElement)("div", {
904
+ className: "nwe-book-head",
905
+ style: { marginTop: 12 }
906
+ }, (0, react.createElement)("span", { className: "nwe-book-title" }, "质量趋势"), (0, react.createElement)("span", { className: "nwe-book-note" }, bars.length === 0 ? "—" : `${view.quality.averageScore.toFixed(1)} 均分`)), (0, react.createElement)("div", {
907
+ className: "nwe-bars",
908
+ role: "img",
909
+ "aria-label": "最近十章评分"
910
+ }, ...bars.map((score, index) => (0, react.createElement)("span", {
911
+ key: `${index}-${score}`,
912
+ className: "nwe-bar",
913
+ style: { height: `${Math.max(4, Math.round(score / 150 * 100))}%` },
914
+ title: `${score}/150`
915
+ }))), (0, react.createElement)("div", { className: `nwe-health${view.health.ok ? "" : " is-bad"}` }, (0, react.createElement)("span", { "aria-hidden": "true" }, view.health.ok ? "✓" : "!"), (0, react.createElement)("span", null, view.health.ok ? "项目健康:没有阻塞或损坏状态" : `需要处理:${view.health.blocking} 条门控 · ${view.health.stateErrors} 个损坏文档`)))));
916
+ };
917
+ const activitySection = (view) => {
918
+ if (view.activity.length === 0) return null;
919
+ return (0, react.createElement)("section", { className: "nwe-section" }, (0, react.createElement)("div", { className: "nwe-section-head" }, (0, react.createElement)("h2", { className: "nwe-section-title" }, "最近活动"), (0, react.createElement)("span", { className: "nwe-section-note" }, "来自事务日志")), (0, react.createElement)("ul", { className: "nwe-activity" }, ...view.activity.slice(-5).map((row, index) => (0, react.createElement)("li", { key: `${row.at}-${row.op}-${index}` }, (0, react.createElement)("span", null, row.at.slice(0, 16).replace("T", " ")), (0, react.createElement)("code", null, row.op), (0, react.createElement)("span", null, row.chapter === 0 ? row.status : `第 ${row.chapter} 章 · ${row.status}`)))));
920
+ };
529
921
  const footer = () => (0, react.createElement)("footer", { className: "nwe-footer" }, (0, react.createElement)("span", null, "只读面板 · 数据来自引擎 · 每 15 秒自动同步"), (0, react.createElement)("code", null, "novel-workshop"));
530
922
  const loadingState = () => (0, react.createElement)("div", {
531
923
  className: "nwe-section",
@@ -553,6 +945,92 @@ window.__ModuleLoader__.load({
553
945
  disabled: loading && state !== "outdated"
554
946
  }, refreshIcon(), state === "outdated" ? "刷新页面" : loading ? "刷新中…" : "重新读取"));
555
947
  };
948
+ const tutorialStep = (step, index) => (0, react.createElement)("li", {
949
+ className: "nwe-step",
950
+ key: step.title
951
+ }, (0, react.createElement)("div", { className: "nwe-step-head" }, (0, react.createElement)("span", { className: "nwe-step-no" }, String(index + 1).padStart(2, "0")), (0, react.createElement)("h4", null, step.title), (0, react.createElement)("code", { className: "nwe-step-tool" }, step.tool)), (0, react.createElement)("p", null, step.body), (0, react.createElement)("p", null, (0, react.createElement)("strong", null, "注意:"), step.note));
952
+ const tutorialDialog = (onClose, closeRef) => (0, react.createElement)("div", {
953
+ className: "nwe-modal-backdrop",
954
+ onMouseDown: (event) => {
955
+ if (event.target === event.currentTarget) onClose();
956
+ }
957
+ }, (0, react.createElement)("div", {
958
+ className: "nwe-modal",
959
+ role: "dialog",
960
+ "aria-modal": "true",
961
+ "aria-labelledby": "nwe-help-title",
962
+ "aria-describedby": "nwe-help-sub",
963
+ tabIndex: -1,
964
+ onKeyDown: (event) => {
965
+ if (event.key !== "Tab") return;
966
+ const focusable = Array.from(event.currentTarget.querySelectorAll("button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])"));
967
+ if (focusable.length === 0) return;
968
+ const first = focusable[0];
969
+ const last = focusable[focusable.length - 1];
970
+ if (event.shiftKey && globalThis.document?.activeElement === first) {
971
+ event.preventDefault();
972
+ last.focus();
973
+ } else if (!event.shiftKey && globalThis.document?.activeElement === last) {
974
+ event.preventDefault();
975
+ first.focus();
976
+ }
977
+ }
978
+ }, (0, react.createElement)("div", { className: "nwe-modal-head" }, (0, react.createElement)("div", { className: "nwe-modal-heading" }, (0, react.createElement)("div", { className: "nwe-eyebrow" }, "USER GUIDE · 插件教程"), (0, react.createElement)("h2", {
979
+ className: "nwe-modal-title",
980
+ id: "nwe-help-title"
981
+ }, "从开书到导出:完整走一遍"), (0, react.createElement)("p", {
982
+ className: "nwe-modal-sub",
983
+ id: "nwe-help-sub"
984
+ }, "这是网文工坊的状态机教程。照着做,不需要记文件名,也不需要自己维护状态。")), (0, react.createElement)("button", {
985
+ type: "button",
986
+ className: "nwe-modal-close",
987
+ ref: closeRef,
988
+ onClick: onClose,
989
+ "aria-label": "关闭使用教程"
990
+ }, (0, react.createElement)("span", { "aria-hidden": "true" }, "×"))), (0, react.createElement)("div", { className: "nwe-modal-body" }, (0, react.createElement)("div", { className: "nwe-tutorial-intro" }, (0, react.createElement)("p", null, "网文工坊不是一个“帮你想剧情”的按钮,而是一台有状态的写作引擎:你提供内容,引擎负责阶段、字数、门控、评审、归档和文件。"), (0, react.createElement)("p", null, "每一步都先领取当前阶段的任务书,再提交;提交后看引擎返回的 blocking 与下一步。面板只读,真正的写操作都在对话里的工具调用中。")), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "开始前:记住三件事"), (0, react.createElement)("div", { className: "nwe-tutorial-cards" }, (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "只用引擎入口写"), (0, react.createElement)("span", null, "正文只走 novel_write_draft / novel_revise;状态只走工具。手改 chapters/、state/、outline/ 会破坏事务与校验。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "每一步先领 brief"), (0, react.createElement)("span", null, "任务书携带当前上下文和 digest;跳过它会撞 PG-01。任务书过期就重新领取,不要复用旧上下文。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "失败先读 code"), (0, react.createElement)("span", null, "参数错改参数,门控没过改正文,系统故障查目录/稍后重试。blocking 会一次列全,一次改完再交。"))), (0, react.createElement)("p", { className: "nwe-tutorial-note" }, (0, react.createElement)("strong", null, "用哪个会话:"), " 想获得完整的引擎保证,请新开一个「网文工坊」预设会话;普通会话里模型可能还挂着文件工具,能绕过引擎直接改稿。")), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "一本书的完整流程"), (0, react.createElement)("p", null, "前四步是开书准备,后面是每章循环。第一次先按顺序走完一章,熟悉后再批量推进整本。"), (0, react.createElement)("ol", { className: "nwe-steps" }, ...TUTORIAL_STEPS.map((step, index) => tutorialStep(step, index)))), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "面板怎么看"), (0, react.createElement)("div", { className: "nwe-tutorial-cards" }, (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "顶部统计"), (0, react.createElement)("span", null, "已归档章、总字数、平均分、待处理门控。数字来自引擎,不是正文估算。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "九阶段轨与下一步"), (0, react.createElement)("span", null, "亮起的阶段是当前进度,“下一步”卡会直接给出该调用的工具名。")), (0, react.createElement)("div", { className: "nwe-tutorial-card" }, (0, react.createElement)("strong", null, "章节与门控"), (0, react.createElement)("span", null, "章节卡展开可看字数、评分与状态;门控区按 code/detail/hint 列出所有阻塞。"))), (0, react.createElement)("p", { className: "nwe-tutorial-note" }, (0, react.createElement)("strong", null, "刷新:"), " 面板每 15 秒静默同步一次,也可以点右上角刷新。它是只读投影;不会替你改稿、归档或修状态。")), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "卡住时先看这里"), (0, react.createElement)("ul", { className: "nwe-faq" }, (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "为什么一直让我先领 brief?"), (0, react.createElement)("p", null, "这是正常门控。任务书决定当前阶段可用的字段与上下文 digest;先按返回值调用 novel_brief,再继续。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "门控失败,改一条还是全改?"), (0, react.createElement)("p", null, "一次看完所有 blocking,再一起改。逐条试会反复烧掉阶段轮次;错误里的 code 会告诉你属于参数、门控还是系统故障。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "字数和评分为什么对不上?"), (0, react.createElement)("p", null, "字数以 engine 返回为准,可用 novel_word_count 核对;review 的 total 必须等于 150 减全部扣分,不能另算一套。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "P4/P6 过不去怎么办?"), (0, react.createElement)("p", null, "P4 有 blocker 就领 P5 任务书修订,再回 P4 重评;P6 失败同样回修订。不要 waive blocker,也不要只跑半个 scope 就归档。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "想改已经归档的章节?"), (0, react.createElement)("p", null, "先 novel_reopen,带非空 reason。它会从归档备份恢复,重开后重新走 P3→P8;不要直接编辑归档卡或正文文件。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "状态文档坏了能手工修吗?"), (0, react.createElement)("p", null, "先用 novel_state 看 raw 和 next;需要修复时用 novel_amend,并在 reason 写清原因。手改 YAML 会丢失事务保护。")), (0, react.createElement)("li", null, (0, react.createElement)("strong", null, "有多个项目,面板看的是哪本?"), (0, react.createElement)("p", null, "面板跟随最近一次工具调用的项目。调用时显式传 project_root;切换活跃项目用 /novel admin novel_registry_activate。")))), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "可以直接复制给模型的起手话术"), (0, react.createElement)("pre", { className: "nwe-prompt" }, TUTORIAL_PROMPT)), (0, react.createElement)("section", { className: "nwe-tutorial-block" }, (0, react.createElement)("h3", null, "一章完成检查清单"), (0, react.createElement)("ul", { className: "nwe-checklist" }, (0, react.createElement)("li", null, "本章 P1/P2 计划已提交,P3 正文已通过格式与字数门控。"), (0, react.createElement)("li", null, "P4 评审 total 对账无误,没有未修 blocker。"), (0, react.createElement)("li", null, "P6 五个语义项都有不同句子的正文引用,完整 scope 通过。"), (0, react.createElement)("li", null, "P8 任务书当前,novel_archive 带完整 state_delta 成功。"), (0, react.createElement)("li", null, "面板出现已归档章节,门控区没有新的阻塞。")))), (0, react.createElement)("div", { className: "nwe-modal-foot" }, "教程基于 0.2.0 · 具体字段以当前任务书和工具返回为准 · 面板只读")));
991
+ const NovelHelp = () => {
992
+ const [open, setOpen] = (0, react.useState)(false);
993
+ const triggerRef = (0, react.useRef)(null);
994
+ const closeRef = (0, react.useRef)(null);
995
+ const wasOpen = (0, react.useRef)(false);
996
+ const close = (0, react.useCallback)(() => setOpen(false), []);
997
+ (0, react.useEffect)(() => {
998
+ const doc = globalThis.document;
999
+ if (doc === void 0) return;
1000
+ if (!open) {
1001
+ if (wasOpen.current) {
1002
+ wasOpen.current = false;
1003
+ triggerRef.current?.focus();
1004
+ }
1005
+ return;
1006
+ }
1007
+ wasOpen.current = true;
1008
+ const previousOverflow = doc.body.style.overflow;
1009
+ doc.body.style.overflow = "hidden";
1010
+ const onKeyDown = (event) => {
1011
+ if (event.key === "Escape") {
1012
+ event.preventDefault();
1013
+ setOpen(false);
1014
+ }
1015
+ };
1016
+ doc.addEventListener("keydown", onKeyDown);
1017
+ closeRef.current?.focus();
1018
+ return () => {
1019
+ doc.removeEventListener("keydown", onKeyDown);
1020
+ doc.body.style.overflow = previousOverflow;
1021
+ };
1022
+ }, [open]);
1023
+ return (0, react.createElement)(react.Fragment, null, (0, react.createElement)("button", {
1024
+ type: "button",
1025
+ className: "nwe-help",
1026
+ ref: triggerRef,
1027
+ onClick: () => setOpen(true),
1028
+ "aria-label": "打开插件使用教程",
1029
+ "aria-haspopup": "dialog",
1030
+ "aria-expanded": open,
1031
+ title: "插件使用教程"
1032
+ }, helpIcon(), (0, react.createElement)("span", { className: "nwe-help-label" }, "教程")), open ? tutorialDialog(close, closeRef) : null);
1033
+ };
556
1034
  /**
557
1035
  * Pure view: given a view model, return the element tree. Keeping the hooks in
558
1036
  * `NovelPanel` makes every visual state renderable without a browser.
@@ -564,20 +1042,20 @@ window.__ModuleLoader__.load({
564
1042
  className: "nwe-root",
565
1043
  "aria-busy": loading,
566
1044
  "data-state": view.state
567
- }, (0, react.createElement)("style", { key: "nwe-style" }, CSS), (0, react.createElement)("header", { className: "nwe-hero" }, (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "nwe-eyebrow" }, "NOVEL STUDIO · 网文工坊"), (0, react.createElement)("h1", { className: "nwe-title" }, view.title), (0, react.createElement)("div", { className: "nwe-meta" }, (0, react.createElement)("span", { className: `nwe-stage${view.finished ? " is-done" : ""}` }, view.stageLabel), ready ? (0, react.createElement)("span", null, view.subtitle) : null, ready ? (0, react.createElement)("span", { "aria-hidden": "true" }, "·") : null, ready ? (0, react.createElement)("span", null, view.revisionLabel) : null)), (0, react.createElement)("button", {
1045
+ }, (0, react.createElement)("style", { key: "nwe-style" }, CSS), (0, react.createElement)("header", { className: "nwe-hero" }, (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "nwe-eyebrow" }, "NOVEL STUDIO · 网文工坊"), (0, react.createElement)("h1", { className: "nwe-title" }, view.title), (0, react.createElement)("div", { className: "nwe-meta" }, (0, react.createElement)("span", { className: `nwe-stage${view.finished ? " is-done" : ""}` }, view.stageLabel), ready ? (0, react.createElement)("span", null, view.subtitle) : null, ready ? (0, react.createElement)("span", { "aria-hidden": "true" }, "·") : null, ready ? (0, react.createElement)("span", null, view.revisionLabel) : null)), (0, react.createElement)("div", { className: "nwe-actions" }, (0, react.createElement)(NovelHelp), (0, react.createElement)("button", {
568
1046
  type: "button",
569
1047
  className: "nwe-refresh",
570
1048
  onClick: onRefresh,
571
1049
  disabled: loading,
572
1050
  "aria-label": "刷新项目状态"
573
- }, refreshIcon(), loading ? "刷新中…" : "刷新")), ready ? (0, react.createElement)("div", {
1051
+ }, refreshIcon(), loading ? "刷新中…" : "刷新"))), ready ? (0, react.createElement)("div", {
574
1052
  className: "nwe-stats",
575
1053
  role: "group",
576
1054
  "aria-label": "项目统计"
577
- }, stat("已归档", String(view.stats.chapters), "章"), stat("总字数", view.stats.words.toLocaleString("zh-CN"), "字"), stat("平均评分", view.stats.chapters === 0 ? "—" : String(view.stats.averageScore), "/150"), stat("待处理", String(view.stats.blocking), "项")) : null, view.state === "loading" ? loadingState() : null, view.state === "error" || view.state === "empty" || view.state === "outdated" ? statePanel(view.state, view, loading, onRefresh) : null, ready ? phases(view) : null, ready && view.nextTitle !== "" ? (0, react.createElement)("section", {
1055
+ }, stat("已归档", String(view.stats.chapters), "章"), stat("总字数", view.stats.words.toLocaleString("zh-CN"), "字"), stat("平均评分", view.stats.chapters === 0 ? "—" : String(view.stats.averageScore), "/150"), stat("待处理", String(view.stats.blocking), "项")) : null, view.state === "loading" ? loadingState() : null, view.state === "error" || view.state === "empty" || view.state === "outdated" ? statePanel(view.state, view, loading, onRefresh) : null, ready ? bookSection(view) : null, ready ? phases(view) : null, ready && view.nextTitle !== "" ? (0, react.createElement)("section", {
578
1056
  className: "nwe-next",
579
1057
  "aria-label": "下一步"
580
- }, (0, react.createElement)("div", { className: "nwe-next-icon" }, arrowIcon()), (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "nwe-next-kicker" }, "NEXT MOVE"), (0, react.createElement)("div", { className: "nwe-next-title" }, view.nextTitle), (0, react.createElement)("div", { className: "nwe-next-detail" }, view.nextDetail)), view.nextTool === "" ? null : (0, react.createElement)("code", { className: "nwe-next-tool" }, view.nextTool)) : null, ready ? (0, react.createElement)("section", { className: "nwe-section" }, (0, react.createElement)("div", { className: "nwe-section-head" }, (0, react.createElement)("h2", { className: "nwe-section-title" }, "章节"), (0, react.createElement)("span", { className: "nwe-section-note" }, `${view.rows.length} 条记录${view.rows.length > 30 ? " · 显示最近 30 条" : ""}`)), view.rows.length === 0 ? (0, react.createElement)("div", {
1058
+ }, (0, react.createElement)("div", { className: "nwe-next-icon" }, arrowIcon()), (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "nwe-next-kicker" }, "NEXT MOVE"), (0, react.createElement)("div", { className: "nwe-next-title" }, view.nextTitle), (0, react.createElement)("div", { className: "nwe-next-detail" }, view.nextDetail)), view.nextTool === "" ? null : (0, react.createElement)("code", { className: "nwe-next-tool" }, view.nextTool)) : null, ready ? (0, react.createElement)("section", { className: "nwe-section" }, (0, react.createElement)("div", { className: "nwe-section-head" }, (0, react.createElement)("h2", { className: "nwe-section-title" }, "章节"), (0, react.createElement)("span", { className: "nwe-section-note" }, `${view.stats.chapters} 条记录${view.stats.chapters > view.rows.filter((row) => row.tone === "ok").length ? ` · 显示最近 ${view.rows.length} 条` : ""}`)), view.rows.length === 0 ? (0, react.createElement)("div", {
581
1059
  className: "nwe-empty",
582
1060
  style: {
583
1061
  marginTop: 12,
@@ -595,7 +1073,7 @@ window.__ModuleLoader__.load({
595
1073
  }, (0, react.createElement)("span", {
596
1074
  className: "nwe-block-icon",
597
1075
  "aria-hidden": "true"
598
- }, "!"), (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "nwe-block-code" }, item.code), (0, react.createElement)("div", { className: "nwe-block-detail" }, item.detail), item.hint === "" ? null : (0, react.createElement)("div", { className: "nwe-block-hint" }, item.hint)))))) : null, footer());
1076
+ }, "!"), (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "nwe-block-code" }, item.code), (0, react.createElement)("div", { className: "nwe-block-detail" }, item.detail), item.hint === "" ? null : (0, react.createElement)("div", { className: "nwe-block-hint" }, item.hint)))))) : null, ready ? activitySection(view) : null, footer());
599
1077
  }
600
1078
  /** Stateful wrapper: fetch once, refresh on demand and after returning to the tab. */
601
1079
  function NovelPanel() {