@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.
@@ -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
- api: NovelApi
123
- shelf: BookshelfSnapshot
124
- /** 点击书卡:激活该书并进入工作台。 */
125
- onOpenBook: (id: string) => void
126
- /** 点击「阅读」:激活该书并进入沉浸式阅读页。 */
127
- onReadBook: (id: string) => void
128
- /** 点击+:进入开书向导页。 */
129
- onAddBook: () => void
130
- }) {
131
- const [query, setQuery] = useState('')
132
- /** 筛选:all=全部 / active=进行中 / done=已完结 / none=未开书。 */
133
- const [filter, setFilter] = useState<'all' | 'active' | 'done' | 'none'>('all')
134
-
135
- const stats = useMemo(() => {
136
- let active = 0, done = 0, none = 0
137
- for (const b of shelf.books) {
138
- const s = bookStatus(b)
139
- if (s === 'none') none++
140
- else if (s === 'done') done++
141
- else active++
142
- }
143
- return { active, done, none }
144
- }, [shelf.books])
145
-
146
- const visible = useMemo(() => {
147
- const q = query.trim().toLowerCase()
148
- return shelf.books.filter(b => {
149
- if (filter !== 'all' && bookStatus(b) !== filter) return false
150
- if (q === '') return true
151
- return b.bookName.toLowerCase().includes(q) || (b.blurb ?? '').toLowerCase().includes(q)
152
- })
153
- }, [shelf.books, query, filter])
154
-
155
- const filters: Array<{ id: 'all' | 'active' | 'done' | 'none'; label: string; count: number }> = [
156
- { id: 'all', label: '全部', count: shelf.books.length },
157
- { id: 'active', label: '进行中', count: stats.active },
158
- { id: 'done', label: '已完结', count: stats.done },
159
- { id: 'none', label: '未开书', count: stats.none },
160
- ]
161
-
162
- return (
163
- <div className={css.shelfView}>
164
- <div className={css.shelfHeader}>
165
- <div className={css.shelfTitleRow}>
166
- <h2 className={css.panelTitle} style={{ margin: 0 }}>📚 书架</h2>
167
- {shelf.books.length > 0 && (
168
- <span className={css.meta}>
169
- {shelf.books.length} 本书 · {stats.active} 进行中 · {stats.done} 已完结 · {stats.none} 未开书
170
- </span>
171
- )}
172
- </div>
173
- <div className={css.shelfToolbar}>
174
- <input
175
- className={`${css.input} ${css.shelfSearch}`}
176
- type="search"
177
- placeholder="🔍 搜索书名 / 简介…"
178
- value={query}
179
- onChange={e => { setQuery(e.target.value) }}
180
- />
181
- </div>
182
- <div className={css.shelfFilters} style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
183
- {filters.map(f => (
184
- <button
185
- key={f.id}
186
- type="button"
187
- className={`${css.button} ${filter === f.id ? css.buttonPrimary : ''}`}
188
- style={{ fontSize: 14, flex: 1 }}
189
- onClick={() => { setFilter(f.id) }}
190
- >
191
- {f.label}{f.count > 0 ? `(${f.count})` : ''}
192
- </button>
193
- ))}
194
- </div>
195
- </div>
196
-
197
- {shelf.books.length === 0 ? (
198
- /* 空书架引导卡 */
199
- <div className={css.shelfEmpty}>
200
- <span className={css.shelfEmptyIcon}>📖</span>
201
- <span className={css.shelfEmptyTitle}>你的创作从这里开始</span>
202
- <span className={css.meta}>开一本书:粘贴大纲或导入 docx,书名自动识别,开书即建项目</span>
203
- <button type="button" className={`${css.button} ${css.buttonPrimary}`} onClick={onAddBook}>
204
- + 开第一本书
205
- </button>
206
- </div>
207
- ) : visible.length === 0 ? (
208
- <div className={css.shelfEmpty} style={{ minHeight: 160 }}>
209
- <span className={css.shelfEmptyIcon}>🔍</span>
210
- <span className={css.shelfEmptyTitle}>没有符合条件的书</span>
211
- <span className={css.meta}>换个关键词或筛选条件试试</span>
212
- </div>
213
- ) : (
214
- <div className={css.shelfGrid}>
215
- {visible.map(book => (
216
- <BookCard
217
- key={book.id}
218
- api={api}
219
- book={book}
220
- active={book.id === shelf.activeBookId}
221
- onOpen={() => { onOpenBook(book.id) }}
222
- onRead={() => { onReadBook(book.id) }}
223
- />
224
- ))}
225
-
226
- {/* 开书入口:进入独立向导页 */}
227
- <div className={`${css.bookCard} ${css.bookAddCard}`} onClick={onAddBook}>
228
- <div className={css.bookAddIcon}>+</div>
229
- <span>开一本新书</span>
230
- <span className={css.meta}>书名 + 大纲,开书即建项目</span>
231
- </div>
232
- </div>
233
- )}
234
- </div>
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
+ }