@waterwx/dsh-novel-forge 1.3.2 → 1.4.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 +2 -2
- package/lib/client.js +548 -267
- package/lib/client.js.map +1 -1
- package/lib/index.js +183 -9
- package/lib/index.js.map +1 -1
- package/lib/types/bookshelf.d.ts +6 -1
- package/lib/types/client/api.d.ts +4 -0
- package/lib/types/client/panel/ImportModal.d.ts +7 -0
- package/lib/types/client/panel/ShelfView.d.ts +3 -1
- package/lib/types/engine.d.ts +6 -0
- package/lib/types/index.d.ts +1 -1
- package/lib/types/protocol.d.ts +32 -0
- package/package.json +97 -97
- package/src/bookshelf.ts +18 -2
- package/src/client/api.ts +420 -410
- package/src/client/panel/ImportModal.tsx +191 -0
- package/src/client/panel/NovelPanel.tsx +5058 -5046
- package/src/client/panel/ShelfView.tsx +249 -236
- package/src/client/panel/panel.module.css +3037 -2941
- package/src/engine.ts +3001 -2943
- package/src/index.ts +7 -5
- package/src/protocol.ts +1244 -1207
- package/src/routes.ts +2332 -2272
- package/scripts/assistant-frames.txt +0 -8
- package/scripts/diagnose-adapter.mts +0 -81
- package/scripts/diagnose-assistant.mjs +0 -58
- package/scripts/diagnose-bible.mjs +0 -72
- package/scripts/diagnose-harness.mts +0 -75
- package/scripts/novel-forge-restart.log +0 -10
- package/scripts/novel-forge-web.stderr.log +0 -2
- package/scripts/novel-forge-web.stdout.log +0 -1
- package/scripts/outline-sample.txt +0 -449
- package/scripts/probe.mjs +0 -6
- package/scripts/restart-web.ps1 +0 -88
- package/scripts/smoke.mts +0 -72
- package/scripts/upload-github.ps1 +0 -51
|
@@ -1,236 +1,249 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 书架首页视图:书卡网格(封面/书名/简介/进度)+ 搜索筛选 + 开书入口。
|
|
3
|
-
* 进入小说工坊默认展示;点击书卡进入该书工作台,+ 进入开书向导页。
|
|
4
|
-
*/
|
|
5
|
-
import { useEffect, useMemo, useState } from 'react'
|
|
6
|
-
import type { NovelApi } from '../api.ts'
|
|
7
|
-
import type { BookshelfSnapshot } from '../../protocol.ts'
|
|
8
|
-
import css from './panel.module.css'
|
|
9
|
-
|
|
10
|
-
/** 相对时间(人性化:刚刚 / N 分钟前 / N 小时前 / N 天前 / 日期)。 */
|
|
11
|
-
function relativeTime(iso: string): string {
|
|
12
|
-
const t = new Date(iso).getTime()
|
|
13
|
-
if (!Number.isFinite(t)) return '—'
|
|
14
|
-
const diff = Date.now() - t
|
|
15
|
-
if (diff < 60_000) return '刚刚'
|
|
16
|
-
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`
|
|
17
|
-
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`
|
|
18
|
-
if (diff < 7 * 86_400_000) return `${Math.floor(diff / 86_400_000)} 天前`
|
|
19
|
-
return new Date(t).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** 一本书的状态标签。 */
|
|
23
|
-
function bookStatus(book: BookshelfSnapshot['books'][number]): 'none' | 'done' | 'active' {
|
|
24
|
-
if (!book.hasProject) return 'none'
|
|
25
|
-
return book.total > 0 && book.done >= book.total ? 'done' : 'active'
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** 一张书卡(封面懒加载)。 */
|
|
29
|
-
function BookCard({
|
|
30
|
-
api,
|
|
31
|
-
book,
|
|
32
|
-
active,
|
|
33
|
-
onOpen,
|
|
34
|
-
onRead,
|
|
35
|
-
}: {
|
|
36
|
-
api: NovelApi
|
|
37
|
-
book: BookshelfSnapshot['books'][number]
|
|
38
|
-
active: boolean
|
|
39
|
-
onOpen: () => void
|
|
40
|
-
onRead: () => void
|
|
41
|
-
}) {
|
|
42
|
-
const [cover, setCover] = useState<string | null>(null)
|
|
43
|
-
|
|
44
|
-
useEffect(() => {
|
|
45
|
-
if (!book.hasCover) return
|
|
46
|
-
let cancelled = false
|
|
47
|
-
void api.coverGet(book.outputDir).then(result => {
|
|
48
|
-
if (!cancelled) setCover(result.dataUrl)
|
|
49
|
-
}).catch(() => { /* best-effort */ })
|
|
50
|
-
return () => { cancelled = true }
|
|
51
|
-
}, [api, book.hasCover, book.outputDir])
|
|
52
|
-
|
|
53
|
-
const ratio = book.total > 0 ? Math.min(book.done / book.total, 1) : 0
|
|
54
|
-
const status = bookStatus(book)
|
|
55
|
-
const statusLabel = status === 'none' ? '未开书' : status === 'done' ? '已完结' : '进行中'
|
|
56
|
-
|
|
57
|
-
return (
|
|
58
|
-
<div
|
|
59
|
-
className={`${css.bookCard} ${active ? css.bookCardActive : ''}`}
|
|
60
|
-
onClick={onOpen}
|
|
61
|
-
title={`打开《${book.bookName}》`}
|
|
62
|
-
>
|
|
63
|
-
<div className={css.bookCardCover}>
|
|
64
|
-
{cover !== null ? (
|
|
65
|
-
<img src={cover} alt={`《${book.bookName}》封面`} />
|
|
66
|
-
) : (
|
|
67
|
-
<div className={css.bookCardCoverFallback}>
|
|
68
|
-
<span className={css.bookCardCoverTitle}>{book.bookName.slice(0, 4)}</span>
|
|
69
|
-
<span className={css.meta}>暂无封面</span>
|
|
70
|
-
</div>
|
|
71
|
-
)}
|
|
72
|
-
</div>
|
|
73
|
-
<div className={css.bookCardBody}>
|
|
74
|
-
<div className={css.bookCardTitleRow}>
|
|
75
|
-
<span className={css.bookCardName}>{book.bookName}</span>
|
|
76
|
-
<span className={`${css.badge} ${status === 'none' ? css.badgePending : status === 'done' ? css.badgeDone : css.badgeWritten}`}>
|
|
77
|
-
{statusLabel}
|
|
78
|
-
</span>
|
|
79
|
-
</div>
|
|
80
|
-
<span className={`${css.meta} ${css.bookCardBlurb}`} title={book.blurb ?? ''}>
|
|
81
|
-
{book.blurb !== undefined && book.blurb !== '' ? book.blurb : '暂无简介'}
|
|
82
|
-
</span>
|
|
83
|
-
<div className={css.bookCardProgressBar}>
|
|
84
|
-
<div className={css.bookCardProgressFill} style={{ width: `${Math.round(ratio * 100)}%` }} />
|
|
85
|
-
</div>
|
|
86
|
-
<div className={css.bookCardMetaRow}>
|
|
87
|
-
<span className={css.meta}>{book.total > 0 ? `已完成 ${book.done} / ${book.total} 章` : '尚未规划章节'}</span>
|
|
88
|
-
{book.hasProject && <span className={css.meta} title="最近活动时间">更新于 {relativeTime(book.updatedAt)}</span>}
|
|
89
|
-
</div>
|
|
90
|
-
<div className={css.bookCardActions} onClick={e => { e.stopPropagation() }}>
|
|
91
|
-
<button
|
|
92
|
-
type="button"
|
|
93
|
-
className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
|
|
94
|
-
onClick={onOpen}
|
|
95
|
-
title="进入创作工作台(大纲/章节/审稿/设定)"
|
|
96
|
-
>
|
|
97
|
-
📝 进入工作台
|
|
98
|
-
</button>
|
|
99
|
-
<button
|
|
100
|
-
type="button"
|
|
101
|
-
className={`${css.button} ${css.buttonSmall}`}
|
|
102
|
-
disabled={!book.hasProject || book.done === 0}
|
|
103
|
-
onClick={onRead}
|
|
104
|
-
title={book.hasProject && book.done > 0 ? '沉浸式阅读已写章节' : '尚无已写章节,先去工作台创作'}
|
|
105
|
-
>
|
|
106
|
-
📖 阅读
|
|
107
|
-
</button>
|
|
108
|
-
</div>
|
|
109
|
-
</div>
|
|
110
|
-
</div>
|
|
111
|
-
)
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** 书架首页。 */
|
|
115
|
-
export function ShelfView({
|
|
116
|
-
api,
|
|
117
|
-
shelf,
|
|
118
|
-
onOpenBook,
|
|
119
|
-
onReadBook,
|
|
120
|
-
onAddBook,
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
{ id: '
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
{
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
<
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
<
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
</div>
|
|
213
|
-
) : (
|
|
214
|
-
<div className={css.
|
|
215
|
-
{
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* 书架首页视图:书卡网格(封面/书名/简介/进度)+ 搜索筛选 + 开书入口。
|
|
3
|
+
* 进入小说工坊默认展示;点击书卡进入该书工作台,+ 进入开书向导页。
|
|
4
|
+
*/
|
|
5
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
6
|
+
import type { NovelApi } from '../api.ts'
|
|
7
|
+
import type { BookshelfSnapshot } from '../../protocol.ts'
|
|
8
|
+
import css from './panel.module.css'
|
|
9
|
+
|
|
10
|
+
/** 相对时间(人性化:刚刚 / N 分钟前 / N 小时前 / N 天前 / 日期)。 */
|
|
11
|
+
function relativeTime(iso: string): string {
|
|
12
|
+
const t = new Date(iso).getTime()
|
|
13
|
+
if (!Number.isFinite(t)) return '—'
|
|
14
|
+
const diff = Date.now() - t
|
|
15
|
+
if (diff < 60_000) return '刚刚'
|
|
16
|
+
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`
|
|
17
|
+
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`
|
|
18
|
+
if (diff < 7 * 86_400_000) return `${Math.floor(diff / 86_400_000)} 天前`
|
|
19
|
+
return new Date(t).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 一本书的状态标签。 */
|
|
23
|
+
function bookStatus(book: BookshelfSnapshot['books'][number]): 'none' | 'done' | 'active' {
|
|
24
|
+
if (!book.hasProject) return 'none'
|
|
25
|
+
return book.total > 0 && book.done >= book.total ? 'done' : 'active'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 一张书卡(封面懒加载)。 */
|
|
29
|
+
function BookCard({
|
|
30
|
+
api,
|
|
31
|
+
book,
|
|
32
|
+
active,
|
|
33
|
+
onOpen,
|
|
34
|
+
onRead,
|
|
35
|
+
}: {
|
|
36
|
+
api: NovelApi
|
|
37
|
+
book: BookshelfSnapshot['books'][number]
|
|
38
|
+
active: boolean
|
|
39
|
+
onOpen: () => void
|
|
40
|
+
onRead: () => void
|
|
41
|
+
}) {
|
|
42
|
+
const [cover, setCover] = useState<string | null>(null)
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!book.hasCover) return
|
|
46
|
+
let cancelled = false
|
|
47
|
+
void api.coverGet(book.outputDir).then(result => {
|
|
48
|
+
if (!cancelled) setCover(result.dataUrl)
|
|
49
|
+
}).catch(() => { /* best-effort */ })
|
|
50
|
+
return () => { cancelled = true }
|
|
51
|
+
}, [api, book.hasCover, book.outputDir])
|
|
52
|
+
|
|
53
|
+
const ratio = book.total > 0 ? Math.min(book.done / book.total, 1) : 0
|
|
54
|
+
const status = bookStatus(book)
|
|
55
|
+
const statusLabel = status === 'none' ? '未开书' : status === 'done' ? '已完结' : '进行中'
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
className={`${css.bookCard} ${active ? css.bookCardActive : ''}`}
|
|
60
|
+
onClick={onOpen}
|
|
61
|
+
title={`打开《${book.bookName}》`}
|
|
62
|
+
>
|
|
63
|
+
<div className={css.bookCardCover}>
|
|
64
|
+
{cover !== null ? (
|
|
65
|
+
<img src={cover} alt={`《${book.bookName}》封面`} />
|
|
66
|
+
) : (
|
|
67
|
+
<div className={css.bookCardCoverFallback}>
|
|
68
|
+
<span className={css.bookCardCoverTitle}>{book.bookName.slice(0, 4)}</span>
|
|
69
|
+
<span className={css.meta}>暂无封面</span>
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
<div className={css.bookCardBody}>
|
|
74
|
+
<div className={css.bookCardTitleRow}>
|
|
75
|
+
<span className={css.bookCardName}>{book.bookName}</span>
|
|
76
|
+
<span className={`${css.badge} ${status === 'none' ? css.badgePending : status === 'done' ? css.badgeDone : css.badgeWritten}`}>
|
|
77
|
+
{statusLabel}
|
|
78
|
+
</span>
|
|
79
|
+
</div>
|
|
80
|
+
<span className={`${css.meta} ${css.bookCardBlurb}`} title={book.blurb ?? ''}>
|
|
81
|
+
{book.blurb !== undefined && book.blurb !== '' ? book.blurb : '暂无简介'}
|
|
82
|
+
</span>
|
|
83
|
+
<div className={css.bookCardProgressBar}>
|
|
84
|
+
<div className={css.bookCardProgressFill} style={{ width: `${Math.round(ratio * 100)}%` }} />
|
|
85
|
+
</div>
|
|
86
|
+
<div className={css.bookCardMetaRow}>
|
|
87
|
+
<span className={css.meta}>{book.total > 0 ? `已完成 ${book.done} / ${book.total} 章` : '尚未规划章节'}</span>
|
|
88
|
+
{book.hasProject && <span className={css.meta} title="最近活动时间">更新于 {relativeTime(book.updatedAt)}</span>}
|
|
89
|
+
</div>
|
|
90
|
+
<div className={css.bookCardActions} onClick={e => { e.stopPropagation() }}>
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
|
|
94
|
+
onClick={onOpen}
|
|
95
|
+
title="进入创作工作台(大纲/章节/审稿/设定)"
|
|
96
|
+
>
|
|
97
|
+
📝 进入工作台
|
|
98
|
+
</button>
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
className={`${css.button} ${css.buttonSmall}`}
|
|
102
|
+
disabled={!book.hasProject || book.done === 0}
|
|
103
|
+
onClick={onRead}
|
|
104
|
+
title={book.hasProject && book.done > 0 ? '沉浸式阅读已写章节' : '尚无已写章节,先去工作台创作'}
|
|
105
|
+
>
|
|
106
|
+
📖 阅读
|
|
107
|
+
</button>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 书架首页。 */
|
|
115
|
+
export function ShelfView({
|
|
116
|
+
api,
|
|
117
|
+
shelf,
|
|
118
|
+
onOpenBook,
|
|
119
|
+
onReadBook,
|
|
120
|
+
onAddBook,
|
|
121
|
+
onImportBook,
|
|
122
|
+
}: {
|
|
123
|
+
api: NovelApi
|
|
124
|
+
shelf: BookshelfSnapshot
|
|
125
|
+
/** 点击书卡:激活该书并进入工作台。 */
|
|
126
|
+
onOpenBook: (id: string) => void
|
|
127
|
+
/** 点击「阅读」:激活该书并进入沉浸式阅读页。 */
|
|
128
|
+
onReadBook: (id: string) => void
|
|
129
|
+
/** 点击+:进入开书向导页。 */
|
|
130
|
+
onAddBook: () => void
|
|
131
|
+
/** 点击「导入」:打开导入弹窗(已有项目目录 / txt/md 全本)。 */
|
|
132
|
+
onImportBook: () => void
|
|
133
|
+
}) {
|
|
134
|
+
const [query, setQuery] = useState('')
|
|
135
|
+
/** 筛选:all=全部 / active=进行中 / done=已完结 / none=未开书。 */
|
|
136
|
+
const [filter, setFilter] = useState<'all' | 'active' | 'done' | 'none'>('all')
|
|
137
|
+
|
|
138
|
+
const stats = useMemo(() => {
|
|
139
|
+
let active = 0, done = 0, none = 0
|
|
140
|
+
for (const b of shelf.books) {
|
|
141
|
+
const s = bookStatus(b)
|
|
142
|
+
if (s === 'none') none++
|
|
143
|
+
else if (s === 'done') done++
|
|
144
|
+
else active++
|
|
145
|
+
}
|
|
146
|
+
return { active, done, none }
|
|
147
|
+
}, [shelf.books])
|
|
148
|
+
|
|
149
|
+
const visible = useMemo(() => {
|
|
150
|
+
const q = query.trim().toLowerCase()
|
|
151
|
+
return shelf.books.filter(b => {
|
|
152
|
+
if (filter !== 'all' && bookStatus(b) !== filter) return false
|
|
153
|
+
if (q === '') return true
|
|
154
|
+
return b.bookName.toLowerCase().includes(q) || (b.blurb ?? '').toLowerCase().includes(q)
|
|
155
|
+
})
|
|
156
|
+
}, [shelf.books, query, filter])
|
|
157
|
+
|
|
158
|
+
const filters: Array<{ id: 'all' | 'active' | 'done' | 'none'; label: string; count: number }> = [
|
|
159
|
+
{ id: 'all', label: '全部', count: shelf.books.length },
|
|
160
|
+
{ id: 'active', label: '进行中', count: stats.active },
|
|
161
|
+
{ id: 'done', label: '已完结', count: stats.done },
|
|
162
|
+
{ id: 'none', label: '未开书', count: stats.none },
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<div className={css.shelfView}>
|
|
167
|
+
<div className={css.shelfHeader}>
|
|
168
|
+
<div className={css.shelfTitleRow}>
|
|
169
|
+
<h2 className={css.panelTitle} style={{ margin: 0 }}>📚 书架</h2>
|
|
170
|
+
{shelf.books.length > 0 && (
|
|
171
|
+
<span className={css.meta}>
|
|
172
|
+
{shelf.books.length} 本书 · {stats.active} 进行中 · {stats.done} 已完结 · {stats.none} 未开书
|
|
173
|
+
</span>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
<div className={css.shelfToolbar}>
|
|
177
|
+
<input
|
|
178
|
+
className={`${css.input} ${css.shelfSearch}`}
|
|
179
|
+
type="search"
|
|
180
|
+
placeholder="🔍 搜索书名 / 简介…"
|
|
181
|
+
value={query}
|
|
182
|
+
onChange={e => { setQuery(e.target.value) }}
|
|
183
|
+
/>
|
|
184
|
+
</div>
|
|
185
|
+
<div className={css.shelfFilters} style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
|
186
|
+
{filters.map(f => (
|
|
187
|
+
<button
|
|
188
|
+
key={f.id}
|
|
189
|
+
type="button"
|
|
190
|
+
className={`${css.button} ${filter === f.id ? css.buttonPrimary : ''}`}
|
|
191
|
+
style={{ fontSize: 14, flex: 1 }}
|
|
192
|
+
onClick={() => { setFilter(f.id) }}
|
|
193
|
+
>
|
|
194
|
+
{f.label}{f.count > 0 ? `(${f.count})` : ''}
|
|
195
|
+
</button>
|
|
196
|
+
))}
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
{shelf.books.length === 0 ? (
|
|
201
|
+
/* 空书架引导卡 */
|
|
202
|
+
<div className={css.shelfEmpty}>
|
|
203
|
+
<span className={css.shelfEmptyIcon}>📖</span>
|
|
204
|
+
<span className={css.shelfEmptyTitle}>你的创作从这里开始</span>
|
|
205
|
+
<span className={css.meta}>开一本书:粘贴大纲或导入 docx,书名自动识别,开书即建项目</span>
|
|
206
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} onClick={onAddBook}>
|
|
207
|
+
+ 开第一本书
|
|
208
|
+
</button>
|
|
209
|
+
<button type="button" className={`${css.button}`} onClick={onImportBook}>
|
|
210
|
+
📥 导入已有小说
|
|
211
|
+
</button>
|
|
212
|
+
</div>
|
|
213
|
+
) : visible.length === 0 ? (
|
|
214
|
+
<div className={css.shelfEmpty} style={{ minHeight: 160 }}>
|
|
215
|
+
<span className={css.shelfEmptyIcon}>🔍</span>
|
|
216
|
+
<span className={css.shelfEmptyTitle}>没有符合条件的书</span>
|
|
217
|
+
<span className={css.meta}>换个关键词或筛选条件试试</span>
|
|
218
|
+
</div>
|
|
219
|
+
) : (
|
|
220
|
+
<div className={css.shelfGrid}>
|
|
221
|
+
{visible.map(book => (
|
|
222
|
+
<BookCard
|
|
223
|
+
key={book.id}
|
|
224
|
+
api={api}
|
|
225
|
+
book={book}
|
|
226
|
+
active={book.id === shelf.activeBookId}
|
|
227
|
+
onOpen={() => { onOpenBook(book.id) }}
|
|
228
|
+
onRead={() => { onReadBook(book.id) }}
|
|
229
|
+
/>
|
|
230
|
+
))}
|
|
231
|
+
|
|
232
|
+
{/* 开书入口:进入独立向导页 */}
|
|
233
|
+
<div className={`${css.bookCard} ${css.bookAddCard}`} onClick={onAddBook}>
|
|
234
|
+
<div className={css.bookAddIcon}>+</div>
|
|
235
|
+
<span>开一本新书</span>
|
|
236
|
+
<span className={css.meta}>书名 + 大纲,开书即建项目</span>
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
{/* 导入入口:已有项目目录 / txt/md 全本 */}
|
|
240
|
+
<div className={`${css.bookCard} ${css.bookAddCard}`} onClick={onImportBook}>
|
|
241
|
+
<div className={css.bookAddIcon}>📥</div>
|
|
242
|
+
<span>导入已有小说</span>
|
|
243
|
+
<span className={css.meta}>项目目录或 txt/md 全本</span>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
)}
|
|
247
|
+
</div>
|
|
248
|
+
)
|
|
249
|
+
}
|