feat: ship v1.2.0 Wave 1 foundation with book settings, chapter management, and focus mode

Deliver schema v9, book/chapter IPC extensions, BookSettingsTab, chapter meta/tags,
AI session menu, focus mode and TTS, template context enrich, and Wave 1 E2E coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-08 15:22:58 +08:00
parent 64da95f7e1
commit fe127ec3ed
57 changed files with 1963 additions and 139 deletions
+118 -2
View File
@@ -26,6 +26,8 @@ import { CockpitModal } from '@renderer/components/cockpit/CockpitModal'
import { CharacterGraphModal } from '@renderer/components/graph/CharacterGraphModal'
import { ChapterBridgeModal } from '@renderer/components/bridge/ChapterBridgeModal'
import { TemplateQuickCreateModal } from '@renderer/components/chapter/TemplateQuickCreateModal'
import { ChapterMetaPanel } from '@renderer/components/chapter/ChapterMetaPanel'
import { VolumeContextMenu } from '@renderer/components/volume/VolumeContextMenu'
import { ExportModal } from '@renderer/components/export/ExportModal'
import { useExportStore } from '@renderer/stores/useExportStore'
import {
@@ -121,6 +123,12 @@ export function EditorLayout(): React.JSX.Element {
const [bridgeOpen, setBridgeOpen] = useState(false)
const [templateModalOpen, setTemplateModalOpen] = useState(false)
const [chapterMetaOpen, setChapterMetaOpen] = useState(false)
const [volumeMenu, setVolumeMenu] = useState<{ volumeId: string; x: number; y: number } | null>(
null
)
const [renamingChapterId, setRenamingChapterId] = useState<string | null>(null)
const [renameDraft, setRenameDraft] = useState('')
const [cockpitSummary, setCockpitSummary] = useState<CockpitSummary | null>(null)
const bookMeta = useBookStore.getState().books.find((b) => b.id === currentBookId)
@@ -237,6 +245,36 @@ export function EditorLayout(): React.JSX.Element {
setActiveVolume(vol.id)
}
const commitChapterRename = async (chapterId: string): Promise<void> => {
if (!currentBookId) return
const ch = chapters.find((c) => c.id === chapterId)
const title = renameDraft.trim()
setRenamingChapterId(null)
if (!ch || !title || title === ch.title) return
const updated = await ipcCall(() =>
window.electronAPI.chapter.update({ bookId: currentBookId, chapterId, title })
)
updateChapterLocal(updated)
}
const handleDeleteChapter = async (chapterId: string, e: React.MouseEvent): Promise<void> => {
e.stopPropagation()
if (!currentBookId) return
const ch = chapters.find((c) => c.id === chapterId)
if (!ch || !window.confirm(t('chapter.deleteConfirm', { title: ch.title }))) return
await flushEditorSave()
await ipcCall(() => window.electronAPI.chapter.delete(currentBookId, chapterId))
await refreshChapters()
if (selectedChapterId === chapterId) {
const remaining = chapters.filter((c) => c.id !== chapterId)
const next = remaining.find((c) => c.volumeId === ch.volumeId) ?? remaining[0]
if (next) {
setSelectedChapter(next.id)
await switchTarget({ kind: 'chapter', id: next.id })
}
}
}
const [dragOverChapterId, setDragOverChapterId] = useState<string | null>(null)
const handleChapterDropOnItem = async (
@@ -309,6 +347,12 @@ export function EditorLayout(): React.JSX.Element {
}
const saveStatus = saving ? t('editor.saving') : dirty ? t('editor.unsaved') : t('editor.saved')
const chapterOverThreshold =
currentChapter?.wordCountThreshold != null &&
wordCount.chapter > currentChapter.wordCountThreshold
const volumeMenuVolume = volumeMenu
? volumes.find((v) => v.id === volumeMenu.volumeId)
: null
return (
<div id="editor-layout" data-book-id={currentBookId ?? ''}>
@@ -337,6 +381,10 @@ export function EditorLayout(): React.JSX.Element {
className="vol-header"
data-volume-id={vol.id}
onClick={() => setActiveVolume(vol.id)}
onContextMenu={(e) => {
e.preventDefault()
setVolumeMenu({ volumeId: vol.id, x: e.clientX, y: e.clientY })
}}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault()
@@ -371,12 +419,44 @@ export function EditorLayout(): React.JSX.Element {
if (draggedId) void handleChapterDropOnItem(draggedId, vol.id, ch.id)
}}
onClick={() => void handleSelectChapter(ch.id)}
onDoubleClick={(e) => {
e.stopPropagation()
setRenamingChapterId(ch.id)
setRenameDraft(ch.title)
}}
onKeyDown={(e) => e.key === 'Enter' && void handleSelectChapter(ch.id)}
role="button"
tabIndex={0}
>
<span>{idx + 1}.</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{ch.title}</span>
{renamingChapterId === ch.id ? (
<input
className="chapter-rename-input"
data-testid={`chapter-rename-${ch.id}`}
value={renameDraft}
autoFocus
onClick={(e) => e.stopPropagation()}
onChange={(e) => setRenameDraft(e.target.value)}
onBlur={() => void commitChapterRename(ch.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') void commitChapterRename(ch.id)
if (e.key === 'Escape') setRenamingChapterId(null)
}}
/>
) : (
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{ch.title}
</span>
)}
<button
type="button"
className="ch-delete-btn"
title={t('chapter.delete')}
data-testid={`chapter-delete-${ch.id}`}
onClick={(e) => void handleDeleteChapter(ch.id, e)}
>
×
</button>
<button
type="button"
className="ch-publish-btn"
@@ -560,7 +640,27 @@ export function EditorLayout(): React.JSX.Element {
))}
</select>
</label>
<span>{t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })}</span>
<button
type="button"
className="btn btn-sm status-meta-btn"
data-testid="chapter-meta-open"
onClick={() => setChapterMetaOpen(true)}
>
{t('chapterMeta.open')}
</button>
{currentChapter?.publishStatus === 'published' && currentChapter.publishedAt && (
<span className="status-published-at" data-testid="status-published-at">
{t('chapterMeta.publishedAt', {
date: new Date(currentChapter.publishedAt).toLocaleString('zh-CN')
})}
</span>
)}
<span
className={chapterOverThreshold ? 'status-word-over-threshold' : undefined}
data-testid="status-chapter-words"
>
{t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })}
</span>
<span>{t('status.volume')}: {t('editor.words', { count: wordCount.volume })}</span>
<span>{t('status.book')}: {t('editor.words', { count: wordCount.book })}</span>
</>
@@ -598,6 +698,22 @@ export function EditorLayout(): React.JSX.Element {
open={templateModalOpen}
onClose={() => setTemplateModalOpen(false)}
/>
<ChapterMetaPanel
open={chapterMetaOpen}
chapter={currentChapter}
onClose={() => setChapterMetaOpen(false)}
/>
{volumeMenu && volumeMenuVolume && currentBookId && (
<VolumeContextMenu
bookId={currentBookId}
volume={volumeMenuVolume}
hasChapters={chapters.some((c) => c.volumeId === volumeMenuVolume.id)}
x={volumeMenu.x}
y={volumeMenu.y}
onClose={() => setVolumeMenu(null)}
onChanged={refreshChapters}
/>
)}
<ExportModal />
</div>
)