Feat: 将编辑器状态存储在 Store 里面,调整头部连接组件逻辑
This commit is contained in:
parent
bf1a7dde48
commit
b214fa944b
|
|
@ -2,6 +2,8 @@ import {
|
||||||
disconnectLanguage,
|
disconnectLanguage,
|
||||||
useFileConnections,
|
useFileConnections,
|
||||||
} from "@/store/file-connection";
|
} from "@/store/file-connection";
|
||||||
|
import { useProjectSourcesStore } from "@/store/sources-store";
|
||||||
|
import { isTauriEnv } from "@/lib/is-tauri";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -11,6 +13,99 @@ type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function HeaderConnectionIndicator({ projectId }: Props) {
|
export function HeaderConnectionIndicator({ projectId }: Props) {
|
||||||
|
const isTauri = isTauriEnv();
|
||||||
|
|
||||||
|
if (isTauri) {
|
||||||
|
return <TauriConnectionIndicator />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BrowserConnectionIndicator projectId={projectId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
title={hasAny ? "已连接文件" : "未配置任何语言文件"}
|
||||||
|
>
|
||||||
|
<span className={cn("size-2 rounded-full", statusColor)}></span>
|
||||||
|
{hasAny
|
||||||
|
? `已连接 ${languages.length}${missingCount > 0 ? ` (${missingCount} 缺失)` : ""}`
|
||||||
|
: "未配置"}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="p-4 max-w-md" align="end">
|
||||||
|
<div className="text-sm font-medium mb-2">连接状态 (Tauri)</div>
|
||||||
|
{languages.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
暂无语言文件配置。请通过项目设置配置语言源目录。
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{baseDir && (
|
||||||
|
<div className="text-xs text-muted-foreground mb-2 break-all">
|
||||||
|
基础目录:{baseDir}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{languages.map((lang) => (
|
||||||
|
<div
|
||||||
|
key={lang.language}
|
||||||
|
className="flex items-start justify-between gap-2"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"size-1.5 rounded-full shrink-0",
|
||||||
|
lang.exists ? "bg-green-500" : "bg-red-500"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium truncate">
|
||||||
|
{lang.language}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-xs text-muted-foreground truncate ml-3.5"
|
||||||
|
title={lang.path}
|
||||||
|
>
|
||||||
|
{lang.path}
|
||||||
|
</div>
|
||||||
|
{!lang.exists && (
|
||||||
|
<div className="text-xs text-red-500 ml-3.5">
|
||||||
|
文件不存在
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="text-xs text-muted-foreground pt-4 mt-4 border-t border-border/30">
|
||||||
|
Tauri 模式下文件自动连接,无需手动操作。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 浏览器模式:使用 FileSystemFileHandle 管理连接 */
|
||||||
|
function BrowserConnectionIndicator({ projectId }: { projectId: string }) {
|
||||||
const snap = useFileConnections(projectId);
|
const snap = useFileConnections(projectId);
|
||||||
const list = Object.values(snap.connections);
|
const list = Object.values(snap.connections);
|
||||||
const hasAny = list.length > 0;
|
const hasAny = list.length > 0;
|
||||||
|
|
@ -31,11 +126,11 @@ export function HeaderConnectionIndicator({ projectId }: Props) {
|
||||||
{hasAny ? `已连线 ${list.length}` : "未连线"}
|
{hasAny ? `已连线 ${list.length}` : "未连线"}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="p-4">
|
<TooltipContent className="p-4" align="end">
|
||||||
<div className="text-sm font-medium mb-2">连线状态</div>
|
<div className="text-sm font-medium mb-2">连线状态</div>
|
||||||
{list.length === 0 ? (
|
{list.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
暂无连线。通过“导入 JSON”选择文件后将建立连线。
|
暂无连线。通过"导入 JSON"选择文件后将建立连线。
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ function ProjectSettingsModalImpl({ open, onOpenChange, project, onSave, onDelet
|
||||||
</div>
|
</div>
|
||||||
{err && <div className="text-sm text-red-600">{err}</div>}
|
{err && <div className="text-sm text-red-600">{err}</div>}
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button className="mr-auto" variant="destructive" onClick={handleDelete} disabled={saving}>删除项目</Button>
|
<Button className="mr-auto" type="button" variant="destructive" onClick={handleDelete} disabled={saving}>删除项目</Button>
|
||||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>取消</Button>
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>取消</Button>
|
||||||
<Button type="submit" disabled={saving}>保存</Button>
|
<Button type="submit" disabled={saving}>保存</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -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<EditorStores | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EditorProvider
|
||||||
|
* 为 Editor 组件提供局部化的状态管理
|
||||||
|
*
|
||||||
|
* 优势:
|
||||||
|
* 1. 每个 Editor 实例都有独立的状态(支持多个 Editor 同时存在)
|
||||||
|
* 2. 组件卸载时自动清理,无需手动重置
|
||||||
|
* 3. 测试更容易,每个测试可以创建独立的 Provider
|
||||||
|
* 4. 更符合 React 的组件化思想
|
||||||
|
*/
|
||||||
|
export function EditorProvider({ children }: { children: ReactNode }) {
|
||||||
|
// 使用 useMemo 确保 stores 只创建一次
|
||||||
|
const stores = useMemo<EditorStores>(
|
||||||
|
() => ({
|
||||||
|
projectStore: createEditorProjectStore(),
|
||||||
|
dataStore: createEditorDataStore(),
|
||||||
|
uiStore: createEditorUIStore(),
|
||||||
|
modalStore: createEditorModalStore(),
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <EditorContext.Provider value={stores}>{children}</EditorContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 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<T>(selector: (state: ReturnType<EditorProjectStore['getState']>) => T): T;
|
||||||
|
export function useEditorProjectStore<T>(selector?: (state: ReturnType<EditorProjectStore['getState']>) => 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<T>(selector: (state: ReturnType<EditorDataStore['getState']>) => T): T;
|
||||||
|
export function useEditorDataStore<T>(selector?: (state: ReturnType<EditorDataStore['getState']>) => 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<T>(selector: (state: ReturnType<EditorUIStore['getState']>) => T): T;
|
||||||
|
export function useEditorUIStore<T>(selector?: (state: ReturnType<EditorUIStore['getState']>) => 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<T>(selector: (state: ReturnType<EditorModalStore['getState']>) => T): T;
|
||||||
|
export function useEditorModalStore<T>(selector?: (state: ReturnType<EditorModalStore['getState']>) => 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();
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
getProject,
|
getProject,
|
||||||
getStructure,
|
getStructure,
|
||||||
listLanguages,
|
listLanguages,
|
||||||
type Project,
|
|
||||||
type ProjectStructure,
|
|
||||||
getLanguageTranslations,
|
getLanguageTranslations,
|
||||||
upsertLanguageTranslations,
|
upsertLanguageTranslations,
|
||||||
upsertStructure,
|
upsertStructure,
|
||||||
|
|
@ -36,37 +34,52 @@ import { useLanguageAdapter } from "@/hooks/use-language-adapter";
|
||||||
import { isTauriEnv } from "@/lib/is-tauri";
|
import { isTauriEnv } from "@/lib/is-tauri";
|
||||||
import EditorTable from "@/components/biz/editor/table";
|
import EditorTable from "@/components/biz/editor/table";
|
||||||
import { useEditorKeyboardShortcuts } from "@/hooks/biz/use-editor-keyboard-shortcuts";
|
import { useEditorKeyboardShortcuts } from "@/hooks/biz/use-editor-keyboard-shortcuts";
|
||||||
|
import { EditorProvider, useEditorProjectStore, useEditorDataStore, useEditorUIStore, useEditorModalStore, useEditorStores } from "@/contexts/editor-context";
|
||||||
|
|
||||||
type EditorProps = {
|
type EditorProps = {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 新的 Editor 组件:包裹 EditorProvider
|
||||||
export default function Editor({ projectId }: EditorProps) {
|
export default function Editor({ projectId }: EditorProps) {
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
return (
|
||||||
const [structure, setStructure] = useState<ProjectStructure | null>(null);
|
<EditorProvider>
|
||||||
const [languages, setLanguages] = useState<string[]>([]);
|
<EditorContent projectId={projectId} />
|
||||||
const [loading, setLoading] = useState(true);
|
</EditorProvider>
|
||||||
const [pageError, setPageError] = useState<string | null>(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);
|
|
||||||
|
|
||||||
const [aiModal, setAiModal] = useState<{ open: boolean; path: string } | null>(null);
|
// 原来的 Editor 逻辑移到这里
|
||||||
const [aiBulkOpen, setAiBulkOpen] = useState(false);
|
function EditorContent({ projectId }: EditorProps) {
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
// 从 Context Store 获取状态(只订阅需要渲染的状态)
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const stores = useEditorStores();
|
||||||
|
const project = useEditorProjectStore((state) => state.project);
|
||||||
|
const structure = useEditorProjectStore((state) => state.structure);
|
||||||
|
const languages = useEditorProjectStore((state) => state.languages);
|
||||||
|
|
||||||
const [savingAll, setSavingAll] = useState(false);
|
const valuesByLang = useEditorDataStore((state) => state.valuesByLang);
|
||||||
|
const setValuesByLang = useEditorDataStore((state) => state.setValuesByLang);
|
||||||
|
|
||||||
const [valuesByLang, setValuesByLang] = useState<Record<string, Record<string, string>>>({});
|
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({
|
const inlineEdit = useTranslationInlineEdit({
|
||||||
projectId,
|
projectId,
|
||||||
valuesByLang,
|
valuesByLang,
|
||||||
setValuesByLang,
|
setValuesByLang,
|
||||||
onError: (message) => setPageError(message),
|
onError: (message) => stores.uiStore.getState().setPageError(message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const connSnap = useFileConnections(projectId ?? "");
|
const connSnap = useFileConnections(projectId ?? "");
|
||||||
|
|
@ -99,35 +112,32 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
nextValues[file.language] = flattened;
|
nextValues[file.language] = flattened;
|
||||||
await upsertLanguageTranslations(projectId, file.language, flattened);
|
await upsertLanguageTranslations(projectId, file.language, flattened);
|
||||||
if (loaded.default_language && file.language === loaded.default_language) {
|
if (loaded.default_language && file.language === loaded.default_language) {
|
||||||
// 使用函数式更新来检查当前 structure 状态,避免依赖它
|
// 获取当前 structure 状态检查
|
||||||
setStructure((currentStructure) => {
|
const currentStructure = stores.projectStore.getState().structure;
|
||||||
if (!currentStructure) {
|
if (!currentStructure) {
|
||||||
try {
|
try {
|
||||||
const root = buildStructureFromObject(parsed);
|
const root = buildStructureFromObject(parsed);
|
||||||
void upsertStructure({ projectId, root });
|
await upsertStructure({ projectId, root });
|
||||||
return { projectId, root };
|
stores.projectStore.getState().setStructure({ projectId, root });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("生成结构失败", err);
|
console.error("生成结构失败", err);
|
||||||
return currentStructure;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return currentStructure;
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const loadedLanguages = Object.keys(nextValues);
|
const loadedLanguages = Object.keys(nextValues);
|
||||||
if (loadedLanguages.length === 0) {
|
if (loadedLanguages.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
setValuesByLang((old) => ({ ...old, ...nextValues }));
|
stores.dataStore.getState().setValuesByLang((old) => ({ ...old, ...nextValues }));
|
||||||
setLanguages(loadedLanguages);
|
stores.projectStore.getState().setLanguages(loadedLanguages);
|
||||||
if (!opts?.silent) {
|
if (!opts?.silent) {
|
||||||
toast.success("翻译内容已同步", {
|
toast.success("翻译内容已同步", {
|
||||||
description: loaded.base_dir ? `来源:${loaded.base_dir}` : undefined,
|
description: loaded.base_dir ? `来源:${loaded.base_dir}` : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}, [projectId, setSourcesMeta]);
|
}, [projectId, setSourcesMeta, stores]);
|
||||||
|
|
||||||
const syncFromAdapter = useCallback(
|
const syncFromAdapter = useCallback(
|
||||||
async (opts?: { silent?: boolean }) => {
|
async (opts?: { silent?: boolean }) => {
|
||||||
|
|
@ -182,11 +192,11 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
const [nextProject, nextStructure, nextLanguages] = await Promise.all([getProject(projectId), getStructure(projectId), listLanguages(projectId)]);
|
const [nextProject, nextStructure, nextLanguages] = await Promise.all([getProject(projectId), getStructure(projectId), listLanguages(projectId)]);
|
||||||
if (!nextProject) throw new Error("项目不存在");
|
if (!nextProject) throw new Error("项目不存在");
|
||||||
setProject(nextProject);
|
stores.projectStore.getState().setProject(nextProject);
|
||||||
upsertProjectMeta(nextProject);
|
upsertProjectMeta(nextProject);
|
||||||
setStructure(nextStructure ?? null);
|
stores.projectStore.getState().setStructure(nextStructure ?? null);
|
||||||
setLanguages(nextLanguages);
|
stores.projectStore.getState().setLanguages(nextLanguages);
|
||||||
}, [projectId, upsertProjectMeta]);
|
}, [projectId, stores, upsertProjectMeta]);
|
||||||
|
|
||||||
const bootstrapSources = useCallback(async (): Promise<LoadedProjectSources | null> => {
|
const bootstrapSources = useCallback(async (): Promise<LoadedProjectSources | null> => {
|
||||||
if (!projectId) return null;
|
if (!projectId) return null;
|
||||||
|
|
@ -228,7 +238,8 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取默认语言的翻译内容
|
// 从 store 获取最新的翻译内容
|
||||||
|
const { valuesByLang } = stores.dataStore.getState();
|
||||||
const translations = valuesByLang[defaultLang];
|
const translations = valuesByLang[defaultLang];
|
||||||
if (!translations || Object.keys(translations).length === 0) {
|
if (!translations || Object.keys(translations).length === 0) {
|
||||||
if (!opts?.silent) {
|
if (!opts?.silent) {
|
||||||
|
|
@ -244,7 +255,7 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
const root = buildStructureFromObject(nestedObj);
|
const root = buildStructureFromObject(nestedObj);
|
||||||
// 保存到数据库
|
// 保存到数据库
|
||||||
await upsertStructure({ projectId, root });
|
await upsertStructure({ projectId, root });
|
||||||
setStructure({ projectId, root });
|
stores.projectStore.getState().setStructure({ projectId, root });
|
||||||
|
|
||||||
if (!opts?.silent) {
|
if (!opts?.silent) {
|
||||||
toast.success("翻译结构已更新");
|
toast.success("翻译结构已更新");
|
||||||
|
|
@ -259,7 +270,7 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[projectId, sourcesState.defaultLanguage, valuesByLang]
|
[projectId, sourcesState.defaultLanguage, stores]
|
||||||
);
|
);
|
||||||
|
|
||||||
const syncExternalSources = useCallback(
|
const syncExternalSources = useCallback(
|
||||||
|
|
@ -288,18 +299,18 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
const refresh = useCallback(
|
const refresh = useCallback(
|
||||||
async (opts?: { silent?: boolean }) => {
|
async (opts?: { silent?: boolean }) => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
setLoading(true);
|
stores.uiStore.getState().setLoading(true);
|
||||||
setPageError(null);
|
stores.uiStore.getState().setPageError(null);
|
||||||
try {
|
try {
|
||||||
await bootstrapProject();
|
await bootstrapProject();
|
||||||
await syncExternalSources({ silent: opts?.silent ?? true, reloadConfig: true });
|
await syncExternalSources({ silent: opts?.silent ?? true, reloadConfig: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setPageError((e as Error)?.message ?? "加载失败");
|
stores.uiStore.getState().setPageError((e as Error)?.message ?? "加载失败");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
stores.uiStore.getState().setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[bootstrapProject, projectId, syncExternalSources]
|
[bootstrapProject, projectId, stores, syncExternalSources]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleWizardSourcesLoaded = useCallback(
|
const handleWizardSourcesLoaded = useCallback(
|
||||||
|
|
@ -323,23 +334,23 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
// 只在 projectId 变化时触发,避免因 refresh 引用变化导致的循环
|
// 只在 projectId 变化时触发,避免因 refresh 引用变化导致的循环
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
setLoading(true);
|
stores.uiStore.getState().setLoading(true);
|
||||||
setPageError(null);
|
stores.uiStore.getState().setPageError(null);
|
||||||
|
|
||||||
const initialize = async () => {
|
const initialize = async () => {
|
||||||
try {
|
try {
|
||||||
await bootstrapProject();
|
await bootstrapProject();
|
||||||
await syncExternalSources({ silent: true, reloadConfig: true });
|
await syncExternalSources({ silent: true, reloadConfig: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setPageError((e as Error)?.message ?? "加载失败");
|
stores.uiStore.getState().setPageError((e as Error)?.message ?? "加载失败");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
stores.uiStore.getState().setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
void initialize();
|
void initialize();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [projectId]); // 只依赖 projectId
|
}, [projectId, stores]); // 依赖 projectId 和 stores
|
||||||
|
|
||||||
const entries: FlatEntry[] = useMemo(() => {
|
const entries: FlatEntry[] = useMemo(() => {
|
||||||
if (!structure) return [];
|
if (!structure) return [];
|
||||||
|
|
@ -347,7 +358,13 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
}, [structure]);
|
}, [structure]);
|
||||||
|
|
||||||
const handleSaveAllConnected = useCallback(async () => {
|
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) {
|
if (!adapter) {
|
||||||
toast.error("当前项目未配置可写入的语言文件");
|
toast.error("当前项目未配置可写入的语言文件");
|
||||||
return;
|
return;
|
||||||
|
|
@ -366,7 +383,7 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSavingAll(true);
|
stores.uiStore.getState().setSavingAll(true);
|
||||||
try {
|
try {
|
||||||
const orderedPaths = flattenEntries(structure.root).map((e) => e.path);
|
const orderedPaths = flattenEntries(structure.root).map((e) => e.path);
|
||||||
const payloads = targetLanguages.map((lang) => ({
|
const payloads = targetLanguages.map((lang) => ({
|
||||||
|
|
@ -385,11 +402,14 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error((err as Error)?.message ?? "保存失败");
|
toast.error((err as Error)?.message ?? "保存失败");
|
||||||
} finally {
|
} finally {
|
||||||
setSavingAll(false);
|
stores.uiStore.getState().setSavingAll(false);
|
||||||
}
|
}
|
||||||
}, [adapter, connSnap.connections, projectId, structure, valuesByLang]);
|
}, [adapter, connSnap.connections, projectId, stores]);
|
||||||
|
|
||||||
|
const computeSuggestedLanguages = useCallback((pathsInput: string[]): string[] => {
|
||||||
|
const { languages } = stores.projectStore.getState();
|
||||||
|
const { valuesByLang } = stores.dataStore.getState();
|
||||||
|
|
||||||
function computeSuggestedLanguages(pathsInput: string[]): string[] {
|
|
||||||
if (languages.length === 0 || pathsInput.length === 0) return [];
|
if (languages.length === 0 || pathsInput.length === 0) return [];
|
||||||
|
|
||||||
const missing = new Set<string>();
|
const missing = new Set<string>();
|
||||||
|
|
@ -412,9 +432,12 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
const arr = Array.from(missing);
|
const arr = Array.from(missing);
|
||||||
|
|
||||||
return arr.length > 0 ? arr : languages; // 如无缺失则默认全选
|
return arr.length > 0 ? arr : languages; // 如无缺失则默认全选
|
||||||
}
|
}, [stores]);
|
||||||
|
|
||||||
const getExistingByPath = useCallback((path: string) => {
|
const getExistingByPath = useCallback((path: string) => {
|
||||||
|
const { languages } = stores.projectStore.getState();
|
||||||
|
const { valuesByLang } = stores.dataStore.getState();
|
||||||
|
|
||||||
const result: Record<string, string> = {};
|
const result: Record<string, string> = {};
|
||||||
for (const lang of languages) {
|
for (const lang of languages) {
|
||||||
const text = valuesByLang[lang]?.[path];
|
const text = valuesByLang[lang]?.[path];
|
||||||
|
|
@ -424,60 +447,64 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [languages, valuesByLang]);
|
}, [stores]);
|
||||||
|
|
||||||
const handleOpenAddEntry = useCallback((path: string, position: "above" | "below") => {
|
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) => {
|
const handleOpenAiTranslate = useCallback((path: string) => {
|
||||||
setAiModal({ open: true, path });
|
stores.modalStore.getState().setAiModal({ open: true, path });
|
||||||
}, []);
|
}, [stores]);
|
||||||
|
|
||||||
const handleOpenRenameEntry = useCallback((path: string) => {
|
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) => {
|
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 {
|
try {
|
||||||
const nextRoot = moveEntryByOffset(structure.root, path, offset);
|
const nextRoot = moveEntryByOffset(structure.root, path, offset);
|
||||||
await upsertStructure({ projectId, root: nextRoot });
|
await upsertStructure({ projectId, root: nextRoot });
|
||||||
setStructure({ projectId, root: nextRoot });
|
stores.projectStore.getState().setStructure({ projectId, root: nextRoot });
|
||||||
} catch (e) {
|
} 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) => {
|
const handleDeleteEntry = useCallback(async (path: string) => {
|
||||||
if (!projectId || !structure) return;
|
if (!projectId) return;
|
||||||
|
|
||||||
|
const { structure } = stores.projectStore.getState();
|
||||||
|
if (!structure) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const nextRoot = removeEntryAtPath(structure.root, path);
|
const nextRoot = removeEntryAtPath(structure.root, path);
|
||||||
await upsertStructure({ projectId, root: nextRoot });
|
await upsertStructure({ projectId, root: nextRoot });
|
||||||
await deleteEntryFromAllLanguages(projectId, path);
|
await deleteEntryFromAllLanguages(projectId, path);
|
||||||
setStructure({ projectId, root: nextRoot });
|
stores.projectStore.getState().setStructure({ projectId, root: nextRoot });
|
||||||
setValuesByLang((old) => {
|
stores.dataStore.getState().deleteEntry(path);
|
||||||
const copy: typeof old = {};
|
stores.uiStore.getState().setSelected((prev) => {
|
||||||
for (const [langKey, vals] of Object.entries(old)) {
|
|
||||||
const next = { ...vals } as Record<string, string>;
|
|
||||||
delete next[path];
|
|
||||||
copy[langKey] = next;
|
|
||||||
}
|
|
||||||
return copy;
|
|
||||||
});
|
|
||||||
setSelected((prev) => {
|
|
||||||
if (!prev.has(path)) return prev;
|
if (!prev.has(path)) return prev;
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.delete(path);
|
next.delete(path);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} 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[]) => {
|
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 {
|
try {
|
||||||
let nextRoot = structure.root;
|
let nextRoot = structure.root;
|
||||||
for (const p of paths) {
|
for (const p of paths) {
|
||||||
|
|
@ -485,25 +512,21 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
}
|
}
|
||||||
await upsertStructure({ projectId, root: nextRoot });
|
await upsertStructure({ projectId, root: nextRoot });
|
||||||
await Promise.all(paths.map((p) => deleteEntryFromAllLanguages(projectId, p)));
|
await Promise.all(paths.map((p) => deleteEntryFromAllLanguages(projectId, p)));
|
||||||
setStructure({ projectId, root: nextRoot });
|
stores.projectStore.getState().setStructure({ projectId, root: nextRoot });
|
||||||
setValuesByLang((old) => {
|
|
||||||
const copy: typeof old = {};
|
// 使用 store 的批量删除
|
||||||
for (const [langKey, vals] of Object.entries(old)) {
|
for (const p of paths) {
|
||||||
const next = { ...vals } as Record<string, string>;
|
stores.dataStore.getState().deleteEntry(p);
|
||||||
for (const p of paths) delete next[p];
|
}
|
||||||
copy[langKey] = next;
|
stores.uiStore.getState().setSelected(new Set());
|
||||||
}
|
|
||||||
return copy;
|
|
||||||
});
|
|
||||||
setSelected(new Set());
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setPageError((e as Error)?.message ?? "批量删除失败");
|
stores.uiStore.getState().setPageError((e as Error)?.message ?? "批量删除失败");
|
||||||
}
|
}
|
||||||
}, [projectId, setSelected, setValuesByLang, structure]);
|
}, [projectId, stores]);
|
||||||
|
|
||||||
const handleOpenBulkAiTranslate = useCallback(() => {
|
const handleOpenBulkAiTranslate = useCallback(() => {
|
||||||
setAiBulkOpen(true);
|
stores.modalStore.getState().setAiBulkOpen(true);
|
||||||
}, []);
|
}, [stores]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectId || languages.length === 0) return;
|
if (!projectId || languages.length === 0) return;
|
||||||
|
|
@ -514,12 +537,12 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
const rec = await getLanguageTranslations(projectId, lang);
|
const rec = await getLanguageTranslations(projectId, lang);
|
||||||
all[lang] = rec?.values ?? {};
|
all[lang] = rec?.values ?? {};
|
||||||
}
|
}
|
||||||
if (!cancelled) setValuesByLang(all);
|
if (!cancelled) stores.dataStore.getState().setValuesByLang(all);
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [projectId, languages]);
|
}, [projectId, languages, stores]);
|
||||||
|
|
||||||
const disabledItems = useMemo(() => {
|
const disabledItems = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
|
|
@ -530,7 +553,7 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
"import-with-files": false,
|
"import-with-files": false,
|
||||||
export: languages.length === 0,
|
export: languages.length === 0,
|
||||||
};
|
};
|
||||||
}, [adapter, structure, languages.length]);
|
}, [adapter, structure, languages.length, savingAll, connSnap.connections]);
|
||||||
|
|
||||||
const onClickItem = useCallback((item: CommonMenubarItem) => {
|
const onClickItem = useCallback((item: CommonMenubarItem) => {
|
||||||
switch (item) {
|
switch (item) {
|
||||||
|
|
@ -544,16 +567,16 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
void handleSaveAllConnected();
|
void handleSaveAllConnected();
|
||||||
break;
|
break;
|
||||||
case "import-with-directory":
|
case "import-with-directory":
|
||||||
setSourcesWizardOpen(true);
|
stores.modalStore.getState().setSourcesWizardOpen(true);
|
||||||
break;
|
break;
|
||||||
case "import-with-files":
|
case "import-with-files":
|
||||||
setImportOpen(true);
|
stores.modalStore.getState().setImportOpen(true);
|
||||||
break;
|
break;
|
||||||
case "export":
|
case "export":
|
||||||
setExportOpen(true);
|
stores.modalStore.getState().setExportOpen(true);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [handleSaveAllConnected, setSourcesWizardOpen, setImportOpen, setExportOpen, syncExternalSources]);
|
}, [handleSaveAllConnected, syncExternalSources, stores]);
|
||||||
|
|
||||||
// 注册键盘快捷方式
|
// 注册键盘快捷方式
|
||||||
useEditorKeyboardShortcuts({
|
useEditorKeyboardShortcuts({
|
||||||
|
|
@ -571,7 +594,7 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
<div className="flex items-center justify-end gap-2 text-foreground">
|
<div className="flex items-center justify-end gap-2 text-foreground">
|
||||||
<HeaderConnectionIndicator projectId={projectId ?? ""} />
|
<HeaderConnectionIndicator projectId={projectId ?? ""} />
|
||||||
{project && (
|
{project && (
|
||||||
<Button variant="ghost" onClick={() => setSettingsOpen(true)}>
|
<Button variant="ghost" onClick={() => stores.modalStore.getState().setSettingsOpen(true)}>
|
||||||
<Settings />
|
<Settings />
|
||||||
<span className="sr-only">设置</span>
|
<span className="sr-only">设置</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -592,7 +615,7 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
该项目还没有翻译结构。请导入一份 JSON 文件来构建结构,并同时录入该语言的翻译内容。
|
该项目还没有翻译结构。请导入一份 JSON 文件来构建结构,并同时录入该语言的翻译内容。
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<Button onClick={() => { setSourcesWizardOpen(true); }}>
|
<Button onClick={() => { stores.modalStore.getState().setSourcesWizardOpen(true); }}>
|
||||||
<FolderOpen />
|
<FolderOpen />
|
||||||
通过目录导入
|
通过目录导入
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -619,7 +642,7 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
|
|
||||||
<ProjectSourcesWizard
|
<ProjectSourcesWizard
|
||||||
open={sourcesWizardOpen}
|
open={sourcesWizardOpen}
|
||||||
onOpenChange={setSourcesWizardOpen}
|
onOpenChange={(v) => stores.modalStore.getState().setSourcesWizardOpen(v)}
|
||||||
projectId={projectId ?? ""}
|
projectId={projectId ?? ""}
|
||||||
onCompleted={refresh}
|
onCompleted={refresh}
|
||||||
onSourcesLoaded={handleWizardSourcesLoaded}
|
onSourcesLoaded={handleWizardSourcesLoaded}
|
||||||
|
|
@ -627,7 +650,7 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
|
|
||||||
<ImportLanguageModal
|
<ImportLanguageModal
|
||||||
open={importOpen}
|
open={importOpen}
|
||||||
onOpenChange={setImportOpen}
|
onOpenChange={(v) => stores.modalStore.getState().setImportOpen(v)}
|
||||||
projectId={projectId ?? ""}
|
projectId={projectId ?? ""}
|
||||||
hasStructure={!!structure}
|
hasStructure={!!structure}
|
||||||
onImported={refresh}
|
onImported={refresh}
|
||||||
|
|
@ -635,7 +658,7 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
/>
|
/>
|
||||||
<ExportLanguageModal
|
<ExportLanguageModal
|
||||||
open={exportOpen}
|
open={exportOpen}
|
||||||
onOpenChange={setExportOpen}
|
onOpenChange={(v) => stores.modalStore.getState().setExportOpen(v)}
|
||||||
projectId={projectId ?? ""}
|
projectId={projectId ?? ""}
|
||||||
languages={languages}
|
languages={languages}
|
||||||
valuesByLang={valuesByLang}
|
valuesByLang={valuesByLang}
|
||||||
|
|
@ -644,12 +667,12 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
|
|
||||||
<ProjectSettingsModal
|
<ProjectSettingsModal
|
||||||
open={settingsOpen}
|
open={settingsOpen}
|
||||||
onOpenChange={setSettingsOpen}
|
onOpenChange={(v) => stores.modalStore.getState().setSettingsOpen(v)}
|
||||||
project={project}
|
project={project}
|
||||||
onSave={async (update) => {
|
onSave={async (update) => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
const next = await updateProject({ id: projectId, name: update.name, preferences: update.preferences });
|
const next = await updateProject({ id: projectId, name: update.name, preferences: update.preferences });
|
||||||
setProject(next);
|
stores.projectStore.getState().setProject(next);
|
||||||
upsertProjectMeta(next);
|
upsertProjectMeta(next);
|
||||||
}}
|
}}
|
||||||
onDelete={async () => {
|
onDelete={async () => {
|
||||||
|
|
@ -662,7 +685,7 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
|
|
||||||
<AiTranslateModal
|
<AiTranslateModal
|
||||||
open={!!aiModal?.open}
|
open={!!aiModal?.open}
|
||||||
onOpenChange={(v) => setAiModal((cur) => (cur ? { ...cur, open: v } : cur))}
|
onOpenChange={(v) => stores.modalStore.getState().setAiModal((cur) => (cur ? { ...cur, open: v } : cur))}
|
||||||
languages={languages}
|
languages={languages}
|
||||||
paths={aiModal?.path ? [aiModal.path] : []}
|
paths={aiModal?.path ? [aiModal.path] : []}
|
||||||
initialSelectedLanguages={aiModal?.path ? computeSuggestedLanguages([aiModal.path]) : []}
|
initialSelectedLanguages={aiModal?.path ? computeSuggestedLanguages([aiModal.path]) : []}
|
||||||
|
|
@ -670,7 +693,14 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
prompt={project?.preferences?.aiPrompt}
|
prompt={project?.preferences?.aiPrompt}
|
||||||
model={project?.preferences?.aiModel}
|
model={project?.preferences?.aiModel}
|
||||||
onConfirm={async (translations, options) => {
|
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<string, Record<string, string>> = {};
|
const updates: Record<string, Record<string, string>> = {};
|
||||||
try {
|
try {
|
||||||
const targetLangs = options?.selectedLanguages ?? languages;
|
const targetLangs = options?.selectedLanguages ?? languages;
|
||||||
|
|
@ -689,10 +719,10 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
await upsertLanguageTranslations(projectId, lang, next);
|
await upsertLanguageTranslations(projectId, lang, next);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
setValuesByLang((old) => ({ ...old, ...updates }));
|
stores.dataStore.getState().setValuesByLang((old) => ({ ...old, ...updates }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = (e as Error)?.message ?? "保存翻译失败";
|
const msg = (e as Error)?.message ?? "保存翻译失败";
|
||||||
setPageError(msg);
|
stores.uiStore.getState().setPageError(msg);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
@ -700,7 +730,7 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
|
|
||||||
<AiTranslateModal
|
<AiTranslateModal
|
||||||
open={aiBulkOpen}
|
open={aiBulkOpen}
|
||||||
onOpenChange={setAiBulkOpen}
|
onOpenChange={(v) => stores.modalStore.getState().setAiBulkOpen(v)}
|
||||||
languages={languages}
|
languages={languages}
|
||||||
paths={Array.from(selected)}
|
paths={Array.from(selected)}
|
||||||
initialSelectedLanguages={computeSuggestedLanguages(Array.from(selected))}
|
initialSelectedLanguages={computeSuggestedLanguages(Array.from(selected))}
|
||||||
|
|
@ -708,7 +738,14 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
prompt={project?.preferences?.aiPrompt}
|
prompt={project?.preferences?.aiPrompt}
|
||||||
model={project?.preferences?.aiModel}
|
model={project?.preferences?.aiModel}
|
||||||
onConfirm={async (translations, options) => {
|
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 {
|
try {
|
||||||
const updatesByLang: Record<string, Record<string, string>> = {};
|
const updatesByLang: Record<string, Record<string, string>> = {};
|
||||||
const targetLangs = options?.selectedLanguages ?? languages;
|
const targetLangs = options?.selectedLanguages ?? languages;
|
||||||
|
|
@ -727,9 +764,9 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
await upsertLanguageTranslations(projectId, lang, next);
|
await upsertLanguageTranslations(projectId, lang, next);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
setValuesByLang((old) => ({ ...old, ...updatesByLang }));
|
stores.dataStore.getState().setValuesByLang((old) => ({ ...old, ...updatesByLang }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setPageError((e as Error)?.message ?? "批量 AI 翻译保存失败");
|
stores.uiStore.getState().setPageError((e as Error)?.message ?? "批量 AI 翻译保存失败");
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
@ -737,16 +774,22 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
|
|
||||||
<EntryNameModal
|
<EntryNameModal
|
||||||
open={!!addModal?.open}
|
open={!!addModal?.open}
|
||||||
onOpenChange={(v) => setAddModal((cur) => (cur ? { ...cur, open: v } : cur))}
|
onOpenChange={(v) => stores.modalStore.getState().setAddModal((cur) => (cur ? { ...cur, open: v } : cur))}
|
||||||
title={addModal?.position === "above" ? "在上方新增条目" : "在下方新增条目"}
|
title={addModal?.position === "above" ? "在上方新增条目" : "在下方新增条目"}
|
||||||
placeholder="请输入条目名称(不含点)"
|
placeholder="请输入条目名称(不含点)"
|
||||||
onConfirm={async (name) => {
|
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);
|
const nextRoot = insertEntrySibling(structure.root, addModal.path, addModal.position, name);
|
||||||
console.log("nextRoot", addModal, nextRoot);
|
console.log("nextRoot", addModal, nextRoot);
|
||||||
|
|
||||||
await upsertStructure({ projectId, root: nextRoot });
|
await upsertStructure({ projectId, root: nextRoot });
|
||||||
setStructure({ projectId, root: nextRoot });
|
stores.projectStore.getState().setStructure({ projectId, root: nextRoot });
|
||||||
}}
|
}}
|
||||||
validate={(name) => {
|
validate={(name) => {
|
||||||
// 同级重名校验在结构函数中也会做,这里做基础提示即可
|
// 同级重名校验在结构函数中也会做,这里做基础提示即可
|
||||||
|
|
@ -757,28 +800,23 @@ export default function Editor({ projectId }: EditorProps) {
|
||||||
|
|
||||||
<EntryNameModal
|
<EntryNameModal
|
||||||
open={!!renameModal?.open}
|
open={!!renameModal?.open}
|
||||||
onOpenChange={(v) => setRenameModal((cur) => (cur ? { ...cur, open: v } : cur))}
|
onOpenChange={(v) => stores.modalStore.getState().setRenameModal((cur) => (cur ? { ...cur, open: v } : cur))}
|
||||||
title="重命名条目"
|
title="重命名条目"
|
||||||
placeholder="请输入新名称(不含点)"
|
placeholder="请输入新名称(不含点)"
|
||||||
defaultValue={renameModal?.path ? renameModal.path.split('.').pop() : ''}
|
defaultValue={renameModal?.path ? renameModal.path.split('.').pop() : ''}
|
||||||
onConfirm={async (newName) => {
|
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);
|
const { root: nextRoot, newPath } = renameEntryAtPath(structure.root, renameModal.path, newName);
|
||||||
await upsertStructure({ projectId, root: nextRoot });
|
await upsertStructure({ projectId, root: nextRoot });
|
||||||
await renameEntryInAllLanguages(projectId, renameModal.path, newPath);
|
await renameEntryInAllLanguages(projectId, renameModal.path, newPath);
|
||||||
setStructure({ projectId, root: nextRoot });
|
stores.projectStore.getState().setStructure({ projectId, root: nextRoot });
|
||||||
setValuesByLang((old) => {
|
stores.dataStore.getState().renameEntry(renameModal.path, newPath);
|
||||||
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;
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
validate={(name) => {
|
validate={(name) => {
|
||||||
if (name.includes('.')) return "名称不能包含 '.'";
|
if (name.includes('.')) return "名称不能包含 '.'";
|
||||||
|
|
|
||||||
|
|
@ -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<typeof createEditorProjectStore>;
|
||||||
|
|
||||||
|
export const createEditorProjectStore = () => {
|
||||||
|
return createStore<EditorProjectState>((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<string, Record<string, string>>;
|
||||||
|
setValuesByLang: (valuesByLang: Record<string, Record<string, string>> | ((old: Record<string, Record<string, string>>) => Record<string, Record<string, string>>)) => void;
|
||||||
|
updateLanguageValues: (lang: string, values: Record<string, string>) => 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<typeof createEditorDataStore>;
|
||||||
|
|
||||||
|
export const createEditorDataStore = () => {
|
||||||
|
return createStore<EditorDataState>((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<string, Record<string, string>> = {};
|
||||||
|
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<string, Record<string, string>> = {};
|
||||||
|
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<string>;
|
||||||
|
savingAll: boolean;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
setPageError: (error: string | null) => void;
|
||||||
|
setSelected: (selected: Set<string> | ((prev: Set<string>) => Set<string>)) => void;
|
||||||
|
toggleSelected: (path: string) => void;
|
||||||
|
clearSelected: () => void;
|
||||||
|
setSavingAll: (saving: boolean) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EditorUIStore = ReturnType<typeof createEditorUIStore>;
|
||||||
|
|
||||||
|
export const createEditorUIStore = () => {
|
||||||
|
return createStore<EditorUIState>((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<typeof createEditorModalStore>;
|
||||||
|
|
||||||
|
export const createEditorModalStore = () => {
|
||||||
|
return createStore<EditorModalState>((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 };
|
||||||
Loading…
Reference in New Issue