@waterwx/dsh-novel-forge 1.4.0 → 1.4.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.
@@ -16,6 +16,8 @@ export declare class NovelApi {
16
16
  ok: boolean;
17
17
  bookName: string;
18
18
  }>;
19
+ /** 反推大纲:从已写章节正文反向生成全书总纲(NDJSON 流)。 */
20
+ outlineReverse(onFrame: (frame: JobFrame) => void): Promise<void>;
19
21
  /** 开书想法 → AI 大纲:生成 count 个方案(换批时传 exclude 避开已暂留方向)。 */
20
22
  outlineSuggest(idea: string, count?: number, exclude?: string[]): Promise<import('../protocol.ts').OutlineSuggestResponse>;
21
23
  /** 拆书分析:对已写章节做结构/人物/文风/卖点体检。 */
@@ -118,8 +120,12 @@ export declare class NovelApi {
118
120
  bookRemove(id: string): Promise<import('../protocol.ts').BookshelfSnapshot>;
119
121
  /** 导入已有项目目录(Mode A):校验 novel-project.json,登记/激活书架。 */
120
122
  bookImportDir(outputDir: string): Promise<import('../protocol.ts').BookImportDirResponse>;
121
- /** 导入 txt/md 全本(Mode B):拆章建项目并登记书架。 */
123
+ /** 导入 txt/md 全本(Mode B):服务器本地文件路径模式。 */
122
124
  bookImportText(filePath: string, outputDir?: string): Promise<import('../protocol.ts').BookImportTextResponse>;
125
+ /** 拆章预览(浏览器上传全文,不落盘):返回识别到的章节与跳过清单。 */
126
+ bookImportTextPreview(text: string, fileName?: string): Promise<import('../protocol.ts').BookImportTextPreviewResponse>;
127
+ /** 导入 txt/md 全本(Mode B):浏览器上传全文内容模式。 */
128
+ bookImportTextContent(text: string, fileName: string, outputDir?: string): Promise<import('../protocol.ts').BookImportTextResponse>;
123
129
  /** 生产单:启动批量生产(区间或新增 N 章;计划不足自动补)。 */
124
130
  runStart(req: import('../protocol.ts').RunStartRequest): Promise<import('../protocol.ts').RunState>;
125
131
  /** 生产单控制:pause / resume / stop。 */
@@ -143,12 +143,14 @@ export declare const zh: {
143
143
  readonly 'settings.model': "模型";
144
144
  readonly 'settings.modelCustom': "自定义…";
145
145
  readonly 'settings.modelCustomPlaceholder': "输入任意模型 id";
146
- readonly 'settings.reasoningEffort': "思考强度 (reasoningEffort)";
146
+ readonly 'settings.reasoningEffort': "写作类任务思考强度 (reasoningEffort)";
147
+ readonly 'settings.reasoningHint': "章节生成/润色/重写/审稿等写作任务使用;关掉更省更快。";
148
+ readonly 'settings.analysisReasoning': "分析类任务思考强度(提炼/拆书/反推大纲)";
149
+ readonly 'settings.analysisReasoningHint': "角色/场景/道藏提炼、拆书、反推大纲等一次性分析任务默认 low,保证质量,不受上面写作档位影响。";
147
150
  readonly 'settings.reasoning.off': "off · 关闭思考(推荐日常写文)";
148
151
  readonly 'settings.reasoning.low': "low · 低思考";
149
152
  readonly 'settings.reasoning.high': "high · 高思考";
150
153
  readonly 'settings.reasoning.max': "max · 最高思考(质检/推演)";
151
- readonly 'settings.reasoningHint': "off 最快最省;high/max 更慢更贵但推理更强";
152
154
  readonly 'settings.theme': "主题";
153
155
  readonly 'settings.themeLiquid': "iOS 液态玻璃(绿)";
154
156
  readonly 'settings.themeClassic': "经典毛玻璃(蓝)";
@@ -71,7 +71,28 @@ export declare function refreshPlotlineProgress(ctx: Context, config: NovelConfi
71
71
  export declare function extractRoles(ctx: Context, config: NovelConfig, project: ProjectState): Promise<RoleRecord[]>;
72
72
  /** ✨ AI 从全书提炼场景库:正文/编年录 → 高频重要场景的结构化视觉锚点。 */
73
73
  export declare function extractScenes(ctx: Context, config: NovelConfig, project: ProjectState): Promise<SceneCard[]>;
74
- /** 从 txt/md 全本拆分章节并建立项目:正文落盘为章节文件,status=written(待审稿)。 */
74
+ /** 从 txt/md 全本文本拆章(纯逻辑,不落盘):识别章节头、剥离重复标题、去重、排序并统一重新编号。 */
75
+ export declare function splitBookText(raw: string): Array<{
76
+ no: number;
77
+ title: string;
78
+ body: string;
79
+ }>;
80
+ /** 拆章预览(不落盘):章节编号/标题/字数 + 跳过清单。 */
81
+ export declare function previewBookText(raw: string): {
82
+ chapters: Array<{
83
+ no: number;
84
+ title: string;
85
+ chars: number;
86
+ }>;
87
+ skipped: string[];
88
+ };
89
+ /** 从全本文本导入(浏览器上传 / 服务器文件共用):建项目、写章节文件、保存。 */
90
+ export declare function importBookTextFromText(raw: string, outputDir: string, bookName: string): {
91
+ bookName: string;
92
+ chapters: number;
93
+ skipped: string[];
94
+ };
95
+ /** 从 txt/md 全本文件导入:编码自适应读取后拆章建项目,status=written(待审稿)。 */
75
96
  export declare function importBookText(filePath: string, outputDir: string): {
76
97
  bookName: string;
77
98
  chapters: number;
@@ -161,6 +182,11 @@ export declare function generateChapterStream(ctx: Context, config: NovelConfig,
161
182
  }, void, unknown>;
162
183
  /** Generate a chapter summary (narrative memory). */
163
184
  export declare function summarizeChapter(ctx: Context, config: NovelConfig, project: ProjectState, outputDir: string, chapterNo: number): Promise<string>;
185
+ /**
186
+ * 反向推大纲:从已写章节正文反推出全书总纲(分卷 + 章节要点 + 主线/人物弧线/伏笔清单)。
187
+ * 两阶段:分批提取章节事件摘要 → 汇总生成大纲。不修改章节/设定,只返回大纲文本。
188
+ */
189
+ export declare function reverseOutlineFromChapters(ctx: Context, config: NovelConfig, project: ProjectState, outputDir: string, onProgress?: (done: number, total: number, phase: string) => void): Promise<string>;
164
190
  /**
165
191
  * 摘要 + 事实抽取合并为一次 LLM 调用(省一次调用与一次正文输入,
166
192
  * 批量生成时整体开销约省 25%)。
@@ -35,6 +35,8 @@ export interface Config {
35
35
  model?: string;
36
36
  /** LLM reasoning effort (off/low/high/max). */
37
37
  reasoningEffort?: 'off' | 'low' | 'high' | 'max';
38
+ /** 分析类任务(提炼/拆书/反推大纲等)的推理档位;默认 low。 */
39
+ analysisReasoning?: 'off' | 'low' | 'high' | 'max';
38
40
  /** Target characters per chapter. */
39
41
  chapterChars?: number;
40
42
  /** Max output tokens per chapter call. */
@@ -39,6 +39,8 @@ export declare const NOVEL_API: {
39
39
  readonly bookshelfImportDir: "/api/dsh-novel-forge/bookshelf/import-dir";
40
40
  /** 导入 txt/md 全本:拆章建项目并登记书架。 */
41
41
  readonly bookshelfImportText: "/api/dsh-novel-forge/bookshelf/import-text";
42
+ /** 导入 txt/md 全本:拆章预览(不落盘)。 */
43
+ readonly bookshelfImportTextPreview: "/api/dsh-novel-forge/bookshelf/import-text/preview";
42
44
  /** 重置项目(可选携带新大纲):清空设定/卷/章节/伏笔/资产/事实库。 */
43
45
  readonly reset: "/api/dsh-novel-forge/reset";
44
46
  /** 全书一致性质检:LLM 扫描已生成章节,输出矛盾问题清单。 */
@@ -73,6 +75,8 @@ export declare const NOVEL_API: {
73
75
  readonly sensitiveCheck: "/api/dsh-novel-forge/sensitive-check";
74
76
  /** 开书想法 → AI 补全大纲:输入一句话想法,生成 2-3 个可选大纲方案。 */
75
77
  readonly outlineSuggest: "/api/dsh-novel-forge/outline/suggest";
78
+ /** 反推大纲:从已写章节正文反向生成全书总纲(NDJSON 流)。 */
79
+ readonly outlineReverse: "/api/dsh-novel-forge/outline/reverse";
76
80
  /** 拆书分析:对已写章节做结构/人物/文风/卖点四维体检(两阶段:源笔记→分节分析)。 */
77
81
  readonly breakdown: "/api/dsh-novel-forge/breakdown";
78
82
  /** 漫剧分镜生成:章节 → 角色锚点 + 分镜表(可适配豆包/Seedance/SD)。 */
@@ -143,10 +147,17 @@ export interface BookImportDirResponse {
143
147
  /** true = 目录已在书架中(本次为重新激活)。 */
144
148
  existed: boolean;
145
149
  }
146
- /** POST /bookshelf/import-text 请求:导入 txt/md 全本。 */
150
+ /** POST /bookshelf/import-text 请求:导入 txt/md 全本。两种模式二选一:
151
+ * - filePath:服务器本地文件路径;
152
+ * - text + fileName:浏览器上传的全文内容(fileName 用于推断书名)。
153
+ */
147
154
  export interface BookImportTextRequest {
148
- /** 源文件绝对路径(txt 或 md)。 */
149
- filePath: string;
155
+ /** 模式一:源文件绝对路径(txt 或 md)。 */
156
+ filePath?: string;
157
+ /** 模式二:浏览器上传的全文内容。 */
158
+ text?: string;
159
+ /** 模式二:原文件名(txt/md),用于推断书名与显示。 */
160
+ fileName?: string;
150
161
  /** 输出目录;缺省为 ~/.dsh/novels/书名。 */
151
162
  outputDir?: string;
152
163
  }
@@ -160,6 +171,25 @@ export interface BookImportTextResponse {
160
171
  /** 登记后的书架条目。 */
161
172
  book: BookEntry;
162
173
  }
174
+ /** POST /bookshelf/import-text/preview 请求:上传全文做拆章预览(不落盘)。 */
175
+ export interface BookImportTextPreviewRequest {
176
+ text: string;
177
+ /** 原文件名(txt/md),用于推断书名。 */
178
+ fileName?: string;
179
+ }
180
+ /** POST /bookshelf/import-text/preview 响应。 */
181
+ export interface BookImportTextPreviewResponse {
182
+ /** 预计书名(fileName 去扩展名)。 */
183
+ bookName: string;
184
+ /** 识别出的章节(已按正文长度过滤过短章节)。 */
185
+ chapters: Array<{
186
+ no: number;
187
+ title: string;
188
+ chars: number;
189
+ }>;
190
+ /** 因内容过短被跳过的章节标题列表。 */
191
+ skipped: string[];
192
+ }
163
193
  /** Chapter lifecycle states (the writing pipeline's state machine). */
164
194
  export type ChapterStatus = 'pending' | 'generating' | 'written' | 'reviewing' | 'approved' | 'rejected' | 'error';
165
195
  /** One chapter in the plan. */
@@ -796,6 +826,8 @@ export interface NovelConfig {
796
826
  model: string;
797
827
  /** LLM reasoning effort: off = no thinking; low/high/max = thinking intensity. */
798
828
  reasoningEffort: 'off' | 'low' | 'high' | 'max';
829
+ /** 分析类任务(提炼/拆书/反推大纲等)的推理档位;默认 low,不受上面写作档位影响。 */
830
+ analysisReasoning: 'off' | 'low' | 'high' | 'max';
799
831
  /** Target characters per chapter. */
800
832
  chapterChars: number;
801
833
  /** Max output tokens per chapter call. */
@@ -961,6 +993,15 @@ export type JobFrame = {
961
993
  no: number;
962
994
  chars: number;
963
995
  draft: string;
996
+ } | {
997
+ type: 'outline-progress';
998
+ done: number;
999
+ total: number;
1000
+ phase: string;
1001
+ } | {
1002
+ type: 'outline-done';
1003
+ outline: string;
1004
+ chars: number;
964
1005
  } | {
965
1006
  type: 'error';
966
1007
  no: number;
@@ -1032,6 +1073,7 @@ export interface ConfigPatch {
1032
1073
  provider?: string;
1033
1074
  model?: string;
1034
1075
  reasoningEffort?: 'off' | 'low' | 'high' | 'max';
1076
+ analysisReasoning?: 'off' | 'low' | 'high' | 'max';
1035
1077
  chapterChars?: number;
1036
1078
  maxTokens?: number;
1037
1079
  reviewPassScore?: number;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@waterwx/dsh-novel-forge",
3
3
  "description": "AI 编译小说工作台 for the dsh web GUI: import a novel outline (docx/text), plan chapters with LLM, generate chapter by chapter (3000-4000 chars each), and save every chapter as Markdown into your chosen output folder.",
4
- "version": "1.4.0",
4
+ "version": "1.4.1",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
package/src/client/api.ts CHANGED
@@ -75,6 +75,11 @@ export class NovelApi {
75
75
  return postJson<{ ok: boolean; bookName: string }>(NOVEL_API.saveOutline, { text })
76
76
  }
77
77
 
78
+ /** 反推大纲:从已写章节正文反向生成全书总纲(NDJSON 流)。 */
79
+ async outlineReverse(onFrame: (frame: JobFrame) => void): Promise<void> {
80
+ await this.streamJob(NOVEL_API.outlineReverse, {}, onFrame)
81
+ }
82
+
78
83
  /** 开书想法 → AI 大纲:生成 count 个方案(换批时传 exclude 避开已暂留方向)。 */
79
84
  async outlineSuggest(idea: string, count?: number, exclude?: string[]): Promise<import('../protocol.ts').OutlineSuggestResponse> {
80
85
  return postJson<import('../protocol.ts').OutlineSuggestResponse>(NOVEL_API.outlineSuggest, { idea, count, exclude })
@@ -259,11 +264,21 @@ export class NovelApi {
259
264
  return postJson<import('../protocol.ts').BookImportDirResponse>(NOVEL_API.bookshelfImportDir, { outputDir })
260
265
  }
261
266
 
262
- /** 导入 txt/md 全本(Mode B):拆章建项目并登记书架。 */
267
+ /** 导入 txt/md 全本(Mode B):服务器本地文件路径模式。 */
263
268
  async bookImportText(filePath: string, outputDir?: string): Promise<import('../protocol.ts').BookImportTextResponse> {
264
269
  return postJson<import('../protocol.ts').BookImportTextResponse>(NOVEL_API.bookshelfImportText, { filePath, outputDir })
265
270
  }
266
271
 
272
+ /** 拆章预览(浏览器上传全文,不落盘):返回识别到的章节与跳过清单。 */
273
+ async bookImportTextPreview(text: string, fileName?: string): Promise<import('../protocol.ts').BookImportTextPreviewResponse> {
274
+ return postJson<import('../protocol.ts').BookImportTextPreviewResponse>(NOVEL_API.bookshelfImportTextPreview, { text, fileName })
275
+ }
276
+
277
+ /** 导入 txt/md 全本(Mode B):浏览器上传全文内容模式。 */
278
+ async bookImportTextContent(text: string, fileName: string, outputDir?: string): Promise<import('../protocol.ts').BookImportTextResponse> {
279
+ return postJson<import('../protocol.ts').BookImportTextResponse>(NOVEL_API.bookshelfImportText, { text, fileName, outputDir })
280
+ }
281
+
267
282
  /** 生产单:启动批量生产(区间或新增 N 章;计划不足自动补)。 */
268
283
  async runStart(req: import('../protocol.ts').RunStartRequest): Promise<import('../protocol.ts').RunState> {
269
284
  return postJson<import('../protocol.ts').RunState>(NOVEL_API.runStart, req)
@@ -144,12 +144,14 @@ export const zh = {
144
144
  'settings.model': '模型',
145
145
  'settings.modelCustom': '自定义…',
146
146
  'settings.modelCustomPlaceholder': '输入任意模型 id',
147
- 'settings.reasoningEffort': '思考强度 (reasoningEffort)',
147
+ 'settings.reasoningEffort': '写作类任务思考强度 (reasoningEffort)',
148
+ 'settings.reasoningHint': '章节生成/润色/重写/审稿等写作任务使用;关掉更省更快。',
149
+ 'settings.analysisReasoning': '分析类任务思考强度(提炼/拆书/反推大纲)',
150
+ 'settings.analysisReasoningHint': '角色/场景/道藏提炼、拆书、反推大纲等一次性分析任务默认 low,保证质量,不受上面写作档位影响。',
148
151
  'settings.reasoning.off': 'off · 关闭思考(推荐日常写文)',
149
152
  'settings.reasoning.low': 'low · 低思考',
150
153
  'settings.reasoning.high': 'high · 高思考',
151
154
  'settings.reasoning.max': 'max · 最高思考(质检/推演)',
152
- 'settings.reasoningHint': 'off 最快最省;high/max 更慢更贵但推理更强',
153
155
  'settings.theme': '主题',
154
156
  'settings.themeLiquid': 'iOS 液态玻璃(绿)',
155
157
  'settings.themeClassic': '经典毛玻璃(蓝)',
@@ -295,7 +297,9 @@ export const en: Record<string, string> = {
295
297
  'settings.model': 'Model',
296
298
  'settings.modelCustom': 'Custom…',
297
299
  'settings.modelCustomPlaceholder': 'Type any model id',
298
- 'settings.reasoningEffort': 'Reasoning effort',
300
+ 'settings.reasoningEffort': 'Writing-task reasoning effort',
301
+ 'settings.analysisReasoning': 'Analysis-task reasoning (extract / breakdown / reverse outline)',
302
+ 'settings.analysisReasoningHint': 'One-off analysis tasks (roles/scenes/bible extract, breakdown, reverse outline) default to low; not affected by the writing-level switch.',
299
303
  'settings.reasoning.off': 'off · no thinking (recommended)',
300
304
  'settings.reasoning.low': 'low',
301
305
  'settings.reasoning.high': 'high',
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * 导入小说弹窗:两种模式 ——
3
- * A) 已有项目目录(含 novel-project.json)→ 登记/激活书架;
4
- * B) txt/md 全本 → 服务器拆章建项目后登记书架。
5
- * 浏览器无法直接浏览宿主机目录,故两种模式均输入绝对路径(粘贴或手输)。
3
+ * A) 已有项目目录(含 novel-project.json)→ 登记/激活书架(粘贴服务器本地路径);
4
+ * B) txt/md 全本 → 点击选择文件,浏览器读取内容 → 服务器拆章预览 → 确认后导入。
5
+ * Mode B 也保留「服务器本地路径」高级选项(大文件或服务器端已有文件时用)。
6
6
  */
7
- import { useState } from 'react'
7
+ import { useRef, useState } from 'react'
8
8
  import type { NovelApi } from '../api.ts'
9
+ import type { BookImportTextPreviewResponse } from '../../protocol.ts'
9
10
  import css from './panel.module.css'
10
11
 
11
12
  type ImportMode = 'dir' | 'text'
@@ -30,13 +31,43 @@ export function ImportModal({
30
31
  onImported: () => void | Promise<void>
31
32
  }) {
32
33
  const [mode, setMode] = useState<ImportMode>('dir')
34
+ // Mode A
33
35
  const [dir, setDir] = useState('')
36
+ // Mode B:文件选择 + 预览
37
+ const fileInputRef = useRef<HTMLInputElement | null>(null)
38
+ const [fileName, setFileName] = useState('')
39
+ const [fileText, setFileText] = useState<string | null>(null)
40
+ const [previewing, setPreviewing] = useState(false)
41
+ const [preview, setPreview] = useState<BookImportTextPreviewResponse | null>(null)
42
+ // Mode B:服务器本地路径(高级)
43
+ const [showPath, setShowPath] = useState(false)
34
44
  const [filePath, setFilePath] = useState('')
45
+ // 通用
35
46
  const [outDir, setOutDir] = useState('')
36
47
  const [busy, setBusy] = useState(false)
37
48
  const [error, setError] = useState('')
38
49
  const [result, setResult] = useState<ImportResult | null>(null)
39
50
 
51
+ /** 选择文件 → 读取内容 → 调服务器预览拆章。 */
52
+ const onPickFile = async (file: File | undefined) => {
53
+ if (file === undefined) return
54
+ setError('')
55
+ setResult(null)
56
+ setPreview(null)
57
+ setFileName(file.name)
58
+ setPreviewing(true)
59
+ try {
60
+ const text = await file.text()
61
+ setFileText(text)
62
+ const p = await api.bookImportTextPreview(text, file.name)
63
+ setPreview(p)
64
+ } catch (err) {
65
+ setError((err as Error).message)
66
+ } finally {
67
+ setPreviewing(false)
68
+ }
69
+ }
70
+
40
71
  const runImport = async () => {
41
72
  setError('')
42
73
  setResult(null)
@@ -53,13 +84,14 @@ export function ImportModal({
53
84
  } finally {
54
85
  setBusy(false)
55
86
  }
56
- } else {
57
- const f = filePath.trim()
58
- if (f === '') { setError('请输入 txt/md 文件路径'); return }
87
+ return
88
+ }
89
+ // Mode B:优先上传内容;未选文件时退回服务器路径
90
+ if (fileText !== null && fileText.length > 0) {
59
91
  setBusy(true)
60
92
  try {
61
93
  const o = outDir.trim()
62
- const r = await api.bookImportText(f, o !== '' ? o : undefined)
94
+ const r = await api.bookImportTextContent(fileText, fileName, o !== '' ? o : undefined)
63
95
  setResult({ kind: 'text', bookName: r.bookName, chapters: r.chapters, skipped: r.skipped })
64
96
  await onImported()
65
97
  } catch (err) {
@@ -67,9 +99,25 @@ export function ImportModal({
67
99
  } finally {
68
100
  setBusy(false)
69
101
  }
102
+ return
103
+ }
104
+ const f = filePath.trim()
105
+ if (f === '') { setError('请先选择文件,或输入服务器本地文件路径'); return }
106
+ setBusy(true)
107
+ try {
108
+ const o = outDir.trim()
109
+ const r = await api.bookImportText(f, o !== '' ? o : undefined)
110
+ setResult({ kind: 'text', bookName: r.bookName, chapters: r.chapters, skipped: r.skipped })
111
+ await onImported()
112
+ } catch (err) {
113
+ setError((err as Error).message)
114
+ } finally {
115
+ setBusy(false)
70
116
  }
71
117
  }
72
118
 
119
+ const switchMode = (m: ImportMode) => { setMode(m); setError(''); setResult(null); setPreview(null) }
120
+
73
121
  return (
74
122
  <div className={css.importModalOverlay} onClick={e => { if (e.target === e.currentTarget) onClose() }}>
75
123
  <div className={css.importModal}>
@@ -84,7 +132,7 @@ export function ImportModal({
84
132
  role="tab"
85
133
  aria-selected={mode === 'dir'}
86
134
  className={`${css.button} ${mode === 'dir' ? css.buttonPrimary : ''}`}
87
- onClick={() => { setMode('dir'); setError(''); setResult(null) }}
135
+ onClick={() => { switchMode('dir') }}
88
136
  >
89
137
  📂 A · 已有项目目录
90
138
  </button>
@@ -93,7 +141,7 @@ export function ImportModal({
93
141
  role="tab"
94
142
  aria-selected={mode === 'text'}
95
143
  className={`${css.button} ${mode === 'text' ? css.buttonPrimary : ''}`}
96
- onClick={() => { setMode('text'); setError(''); setResult(null) }}
144
+ onClick={() => { switchMode('text') }}
97
145
  >
98
146
  📄 B · txt/md 全本
99
147
  </button>
@@ -118,16 +166,51 @@ export function ImportModal({
118
166
  </>
119
167
  ) : (
120
168
  <>
121
- <label className={css.importField}>
122
- <span>全本文本文件(绝对路径,txt / md)</span>
123
- <input
124
- className={css.input}
125
- type="text"
126
- placeholder="例如 D:\novels\全本.txt"
127
- value={filePath}
128
- onChange={e => { setFilePath(e.target.value) }}
129
- />
130
- </label>
169
+ {/* 文件选择 + 预览 */}
170
+ <input
171
+ ref={fileInputRef}
172
+ type="file"
173
+ accept=".txt,.md,text/plain,text/markdown"
174
+ style={{ display: 'none' }}
175
+ onChange={e => { void onPickFile(e.target.files?.[0]); e.target.value = '' }}
176
+ />
177
+ <div className={css.importField}>
178
+ <span>全本文本文件(txt / md)</span>
179
+ <button
180
+ type="button"
181
+ className={`${css.button} ${css.buttonPrimary}`}
182
+ onClick={() => { fileInputRef.current?.click() }}
183
+ >
184
+ 📄 选择文件
185
+ </button>
186
+ </div>
187
+ {fileName !== '' && (
188
+ <div className={css.importFileInfo}>
189
+ <span>已选择:{fileName}</span>
190
+ {previewing && <span className={css.meta}>正在拆章预览…</span>}
191
+ </div>
192
+ )}
193
+ {preview !== null && (
194
+ <div className={css.importPreview}>
195
+ <span>📖 《{preview.bookName}》 · 识别到 {preview.chapters.length} 章</span>
196
+ {preview.chapters.length > 0 && (
197
+ <ul className={css.importPreviewList}>
198
+ {preview.chapters.slice(0, 5).map(c => (
199
+ <li key={c.no}>第{c.no}章 {c.title}({c.chars} 字)</li>
200
+ ))}
201
+ {preview.chapters.length > 5 && <li className={css.meta}>… 共 {preview.chapters.length} 章</li>}
202
+ </ul>
203
+ )}
204
+ {preview.skipped.length > 0 && (
205
+ <span className={css.meta}>
206
+ 跳过 {preview.skipped.length} 个过短章节:{preview.skipped.slice(0, 5).join('、')}
207
+ {preview.skipped.length > 5 ? ' 等' : ''}
208
+ </span>
209
+ )}
210
+ </div>
211
+ )}
212
+
213
+ {/* 输出目录 + 高级路径选项 */}
131
214
  <label className={css.importField}>
132
215
  <span>输出目录(可选,默认 ~/.dsh/novels/书名)</span>
133
216
  <input
@@ -138,8 +221,27 @@ export function ImportModal({
138
221
  onChange={e => { setOutDir(e.target.value) }}
139
222
  />
140
223
  </label>
224
+ <button
225
+ type="button"
226
+ className={`${css.button} ${css.buttonSmall}`}
227
+ onClick={() => { setShowPath(!showPath) }}
228
+ >
229
+ {showPath ? '▾ 收起高级选项' : '▸ 高级:使用服务器本地文件路径'}
230
+ </button>
231
+ {showPath && (
232
+ <label className={css.importField}>
233
+ <span>服务器本地文件绝对路径(大文件或服务器端已有文件时用)</span>
234
+ <input
235
+ className={css.input}
236
+ type="text"
237
+ placeholder="例如 D:\novels\全本.txt"
238
+ value={filePath}
239
+ onChange={e => { setFilePath(e.target.value) }}
240
+ />
241
+ </label>
242
+ )}
141
243
  <div className={css.importHint}>
142
- 服务器按「第X章 / 第X回 / 第X节 / 第X卷」(可带 # 前缀)拆章;正文过短(&lt;50 字)的章节会跳过并列出。
244
+ 服务器按「第X章/第X回/第X节/第X卷」、中文数字章节(一、二、三…)、序章/楔子/尾声、英文 Chapter N 拆章;正文过短(&lt;50 字)的章节会跳过并列出。
143
245
  </div>
144
246
  </>
145
247
  )}