@waterwx/dsh-novel-forge 0.1.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.
Files changed (37) hide show
  1. package/README.md +67 -25
  2. package/cordis.patch.yml +1 -1
  3. package/lib/client.js +4563 -1093
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +1138 -92
  6. package/lib/index.js.map +1 -1
  7. package/lib/types/assets.d.ts +2 -2
  8. package/lib/types/assistant.d.ts +2 -0
  9. package/lib/types/bookshelf.d.ts +2 -0
  10. package/lib/types/client/api.d.ts +61 -2
  11. package/lib/types/client/locales.d.ts +24 -24
  12. package/lib/types/client/panel/AssetsTab.d.ts +6 -1
  13. package/lib/types/client/panel/CreateBookView.d.ts +9 -0
  14. package/lib/types/client/panel/NovelPanel.d.ts +1 -1
  15. package/lib/types/client/panel/ShelfView.d.ts +11 -0
  16. package/lib/types/client/panel/WorldTab.d.ts +10 -0
  17. package/lib/types/engine.d.ts +67 -6
  18. package/lib/types/index.d.ts +1 -1
  19. package/lib/types/protocol.d.ts +177 -0
  20. package/package.json +1 -13
  21. package/src/assets.ts +9 -7
  22. package/src/assistant.ts +152 -32
  23. package/src/bookshelf.ts +14 -0
  24. package/src/client/api.ts +80 -3
  25. package/src/client/locales.ts +24 -24
  26. package/src/client/panel/AssetsTab.tsx +139 -41
  27. package/src/client/panel/AssistantTab.tsx +136 -23
  28. package/src/client/panel/BookshelfBar.tsx +62 -8
  29. package/src/client/panel/CreateBookView.tsx +189 -0
  30. package/src/client/panel/NovelPanel.tsx +1834 -201
  31. package/src/client/panel/ShelfView.tsx +112 -0
  32. package/src/client/panel/WorldTab.tsx +202 -0
  33. package/src/client/panel/panel.module.css +1210 -59
  34. package/src/engine.ts +618 -29
  35. package/src/index.ts +1 -1
  36. package/src/protocol.ts +189 -1
  37. package/src/routes.ts +511 -14
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * 书架条:显示所有书,点击切换当前书(继续编译),+ 新建。
3
+ * 新建 = 开书向导:书名 + 大纲(拖拽/选择 docx 或粘贴文本),创建即建项目。
3
4
  */
4
- import { useState } from 'react'
5
+ import { useRef, useState } from 'react'
5
6
  import type { NovelApi } from '../api.ts'
7
+ import { extractDocxTextFromBuffer } from '../docx.ts'
6
8
  import type { BookshelfSnapshot } from '../../protocol.ts'
7
9
  import css from './panel.module.css'
8
10
 
@@ -18,8 +20,28 @@ export interface BookshelfBarProps {
18
20
  export function BookshelfBar({ api, shelf, onSwitch }: BookshelfBarProps) {
19
21
  const [creating, setCreating] = useState(false)
20
22
  const [name, setName] = useState('')
23
+ const [outlineText, setOutlineText] = useState('')
24
+ const [outlineName, setOutlineName] = useState('')
21
25
  const [busy, setBusy] = useState(false)
22
26
  const [error, setError] = useState('')
27
+ const outlineFileRef = useRef<HTMLInputElement | null>(null)
28
+
29
+ const handlePickOutlineFile = async (file: File | undefined): Promise<void> => {
30
+ if (file === undefined) return
31
+ try {
32
+ const buffer = await file.arrayBuffer()
33
+ const text = extractDocxTextFromBuffer(buffer)
34
+ if (text.length < 50) {
35
+ setError('大纲内容过短(<50 字符),请检查文件')
36
+ return
37
+ }
38
+ setOutlineText(text)
39
+ setOutlineName(file.name)
40
+ setError('')
41
+ } catch (err) {
42
+ setError(`读取大纲失败:${(err as Error).message}`)
43
+ }
44
+ }
23
45
 
24
46
  const handleCreate = async (): Promise<void> => {
25
47
  const bookName = name.trim()
@@ -27,9 +49,11 @@ export function BookshelfBar({ api, shelf, onSwitch }: BookshelfBarProps) {
27
49
  setBusy(true)
28
50
  setError('')
29
51
  try {
30
- await api.bookCreate(bookName)
52
+ await api.bookCreate(bookName, undefined, outlineText.trim() !== '' ? outlineText.trim() : undefined)
31
53
  setCreating(false)
32
54
  setName('')
55
+ setOutlineText('')
56
+ setOutlineName('')
33
57
  onSwitch()
34
58
  } catch (err) {
35
59
  setError((err as Error).message)
@@ -101,12 +125,42 @@ export function BookshelfBar({ api, shelf, onSwitch }: BookshelfBarProps) {
101
125
  onKeyDown={e => { if (e.key === 'Enter') void handleCreate() }}
102
126
  autoFocus
103
127
  />
104
- <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy || name.trim() === ''} onClick={() => { void handleCreate() }}>
105
- 创建
106
- </button>
107
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setCreating(false) }}>
108
- 取消
109
- </button>
128
+ {/* 开书向导:大纲(可选,推荐) */}
129
+ <div className={css.bookCreateOutline}>
130
+ <button
131
+ type="button"
132
+ className={`${css.button} ${css.buttonSmall}`}
133
+ onClick={() => { outlineFileRef.current?.click() }}
134
+ >
135
+ {outlineName !== '' ? `✓ ${outlineName}` : '📄 选择大纲 docx'}
136
+ </button>
137
+ <input
138
+ ref={outlineFileRef}
139
+ type="file"
140
+ accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
141
+ style={{ display: 'none' }}
142
+ onChange={e => {
143
+ void handlePickOutlineFile(e.target.files?.[0])
144
+ e.target.value = ''
145
+ }}
146
+ />
147
+ <textarea
148
+ className={css.input}
149
+ style={{ width: 280, minHeight: 64, resize: 'vertical' }}
150
+ placeholder="或直接粘贴大纲文本(提供大纲后开书即建立项目,书名自动从大纲首行识别)"
151
+ value={outlineText}
152
+ onChange={e => { setOutlineText(e.target.value) }}
153
+ />
154
+ </div>
155
+ <div className={css.row}>
156
+ <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy || name.trim() === ''} onClick={() => { void handleCreate() }}>
157
+ 开书
158
+ </button>
159
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setCreating(false) }}>
160
+ 取消
161
+ </button>
162
+ <span className={css.meta}>{outlineText.length > 0 ? `大纲 ${outlineText.length} 字` : '未提供大纲(稍后在大纲页导入)'}</span>
163
+ </div>
110
164
  </div>
111
165
  )}
112
166
  </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
+ }