From b214fa944b9fe11dc1710fda8b06db92c53896d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A5=87=E8=B6=A3=E4=BF=9D=E7=BD=97?= Date: Thu, 5 Feb 2026 14:34:48 +0800 Subject: [PATCH] =?UTF-8?q?Feat:=20=E5=B0=86=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=AD=98=E5=82=A8=E5=9C=A8=20Store=20?= =?UTF-8?q?=E9=87=8C=E9=9D=A2=EF=BC=8C=E8=B0=83=E6=95=B4=E5=A4=B4=E9=83=A8?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E7=BB=84=E4=BB=B6=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../biz/header-connection-indicator.tsx | 99 +++++- src/components/biz/project-settings-modal.tsx | 2 +- src/contexts/editor-context.tsx | 149 ++++++++ src/pages/editor.tsx | 322 ++++++++++-------- src/store/editor-store.ts | 214 ++++++++++++ 5 files changed, 641 insertions(+), 145 deletions(-) create mode 100644 src/contexts/editor-context.tsx create mode 100644 src/store/editor-store.ts diff --git a/src/components/biz/header-connection-indicator.tsx b/src/components/biz/header-connection-indicator.tsx index c12486f..ccc0f79 100644 --- a/src/components/biz/header-connection-indicator.tsx +++ b/src/components/biz/header-connection-indicator.tsx @@ -2,6 +2,8 @@ import { disconnectLanguage, useFileConnections, } from "@/store/file-connection"; +import { useProjectSourcesStore } from "@/store/sources-store"; +import { isTauriEnv } from "@/lib/is-tauri"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { Button } from "../ui/button"; import { cn } from "@/lib/utils"; @@ -11,6 +13,99 @@ type Props = { }; export function HeaderConnectionIndicator({ projectId }: Props) { + const isTauri = isTauriEnv(); + + if (isTauri) { + return ; + } + + return ; +} + +/** Tauri 模式:展示目前链接的所有语言文件地址 */ +function TauriConnectionIndicator() { + const { languages, baseDir } = useProjectSourcesStore(); + + const hasAny = languages.length > 0; + const allExist = languages.every((lang) => lang.exists); + const missingCount = languages.filter((lang) => !lang.exists).length; + + // 状态颜色:全部存在=绿色,部分缺失=黄色,无文件=红色 + const statusColor = !hasAny + ? "bg-red-500" + : allExist + ? "bg-green-500" + : "bg-yellow-500"; + + return ( + + + + + +
连接状态 (Tauri)
+ {languages.length === 0 ? ( +
+ 暂无语言文件配置。请通过项目设置配置语言源目录。 +
+ ) : ( +
+ {baseDir && ( +
+ 基础目录:{baseDir} +
+ )} + {languages.map((lang) => ( +
+
+
+ + + {lang.language} + +
+
+ {lang.path} +
+ {!lang.exists && ( +
+ 文件不存在 +
+ )} +
+
+ ))} +
+ Tauri 模式下文件自动连接,无需手动操作。 +
+
+ )} +
+
+ ); +} + +/** 浏览器模式:使用 FileSystemFileHandle 管理连接 */ +function BrowserConnectionIndicator({ projectId }: { projectId: string }) { const snap = useFileConnections(projectId); const list = Object.values(snap.connections); const hasAny = list.length > 0; @@ -31,11 +126,11 @@ export function HeaderConnectionIndicator({ projectId }: Props) { {hasAny ? `已连线 ${list.length}` : "未连线"} - +
连线状态
{list.length === 0 ? (
- 暂无连线。通过“导入 JSON”选择文件后将建立连线。 + 暂无连线。通过"导入 JSON"选择文件后将建立连线。
) : (
diff --git a/src/components/biz/project-settings-modal.tsx b/src/components/biz/project-settings-modal.tsx index 19d4c7f..d02db43 100644 --- a/src/components/biz/project-settings-modal.tsx +++ b/src/components/biz/project-settings-modal.tsx @@ -98,7 +98,7 @@ function ProjectSettingsModalImpl({ open, onOpenChange, project, onSave, onDelet
{err &&
{err}
} - + diff --git a/src/contexts/editor-context.tsx b/src/contexts/editor-context.tsx new file mode 100644 index 0000000..031cc0f --- /dev/null +++ b/src/contexts/editor-context.tsx @@ -0,0 +1,149 @@ +import { createContext, useContext, useMemo, type ReactNode } from 'react'; +import { + createEditorProjectStore, + createEditorDataStore, + createEditorUIStore, + createEditorModalStore, + useStore, + type EditorProjectStore, + type EditorDataStore, + type EditorUIStore, + type EditorModalStore, +} from '@/store/editor-store'; + +/** + * Editor 的所有 Store 实例 + * 每个 Editor 组件实例都会创建独立的 stores + */ +interface EditorStores { + projectStore: EditorProjectStore; + dataStore: EditorDataStore; + uiStore: EditorUIStore; + modalStore: EditorModalStore; +} + +const EditorContext = createContext(null); + +/** + * EditorProvider + * 为 Editor 组件提供局部化的状态管理 + * + * 优势: + * 1. 每个 Editor 实例都有独立的状态(支持多个 Editor 同时存在) + * 2. 组件卸载时自动清理,无需手动重置 + * 3. 测试更容易,每个测试可以创建独立的 Provider + * 4. 更符合 React 的组件化思想 + */ +export function EditorProvider({ children }: { children: ReactNode }) { + // 使用 useMemo 确保 stores 只创建一次 + const stores = useMemo( + () => ({ + projectStore: createEditorProjectStore(), + dataStore: createEditorDataStore(), + uiStore: createEditorUIStore(), + modalStore: createEditorModalStore(), + }), + [] + ); + + return {children}; +} + +/** + * 获取 EditorContext,如果未在 EditorProvider 内使用会抛出错误 + */ +function useEditorContext() { + const context = useContext(EditorContext); + if (!context) { + throw new Error('Editor hooks must be used within EditorProvider'); + } + return context; +} + +/** + * 使用项目元数据 Store + * + * @example + * // 获取整个 store + * const projectStore = useEditorProjectStore(); + * + * // 使用 selector 获取特定字段(推荐,避免不必要的重渲染) + * const structure = useEditorProjectStore((state) => state.structure); + * const setStructure = useEditorProjectStore((state) => state.setStructure); + */ +export function useEditorProjectStore(): EditorProjectStore; +export function useEditorProjectStore(selector: (state: ReturnType) => T): T; +export function useEditorProjectStore(selector?: (state: ReturnType) => T) { + const { projectStore } = useEditorContext(); + return useStore(projectStore, selector!); +} + +/** + * 使用翻译数据 Store + * + * @example + * // 获取某个语言的所有翻译 + * const enValues = useEditorDataStore((state) => state.valuesByLang['en']); + * + * // 获取单个单元格的值(超细粒度,性能最优) + * const cellValue = useEditorDataStore((state) => state.valuesByLang[lang]?.[path] ?? ''); + * + * // 获取更新函数 + * const updateSingleValue = useEditorDataStore((state) => state.updateSingleValue); + */ +export function useEditorDataStore(): EditorDataStore; +export function useEditorDataStore(selector: (state: ReturnType) => T): T; +export function useEditorDataStore(selector?: (state: ReturnType) => T) { + const { dataStore } = useEditorContext(); + return useStore(dataStore, selector!); +} + +/** + * 使用 UI 状态 Store + * + * @example + * // 只订阅 loading 状态 + * const loading = useEditorUIStore((state) => state.loading); + * const setLoading = useEditorUIStore((state) => state.setLoading); + * + * // 订阅选中状态 + * const selected = useEditorUIStore((state) => state.selected); + * const toggleSelected = useEditorUIStore((state) => state.toggleSelected); + */ +export function useEditorUIStore(): EditorUIStore; +export function useEditorUIStore(selector: (state: ReturnType) => T): T; +export function useEditorUIStore(selector?: (state: ReturnType) => T) { + const { uiStore } = useEditorContext(); + return useStore(uiStore, selector!); +} + +/** + * 使用模态框状态 Store + * + * @example + * const importOpen = useEditorModalStore((state) => state.importOpen); + * const setImportOpen = useEditorModalStore((state) => state.setImportOpen); + */ +export function useEditorModalStore(): EditorModalStore; +export function useEditorModalStore(selector: (state: ReturnType) => T): T; +export function useEditorModalStore(selector?: (state: ReturnType) => T) { + const { modalStore } = useEditorContext(); + return useStore(modalStore, selector!); +} + +/** + * 获取所有 stores 的实例(用于在回调中访问最新状态) + * + * @example + * const stores = useEditorStores(); + * + * const handleSave = useCallback(() => { + * // 在回调中直接获取最新状态,不需要通过依赖 + * const { valuesByLang } = stores.dataStore.getState(); + * const { structure } = stores.projectStore.getState(); + * // 执行保存逻辑... + * }, []); // 不需要任何依赖! + */ +export function useEditorStores() { + return useEditorContext(); +} diff --git a/src/pages/editor.tsx b/src/pages/editor.tsx index aa34606..5dbe51d 100644 --- a/src/pages/editor.tsx +++ b/src/pages/editor.tsx @@ -1,11 +1,9 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { getProject, getStructure, listLanguages, - type Project, - type ProjectStructure, getLanguageTranslations, upsertLanguageTranslations, upsertStructure, @@ -36,37 +34,52 @@ import { useLanguageAdapter } from "@/hooks/use-language-adapter"; import { isTauriEnv } from "@/lib/is-tauri"; import EditorTable from "@/components/biz/editor/table"; import { useEditorKeyboardShortcuts } from "@/hooks/biz/use-editor-keyboard-shortcuts"; +import { EditorProvider, useEditorProjectStore, useEditorDataStore, useEditorUIStore, useEditorModalStore, useEditorStores } from "@/contexts/editor-context"; type EditorProps = { projectId?: string; }; +// 新的 Editor 组件:包裹 EditorProvider export default function Editor({ projectId }: EditorProps) { - const [project, setProject] = useState(null); - const [structure, setStructure] = useState(null); - const [languages, setLanguages] = useState([]); - const [loading, setLoading] = useState(true); - const [pageError, setPageError] = useState(null); - const [importOpen, setImportOpen] = useState(false); - const [exportOpen, setExportOpen] = useState(false); - const [sourcesWizardOpen, setSourcesWizardOpen] = useState(false); - const [addModal, setAddModal] = useState<{ open: boolean; path: string; position: "above" | "below" } | null>(null); - const [renameModal, setRenameModal] = useState<{ open: boolean; path: string } | null>(null); + return ( + + + + ); +} - const [aiModal, setAiModal] = useState<{ open: boolean; path: string } | null>(null); - const [aiBulkOpen, setAiBulkOpen] = useState(false); - const [settingsOpen, setSettingsOpen] = useState(false); - const [selected, setSelected] = useState>(new Set()); - - const [savingAll, setSavingAll] = useState(false); - - const [valuesByLang, setValuesByLang] = useState>>({}); +// 原来的 Editor 逻辑移到这里 +function EditorContent({ projectId }: EditorProps) { + // 从 Context Store 获取状态(只订阅需要渲染的状态) + const stores = useEditorStores(); + const project = useEditorProjectStore((state) => state.project); + const structure = useEditorProjectStore((state) => state.structure); + const languages = useEditorProjectStore((state) => state.languages); + + const valuesByLang = useEditorDataStore((state) => state.valuesByLang); + const setValuesByLang = useEditorDataStore((state) => state.setValuesByLang); + + const loading = useEditorUIStore((state) => state.loading); + const pageError = useEditorUIStore((state) => state.pageError); + const selected = useEditorUIStore((state) => state.selected); + const setSelected = useEditorUIStore((state) => state.setSelected); + const savingAll = useEditorUIStore((state) => state.savingAll); + + const importOpen = useEditorModalStore((state) => state.importOpen); + const exportOpen = useEditorModalStore((state) => state.exportOpen); + const sourcesWizardOpen = useEditorModalStore((state) => state.sourcesWizardOpen); + const settingsOpen = useEditorModalStore((state) => state.settingsOpen); + const aiBulkOpen = useEditorModalStore((state) => state.aiBulkOpen); + const addModal = useEditorModalStore((state) => state.addModal); + const renameModal = useEditorModalStore((state) => state.renameModal); + const aiModal = useEditorModalStore((state) => state.aiModal); const inlineEdit = useTranslationInlineEdit({ projectId, valuesByLang, setValuesByLang, - onError: (message) => setPageError(message), + onError: (message) => stores.uiStore.getState().setPageError(message), }); const connSnap = useFileConnections(projectId ?? ""); @@ -99,35 +112,32 @@ export default function Editor({ projectId }: EditorProps) { nextValues[file.language] = flattened; await upsertLanguageTranslations(projectId, file.language, flattened); if (loaded.default_language && file.language === loaded.default_language) { - // 使用函数式更新来检查当前 structure 状态,避免依赖它 - setStructure((currentStructure) => { - if (!currentStructure) { - try { - const root = buildStructureFromObject(parsed); - void upsertStructure({ projectId, root }); - return { projectId, root }; - } catch (err) { - console.error("生成结构失败", err); - return currentStructure; - } + // 获取当前 structure 状态检查 + const currentStructure = stores.projectStore.getState().structure; + if (!currentStructure) { + try { + const root = buildStructureFromObject(parsed); + await upsertStructure({ projectId, root }); + stores.projectStore.getState().setStructure({ projectId, root }); + } catch (err) { + console.error("生成结构失败", err); } - return currentStructure; - }); + } } } const loadedLanguages = Object.keys(nextValues); if (loadedLanguages.length === 0) { return false; } - setValuesByLang((old) => ({ ...old, ...nextValues })); - setLanguages(loadedLanguages); + stores.dataStore.getState().setValuesByLang((old) => ({ ...old, ...nextValues })); + stores.projectStore.getState().setLanguages(loadedLanguages); if (!opts?.silent) { toast.success("翻译内容已同步", { description: loaded.base_dir ? `来源:${loaded.base_dir}` : undefined, }); } return true; - }, [projectId, setSourcesMeta]); + }, [projectId, setSourcesMeta, stores]); const syncFromAdapter = useCallback( async (opts?: { silent?: boolean }) => { @@ -182,11 +192,11 @@ export default function Editor({ projectId }: EditorProps) { if (!projectId) return; const [nextProject, nextStructure, nextLanguages] = await Promise.all([getProject(projectId), getStructure(projectId), listLanguages(projectId)]); if (!nextProject) throw new Error("项目不存在"); - setProject(nextProject); + stores.projectStore.getState().setProject(nextProject); upsertProjectMeta(nextProject); - setStructure(nextStructure ?? null); - setLanguages(nextLanguages); - }, [projectId, upsertProjectMeta]); + stores.projectStore.getState().setStructure(nextStructure ?? null); + stores.projectStore.getState().setLanguages(nextLanguages); + }, [projectId, stores, upsertProjectMeta]); const bootstrapSources = useCallback(async (): Promise => { if (!projectId) return null; @@ -228,7 +238,8 @@ export default function Editor({ projectId }: EditorProps) { return false; } - // 获取默认语言的翻译内容 + // 从 store 获取最新的翻译内容 + const { valuesByLang } = stores.dataStore.getState(); const translations = valuesByLang[defaultLang]; if (!translations || Object.keys(translations).length === 0) { if (!opts?.silent) { @@ -244,7 +255,7 @@ export default function Editor({ projectId }: EditorProps) { const root = buildStructureFromObject(nestedObj); // 保存到数据库 await upsertStructure({ projectId, root }); - setStructure({ projectId, root }); + stores.projectStore.getState().setStructure({ projectId, root }); if (!opts?.silent) { toast.success("翻译结构已更新"); @@ -259,7 +270,7 @@ export default function Editor({ projectId }: EditorProps) { return false; } }, - [projectId, sourcesState.defaultLanguage, valuesByLang] + [projectId, sourcesState.defaultLanguage, stores] ); const syncExternalSources = useCallback( @@ -288,18 +299,18 @@ export default function Editor({ projectId }: EditorProps) { const refresh = useCallback( async (opts?: { silent?: boolean }) => { if (!projectId) return; - setLoading(true); - setPageError(null); + stores.uiStore.getState().setLoading(true); + stores.uiStore.getState().setPageError(null); try { await bootstrapProject(); await syncExternalSources({ silent: opts?.silent ?? true, reloadConfig: true }); } catch (e) { - setPageError((e as Error)?.message ?? "加载失败"); + stores.uiStore.getState().setPageError((e as Error)?.message ?? "加载失败"); } finally { - setLoading(false); + stores.uiStore.getState().setLoading(false); } }, - [bootstrapProject, projectId, syncExternalSources] + [bootstrapProject, projectId, stores, syncExternalSources] ); const handleWizardSourcesLoaded = useCallback( @@ -323,23 +334,23 @@ export default function Editor({ projectId }: EditorProps) { // 只在 projectId 变化时触发,避免因 refresh 引用变化导致的循环 useEffect(() => { if (!projectId) return; - setLoading(true); - setPageError(null); + stores.uiStore.getState().setLoading(true); + stores.uiStore.getState().setPageError(null); const initialize = async () => { try { await bootstrapProject(); await syncExternalSources({ silent: true, reloadConfig: true }); } catch (e) { - setPageError((e as Error)?.message ?? "加载失败"); + stores.uiStore.getState().setPageError((e as Error)?.message ?? "加载失败"); } finally { - setLoading(false); + stores.uiStore.getState().setLoading(false); } }; void initialize(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectId]); // 只依赖 projectId + }, [projectId, stores]); // 依赖 projectId 和 stores const entries: FlatEntry[] = useMemo(() => { if (!structure) return []; @@ -347,7 +358,13 @@ export default function Editor({ projectId }: EditorProps) { }, [structure]); const handleSaveAllConnected = useCallback(async () => { - if (!projectId || !structure) return; + if (!projectId) return; + + // 在执行时获取最新状态,减少依赖 + const { structure } = stores.projectStore.getState(); + const { valuesByLang } = stores.dataStore.getState(); + + if (!structure) return; if (!adapter) { toast.error("当前项目未配置可写入的语言文件"); return; @@ -366,7 +383,7 @@ export default function Editor({ projectId }: EditorProps) { return; } - setSavingAll(true); + stores.uiStore.getState().setSavingAll(true); try { const orderedPaths = flattenEntries(structure.root).map((e) => e.path); const payloads = targetLanguages.map((lang) => ({ @@ -385,11 +402,14 @@ export default function Editor({ projectId }: EditorProps) { } catch (err) { toast.error((err as Error)?.message ?? "保存失败"); } finally { - setSavingAll(false); + stores.uiStore.getState().setSavingAll(false); } - }, [adapter, connSnap.connections, projectId, structure, valuesByLang]); + }, [adapter, connSnap.connections, projectId, stores]); - function computeSuggestedLanguages(pathsInput: string[]): string[] { + const computeSuggestedLanguages = useCallback((pathsInput: string[]): string[] => { + const { languages } = stores.projectStore.getState(); + const { valuesByLang } = stores.dataStore.getState(); + if (languages.length === 0 || pathsInput.length === 0) return []; const missing = new Set(); @@ -412,9 +432,12 @@ export default function Editor({ projectId }: EditorProps) { const arr = Array.from(missing); return arr.length > 0 ? arr : languages; // 如无缺失则默认全选 - } + }, [stores]); const getExistingByPath = useCallback((path: string) => { + const { languages } = stores.projectStore.getState(); + const { valuesByLang } = stores.dataStore.getState(); + const result: Record = {}; for (const lang of languages) { const text = valuesByLang[lang]?.[path]; @@ -424,60 +447,64 @@ export default function Editor({ projectId }: EditorProps) { } return result; - }, [languages, valuesByLang]); + }, [stores]); const handleOpenAddEntry = useCallback((path: string, position: "above" | "below") => { - setAddModal({ open: true, path, position }); - }, []); + stores.modalStore.getState().setAddModal({ open: true, path, position }); + }, [stores]); const handleOpenAiTranslate = useCallback((path: string) => { - setAiModal({ open: true, path }); - }, []); + stores.modalStore.getState().setAiModal({ open: true, path }); + }, [stores]); const handleOpenRenameEntry = useCallback((path: string) => { - setRenameModal({ open: true, path }); - }, []); + stores.modalStore.getState().setRenameModal({ open: true, path }); + }, [stores]); const handleMoveEntry = useCallback(async (path: string, offset: number) => { - if (!projectId || !structure || offset === 0) return; + if (!projectId || offset === 0) return; + + const { structure } = stores.projectStore.getState(); + if (!structure) return; + try { const nextRoot = moveEntryByOffset(structure.root, path, offset); await upsertStructure({ projectId, root: nextRoot }); - setStructure({ projectId, root: nextRoot }); + stores.projectStore.getState().setStructure({ projectId, root: nextRoot }); } catch (e) { - setPageError((e as Error)?.message ?? "移动失败"); + stores.uiStore.getState().setPageError((e as Error)?.message ?? "移动失败"); } - }, [projectId, structure]); + }, [projectId, stores]); const handleDeleteEntry = useCallback(async (path: string) => { - if (!projectId || !structure) return; + if (!projectId) return; + + const { structure } = stores.projectStore.getState(); + if (!structure) return; + try { const nextRoot = removeEntryAtPath(structure.root, path); await upsertStructure({ projectId, root: nextRoot }); await deleteEntryFromAllLanguages(projectId, path); - setStructure({ projectId, root: nextRoot }); - setValuesByLang((old) => { - const copy: typeof old = {}; - for (const [langKey, vals] of Object.entries(old)) { - const next = { ...vals } as Record; - delete next[path]; - copy[langKey] = next; - } - return copy; - }); - setSelected((prev) => { + stores.projectStore.getState().setStructure({ projectId, root: nextRoot }); + stores.dataStore.getState().deleteEntry(path); + stores.uiStore.getState().setSelected((prev) => { if (!prev.has(path)) return prev; const next = new Set(prev); next.delete(path); return next; }); } catch (e) { - setPageError((e as Error)?.message ?? "删除失败"); + stores.uiStore.getState().setPageError((e as Error)?.message ?? "删除失败"); } - }, [projectId, setSelected, setValuesByLang, structure]); + }, [projectId, stores]); const handleDeleteSelectedEntries = useCallback(async (paths: string[]) => { - if (!projectId || !structure || paths.length === 0) return; + if (!projectId || paths.length === 0) return; + + const { structure } = stores.projectStore.getState(); + if (!structure) return; + try { let nextRoot = structure.root; for (const p of paths) { @@ -485,25 +512,21 @@ export default function Editor({ projectId }: EditorProps) { } await upsertStructure({ projectId, root: nextRoot }); await Promise.all(paths.map((p) => deleteEntryFromAllLanguages(projectId, p))); - setStructure({ projectId, root: nextRoot }); - setValuesByLang((old) => { - const copy: typeof old = {}; - for (const [langKey, vals] of Object.entries(old)) { - const next = { ...vals } as Record; - for (const p of paths) delete next[p]; - copy[langKey] = next; - } - return copy; - }); - setSelected(new Set()); + stores.projectStore.getState().setStructure({ projectId, root: nextRoot }); + + // 使用 store 的批量删除 + for (const p of paths) { + stores.dataStore.getState().deleteEntry(p); + } + stores.uiStore.getState().setSelected(new Set()); } catch (e) { - setPageError((e as Error)?.message ?? "批量删除失败"); + stores.uiStore.getState().setPageError((e as Error)?.message ?? "批量删除失败"); } - }, [projectId, setSelected, setValuesByLang, structure]); + }, [projectId, stores]); const handleOpenBulkAiTranslate = useCallback(() => { - setAiBulkOpen(true); - }, []); + stores.modalStore.getState().setAiBulkOpen(true); + }, [stores]); useEffect(() => { if (!projectId || languages.length === 0) return; @@ -514,12 +537,12 @@ export default function Editor({ projectId }: EditorProps) { const rec = await getLanguageTranslations(projectId, lang); all[lang] = rec?.values ?? {}; } - if (!cancelled) setValuesByLang(all); + if (!cancelled) stores.dataStore.getState().setValuesByLang(all); })(); return () => { cancelled = true; }; - }, [projectId, languages]); + }, [projectId, languages, stores]); const disabledItems = useMemo(() => { return { @@ -530,7 +553,7 @@ export default function Editor({ projectId }: EditorProps) { "import-with-files": false, export: languages.length === 0, }; - }, [adapter, structure, languages.length]); + }, [adapter, structure, languages.length, savingAll, connSnap.connections]); const onClickItem = useCallback((item: CommonMenubarItem) => { switch (item) { @@ -544,16 +567,16 @@ export default function Editor({ projectId }: EditorProps) { void handleSaveAllConnected(); break; case "import-with-directory": - setSourcesWizardOpen(true); + stores.modalStore.getState().setSourcesWizardOpen(true); break; case "import-with-files": - setImportOpen(true); + stores.modalStore.getState().setImportOpen(true); break; case "export": - setExportOpen(true); + stores.modalStore.getState().setExportOpen(true); break; } - }, [handleSaveAllConnected, setSourcesWizardOpen, setImportOpen, setExportOpen, syncExternalSources]); + }, [handleSaveAllConnected, syncExternalSources, stores]); // 注册键盘快捷方式 useEditorKeyboardShortcuts({ @@ -571,7 +594,7 @@ export default function Editor({ projectId }: EditorProps) {
{project && ( - @@ -592,7 +615,7 @@ export default function Editor({ projectId }: EditorProps) { 该项目还没有翻译结构。请导入一份 JSON 文件来构建结构,并同时录入该语言的翻译内容。
- @@ -619,7 +642,7 @@ export default function Editor({ projectId }: EditorProps) { stores.modalStore.getState().setSourcesWizardOpen(v)} projectId={projectId ?? ""} onCompleted={refresh} onSourcesLoaded={handleWizardSourcesLoaded} @@ -627,7 +650,7 @@ export default function Editor({ projectId }: EditorProps) { stores.modalStore.getState().setImportOpen(v)} projectId={projectId ?? ""} hasStructure={!!structure} onImported={refresh} @@ -635,7 +658,7 @@ export default function Editor({ projectId }: EditorProps) { /> stores.modalStore.getState().setExportOpen(v)} projectId={projectId ?? ""} languages={languages} valuesByLang={valuesByLang} @@ -644,12 +667,12 @@ export default function Editor({ projectId }: EditorProps) { stores.modalStore.getState().setSettingsOpen(v)} project={project} onSave={async (update) => { if (!projectId) return; const next = await updateProject({ id: projectId, name: update.name, preferences: update.preferences }); - setProject(next); + stores.projectStore.getState().setProject(next); upsertProjectMeta(next); }} onDelete={async () => { @@ -662,7 +685,7 @@ export default function Editor({ projectId }: EditorProps) { setAiModal((cur) => (cur ? { ...cur, open: v } : cur))} + onOpenChange={(v) => stores.modalStore.getState().setAiModal((cur) => (cur ? { ...cur, open: v } : cur))} languages={languages} paths={aiModal?.path ? [aiModal.path] : []} initialSelectedLanguages={aiModal?.path ? computeSuggestedLanguages([aiModal.path]) : []} @@ -670,7 +693,14 @@ export default function Editor({ projectId }: EditorProps) { prompt={project?.preferences?.aiPrompt} model={project?.preferences?.aiModel} onConfirm={async (translations, options) => { - if (!projectId || !aiModal) return; + if (!projectId) return; + + const { aiModal } = stores.modalStore.getState(); + if (!aiModal) return; + + const { languages } = stores.projectStore.getState(); + const { valuesByLang } = stores.dataStore.getState(); + const updates: Record> = {}; try { const targetLangs = options?.selectedLanguages ?? languages; @@ -689,10 +719,10 @@ export default function Editor({ projectId }: EditorProps) { await upsertLanguageTranslations(projectId, lang, next); }) ); - setValuesByLang((old) => ({ ...old, ...updates })); + stores.dataStore.getState().setValuesByLang((old) => ({ ...old, ...updates })); } catch (e) { const msg = (e as Error)?.message ?? "保存翻译失败"; - setPageError(msg); + stores.uiStore.getState().setPageError(msg); throw e; } }} @@ -700,7 +730,7 @@ export default function Editor({ projectId }: EditorProps) { stores.modalStore.getState().setAiBulkOpen(v)} languages={languages} paths={Array.from(selected)} initialSelectedLanguages={computeSuggestedLanguages(Array.from(selected))} @@ -708,7 +738,14 @@ export default function Editor({ projectId }: EditorProps) { prompt={project?.preferences?.aiPrompt} model={project?.preferences?.aiModel} onConfirm={async (translations, options) => { - if (!projectId || selected.size === 0) return; + if (!projectId) return; + + const { selected } = stores.uiStore.getState(); + if (selected.size === 0) return; + + const { languages } = stores.projectStore.getState(); + const { valuesByLang } = stores.dataStore.getState(); + try { const updatesByLang: Record> = {}; const targetLangs = options?.selectedLanguages ?? languages; @@ -727,9 +764,9 @@ export default function Editor({ projectId }: EditorProps) { await upsertLanguageTranslations(projectId, lang, next); }) ); - setValuesByLang((old) => ({ ...old, ...updatesByLang })); + stores.dataStore.getState().setValuesByLang((old) => ({ ...old, ...updatesByLang })); } catch (e) { - setPageError((e as Error)?.message ?? "批量 AI 翻译保存失败"); + stores.uiStore.getState().setPageError((e as Error)?.message ?? "批量 AI 翻译保存失败"); throw e; } }} @@ -737,16 +774,22 @@ export default function Editor({ projectId }: EditorProps) { setAddModal((cur) => (cur ? { ...cur, open: v } : cur))} + onOpenChange={(v) => stores.modalStore.getState().setAddModal((cur) => (cur ? { ...cur, open: v } : cur))} title={addModal?.position === "above" ? "在上方新增条目" : "在下方新增条目"} placeholder="请输入条目名称(不含点)" onConfirm={async (name) => { - if (!projectId || !structure || !addModal) return; + if (!projectId) return; + + const { structure } = stores.projectStore.getState(); + const { addModal } = stores.modalStore.getState(); + + if (!structure || !addModal) return; + const nextRoot = insertEntrySibling(structure.root, addModal.path, addModal.position, name); console.log("nextRoot", addModal, nextRoot); await upsertStructure({ projectId, root: nextRoot }); - setStructure({ projectId, root: nextRoot }); + stores.projectStore.getState().setStructure({ projectId, root: nextRoot }); }} validate={(name) => { // 同级重名校验在结构函数中也会做,这里做基础提示即可 @@ -757,28 +800,23 @@ export default function Editor({ projectId }: EditorProps) { setRenameModal((cur) => (cur ? { ...cur, open: v } : cur))} + onOpenChange={(v) => stores.modalStore.getState().setRenameModal((cur) => (cur ? { ...cur, open: v } : cur))} title="重命名条目" placeholder="请输入新名称(不含点)" defaultValue={renameModal?.path ? renameModal.path.split('.').pop() : ''} onConfirm={async (newName) => { - if (!projectId || !structure || !renameModal) return; + if (!projectId) return; + + const { structure } = stores.projectStore.getState(); + const { renameModal } = stores.modalStore.getState(); + + if (!structure || !renameModal) return; + const { root: nextRoot, newPath } = renameEntryAtPath(structure.root, renameModal.path, newName); await upsertStructure({ projectId, root: nextRoot }); await renameEntryInAllLanguages(projectId, renameModal.path, newPath); - setStructure({ projectId, root: nextRoot }); - setValuesByLang((old) => { - const copy: typeof old = {}; - for (const [langKey, vals] of Object.entries(old)) { - if (Object.prototype.hasOwnProperty.call(vals, renameModal.path)) { - const { [renameModal.path]: val, ...rest } = vals; - copy[langKey] = { ...rest, [newPath]: val }; - } else { - copy[langKey] = vals; - } - } - return copy; - }); + stores.projectStore.getState().setStructure({ projectId, root: nextRoot }); + stores.dataStore.getState().renameEntry(renameModal.path, newPath); }} validate={(name) => { if (name.includes('.')) return "名称不能包含 '.'"; diff --git a/src/store/editor-store.ts b/src/store/editor-store.ts new file mode 100644 index 0000000..991c620 --- /dev/null +++ b/src/store/editor-store.ts @@ -0,0 +1,214 @@ +import { createStore, useStore } from 'zustand'; +import type { Project, ProjectStructure } from '@/lib/db'; + +/** + * 编辑器项目元数据 Store + * 变化频率:低(只在切换项目或首次加载时变化) + */ +interface EditorProjectState { + project: Project | null; + structure: ProjectStructure | null; + languages: string[]; + setProject: (project: Project | null) => void; + setStructure: (structure: ProjectStructure | null) => void; + setLanguages: (languages: string[]) => void; + reset: () => void; +} + +export type EditorProjectStore = ReturnType; + +export const createEditorProjectStore = () => { + return createStore((set) => ({ + project: null, + structure: null, + languages: [], + setProject: (project) => set({ project }), + setStructure: (structure) => set({ structure }), + setLanguages: (languages) => set({ languages }), + reset: () => set({ project: null, structure: null, languages: [] }), + })); +}; + +/** + * 编辑器翻译数据 Store + * 变化频率:中(编辑翻译内容时变化) + */ +interface EditorDataState { + valuesByLang: Record>; + setValuesByLang: (valuesByLang: Record> | ((old: Record>) => Record>)) => void; + updateLanguageValues: (lang: string, values: Record) => void; + updateSingleValue: (lang: string, path: string, value: string) => void; + deleteEntry: (path: string) => void; + renameEntry: (oldPath: string, newPath: string) => void; + reset: () => void; +} + +export type EditorDataStore = ReturnType; + +export const createEditorDataStore = () => { + return createStore((set) => ({ + valuesByLang: {}, + setValuesByLang: (valuesByLang) => + set((state) => ({ + valuesByLang: typeof valuesByLang === 'function' ? valuesByLang(state.valuesByLang) : valuesByLang + })), + updateLanguageValues: (lang, values) => + set((state) => ({ + valuesByLang: { + ...state.valuesByLang, + [lang]: { ...state.valuesByLang[lang], ...values }, + }, + })), + updateSingleValue: (lang, path, value) => + set((state) => ({ + valuesByLang: { + ...state.valuesByLang, + [lang]: { ...state.valuesByLang[lang], [path]: value }, + }, + })), + deleteEntry: (path) => + set((state) => { + const nextValuesByLang: Record> = {}; + for (const [lang, values] of Object.entries(state.valuesByLang)) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [path]: _, ...rest } = values; + nextValuesByLang[lang] = rest; + } + return { valuesByLang: nextValuesByLang }; + }), + renameEntry: (oldPath, newPath) => + set((state) => { + const nextValuesByLang: Record> = {}; + for (const [lang, values] of Object.entries(state.valuesByLang)) { + if (Object.prototype.hasOwnProperty.call(values, oldPath)) { + const { [oldPath]: value, ...rest } = values; + nextValuesByLang[lang] = { ...rest, [newPath]: value }; + } else { + nextValuesByLang[lang] = values; + } + } + return { valuesByLang: nextValuesByLang }; + }), + reset: () => set({ valuesByLang: {} }), + })); +}; + +/** + * 编辑器 UI 状态 Store + * 变化频率:高(加载状态、错误信息、选中状态频繁变化) + */ +interface EditorUIState { + loading: boolean; + pageError: string | null; + selected: Set; + savingAll: boolean; + setLoading: (loading: boolean) => void; + setPageError: (error: string | null) => void; + setSelected: (selected: Set | ((prev: Set) => Set)) => void; + toggleSelected: (path: string) => void; + clearSelected: () => void; + setSavingAll: (saving: boolean) => void; + reset: () => void; +} + +export type EditorUIStore = ReturnType; + +export const createEditorUIStore = () => { + return createStore((set) => ({ + loading: true, + pageError: null, + selected: new Set(), + savingAll: false, + setLoading: (loading) => set({ loading }), + setPageError: (pageError) => set({ pageError }), + setSelected: (selected) => + set((state) => ({ + selected: typeof selected === 'function' ? selected(state.selected) : selected + })), + toggleSelected: (path) => + set((state) => { + const next = new Set(state.selected); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return { selected: next }; + }), + clearSelected: () => set({ selected: new Set() }), + setSavingAll: (savingAll) => set({ savingAll }), + reset: () => set({ loading: true, pageError: null, selected: new Set(), savingAll: false }), + })); +}; + +/** + * 编辑器模态框状态 Store + * 变化频率:中(打开/关闭弹窗时变化) + */ +interface EditorModalState { + importOpen: boolean; + exportOpen: boolean; + sourcesWizardOpen: boolean; + settingsOpen: boolean; + aiBulkOpen: boolean; + addModal: { open: boolean; path: string; position: 'above' | 'below' } | null; + renameModal: { open: boolean; path: string } | null; + aiModal: { open: boolean; path: string } | null; + setImportOpen: (open: boolean) => void; + setExportOpen: (open: boolean) => void; + setSourcesWizardOpen: (open: boolean) => void; + setSettingsOpen: (open: boolean) => void; + setAiBulkOpen: (open: boolean) => void; + setAddModal: (modal: { open: boolean; path: string; position: 'above' | 'below' } | null | ((cur: { open: boolean; path: string; position: 'above' | 'below' } | null) => { open: boolean; path: string; position: 'above' | 'below' } | null)) => void; + setRenameModal: (modal: { open: boolean; path: string } | null | ((cur: { open: boolean; path: string } | null) => { open: boolean; path: string } | null)) => void; + setAiModal: (modal: { open: boolean; path: string } | null | ((cur: { open: boolean; path: string } | null) => { open: boolean; path: string } | null)) => void; + reset: () => void; +} + +export type EditorModalStore = ReturnType; + +export const createEditorModalStore = () => { + return createStore((set) => ({ + importOpen: false, + exportOpen: false, + sourcesWizardOpen: false, + settingsOpen: false, + aiBulkOpen: false, + addModal: null, + renameModal: null, + aiModal: null, + setImportOpen: (importOpen) => set({ importOpen }), + setExportOpen: (exportOpen) => set({ exportOpen }), + setSourcesWizardOpen: (sourcesWizardOpen) => set({ sourcesWizardOpen }), + setSettingsOpen: (settingsOpen) => set({ settingsOpen }), + setAiBulkOpen: (aiBulkOpen) => set({ aiBulkOpen }), + setAddModal: (addModal) => + set((state) => ({ + addModal: typeof addModal === 'function' ? addModal(state.addModal) : addModal + })), + setRenameModal: (renameModal) => + set((state) => ({ + renameModal: typeof renameModal === 'function' ? renameModal(state.renameModal) : renameModal + })), + setAiModal: (aiModal) => + set((state) => ({ + aiModal: typeof aiModal === 'function' ? aiModal(state.aiModal) : aiModal + })), + reset: () => + set({ + importOpen: false, + exportOpen: false, + sourcesWizardOpen: false, + settingsOpen: false, + aiBulkOpen: false, + addModal: null, + renameModal: null, + aiModal: null, + }), + })); +}; + +/** + * 导出 useStore 以便在自定义 hooks 中使用 + */ +export { useStore };