@waterwx/dsh-novel-forge 1.0.0 → 1.1.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/README.md +23 -7
- package/lib/client.js +3595 -2265
- package/lib/client.js.map +1 -1
- package/lib/index.js +724 -77
- package/lib/index.js.map +1 -1
- package/lib/types/assets.d.ts +2 -2
- package/lib/types/assistant.d.ts +2 -0
- package/lib/types/client/api.d.ts +29 -0
- package/lib/types/client/locales.d.ts +24 -24
- package/lib/types/client/panel/CreateBookView.d.ts +9 -0
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/client/panel/ShelfView.d.ts +11 -0
- package/lib/types/client/panel/WorldTab.d.ts +10 -0
- package/lib/types/engine.d.ts +44 -2
- package/lib/types/index.d.ts +1 -1
- package/lib/types/protocol.d.ts +95 -0
- package/package.json +1 -1
- package/src/assets.ts +9 -7
- package/src/assistant.ts +133 -34
- package/src/bookshelf.ts +3 -0
- package/src/client/api.ts +42 -0
- package/src/client/locales.ts +24 -24
- package/src/client/panel/AssetsTab.tsx +11 -28
- package/src/client/panel/AssistantTab.tsx +136 -23
- package/src/client/panel/CreateBookView.tsx +189 -0
- package/src/client/panel/NovelPanel.tsx +516 -52
- package/src/client/panel/ShelfView.tsx +112 -0
- package/src/client/panel/WorldTab.tsx +202 -0
- package/src/client/panel/panel.module.css +347 -0
- package/src/engine.ts +395 -20
- package/src/index.ts +1 -1
- package/src/protocol.ts +105 -1
- package/src/routes.ts +295 -15
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* AI
|
|
3
|
-
* 大纲 / 设定圣经 /
|
|
2
|
+
* AI 助手(编辑老师):与 AI 编辑对话讨论剧情,助手通过动作指令直接修改
|
|
3
|
+
* 大纲 / 设定圣经 / 章节。流式渲染回复;工具调用显示为步骤卡片(中文名 +
|
|
4
|
+
* 状态 + 耗时),顶部状态条展示当前在做什么;支持清空对话记录。
|
|
4
5
|
*/
|
|
5
6
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
6
7
|
import type { NovelApi } from '../api.ts'
|
|
@@ -8,13 +9,35 @@ import { tt } from './helpers.ts'
|
|
|
8
9
|
import type { AssistantFrame, AssistantMessage } from '../../protocol.ts'
|
|
9
10
|
import css from './panel.module.css'
|
|
10
11
|
|
|
12
|
+
/** 工具中文名 + 图标映射(作者看得懂)。 */
|
|
13
|
+
const TOOL_LABELS: Record<string, { icon: string; label: string }> = {
|
|
14
|
+
book_overview: { icon: '📖', label: '读取全书上下文' },
|
|
15
|
+
impact_analysis: { icon: '🔗', label: '影响分析' },
|
|
16
|
+
outline_text: { icon: '📄', label: '读取大纲' },
|
|
17
|
+
outline_replace: { icon: '📝', label: '修改大纲' },
|
|
18
|
+
bible_set_rule: { icon: '📖', label: '修改设定规则' },
|
|
19
|
+
bible_set_redline: { icon: '🚫', label: '修改写作红线' },
|
|
20
|
+
chapter_text: { icon: '📄', label: '读取章节' },
|
|
21
|
+
chapter_rewrite: { icon: '✏️', label: '修订章节' },
|
|
22
|
+
chapter_generate: { icon: '✨', label: '生成章节' },
|
|
23
|
+
chapter_review: { icon: '🔍', label: 'AI 审稿' },
|
|
24
|
+
foreshadow_add: { icon: '🪤', label: '新增伏笔' },
|
|
25
|
+
foreshadow_update: { icon: '🪤', label: '更新伏笔' },
|
|
26
|
+
export_txt: { icon: '📦', label: '导出 TXT' },
|
|
27
|
+
assets_status: { icon: '🎨', label: '查看写作资产' },
|
|
28
|
+
assets_set_genre: { icon: '🏷️', label: '设置题材' },
|
|
29
|
+
assets_set_progression: { icon: '📈', label: '设置推进模式' },
|
|
30
|
+
assets_add_rule: { icon: '🚫', label: '新增反AI规则' },
|
|
31
|
+
error: { icon: '⚠️', label: '出错了' },
|
|
32
|
+
}
|
|
33
|
+
|
|
11
34
|
/** One chat bubble (either side). */
|
|
12
35
|
interface ChatLine {
|
|
13
36
|
id: number
|
|
14
37
|
role: 'user' | 'assistant'
|
|
15
38
|
text: string
|
|
16
|
-
/** Tool
|
|
17
|
-
tools: Array<{ name: string; status: 'start' | 'done' | 'error'; detail?: string }>
|
|
39
|
+
/** Tool steps interleaved with the assistant reply. */
|
|
40
|
+
tools: Array<{ name: string; status: 'start' | 'done' | 'error'; detail?: string; startedAt?: number; elapsedMs?: number }>
|
|
18
41
|
/** Live output while a tool runs (generated text streamed into the bubble). */
|
|
19
42
|
live?: string
|
|
20
43
|
}
|
|
@@ -30,9 +53,25 @@ export function AssistantTab({ api }: AssistantTabProps) {
|
|
|
30
53
|
const [input, setInput] = useState('')
|
|
31
54
|
const [busy, setBusy] = useState(false)
|
|
32
55
|
const [error, setError] = useState('')
|
|
56
|
+
const [notice, setNotice] = useState('')
|
|
57
|
+
/** 思考耗时(秒,busy 时每秒刷新)。 */
|
|
58
|
+
const [thinkSeconds, setThinkSeconds] = useState(0)
|
|
33
59
|
const idRef = useRef(0)
|
|
34
60
|
const scrollRef = useRef<HTMLDivElement | null>(null)
|
|
35
61
|
|
|
62
|
+
/** busy 计时:显示"已思考 Xs",让作者知道在干活。 */
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!busy) {
|
|
65
|
+
setThinkSeconds(0)
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
const started = Date.now()
|
|
69
|
+
const timer = window.setInterval(() => {
|
|
70
|
+
setThinkSeconds(Math.floor((Date.now() - started) / 1000))
|
|
71
|
+
}, 1000)
|
|
72
|
+
return () => { window.clearInterval(timer) }
|
|
73
|
+
}, [busy])
|
|
74
|
+
|
|
36
75
|
/** Append a bubble (or extend the current assistant bubble). */
|
|
37
76
|
const pushLine = useCallback((line: Omit<ChatLine, 'id'>) => {
|
|
38
77
|
setLines(prev => {
|
|
@@ -45,14 +84,37 @@ export function AssistantTab({ api }: AssistantTabProps) {
|
|
|
45
84
|
})
|
|
46
85
|
}, [])
|
|
47
86
|
|
|
48
|
-
/** Push a tool event
|
|
87
|
+
/** Push a tool event: start 创建步骤,done/error 更新同名步骤(含耗时)。 */
|
|
49
88
|
const pushTool = useCallback((tool: ChatLine['tools'][number]) => {
|
|
50
89
|
setLines(prev => {
|
|
51
90
|
const last = prev[prev.length - 1]
|
|
52
91
|
if (last === undefined || last.role !== 'assistant') {
|
|
53
|
-
return [...prev, {
|
|
92
|
+
return [...prev, {
|
|
93
|
+
id: idRef.current++,
|
|
94
|
+
role: 'assistant',
|
|
95
|
+
text: '',
|
|
96
|
+
tools: [{ ...tool, startedAt: tool.status === 'start' ? Date.now() : undefined }],
|
|
97
|
+
}]
|
|
54
98
|
}
|
|
55
|
-
|
|
99
|
+
const tools = [...last.tools]
|
|
100
|
+
if (tool.status === 'start') {
|
|
101
|
+
tools.push({ ...tool, startedAt: Date.now() })
|
|
102
|
+
} else {
|
|
103
|
+
// 找到同名未完成的步骤,更新状态与耗时。
|
|
104
|
+
for (let i = tools.length - 1; i >= 0; i--) {
|
|
105
|
+
const step = tools[i]!
|
|
106
|
+
if (step.name === tool.name && step.status === 'start') {
|
|
107
|
+
tools[i] = {
|
|
108
|
+
...step,
|
|
109
|
+
status: tool.status,
|
|
110
|
+
detail: tool.detail,
|
|
111
|
+
elapsedMs: Date.now() - (step.startedAt ?? Date.now()),
|
|
112
|
+
}
|
|
113
|
+
break
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return [...prev.slice(0, -1), { ...last, tools, live: undefined }]
|
|
56
118
|
})
|
|
57
119
|
}, [])
|
|
58
120
|
|
|
@@ -65,6 +127,19 @@ export function AssistantTab({ api }: AssistantTabProps) {
|
|
|
65
127
|
})
|
|
66
128
|
}, [])
|
|
67
129
|
|
|
130
|
+
/** 当前进行中的工具(状态条显示)。 */
|
|
131
|
+
const activeTool = (() => {
|
|
132
|
+
if (!busy) return null
|
|
133
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
134
|
+
const line = lines[i]!
|
|
135
|
+
if (line.role !== 'assistant') continue
|
|
136
|
+
for (let j = line.tools.length - 1; j >= 0; j--) {
|
|
137
|
+
if (line.tools[j]!.status === 'start') return line.tools[j]!
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return null
|
|
141
|
+
})()
|
|
142
|
+
|
|
68
143
|
/** Load persisted history on mount. */
|
|
69
144
|
useEffect(() => {
|
|
70
145
|
let cancelled = false
|
|
@@ -78,11 +153,6 @@ export function AssistantTab({ api }: AssistantTabProps) {
|
|
|
78
153
|
restored.push({ id: idRef.current++, role: 'user', text: entry.content, tools: [] })
|
|
79
154
|
} else if (entry.role === 'assistant') {
|
|
80
155
|
restored.push({ id: idRef.current++, role: 'assistant', text: entry.content, tools: [] })
|
|
81
|
-
} else if (entry.role === 'tool') {
|
|
82
|
-
const last = restored[restored.length - 1]
|
|
83
|
-
if (last !== undefined && last.role === 'assistant') {
|
|
84
|
-
last.tools.push({ name: entry.tool ?? 'tool', status: 'done', detail: entry.content.slice(0, 120) })
|
|
85
|
-
}
|
|
86
156
|
}
|
|
87
157
|
}
|
|
88
158
|
setLines(restored)
|
|
@@ -98,12 +168,29 @@ export function AssistantTab({ api }: AssistantTabProps) {
|
|
|
98
168
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
|
|
99
169
|
}, [lines])
|
|
100
170
|
|
|
171
|
+
/** 清空对话记录(服务端删除历史 + 本地清空)。 */
|
|
172
|
+
const handleClear = async (): Promise<void> => {
|
|
173
|
+
if (!window.confirm('清空全部聊天记录?此操作不可恢复(不影响大纲/设定/章节)。')) return
|
|
174
|
+
setBusy(true)
|
|
175
|
+
setError('')
|
|
176
|
+
try {
|
|
177
|
+
await api.assistantClear()
|
|
178
|
+
setLines([])
|
|
179
|
+
setNotice('对话已清空,编辑老师会重新了解项目')
|
|
180
|
+
} catch (err) {
|
|
181
|
+
setError((err as Error).message)
|
|
182
|
+
} finally {
|
|
183
|
+
setBusy(false)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
101
187
|
/** Send one message. */
|
|
102
188
|
const handleSend = async (): Promise<void> => {
|
|
103
189
|
const message = input.trim()
|
|
104
190
|
if (message === '' || busy) return
|
|
105
191
|
setInput('')
|
|
106
192
|
setError('')
|
|
193
|
+
setNotice('')
|
|
107
194
|
pushLine({ role: 'user', text: message, tools: [] })
|
|
108
195
|
// Start an empty assistant bubble.
|
|
109
196
|
setLines(prev => [...prev, { id: idRef.current++, role: 'assistant', text: '', tools: [] }])
|
|
@@ -133,9 +220,21 @@ export function AssistantTab({ api }: AssistantTabProps) {
|
|
|
133
220
|
|
|
134
221
|
return (
|
|
135
222
|
<div className={css.card} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
|
136
|
-
<
|
|
223
|
+
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
224
|
+
<span className={css.cardTitle}>{tt('tab.assistant')}</span>
|
|
225
|
+
<button type="button" className={css.iconButton} title="清空聊天记录" aria-label="清空聊天记录" onClick={() => { void handleClear() }}>
|
|
226
|
+
🗑️
|
|
227
|
+
</button>
|
|
228
|
+
</div>
|
|
137
229
|
<span className={css.meta}>{tt('assistant.hint')}</span>
|
|
230
|
+
{notice !== '' && <span style={{ color: 'var(--nf-success)', fontSize: 12 }}>{notice}</span>}
|
|
138
231
|
{error !== '' && <span style={{ color: 'var(--nf-error)', fontSize: 12 }}>{tt('common.error')}: {error}</span>}
|
|
232
|
+
{/* 状态条:编辑老师正在做什么 */}
|
|
233
|
+
<div className={`${css.assistantStatus} ${busy ? css.assistantStatusBusy : ''}`}>
|
|
234
|
+
{busy
|
|
235
|
+
? `🤖 编辑老师 · ${activeTool !== null ? `正在「${TOOL_LABELS[activeTool.name]?.label ?? activeTool.name}」` : `正在思考…(已 ${thinkSeconds}s)`}`
|
|
236
|
+
: '💬 编辑老师 · 等你开口'}
|
|
237
|
+
</div>
|
|
139
238
|
<div
|
|
140
239
|
ref={scrollRef}
|
|
141
240
|
className={css.chatScroll}
|
|
@@ -148,17 +247,31 @@ export function AssistantTab({ api }: AssistantTabProps) {
|
|
|
148
247
|
{line.live !== undefined && line.live !== '' && (
|
|
149
248
|
<div className={css.toolLive}>{line.live}</div>
|
|
150
249
|
)}
|
|
250
|
+
{/* 步骤卡片 */}
|
|
151
251
|
{line.tools.length > 0 && (
|
|
152
|
-
<div
|
|
153
|
-
{line.tools.map((tool, i) =>
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
252
|
+
<div className={css.toolSteps}>
|
|
253
|
+
{line.tools.map((tool, i) => {
|
|
254
|
+
const meta = TOOL_LABELS[tool.name] ?? { icon: '⚙️', label: tool.name }
|
|
255
|
+
return (
|
|
256
|
+
<div
|
|
257
|
+
key={i}
|
|
258
|
+
className={`${css.toolStep} ${tool.status === 'error' ? css.toolStepError : tool.status === 'start' ? css.toolStepActive : ''}`}
|
|
259
|
+
>
|
|
260
|
+
<span className={css.toolStepIcon}>{meta.icon}</span>
|
|
261
|
+
<span className={css.toolStepName}>{meta.label}</span>
|
|
262
|
+
<span className={css.toolStepStatus}>
|
|
263
|
+
{tool.status === 'start'
|
|
264
|
+
? '⏳ 进行中'
|
|
265
|
+
: tool.status === 'done'
|
|
266
|
+
? `✓ ${tool.elapsedMs !== undefined ? `${(tool.elapsedMs / 1000).toFixed(1)}s` : ''}`
|
|
267
|
+
: '✗ 失败'}
|
|
268
|
+
</span>
|
|
269
|
+
{tool.status === 'error' && tool.detail !== undefined && (
|
|
270
|
+
<span className={css.toolStepDetail}>{tool.detail}</span>
|
|
271
|
+
)}
|
|
272
|
+
</div>
|
|
273
|
+
)
|
|
274
|
+
})}
|
|
162
275
|
</div>
|
|
163
276
|
)}
|
|
164
277
|
</div>
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 开书向导:独立页面视图 —— 书名 + 大纲(选择 docx / 拖拽 / 粘贴),
|
|
3
|
+
* 实时书名识别与题材提示,开书即建项目并进入工作台。
|
|
4
|
+
*/
|
|
5
|
+
import { useRef, useState } from 'react'
|
|
6
|
+
import type { NovelApi } from '../api.ts'
|
|
7
|
+
import { extractDocxTextFromBuffer } from '../docx.ts'
|
|
8
|
+
import css from './panel.module.css'
|
|
9
|
+
|
|
10
|
+
/** 从大纲首行推断书名(与服务端 inferBookName 一致,供实时预览)。 */
|
|
11
|
+
function inferBookNamePreview(outline: string): string {
|
|
12
|
+
const line = outline.split('\n').map(l => l.trim()).find(l => l.length > 0)
|
|
13
|
+
if (line === undefined) return ''
|
|
14
|
+
return line.replace(/^《/, '').replace(/》.*$/, '').slice(0, 40)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 简单题材识别(提示用)。 */
|
|
18
|
+
function guessGenre(outline: string): string | null {
|
|
19
|
+
const map: Array<[string, string[]]> = [
|
|
20
|
+
['仙侠修真', ['仙', '修', '灵根', '元婴', '宗门', '飞升']],
|
|
21
|
+
['都市', ['都市', '公司', '外卖', '职场', '总裁']],
|
|
22
|
+
['玄幻', ['斗气', '魂力', '大陆', '斗罗', '神']],
|
|
23
|
+
['悬疑', ['悬疑', '密室', '案件', '推理', '凶']],
|
|
24
|
+
['科幻', ['机甲', '星舰', 'AI', '未来', '星际']],
|
|
25
|
+
['历史', ['朝代', '皇帝', '将军', '古代', '王朝']],
|
|
26
|
+
['游戏', ['游戏', '副本', '装备', '等级', '职业']],
|
|
27
|
+
]
|
|
28
|
+
for (const [genre, keywords] of map) {
|
|
29
|
+
if (keywords.some(k => outline.includes(k))) return genre
|
|
30
|
+
}
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 开书向导页。 */
|
|
35
|
+
export function CreateBookView({
|
|
36
|
+
api,
|
|
37
|
+
onBack,
|
|
38
|
+
onCreated,
|
|
39
|
+
}: {
|
|
40
|
+
api: NovelApi
|
|
41
|
+
/** 返回书架。 */
|
|
42
|
+
onBack: () => void
|
|
43
|
+
/** 开书成功:进入新书工作台。 */
|
|
44
|
+
onCreated: (id: string) => void
|
|
45
|
+
}) {
|
|
46
|
+
const [name, setName] = useState('')
|
|
47
|
+
const [outlineText, setOutlineText] = useState('')
|
|
48
|
+
const [outlineName, setOutlineName] = useState('')
|
|
49
|
+
const [dragActive, setDragActive] = useState(false)
|
|
50
|
+
const [busy, setBusy] = useState(false)
|
|
51
|
+
const [error, setError] = useState('')
|
|
52
|
+
const outlineFileRef = useRef<HTMLInputElement | null>(null)
|
|
53
|
+
|
|
54
|
+
const autoName = inferBookNamePreview(outlineText)
|
|
55
|
+
const effectiveName = name.trim() !== '' ? name.trim() : autoName
|
|
56
|
+
const genre = guessGenre(outlineText)
|
|
57
|
+
|
|
58
|
+
const handlePickOutlineFile = async (file: File | undefined): Promise<void> => {
|
|
59
|
+
if (file === undefined) return
|
|
60
|
+
try {
|
|
61
|
+
const buffer = await file.arrayBuffer()
|
|
62
|
+
const text = extractDocxTextFromBuffer(buffer)
|
|
63
|
+
if (text.length < 50) {
|
|
64
|
+
setError('大纲内容过短(<50 字符),请检查文件')
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
setOutlineText(text)
|
|
68
|
+
setOutlineName(file.name)
|
|
69
|
+
if (name.trim() === '') setName(inferBookNamePreview(text))
|
|
70
|
+
setError('')
|
|
71
|
+
} catch (err) {
|
|
72
|
+
setError(`读取大纲失败:${(err as Error).message}`)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const handleCreate = async (): Promise<void> => {
|
|
77
|
+
if (effectiveName === '') {
|
|
78
|
+
setError('请填写书名,或提供大纲自动识别')
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
setBusy(true)
|
|
82
|
+
setError('')
|
|
83
|
+
try {
|
|
84
|
+
const snapshot = await api.bookCreate(effectiveName, undefined, outlineText.trim() !== '' ? outlineText.trim() : undefined)
|
|
85
|
+
const created = snapshot.books.find(b => b.id === snapshot.activeBookId)
|
|
86
|
+
if (created !== undefined) {
|
|
87
|
+
onCreated(created.id)
|
|
88
|
+
} else {
|
|
89
|
+
setError('开书失败:未找到新书')
|
|
90
|
+
}
|
|
91
|
+
} catch (err) {
|
|
92
|
+
setError((err as Error).message)
|
|
93
|
+
} finally {
|
|
94
|
+
setBusy(false)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div className={css.createBookView}>
|
|
100
|
+
<div className={css.createBookTop}>
|
|
101
|
+
<button type="button" className={css.iconButton} title="返回书架" aria-label="返回书架" onClick={onBack}>
|
|
102
|
+
← 书架
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<div className={css.createBookCard}>
|
|
107
|
+
<span className={css.createBookIcon}>✒️</span>
|
|
108
|
+
<h2 className={css.createBookTitle}>开书向导</h2>
|
|
109
|
+
<span className={css.meta}>把一份大纲「编译」成一本完整的小说</span>
|
|
110
|
+
|
|
111
|
+
{error !== '' && (
|
|
112
|
+
<div className={css.card} style={{ borderColor: 'var(--nf-error)', padding: '8px 12px' }}>
|
|
113
|
+
<span style={{ color: 'var(--nf-error)', fontSize: 12 }}>{error}</span>
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
|
|
117
|
+
<div className={css.field}>
|
|
118
|
+
<label className={css.fieldLabel}>书名</label>
|
|
119
|
+
<input
|
|
120
|
+
className={css.input}
|
|
121
|
+
placeholder={autoName !== '' ? `自动识别:${autoName}` : '输入书名(提供大纲后自动识别)'}
|
|
122
|
+
value={name}
|
|
123
|
+
onChange={e => { setName(e.target.value) }}
|
|
124
|
+
onKeyDown={e => { if (e.key === 'Enter') void handleCreate() }}
|
|
125
|
+
autoFocus
|
|
126
|
+
/>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<div className={css.field}>
|
|
130
|
+
<label className={css.fieldLabel}>大纲</label>
|
|
131
|
+
<div
|
|
132
|
+
className={`${css.dropzone} ${dragActive ? css.dropzoneActive : ''}`}
|
|
133
|
+
onClick={() => { outlineFileRef.current?.click() }}
|
|
134
|
+
onDragOver={e => { e.preventDefault(); setDragActive(true) }}
|
|
135
|
+
onDragLeave={() => { setDragActive(false) }}
|
|
136
|
+
onDrop={e => {
|
|
137
|
+
e.preventDefault()
|
|
138
|
+
setDragActive(false)
|
|
139
|
+
void handlePickOutlineFile(e.dataTransfer.files?.[0])
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
<span className={css.dropzoneIcon}>📄</span>
|
|
143
|
+
<span>{outlineName !== '' ? `已选择:${outlineName}` : '点击选择 docx 大纲,或将文件拖到这里'}</span>
|
|
144
|
+
<span className={css.meta}>推荐提供大纲:开书即建立项目,书名自动识别</span>
|
|
145
|
+
<input
|
|
146
|
+
ref={outlineFileRef}
|
|
147
|
+
type="file"
|
|
148
|
+
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
149
|
+
style={{ display: 'none' }}
|
|
150
|
+
onChange={e => {
|
|
151
|
+
void handlePickOutlineFile(e.target.files?.[0])
|
|
152
|
+
e.target.value = ''
|
|
153
|
+
}}
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
<textarea
|
|
157
|
+
className={css.textarea}
|
|
158
|
+
style={{ minHeight: 130 }}
|
|
159
|
+
placeholder="或直接粘贴大纲文本(50 字以上)…"
|
|
160
|
+
value={outlineText}
|
|
161
|
+
onChange={e => { setOutlineText(e.target.value) }}
|
|
162
|
+
spellCheck={false}
|
|
163
|
+
/>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
{(outlineText.trim().length > 0 || effectiveName !== '') && (
|
|
167
|
+
<div className={css.row} style={{ flexWrap: 'wrap' }}>
|
|
168
|
+
{outlineText.trim().length > 0 && <span className={css.meta}>大纲 {outlineText.length} 字</span>}
|
|
169
|
+
{effectiveName !== '' && <span className={css.meta}>书名:{effectiveName}</span>}
|
|
170
|
+
{genre !== null && <span className={css.meta}>题材:{genre}</span>}
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
className={`${css.button} ${css.buttonPrimary}`}
|
|
177
|
+
style={{ width: '100%', padding: '10px 0', fontSize: 14 }}
|
|
178
|
+
disabled={busy || effectiveName === ''}
|
|
179
|
+
onClick={() => { void handleCreate() }}
|
|
180
|
+
>
|
|
181
|
+
✨ 开书并进入工作台
|
|
182
|
+
</button>
|
|
183
|
+
<span className={css.meta} style={{ textAlign: 'center' }}>
|
|
184
|
+
未提供大纲也能开书,稍后可在大纲页导入
|
|
185
|
+
</span>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
)
|
|
189
|
+
}
|