/** * ProjectTree — 项目导航树(侧边栏核心视图) * * 包含:小说配置、故事架构、章节蓝图、草稿箱、正文章节、全局摘要 */ import { useState, useEffect, useCallback, useRef } from 'react' import { ChevronRight, ChevronDown, RefreshCw, CheckCircle2, Circle, FolderOpen, Copy, FolderTree } from 'lucide-react' import { useProjectStore } from '../../../stores/project-store' import { useWorkflowStore } from '../../../stores/workflow-store' import { useDraftStore } from '../../../stores/draft-store' import { useEditorStore } from '../../../stores/editor-store' import { useLayoutStore } from '../../../stores/layout-store' import { ipc } from '../../../services/ipc-client' import { Button } from '../../ui/Button' import { EmptyState } from '../../ui/EmptyState' import { useTranslation } from 'react-i18next' import { ARCH_FILES, LeafItem, renderIcon, showSidebarMenu, openArchFile, openBuiltinEditor, } from './SidebarShared' import DraftBoxGroup from './DraftBoxGroup' import ManuscriptGroup from './ManuscriptGroup' export default function ProjectTree() { const { t } = useTranslation('panels') const currentProject = useProjectStore(s => s.currentProject) // refreshFileTree / loadAllDrafts 在 refreshAll 内通过 getState() 调用 // ✅ 只订阅 activeRuns const activeRuns = useWorkflowStore(s => s.activeRuns) // ✅ 精确订阅,避免 loadAllDrafts 执行后引用变化触发 useCallback/useEffect 循环 const draftsByChapter = useDraftStore(s => s.draftsByChapter) // 存储各架构文件是否有实际内容(已生成) const [archStatus, setArchStatus] = useState>({}) // 章节蓝图数量 const [blueprintCount, setBlueprintCount] = useState(-1) /** 统一刷新:文件树 + 架构状态 + 草稿列表 + 蓝图数量 */ // ✅ 用 getState() 获取最新的 action,不作为依赖项,避免重建导致 useEffect 循环 const refreshAll = useCallback(async () => { useProjectStore.getState().refreshFileTree() useDraftStore.getState().loadAllDrafts() // 通过 Service 层获取架构状态和蓝图数量(避免直接 IPC) const { checkArchStatus, getBlueprintCount } = await import('../../../services/architecture-service') const [status, count] = await Promise.all([ checkArchStatus(), getBlueprintCount(), ]) setArchStatus(status) setBlueprintCount(count) }, []) // ✅ 空依赖:内部用 getState() 获取最新 action,不依赖闭包 // 项目切换时刷新 useEffect(() => { if (currentProject) refreshAll() }, [currentProject?.path, refreshAll]) // eslint-disable-line react-hooks/exhaustive-deps -- currentProject 对象引用变化不每次都需重跑 // 工作流步骤状态或整体状态变化时刷新侧边栏(适配多任务) // 合并为单一 effect + 防抖,避免一次步骤完成同时触发多次刷新 const workflowKey = activeRuns.map(r => `${r.id}:${r.status}|${r.steps.map(s => s.status).join(',')}`).join(';') const debounceRef = useRef | null>(null) useEffect(() => { if (!currentProject) return if (debounceRef.current) clearTimeout(debounceRef.current) debounceRef.current = setTimeout(() => { refreshAll() }, 80) return () => { if (debounceRef.current) clearTimeout(debounceRef.current) } // ✅ 依赖 path 字符串而非 currentProject 对象引用 // 避免 updateNovelConfig 改变对象引用后触发不必要的 refreshAll }, [workflowKey, currentProject?.path, refreshAll]) // eslint-disable-line react-hooks/exhaustive-deps -- currentProject 对象引用变化不触发,仅 path 变化需响应 if (!currentProject) { return ( } message={t('projectTree.noProject')} className="p-4 pb-[15vh]" opacity={1} > {t('projectTree.noProjectDesc')} {/* 操作按钮 */}
) } const p = currentProject.path // 改为彻底的数据驱动:从内存的全部草稿中提取 status='finalized' 的草稿 const manuscriptFiles = Object.values(draftsByChapter) .map(drafts => drafts.find(d => d.status === 'finalized')) .filter(Boolean) .sort((a, b) => a!.chapterNumber - b!.chapterNumber) .map(draft => ({ path: `vela://manuscript/${draft!.id}`, // 诸如 vela://manuscript/42 name: `chapter_${draft!.chapterNumber}.md`, // 提供格式化的伪文件名供组件适配解析 isDir: false, })) as Array<{ path: string; name: string; isDir: boolean }> // 小说配置是否已完成(核心大纲非空视为已完成) const nc = currentProject.novelConfig const configDone = !!(nc.coreOutline?.trim() || nc.protagonistProfile?.trim()) // 故事架构进度 const archDone = ARCH_FILES.filter(f => archStatus[f.key]).length return (
{/* 项目名 + 刷新 */}
{currentProject.name}
{/* 1. 小说配置 */} { const state = useEditorStore.getState() const configTab = state.tabs.find(t => t.type === 'config') if (configTab) { state.setActiveTab(configTab.id) } else { state.openFile({ id: 'config', name: t('projectTree.novelConfig'), type: 'config' }) } }} onContextMenu={e => showSidebarMenu([ { key: 'open', label: t('projectTree.openNovelConfig'), icon: , onClick: () => { const state = useEditorStore.getState() const configTab = state.tabs.find(t => t.type === 'config') if (configTab) state.setActiveTab(configTab.id) else state.openFile({ id: 'config', name: t('projectTree.novelConfig'), type: 'config' }) }, }, ], e)} /> {/* 2. 故事架构 — 点击标题行打开编辑器,子文件仍可单独点开 */} {/* 3. 章节蓝图 — 点击打开编辑器页 */} 0 ? `${blueprintCount}/${nc.totalChapters} ${t('common.chapters', { ns: 'common' })}` : t('projectTree.pendingGeneration')} badgeColor={ blueprintCount >= nc.totalChapters ? 'var(--color-success)' : blueprintCount > 0 ? 'var(--color-warning, #eab308)' : undefined } badgeDone={blueprintCount >= nc.totalChapters} onClick={() => openBuiltinEditor('chapter-card-editor', t('projectTree.chapterBlueprint'), 'chapter-card')} onContextMenu={e => showSidebarMenu([ { key: 'open', label: t('projectTree.openChapterBlueprint'), icon: , onClick: () => openBuiltinEditor('chapter-card-editor', '章节蓝图', 'chapter-card'), }, ], e)} /> {/* 4. 草稿箱 — 独立分区,按章节分组展示草稿 */} {/* 5. 正文章节 — 仅显示已定稿 */}
) } // ===== 故事架构折叠组 ===== function WorldBuildingGroup({ archStatus, archDone, }: { archStatus: Record archDone: number }) { const { t } = useTranslation('panels') const [open, setOpen] = useState(true) const allDone = archDone === ARCH_FILES.length return (
{/* 组标题行 — 点击打开故事架构编辑器,双击展开/折叠子文件 */}
openBuiltinEditor('world-building-editor', t('projectTree.storyArch'), 'world-building')} title={t('projectTree.archEditorTip')} > { e.stopPropagation(); setOpen(v => !v) }} > {open ? : } {t('projectTree.storyArch')} {/* 进度徽章 */} 0 ? 'var(--color-warning, #eab308)' : 'var(--color-text-muted)' }} > {archDone}/{ARCH_FILES.length}
{/* 子文件列表(点击直接在 Markdown 编辑器打开) */} {open && (
{ARCH_FILES.map(f => { const isGenerated = archStatus[f.key] const filePath = `vela://core/${f.key}` return ( ) })}
)}
) } /** 单个架构文件行 */ function ArchFileRow({ f, filePath, isGenerated, }: { f: { key: string; iconName: string; label: string; desc: string } filePath: string isGenerated: boolean }) { const { t } = useTranslation('panels') return (
openArchFile(filePath, `${f.label}`)} onContextMenu={e => showSidebarMenu([ { key: 'open', label: t('projectTree.openFile'), icon: , onClick: () => openArchFile(filePath, `${f.label}`), }, { key: 'div1', type: 'divider' as const }, { key: 'copy-path', label: t('projectTree.copyFilePath'), icon: , onClick: () => navigator.clipboard.writeText(filePath).catch(() => { }), }, ], e)} title={f.desc} > {isGenerated ? : } {renderIcon(f.iconName, 13)} {f.label} {!isGenerated && ( {t('projectTree.pendingGeneration')} )}
) }