File size: 13,722 Bytes
55847d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
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>
  )
}