连线状态
{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 && (
-
-
{ setSourcesWizardOpen(true); }}>
+ { stores.modalStore.getState().setSourcesWizardOpen(true); }}>
通过目录导入
@@ -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 };