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:
@@ -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)} />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
}))
|
||||
@@ -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
|
||||
}
|
||||
}))
|
||||
@@ -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[],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user