Download src/components/editor/ArchFileViewer.tsx from Leon4gr45/Velo: direct link, hf CLI and curl.
- Browser
- Download file 13.7 kB
-
https://huggingface.co/spaces/Leon4gr45/Velo/resolve/main/src/components/editor/ArchFileViewer.tsx
- Command line
-
hf download hf://spaces/Leon4gr45/Velo/src/components/editor/ArchFileViewer.tsx
-
curl -L -o ArchFileViewer.tsx https://huggingface.co/spaces/Leon4gr45/Velo/resolve/main/src/components/editor/ArchFileViewer.tsx
13.7 kB
| 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<ArchStepKey, { iconName: string; label: string; desc: string }> = 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<Record<string, boolean>>({}) | |
| 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<string, string>) => { | |
| 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<string, boolean> = { | |
| 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 ( | |
| <div className="h-full flex flex-col overflow-hidden"> | |
| {/* 工具栏(背景与编辑区一致,内嵌在内容区中而非独立标题栏) */} | |
| <div | |
| className="flex items-center justify-between gap-2 px-3 h-9 flex-shrink-0" | |
| style={{ | |
| borderBottom: '1px solid var(--color-border)', | |
| backgroundColor: 'var(--color-editor-bg)', | |
| }} | |
| > | |
| {/* 左侧:Emoji + 标题 + 描述 */} | |
| <div className="flex items-center gap-1.5 min-w-0"> | |
| <span className="flex-shrink-0" style={{ color: 'var(--color-text-muted)', opacity: 0.6 }}>{meta ? renderIcon(meta.iconName, 14) : <FileText size={14} />}</span> | |
| <span className="text-xs font-medium flex-shrink-0" style={{ color: 'var(--color-text-secondary)' }}> | |
| {meta?.label ?? t('archFile.architectureDoc')} | |
| </span> | |
| {meta && ( | |
| <span className="text-xs truncate hidden sm:inline" style={{ color: 'var(--color-text-muted)' }}> | |
| — {meta.desc} | |
| </span> | |
| )} | |
| </div> | |
| {/* 右侧:字数 + 状态 + 操作按钮 */} | |
| <div className="flex items-center gap-2 flex-shrink-0"> | |
| {/* 字数 */} | |
| {charCount > 0 && ( | |
| <span className="text-xs tabular-nums" style={{ color: 'var(--color-text-muted)' }}> | |
| {charCount.toLocaleString()} {t('archFile.characters')} | |
| </span> | |
| )} | |
| {/* 保存状态 */} | |
| {saving && ( | |
| <span className="text-xs" style={{ color: 'var(--color-accent)' }}>{t('archFile.saving')}</span> | |
| )} | |
| {isDirty && !saving && ( | |
| <span className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ backgroundColor: 'var(--color-warning)' }} title={t('archFile.hasUnsavedChanges')} /> | |
| )} | |
| {/* 刷新按钮 */} | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| onClick={handleReload} | |
| title={t('archFile.reloadTooltip')} | |
| disabled={loading} | |
| > | |
| <RefreshCw size={13} className={loading ? 'animate-spin' : ''} /> | |
| </Button> | |
| {/* 保存按钮(有修改时才显示) */} | |
| {isDirty && ( | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => handleSave(currentContentRef.current)} | |
| disabled={saving} | |
| title={t('archFile.saveTooltip')} | |
| > | |
| <Save size={12} /> | |
| {t('archFile.save')} | |
| </Button> | |
| )} | |
| {/* 角色卡提取按钮(仅角色图谱页面显式且为空时、且不在架构生成中时才显示) */} | |
| {stepKey === 'characters' && generated && characterCount === 0 && !isArchRunning && ( | |
| <Button | |
| size="sm" | |
| disabled={extracting} | |
| onClick={handleExtractCharacters} | |
| className="gap-1.5 bg-gradient-to-r from-red-500 to-orange-500 text-white shadow-sm hover:from-red-600 hover:to-orange-600 border-none hover:shadow hover:-translate-y-[0.5px] transition-all" | |
| title={t('archFile.rolesEmptyTooltip')} | |
| > | |
| {extracting | |
| ? <RefreshCw size={12} className="animate-spin opacity-90" /> | |
| : <AlertTriangle size={12} className="opacity-90" /> | |
| } | |
| {extracting ? t('archFile.extractingRoles') : t('archFile.extractRoles')} | |
| </Button> | |
| )} | |
| {/* AI 生成按钮 */} | |
| {stepKey && ( | |
| <Button | |
| variant="ai" | |
| size="sm" | |
| onClick={handleOpenDialog} | |
| disabled={checkingArch} | |
| title={t('archFile.aiGenerateTooltip', { action: generated ? t('archFile.aiRegenerate') : t('archFile.aiGenerate'), label: meta?.label })} | |
| > | |
| {checkingArch ? <Loader2 size={12} className="animate-spin" /> : <Sparkles size={12} />} | |
| {generated ? t('archFile.aiRegenerate') : t('archFile.aiGenerate')} | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| {/* CodeMirrorEditor document 模式,隐藏底部栏(信息已整合到上方工具栏) */} | |
| <div className="flex-1 overflow-hidden"> | |
| <CodeMirrorEditor | |
| mode="document" | |
| content={editorContent} | |
| filePath={filePath} | |
| onChange={handleChange} | |
| onSave={handleSave} | |
| onCharCountChange={setCharCount} | |
| hideStatusBar | |
| placeholder={t('archFile.notGeneratedPlaceholder')} | |
| /> | |
| </div> | |
| {/* AI 生成确认弹窗 */} | |
| {stepKey && ( | |
| <ArchitectureConfirmDialog | |
| isOpen={showDialog} | |
| onClose={() => setShowDialog(false)} | |
| archStatus={fullArchStatus} | |
| initialSelectedSteps={[stepKey]} | |
| onConfirm={handleConfirm} | |
| /> | |
| )} | |
| </div> | |
| ) | |
| } | |