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
+138
View File
@@ -0,0 +1,138 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useTabStore } from '@renderer/stores/useTabStore'
import { ipcCall } from '@renderer/lib/ipc-client'
export function HomePage(): React.JSX.Element {
const { t } = useTranslation()
const showToast = useAppStore((s) => s.showToast)
const setView = useAppStore((s) => s.setView)
const { books, loadBooks, openBook } = useBookStore()
const { openBookTab, openSettingsTab } = useTabStore()
const [showDialog, setShowDialog] = useState(false)
const [name, setName] = useState('')
const [category, setCategory] = useState('玄幻')
const [target, setTarget] = useState('')
const handleOpenBook = async (bookId: string, bookName: string): Promise<void> => {
await openBook(bookId)
openBookTab(bookId, bookName)
setView('editor')
}
const handleCreate = async (): Promise<void> => {
if (!name.trim()) {
showToast(t('toast.enterBookName'))
return
}
const meta = await ipcCall(() =>
window.electronAPI.book.create({
name: name.trim(),
category,
targetWordCount: target ? Number(target) : null
})
)
setShowDialog(false)
setName('')
await loadBooks()
showToast(t('toast.bookCreated'))
await handleOpenBook(meta.id, meta.name)
}
return (
<div id="home-page">
<div className="home-hero">
<h1> {t('app.name')}</h1>
<p className="subtitle">{t('app.tagline')}</p>
</div>
<div className="home-actions">
<button type="button" className="btn-large primary" onClick={() => setShowDialog(true)}>
+ {t('home.newBook')}
</button>
<button type="button" className="btn-large" onClick={() => showToast(t('feature.comingSoon'))}>
{t('home.importBook')}
</button>
<button type="button" className="btn-large" onClick={() => showToast(t('feature.comingSoon'))}>
{t('home.exportAll')}
</button>
<button
type="button"
className="btn-large"
onClick={() => {
openSettingsTab()
setView('settings')
}}
>
{t('settings.title')}
</button>
</div>
<h3 style={{ marginBottom: 12, fontSize: 14 }}>
📚 {t('home.myBooks')}{' '}
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}>
{t('home.bookCount', { count: books.length })}
</span>
</h3>
<div className="book-grid">
{books.map((book) => (
<div
key={book.id}
className="book-card"
onClick={() => void handleOpenBook(book.id, book.name)}
onKeyDown={(e) => e.key === 'Enter' && void handleOpenBook(book.id, book.name)}
role="button"
tabIndex={0}
>
<div className="book-name">{book.name}</div>
<div className="book-meta">
<div>{book.category}</div>
<div>{new Date(book.lastOpenedAt).toLocaleDateString()}</div>
</div>
</div>
))}
<div
className="book-card"
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 100, color: 'var(--text-muted)' }}
onClick={() => setShowDialog(true)}
role="button"
tabIndex={0}
>
+ {t('home.newBook')}
</div>
</div>
{showDialog && (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog-content">
<h3 style={{ marginBottom: 16 }}>{t('dialog.newBook.title')}</h3>
<div className="form-group">
<label>{t('dialog.newBook.name')}</label>
<input value={name} onChange={(e) => setName(e.target.value)} autoFocus />
</div>
<div className="form-group">
<label>{t('dialog.newBook.category')}</label>
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="玄幻"></option>
<option value="仙侠"></option>
<option value="科幻"></option>
<option value="言情"></option>
</select>
</div>
<div className="form-group">
<label>{t('dialog.newBook.target')}</label>
<input type="number" value={target} onChange={(e) => setTarget(e.target.value)} />
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
<button type="button" className="btn" onClick={() => setShowDialog(false)}>
{t('dialog.cancel')}
</button>
<button type="button" className="btn primary" onClick={() => void handleCreate()}>
{t('dialog.create')}
</button>
</div>
</div>
</div>
)}
</div>
)
}