@waterwx/dsh-novel-forge 2.1.1-alpha → 2.1.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/README.md +368 -472
- package/lib/client.js +19578 -21391
- package/lib/index.js +4640 -3932
- package/lib/types/action-guard.d.ts +61 -0
- package/lib/types/ai-scan.d.ts +0 -6
- package/lib/types/assets.d.ts +7 -9
- package/lib/types/assistant.d.ts +25 -0
- package/lib/types/author-assets.d.ts +1 -3
- package/lib/types/bookshelf.d.ts +13 -3
- package/lib/types/client/api.d.ts +49 -29
- package/lib/types/client/chip-prefs.d.ts +36 -0
- package/lib/types/client/docx.d.ts +2 -2
- package/lib/types/client/llmLive.d.ts +12 -14
- package/lib/types/client/locales.d.ts +12 -67
- package/lib/types/client/mount.d.ts +0 -2
- package/lib/types/client/panel/AdaptModeView.d.ts +1 -1
- package/lib/types/client/panel/AiTag.d.ts +1 -0
- package/lib/types/client/panel/AssetsTab.d.ts +1 -1
- package/lib/types/client/panel/AssistantTab.d.ts +4 -1
- package/lib/types/client/panel/AuthorAssetsView.d.ts +1 -1
- package/lib/types/client/panel/AuthorHome.d.ts +12 -12
- package/lib/types/client/panel/CmdPalette.d.ts +12 -0
- package/lib/types/client/panel/CreateBookView.d.ts +1 -1
- package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +1 -1
- package/lib/types/client/panel/ImportModal.d.ts +1 -1
- package/lib/types/client/panel/LiveFeedLog.d.ts +8 -0
- package/lib/types/client/panel/ModelManager.d.ts +2 -2
- package/lib/types/client/panel/NovelPanel.d.ts +7 -2
- package/lib/types/client/panel/PageHeader.d.ts +18 -0
- package/lib/types/client/panel/ProgressConsole.d.ts +1 -1
- package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
- package/lib/types/client/panel/ReaderView.d.ts +1 -1
- package/lib/types/client/panel/RunPanel.d.ts +1 -1
- package/lib/types/client/panel/SettingsView.d.ts +13 -10
- package/lib/types/client/panel/ShelfView.d.ts +11 -2
- package/lib/types/client/panel/SubPage.d.ts +27 -0
- package/lib/types/client/panel/TensionView.d.ts +18 -0
- package/lib/types/client/panel/TimelineView.d.ts +18 -0
- package/lib/types/client/panel/WorldTab.d.ts +1 -1
- package/lib/types/client/panel/helpers.d.ts +5 -3
- package/lib/types/client/panel/market-radar-state.d.ts +56 -0
- package/lib/types/client/panel/outline-markdown.d.ts +112 -0
- package/lib/types/client/panel/status-badge.d.ts +13 -0
- package/lib/types/client/panel/storage-parse.d.ts +46 -0
- package/lib/types/client/panel/toast.d.ts +2 -0
- package/lib/types/client/panel/usePersistentState.d.ts +17 -0
- package/lib/types/client/panel/views.d.ts +21 -35
- package/lib/types/client/run-status.d.ts +21 -0
- package/lib/types/client/sidebar-entry.d.ts +0 -2
- package/lib/types/digest-parse.d.ts +29 -0
- package/lib/types/docx-core.d.ts +16 -0
- package/lib/types/docx.d.ts +2 -3
- package/lib/types/engine.d.ts +122 -103
- package/lib/types/home.d.ts +11 -0
- package/lib/types/index.d.ts +21 -13
- package/lib/types/llm-live.d.ts +62 -36
- package/lib/types/llm-retry.d.ts +38 -0
- package/lib/types/market-radar-scan.d.ts +0 -1
- package/lib/types/market-radar-sources.d.ts +1 -1
- package/lib/types/migrate-output.d.ts +25 -0
- package/lib/types/model-capability.d.ts +119 -0
- package/lib/types/model-request.d.ts +36 -0
- package/lib/types/output-budget.d.ts +86 -0
- package/lib/types/plotline-align.d.ts +35 -0
- package/lib/types/prompt-slots.d.ts +57 -0
- package/lib/types/prompting.d.ts +2 -27
- package/lib/types/protocol.d.ts +479 -595
- package/lib/types/revision.d.ts +76 -0
- package/lib/types/routes.d.ts +9 -3
- package/lib/types/run-selection.d.ts +51 -0
- package/lib/types/run.d.ts +31 -0
- package/lib/types/settings-persist.d.ts +59 -0
- package/lib/types/snapshots.d.ts +46 -0
- package/lib/types/stage-contract.d.ts +58 -0
- package/lib/types/stream-fallback.d.ts +60 -0
- package/lib/types/strip-headings.d.ts +33 -0
- package/lib/types/tension.d.ts +53 -0
- package/lib/types/timeline.d.ts +37 -0
- package/package.json +41 -28
- package/assets/styles/arcane-thick-3d.webp +0 -0
- package/assets/styles/chinese-ink.webp +0 -0
- package/assets/styles/clay-stopmotion-3d.webp +0 -0
- package/assets/styles/dark-ghibli.webp +0 -0
- package/assets/styles/dark-gothic-3d.webp +0 -0
- package/assets/styles/film-photo.webp +0 -0
- package/assets/styles/guofeng-heavy.webp +0 -0
- package/assets/styles/guoman-2d-gufeng.webp +0 -0
- package/assets/styles/guoman-3d-tianyuan.webp +0 -0
- package/assets/styles/guoman-3d-xuanhuan.webp +0 -0
- package/assets/styles/horror-game-3d.webp +0 -0
- package/assets/styles/hyperreal-3d.webp +0 -0
- package/assets/styles/japan-cel-dark.webp +0 -0
- package/assets/styles/korean-webtoon.webp +0 -0
- package/assets/styles/low-cyberpunk-3d.webp +0 -0
- package/assets/styles/nextgen-cg.webp +0 -0
- package/assets/styles/noir-film.webp +0 -0
- package/assets/styles/openworld-survival.webp +0 -0
- package/assets/styles/paper-cut-3d.webp +0 -0
- package/assets/styles/pixar-adult-3d.webp +0 -0
- package/assets/styles/pop-marvel-2d.webp +0 -0
- package/assets/styles/sandiao-qban.webp +0 -0
- package/assets/styles/shinkai-anime.webp +0 -0
- package/assets/styles/tactical-stealth.webp +0 -0
- package/assets/styles/urban-lightreal-3d.webp +0 -0
- package/assets/styles/vaporwave-3d.webp +0 -0
- package/assets/styles/vhs90.webp +0 -0
- package/lib/types/client/panel/BookshelfBar.d.ts +0 -11
- package/lib/types/client/panel/FlowGuide.d.ts +0 -22
- package/lib/types/client/panel/MangaRoleLibrary.d.ts +0 -20
- package/lib/types/client/panel/MangaWorkspace.d.ts +0 -12
- package/lib/types/client/panel/ModelPicker.d.ts +0 -11
- package/lib/types/client/panel/ProgressHomeView.d.ts +0 -5
- package/lib/types/client/panel/PropLibrary.d.ts +0 -8
- package/lib/types/client/panel/SceneLibrary.d.ts +0 -16
- package/lib/types/client/panel/StoryboardTab.d.ts +0 -19
- package/lib/types/client/panel/StyleCard.d.ts +0 -8
- package/lib/types/client/panel/StyleGallery.d.ts +0 -1
- package/lib/types/comic-presets.d.ts +0 -12
- package/lib/types/manga-genre-rules.d.ts +0 -22
- package/lib/types/prompt-utils.d.ts +0 -35
- package/lib/types/review-policy.d.ts +0 -97
- package/lib/types/shot-language.d.ts +0 -46
- package/lib/types/src/ai-scan.d.ts +0 -36
- package/lib/types/src/assets.d.ts +0 -37
- package/lib/types/src/assistant.d.ts +0 -45
- package/lib/types/src/author-assets.d.ts +0 -25
- package/lib/types/src/bookshelf.d.ts +0 -42
- package/lib/types/src/client/api.d.ts +0 -234
- package/lib/types/src/client/docx.d.ts +0 -15
- package/lib/types/src/client/index.d.ts +0 -14
- package/lib/types/src/client/locales.d.ts +0 -212
- package/lib/types/src/client/mount.d.ts +0 -9
- package/lib/types/src/client/panel/AdaptModeView.d.ts +0 -5
- package/lib/types/src/client/panel/AssetsTab.d.ts +0 -12
- package/lib/types/src/client/panel/AssistantTab.d.ts +0 -7
- package/lib/types/src/client/panel/AuthorAssetsView.d.ts +0 -4
- package/lib/types/src/client/panel/AuthorHome.d.ts +0 -32
- package/lib/types/src/client/panel/BookshelfBar.d.ts +0 -11
- package/lib/types/src/client/panel/CreateBookView.d.ts +0 -9
- package/lib/types/src/client/panel/FlowGuide.d.ts +0 -22
- package/lib/types/src/client/panel/GlobalAssetLibraryView.d.ts +0 -4
- package/lib/types/src/client/panel/ImportModal.d.ts +0 -7
- package/lib/types/src/client/panel/MangaRoleLibrary.d.ts +0 -20
- package/lib/types/src/client/panel/MangaWorkspace.d.ts +0 -12
- package/lib/types/src/client/panel/ModelManager.d.ts +0 -14
- package/lib/types/src/client/panel/NovelPanel.d.ts +0 -13
- package/lib/types/src/client/panel/ProgressConsole.d.ts +0 -17
- package/lib/types/src/client/panel/ProgressHomeView.d.ts +0 -5
- package/lib/types/src/client/panel/PropLibrary.d.ts +0 -8
- package/lib/types/src/client/panel/ReaderView.d.ts +0 -8
- package/lib/types/src/client/panel/ReasoningSection.d.ts +0 -10
- package/lib/types/src/client/panel/RunPanel.d.ts +0 -5
- package/lib/types/src/client/panel/SceneLibrary.d.ts +0 -16
- package/lib/types/src/client/panel/SettingsView.d.ts +0 -16
- package/lib/types/src/client/panel/ShelfView.d.ts +0 -15
- package/lib/types/src/client/panel/StoryboardTab.d.ts +0 -19
- package/lib/types/src/client/panel/StyleCard.d.ts +0 -8
- package/lib/types/src/client/panel/StyleGallery.d.ts +0 -1
- package/lib/types/src/client/panel/WorldTab.d.ts +0 -10
- package/lib/types/src/client/panel/controller.d.ts +0 -19
- package/lib/types/src/client/panel/helpers.d.ts +0 -18
- package/lib/types/src/client/panel/views.d.ts +0 -77
- package/lib/types/src/client/sidebar-entry.d.ts +0 -13
- package/lib/types/src/client/text.d.ts +0 -9
- package/lib/types/src/comic-presets.d.ts +0 -12
- package/lib/types/src/docx.d.ts +0 -19
- package/lib/types/src/engine.d.ts +0 -402
- package/lib/types/src/index.d.ts +0 -79
- package/lib/types/src/manga-genre-rules.d.ts +0 -22
- package/lib/types/src/novel-context.d.ts +0 -80
- package/lib/types/src/prompt-utils.d.ts +0 -35
- package/lib/types/src/protocol.d.ts +0 -2176
- package/lib/types/src/review-policy.d.ts +0 -97
- package/lib/types/src/routes.d.ts +0 -29
- package/lib/types/src/run.d.ts +0 -32
- package/lib/types/src/shot-language.d.ts +0 -46
- package/lib/types/src/story-beat-language.d.ts +0 -35
- package/lib/types/src/style-library.d.ts +0 -33
- package/lib/types/story-beat-language.d.ts +0 -35
- package/lib/types/style-library.d.ts +0 -33
- package/src/ai-scan.ts +0 -179
- package/src/assets.ts +0 -1774
- package/src/assistant.ts +0 -915
- package/src/author-assets.ts +0 -147
- package/src/bookshelf.ts +0 -167
- package/src/client/api.ts +0 -716
- package/src/client/css-modules.d.ts +0 -8
- package/src/client/docx.ts +0 -69
- package/src/client/index.ts +0 -34
- package/src/client/llmLive.ts +0 -101
- package/src/client/locales.ts +0 -418
- package/src/client/mount.tsx +0 -97
- package/src/client/mount.tsx._new +0 -97
- package/src/client/mount.tsx._nf +0 -97
- package/src/client/mount.tsx._tmp +0 -97
- package/src/client/panel/AdaptModeView.tsx +0 -535
- package/src/client/panel/AssetsTab.tsx +0 -458
- package/src/client/panel/AssistantTab.tsx +0 -339
- package/src/client/panel/AuthorAssetsView.tsx +0 -208
- package/src/client/panel/AuthorHome.tsx +0 -234
- package/src/client/panel/BookAnalysisView.tsx +0 -60
- package/src/client/panel/BookshelfBar.tsx +0 -170
- package/src/client/panel/CreateBookView.tsx +0 -371
- package/src/client/panel/DirectorView.tsx +0 -200
- package/src/client/panel/FlowGuide.tsx +0 -84
- package/src/client/panel/GlobalAssetLibraryView.tsx +0 -172
- package/src/client/panel/IdeaInspirationView.tsx +0 -71
- package/src/client/panel/ImportModal.tsx +0 -303
- package/src/client/panel/KnowledgeBaseView.tsx +0 -73
- package/src/client/panel/LiveFeedLog.tsx +0 -76
- package/src/client/panel/MangaRoleLibrary.tsx +0 -1002
- package/src/client/panel/MangaWorkspace.tsx +0 -715
- package/src/client/panel/MarketRadarView.tsx +0 -374
- package/src/client/panel/ModelManager.tsx +0 -205
- package/src/client/panel/NovelPanel.tsx +0 -4721
- package/src/client/panel/ProgressConsole.tsx +0 -63
- package/src/client/panel/ProgressHomeView.tsx +0 -87
- package/src/client/panel/PropLibrary.tsx +0 -175
- package/src/client/panel/ReaderView.tsx +0 -306
- package/src/client/panel/ReasoningSection.tsx +0 -31
- package/src/client/panel/RunPanel.tsx +0 -204
- package/src/client/panel/SceneLibrary.tsx +0 -453
- package/src/client/panel/SettingsView.tsx +0 -289
- package/src/client/panel/ShelfView.tsx +0 -242
- package/src/client/panel/StoryboardTab.tsx +0 -472
- package/src/client/panel/StyleCard.tsx +0 -90
- package/src/client/panel/StyleGallery.tsx +0 -58
- package/src/client/panel/WorldTab.tsx +0 -202
- package/src/client/panel/controller.ts +0 -45
- package/src/client/panel/helpers.ts +0 -67
- package/src/client/panel/panel.module.css +0 -4359
- package/src/client/panel/views.tsx +0 -314
- package/src/client/sidebar-entry.ts +0 -122
- package/src/client/text.ts +0 -28
- package/src/comic-presets.ts +0 -67
- package/src/docx.ts +0 -83
- package/src/engine.ts +0 -5636
- package/src/global-assets.ts +0 -59
- package/src/index.ts +0 -346
- package/src/llm-live.ts +0 -68
- package/src/manga-genre-rules.ts +0 -87
- package/src/market-radar-scan.ts +0 -41
- package/src/market-radar-sources.ts +0 -173
- package/src/novel-context.ts +0 -287
- package/src/prompt-utils.ts +0 -73
- package/src/prompting.ts +0 -159
- package/src/protocol.ts +0 -2565
- package/src/review-policy.ts +0 -161
- package/src/routes.ts +0 -3939
- package/src/run.ts +0 -338
- package/src/shot-language.ts +0 -181
- package/src/story-beat-language.ts +0 -130
- package/src/style-library.ts +0 -84
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 共享「AI 进度」实时控制台:当前任务大进度条 + 全量活动日志。
|
|
3
|
-
* 书内与首页共用一个表现,内容来自同一份进度状态。
|
|
4
|
-
*/
|
|
5
|
-
import { useEffect, useRef } from 'react'
|
|
6
|
-
import css from './panel.module.css'
|
|
7
|
-
|
|
8
|
-
export interface ProgressLine {
|
|
9
|
-
id: number;
|
|
10
|
-
text: string;
|
|
11
|
-
kind: 'info' | 'done' | 'error';
|
|
12
|
-
live?: boolean;
|
|
13
|
-
ratio?: number;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function ProgressConsole({ progress, busy, busyLabel, liveBar, onClear }: {
|
|
17
|
-
progress: ProgressLine[];
|
|
18
|
-
busy: boolean;
|
|
19
|
-
busyLabel: string;
|
|
20
|
-
liveBar: { text: string; ratio?: number } | null;
|
|
21
|
-
onClear: () => void;
|
|
22
|
-
}) {
|
|
23
|
-
const endRef = useRef<HTMLDivElement | null>(null);
|
|
24
|
-
useEffect(() => {
|
|
25
|
-
endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
26
|
-
}, [progress]);
|
|
27
|
-
|
|
28
|
-
return (
|
|
29
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-10)', minHeight: 0 }}>
|
|
30
|
-
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
31
|
-
<span style={{ fontWeight: 600 }}>📊 AI 进度</span>
|
|
32
|
-
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={onClear} title="清空活动记录">清空</button>
|
|
33
|
-
</div>
|
|
34
|
-
{(busy && (busyLabel !== '' || liveBar !== null)) && (
|
|
35
|
-
<div style={{ border: '1px solid var(--nf-accent)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-8) var(--nf-space-12)', display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)', background: 'color-mix(in srgb, var(--nf-accent) 6%, transparent)' }}>
|
|
36
|
-
<span style={{ fontSize: 'var(--nf-fs-12)', fontWeight: 600, color: 'var(--nf-accent)' }}>✍ {busyLabel !== '' ? busyLabel : (liveBar?.text ?? '任务进行中')}…</span>
|
|
37
|
-
{liveBar?.ratio !== undefined && (
|
|
38
|
-
<div className={css.bigProgressBar}><div className={css.bigProgressBarFill} style={{ width: Math.round(liveBar.ratio * 100) + '%' }} /></div>
|
|
39
|
-
)}
|
|
40
|
-
{liveBar?.text !== undefined && <span className={css.liveText}>{liveBar.text}</span>}
|
|
41
|
-
</div>
|
|
42
|
-
)}
|
|
43
|
-
<div className={css.progress} style={{ flex: '0 1 auto', maxHeight: '42%', minHeight: 0, overflowY: 'auto', border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', background: 'var(--nf-bg-inset)', padding: 'var(--nf-space-8)', display: 'flex', flexDirection: 'column' }}>
|
|
44
|
-
{progress.length === 0 ? (
|
|
45
|
-
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--nf-space-6)', color: 'var(--nf-text-3)', fontSize: 'var(--nf-fs-12)', padding: 'var(--nf-space-4) 2px' }}>
|
|
46
|
-
<span>📭</span>
|
|
47
|
-
<span>暂无活动记录,生成、审稿等操作会显示在这里</span>
|
|
48
|
-
</div>
|
|
49
|
-
) : (
|
|
50
|
-
progress.map(line => (
|
|
51
|
-
<div key={line.id} className={line.kind === 'done' ? css.progressLineDone : line.kind === 'error' ? css.progressLineError : line.live === true ? css.progressLineLive : css.progressLine}>
|
|
52
|
-
{line.live === true && (
|
|
53
|
-
<span className={css.progressBar}><span className={css.progressBarFill} style={{ width: Math.round((line.ratio ?? 0) * 100) + '%' }} /></span>
|
|
54
|
-
)}
|
|
55
|
-
{line.text}
|
|
56
|
-
</div>
|
|
57
|
-
))
|
|
58
|
-
)}
|
|
59
|
-
<div ref={endRef} />
|
|
60
|
-
</div>
|
|
61
|
-
</div>
|
|
62
|
-
);
|
|
63
|
-
}
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 首页 AI 进度视图:书架级聚合进度——总览 + 每本书进度条 + 进行中高亮。
|
|
3
|
-
* 读取 BookshelfSnapshot,不依赖打开某本书。
|
|
4
|
-
*/
|
|
5
|
-
import { useMemo } from 'react'
|
|
6
|
-
import { BarChart3, BookOpen, PenLine, CheckCircle2, Clock, RefreshCw } from 'lucide-react'
|
|
7
|
-
import type { BookshelfSnapshot } from '../../protocol.ts'
|
|
8
|
-
import css from './panel.module.css'
|
|
9
|
-
|
|
10
|
-
function relativeTime(iso: string): string {
|
|
11
|
-
const t = new Date(iso).getTime()
|
|
12
|
-
if (!Number.isFinite(t)) return '—'
|
|
13
|
-
const diff = Date.now() - t
|
|
14
|
-
if (diff < 60_000) return '刚刚'
|
|
15
|
-
if (diff < 3_600_000) return Math.floor(diff / 60_000) + ' 分钟前'
|
|
16
|
-
if (diff < 86_400_000) return Math.floor(diff / 3_600_000) + ' 小时前'
|
|
17
|
-
if (diff < 7 * 86_400_000) return Math.floor(diff / 86_400_000) + ' 天前'
|
|
18
|
-
return new Date(t).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function bookStatus(book: BookshelfSnapshot['books'][number]): 'none' | 'done' | 'active' {
|
|
22
|
-
if (!book.hasProject) return 'none'
|
|
23
|
-
return book.total > 0 && book.done >= book.total ? 'done' : 'active'
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function ProgressHomeView({ shelf, onOpenBook }: {
|
|
27
|
-
shelf: BookshelfSnapshot;
|
|
28
|
-
onOpenBook: (id: string) => void;
|
|
29
|
-
}) {
|
|
30
|
-
const stats = useMemo(() => {
|
|
31
|
-
let active = 0, done = 0, none = 0, chapters = 0, written = 0;
|
|
32
|
-
for (const b of shelf.books) {
|
|
33
|
-
const s = bookStatus(b)
|
|
34
|
-
if (s === 'none') none++; else if (s === 'done') done++; else active++;
|
|
35
|
-
chapters += b.total;
|
|
36
|
-
written += b.done;
|
|
37
|
-
}
|
|
38
|
-
return { active, done, none, chapters, written }
|
|
39
|
-
}, [shelf.books]);
|
|
40
|
-
|
|
41
|
-
const progressPct = stats.chapters > 0 ? Math.round((stats.written / stats.chapters) * 100) : 0;
|
|
42
|
-
|
|
43
|
-
return (
|
|
44
|
-
<div className={css.authorPageBody}>
|
|
45
|
-
<div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-6)' }}><h2 className={css.panelTitle} style={{ margin: 0 }}>📊 AI 进度</h2><span className={css.meta}>跨书架聚合进度与最近活动</span></div>
|
|
46
|
-
|
|
47
|
-
<div className={css.progressOverview}>
|
|
48
|
-
<div className={css.progressOverviewCard}><span className={css.progressOverviewNum}>{shelf.books.length}</span><span className={css.meta}>总书数</span></div>
|
|
49
|
-
<div className={css.progressOverviewCard}><span className={css.progressOverviewNum}>{stats.active}</span><span className={css.meta}>进行中</span></div>
|
|
50
|
-
<div className={css.progressOverviewCard}><span className={css.progressOverviewNum}>{stats.done}</span><span className={css.meta}>已完结</span></div>
|
|
51
|
-
<div className={css.progressOverviewCard}><span className={css.progressOverviewNum}>{stats.written}/{stats.chapters}</span><span className={css.meta}>已完成章节</span></div>
|
|
52
|
-
</div>
|
|
53
|
-
|
|
54
|
-
<div className={css.progressTotalBar}><div className={css.progressTotalFill} style={{ width: progressPct + '%' }} /><span className={css.meta}>全书架整体进度 {progressPct}%</span></div>
|
|
55
|
-
|
|
56
|
-
{shelf.books.length === 0 ? (
|
|
57
|
-
<div className={css.shelfEmpty} style={{ minHeight: 160 }}>
|
|
58
|
-
<span className={css.shelfEmptyIcon}><BarChart3 size={30} /></span>
|
|
59
|
-
<span className={css.shelfEmptyTitle}>还没有书</span>
|
|
60
|
-
<span className={css.meta}>去书架开一本书或导入全文后,这里会显示进度。</span>
|
|
61
|
-
</div>
|
|
62
|
-
) : (
|
|
63
|
-
<div className={css.progressBookList}>
|
|
64
|
-
{shelf.books.map(book => {
|
|
65
|
-
const s = bookStatus(book)
|
|
66
|
-
const ratio = book.total > 0 ? Math.min(book.done / book.total, 1) : 0;
|
|
67
|
-
const statusLabel = s === 'none' ? '未开书' : s === 'done' ? '已完结' : '进行中';
|
|
68
|
-
return (
|
|
69
|
-
<div key={book.id} className={css.progressBook + (s === 'active' ? ' ' + css.progressBookActive : '')}>
|
|
70
|
-
<div className={css.progressBookHead}>
|
|
71
|
-
<span className={css.progressBookName} title={book.bookName}>{book.bookName}</span>
|
|
72
|
-
<span className={css.badge + (s === 'done' ? ' ' + css.badgeDone : s === 'active' ? ' ' + css.badgeWritten : ' ' + css.badgePending)}>{statusLabel}</span>
|
|
73
|
-
{s === 'active' && <span className={css.meta + ' ' + css.progressLive}><RefreshCw size={12} style={{ verticalAlign: -2 }} /> 创作中</span>}
|
|
74
|
-
</div>
|
|
75
|
-
<div className={css.progressBookBar}><div className={css.progressBookFill} style={{ width: Math.round(ratio * 100) + '%' }} /></div>
|
|
76
|
-
<div className={css.rowEnd} style={{ justifyContent: 'space-between' }}>
|
|
77
|
-
<span className={css.meta}>{book.total > 0 ? '已完成 ' + book.done + ' / ' + book.total + ' 章' : '尚未规划章节'} · 更新于 {relativeTime(book.updatedAt)}</span>
|
|
78
|
-
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => onOpenBook(book.id)}><PenLine size={12} style={{ verticalAlign: -2 }} /> 进入</button>
|
|
79
|
-
</div>
|
|
80
|
-
</div>
|
|
81
|
-
);
|
|
82
|
-
})}
|
|
83
|
-
</div>
|
|
84
|
-
)}
|
|
85
|
-
</div>
|
|
86
|
-
);
|
|
87
|
-
}
|
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 道具库:从已写章节自动提炼「常驻道具」(跨镜头需保持一致)→ 注入每个分镜提示词。
|
|
3
|
-
* 支持:AI 提炼 / 手动增删改 / 保存;道具一行统一外观描述,提示词按此外观保持跨镜头一致。
|
|
4
|
-
*/
|
|
5
|
-
import { useState } from 'react'
|
|
6
|
-
import type { NovelApi } from '../api.ts'
|
|
7
|
-
import type { ProjectState, Prop } from '../../protocol.ts'
|
|
8
|
-
import css from './panel.module.css'
|
|
9
|
-
|
|
10
|
-
export function PropLibrary({
|
|
11
|
-
api,
|
|
12
|
-
project,
|
|
13
|
-
refresh,
|
|
14
|
-
onProgress,
|
|
15
|
-
}: {
|
|
16
|
-
api: NovelApi
|
|
17
|
-
project: ProjectState | null
|
|
18
|
-
refresh: () => void | Promise<void>
|
|
19
|
-
onProgress?: (text: string, kind?: 'info' | 'done' | 'error') => void
|
|
20
|
-
}) {
|
|
21
|
-
const [busy, setBusy] = useState(false)
|
|
22
|
-
const [error, setError] = useState('')
|
|
23
|
-
const [notice, setNotice] = useState('')
|
|
24
|
-
const [editing, setEditing] = useState<Prop | null>(null)
|
|
25
|
-
const [extracted, setExtracted] = useState<Prop[] | null>(null)
|
|
26
|
-
|
|
27
|
-
const notify = (msg: string): void => { setNotice(msg); setTimeout(() => { setNotice('') }, 3000) }
|
|
28
|
-
const report = (text: string, kind: 'info' | 'done' | 'error' = 'info'): void => { onProgress?.(text, kind) }
|
|
29
|
-
|
|
30
|
-
const props = (project?.props ?? []).map(p => ({ ...p }))
|
|
31
|
-
|
|
32
|
-
/** AI 提炼:从已写章节识别常驻道具,暂存为候选,待保存采纳。 */
|
|
33
|
-
const extract = async (): Promise<void> => {
|
|
34
|
-
setBusy(true); setError('')
|
|
35
|
-
report('从已写章节提炼常驻道具…')
|
|
36
|
-
try {
|
|
37
|
-
const res = await api.mangaProps({ op: 'extract' })
|
|
38
|
-
setExtracted(res.props ?? [])
|
|
39
|
-
await refresh()
|
|
40
|
-
report('道具提炼完成:' + (res.props ?? []).length + ' 个常驻道具', 'done')
|
|
41
|
-
} catch (err) {
|
|
42
|
-
const m = (err as Error).message
|
|
43
|
-
setError(m)
|
|
44
|
-
report('道具提炼失败:' + m, 'error')
|
|
45
|
-
} finally { setBusy(false) }
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** 保存:整体替换道具库(候选 / 手动清单)。 */
|
|
49
|
-
const save = async (list: Prop[]): Promise<void> => {
|
|
50
|
-
const cleaned = list.map(p => ({ name: p.name.trim(), desc: p.desc.trim() })).filter(p => p.name !== '')
|
|
51
|
-
if (cleaned.length === 0) { setError('道具名为空,无法保存'); return }
|
|
52
|
-
setBusy(true); setError('')
|
|
53
|
-
report('保存道具库…')
|
|
54
|
-
try {
|
|
55
|
-
await api.mangaProps({ op: 'save', props: cleaned })
|
|
56
|
-
await refresh()
|
|
57
|
-
setExtracted(null)
|
|
58
|
-
setEditing(null)
|
|
59
|
-
report('道具库已保存:' + cleaned.length + ' 个', 'done')
|
|
60
|
-
} catch (err) {
|
|
61
|
-
const m = (err as Error).message
|
|
62
|
-
setError(m)
|
|
63
|
-
report('保存道具库失败:' + m, 'error')
|
|
64
|
-
} finally { setBusy(false) }
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (project === null) {
|
|
68
|
-
return <div className={css.card}><span className={css.meta}>请先开书或选择一本书,再进入道具库。</span></div>
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const editingList: Prop[] = extracted ?? props
|
|
72
|
-
|
|
73
|
-
return (
|
|
74
|
-
<div className={css.card} style={{ flex: 1, minHeight: 0 }}>
|
|
75
|
-
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
76
|
-
<span className={css.cardTitle} style={{ fontSize: 'var(--nf-fs-16)', fontWeight: 700 }}>🎒 道具库</span>
|
|
77
|
-
<span className={css.meta}>常驻道具(跨镜头需保持一致):提炼自已写章节,自动注入每个分镜提示词</span>
|
|
78
|
-
</div>
|
|
79
|
-
<span className={css.meta} style={{ display: 'block', marginTop: 'var(--nf-space-2)' }}>
|
|
80
|
-
流程位置:道具库(选做) · 前置:已写章节 · 作用:分镜提示词里道具外观跨镜头统一
|
|
81
|
-
</span>
|
|
82
|
-
|
|
83
|
-
<div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-6)', margin: '8px 0' }}>
|
|
84
|
-
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void extract() }}>
|
|
85
|
-
{busy ? '⏳ 提炼中…' : '✨ 提炼道具'}
|
|
86
|
-
</button>
|
|
87
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setEditing({ name: '', desc: '' }) }}>+ 手动添加</button>
|
|
88
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { void refresh(); report('道具库已刷新', 'done') }}>🔄 刷新</button>
|
|
89
|
-
</div>
|
|
90
|
-
|
|
91
|
-
{error !== '' && <div className={css.importError}>{error}</div>}
|
|
92
|
-
{notice !== '' && <div className={css.importResult} style={{ padding: 'var(--nf-space-6) var(--nf-space-10)' }}>{notice}</div>}
|
|
93
|
-
|
|
94
|
-
{/* 提炼候选:待保存 */}
|
|
95
|
-
{extracted !== null && (
|
|
96
|
-
<div style={{ border: '1px solid var(--nf-accent)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-10)', marginBottom: 'var(--nf-space-10)' }}>
|
|
97
|
-
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
98
|
-
<b style={{ fontSize: 'var(--nf-fs-14)' }}>✨ AI 提炼结果(确认/编辑后保存)</b>
|
|
99
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setExtracted(null) }}>取消</button>
|
|
100
|
-
</div>
|
|
101
|
-
{extracted.length === 0
|
|
102
|
-
? <span className={css.meta} style={{ display: 'block', marginTop: 'var(--nf-space-6)' }}>未识别到明显常驻道具(仅一次性出现的道具不列入)。可手动添加。</span>
|
|
103
|
-
: <PropRows rows={editingList} setRows={setExtracted} />}
|
|
104
|
-
{extracted.length > 0 && (
|
|
105
|
-
<div className={css.row} style={{ marginTop: 'var(--nf-space-8)', gap: 'var(--nf-space-6)' }}>
|
|
106
|
-
<button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void save(extracted) }}>保存道具库</button>
|
|
107
|
-
<span className={css.meta}>保存后会注入所有分镜提示词,跨镜头统一此道具外观</span>
|
|
108
|
-
</div>
|
|
109
|
-
)}
|
|
110
|
-
</div>
|
|
111
|
-
)}
|
|
112
|
-
|
|
113
|
-
{/* 手动添加/编辑行 */}
|
|
114
|
-
{editing !== null && extracted === null && (
|
|
115
|
-
<div style={{ border: '1px solid var(--nf-accent)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-10)', marginBottom: 'var(--nf-space-10)', display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
|
|
116
|
-
<b style={{ fontSize: 'var(--nf-fs-14)' }}>编辑道具</b>
|
|
117
|
-
<input className={css.input} style={{ fontSize: 'var(--nf-fs-12)', padding: 'var(--nf-space-4) var(--nf-space-8)' }} placeholder="道具名(如 外卖电动车)" value={editing.name} onChange={e => { setEditing({ ...editing, name: e.target.value }) }} />
|
|
118
|
-
<input className={css.input} style={{ fontSize: 'var(--nf-fs-12)', padding: 'var(--nf-space-4) var(--nf-space-8)' }} placeholder="一行统一外观描述(颜色/材质/状态)" value={editing.desc} onChange={e => { setEditing({ ...editing, desc: e.target.value }) }} />
|
|
119
|
-
<div className={css.row} style={{ gap: 'var(--nf-space-6)' }}>
|
|
120
|
-
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || editing.name.trim() === ''} onClick={() => {
|
|
121
|
-
const list = props
|
|
122
|
-
const idx = list.findIndex(p => p.name === editing.name)
|
|
123
|
-
if (idx >= 0) list[idx] = editing
|
|
124
|
-
else list.push(editing)
|
|
125
|
-
void save(list)
|
|
126
|
-
}}>保存</button>
|
|
127
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setEditing(null) }}>取消</button>
|
|
128
|
-
</div>
|
|
129
|
-
</div>
|
|
130
|
-
)}
|
|
131
|
-
|
|
132
|
-
{props.length === 0 && extracted === null ? (
|
|
133
|
-
<div className={css.shelfEmpty} style={{ minHeight: 140, flex: 1 }}>
|
|
134
|
-
<span className={css.shelfEmptyIcon}>🎒</span>
|
|
135
|
-
<span className={css.shelfEmptyTitle}>道具库为空</span>
|
|
136
|
-
<span className={css.meta}>点「✨ 提炼道具」自动识别,或「+ 手动添加」</span>
|
|
137
|
-
</div>
|
|
138
|
-
) : (
|
|
139
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
|
|
140
|
-
{props.map(p => (
|
|
141
|
-
<div key={p.name} style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-8) var(--nf-space-10)', display: 'flex', alignItems: 'center', gap: 'var(--nf-space-10)' }}>
|
|
142
|
-
<span style={{ fontSize: 'var(--nf-fs-14)', fontWeight: 600, minWidth: 110 }}>{p.name}</span>
|
|
143
|
-
<span className={css.meta} style={{ flex: 1 }}>{p.desc}</span>
|
|
144
|
-
<span className={css.row} style={{ gap: 'var(--nf-space-4)' }}>
|
|
145
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setEditing({ ...p }) }}>✏️ 编辑</button>
|
|
146
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ color: 'var(--nf-danger, #e05)' }} onClick={() => { void save(props.filter(x => x.name !== p.name)) }}>🗑</button>
|
|
147
|
-
</span>
|
|
148
|
-
</div>
|
|
149
|
-
))}
|
|
150
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ alignSelf: 'flex-start' }} onClick={() => { void refresh() }}>已是最新</button>
|
|
151
|
-
</div>
|
|
152
|
-
)}
|
|
153
|
-
</div>
|
|
154
|
-
)
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** 可编辑道具行(提炼候选用)。 */
|
|
158
|
-
function PropRows({ rows, setRows }: { rows: Prop[]; setRows: (r: Prop[]) => void }) {
|
|
159
|
-
const set = (idx: number, patch: Partial<Prop>): void => {
|
|
160
|
-
const next = rows.map((r, i) => (i === idx ? { ...r, ...patch } : r))
|
|
161
|
-
setRows(next)
|
|
162
|
-
}
|
|
163
|
-
return (
|
|
164
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', marginTop: 'var(--nf-space-6)' }}>
|
|
165
|
-
{rows.map((p, i) => (
|
|
166
|
-
<div key={i} className={css.row} style={{ gap: 'var(--nf-space-6)' }}>
|
|
167
|
-
<input className={css.input} style={{ flex: '0 0 140px', fontSize: 'var(--nf-fs-12)', padding: 'var(--nf-space-4) var(--nf-space-8)' }} value={p.name} onChange={e => { set(i, { name: e.target.value }) }} />
|
|
168
|
-
<input className={css.input} style={{ flex: 1, fontSize: 'var(--nf-fs-12)', padding: 'var(--nf-space-4) var(--nf-space-8)' }} value={p.desc} onChange={e => { set(i, { desc: e.target.value }) }} />
|
|
169
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setRows(rows.filter((_, idx) => idx !== i)) }}>删</button>
|
|
170
|
-
</div>
|
|
171
|
-
))}
|
|
172
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ alignSelf: 'flex-start' }} onClick={() => { setRows([...rows, { name: '', desc: '' }]) }}>+ 添加</button>
|
|
173
|
-
</div>
|
|
174
|
-
)
|
|
175
|
-
}
|
|
@@ -1,306 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 阅读页:沉浸式章节阅读(纯只读,零 LLM 消耗)。
|
|
3
|
-
* 布局:顶部工具栏 + 左侧可折叠目录栏(按卷分组)+ 阅读区。
|
|
4
|
-
* 轻量渲染:去掉 md 首行标题,按行分段;字号/主题/阅读进度 localStorage 记忆。
|
|
5
|
-
*/
|
|
6
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
7
|
-
import type { NovelApi } from '../api.ts'
|
|
8
|
-
import type { ProjectState, ChapterPlan } from '../../protocol.ts'
|
|
9
|
-
import css from './panel.module.css'
|
|
10
|
-
|
|
11
|
-
const FONT_SIZES = [
|
|
12
|
-
{ id: 'sm', px: 15, label: 'A-' },
|
|
13
|
-
{ id: 'md', px: 17, label: 'A' },
|
|
14
|
-
{ id: 'lg', px: 20, label: 'A+' },
|
|
15
|
-
] as const
|
|
16
|
-
|
|
17
|
-
const THEMES = [
|
|
18
|
-
{ id: 'paper', label: '📄 纸白', bg: '#f7f1e3', fg: '#3b3226', accent: '#8b6f47', dim: '#8a7d66' },
|
|
19
|
-
{ id: 'eye', label: '🌿 护眼', bg: '#e8f0e3', fg: '#2f3d2e', accent: '#5f7d5a', dim: '#6f836b' },
|
|
20
|
-
{ id: 'night', label: '🌙 夜间', bg: '#1d2226', fg: '#c9d1d9', accent: '#8aa5c0', dim: '#6d7882' },
|
|
21
|
-
] as const
|
|
22
|
-
|
|
23
|
-
type FontId = typeof FONT_SIZES[number]['id']
|
|
24
|
-
type ThemeId = typeof THEMES[number]['id']
|
|
25
|
-
|
|
26
|
-
function readPref<T>(key: string, fallback: T): T {
|
|
27
|
-
try {
|
|
28
|
-
const raw = window.localStorage.getItem(key)
|
|
29
|
-
if (raw !== null) return JSON.parse(raw) as T
|
|
30
|
-
} catch { /* ignore */ }
|
|
31
|
-
return fallback
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function writePref(key: string, value: unknown): void {
|
|
35
|
-
try { window.localStorage.setItem(key, JSON.stringify(value)) } catch { /* ignore */ }
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** 章节状态小标(阅读视角)。 */
|
|
39
|
-
function statusBadge(ch: ChapterPlan): { label: string; cls: string } {
|
|
40
|
-
switch (ch.status) {
|
|
41
|
-
case 'approved': return { label: '✅', cls: css.badgeDone }
|
|
42
|
-
case 'written': return { label: '📝', cls: css.badgeWritten }
|
|
43
|
-
case 'rejected': return { label: '⚠️', cls: css.badgePending }
|
|
44
|
-
default: return { label: '', cls: css.badgePending }
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** 目录分组:按卷区间过滤可读章节;卷外章节归入「未分卷」。 */
|
|
49
|
-
function buildGroups(project: ProjectState, readable: ChapterPlan[]): Array<{ title: string; no: number; chapters: ChapterPlan[] }> {
|
|
50
|
-
const vols = project.volumes ?? []
|
|
51
|
-
if (vols.length === 0) return [{ title: '全部章节', no: 0, chapters: readable }]
|
|
52
|
-
const groups: Array<{ title: string; no: number; chapters: ChapterPlan[] }> = []
|
|
53
|
-
for (const v of vols) {
|
|
54
|
-
const inVol = readable.filter(c => c.no >= v.chapterStart && c.no <= v.chapterEnd)
|
|
55
|
-
if (inVol.length > 0) groups.push({ title: `第${v.no}卷 ${v.title}`, no: v.no, chapters: inVol })
|
|
56
|
-
}
|
|
57
|
-
const rest = readable.filter(c => !vols.some(v => c.no >= v.chapterStart && c.no <= v.chapterEnd))
|
|
58
|
-
if (rest.length > 0) groups.push({ title: '未分卷', no: 0, chapters: rest })
|
|
59
|
-
return groups
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function ReaderView({
|
|
63
|
-
api,
|
|
64
|
-
project,
|
|
65
|
-
onBack,
|
|
66
|
-
onOpenWorkspace,
|
|
67
|
-
}: {
|
|
68
|
-
api: NovelApi
|
|
69
|
-
project: ProjectState
|
|
70
|
-
onBack: () => void
|
|
71
|
-
onOpenWorkspace: () => void
|
|
72
|
-
}) {
|
|
73
|
-
const prefKey = useMemo(() => {
|
|
74
|
-
const id = project.bookName || 'book'
|
|
75
|
-
return `dsh-novel-forge.reader.${id}`
|
|
76
|
-
}, [project.bookName])
|
|
77
|
-
|
|
78
|
-
/** 可读章节(有正文落盘):approved / written / rejected。 */
|
|
79
|
-
const readable = useMemo(() => project.chapters.filter(c =>
|
|
80
|
-
c.status === 'approved' || c.status === 'written' || c.status === 'rejected',
|
|
81
|
-
), [project.chapters])
|
|
82
|
-
|
|
83
|
-
const groups = useMemo(() => buildGroups(project, readable), [project, readable])
|
|
84
|
-
|
|
85
|
-
const [currentNo, setCurrentNo] = useState<number>(() => {
|
|
86
|
-
const saved = readPref<number | null>(`${prefKey}.no`, null)
|
|
87
|
-
if (saved !== null && readable.some(c => c.no === saved)) return saved
|
|
88
|
-
// 无记忆 → 最后一章已通过
|
|
89
|
-
const lastApproved = [...readable].reverse().find(c => c.status === 'approved')
|
|
90
|
-
return lastApproved?.no ?? readable[0]?.no ?? 0
|
|
91
|
-
})
|
|
92
|
-
const [markdown, setMarkdown] = useState<string>('')
|
|
93
|
-
const [loading, setLoading] = useState(false)
|
|
94
|
-
const [loadError, setLoadError] = useState<string>('')
|
|
95
|
-
const [font, setFont] = useState<FontId>(() => readPref(`${prefKey}.font`, 'md'))
|
|
96
|
-
const [theme, setTheme] = useState<ThemeId>(() => readPref(`${prefKey}.theme`, 'paper'))
|
|
97
|
-
const [navOpen, setNavOpen] = useState<boolean>(() => readPref(`${prefKey}.nav`, true))
|
|
98
|
-
/** 折叠的卷号(点击卷标题切换;记忆在 localStorage)。 */
|
|
99
|
-
const [collapsedVols, setCollapsedVols] = useState<number[]>(() => readPref(`${prefKey}.collapsed`, []))
|
|
100
|
-
const sidebarRef = useRef<HTMLDivElement>(null)
|
|
101
|
-
|
|
102
|
-
const toggleVol = useCallback((no: number) => {
|
|
103
|
-
setCollapsedVols(prev => {
|
|
104
|
-
const next = prev.includes(no) ? prev.filter(v => v !== no) : [...prev, no]
|
|
105
|
-
writePref(`${prefKey}.collapsed`, next)
|
|
106
|
-
return next
|
|
107
|
-
})
|
|
108
|
-
}, [prefKey])
|
|
109
|
-
|
|
110
|
-
const current = useMemo(() => project.chapters.find(c => c.no === currentNo), [project.chapters, currentNo])
|
|
111
|
-
const idx = readable.findIndex(c => c.no === currentNo)
|
|
112
|
-
const prevCh = idx > 0 ? readable[idx - 1] : undefined
|
|
113
|
-
const nextCh = idx >= 0 && idx < readable.length - 1 ? readable[idx + 1] : undefined
|
|
114
|
-
|
|
115
|
-
const load = useCallback(async (no: number) => {
|
|
116
|
-
if (no === 0) return
|
|
117
|
-
setLoading(true)
|
|
118
|
-
setLoadError('')
|
|
119
|
-
try {
|
|
120
|
-
const res = await api.chapter(no)
|
|
121
|
-
setMarkdown(res.markdown)
|
|
122
|
-
setCurrentNo(no)
|
|
123
|
-
writePref(`${prefKey}.no`, no)
|
|
124
|
-
} catch (err) {
|
|
125
|
-
setLoadError((err as Error).message)
|
|
126
|
-
} finally {
|
|
127
|
-
setLoading(false)
|
|
128
|
-
}
|
|
129
|
-
}, [api, prefKey])
|
|
130
|
-
|
|
131
|
-
// 初始加载
|
|
132
|
-
const initialNo = useRef<number>(0)
|
|
133
|
-
useEffect(() => {
|
|
134
|
-
if (initialNo.current === currentNo && markdown !== '') return
|
|
135
|
-
if (currentNo === 0) return
|
|
136
|
-
initialNo.current = currentNo
|
|
137
|
-
void load(currentNo)
|
|
138
|
-
}, [currentNo, load, markdown])
|
|
139
|
-
|
|
140
|
-
// 键盘翻章
|
|
141
|
-
useEffect(() => {
|
|
142
|
-
const onKey = (e: KeyboardEvent) => {
|
|
143
|
-
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
|
144
|
-
if (e.key === 'ArrowLeft' && prevCh !== undefined) void load(prevCh.no)
|
|
145
|
-
if (e.key === 'ArrowRight' && nextCh !== undefined) void load(nextCh.no)
|
|
146
|
-
}
|
|
147
|
-
window.addEventListener('keydown', onKey)
|
|
148
|
-
return () => window.removeEventListener('keydown', onKey)
|
|
149
|
-
}, [prevCh, nextCh, load])
|
|
150
|
-
|
|
151
|
-
// 当前章滚动到可见(目录栏内)
|
|
152
|
-
useEffect(() => {
|
|
153
|
-
sidebarRef.current?.querySelector<HTMLElement>(`[data-no="${currentNo}"]`)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
|
154
|
-
}, [currentNo])
|
|
155
|
-
|
|
156
|
-
const themeMeta = THEMES.find(t => t.id === theme) ?? THEMES[0]
|
|
157
|
-
const fontMeta = FONT_SIZES.find(f => f.id === font) ?? FONT_SIZES[1]
|
|
158
|
-
|
|
159
|
-
/** 轻量渲染:去标题行、按行分段(网文每行一段)。 */
|
|
160
|
-
const paragraphs = useMemo(() => {
|
|
161
|
-
return markdown
|
|
162
|
-
.split(/\r?\n/)
|
|
163
|
-
.map(l => l.replace(/^#{1,6}\s*/, '').trim())
|
|
164
|
-
.filter(Boolean)
|
|
165
|
-
}, [markdown])
|
|
166
|
-
|
|
167
|
-
const itemBg = theme === 'night' ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.025)'
|
|
168
|
-
|
|
169
|
-
return (
|
|
170
|
-
<div className={css.readerView} style={{ background: themeMeta.bg, color: themeMeta.fg }}>
|
|
171
|
-
{/* 顶部栏 */}
|
|
172
|
-
<div className={css.readerHeader} style={{ borderColor: themeMeta.dim }}>
|
|
173
|
-
<button type="button" className={css.iconButton} title="返回书架" aria-label="返回书架" onClick={onBack}>←</button>
|
|
174
|
-
<span className={css.readerTitle}>📖 《{project.bookName}》</span>
|
|
175
|
-
<span className={css.meta} style={{ color: themeMeta.dim }}>
|
|
176
|
-
{readable.length > 0 && currentNo > 0 ? `第 ${currentNo} 章 · ${idx + 1}/${readable.length}` : ''}
|
|
177
|
-
</span>
|
|
178
|
-
<button
|
|
179
|
-
type="button"
|
|
180
|
-
className={`${css.button} ${css.buttonSmall} ${navOpen ? css.buttonPrimary : ''}`}
|
|
181
|
-
style={{ marginLeft: 'var(--nf-space-4)' }}
|
|
182
|
-
onClick={() => { setNavOpen(v => { const next = !v; writePref(`${prefKey}.nav`, next); return next }) }}
|
|
183
|
-
title={navOpen ? '收起目录' : '展开目录'}
|
|
184
|
-
>
|
|
185
|
-
☰ 目录
|
|
186
|
-
</button>
|
|
187
|
-
<div style={{ flex: 1 }} />
|
|
188
|
-
<span className={css.meta} style={{ color: themeMeta.dim }}>字号</span>
|
|
189
|
-
<div className={css.readerSeg}>
|
|
190
|
-
{FONT_SIZES.map(f => (
|
|
191
|
-
<button
|
|
192
|
-
key={f.id}
|
|
193
|
-
type="button"
|
|
194
|
-
className={`${css.readerSegBtn} ${font === f.id ? css.readerSegActive : ''}`}
|
|
195
|
-
style={font === f.id ? { background: themeMeta.accent, color: themeMeta.bg } : undefined}
|
|
196
|
-
onClick={() => { setFont(f.id); writePref(`${prefKey}.font`, f.id) }}
|
|
197
|
-
title={`字号 ${f.label}`}
|
|
198
|
-
>
|
|
199
|
-
{f.label}
|
|
200
|
-
</button>
|
|
201
|
-
))}
|
|
202
|
-
</div>
|
|
203
|
-
<div className={css.readerSeg} style={{ marginLeft: 'var(--nf-space-8)' }}>
|
|
204
|
-
{THEMES.map(t => (
|
|
205
|
-
<button
|
|
206
|
-
key={t.id}
|
|
207
|
-
type="button"
|
|
208
|
-
className={`${css.readerSegBtn} ${theme === t.id ? css.readerSegActive : ''}`}
|
|
209
|
-
style={theme === t.id ? { background: themeMeta.accent, color: themeMeta.bg } : undefined}
|
|
210
|
-
onClick={() => { setTheme(t.id); writePref(`${prefKey}.theme`, t.id) }}
|
|
211
|
-
title={t.label}
|
|
212
|
-
>
|
|
213
|
-
{t.label}
|
|
214
|
-
</button>
|
|
215
|
-
))}
|
|
216
|
-
</div>
|
|
217
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ marginLeft: 'var(--nf-space-10)' }} onClick={onOpenWorkspace} title="回到创作工作台">
|
|
218
|
-
✏️ 去工作台
|
|
219
|
-
</button>
|
|
220
|
-
</div>
|
|
221
|
-
|
|
222
|
-
{/* 主体:左目录 + 右阅读区 */}
|
|
223
|
-
<div className={css.readerBody}>
|
|
224
|
-
{navOpen && (
|
|
225
|
-
<aside className={css.readerSidebar} style={{ borderColor: themeMeta.dim, background: theme === 'night' ? 'rgba(0,0,0,0.25)' : 'rgba(255,255,255,0.35)' }} ref={sidebarRef}>
|
|
226
|
-
{groups.length === 0 ? (
|
|
227
|
-
<div className={css.meta} style={{ color: themeMeta.dim, padding: 'var(--nf-space-12)' }}>还没有已写章节,先去工作台创作吧</div>
|
|
228
|
-
) : groups.map(g => {
|
|
229
|
-
const collapsed = collapsedVols.includes(g.no)
|
|
230
|
-
return (
|
|
231
|
-
<div key={g.no} className={css.readerGroup}>
|
|
232
|
-
<button
|
|
233
|
-
type="button"
|
|
234
|
-
className={css.readerGroupTitle}
|
|
235
|
-
style={{ color: themeMeta.accent }}
|
|
236
|
-
onClick={() => { toggleVol(g.no) }}
|
|
237
|
-
title={collapsed ? `展开${g.title}` : `收起${g.title}`}
|
|
238
|
-
>
|
|
239
|
-
<span className={css.readerGroupArrow}>{collapsed ? '▸' : '▾'}</span>
|
|
240
|
-
<span>{g.title}</span>
|
|
241
|
-
<span className={css.readerGroupCount}>{g.chapters.length}</span>
|
|
242
|
-
</button>
|
|
243
|
-
{!collapsed && g.chapters.map(ch => {
|
|
244
|
-
const badge = statusBadge(ch)
|
|
245
|
-
const active = ch.no === currentNo
|
|
246
|
-
return (
|
|
247
|
-
<button
|
|
248
|
-
key={ch.no}
|
|
249
|
-
type="button"
|
|
250
|
-
data-no={ch.no}
|
|
251
|
-
className={`${css.readerItem} ${active ? css.readerItemActive : ''}`}
|
|
252
|
-
style={active ? { background: themeMeta.accent, color: themeMeta.bg, borderColor: themeMeta.accent } : { borderColor: 'transparent', color: themeMeta.fg }}
|
|
253
|
-
onClick={() => { void load(ch.no) }}
|
|
254
|
-
title={`第${ch.no}章 ${ch.title}`}
|
|
255
|
-
>
|
|
256
|
-
<span className={css.readerItemNo}>{ch.no}</span>
|
|
257
|
-
<span className={css.readerItemTitle}>{ch.title}</span>
|
|
258
|
-
{badge.label !== '' && <span className={css.readerItemBadge}>{badge.label}</span>}
|
|
259
|
-
</button>
|
|
260
|
-
)
|
|
261
|
-
})}
|
|
262
|
-
</div>
|
|
263
|
-
)
|
|
264
|
-
})}
|
|
265
|
-
</aside>
|
|
266
|
-
)}
|
|
267
|
-
|
|
268
|
-
{/* 阅读区 */}
|
|
269
|
-
<div className={css.readerMain} style={{ background: itemBg }}>
|
|
270
|
-
{loadError !== '' ? (
|
|
271
|
-
<div className={css.readerError}>{loadError}</div>
|
|
272
|
-
) : loading && markdown === '' ? (
|
|
273
|
-
<div className={css.readerLoading}>⏳ 加载中…</div>
|
|
274
|
-
) : current !== undefined && currentNo > 0 ? (
|
|
275
|
-
<article className={css.readerArticle} style={{ maxWidth: 720 }}>
|
|
276
|
-
<h1 className={css.readerChapterTitle} style={{ color: themeMeta.accent }}>{current.title}</h1>
|
|
277
|
-
<div className={css.readerChapterMeta} style={{ color: themeMeta.dim }}>
|
|
278
|
-
<span>{statusBadge(current).label}</span>
|
|
279
|
-
<span>第 {current.no} 章</span>
|
|
280
|
-
{current.chars !== undefined && <span> · {current.chars.toLocaleString()} 字</span>}
|
|
281
|
-
</div>
|
|
282
|
-
<div className={css.readerText} style={{ fontSize: fontMeta.px, lineHeight: 1.9 }}>
|
|
283
|
-
{paragraphs.map((p, i) => <p key={i} className={css.readerPara}>{p}</p>)}
|
|
284
|
-
</div>
|
|
285
|
-
<div className={css.readerFoot}>
|
|
286
|
-
{prevCh !== undefined && (
|
|
287
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ borderColor: themeMeta.dim, color: themeMeta.fg }} onClick={() => { void load(prevCh.no) }} title="← 上一章">
|
|
288
|
-
← 第{prevCh.no}章
|
|
289
|
-
</button>
|
|
290
|
-
)}
|
|
291
|
-
<span className={css.meta} style={{ color: themeMeta.dim }}>← → 键盘翻章</span>
|
|
292
|
-
{nextCh !== undefined && (
|
|
293
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ borderColor: themeMeta.accent, color: themeMeta.accent }} onClick={() => { void load(nextCh.no) }} title="下一章 →">
|
|
294
|
-
第{nextCh.no}章 →
|
|
295
|
-
</button>
|
|
296
|
-
)}
|
|
297
|
-
</div>
|
|
298
|
-
</article>
|
|
299
|
-
) : (
|
|
300
|
-
<div className={css.readerLoading}>暂无内容</div>
|
|
301
|
-
)}
|
|
302
|
-
</div>
|
|
303
|
-
</div>
|
|
304
|
-
</div>
|
|
305
|
-
)
|
|
306
|
-
}
|