feat: ship v0.6.0 with cockpit, knowledge base, and chapter bridge

Deliver P5 writer workflow: writing cockpit, manual knowledge CRUD with review, chapter bridge with optional AI, publish status, and stock buffer reminders.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 23:03:11 +08:00
parent 78f046890d
commit b33d2e7b34
45 changed files with 2389 additions and 29 deletions
@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { ChapterBridgeResult } from '@shared/types'
import { useBookStore } from '@renderer/stores/useBookStore'
import { ipcCall } from '@renderer/lib/ipc-client'
interface ChapterBridgeModalProps {
open: boolean
chapterId: string | null
onClose: () => void
}
export function ChapterBridgeModal({
open,
chapterId,
onClose
}: ChapterBridgeModalProps): React.JSX.Element | null {
const { t } = useTranslation()
const bookId = useBookStore((s) => s.currentBookId)
const [result, setResult] = useState<ChapterBridgeResult | null>(null)
const [loading, setLoading] = useState(false)
const [dismiss, setDismiss] = useState(false)
useEffect(() => {
if (!open || !bookId || !chapterId) return
setLoading(true)
setResult(null)
void ipcCall(() => window.electronAPI.bridge.get(bookId, chapterId, true))
.then(setResult)
.finally(() => setLoading(false))
}, [open, bookId, chapterId])
const handleClose = async (): Promise<void> => {
if (dismiss && bookId && chapterId) {
await ipcCall(() => window.electronAPI.bridge.dismiss(bookId, chapterId))
}
setDismiss(false)
onClose()
}
if (!open) return null
return (
<div className="modal-overlay" data-testid="bridge-modal">
<div className="modal-content modal-content--wide">
<div className="modal-header">
<h3>{t('bridge.title')}</h3>
<button type="button" className="modal-close" onClick={() => void handleClose()}>
</button>
</div>
<div className="modal-body bridge-body">
{loading ? (
<div className="sidebar-empty">{t('bridge.loading')}</div>
) : result ? (
<>
<div className="bridge-section">
<h4>{t('bridge.previousTail')}</h4>
<p data-testid="bridge-previous-tail">{result.previousTail || t('bridge.none')}</p>
</div>
<div className="bridge-section">
<h4>{t('bridge.currentHead')}</h4>
<p data-testid="bridge-current-head">{result.currentHead || t('bridge.none')}</p>
</div>
{result.aiSuggestion && (
<div className="bridge-section">
<h4>{t('bridge.aiSuggestion')}</h4>
<p data-testid="bridge-ai-suggestion">{result.aiSuggestion}</p>
</div>
)}
</>
) : (
<div className="sidebar-empty">{t('bridge.empty')}</div>
)}
<label className="bridge-dismiss">
<input
type="checkbox"
data-testid="bridge-dismiss-checkbox"
checked={dismiss}
onChange={(e) => setDismiss(e.target.checked)}
/>
{t('bridge.dismissLabel')}
</label>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-primary"
data-testid="bridge-start-writing"
onClick={() => void handleClose()}
>
{t('bridge.startWriting')}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,120 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useEditStore } from '@renderer/stores/useEditStore'
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
import { useKnowledgeStore } from '@renderer/stores/useKnowledgeStore'
interface CockpitModalProps {
onOpenBridge: (chapterId: string) => void
}
export function CockpitModal({ onOpenBridge }: CockpitModalProps): React.JSX.Element | null {
const { t } = useTranslation()
const bookId = useBookStore((s) => s.currentBookId)
const activeVolumeId = useBookStore((s) => s.activeVolumeId)
const selectedChapterId = useBookStore((s) => s.selectedChapterId)
const setSelectedChapter = useBookStore((s) => s.setSelectedChapter)
const switchTarget = useEditStore((s) => s.switchTarget)
const { open, summary, loading, close, loadSummary } = useCockpitStore()
const openForgottenFilter = useKnowledgeStore((s) => s.openForgottenFilter)
useEffect(() => {
if (!open || !bookId) return
void loadSummary(bookId, activeVolumeId ?? undefined)
}, [open, bookId, activeVolumeId, loadSummary])
if (!open) return null
const handleResume = async (): Promise<void> => {
if (selectedChapterId) {
setSelectedChapter(selectedChapterId)
await switchTarget({ kind: 'chapter', id: selectedChapterId })
}
close()
}
const handleBridge = (): void => {
const chapterId = selectedChapterId
if (chapterId) {
close()
onOpenBridge(chapterId)
}
}
const handleForgotten = (): void => {
openForgottenFilter()
close()
}
return (
<div className="modal-overlay" data-testid="cockpit-modal">
<div className="modal-content modal-content--wide">
<div className="modal-header">
<h3>{t('cockpit.title')}</h3>
<button type="button" className="modal-close" onClick={close}>
</button>
</div>
<div className="cockpit-body">
{loading || !summary ? (
<div className="sidebar-empty">{t('cockpit.loading')}</div>
) : (
<div className="cockpit-grid">
<div className="cockpit-card" data-testid="cockpit-total-words">
<div className="cockpit-card-label">{t('cockpit.totalWords')}</div>
<div className="cockpit-card-value">{summary.totalWords.toLocaleString()}</div>
</div>
<div className="cockpit-card" data-testid="cockpit-volume-words">
<div className="cockpit-card-label">{t('cockpit.volumeWords')}</div>
<div className="cockpit-card-value">{summary.volumeWords.toLocaleString()}</div>
</div>
<div className="cockpit-card" data-testid="cockpit-stock-count">
<div className="cockpit-card-label">{t('cockpit.stockReady')}</div>
<div className="cockpit-card-value">
{summary.stockReadyCount} / {summary.stockThreshold}
</div>
</div>
<div className="cockpit-card" data-testid="cockpit-foreshadow-summary">
<div className="cockpit-card-label">{t('cockpit.foreshadow')}</div>
<div className="cockpit-card-value">
{t('cockpit.foreshadowDetail', {
buried: summary.foreshadowBuried,
resolved: summary.foreshadowResolved,
forgotten: summary.foreshadowForgotten
})}
</div>
</div>
</div>
)}
</div>
<div className="modal-footer cockpit-actions">
<button
type="button"
className="btn btn-primary"
data-testid="cockpit-resume-edit"
onClick={() => void handleResume()}
>
{t('cockpit.resumeEdit')}
</button>
<button
type="button"
className="btn"
data-testid="cockpit-bridge-check"
onClick={handleBridge}
>
{t('cockpit.bridgeCheck')}
</button>
<button
type="button"
className="btn"
data-testid="cockpit-forgotten-foreshadow"
onClick={handleForgotten}
>
{t('cockpit.forgottenForeshadow')}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,216 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
CreateKnowledgeInput,
ForeshadowStatus,
KnowledgeEntry,
KnowledgeType
} from '@shared/types'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useKnowledgeStore } from '@renderer/stores/useKnowledgeStore'
import { ipcCall } from '@renderer/lib/ipc-client'
interface KnowledgeEditorDialogProps {
open: boolean
entryId: string | null
onClose: () => void
}
const TYPES: KnowledgeType[] = ['character', 'foreshadow', 'location', 'relationship', 'fact']
export function KnowledgeEditorDialog({
open,
entryId,
onClose
}: KnowledgeEditorDialogProps): React.JSX.Element | null {
const { t } = useTranslation()
const bookId = useBookStore((s) => s.currentBookId)
const chapters = useBookStore((s) => s.chapters)
const entries = useKnowledgeStore((s) => s.entries)
const create = useKnowledgeStore((s) => s.create)
const update = useKnowledgeStore((s) => s.update)
const existing = entryId ? entries.find((e) => e.id === entryId) : null
const [type, setType] = useState<KnowledgeType>('fact')
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [importance, setImportance] = useState(3)
const [foreshadowStatus, setForeshadowStatus] = useState<ForeshadowStatus>('buried')
const [sourceChapterId, setSourceChapterId] = useState('')
const [approveNow, setApproveNow] = useState(false)
useEffect(() => {
if (!open) return
if (existing) {
setType(existing.type)
setTitle(existing.title)
setContent(existing.content)
setImportance(existing.importance)
setForeshadowStatus(existing.foreshadowStatus ?? 'buried')
setSourceChapterId(existing.sourceChapterId ?? '')
setApproveNow(existing.status === 'approved')
} else {
setType('fact')
setTitle('')
setContent('')
setImportance(3)
setForeshadowStatus('buried')
setSourceChapterId(chapters[0]?.id ?? '')
setApproveNow(false)
}
}, [open, existing, chapters])
const handleSave = async (): Promise<void> => {
if (!bookId || !title.trim()) return
const input: CreateKnowledgeInput = {
type,
title: title.trim(),
content,
importance,
foreshadowStatus: type === 'foreshadow' ? foreshadowStatus : undefined,
sourceChapterId: sourceChapterId || undefined,
status: approveNow ? 'approved' : 'pending'
}
if (existing) {
await update(bookId, existing.id, input)
} else {
await create(bookId, input)
}
onClose()
}
if (!open) return null
return (
<div className="modal-overlay" data-testid="knowledge-editor-dialog">
<div className="modal-content">
<div className="modal-header">
<h3>{existing ? t('knowledge.edit') : t('knowledge.new')}</h3>
<button type="button" className="modal-close" onClick={onClose}>
</button>
</div>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<label>
{t('knowledge.type')}
<select
className="form-control"
data-testid="knowledge-type-select"
value={type}
onChange={(e) => setType(e.target.value as KnowledgeType)}
>
{TYPES.map((tp) => (
<option key={tp} value={tp}>
{t(`knowledge.type.${tp}`)}
</option>
))}
</select>
</label>
<label>
{t('knowledge.titleLabel')}
<input
className="form-control"
data-testid="knowledge-title-input"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</label>
<label>
{t('knowledge.contentLabel')}
<textarea
className="form-control"
rows={4}
data-testid="knowledge-content-input"
value={content}
onChange={(e) => setContent(e.target.value)}
/>
</label>
<label>
{t('knowledge.importance')}
<input
type="range"
min={1}
max={5}
value={importance}
onChange={(e) => setImportance(Number(e.target.value))}
/>
{importance}
</label>
{type === 'foreshadow' && (
<label>
{t('knowledge.foreshadowStatus')}
<select
className="form-control"
value={foreshadowStatus}
onChange={(e) => setForeshadowStatus(e.target.value as ForeshadowStatus)}
>
<option value="buried">{t('knowledge.foreshadow.buried')}</option>
<option value="partial">{t('knowledge.foreshadow.partial')}</option>
<option value="resolved">{t('knowledge.foreshadow.resolved')}</option>
</select>
</label>
)}
<label>
{t('knowledge.sourceChapter')}
<select
className="form-control"
value={sourceChapterId}
onChange={(e) => setSourceChapterId(e.target.value)}
>
<option value="">{t('knowledge.noChapter')}</option>
{chapters.map((ch) => (
<option key={ch.id} value={ch.id}>
{ch.title}
</option>
))}
</select>
</label>
{!existing && (
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
data-testid="knowledge-approve-now"
checked={approveNow}
onChange={(e) => setApproveNow(e.target.checked)}
/>
{t('knowledge.approveNow')}
</label>
)}
</div>
<div className="modal-footer">
<button type="button" className="btn" onClick={onClose}>
{t('dialog.cancel')}
</button>
<button
type="button"
className="btn btn-primary"
data-testid="knowledge-save"
onClick={() => void handleSave()}
>
{t('dialog.save')}
</button>
</div>
</div>
</div>
)
}
export async function loadKnowledgeEntries(
bookId: string,
tab: 'pending' | 'all' | 'foreshadow',
forgottenOnly: boolean
): Promise<{ entries: KnowledgeEntry[]; stats: { buried: number; resolved: number; forgotten: number } }> {
let filter: { status?: 'pending'; type?: 'foreshadow'; forgottenOnly?: boolean } | undefined
if (tab === 'pending') filter = { status: 'pending' }
else if (tab === 'foreshadow') {
filter = forgottenOnly
? { type: 'foreshadow', forgottenOnly: true }
: { type: 'foreshadow' }
}
const [entries, stats] = await Promise.all([
ipcCall(() => window.electronAPI.knowledge.list(bookId, filter)),
ipcCall(() => window.electronAPI.knowledge.stats(bookId))
])
return { entries, stats }
}
@@ -1,15 +1,68 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useKnowledgeStore } from '@renderer/stores/useKnowledgeStore'
import { ipcCall } from '@renderer/lib/ipc-client'
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
import {
KnowledgeEditorDialog,
loadKnowledgeEntries
} from '@renderer/components/knowledge/KnowledgeEditorDialog'
export function KnowledgePanel(): React.JSX.Element {
const { t } = useTranslation()
const bookId = useBookStore((s) => s.currentBookId)
const {
entries,
stats,
tab,
forgottenOnly,
editorOpen,
editingId,
setTab,
openEditor,
closeEditor,
approve,
reject,
deleteEntry,
batchApprove
} = useKnowledgeStore()
const [namingOpen, setNamingOpen] = useState(false)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [pendingCount, setPendingCount] = useState(0)
useEffect(() => {
if (!bookId) return
void loadKnowledgeEntries(bookId, tab, forgottenOnly).then(({ entries, stats }) => {
useKnowledgeStore.setState({ entries, stats })
})
}, [bookId, tab, forgottenOnly])
useEffect(() => {
if (!bookId) return
void ipcCall(() => window.electronAPI.knowledge.list(bookId, { status: 'pending' })).then(
(list) => setPendingCount(list.length)
)
}, [bookId, entries])
const handleBatchApprove = async (): Promise<void> => {
if (!bookId || selected.size === 0) return
await batchApprove(bookId, [...selected])
setSelected(new Set())
}
return (
<>
<div className="knowledge-panel" data-testid="knowledge-panel">
<div className="knowledge-toolbar">
<button
type="button"
className="btn btn-primary"
data-testid="knowledge-new"
onClick={() => openEditor(null)}
>
{t('knowledge.new')}
</button>
<button
type="button"
className="btn"
@@ -19,8 +72,115 @@ export function KnowledgePanel(): React.JSX.Element {
{t('naming.open')}
</button>
</div>
<div className="placeholder-box">{t('knowledge.placeholder')}</div>
<div className="knowledge-tabs">
<button
type="button"
className={`kb-tab ${tab === 'pending' ? 'active' : ''}`}
data-testid="knowledge-tab-pending"
onClick={() => setTab('pending')}
>
{t('knowledge.tab.pending')}
{pendingCount > 0 && <span className="kb-badge">{pendingCount}</span>}
</button>
<button
type="button"
className={`kb-tab ${tab === 'all' ? 'active' : ''}`}
data-testid="knowledge-tab-all"
onClick={() => setTab('all')}
>
{t('knowledge.tab.all')}
</button>
<button
type="button"
className={`kb-tab ${tab === 'foreshadow' ? 'active' : ''}`}
data-testid="knowledge-tab-foreshadow"
onClick={() => setTab('foreshadow')}
>
{t('knowledge.tab.foreshadow')}
</button>
</div>
{tab === 'foreshadow' && (
<div className="knowledge-stats" data-testid="knowledge-foreshadow-stats">
{t('knowledge.statsSummary', {
buried: stats.buried,
resolved: stats.resolved,
forgotten: stats.forgotten
})}
</div>
)}
{tab === 'pending' && selected.size > 0 && (
<button
type="button"
className="btn"
data-testid="knowledge-batch-approve"
onClick={() => void handleBatchApprove()}
>
{t('knowledge.batchApprove', { count: selected.size })}
</button>
)}
<div className="knowledge-list">
{entries.map((entry) => (
<div key={entry.id} className="kb-item" data-testid={`knowledge-item-${entry.id}`}>
{tab === 'pending' && (
<input
type="checkbox"
checked={selected.has(entry.id)}
onChange={(e) => {
const next = new Set(selected)
if (e.target.checked) next.add(entry.id)
else next.delete(entry.id)
setSelected(next)
}}
/>
)}
<div className="kb-item-main">
<div className="kb-item-title">{entry.title}</div>
<div className="kb-item-meta">
{t(`knowledge.type.${entry.type}`)} · {t(`knowledge.status.${entry.status}`)}
</div>
{entry.content && <div className="kb-item-content">{entry.content}</div>}
</div>
<div className="kb-item-actions">
{entry.status === 'pending' && bookId && (
<>
<button
type="button"
className="btn btn-sm"
data-testid={`knowledge-approve-${entry.id}`}
onClick={() => void approve(bookId, entry.id)}
>
{t('knowledge.approve')}
</button>
<button
type="button"
className="btn btn-sm"
onClick={() => void reject(bookId, entry.id)}
>
{t('knowledge.reject')}
</button>
</>
)}
<button type="button" className="btn btn-sm" onClick={() => openEditor(entry.id)}>
{t('knowledge.edit')}
</button>
{bookId && (
<button
type="button"
className="btn btn-sm"
onClick={() => void deleteEntry(bookId, entry.id)}
>
{t('knowledge.delete')}
</button>
)}
</div>
</div>
))}
{entries.length === 0 && (
<div className="sidebar-empty">{t('knowledge.empty')}</div>
)}
</div>
</div>
<KnowledgeEditorDialog open={editorOpen} entryId={editingId} onClose={closeEditor} />
<NamingCheckModal open={namingOpen} onClose={() => setNamingOpen(false)} />
</>
)
+109 -4
View File
@@ -1,11 +1,14 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAtomValue } from 'jotai'
import type { CockpitSummary, PublishStatus } from '@shared/types'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useEditStore } from '@renderer/stores/useEditStore'
import { useOutlineStore } from '@renderer/stores/useOutlineStore'
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
import { ipcCall } from '@renderer/lib/ipc-client'
import { TipTapEditor, flushEditorSave } from '@renderer/components/editor/TipTapEditor'
import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
@@ -17,6 +20,8 @@ import { SettingList } from '@renderer/components/setting/SettingList'
import { InspirationList } from '@renderer/components/inspiration/InspirationList'
import { VersionModal } from '@renderer/components/version/VersionModal'
import { LandmarkModal } from '@renderer/components/landmark/LandmarkModal'
import { CockpitModal } from '@renderer/components/cockpit/CockpitModal'
import { ChapterBridgeModal } from '@renderer/components/bridge/ChapterBridgeModal'
import {
editorDirtyAtom,
editorSavingAtom,
@@ -24,6 +29,19 @@ import {
wordCountAtom
} from '@renderer/atoms/editorAtoms'
const PUBLISH_CYCLE: PublishStatus[] = ['draft', 'ready', 'published']
function nextPublishStatus(current: PublishStatus | undefined): PublishStatus {
const idx = PUBLISH_CYCLE.indexOf(current ?? 'draft')
return PUBLISH_CYCLE[(idx + 1) % PUBLISH_CYCLE.length]
}
function publishIcon(status: PublishStatus | undefined): string {
if (status === 'ready') return '📦'
if (status === 'published') return '✅'
return '📝'
}
function useDocumentTitle(): string {
const target = useEditStore((s) => s.target)
const chapters = useBookStore((s) => s.chapters)
@@ -71,6 +89,10 @@ export function EditorLayout(): React.JSX.Element {
const target = useEditStore((s) => s.target)
const switchTarget = useEditStore((s) => s.switchTarget)
const setTarget = useEditStore((s) => s.setTarget)
const updateSchedule = useSettingsStore((s) => s.updateSchedule)
const bridgeChapterId = useCockpitStore((s) => s.bridgeChapterId)
const clearBridgeChapter = useCockpitStore((s) => s.clearBridgeChapter)
const updateChapterLocal = useBookStore((s) => s.updateChapterLocal)
const dirty = useAtomValue(editorDirtyAtom)
const saving = useAtomValue(editorSavingAtom)
@@ -78,6 +100,9 @@ export function EditorLayout(): React.JSX.Element {
const wordCount = useAtomValue(wordCountAtom)
const documentTitle = useDocumentTitle()
const [bridgeOpen, setBridgeOpen] = useState(false)
const [cockpitSummary, setCockpitSummary] = useState<CockpitSummary | null>(null)
const bookMeta = useBookStore.getState().books.find((b) => b.id === currentBookId)
const isChapterTarget = target?.kind === 'chapter'
const chapterId = target?.kind === 'chapter' ? target.id : selectedChapterId
@@ -97,6 +122,47 @@ export function EditorLayout(): React.JSX.Element {
}
}, [currentBookId, target?.kind, target?.id])
useEffect(() => {
if (!currentBookId) return
void ipcCall(() =>
window.electronAPI.cockpit.getSummary(currentBookId, activeVolumeId ?? undefined)
).then(setCockpitSummary)
}, [currentBookId, activeVolumeId, chapters])
useEffect(() => {
if (bridgeChapterId) setBridgeOpen(true)
}, [bridgeChapterId])
useEffect(() => {
if (!currentBookId || target?.kind !== 'chapter') return
const chapterId = target.id
void ipcCall(() => window.electronAPI.bridge.shouldPrompt(currentBookId, chapterId)).then(
(should) => {
if (should) setTimeout(() => setBridgeOpen(true), 500)
}
)
}, [currentBookId, target?.kind, target?.id])
const activeBridgeChapterId =
bridgeChapterId ?? (target?.kind === 'chapter' ? target.id : selectedChapterId)
const showStockWarning =
updateSchedule !== 'none' &&
cockpitSummary != null &&
cockpitSummary.stockReadyCount < cockpitSummary.stockThreshold
const handlePublishCycle = async (chapterId: string, e: React.MouseEvent): Promise<void> => {
e.stopPropagation()
if (!currentBookId) return
const ch = chapters.find((c) => c.id === chapterId)
if (!ch) return
const next = nextPublishStatus(ch.publishStatus)
const updated = await ipcCall(() =>
window.electronAPI.chapter.setPublishStatus(currentBookId, chapterId, next)
)
updateChapterLocal(updated)
}
const handleSelectChapter = async (chapterId: string): Promise<void> => {
setSelectedChapter(chapterId)
await switchTarget({ kind: 'chapter', id: chapterId })
@@ -178,11 +244,19 @@ export function EditorLayout(): React.JSX.Element {
}
const label = prompt(t('landmark.prompt'))
if (!label?.trim()) return
insertLandmarkInEditor({ label: label.trim(), landmarkType: 'todo' })
await ipcCall(() =>
window.electronAPI.bookmark.create(currentBookId, target.id, 0, label.trim(), 'todo')
const isForeshadow = confirm(t('landmark.foreshadowConfirm'))
const landmarkType = isForeshadow ? 'foreshadow' : 'todo'
const syncKnowledge = isForeshadow && confirm(t('knowledge.syncFromLandmark'))
insertLandmarkInEditor({ label: label.trim(), landmarkType })
const bm = await ipcCall(() =>
window.electronAPI.bookmark.create(currentBookId, target.id, 0, label.trim(), landmarkType)
)
showToast(t('landmark.created'))
if (syncKnowledge) {
await ipcCall(() => window.electronAPI.knowledge.createFromBookmark(currentBookId, bm.id))
showToast(t('knowledge.syncedFromLandmark'))
} else {
showToast(t('landmark.created'))
}
}
const saveStatus = saving ? t('editor.saving') : dirty ? t('editor.unsaved') : t('editor.saved')
@@ -254,6 +328,15 @@ export function EditorLayout(): React.JSX.Element {
>
<span>{idx + 1}.</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{ch.title}</span>
<button
type="button"
className="ch-publish-btn"
title={t(`publish.status.${ch.publishStatus ?? 'draft'}`)}
data-testid={`chapter-publish-${ch.id}`}
onClick={(e) => void handlePublishCycle(ch.id, e)}
>
{publishIcon(ch.publishStatus)}
</button>
<span className={`ch-badge ${ch.status === 'done' ? 'done' : 'draft'}`}>{ch.status}</span>
</div>
))}
@@ -322,6 +405,14 @@ export function EditorLayout(): React.JSX.Element {
/>
<div id="editor-statusbar">
<span>{saveStatus}{lastSaved ? ` · ${lastSaved.toLocaleTimeString()}` : ''}</span>
{showStockWarning && (
<span className="status-stock-warning" data-testid="status-stock-warning">
{t('stock.warning', {
count: cockpitSummary!.stockReadyCount,
threshold: cockpitSummary!.stockThreshold
})}
</span>
)}
{isChapterTarget ? (
<>
<span>{t('status.chapter')}: {t('editor.words', { count: wordCount.chapter })}</span>
@@ -343,6 +434,20 @@ export function EditorLayout(): React.JSX.Element {
chapterId={chapterId}
/>
<LandmarkModal open={landmarksModalOpen} onClose={() => setLandmarksModalOpen(false)} />
<CockpitModal
onOpenBridge={(chapterId) => {
useCockpitStore.getState().openBridgeForChapter(chapterId)
setBridgeOpen(true)
}}
/>
<ChapterBridgeModal
open={bridgeOpen}
chapterId={activeBridgeChapterId}
onClose={() => {
setBridgeOpen(false)
clearBridgeChapter()
}}
/>
</div>
)
}
+16 -1
View File
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useTabStore } from '@renderer/stores/useTabStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
export function TopBar(): React.JSX.Element {
const { t } = useTranslation()
@@ -9,6 +10,9 @@ export function TopBar(): React.JSX.Element {
const showToast = useAppStore((s) => s.showToast)
const { openTabs, activeTabId, switchTab, closeTab, openSettingsTab } = useTabStore()
const currentBookId = useBookStore((s) => s.currentBookId)
const openCockpit = useCockpitStore((s) => s.openModal)
const loadCockpitSummary = useCockpitStore((s) => s.loadSummary)
const activeVolumeId = useBookStore((s) => s.activeVolumeId)
const handleCloseTab = (tabId: string, e: React.MouseEvent): void => {
e.stopPropagation()
@@ -60,7 +64,18 @@ export function TopBar(): React.JSX.Element {
))}
</div>
<div className="top-spacer" />
<button type="button" className="top-btn" title={t('feature.comingSoon')} onClick={() => showToast(t('feature.comingSoon'))}>
<button
type="button"
className="top-btn"
title={t('cockpit.title')}
data-testid="topbar-cockpit"
disabled={!currentBookId}
onClick={() => {
if (!currentBookId) return
openCockpit()
void loadCockpitSummary(currentBookId, activeVolumeId ?? undefined)
}}
>
📊
</button>
<button type="button" className="top-btn" onClick={() => { openSettingsTab(); setView('settings') }}>
@@ -97,13 +97,47 @@ export function SettingsPage(): React.JSX.Element {
<option value="en">English</option>
</select>
</div>
<div className="setting-item">
<span>{t('settings.updateSchedule')}</span>
<select
data-testid="settings-update-schedule"
className="form-control"
value={settings.updateSchedule}
onChange={(e) =>
void settings.update({
updateSchedule: e.target.value as typeof settings.updateSchedule
})
}
>
<option value="none">{t('settings.updateSchedule.none')}</option>
<option value="daily">{t('settings.updateSchedule.daily')}</option>
<option value="alternate">{t('settings.updateSchedule.alternate')}</option>
<option value="weekly">{t('settings.updateSchedule.weekly')}</option>
</select>
</div>
<div className="setting-item">
<span>{t('settings.stockBufferThreshold')}</span>
<input
type="number"
min={1}
max={20}
data-testid="settings-stock-threshold"
className="form-control form-control--narrow"
value={settings.stockBufferThreshold}
onChange={(e) =>
void settings.update({
stockBufferThreshold: Math.min(20, Math.max(1, Number(e.target.value) || 3))
})
}
/>
</div>
</>
)}
{section === 'ai' && <AiSettingsPage />}
{section === 'shortcuts' && <ShortcutEditor />}
{section === 'about' && (
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
{t('app.name')} v0.5.0
{t('app.name')} v0.6.0
<br />
{t('app.tagline')}
</p>
+11
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand'
import type { BookMeta, Volume, Chapter, OutlineItem, SettingEntry, InspirationEntry } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useCockpitStore } from '@renderer/stores/useCockpitStore'
interface BookState {
books: BookMeta[]
@@ -62,6 +63,16 @@ export const useBookStore = create<BookState>((set, get) => ({
activeVolumeId: result.chapters.find((c) => c.id === lastChapter)?.volumeId ?? firstVolume,
selectedChapterId: lastChapter
})
const cockpit = useCockpitStore.getState()
const shouldShow = await cockpit.shouldShowOnOpen(bookId)
if (shouldShow) {
await cockpit.markSeen(bookId)
cockpit.openModal()
void cockpit.loadSummary(
bookId,
result.chapters.find((c) => c.id === lastChapter)?.volumeId ?? firstVolume ?? undefined
)
}
},
setSelectedChapter: (chapterId) => {
const ch = get().chapters.find((c) => c.id === chapterId)
+42
View File
@@ -0,0 +1,42 @@
import { create } from 'zustand'
import type { CockpitSummary } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
interface CockpitState {
open: boolean
summary: CockpitSummary | null
loading: boolean
bridgeChapterId: string | null
loadSummary: (bookId: string, volumeId?: string) => Promise<void>
openModal: () => void
close: () => void
markSeen: (bookId: string) => Promise<void>
shouldShowOnOpen: (bookId: string) => Promise<boolean>
openBridgeForChapter: (chapterId: string) => void
clearBridgeChapter: () => void
}
export const useCockpitStore = create<CockpitState>((set) => ({
open: false,
summary: null,
loading: false,
bridgeChapterId: null,
loadSummary: async (bookId, volumeId) => {
set({ loading: true })
try {
const summary = await ipcCall(() => window.electronAPI.cockpit.getSummary(bookId, volumeId))
set({ summary })
} finally {
set({ loading: false })
}
},
openModal: () => set({ open: true }),
close: () => set({ open: false }),
markSeen: async (bookId) => {
await ipcCall(() => window.electronAPI.cockpit.markSeen(bookId))
},
shouldShowOnOpen: async (bookId) =>
ipcCall(() => window.electronAPI.cockpit.shouldShowOnOpen(bookId)),
openBridgeForChapter: (chapterId) => set({ bridgeChapterId: chapterId, open: false }),
clearBridgeChapter: () => set({ bridgeChapterId: null })
}))
+97
View File
@@ -0,0 +1,97 @@
import { create } from 'zustand'
import type {
CreateKnowledgeInput,
KnowledgeEntry,
KnowledgeStatus,
KnowledgeType
} from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useAppStore } from '@renderer/stores/useAppStore'
export type KnowledgeTab = 'pending' | 'all' | 'foreshadow'
interface KnowledgeState {
entries: KnowledgeEntry[]
stats: { buried: number; resolved: number; forgotten: number }
tab: KnowledgeTab
forgottenOnly: boolean
editorOpen: boolean
editingId: string | null
load: (bookId: string) => Promise<void>
setTab: (tab: KnowledgeTab) => void
openForgottenFilter: () => void
openEditor: (id?: string | null) => void
closeEditor: () => void
create: (bookId: string, input: CreateKnowledgeInput) => Promise<KnowledgeEntry>
update: (
bookId: string,
id: string,
patch: Partial<CreateKnowledgeInput & { status: KnowledgeStatus }>
) => Promise<KnowledgeEntry>
deleteEntry: (bookId: string, id: string) => Promise<void>
approve: (bookId: string, id: string) => Promise<void>
reject: (bookId: string, id: string) => Promise<void>
batchApprove: (bookId: string, ids: string[]) => Promise<void>
filteredEntries: () => KnowledgeEntry[]
}
export const useKnowledgeStore = create<KnowledgeState>((set, get) => ({
entries: [],
stats: { buried: 0, resolved: 0, forgotten: 0 },
tab: 'all',
forgottenOnly: false,
editorOpen: false,
editingId: null,
load: async (bookId) => {
const [entries, stats] = await Promise.all([
ipcCall(() => window.electronAPI.knowledge.list(bookId)),
ipcCall(() => window.electronAPI.knowledge.stats(bookId))
])
set({ entries, stats })
},
setTab: (tab) => set({ tab, forgottenOnly: tab === 'foreshadow' ? get().forgottenOnly : false }),
openForgottenFilter: () => {
useAppStore.getState().setRightPanel('knowledge')
set({ tab: 'foreshadow', forgottenOnly: true })
},
openEditor: (id = null) => set({ editorOpen: true, editingId: id }),
closeEditor: () => set({ editorOpen: false, editingId: null }),
create: async (bookId, input) => {
const entry = await ipcCall(() => window.electronAPI.knowledge.create(bookId, input))
await get().load(bookId)
return entry
},
update: async (bookId, id, patch) => {
const entry = await ipcCall(() => window.electronAPI.knowledge.update(bookId, id, patch))
await get().load(bookId)
return entry
},
deleteEntry: async (bookId, id) => {
await ipcCall(() => window.electronAPI.knowledge.delete(bookId, id))
await get().load(bookId)
},
approve: async (bookId, id) => {
await ipcCall(() => window.electronAPI.knowledge.approve(bookId, id))
await get().load(bookId)
},
reject: async (bookId, id) => {
await ipcCall(() => window.electronAPI.knowledge.reject(bookId, id))
await get().load(bookId)
},
batchApprove: async (bookId, ids) => {
await ipcCall(() => window.electronAPI.knowledge.batchApprove(bookId, ids))
await get().load(bookId)
},
filteredEntries: () => {
const { entries, tab, forgottenOnly } = get()
let list = entries
if (tab === 'pending') list = list.filter((e) => e.status === 'pending')
else if (tab === 'foreshadow') {
list = list.filter((e) => e.type === 'foreshadow')
if (forgottenOnly) {
// forgotten filter applied client-side via reload with filter in panel
}
}
return list
}
}))
+2
View File
@@ -17,6 +17,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
theme: 'default',
language: 'zh-CN',
dailyWordGoal: 0,
updateSchedule: 'none',
stockBufferThreshold: 3,
shortcuts: {} as Record<ActionId, string>,
aiConfig: { ...DEFAULT_AI_CONFIG },
namingIgnoreWords: [] as string[],
+174
View File
@@ -741,6 +741,180 @@
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.knowledge-toolbar {
display: flex;
gap: 6px;
padding: 8px;
flex-shrink: 0;
}
.knowledge-tabs {
display: flex;
gap: 4px;
padding: 0 8px 8px;
flex-shrink: 0;
}
.kb-tab {
flex: 1;
padding: 6px 8px;
font-size: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
cursor: pointer;
}
.kb-tab.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.kb-badge {
margin-left: 4px;
background: rgba(255, 255, 255, 0.25);
padding: 0 5px;
border-radius: 8px;
font-size: 10px;
}
.knowledge-stats {
font-size: 11px;
color: var(--text-secondary);
padding: 0 8px 8px;
}
.knowledge-list {
flex: 1;
overflow-y: auto;
padding: 0 8px 8px;
}
.kb-item {
display: flex;
gap: 8px;
padding: 10px;
margin-bottom: 6px;
border-radius: var(--radius-sm);
background: var(--bg-tertiary);
border: 1px solid var(--border);
}
.kb-item-main {
flex: 1;
min-width: 0;
}
.kb-item-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 4px;
}
.kb-item-meta {
font-size: 11px;
color: var(--text-secondary);
}
.kb-item-content {
font-size: 12px;
margin-top: 4px;
color: var(--text-secondary);
}
.kb-item-actions {
display: flex;
flex-direction: column;
gap: 4px;
}
.btn-sm {
font-size: 11px;
padding: 2px 6px;
}
.cockpit-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 12px;
}
.cockpit-card {
padding: 14px;
border-radius: var(--radius-sm);
background: var(--bg-tertiary);
border: 1px solid var(--border);
}
.cockpit-card-label {
font-size: 11px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.cockpit-card-value {
font-size: 18px;
font-weight: 600;
}
.cockpit-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.bridge-body {
max-height: 420px;
overflow-y: auto;
}
.bridge-section {
margin-bottom: 14px;
}
.bridge-section h4 {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.bridge-section p {
font-size: 13px;
line-height: 1.6;
padding: 10px;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
}
.bridge-dismiss {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
margin-top: 8px;
}
.ch-publish-btn {
background: none;
border: none;
cursor: pointer;
font-size: 12px;
padding: 0 2px;
opacity: 0.85;
}
.ch-publish-btn:hover {
opacity: 1;
}
.status-stock-warning {
color: var(--warning, #e6a23c);
margin-left: 12px;
}
.naming-modal-body {