feat: 实现笔临 P0/P1 Electron 桌面应用 v0.1.0

交付写作核心(TipTap、自动保存、分卷分章)、Onboarding、7 主题与双语 i18n、快捷键设置;主进程使用 node:sqlite;补全 Vitest 与 Playwright E2E;统一 design/ 目录并移除 desigin 拼写错误路径。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 10:39:20 +08:00
parent a06038035b
commit 011bd6d4ca
70 changed files with 14245 additions and 84 deletions
@@ -0,0 +1,149 @@
import { useTranslation } from 'react-i18next'
import { useAtomValue } from 'jotai'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { ipcCall } from '@renderer/lib/ipc-client'
import { TipTapEditor, flushEditorSave } from '@renderer/components/editor/TipTapEditor'
import { RightPanel } from '@renderer/components/layout/RightPanel'
import {
editorDirtyAtom,
editorSavingAtom,
lastSavedAtAtom,
wordCountAtom
} from '@renderer/atoms/editorAtoms'
export function EditorLayout(): React.JSX.Element {
const { t } = useTranslation()
const showToast = useAppStore((s) => s.showToast)
const sidebarPanel = useAppStore((s) => s.setSidebarPanel)
const activePanel = useAppStore((s) => s.sidebarPanel)
const {
currentBookId,
volumes,
chapters,
selectedChapterId,
activeVolumeId,
setSelectedChapter,
setActiveVolume,
refreshChapters
} = useBookStore()
const dirty = useAtomValue(editorDirtyAtom)
const saving = useAtomValue(editorSavingAtom)
const lastSaved = useAtomValue(lastSavedAtAtom)
const wordCount = useAtomValue(wordCountAtom)
const currentChapter = chapters.find((c) => c.id === selectedChapterId) ?? null
const bookMeta = useBookStore.getState().books.find((b) => b.id === currentBookId)
const handleSelectChapter = async (chapterId: string): Promise<void> => {
await flushEditorSave()
setSelectedChapter(chapterId)
}
const handleNewChapter = async (): Promise<void> => {
if (!currentBookId || !activeVolumeId) return
await flushEditorSave()
const ch = await ipcCall(() =>
window.electronAPI.chapter.create(currentBookId, activeVolumeId, t('editor.newChapter'))
)
await refreshChapters()
setSelectedChapter(ch.id)
}
const handleNewVolume = async (): Promise<void> => {
if (!currentBookId) return
const vol = await ipcCall(() =>
window.electronAPI.volume.create(currentBookId, t('editor.newVolume'))
)
await refreshChapters()
setActiveVolume(vol.id)
}
const saveStatus = saving ? t('editor.saving') : dirty ? t('editor.unsaved') : t('editor.saved')
return (
<div id="editor-layout">
<div id="left-sidebar">
<div className="sidebar-header">{bookMeta?.name ?? '—'}</div>
<div className="sidebar-nav">
{(['chapters', 'outline', 'setting', 'inspiration'] as const).map((panel) => (
<button
key={panel}
type="button"
className={`nav-btn ${activePanel === panel ? 'active' : ''}`}
onClick={() => sidebarPanel(panel)}
>
{panel === 'chapters' && t('sidebar.chapters')}
{panel === 'outline' && t('sidebar.outline')}
{panel === 'setting' && t('sidebar.settings')}
{panel === 'inspiration' && t('sidebar.inspiration')}
</button>
))}
</div>
<div className={`sidebar-panel ${activePanel === 'chapters' ? 'active' : ''}`}>
{volumes.map((vol) => (
<div key={vol.id}>
<div
className="vol-header"
onClick={() => setActiveVolume(vol.id)}
style={{ color: activeVolumeId === vol.id ? 'var(--accent-light)' : undefined }}
>
{vol.name}
</div>
{chapters
.filter((c) => c.volumeId === vol.id)
.map((ch, idx) => (
<div
key={ch.id}
className={`chapter-item ${selectedChapterId === ch.id ? 'active' : ''}`}
onClick={() => void handleSelectChapter(ch.id)}
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>
<span className={`ch-badge ${ch.status === 'done' ? 'done' : 'draft'}`}>{ch.status}</span>
</div>
))}
</div>
))}
</div>
{activePanel !== 'chapters' && (
<div className="sidebar-panel active">
<div className="placeholder-box" style={{ padding: 20 }}>
{t('feature.comingSoon')}
</div>
</div>
)}
<div className="sidebar-footer">
<button type="button" onClick={() => void handleNewChapter()}>
+ {t('editor.newChapter')}
</button>
<button type="button" style={{ marginTop: 6 }} onClick={() => void handleNewVolume()}>
+ {t('editor.newVolume')}
</button>
</div>
</div>
<div id="editor-area">
<div id="editor-toolbar">
<button type="button" className="tool-btn" onClick={() => showToast(t('feature.comingSoon'))} title="引用">
📎
</button>
<span className="editor-label">
{currentChapter ? currentChapter.title : '—'}
</span>
</div>
<TipTapEditor chapter={currentChapter} bookId={currentBookId} />
<div id="editor-statusbar">
<span>{saveStatus}{lastSaved ? ` · ${lastSaved.toLocaleTimeString()}` : ''}</span>
<span>{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>
</div>
</div>
<RightPanel />
</div>
)
}