import { useState, useCallback, useRef, useEffect, useMemo } from 'react' import { Save, RefreshCw, Sparkles, Loader2, AlertTriangle, FileText } from 'lucide-react' import { useTranslation } from 'react-i18next' import { renderIcon } from '../panels/sidebar/SidebarShared' import { useEditorStore } from '../../stores/editor-store' import ArchitectureConfirmDialog from '../dialogs/ArchitectureConfirmDialog' import { Button } from '../ui/Button' import { ipc } from '../../services/ipc-client' import { readCoreContent, writeCoreContent } from '../../services/vela-protocol' import CodeMirrorEditor from './CodeMirrorEditor' import { useProjectStore } from '../../stores/project-store' import { useCharacterStore } from '../../stores/character-store' import { runArchCharacterExtract, createArchitectureWorkflow } from '../../services/workflows/architecture-workflow' import { useWorkflowStore } from '../../stores/workflow-store' import { globalEventBus } from '../../shared/event-bus' type ArchStepKey = 'premise' | 'characters' | 'worldbuilding' | 'synopsis' /** 从文件路径推断出 ArchStepKey */ function detectStepKey(filePath: string): ArchStepKey | null { if (filePath.endsWith('premise.md')) return 'premise' if (filePath.endsWith('characters.md')) return 'characters' if (filePath.endsWith('worldbuilding.md')) return 'worldbuilding' if (filePath.endsWith('synopsis.md')) return 'synopsis' return null } interface Props { filePath: string content: string } /** * 架构文件编辑器(Markdown 文件 WYSIWYG 编辑) * - 使用 CodeMirrorEditor(document 模式)+ hideStatusBar,底部栏信息整合到本组件工具栏 * - 脏状态通过比较内容字符串判断,不依赖 onChange 时机 */ export default function ArchFileViewer({ filePath, content: initialContent }: Props) { const { t } = useTranslation('editors') const stepKey = detectStepKey(filePath) const ARCH_META: Record = useMemo(() => ({ premise: { iconName: 'target', label: t('archFile.premise'), desc: t('archFile.premiseDesc') }, characters: { iconName: 'users', label: t('archFile.characterMap'), desc: t('archFile.characterMapDesc') }, worldbuilding: { iconName: 'globe', label: t('archFile.worldbuilding'), desc: t('archFile.worldbuildingDesc') }, synopsis: { iconName: 'map', label: t('archFile.synopsis'), desc: t('archFile.synopsisDesc') }, }), [t]) const meta = stepKey ? ARCH_META[stepKey] : null // 磁盘上的内容(已保存的基准) const savedContentRef = useRef(initialContent) // 编辑器当前内容(用 ref 而非 state,避免每次键入都重渲染导致光标跳末尾) const currentContentRef = useRef(initialContent) // 传给 CodeMirrorEditor 的初始内容(只有『外部重载』时才更新,不随用户键入变化) const [editorContent, setEditorContent] = useState(initialContent) const [saving, setSaving] = useState(false) const [loading, setLoading] = useState(false) const [showDialog, setShowDialog] = useState(false) const [checkingArch, setCheckingArch] = useState(false) const [fullArchStatus, setFullArchStatus] = useState>({}) const [extracting, setExtracting] = useState(false) const characterCount = useCharacterStore(s => s.characters.length) const isArchRunning = useWorkflowStore(s => s.isTypeRunning('architecture_generation')) // 中文字数(由 CodeMirrorEditor 回调更新) const [charCount, setCharCount] = useState(0) // 脚状态(独立 state,不跟着 content 走) const [isDirty, setIsDirty] = useState(false) // 外部内容更新时的热重载(拦截 store.syncTabContent 带来的 props.content 更新) useEffect(() => { if (initialContent !== savedContentRef.current && initialContent !== currentContentRef.current) { savedContentRef.current = initialContent currentContentRef.current = initialContent setEditorContent(initialContent) setIsDirty(false) } }, [initialContent]) // 内容变化回调:更新 ref,不触发重渲染,避免 content prop 回传导致光标跳末尾 const handleChange = useCallback((md: string) => { currentContentRef.current = md const dirty = md !== savedContentRef.current setIsDirty(dirty) // 同步 editor-store 的 tab.dirty,供标题栏警示灯、Tab 圆点、关闭确认使用 if (dirty) { useEditorStore.getState().updateTabContent(filePath, md) } else { useEditorStore.getState().syncTabContent(filePath, md) } }, [filePath]) /** 保存(统一走 vela://core/ DB 路径) */ const handleSave = useCallback(async (md: string) => { setSaving(true) try { let success = true if (filePath.startsWith('vela://core/')) { success = await writeCoreContent(filePath, md) } else { // DB 化后架构文件不应有物理路径;如果意外触发,尝试 FS 写入兜底 console.warn('[ArchFileViewer] 非预期的物理路径保存:', filePath) const res = await ipc.invoke('fs:write-file', filePath, md) success = res.success !== false } if (success) { savedContentRef.current = md setIsDirty(false) useEditorStore.getState().markTabSaved(filePath) } } finally { setSaving(false) } }, [filePath]) /** 从 DB 重新加载(AI 生成后刷新用) */ const handleReload = useCallback(async () => { setLoading(true) let newContent = '' if (filePath.startsWith('vela://core/')) { newContent = await readCoreContent(filePath) } else { // DB 化后架构文件不应有物理路径 console.warn('[ArchFileViewer] 非预期的物理路径刷新:', filePath) const res = await ipc.invoke('fs:read-file', filePath) if (res.success) newContent = res.content } savedContentRef.current = newContent currentContentRef.current = newContent setEditorContent(newContent) setIsDirty(false) useEditorStore.getState().markTabSaved(filePath) setLoading(false) }, [filePath]) // 监听架构生成完成事件,自动刷新当前页面 useEffect(() => { return globalEventBus.on('WORKFLOW_COMPLETE', (payload) => { if (payload.type === 'architecture_generation') { handleReload() } }) }, [handleReload]) /** 确认后启动架构生成工作流 */ const handleConfirm = async (selectedSteps: ArchStepKey[], stepGuidance: Record) => { useWorkflowStore.getState().startWorkflow(createArchitectureWorkflow({ selectedSteps, stepGuidance })) } const handleOpenDialog = async () => { if (!stepKey) return setCheckingArch(true) const core = await ipc.invoke('db:project-core-get') const status: Record = { premise: !!core?.premise && core.premise.length > 50 && !core.premise.includes('待生成'), characters: !!core?.charactersArch && core.charactersArch.length > 50 && !core.charactersArch.includes('待生成'), worldbuilding: !!core?.worldbuilding && core.worldbuilding.length > 50 && !core.worldbuilding.includes('待生成'), synopsis: !!core?.synopsis && core.synopsis.length > 50 && !core.synopsis.includes('待生成'), } // 对于当前文件,如果编辑器内已修改但未保存,也暂时以前面的基准为准即可 const EditorContentLen = currentContentRef.current.length; if (EditorContentLen > 50 && !currentContentRef.current.includes('待生成')) { status[stepKey] = true } setFullArchStatus(status) setCheckingArch(false) setShowDialog(true) } const generated = initialContent.length > 50 && !initialContent.includes('待生成') const handleExtractCharacters = useCallback(async () => { const project = useProjectStore.getState().currentProject if (!project || extracting) return setExtracting(true) try { const core = await ipc.invoke('db:project-core-get') const charArch = core?.charactersArch ?? '' if (charArch.length < 50) { setExtracting(false) return } runArchCharacterExtract(project.path, charArch, project.novelConfig.genre) // 通过 EventBus 监听提取完成事件 const unsub1 = globalEventBus.on('ARCH_POSTPROCESS_UPDATED', () => { setExtracting(false) unsub1() unsub2() }) const unsub2 = globalEventBus.on('CHARACTER_EXTRACT_FAILED', () => { setExtracting(false) unsub1() unsub2() }) } catch (e) { console.error('角色卡提取失败', e) setExtracting(false) } }, [extracting]) return (
{/* 工具栏(背景与编辑区一致,内嵌在内容区中而非独立标题栏) */}
{/* 左侧:Emoji + 标题 + 描述 */}
{meta ? renderIcon(meta.iconName, 14) : } {meta?.label ?? t('archFile.architectureDoc')} {meta && ( — {meta.desc} )}
{/* 右侧:字数 + 状态 + 操作按钮 */}
{/* 字数 */} {charCount > 0 && ( {charCount.toLocaleString()} {t('archFile.characters')} )} {/* 保存状态 */} {saving && ( {t('archFile.saving')} )} {isDirty && !saving && ( )} {/* 刷新按钮 */} {/* 保存按钮(有修改时才显示) */} {isDirty && ( )} {/* 角色卡提取按钮(仅角色图谱页面显式且为空时、且不在架构生成中时才显示) */} {stepKey === 'characters' && generated && characterCount === 0 && !isArchRunning && ( )} {/* AI 生成按钮 */} {stepKey && ( )}
{/* CodeMirrorEditor document 模式,隐藏底部栏(信息已整合到上方工具栏) */}
{/* AI 生成确认弹窗 */} {stepKey && ( setShowDialog(false)} archStatus={fullArchStatus} initialSelectedSteps={[stepKey]} onConfirm={handleConfirm} /> )}
) }