@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.
- package/lib/client.js +825 -564
- package/lib/client.js.map +1 -1
- package/lib/index.js +330 -58
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +7 -1
- package/lib/types/client/locales.d.ts +4 -2
- package/lib/types/engine.d.ts +27 -1
- package/lib/types/index.d.ts +2 -0
- package/lib/types/protocol.d.ts +45 -3
- package/package.json +1 -1
- package/src/client/api.ts +16 -1
- package/src/client/locales.ts +7 -3
- package/src/client/panel/ImportModal.tsx +123 -21
- package/src/client/panel/NovelPanel.tsx +221 -147
- package/src/client/panel/panel.module.css +77 -0
- package/src/engine.ts +210 -39
- package/src/index.ts +6 -0
- package/src/protocol.ts +36 -3
- package/src/routes.ts +88 -11
|
@@ -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': "
|
|
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': "经典毛玻璃(蓝)";
|
package/lib/types/engine.d.ts
CHANGED
|
@@ -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
|
|
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%)。
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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. */
|
package/lib/types/protocol.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
149
|
-
filePath
|
|
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.
|
|
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)
|
package/src/client/locales.ts
CHANGED
|
@@ -144,12 +144,14 @@ export const zh = {
|
|
|
144
144
|
'settings.model': '模型',
|
|
145
145
|
'settings.modelCustom': '自定义…',
|
|
146
146
|
'settings.modelCustomPlaceholder': '输入任意模型 id',
|
|
147
|
-
'settings.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': '
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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.
|
|
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={() => {
|
|
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={() => {
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
244
|
+
服务器按「第X章/第X回/第X节/第X卷」、中文数字章节(一、二、三…)、序章/楔子/尾声、英文 Chapter N 拆章;正文过短(<50 字)的章节会跳过并列出。
|
|
143
245
|
</div>
|
|
144
246
|
</>
|
|
145
247
|
)}
|