dsh-novel-write-engine 0.0.0-stage → 0.1.1

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 ADDED
@@ -0,0 +1,694 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-novel-write-engine",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react = require("react");
8
+ // src/constants.ts
9
+ const PANEL_ID = "novel-workshop";
10
+ const PANEL_ROUTE = "/api/novel-write-engine/panel";
11
+ // src/client/viewmodel.ts
12
+ const PANEL_SCHEMA_VERSION = 1;
13
+ const PHASE_LABELS = {
14
+ P1: "世界观",
15
+ P2: "章节计划",
16
+ P3: "初稿",
17
+ P4: "评审",
18
+ P5: "修订",
19
+ P6: "一致性",
20
+ P7: "终审",
21
+ P8: "归档",
22
+ P9: "完成"
23
+ };
24
+ const STAGE_LABELS = {
25
+ idle: "待开始",
26
+ P2: "章节规划",
27
+ P3: "初稿",
28
+ P4: "评审",
29
+ P5: "修订",
30
+ P6: "一致性校验",
31
+ ready_to_archive: "待归档",
32
+ done: "已完成"
33
+ };
34
+ const PLATFORM_LABELS = {
35
+ fanqie: "番茄小说",
36
+ qidian: "起点中文网",
37
+ qimao: "七猫小说",
38
+ jinjiang: "晋江文学城"
39
+ };
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
+ const TOOL_LABELS = {
51
+ novel_init: "创建项目",
52
+ novel_status: "查看状态",
53
+ novel_brief: "领取任务书",
54
+ novel_plan_world: "补全世界观",
55
+ novel_plan_chapter: "规划本章",
56
+ novel_write_draft: "写初稿",
57
+ novel_review: "评审本章",
58
+ novel_revise: "修订本章",
59
+ novel_check: "一致性校验",
60
+ novel_archive: "归档本章",
61
+ novel_word_count: "核对字数",
62
+ novel_state: "查询状态",
63
+ novel_amend: "修订状态",
64
+ novel_rollback: "回退阶段",
65
+ novel_diff: "查看差异",
66
+ novel_export: "导出书稿",
67
+ novel_audit: "查看审计",
68
+ novel_pipeline: "查看建议"
69
+ };
70
+ const loading = () => ({
71
+ state: "loading",
72
+ title: "网文工坊",
73
+ subtitle: "正在整理书稿…",
74
+ stageLabel: "读取中",
75
+ finished: false,
76
+ revisionLabel: "",
77
+ phases: [],
78
+ rows: [],
79
+ blocking: [],
80
+ blockingTitle: "门控",
81
+ stats: {
82
+ chapters: 0,
83
+ words: 0,
84
+ averageScore: 0,
85
+ blocking: 0
86
+ },
87
+ nextTitle: "",
88
+ nextDetail: "",
89
+ nextTool: "",
90
+ emptyCommand: "novel_init",
91
+ revision: "",
92
+ next: ""
93
+ });
94
+ const errorView = (message) => ({
95
+ ...loading(),
96
+ state: "error",
97
+ title: "网文工坊",
98
+ subtitle: message,
99
+ stageLabel: "连接异常"
100
+ });
101
+ const phaseLabel = (id) => PHASE_LABELS[id] ?? id;
102
+ const stageLabel = (stage) => STAGE_LABELS[stage] ?? stage;
103
+ const platformLabel = (platform) => PLATFORM_LABELS[platform] ?? platform;
104
+ const toolLabel = (tool) => TOOL_LABELS[tool] ?? "继续";
105
+ /**
106
+ * Project the wire payload into strings. Version mismatch wins over everything
107
+ * else: a stale panel must never render data from another schema as if it were
108
+ * current.
109
+ */
110
+ function buildView(payload, error = null) {
111
+ if (error !== null) return errorView(error);
112
+ if (payload === null) return loading();
113
+ if (payload.schemaVersion !== PANEL_SCHEMA_VERSION) return {
114
+ ...loading(),
115
+ state: "outdated",
116
+ subtitle: "面板与引擎版本不一致,请刷新页面。",
117
+ stageLabel: "需要刷新"
118
+ };
119
+ if (payload.empty === true) return {
120
+ ...loading(),
121
+ state: "empty",
122
+ subtitle: payload.error ?? "还没有项目。调用 novel_init 开始第一本书。",
123
+ stageLabel: "等待创建"
124
+ };
125
+ const summary = payload.summary;
126
+ const blocking = summary.blocking.map((item) => ({
127
+ code: item.code,
128
+ scope: item.scope ?? "",
129
+ detail: item.detail,
130
+ hint: item.hint ?? ""
131
+ }));
132
+ const rows = payload.chapters.rows.map((row) => {
133
+ const scoreValue = Number.isFinite(row.score) ? Math.max(0, Math.min(150, row.score)) : 0;
134
+ return {
135
+ num: String(row.num),
136
+ title: row.title,
137
+ words: row.wordCount.toLocaleString("zh-CN"),
138
+ wordCount: row.wordCount,
139
+ score: `${scoreValue}/150`,
140
+ scoreValue,
141
+ scorePercent: Math.round(scoreValue / 150 * 100),
142
+ badge: row.status === "passed" ? "已归档" : row.status === "blocked" ? "阻塞" : "草稿",
143
+ tone: row.status === "passed" ? "ok" : row.status === "blocked" ? "bad" : "muted",
144
+ details: row.details ?? "",
145
+ stageLabel: stageLabel(row.stage)
146
+ };
147
+ });
148
+ 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;
150
+ const next = summary.next;
151
+ const finished = summary.finished === true;
152
+ const doneLabel = summary.stage === "done" ? finished ? "全书完成" : "本章已归档" : stageLabel(summary.stage);
153
+ return {
154
+ state: "ready",
155
+ 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} 章`,
157
+ stageLabel: doneLabel,
158
+ finished,
159
+ revisionLabel: `第 ${summary.revision} 次修订`,
160
+ phases: summary.phases.map((phase) => ({
161
+ ...phase,
162
+ label: phaseLabel(phase.id)
163
+ })),
164
+ rows,
165
+ blocking,
166
+ blockingTitle: blocking.length === 0 ? "门控已通过" : `待处理 ${blocking.length} 项`,
167
+ stats: {
168
+ chapters: summary.totalChapters,
169
+ words: summary.totalWords,
170
+ averageScore: Math.round(average * 10) / 10,
171
+ blocking: blocking.length
172
+ },
173
+ nextTitle: finished ? "" : next === void 0 ? "状态已同步" : toolLabel(next.tool),
174
+ nextDetail: finished ? "" : next === void 0 ? "没有待执行的下一步,刷新查看最新状态。" : NEXT_DETAILS[next.tool] ?? next.reason,
175
+ nextTool: next?.tool ?? "",
176
+ emptyCommand: "novel_init",
177
+ revision: `第 ${summary.revision} 次修订 · ${summary.totalChapters} 章 · ${summary.totalWords} 字`,
178
+ next: next === void 0 ? "" : `下一步:${next.tool} — ${next.reason}`
179
+ };
180
+ }
181
+ /** Fetch the panel payload; failures become a plain message, never a throw. */
182
+ async function fetchPanel(fetcher, path, signal) {
183
+ try {
184
+ const response = await fetcher(path, {
185
+ headers: { accept: "application/json" },
186
+ ...signal === void 0 ? {} : { signal }
187
+ });
188
+ if (!response.ok) {
189
+ let detail = `宿主返回 ${response.status}`;
190
+ try {
191
+ const body = await response.json();
192
+ if (typeof body?.error === "string" && body.error !== "") detail = body.error;
193
+ } catch {}
194
+ return {
195
+ payload: null,
196
+ error: detail
197
+ };
198
+ }
199
+ return {
200
+ payload: await response.json(),
201
+ error: null
202
+ };
203
+ } catch (error) {
204
+ return {
205
+ payload: null,
206
+ error: error instanceof Error ? error.message : "无法连接宿主"
207
+ };
208
+ }
209
+ }
210
+ // src/client/panel.ts
211
+ const CSS = `
212
+ .nwe-root {
213
+ --nwe-ink: var(--dsw-alias-label-primary, #ebe7dd);
214
+ --nwe-muted: var(--dsw-alias-label-secondary, #9aa4b2);
215
+ --nwe-surface: var(--dsw-alias-bg-layer-1, #171a20);
216
+ --nwe-raised: var(--dsw-alias-bg-layer-2, #1e222a);
217
+ --nwe-border: var(--dsw-alias-border-l1, #ffffff14);
218
+ --nwe-gold: #8f6a28;
219
+ --nwe-jade: #3f7f69;
220
+ --nwe-rose: #9e5454;
221
+ --nwe-gold: color-mix(in srgb, #b7862f 82%, var(--nwe-ink));
222
+ --nwe-jade: color-mix(in srgb, #4f9f82 68%, var(--nwe-ink));
223
+ --nwe-rose: color-mix(in srgb, #c96a6a 68%, var(--nwe-ink));
224
+ position: relative;
225
+ isolation: isolate;
226
+ box-sizing: border-box;
227
+ width: 100%;
228
+ max-width: 1120px;
229
+ min-height: 100%;
230
+ margin: 0 auto;
231
+ padding: clamp(18px, 3.2vw, 38px);
232
+ color: var(--nwe-ink);
233
+ font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
234
+ line-height: 1.55;
235
+ }
236
+ .nwe-root * { box-sizing: border-box; }
237
+ .nwe-root::before {
238
+ content: "";
239
+ position: absolute;
240
+ inset: 0;
241
+ z-index: -1;
242
+ pointer-events: none;
243
+ background:
244
+ radial-gradient(ellipse 70% 40% at 92% 0%, color-mix(in srgb, var(--nwe-gold) 16%, transparent), transparent 70%),
245
+ radial-gradient(ellipse 50% 30% at 0% 100%, color-mix(in srgb, var(--nwe-jade) 9%, transparent), transparent 70%);
246
+ }
247
+ .nwe-hero {
248
+ display: flex;
249
+ align-items: flex-start;
250
+ justify-content: space-between;
251
+ gap: 24px;
252
+ padding: 22px 24px;
253
+ border: 1px solid var(--nwe-border);
254
+ border-radius: 20px;
255
+ background: linear-gradient(135deg, color-mix(in srgb, var(--nwe-gold) 10%, var(--nwe-surface)), var(--nwe-surface) 70%);
256
+ box-shadow: 0 18px 50px -32px color-mix(in srgb, #000 70%, transparent);
257
+ }
258
+ .nwe-eyebrow {
259
+ color: var(--nwe-gold);
260
+ font-size: 10px;
261
+ font-weight: 600;
262
+ letter-spacing: .22em;
263
+ text-transform: uppercase;
264
+ }
265
+ .nwe-title {
266
+ margin: 8px 0 6px;
267
+ font-family: "Noto Serif SC", "Songti SC", Georgia, serif;
268
+ font-size: clamp(26px, 3.4vw, 42px);
269
+ font-weight: 600;
270
+ line-height: 1.15;
271
+ letter-spacing: .015em;
272
+ }
273
+ .nwe-meta {
274
+ display: flex;
275
+ align-items: center;
276
+ flex-wrap: wrap;
277
+ gap: 8px;
278
+ color: var(--nwe-muted);
279
+ font-size: 12px;
280
+ }
281
+ .nwe-stage {
282
+ display: inline-flex;
283
+ align-items: center;
284
+ gap: 6px;
285
+ padding: 3px 9px;
286
+ border: 1px solid color-mix(in srgb, var(--nwe-gold) 50%, transparent);
287
+ border-radius: 999px;
288
+ color: var(--nwe-gold);
289
+ font-size: 11px;
290
+ font-weight: 600;
291
+ }
292
+ .nwe-stage::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
293
+ .nwe-stage.is-done { border-color: color-mix(in srgb, var(--nwe-jade) 50%, transparent); color: var(--nwe-jade); }
294
+ .nwe-refresh {
295
+ display: inline-flex;
296
+ align-items: center;
297
+ gap: 7px;
298
+ flex: 0 0 auto;
299
+ padding: 8px 14px;
300
+ border: 1px solid var(--nwe-border);
301
+ border-radius: 10px;
302
+ background: var(--nwe-raised);
303
+ color: var(--nwe-ink);
304
+ font: inherit;
305
+ font-size: 12px;
306
+ cursor: pointer;
307
+ transition: border-color .18s ease, transform .18s ease;
308
+ }
309
+ .nwe-refresh:hover:not(:disabled) { border-color: color-mix(in srgb, var(--nwe-gold) 65%, var(--nwe-border)); transform: translateY(-1px); }
310
+ .nwe-refresh:disabled { cursor: wait; opacity: .55; }
311
+ .nwe-root :focus-visible { outline: 2px solid var(--nwe-gold); outline-offset: 3px; }
312
+ .nwe-stats {
313
+ display: grid;
314
+ grid-template-columns: repeat(4, minmax(0, 1fr));
315
+ gap: 10px;
316
+ margin-top: 12px;
317
+ }
318
+ .nwe-stat {
319
+ padding: 13px 16px;
320
+ border: 1px solid var(--nwe-border);
321
+ border-radius: 14px;
322
+ background: color-mix(in srgb, var(--nwe-surface) 88%, var(--nwe-gold) 4%);
323
+ }
324
+ .nwe-stat-label { color: var(--nwe-muted); font-size: 11px; letter-spacing: .04em; }
325
+ .nwe-stat-value {
326
+ margin-top: 3px;
327
+ font-size: 22px;
328
+ font-variant-numeric: tabular-nums;
329
+ font-weight: 600;
330
+ letter-spacing: -.02em;
331
+ }
332
+ .nwe-stat-unit { margin-left: 2px; color: var(--nwe-muted); font-size: 11px; font-weight: 400; }
333
+ .nwe-section { margin-top: 26px; }
334
+ .nwe-section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
335
+ .nwe-section-title {
336
+ margin: 0;
337
+ font-family: "Noto Serif SC", "Songti SC", Georgia, serif;
338
+ font-size: 17px;
339
+ font-weight: 600;
340
+ }
341
+ .nwe-section-note { color: var(--nwe-muted); font-size: 11px; }
342
+ .nwe-phases {
343
+ display: grid;
344
+ grid-template-columns: repeat(9, minmax(0, 1fr));
345
+ gap: 0;
346
+ margin: 12px 0 0;
347
+ padding: 14px 8px 10px;
348
+ border: 1px solid var(--nwe-border);
349
+ border-radius: 14px;
350
+ background: color-mix(in srgb, var(--nwe-surface) 92%, transparent);
351
+ list-style: none;
352
+ }
353
+ .nwe-phase { position: relative; display: flex; flex-direction: column; align-items: center; gap: 6px; min-width: 0; }
354
+ .nwe-phase:not(:last-child)::after {
355
+ content: "";
356
+ position: absolute;
357
+ top: 8px;
358
+ left: calc(50% + 11px);
359
+ width: calc(100% - 22px);
360
+ height: 1px;
361
+ background: var(--nwe-border);
362
+ }
363
+ .nwe-phase.is-done:not(:last-child)::after { background: color-mix(in srgb, var(--nwe-jade) 55%, transparent); }
364
+ .nwe-dot {
365
+ position: relative;
366
+ z-index: 1;
367
+ display: grid;
368
+ place-items: center;
369
+ width: 16px;
370
+ height: 16px;
371
+ border: 1px solid var(--nwe-border);
372
+ border-radius: 50%;
373
+ background: var(--nwe-surface);
374
+ color: transparent;
375
+ font-size: 9px;
376
+ font-weight: 700;
377
+ }
378
+ .nwe-phase.is-done .nwe-dot { border-color: color-mix(in srgb, var(--nwe-jade) 70%, transparent); background: color-mix(in srgb, var(--nwe-jade) 18%, var(--nwe-surface)); color: var(--nwe-jade); }
379
+ .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
+ .nwe-phase-label { overflow: hidden; max-width: 100%; color: var(--nwe-muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
381
+ .nwe-phase.is-active .nwe-phase-label { color: var(--nwe-ink); font-weight: 600; }
382
+ .nwe-next {
383
+ display: flex;
384
+ align-items: flex-start;
385
+ gap: 14px;
386
+ margin-top: 12px;
387
+ padding: 15px 18px;
388
+ border: 1px solid color-mix(in srgb, var(--nwe-gold) 35%, var(--nwe-border));
389
+ border-left: 3px solid var(--nwe-gold);
390
+ border-radius: 14px;
391
+ background: linear-gradient(90deg, color-mix(in srgb, var(--nwe-gold) 9%, transparent), transparent 72%), var(--nwe-surface);
392
+ }
393
+ .nwe-next-icon { display: grid; place-items: center; flex: 0 0 auto; width: 28px; height: 28px; border-radius: 9px; background: color-mix(in srgb, var(--nwe-gold) 15%, transparent); color: var(--nwe-gold); }
394
+ .nwe-next-kicker { color: var(--nwe-gold); font-size: 10px; font-weight: 600; letter-spacing: .16em; text-transform: uppercase; }
395
+ .nwe-next-title { margin-top: 2px; font-size: 15px; font-weight: 600; }
396
+ .nwe-next-detail { margin-top: 2px; color: var(--nwe-muted); font-size: 12px; }
397
+ .nwe-next-tool { margin-left: auto; align-self: center; flex: 0 0 auto; color: var(--nwe-muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; }
398
+ .nwe-chapters { display: flex; flex-direction: column; gap: 9px; margin: 12px 0 0; padding: 0; list-style: none; }
399
+ .nwe-chapter {
400
+ overflow: hidden;
401
+ border: 1px solid var(--nwe-border);
402
+ border-left: 2px solid var(--nwe-border);
403
+ border-radius: 14px;
404
+ background: color-mix(in srgb, var(--nwe-surface) 92%, transparent);
405
+ transition: border-color .18s ease, background .18s ease;
406
+ }
407
+ .nwe-chapter:hover { background: color-mix(in srgb, var(--nwe-surface) 84%, var(--nwe-gold) 5%); }
408
+ .nwe-chapter.is-ok { border-left-color: color-mix(in srgb, var(--nwe-jade) 70%, transparent); }
409
+ .nwe-chapter.is-bad { border-left-color: color-mix(in srgb, var(--nwe-rose) 70%, transparent); }
410
+ .nwe-chapter summary {
411
+ display: grid;
412
+ grid-template-columns: 54px minmax(0, 1fr) minmax(200px, auto) 18px;
413
+ gap: 16px;
414
+ align-items: center;
415
+ padding: 14px 18px;
416
+ cursor: pointer;
417
+ list-style: none;
418
+ }
419
+ .nwe-chapter summary::-webkit-details-marker { display: none; }
420
+ .nwe-chapter-num { color: var(--nwe-gold); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; letter-spacing: .04em; }
421
+ .nwe-chapter-title { overflow: hidden; font-size: 14px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
422
+ .nwe-chapter-stage { margin-top: 2px; color: var(--nwe-muted); font-size: 10px; }
423
+ .nwe-chevron { color: var(--nwe-muted); font-size: 16px; line-height: 1; transition: transform .18s ease; }
424
+ .nwe-chapter[open] .nwe-chevron { transform: rotate(90deg); }
425
+ .nwe-chapter-meta { display: flex; align-items: center; gap: 10px; justify-content: flex-end; color: var(--nwe-muted); font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; }
426
+ .nwe-score { display: flex; flex-direction: column; gap: 4px; width: 92px; }
427
+ .nwe-score-line { display: flex; justify-content: space-between; gap: 6px; }
428
+ .nwe-score-value { color: var(--nwe-ink); font-size: 11px; }
429
+ .nwe-scorebar { overflow: hidden; height: 4px; border-radius: 99px; background: var(--nwe-border); }
430
+ .nwe-scorebar > span { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, color-mix(in srgb, var(--nwe-gold) 65%, var(--nwe-jade)), var(--nwe-gold)); }
431
+ .nwe-badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border: 1px solid var(--nwe-border); border-radius: 999px; font-size: 10px; white-space: nowrap; }
432
+ .nwe-badge.is-ok { border-color: color-mix(in srgb, var(--nwe-jade) 45%, transparent); color: var(--nwe-jade); }
433
+ .nwe-badge.is-bad { border-color: color-mix(in srgb, var(--nwe-rose) 45%, transparent); color: var(--nwe-rose); }
434
+ .nwe-badge.is-muted { color: var(--nwe-muted); }
435
+ .nwe-chapter-detail { padding: 0 18px 15px 88px; color: var(--nwe-muted); font-size: 12px; line-height: 1.7; }
436
+ .nwe-empty, .nwe-state {
437
+ display: flex;
438
+ flex-direction: column;
439
+ align-items: center;
440
+ justify-content: center;
441
+ min-height: 260px;
442
+ padding: 34px 24px;
443
+ border: 1px dashed var(--nwe-border);
444
+ border-radius: 18px;
445
+ text-align: center;
446
+ }
447
+ .nwe-state-icon { display: grid; place-items: center; width: 42px; height: 42px; margin-bottom: 12px; border-radius: 14px; background: color-mix(in srgb, var(--nwe-gold) 12%, transparent); color: var(--nwe-gold); }
448
+ .nwe-state-title { font-family: "Noto Serif SC", "Songti SC", Georgia, serif; font-size: 19px; }
449
+ .nwe-state-text { max-width: 460px; margin-top: 6px; color: var(--nwe-muted); font-size: 13px; }
450
+ .nwe-command { display: inline-block; margin-top: 14px; padding: 7px 12px; border: 1px solid var(--nwe-border); border-radius: 8px; background: var(--nwe-raised); color: var(--nwe-gold); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
451
+ .nwe-skeleton { display: grid; gap: 10px; margin-top: 12px; }
452
+ .nwe-skeleton-line { height: 16px; border-radius: 8px; background: linear-gradient(90deg, var(--nwe-border), color-mix(in srgb, var(--nwe-border) 40%, transparent), var(--nwe-border)); background-size: 200% 100%; animation: nwe-shimmer 1.6s linear infinite; }
453
+ .nwe-skeleton-line.is-wide { height: 72px; }
454
+ .nwe-skeleton-line.is-short { width: 42%; }
455
+ .nwe-blocking { display: flex; flex-direction: column; gap: 8px; margin: 12px 0 0; padding: 0; list-style: none; }
456
+ .nwe-block { display: flex; gap: 11px; align-items: flex-start; padding: 13px 15px; border: 1px solid var(--nwe-border); border-left: 2px solid var(--nwe-rose); border-radius: 12px; background: color-mix(in srgb, var(--nwe-surface) 92%, transparent); }
457
+ .nwe-block-icon { display: grid; place-items: center; flex: 0 0 auto; width: 22px; height: 22px; border-radius: 7px; background: color-mix(in srgb, var(--nwe-rose) 14%, transparent); color: var(--nwe-rose); font-size: 11px; font-weight: 700; }
458
+ .nwe-block-code { color: var(--nwe-rose); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; }
459
+ .nwe-block-detail { margin-top: 2px; font-size: 12px; }
460
+ .nwe-block-hint { margin-top: 3px; color: var(--nwe-muted); font-size: 11px; }
461
+ .nwe-pass { display: flex; align-items: center; gap: 9px; margin-top: 12px; padding: 13px 15px; border: 1px solid color-mix(in srgb, var(--nwe-jade) 30%, var(--nwe-border)); border-radius: 12px; color: var(--nwe-jade); font-size: 12px; background: color-mix(in srgb, var(--nwe-jade) 6%, var(--nwe-surface)); }
462
+ .nwe-footer { display: flex; justify-content: space-between; gap: 10px; margin-top: 26px; padding-top: 12px; border-top: 1px solid var(--nwe-border); color: var(--nwe-muted); font-size: 10px; }
463
+ .nwe-footer code { color: var(--nwe-muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
464
+ @keyframes nwe-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
465
+ @media (max-width: 760px) {
466
+ .nwe-root { padding: 14px; }
467
+ .nwe-hero { flex-direction: column; padding: 18px; }
468
+ .nwe-refresh { width: 100%; justify-content: center; }
469
+ .nwe-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
470
+ .nwe-phases { grid-template-columns: repeat(3, minmax(0, 1fr)); row-gap: 12px; padding: 12px; }
471
+ .nwe-phase:nth-child(3n)::after { display: none; }
472
+ .nwe-chapter summary { grid-template-columns: 38px minmax(0, 1fr) 18px; gap: 10px; }
473
+ .nwe-chapter-meta { display: grid; grid-column: 2 / 4; grid-row: 2; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 8px; white-space: nowrap; }
474
+ .nwe-chapter-meta > span:first-child { grid-column: auto; }
475
+ .nwe-chevron { grid-column: 3; grid-row: 1; }
476
+ .nwe-score { grid-column: auto; width: 100%; }
477
+ .nwe-chapter-meta > .nwe-badge { grid-column: auto; grid-row: auto; }
478
+ .nwe-chapter-detail { padding-left: 18px; }
479
+ .nwe-next-tool { display: none; }
480
+ .nwe-footer { flex-direction: column; }
481
+ }
482
+ @media (prefers-reduced-motion: reduce) {
483
+ .nwe-skeleton-line { animation: none; }
484
+ .nwe-refresh, .nwe-chapter { transition: none; }
485
+ }
486
+ `;
487
+ const icon = (size, path) => (0, react.createElement)("svg", {
488
+ width: size,
489
+ height: size,
490
+ viewBox: "0 0 24 24",
491
+ fill: "none",
492
+ "aria-hidden": "true"
493
+ }, (0, react.createElement)("path", {
494
+ d: path,
495
+ stroke: "currentColor",
496
+ strokeWidth: 1.7,
497
+ strokeLinecap: "round",
498
+ strokeLinejoin: "round"
499
+ }));
500
+ 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
+ const arrowIcon = () => icon(14, "M5 12h13M13 6l6 6-6 6");
502
+ 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");
503
+ const stat = (label, value, unit) => (0, react.createElement)("div", {
504
+ className: "nwe-stat",
505
+ key: label
506
+ }, (0, react.createElement)("div", { className: "nwe-stat-label" }, label), (0, react.createElement)("div", { className: "nwe-stat-value" }, value, (0, react.createElement)("span", { className: "nwe-stat-unit" }, unit)));
507
+ const chapterCard = (row) => (0, react.createElement)("li", { key: row.num }, (0, react.createElement)("details", { className: `nwe-chapter is-${row.tone}` }, (0, react.createElement)("summary", { "aria-label": `第 ${row.num} 章 ${row.title},${row.badge},${row.score}` }, (0, react.createElement)("div", null, (0, react.createElement)("div", { className: "nwe-chapter-num" }, `CH ${row.num.padStart(2, "0")}`)), (0, react.createElement)("div", { style: { minWidth: 0 } }, (0, react.createElement)("div", { className: "nwe-chapter-title" }, row.title), (0, react.createElement)("div", { className: "nwe-chapter-stage" }, row.stageLabel)), (0, react.createElement)("div", { className: "nwe-chapter-meta" }, (0, react.createElement)("span", null, `${row.words} 字`), (0, react.createElement)("div", { className: "nwe-score" }, (0, react.createElement)("div", { className: "nwe-score-line" }, (0, react.createElement)("span", { className: "nwe-score-value" }, row.score), (0, react.createElement)("span", { className: "nwe-chapter-stage" }, `${row.scorePercent}%`)), (0, react.createElement)("div", {
508
+ className: "nwe-scorebar",
509
+ role: "progressbar",
510
+ "aria-valuemin": 0,
511
+ "aria-valuemax": 150,
512
+ "aria-valuenow": row.scoreValue,
513
+ "aria-label": `${row.title} 评分`
514
+ }, (0, react.createElement)("span", { style: { width: `${row.scorePercent}%` } }))), (0, react.createElement)("span", { className: `nwe-badge is-${row.tone}` }, row.badge)), (0, react.createElement)("span", {
515
+ className: "nwe-chevron",
516
+ "aria-hidden": "true"
517
+ }, "›")), row.details === "" ? null : (0, react.createElement)("div", { className: "nwe-chapter-detail" }, row.details)));
518
+ const phases = (view) => (0, react.createElement)("ol", {
519
+ className: "nwe-phases",
520
+ "aria-label": "创作阶段"
521
+ }, ...view.phases.map((phase) => (0, react.createElement)("li", {
522
+ key: phase.id,
523
+ className: `nwe-phase${phase.done ? " is-done" : ""}${phase.active ? " is-active" : ""}`,
524
+ "aria-current": phase.active ? "step" : void 0
525
+ }, (0, react.createElement)("span", {
526
+ className: "nwe-dot",
527
+ "aria-hidden": "true"
528
+ }, phase.done ? "✓" : ""), (0, react.createElement)("span", { className: "nwe-phase-label" }, phase.label))));
529
+ const footer = () => (0, react.createElement)("footer", { className: "nwe-footer" }, (0, react.createElement)("span", null, "只读面板 · 数据来自引擎 · 每 15 秒自动同步"), (0, react.createElement)("code", null, "novel-workshop"));
530
+ const loadingState = () => (0, react.createElement)("div", {
531
+ className: "nwe-section",
532
+ role: "status",
533
+ "aria-live": "polite"
534
+ }, (0, react.createElement)("div", { className: "nwe-skeleton" }, ...Array.from({ length: 4 }, (_, index) => (0, react.createElement)("div", {
535
+ key: index,
536
+ className: `nwe-skeleton-line${index === 0 ? " is-wide" : index === 1 ? " is-short" : ""}`
537
+ }))), (0, react.createElement)("div", {
538
+ className: "nwe-section-note",
539
+ style: { marginTop: 12 }
540
+ }, "正在读取项目状态…"));
541
+ const statePanel = (state, view, loading, onRefresh) => {
542
+ const title = state === "empty" ? "还没有开局" : state === "outdated" ? "面板需要刷新" : "书稿读取失败";
543
+ return (0, react.createElement)("div", {
544
+ className: "nwe-state",
545
+ role: state === "error" ? "alert" : "status"
546
+ }, (0, react.createElement)("div", { className: "nwe-state-icon" }, stateIcon()), (0, react.createElement)("div", { className: "nwe-state-title" }, title), (0, react.createElement)("div", { className: "nwe-state-text" }, view.subtitle), state === "empty" ? (0, react.createElement)("code", { className: "nwe-command" }, view.emptyCommand) : null, state === "empty" ? (0, react.createElement)("div", { className: "nwe-state-text" }, "创建后,面板会自动同步章节与门控状态。") : (0, react.createElement)("button", {
547
+ type: "button",
548
+ className: "nwe-refresh",
549
+ style: { marginTop: 16 },
550
+ onClick: state === "outdated" ? () => {
551
+ globalThis.location?.reload?.();
552
+ } : onRefresh,
553
+ disabled: loading && state !== "outdated"
554
+ }, refreshIcon(), state === "outdated" ? "刷新页面" : loading ? "刷新中…" : "重新读取"));
555
+ };
556
+ /**
557
+ * Pure view: given a view model, return the element tree. Keeping the hooks in
558
+ * `NovelPanel` makes every visual state renderable without a browser.
559
+ */
560
+ function panelElement(view, loading, onRefresh) {
561
+ const ready = view.state === "ready";
562
+ return (0, react.createElement)("section", {
563
+ "data-plugin": "dsh-novel-write-engine",
564
+ className: "nwe-root",
565
+ "aria-busy": loading,
566
+ "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", {
568
+ type: "button",
569
+ className: "nwe-refresh",
570
+ onClick: onRefresh,
571
+ disabled: loading,
572
+ "aria-label": "刷新项目状态"
573
+ }, refreshIcon(), loading ? "刷新中…" : "刷新")), ready ? (0, react.createElement)("div", {
574
+ className: "nwe-stats",
575
+ role: "group",
576
+ "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", {
578
+ className: "nwe-next",
579
+ "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", {
581
+ className: "nwe-empty",
582
+ style: {
583
+ marginTop: 12,
584
+ minHeight: 170
585
+ }
586
+ }, (0, react.createElement)("div", { className: "nwe-state-icon" }, stateIcon()), (0, react.createElement)("div", { className: "nwe-state-title" }, "书架还是空的"), (0, react.createElement)("div", { className: "nwe-state-text" }, "完成第一次归档后,章节会出现在这里。")) : (0, react.createElement)("ul", { className: "nwe-chapters" }, ...view.rows.slice(-30).map(chapterCard))) : 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.blockingTitle)), view.blocking.length === 0 ? (0, react.createElement)("div", {
587
+ className: "nwe-pass",
588
+ role: "status"
589
+ }, (0, react.createElement)("span", { "aria-hidden": "true" }, "✓"), (0, react.createElement)("span", null, "当前阶段没有阻塞项,可以继续下一步。")) : (0, react.createElement)("ul", {
590
+ className: "nwe-blocking",
591
+ "aria-label": "阻塞项"
592
+ }, ...view.blocking.map((item) => (0, react.createElement)("li", {
593
+ className: "nwe-block",
594
+ key: `${item.code}-${item.scope}-${item.detail}`
595
+ }, (0, react.createElement)("span", {
596
+ className: "nwe-block-icon",
597
+ "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());
599
+ }
600
+ /** Stateful wrapper: fetch once, refresh on demand and after returning to the tab. */
601
+ function NovelPanel() {
602
+ const [view, setView] = (0, react.useState)(() => buildView(null));
603
+ const [loading, setLoading] = (0, react.useState)(true);
604
+ const alive = (0, react.useRef)(true);
605
+ const inFlight = (0, react.useRef)(false);
606
+ const load = (0, react.useCallback)(async (silent = false) => {
607
+ if (inFlight.current) return;
608
+ inFlight.current = true;
609
+ if (!silent) setLoading(true);
610
+ try {
611
+ const timeout = typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(1e4) : void 0;
612
+ const result = await fetchPanel(fetch, PANEL_ROUTE.replace(/^\/+/, ""), timeout);
613
+ if (!alive.current) return;
614
+ setView((previous) => {
615
+ const next = buildView(result.payload, result.error);
616
+ if (silent && result.error !== null && previous.state === "ready") return previous;
617
+ return next;
618
+ });
619
+ } finally {
620
+ inFlight.current = false;
621
+ if (!silent && alive.current) setLoading(false);
622
+ }
623
+ }, []);
624
+ (0, react.useEffect)(() => {
625
+ alive.current = true;
626
+ load();
627
+ const win = globalThis.window;
628
+ const doc = globalThis.document;
629
+ if (win === void 0 || doc === void 0) return;
630
+ const refreshWhenVisible = () => {
631
+ if (doc.visibilityState !== "hidden") load(true);
632
+ };
633
+ const timer = win.setInterval(refreshWhenVisible, 15e3);
634
+ win.addEventListener("focus", refreshWhenVisible);
635
+ doc.addEventListener("visibilitychange", refreshWhenVisible);
636
+ return () => {
637
+ alive.current = false;
638
+ win.clearInterval(timer);
639
+ win.removeEventListener("focus", refreshWhenVisible);
640
+ doc.removeEventListener("visibilitychange", refreshWhenVisible);
641
+ };
642
+ }, [load]);
643
+ return panelElement(view, loading, () => void load());
644
+ }
645
+ /** Sidebar icon: a small book mark, inheriting the current text colour. */
646
+ function NovelIcon({ size }) {
647
+ const edge = size ?? 18;
648
+ return (0, react.createElement)("svg", {
649
+ width: edge,
650
+ height: edge,
651
+ viewBox: "0 0 24 24",
652
+ fill: "none",
653
+ "aria-hidden": "true"
654
+ }, (0, react.createElement)("path", {
655
+ d: "M4 5.5A2.5 2.5 0 0 1 6.5 3H20v15H6.5A2.5 2.5 0 0 0 4 20.5V5.5Z",
656
+ stroke: "currentColor",
657
+ strokeWidth: 1.8,
658
+ strokeLinejoin: "round"
659
+ }), (0, react.createElement)("path", {
660
+ d: "M8 7h8M8 10.5h5",
661
+ stroke: "currentColor",
662
+ strokeWidth: 1.6,
663
+ strokeLinecap: "round"
664
+ }));
665
+ }
666
+ // src/client/index.ts
667
+ const name = "dsh-novel-write-engine";
668
+ const inject = ["slots"];
669
+ /**
670
+ * Register the sidebar entry and the main page. The sidebar id must equal the
671
+ * `main` key; that pairing is what makes the icon open this page.
672
+ */
673
+ function apply(ctx) {
674
+ ctx.slots.inject("main", () => ctx.slots.register({
675
+ name: "main",
676
+ key: PANEL_ID,
677
+ locale: "novel-workshop"
678
+ }, NovelPanel));
679
+ ctx.slots.inject("sidebar.panellist", () => ctx.slots.register({
680
+ name: "sidebar.panellist",
681
+ id: PANEL_ID,
682
+ order: 20,
683
+ label: () => "网文工坊",
684
+ locale: "novel-workshop"
685
+ }, NovelIcon));
686
+ }
687
+ exports.NovelIcon = NovelIcon;
688
+ exports.NovelPanel = NovelPanel;
689
+ exports.apply = apply;
690
+ exports.inject = inject;
691
+ exports.name = name;
692
+ return module.exports;
693
+ }
694
+ });