Fix: 重构部分逻辑,编辑器表格部分逻辑拆分,数据源改为适配器模式,增加键盘快捷键操作

This commit is contained in:
奇趣保罗 2026-01-29 12:01:09 +08:00
parent ff35f5b105
commit b51da785af
14 changed files with 1019 additions and 661 deletions

View File

@ -3,6 +3,7 @@ import {
MenubarContent, MenubarContent,
MenubarItem, MenubarItem,
MenubarMenu, MenubarMenu,
MenubarSeparator,
MenubarShortcut, MenubarShortcut,
MenubarTrigger, MenubarTrigger,
} from "@/components/ui/menubar"; } from "@/components/ui/menubar";
@ -10,6 +11,7 @@ import { ChevronDown } from "lucide-react";
export type CommonMenubarItem = export type CommonMenubarItem =
| "read" | "read"
| "read-all"
| "save" | "save"
| "import-with-directory" | "import-with-directory"
| "import-with-files" | "import-with-files"
@ -29,10 +31,14 @@ function CommonMenubar({ disabledItems, onClickItem }: CommonMenubarProps) {
</MenubarTrigger> </MenubarTrigger>
<MenubarContent> <MenubarContent>
<MenubarItem onClick={() => onClickItem("read")} disabled={disabledItems?.read}> <MenubarItem onClick={() => onClickItem("read")} disabled={disabledItems?.read}>
<MenubarShortcut>R</MenubarShortcut> <MenubarShortcut>R</MenubarShortcut>
</MenubarItem> </MenubarItem>
<MenubarItem onClick={() => onClickItem("read-all")} disabled={disabledItems?.read}>
<MenubarShortcut>R</MenubarShortcut>
</MenubarItem>
<MenubarSeparator />
<MenubarItem onClick={() => onClickItem("save")} disabled={disabledItems?.save}> <MenubarItem onClick={() => onClickItem("save")} disabled={disabledItems?.save}>
<MenubarShortcut>S</MenubarShortcut> <MenubarShortcut>S</MenubarShortcut>
</MenubarItem> </MenubarItem>
</MenubarContent> </MenubarContent>
</MenubarMenu> </MenubarMenu>
@ -54,7 +60,9 @@ function CommonMenubar({ disabledItems, onClickItem }: CommonMenubarProps) {
<ChevronDown className="size-4 opacity-30" /> <ChevronDown className="size-4 opacity-30" />
</MenubarTrigger> </MenubarTrigger>
<MenubarContent> <MenubarContent>
<MenubarItem onClick={() => onClickItem("export")} disabled={disabledItems?.export}> JSON</MenubarItem> <MenubarItem onClick={() => onClickItem("export")} disabled={disabledItems?.export}>
JSON
</MenubarItem>
</MenubarContent> </MenubarContent>
</MenubarMenu> </MenubarMenu>
</Menubar> </Menubar>

View File

@ -0,0 +1,388 @@
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import { ArrowBigDownDash, ArrowBigUpDash, Brackets, CaseSensitive, Filter, Languages, LocateFixed, MoreVertical, PencilLine, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type React from "react";
import { TableVirtuoso, type TableVirtuosoHandle } from "react-virtuoso";
import { toast } from "sonner";
import { useClipboard } from "@/hooks/use-clipboard";
import { useTableOptionState } from "./use-table-option-state";
import type { FlatEntry } from "@/lib/i18n-structure";
import type { TranslationInlineEditHandlers } from "@/hooks/biz/use-translation-inline-edit";
interface EditorTableProps {
entries: FlatEntry[];
languages: string[];
selected: Set<string>;
setSelected: React.Dispatch<React.SetStateAction<Set<string>>>;
inlineEdit: TranslationInlineEditHandlers;
onOpenAddEntry: (path: string, position: "above" | "below") => void;
onOpenAiTranslate: (path: string) => void;
onOpenBulkAiTranslate: () => void;
onOpenRenameEntry: (path: string) => void;
onMoveEntry: (path: string, offset: number) => Promise<void> | void;
onDeleteEntry: (path: string) => Promise<void> | void;
onDeleteSelected: (paths: string[]) => Promise<void> | void;
maxAiItems?: number;
}
export default function EditorTable({
entries,
languages,
selected,
setSelected,
inlineEdit,
onOpenAddEntry,
onOpenAiTranslate,
onOpenBulkAiTranslate,
onOpenRenameEntry,
onMoveEntry,
onDeleteEntry,
onDeleteSelected,
maxAiItems = 50,
}: EditorTableProps) {
const { copy } = useClipboard();
const [visibleLangs, setVisibleLangs] = useState<Set<string>>(new Set());
useEffect(() => {
setVisibleLangs(new Set(languages));
}, [languages]);
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
const scrollerRootRef = useRef<HTMLElement | Window | null>(null);
const {
query, setQuery,
caseSensitive, setCaseSensitive,
fullMatch, setFullMatch,
} = useTableOptionState();
const [moveCountUp, setMoveCountUp] = useState(1);
const [moveCountDown, setMoveCountDown] = useState(1);
const allSelected = useMemo(() => entries.length > 0 && selected.size === entries.length, [entries, selected]);
const MAX_AI_ITEMS = maxAiItems;
const highlightRow = useCallback((index: number) => {
const tryFindAndAnimate = (attempt = 0) => {
const root = scrollerRootRef.current as HTMLElement | null;
if (!root) return;
const row = root.querySelector(`tr[data-item-index="${index}"]`) as HTMLTableRowElement | null;
if (row) {
row.animate(
[
{ backgroundColor: "rgba(250, 204, 21, 0.6)" },
{ backgroundColor: "transparent" },
{ backgroundColor: "rgba(250, 204, 21, 0.6)" },
{ backgroundColor: "transparent" },
],
{ duration: 1200, easing: "ease-in-out" }
);
return;
}
if (attempt < 10) {
setTimeout(() => tryFindAndAnimate(attempt + 1), 50);
}
};
requestAnimationFrame(() => tryFindAndAnimate(0));
}, []);
const scrollToQuery = useCallback((ev: React.FormEvent<HTMLFormElement>) => {
ev.preventDefault();
ev.stopPropagation();
if (!query) return;
const idx = entries.findIndex((e) => {
const hay = caseSensitive ? e.path : e.path.toLowerCase();
const needle = caseSensitive ? query : query.toLowerCase();
return fullMatch ? hay === needle : hay.includes(needle);
});
if (idx >= 0) {
virtuosoRef.current?.scrollIntoView({ index: idx, align: "center", done: () => highlightRow(idx) });
}
}, [caseSensitive, entries, fullMatch, highlightRow, query]);
const displayedLanguages = useMemo(() => languages.filter((l) => visibleLangs.has(l)), [languages, visibleLangs]);
const tableWidth = useMemo(() => (displayedLanguages.length * 200) + 36 + 60 + 300, [displayedLanguages.length]);
const virtuosoComponents = useMemo(() => ({
Table: ({ style, ...props }: React.TableHTMLAttributes<HTMLTableElement>) => <table className="min-w-full text-sm table-fixed" {...props} style={{ ...style, width: tableWidth }} />,
TableHead: (props: React.HTMLAttributes<HTMLTableSectionElement>) => <thead className="bg-muted" {...props} />,
TableRow: (props: React.HTMLAttributes<HTMLTableRowElement>) => <tr className="border-t" {...props} />,
TableBody: (props: React.HTMLAttributes<HTMLTableSectionElement>) => <tbody {...props} />,
TableFoot: (props: React.HTMLAttributes<HTMLTableSectionElement>) => <tfoot {...props} />,
}), [tableWidth]);
const headerContent = useCallback(() => (
<tr>
<th style={{ width: 36 }} className="sticky left-0 text-left px-3 py-2 bg-muted">
<Checkbox
checked={allSelected}
onCheckedChange={(checked) => {
const next = new Set<string>();
if (checked) {
for (const en of entries) next.add(en.path);
}
setSelected(next);
}}
/>
</th>
<th style={{ width: 300 }} className="text-left px-3 py-2"></th>
{displayedLanguages.map((lang) => (
<th key={lang} style={{ width: 200 }} className="text-left px-3 py-2">{lang}</th>
))}
<th style={{ width: 60 }} className="sticky right-0 text-left px-3 py-2 bg-muted"></th>
</tr>
), [allSelected, displayedLanguages, entries, setSelected]);
const renderItemContent = useCallback((_idx: number, entry: FlatEntry) => {
const handleCopy = () => {
copy(entry.path);
toast.success("复制成功");
};
return (
<>
<td className="sticky left-0 px-3 py-2 bg-white">
<Checkbox
checked={selected.has(entry.path)}
onCheckedChange={(checked) => {
setSelected((prev) => {
const next = new Set(prev);
if (checked) next.add(entry.path); else next.delete(entry.path);
return next;
});
}}
/>
</td>
<td className="px-3 py-2 font-mono wrap-break-word">
<button type="button" className="w-full text-left min-h-8 leading-normal px-2 rounded hover:bg-accent" onClick={handleCopy} title="点击复制">
{entry.path}
</button>
</td>
{displayedLanguages.map((lang) => {
const isEditing = inlineEdit.isEditingCell(entry.path, lang);
const isSaving = inlineEdit.isSavingCell(entry.path, lang);
const displayValue = inlineEdit.getDisplayValue(entry.path, lang);
return (
<td key={`${entry.path}:${lang}`} className="px-3 py-2 text-foreground/90 align-top">
{isEditing ? (
<Textarea
ref={inlineEdit.inputRef}
value={inlineEdit.editingValue}
onChange={(e) => inlineEdit.setEditingValue(e.target.value)}
onBlur={() => { void inlineEdit.saveEdit(); }}
onKeyDown={inlineEdit.handleKeyDown}
disabled={isSaving}
className="leading-normal px-2 py-0"
/>
) : (
<button
type="button"
className="w-full text-left min-h-8 leading-normal px-2 rounded hover:bg-accent"
onClick={() => inlineEdit.startEdit(entry.path, lang)}
title="点击编辑"
>
{displayValue || <span className="text-destructive"></span>}
</button>
)}
</td>
);
})}
<td className="sticky right-0 px-3 py-2 align-top bg-white">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline">
<MoreVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuItem onClick={() => onOpenAddEntry(entry.path, "below")}>
<ArrowBigDownDash />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onOpenAddEntry(entry.path, "above")}>
<ArrowBigUpDash />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onOpenAiTranslate(entry.path)}>
<Languages />
AI
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onOpenRenameEntry(entry.path)}>
<PencilLine />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={async () => {
await onMoveEntry(entry.path, -moveCountUp);
}}
>
<ArrowBigUpDash />
<span className="whitespace-nowrap"></span>
<Input
type="number"
min={1}
value={moveCountUp}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setMoveCountUp(Number.isFinite(n) && n > 0 ? n : 1);
}}
className="h-7 w-16"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
/>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={async () => {
await onMoveEntry(entry.path, moveCountDown);
}}
>
<ArrowBigDownDash />
<span className="whitespace-nowrap"></span>
<Input
type="number"
min={1}
value={moveCountDown}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setMoveCountDown(Number.isFinite(n) && n > 0 ? n : 1);
}}
className="h-7 w-16"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
/>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={async () => {
if (!confirm("确认删除该条目?此操作会移除所有语言下的该键")) return;
await onDeleteEntry(entry.path);
}}
>
<Trash2 />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</>
);
}, [copy, displayedLanguages, inlineEdit, moveCountDown, moveCountUp, onDeleteEntry, onMoveEntry, onOpenAddEntry, onOpenAiTranslate, onOpenRenameEntry, selected, setSelected]);
return (
<div className="h-full flex flex-col">
<form className="mb-3 flex items-center gap-2" onSubmit={scrollToQuery}>
<Input placeholder="搜索翻译条目名称" value={query} onChange={(e) => setQuery(e.target.value)} />
<Button
type="button"
variant={fullMatch ? "default" : "outline"}
onClick={() => setFullMatch((v) => !v)}
title="切换全量匹配/模糊匹配"
>
<Brackets />
{fullMatch ? "全量匹配" : "模糊匹配"}
</Button>
<Button
type="button"
variant={caseSensitive ? "default" : "outline"}
onClick={() => setCaseSensitive((v) => !v)}
title="切换大小写敏感"
>
<CaseSensitive />
{caseSensitive ? "区分大小写" : "忽略大小写"}
</Button>
<Button type="submit">
<LocateFixed />
</Button>
</form>
<div className="mb-2 flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" title="过滤显示的语言列">
<Filter />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuItem onClick={() => setVisibleLangs(new Set(languages))}>
</DropdownMenuItem>
<DropdownMenuSeparator />
{languages.map((lang) => {
const checked = visibleLangs.has(lang);
return (
<DropdownMenuCheckboxItem
key={lang}
onSelect={(e) => e.preventDefault()}
checked={checked}
onCheckedChange={(v) => {
setVisibleLangs((prev) => {
const next = new Set(prev);
if (v) next.add(lang); else next.delete(lang);
return next;
});
}}
>
{lang}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
disabled={selected.size === 0 || selected.size > MAX_AI_ITEMS}
onClick={onOpenBulkAiTranslate}
title={selected.size > MAX_AI_ITEMS ? `最多支持 ${MAX_AI_ITEMS}` : "对所选条目进行 AI 翻译"}
>
<Languages />
AI {selected.size}
</Button>
{selected.size > MAX_AI_ITEMS && (
<span className="text-xs text-red-600"> {MAX_AI_ITEMS} </span>
)}
<Button
variant="outline-destructive"
disabled={selected.size === 0}
onClick={async () => {
if (!confirm(`确认删除所选 ${selected.size} 个条目?此操作会移除所有语言下的这些键`)) return;
await onDeleteSelected(Array.from(selected));
}}
title="删除所选条目"
>
<Trash2 />
</Button>
</div>
<div className="flex-1 border rounded-md">
<TableVirtuoso
ref={virtuosoRef}
data={entries}
fixedHeaderContent={headerContent}
itemContent={renderItemContent}
components={virtuosoComponents}
computeItemKey={(_index, entry) => entry.path}
scrollerRef={(el) => { scrollerRootRef.current = el; }}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,16 @@
import { useState } from "react";
export function useTableOptionState() {
const [query, setQuery] = useState("");
const [caseSensitive, setCaseSensitive] = useState(false);
const [fullMatch, setFullMatch] = useState(false);
return {
query,
setQuery,
caseSensitive,
setCaseSensitive,
fullMatch,
setFullMatch,
};
}

View File

@ -175,9 +175,15 @@ export function ProjectSourcesWizard({
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{" "}
<code>en/messages.json</code><code>zh-CN/messages.json</code>
</p> </p>
<pre className="text-xs rounded-md border bg-muted/40 p-3 text-muted-foreground"><code>{`en/
common.json
messages.json
zh-CN/
common.json
messages.json
`}</code></pre>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
type="button" type="button"

View File

@ -1,106 +0,0 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { useFileConnections } from "@/store/file-connection";
import { toast } from "sonner";
import { upsertLanguageTranslations } from "@/lib/db";
import { flattenValues } from "@/lib/i18n-structure";
import { RefreshCw } from "lucide-react";
type Props = {
projectId: string;
onSynced: () => void | Promise<void>;
disabled?: boolean;
};
async function ensureReadPermission(handle: FileSystemFileHandle): Promise<boolean> {
try {
// @ts-expect-error - queryPermission/requestPermission exist in supporting browsers
const q = await handle.queryPermission?.({ mode: "read" });
if (q === "granted") return true;
// @ts-expect-error - requestPermission exist in supporting browsers
const r = await handle.requestPermission?.({ mode: "read" });
return r === "granted";
} catch {
try {
// Fallback: try to read once; if it throws, we treat as no permission
const f = await handle.getFile();
// Touch the file object so TS doesn't complain about unused variable
if (!f) return false;
return true;
} catch {
return false;
}
}
}
export function SyncFromFilesButton({ projectId, onSynced, disabled }: Props) {
const [syncing, setSyncing] = useState(false);
const connSnap = useFileConnections(projectId);
const hasConnections = Object.keys(connSnap.connections).length > 0;
async function handleSync() {
if (!projectId) return;
if (!hasConnections) {
toast.info("没有已连接的语言");
return;
}
setSyncing(true);
try {
const entries = Object.entries(connSnap.connections);
const results = await Promise.allSettled(
entries.map(async ([lang, conn]) => {
const canRead = await ensureReadPermission(conn.handle);
if (!canRead) throw new Error(`${lang}: 无读取权限`);
const file = await conn.handle.getFile();
const text = await file.text();
let json: unknown;
try {
json = JSON.parse(text);
} catch {
throw new Error(`${lang}: JSON 解析失败`);
}
const values = flattenValues(json);
await upsertLanguageTranslations(projectId, lang, values);
return lang;
})
);
const failed: string[] = [];
let success = 0;
for (const r of results) {
if (r.status === "fulfilled") success += 1;
else failed.push((r.reason as Error)?.message || "未知语言");
}
if (failed.length === 0) {
toast.success(`同步完成(${success}`);
} else if (success === 0) {
toast.error(`全部失败(${failed.length}${failed.join("")}`);
} else {
toast.warning(`部分成功(成功 ${success},失败 ${failed.length}${failed.join("")}`);
}
} finally {
setSyncing(false);
await onSynced?.();
}
}
return (
<Button
variant="outline"
onClick={handleSync}
disabled={syncing || disabled || !hasConnections}
title={!hasConnections ? "暂无已连接的语言" : "读取已连接文件并导入翻译"}
>
<RefreshCw />
{syncing ? "读取中..." : "一键读取"}
</Button>
);
}

View File

@ -0,0 +1,87 @@
import { useEffect } from "react";
import type { CommonMenubarItem } from "@/components/biz/editor/common-menubar";
interface UseEditorKeyboardShortcutsOptions {
/**
*
*/
onClickItem: (item: CommonMenubarItem) => void;
/**
*
*/
disabledItems?: Partial<Record<CommonMenubarItem, boolean>>;
/**
* true
*/
enabled?: boolean;
}
/**
* Editor Hook
*
*
* - R / Ctrl+R: 读取单个文件
* - R / Ctrl+Shift+R: 自动读取并更新结构
* - S / Ctrl+S: 保存所有文件
* - L / Ctrl+L: 通过目录导入
* - I / Ctrl+I: 单文件导入
*/
export function useEditorKeyboardShortcuts({
onClickItem,
disabledItems,
enabled = true,
}: UseEditorKeyboardShortcutsOptions) {
useEffect(() => {
if (!enabled) return;
const handleKeyDown = (event: KeyboardEvent) => {
// 检测是否按下 Cmd (Mac) 或 Ctrl (Windows/Linux)
const isMod = event.metaKey || event.ctrlKey;
if (!isMod) return;
// 定义快捷键映射
const shortcuts: Record<string, { item: CommonMenubarItem; shift: boolean }> = {
r: { item: "read", shift: false },
R: { item: "read-all", shift: true }, // Shift+R
s: { item: "save", shift: false },
l: { item: "import-with-directory", shift: false },
i: { item: "import-with-files", shift: false },
};
const key = event.key;
const shortcut = shortcuts[key];
if (!shortcut) return;
// 检查是否需要 Shift 键
if (shortcut.shift && !event.shiftKey) return;
if (!shortcut.shift && event.shiftKey && key.toLowerCase() === "r") {
// 如果是 Shift+R应该触发 read-all
const readAllShortcut = shortcuts["R"];
if (readAllShortcut && !disabledItems?.[readAllShortcut.item]) {
event.preventDefault();
onClickItem(readAllShortcut.item);
}
return;
}
// 检查该项是否被禁用
if (disabledItems?.[shortcut.item]) {
return;
}
// 阻止默认行为并执行操作
event.preventDefault();
onClickItem(shortcut.item);
};
// 注册全局键盘事件监听器
window.addEventListener("keydown", handleKeyDown);
// 清理函数
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [onClickItem, disabledItems, enabled]);
}

View File

@ -92,4 +92,6 @@ export function useTranslationInlineEdit(opts: {
} as const; } as const;
} }
export type TranslationInlineEditHandlers = ReturnType<typeof useTranslationInlineEdit>;

View File

@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
import type { LanguageSourceAdapter } from "@/lib/language-source/types";
import { createLanguageSourceAdapter } from "@/lib/language-source/manager";
import { useProjectSourcesStore } from "@/store/sources-store";
export function useLanguageAdapter(projectId?: string) {
const mode = useProjectSourcesStore((state) =>
projectId && state.projectId === projectId ? state.mode : null
);
const [adapter, setAdapter] = useState<LanguageSourceAdapter | null>();
useEffect(() => {
if (!projectId || !mode) {
setAdapter(null);
return;
}
const instance = createLanguageSourceAdapter(projectId, mode);
setAdapter(instance);
console.log("adapter", instance);
return () => {
instance.dispose?.();
};
}, [projectId, mode]);
return adapter;
}

View File

@ -0,0 +1,45 @@
import { listConnections, connectLanguageToFile, writeLanguageToConnectedFile, getConnection } from "@/store/file-connection";
import type { LanguageFileMeta, LanguagePayload, LanguageSourceAdapter } from "./types";
export class BrowserFileAdapter implements LanguageSourceAdapter {
public readonly mode = "browser" as const;
public readonly supportsBulkWrite = false;
private readonly projectId: string;
constructor(projectId: string) {
this.projectId = projectId;
}
async listLanguages(): Promise<LanguageFileMeta[]> {
return listConnections(this.projectId).map((conn) => ({
language: conn.language,
displayName: conn.name,
}));
}
async readLanguage(language: string): Promise<LanguagePayload> {
const existing = getConnection(this.projectId, language);
if (!existing) {
const picked = await connectLanguageToFile(this.projectId, language);
return {
language,
displayName: picked?.connection.name,
content: picked?.text ?? null,
};
}
const file = await existing.handle.getFile();
const text = await file.text();
return {
language,
displayName: existing.name,
content: text,
};
}
async writeLanguage(language: string, content: string): Promise<void> {
const ok = await writeLanguageToConnectedFile(this.projectId, language, content);
if (!ok) {
throw new Error(`语言 ${language} 未绑定文件或写入失败`);
}
}
}

View File

@ -0,0 +1,15 @@
import { isTauriEnv } from "@/lib/is-tauri";
import { BrowserFileAdapter } from "./browser-adapter";
import { TauriDirectoryAdapter } from "./tauri-adapter";
import type { LanguageSourceAdapter } from "./types";
export function createLanguageSourceAdapter(
projectId: string,
mode: "directory" | "flat"
): LanguageSourceAdapter {
if (isTauriEnv() && mode === "directory") {
return new TauriDirectoryAdapter(projectId);
}
return new BrowserFileAdapter(projectId);
}

View File

@ -0,0 +1,61 @@
import { loadNativeProjectSources, saveNativeLanguageFile, type LoadedProjectSources } from "@/lib/native-sources";
import type { LanguageFileMeta, LanguagePayload, LanguageSourceAdapter } from "./types";
export class TauriDirectoryAdapter implements LanguageSourceAdapter {
public readonly mode = "directory" as const;
public readonly supportsBulkWrite = true;
private readonly projectId: string;
private latest: LoadedProjectSources | null = null;
constructor(projectId: string) {
this.projectId = projectId;
}
async listLanguages(): Promise<LanguageFileMeta[]> {
const loaded = await this.ensureLoaded();
return loaded.languages.map((lang) => ({
language: lang.language,
displayName: lang.path?.split("/").pop() ?? lang.path,
path: lang.path,
exists: lang.exists,
modifiedMs: lang.modified_ms ?? undefined,
}));
}
async readLanguage(language: string): Promise<LanguagePayload> {
const loaded = await this.ensureLoaded();
const match = loaded.languages.find((lang) => lang.language === language);
if (!match) throw new Error(`未找到语言 ${language}`);
if (!match.content) throw new Error(`语言 ${language} 没有内容`);
return {
language: match.language,
displayName: match.path?.split("/").pop() ?? match.path,
path: match.path,
exists: match.exists,
content: match.content,
};
}
async writeLanguage(language: string, content: string): Promise<void> {
await saveNativeLanguageFile(this.projectId, language, content);
}
async readAll(): Promise<LanguagePayload[]> {
const loaded = await this.ensureLoaded(true);
return loaded.languages.map((lang) => ({
language: lang.language,
displayName: lang.path?.split("/").pop() ?? lang.path,
path: lang.path,
exists: lang.exists,
content: lang.content ?? null,
modifiedMs: lang.modified_ms ?? undefined,
}));
}
private async ensureLoaded(force = false): Promise<LoadedProjectSources> {
if (!this.latest || force) {
this.latest = await loadNativeProjectSources(this.projectId);
}
return this.latest;
}
}

View File

@ -0,0 +1,22 @@
export type LanguageFileMeta = {
language: string;
displayName?: string | null;
path?: string | null;
exists?: boolean;
modifiedMs?: number | null;
};
export type LanguagePayload = LanguageFileMeta & {
content?: string | null;
};
export interface LanguageSourceAdapter {
readonly mode: "browser" | "directory";
readonly supportsBulkWrite: boolean;
listLanguages(): Promise<LanguageFileMeta[]>;
readLanguage(language: string): Promise<LanguagePayload>;
writeLanguage(language: string, content: string): Promise<void>;
readAll?(): Promise<LanguagePayload[]>;
writeAll?(payloads: { language: string; content: string }[]): Promise<void>;
dispose?(): void;
}

View File

@ -1,8 +1,5 @@
/* eslint-disable react-hooks/exhaustive-deps */ import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type React from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { import {
getProject, getProject,
getStructure, getStructure,
@ -17,7 +14,7 @@ import {
updateProject, updateProject,
deleteProjectDeep, deleteProjectDeep,
} from "@/lib/db"; } from "@/lib/db";
import { ArrowBigDownDash, ArrowBigUpDash, Brackets, CaseSensitive, Filter, FolderOpen, Languages, LocateFixed, MoreVertical, PencilLine, Save, Settings, Trash2 } from "lucide-react"; import { FolderOpen, Settings } from "lucide-react";
import { useTranslationInlineEdit } from "@/hooks/biz/use-translation-inline-edit"; import { useTranslationInlineEdit } from "@/hooks/biz/use-translation-inline-edit";
import { buildStructureFromObject, flattenEntries, flattenValues, type FlatEntry, insertEntrySibling, removeEntryAtPath, renameEntryAtPath, moveEntryByOffset } from "@/lib/i18n-structure"; import { buildStructureFromObject, flattenEntries, flattenValues, type FlatEntry, insertEntrySibling, removeEntryAtPath, renameEntryAtPath, moveEntryByOffset } from "@/lib/i18n-structure";
import { ImportLanguageModal } from "@/components/biz/import-language-modal"; import { ImportLanguageModal } from "@/components/biz/import-language-modal";
@ -25,30 +22,20 @@ import { ExportLanguageModal } from "@/components/biz/export-language-modal";
import { EntryNameModal } from "@/components/biz/entry-name-modal"; import { EntryNameModal } from "@/components/biz/entry-name-modal";
import { ProjectSettingsModal } from "@/components/biz/project-settings-modal"; import { ProjectSettingsModal } from "@/components/biz/project-settings-modal";
import { AiTranslateModal } from "@/components/biz/ai-translate-modal"; import { AiTranslateModal } from "@/components/biz/ai-translate-modal";
import { TableVirtuoso, type TableVirtuosoHandle } from "react-virtuoso";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { useClipboard } from "@/hooks/use-clipboard";
import { toast } from "sonner"; import { toast } from "sonner";
import { Checkbox } from "@/components/ui/checkbox"; import { useFileConnections } from "@/store/file-connection";
import { Textarea } from "@/components/ui/textarea";
import { useFileConnections, writeLanguageToConnectedFile } from "@/store/file-connection";
import { generateLanguageJson } from "@/lib/utils"; import { generateLanguageJson } from "@/lib/utils";
import { HeaderConnectionIndicator } from "@/components/biz/header-connection-indicator"; import { HeaderConnectionIndicator } from "@/components/biz/header-connection-indicator";
import { SyncFromFilesButton } from "@/components/biz/sync-from-files-button";
import { useProjectTabsStore } from "@/store/project-tabs-store"; import { useProjectTabsStore } from "@/store/project-tabs-store";
import { ProjectTabsBar } from "@/components/biz/project-tabs-bar"; import { ProjectTabsBar } from "@/components/biz/project-tabs-bar";
import { ProjectSourcesWizard } from "@/components/biz/project-sources-wizard"; import { ProjectSourcesWizard } from "@/components/biz/project-sources-wizard";
import { isTauriEnv } from "@/lib/is-tauri"; import { loadNativeProjectSources, type LoadedProjectSources, toProjectSourcesState } from "@/lib/native-sources";
import { loadNativeProjectSources, saveNativeLanguageFile, type LoadedProjectSources, toProjectSourcesState } from "@/lib/native-sources";
import { useProjectSourcesStore } from "@/store/sources-store"; import { useProjectSourcesStore } from "@/store/sources-store";
import CommonMenubar, { type CommonMenubarItem } from "@/components/biz/editor/common-menubar"; import CommonMenubar, { type CommonMenubarItem } from "@/components/biz/editor/common-menubar";
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";
type EditorProps = { type EditorProps = {
projectId?: string; projectId?: string;
@ -65,69 +52,39 @@ export default function Editor({ projectId }: EditorProps) {
const [sourcesWizardOpen, setSourcesWizardOpen] = useState(false); const [sourcesWizardOpen, setSourcesWizardOpen] = useState(false);
const [addModal, setAddModal] = useState<{ open: boolean; path: string; position: "above" | "below" } | null>(null); const [addModal, setAddModal] = useState<{ open: boolean; path: string; position: "above" | "below" } | null>(null);
const [renameModal, setRenameModal] = useState<{ open: boolean; path: string } | null>(null); const [renameModal, setRenameModal] = useState<{ open: boolean; path: string } | null>(null);
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
const scrollerRootRef = useRef<HTMLElement | Window | null>(null);
const [query, setQuery] = useState("");
const [caseSensitive, setCaseSensitive] = useState(false);
const [fullMatch, setFullMatch] = useState(false);
const [aiModal, setAiModal] = useState<{ open: boolean; path: string } | null>(null); const [aiModal, setAiModal] = useState<{ open: boolean; path: string } | null>(null);
const [aiBulkOpen, setAiBulkOpen] = useState(false); const [aiBulkOpen, setAiBulkOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const [visibleLangs, setVisibleLangs] = useState<Set<string>>(new Set());
const [moveCountUp, setMoveCountUp] = useState(1);
const [moveCountDown, setMoveCountDown] = useState(1);
const [savingAll, setSavingAll] = useState(false); const [savingAll, setSavingAll] = useState(false);
const { copy } = useClipboard(); const [valuesByLang, setValuesByLang] = useState<Record<string, Record<string, string>>>({});
const inlineEdit = useTranslationInlineEdit({
projectId,
valuesByLang,
setValuesByLang,
onError: (message) => setPageError(message),
});
const connSnap = useFileConnections(projectId ?? ""); const connSnap = useFileConnections(projectId ?? "");
const setSourcesMeta = useProjectSourcesStore((state) => state.setSources); const setSourcesMeta = useProjectSourcesStore((state) => state.setSources);
const clearSourcesMeta = useProjectSourcesStore((state) => state.clear); const clearSourcesMeta = useProjectSourcesStore((state) => state.clear);
const getLanguagePath = useProjectSourcesStore((state) => state.getLanguagePath); const sourcesState = useProjectSourcesStore((state) => state);
// const nativeLanguageTargets = [];
const sourcesLanguages = useProjectSourcesStore((state) => state.languages);
const nativeLanguageTargets = useMemo(() => {
return sourcesLanguages.filter((meta) => !!meta.path).map((meta) => meta.language);
}, [sourcesLanguages]);
const hasNativeSources = useProjectSourcesStore(
(state) => state.projectId === (projectId ?? "") && state.mode === "directory"
);
const openProjectTab = useProjectTabsStore((state) => state.openProjectTab); const openProjectTab = useProjectTabsStore((state) => state.openProjectTab);
const upsertProjectMeta = useProjectTabsStore((state) => state.upsertProjectMeta); const upsertProjectMeta = useProjectTabsStore((state) => state.upsertProjectMeta);
const forgetProjectInTabs = useProjectTabsStore((state) => state.forgetProject); const forgetProjectInTabs = useProjectTabsStore((state) => state.forgetProject);
const activateHomeTab = useProjectTabsStore((state) => state.activateHome); const activateHomeTab = useProjectTabsStore((state) => state.activateHome);
const adapter = useLanguageAdapter(projectId);
function highlightRow(index: number) { const applyLoadedSources = useCallback(async (loaded: LoadedProjectSources, opts?: { silent?: boolean }) => {
const tryFindAndAnimate = (attempt = 0) => {
const root = scrollerRootRef.current as HTMLElement | null;
if (!root) return;
const row = root.querySelector(`tr[data-item-index="${index}"]`) as HTMLTableRowElement | null;
if (row) {
row.animate(
[
{ backgroundColor: "rgba(250, 204, 21, 0.6)" },
{ backgroundColor: "transparent" },
{ backgroundColor: "rgba(250, 204, 21, 0.6)" },
{ backgroundColor: "transparent" },
],
{ duration: 1200, easing: "ease-in-out" }
);
return;
}
if (attempt < 10) {
setTimeout(() => tryFindAndAnimate(attempt + 1), 50);
}
};
// 等一帧,确保滚动定位后的 DOM 稳定
requestAnimationFrame(() => tryFindAndAnimate(0));
}
const applyLoadedSources =
async (loaded: LoadedProjectSources, opts?: { silent?: boolean }) => {
if (!projectId) return false; if (!projectId) return false;
setSourcesMeta(toProjectSourcesState(loaded)); setSourcesMeta(toProjectSourcesState(loaded));
console.log("loaded", loaded);
const nextValues: Record<string, Record<string, string>> = {}; const nextValues: Record<string, Record<string, string>> = {};
for (const file of loaded.languages) { for (const file of loaded.languages) {
if (!file.content) continue; if (!file.content) continue;
@ -141,15 +98,22 @@ export default function Editor({ projectId }: EditorProps) {
const flattened = flattenValues(parsed); const flattened = flattenValues(parsed);
nextValues[file.language] = flattened; nextValues[file.language] = flattened;
await upsertLanguageTranslations(projectId, file.language, flattened); await upsertLanguageTranslations(projectId, file.language, flattened);
if (!structure && loaded.default_language && file.language === loaded.default_language) { if (loaded.default_language && file.language === loaded.default_language) {
// 使用函数式更新来检查当前 structure 状态,避免依赖它
setStructure((currentStructure) => {
if (!currentStructure) {
try { try {
const root = buildStructureFromObject(parsed); const root = buildStructureFromObject(parsed);
await upsertStructure({ projectId, root }); void upsertStructure({ projectId, root });
setStructure({ projectId, root }); return { 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) {
@ -163,20 +127,123 @@ export default function Editor({ projectId }: EditorProps) {
}); });
} }
return true; return true;
}; }, [projectId, setSourcesMeta]);
const syncNativeSources = useCallback( const syncFromAdapter = useCallback(
async (opts?: { silent?: boolean }) => { async (opts?: { silent?: boolean }) => {
if (!projectId || !isTauriEnv()) return false; if (!projectId) return false;
try { if (!adapter) {
const loaded = await loadNativeProjectSources(projectId); if (!opts?.silent) toast.info("请先配置语言文件来源");
return await applyLoadedSources(loaded, opts);
} catch (err) {
console.error("同步本地语言文件失败", err);
return false; return false;
} }
if (adapter.mode === "directory" && typeof adapter.readAll === "function") {
const payloads = await adapter.readAll();
const loaded: LoadedProjectSources = {
project_id: projectId,
mode: "directory",
base_dir: sourcesState.baseDir ?? undefined,
default_language: sourcesState.defaultLanguage ?? undefined,
relative_path: sourcesState.relativePath ?? undefined,
languages: payloads.map((p) => ({
language: p.language,
path: p.path ?? "",
exists: p.exists ?? true,
content: p.content ?? null,
modified_ms: p.modifiedMs ?? undefined,
})),
};
return applyLoadedSources(loaded, opts);
}
const connectionLanguages = Object.keys(connSnap.connections);
if (connectionLanguages.length === 0) {
if (!opts?.silent) toast.info("请先绑定语言文件");
return false;
}
const results: LoadedProjectSources = {
project_id: projectId,
mode: "flat",
languages: [],
};
for (const lang of connectionLanguages) {
const payload = await adapter.readLanguage(lang);
results.languages.push({
language: payload.language,
content: payload.content ?? null,
path: payload.path ?? "",
exists: true,
});
}
return applyLoadedSources(results, opts);
}, },
[applyLoadedSources, projectId] [adapter, applyLoadedSources, connSnap.connections, projectId, sourcesState.baseDir, sourcesState.defaultLanguage, sourcesState.relativePath]
);
const bootstrapProject = useCallback(async () => {
if (!projectId) return;
const [nextProject, nextStructure, nextLanguages] = await Promise.all([getProject(projectId), getStructure(projectId), listLanguages(projectId)]);
if (!nextProject) throw new Error("项目不存在");
setProject(nextProject);
upsertProjectMeta(nextProject);
setStructure(nextStructure ?? null);
setLanguages(nextLanguages);
}, [projectId, upsertProjectMeta]);
const bootstrapSources = useCallback(async (): Promise<LoadedProjectSources | null> => {
if (!projectId) return null;
if (!isTauriEnv()) {
setSourcesMeta({
projectId,
mode: "flat",
languages: [],
syncedAt: Date.now(),
});
return null;
}
try {
const loaded = await loadNativeProjectSources(projectId);
return loaded;
} catch (err) {
console.error("加载目录配置失败", err);
setSourcesMeta({
projectId,
mode: "flat",
languages: [],
syncedAt: Date.now(),
});
toast.error((err as Error)?.message ?? "读取目录配置失败");
return null;
}
}, [projectId, setSourcesMeta]);
const syncExternalSources = useCallback(
async (opts?: { silent?: boolean; reloadConfig?: boolean }) => {
let preloaded: LoadedProjectSources | null = null;
if (opts?.reloadConfig) {
preloaded = await bootstrapSources();
}
if (preloaded) {
return applyLoadedSources(preloaded, opts);
}
return syncFromAdapter(opts);
},
[applyLoadedSources, bootstrapSources, syncFromAdapter]
);
const refresh = useCallback(
async (opts?: { silent?: boolean }) => {
if (!projectId) return;
setLoading(true);
setPageError(null);
try {
await bootstrapProject();
await syncExternalSources({ silent: opts?.silent ?? true, reloadConfig: true });
} catch (e) {
setPageError((e as Error)?.message ?? "加载失败");
} finally {
setLoading(false);
}
},
[bootstrapProject, projectId, syncExternalSources]
); );
const handleWizardSourcesLoaded = useCallback( const handleWizardSourcesLoaded = useCallback(
@ -186,57 +253,7 @@ export default function Editor({ projectId }: EditorProps) {
[applyLoadedSources] [applyLoadedSources]
); );
const handleMove = useCallback(async (path: string, offset: number) => {
if (!projectId || !structure) return;
try {
const nextRoot = moveEntryByOffset(structure.root, path, offset);
await upsertStructure({ projectId, root: nextRoot });
setStructure({ projectId, root: nextRoot });
const nextEntries = flattenEntries(nextRoot);
const idx = nextEntries.findIndex((e) => e.path === path);
if (idx >= 0) {
virtuosoRef.current?.scrollIntoView({ index: idx, align: "center", done: () => highlightRow(idx) });
}
} catch (e) {
setPageError((e as Error)?.message ?? "移动失败");
}
}, [projectId, structure]);
function scrollToQuery(ev: React.FormEvent<HTMLFormElement>) {
ev.preventDefault();
ev.stopPropagation();
if (!query) return;
const idx = entries.findIndex((e) => {
const hay = caseSensitive ? e.path : e.path.toLowerCase();
const needle = caseSensitive ? query : query.toLowerCase();
return fullMatch ? hay === needle : hay.includes(needle);
});
if (idx >= 0) {
virtuosoRef.current?.scrollIntoView({ index: idx, align: "center", done: () => highlightRow(idx) });
}
}
async function refresh() {
if (!projectId) return;
setLoading(true);
setPageError(null);
try {
const p = await getProject(projectId);
if (!p) throw new Error("项目不存在");
setProject(p);
upsertProjectMeta(p);
const s = await getStructure(projectId);
if (s) setStructure(s);
const langs = await listLanguages(projectId);
setLanguages(langs);
await syncNativeSources();
} catch (e) {
setPageError((e as Error)?.message ?? "加载失败");
} finally {
setLoading(false);
}
}
useEffect(() => { useEffect(() => {
if (!projectId) return; if (!projectId) return;
@ -247,79 +264,74 @@ export default function Editor({ projectId }: EditorProps) {
clearSourcesMeta(); clearSourcesMeta();
}, [projectId, clearSourcesMeta]); }, [projectId, clearSourcesMeta]);
// 只在 projectId 变化时触发,避免因 refresh 引用变化导致的循环
useEffect(() => { useEffect(() => {
void refresh(); if (!projectId) return;
setLoading(true);
setPageError(null);
const initialize = async () => {
try {
await bootstrapProject();
await syncExternalSources({ silent: true, reloadConfig: true });
} catch (e) {
setPageError((e as Error)?.message ?? "加载失败");
} finally {
setLoading(false);
}
};
void initialize();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]); }, [projectId]); // 只依赖 projectId
const entries: FlatEntry[] = useMemo(() => { const entries: FlatEntry[] = useMemo(() => {
if (!structure) return []; if (!structure) return [];
return flattenEntries(structure.root); return flattenEntries(structure.root);
}, [structure]); }, [structure]);
const [valuesByLang, setValuesByLang] = useState<Record<string, Record<string, string>>>({});
const inline = useTranslationInlineEdit({
projectId,
valuesByLang,
setValuesByLang,
onError: (m) => setPageError(m),
});
// 同步可见语言集合
useEffect(() => {
setVisibleLangs(new Set(languages));
}, [languages]);
const displayedLanguages = useMemo(() => languages.filter((l) => visibleLangs.has(l)), [languages, visibleLangs]);
const tableWidth = useMemo(() => (displayedLanguages.length * 200) + 36 + 60 + 300, [displayedLanguages.length]);
const handleSaveAllConnected = useCallback(async () => { const handleSaveAllConnected = useCallback(async () => {
if (!projectId || !structure) return; if (!projectId || !structure) return;
const targetLanguages = new Set<string>(Object.keys(connSnap.connections)); if (!adapter) {
if (hasNativeSources) { toast.error("当前项目未配置可写入的语言文件");
nativeLanguageTargets.forEach((lang) => targetLanguages.add(lang));
}
if (targetLanguages.size === 0) {
toast.info("没有可保存的语言");
return; return;
} }
let targetLanguages: string[] = [];
if (adapter.mode === "directory") {
const metas = await adapter.listLanguages();
targetLanguages = metas.map((meta) => meta.language);
} else {
targetLanguages = Object.keys(connSnap.connections);
}
if (targetLanguages.length === 0) {
toast.info("请先绑定或关联语言文件");
return;
}
setSavingAll(true); setSavingAll(true);
try { try {
const orderedPaths = flattenEntries(structure.root).map((e) => e.path); const orderedPaths = flattenEntries(structure.root).map((e) => e.path);
const results = await Promise.allSettled( const payloads = targetLanguages.map((lang) => ({
Array.from(targetLanguages).map(async (lang) => { language: lang,
const jsonText = generateLanguageJson(valuesByLang, lang, orderedPaths); content: generateLanguageJson(valuesByLang, lang, orderedPaths),
const hasNativePath = hasNativeSources && isTauriEnv() && !!getLanguagePath(lang); }));
if (hasNativePath) {
await saveNativeLanguageFile(projectId, lang, jsonText); if (adapter.supportsBulkWrite && adapter.writeAll) {
return lang; await adapter.writeAll(payloads);
}
if (connSnap.connections[lang]) {
const ok = await writeLanguageToConnectedFile(projectId, lang, jsonText);
if (!ok) throw new Error(lang);
return lang;
}
throw new Error(`${lang} 未绑定到任何文件`);
})
);
const failed: string[] = [];
let success = 0;
for (const r of results) {
if (r.status === "fulfilled") success += 1;
else failed.push((r.reason as Error)?.message || "未知语言");
}
if (failed.length === 0) {
toast.success(`全部保存成功(${success}`);
} else if (success === 0) {
toast.error(`保存失败(${failed.length}${failed.join(", ")}`);
} else { } else {
toast.warning(`部分成功(成功 ${success},失败 ${failed.length}${failed.join(", ")}`); for (const payload of payloads) {
await adapter.writeLanguage(payload.language, payload.content);
} }
}
toast.success(`已写入 ${targetLanguages.length} 个语言文件`);
} catch (err) {
toast.error((err as Error)?.message ?? "保存失败");
} finally { } finally {
setSavingAll(false); setSavingAll(false);
} }
}, [projectId, structure, connSnap.connections, valuesByLang, hasNativeSources, nativeLanguageTargets, getLanguagePath]); }, [adapter, connSnap.connections, projectId, structure, valuesByLang]);
function computeSuggestedLanguages(pathsInput: string[]): string[] { function computeSuggestedLanguages(pathsInput: string[]): string[] {
if (languages.length === 0 || pathsInput.length === 0) return []; if (languages.length === 0 || pathsInput.length === 0) return [];
@ -358,199 +370,84 @@ export default function Editor({ projectId }: EditorProps) {
return result; return result;
}, [languages, valuesByLang]); }, [languages, valuesByLang]);
const virtuosoComponents = useMemo(() => ({ const handleOpenAddEntry = useCallback((path: string, position: "above" | "below") => {
Table: ({ style, ...props }: React.TableHTMLAttributes<HTMLTableElement>) => <table className="min-w-full text-sm table-fixed" {...props} style={{ ...style, width: tableWidth }} />, setAddModal({ open: true, path, position });
TableHead: (props: React.HTMLAttributes<HTMLTableSectionElement>) => <thead className="bg-muted" {...props} />, }, []);
TableRow: (props: React.HTMLAttributes<HTMLTableRowElement>) => <tr className="border-t" {...props} />,
TableBody: (props: React.HTMLAttributes<HTMLTableSectionElement>) => <tbody {...props} />,
TableFoot: (props: React.HTMLAttributes<HTMLTableSectionElement>) => <tfoot {...props} />,
}), [tableWidth]);
const allSelected = useMemo(() => entries.length > 0 && selected.size === entries.length, [entries, selected]); const handleOpenAiTranslate = useCallback((path: string) => {
const MAX_AI_ITEMS = 50; setAiModal({ open: true, path });
}, []);
const headerContent = useCallback(() => ( const handleOpenRenameEntry = useCallback((path: string) => {
<tr> setRenameModal({ open: true, path });
<th style={{ width: 36 }} className="sticky left-0 text-left px-3 py-2 bg-muted"> }, []);
<Checkbox
checked={allSelected}
onCheckedChange={(checked) => {
const next = new Set<string>();
if (checked) {
for (const en of entries) next.add(en.path);
}
setSelected(next);
}}
/>
</th>
<th style={{ width: 300 }} className="text-left px-3 py-2"></th>
{displayedLanguages.map((lang) => (
<th key={lang} style={{ width: 200 }} className="text-left px-3 py-2">{lang}</th>
))}
<th style={{ width: 60 }} className="sticky right-0 text-left px-3 py-2 bg-muted"></th>
</tr>
), [displayedLanguages, allSelected, entries]);
const renderItemContent = useCallback((_idx: number, entry: FlatEntry) => { const handleMoveEntry = useCallback(async (path: string, offset: number) => {
const handleCopy = () => { if (!projectId || !structure || offset === 0) return;
copy(entry.path);
toast.success("复制成功");
};
return (
<>
<td className="sticky left-0 px-3 py-2 bg-white">
<Checkbox
checked={selected.has(entry.path)}
onCheckedChange={(checked) => {
setSelected((prev) => {
const next = new Set(prev);
if (checked) next.add(entry.path); else next.delete(entry.path);
return next;
});
}}
/>
</td>
<td className="px-3 py-2 font-mono wrap-break-word">
<button type="button" className="w-full text-left min-h-8 leading-normal px-2 rounded hover:bg-accent" onClick={handleCopy} title="点击复制">
{entry.path}
</button>
</td>
{displayedLanguages.map((lang) => {
const isEditing = inline.isEditingCell(entry.path, lang);
const isSaving = inline.isSavingCell(entry.path, lang);
const displayValue = inline.getDisplayValue(entry.path, lang);
return (
<td key={`${entry.path}:${lang}`} className="px-3 py-2 text-foreground/90 align-top">
{isEditing ? (
<Textarea
ref={inline.inputRef}
value={inline.editingValue}
onChange={(e) => inline.setEditingValue(e.target.value)}
onBlur={inline.saveEdit}
onKeyDown={inline.handleKeyDown}
disabled={isSaving}
className="leading-normal px-2 py-0"
/>
) : (
<button
type="button"
className="w-full text-left min-h-8 leading-normal px-2 rounded hover:bg-accent"
onClick={() => inline.startEdit(entry.path, lang)}
title="点击编辑"
>
{displayValue || <span className="text-destructive"></span>}
</button>
)}
</td>
);
})}
<td className="sticky right-0 px-3 py-2 align-top bg-white">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline">
<MoreVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuItem onClick={() => setAddModal({ open: true, path: entry.path, position: "below" })}>
<ArrowBigDownDash />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setAddModal({ open: true, path: entry.path, position: "above" })}>
<ArrowBigUpDash />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setAiModal({ open: true, path: entry.path })}>
<Languages />
AI
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setRenameModal({ open: true, path: entry.path })}>
<PencilLine />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
handleMove(entry.path, -moveCountUp)
}}
>
<ArrowBigUpDash />
<span className="whitespace-nowrap"></span>
<Input
type="number"
min={1}
value={moveCountUp}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setMoveCountUp(Number.isFinite(n) && n > 0 ? n : 1);
}}
className="h-7 w-16"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
/>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => handleMove(entry.path, moveCountDown)}
>
<ArrowBigDownDash />
<span className="whitespace-nowrap"></span>
<Input
type="number"
min={1}
value={moveCountDown}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
setMoveCountDown(Number.isFinite(n) && n > 0 ? n : 1);
}}
className="h-7 w-16"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
/>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={async () => {
if (!projectId || !structure) return;
if (!confirm("确认删除该条目?此操作会移除所有语言下的该键")) return;
try { try {
const nextRoot = removeEntryAtPath(structure.root, entry.path); const nextRoot = moveEntryByOffset(structure.root, path, offset);
await upsertStructure({ projectId, root: nextRoot }); await upsertStructure({ projectId, root: nextRoot });
await deleteEntryFromAllLanguages(projectId, entry.path); setStructure({ projectId, root: nextRoot });
} catch (e) {
setPageError((e as Error)?.message ?? "移动失败");
}
}, [projectId, structure]);
const handleDeleteEntry = useCallback(async (path: string) => {
if (!projectId || !structure) return;
try {
const nextRoot = removeEntryAtPath(structure.root, path);
await upsertStructure({ projectId, root: nextRoot });
await deleteEntryFromAllLanguages(projectId, path);
setStructure({ projectId, root: nextRoot }); setStructure({ projectId, root: nextRoot });
setValuesByLang((old) => { setValuesByLang((old) => {
const copy: typeof old = {}; const copy: typeof old = {};
for (const [langKey, vals] of Object.entries(old)) { for (const [langKey, vals] of Object.entries(old)) {
const rest = { ...vals } as Record<string, string>; const next = { ...vals } as Record<string, string>;
delete rest[entry.path]; delete next[path];
copy[langKey] = rest; copy[langKey] = next;
} }
return copy; return copy;
}); });
setSelected((prev) => {
if (!prev.has(path)) return prev;
const next = new Set(prev);
next.delete(path);
return next;
});
} catch (e) { } catch (e) {
setPageError((e as Error)?.message ?? "删除失败"); setPageError((e as Error)?.message ?? "删除失败");
} }
}} }, [projectId, setSelected, setValuesByLang, structure]);
>
<Trash2 /> const handleDeleteSelectedEntries = useCallback(async (paths: string[]) => {
if (!projectId || !structure || paths.length === 0) return;
</DropdownMenuItem> try {
</DropdownMenuContent> let nextRoot = structure.root;
</DropdownMenu> for (const p of paths) {
</td> nextRoot = removeEntryAtPath(nextRoot, p);
</> }
); await upsertStructure({ projectId, root: nextRoot });
}, [displayedLanguages, inline, projectId, structure, setStructure, setValuesByLang, copy, selected, moveCountUp, moveCountDown, handleMove]); 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<string, string>;
for (const p of paths) delete next[p];
copy[langKey] = next;
}
return copy;
});
setSelected(new Set());
} catch (e) {
setPageError((e as Error)?.message ?? "批量删除失败");
}
}, [projectId, setSelected, setValuesByLang, structure]);
const handleOpenBulkAiTranslate = useCallback(() => {
setAiBulkOpen(true);
}, []);
useEffect(() => { useEffect(() => {
if (!projectId || languages.length === 0) return; if (!projectId || languages.length === 0) return;
@ -570,21 +467,25 @@ export default function Editor({ projectId }: EditorProps) {
const disabledItems = useMemo(() => { const disabledItems = useMemo(() => {
return { return {
read: !structure, read: !adapter,
save: !structure, "read-all": !adapter,
"import-with-directory": !structure, save: savingAll || !structure || !adapter || (adapter.mode === "browser" && Object.keys(connSnap.connections).length === 0),
"import-with-files": !structure, "import-with-directory": false,
"import-with-files": false,
export: languages.length === 0, export: languages.length === 0,
}; };
}, [structure, languages.length]); }, [adapter, structure, languages.length]);
const onClickItem = useCallback((item: CommonMenubarItem) => { const onClickItem = useCallback((item: CommonMenubarItem) => {
switch (item) { switch (item) {
case "read": case "read":
// setSourcesWizardOpen(true); void syncExternalSources({ silent: false });
break;
case "read-all":
void syncExternalSources({ silent: false, reloadConfig: true });
break; break;
case "save": case "save":
handleSaveAllConnected(); void handleSaveAllConnected();
break; break;
case "import-with-directory": case "import-with-directory":
setSourcesWizardOpen(true); setSourcesWizardOpen(true);
@ -596,7 +497,14 @@ export default function Editor({ projectId }: EditorProps) {
setExportOpen(true); setExportOpen(true);
break; break;
} }
}, [setSourcesWizardOpen, handleSaveAllConnected]); }, [handleSaveAllConnected, setSourcesWizardOpen, setImportOpen, setExportOpen, syncExternalSources]);
// 注册键盘快捷方式
useEditorKeyboardShortcuts({
onClickItem,
disabledItems,
enabled: !loading && !!projectId,
});
return ( return (
<div className="flex h-screen flex-col bg-background"> <div className="flex h-screen flex-col bg-background">
@ -606,23 +514,6 @@ export default function Editor({ projectId }: EditorProps) {
<CommonMenubar disabledItems={disabledItems} onClickItem={onClickItem} /> <CommonMenubar disabledItems={disabledItems} onClickItem={onClickItem} />
<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 ?? ""} />
{projectId && (
<SyncFromFilesButton
projectId={projectId ?? ""}
onSynced={refresh}
/>
)}
{projectId && (
<Button
variant="outline"
onClick={handleSaveAllConnected}
disabled={savingAll || !structure || Object.keys(connSnap.connections).length === 0}
title={Object.keys(connSnap.connections).length === 0 ? "暂无已连接的语言" : "将所有已连接语言写入各自文件"}
>
<Save />
{savingAll ? "保存中..." : "一键保存"}
</Button>
)}
{project && ( {project && (
<Button variant="ghost" onClick={() => setSettingsOpen(true)}> <Button variant="ghost" onClick={() => setSettingsOpen(true)}>
<Settings /> <Settings />
@ -652,126 +543,20 @@ export default function Editor({ projectId }: EditorProps) {
</div> </div>
</div> </div>
) : ( ) : (
<div className="h-full flex flex-col"> <EditorTable
<form className="mb-3 flex items-center gap-2" onSubmit={scrollToQuery}> entries={entries}
<Input placeholder="搜索翻译条目名称" value={query} onChange={(e) => setQuery(e.target.value)} /> languages={languages}
<Button selected={selected}
type="button" setSelected={setSelected}
variant={fullMatch ? "default" : "outline"} inlineEdit={inlineEdit}
onClick={() => setFullMatch((v) => !v)} onOpenAddEntry={handleOpenAddEntry}
title="切换全量匹配/模糊匹配" onOpenAiTranslate={handleOpenAiTranslate}
> onOpenBulkAiTranslate={handleOpenBulkAiTranslate}
<Brackets /> onOpenRenameEntry={handleOpenRenameEntry}
{fullMatch ? "全量匹配" : "模糊匹配"} onMoveEntry={handleMoveEntry}
</Button> onDeleteEntry={handleDeleteEntry}
<Button onDeleteSelected={handleDeleteSelectedEntries}
type="button"
variant={caseSensitive ? "default" : "outline"}
onClick={() => setCaseSensitive((v) => !v)}
title="切换大小写敏感"
>
<CaseSensitive />
{caseSensitive ? "区分大小写" : "忽略大小写"}
</Button>
<Button type="submit">
<LocateFixed />
</Button>
</form>
<div className="mb-2 flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" title="过滤显示的语言列">
<Filter />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuItem onClick={() => setVisibleLangs(new Set(languages))}>
</DropdownMenuItem>
<DropdownMenuSeparator />
{languages.map((lang) => {
const checked = visibleLangs.has(lang);
return (
<DropdownMenuCheckboxItem
key={lang}
onSelect={(e) => e.preventDefault()}
checked={checked}
onCheckedChange={(v) => {
setVisibleLangs((prev) => {
const next = new Set(prev);
if (v) next.add(lang); else next.delete(lang);
return next;
});
}}
>
{lang}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
disabled={selected.size === 0 || selected.size > MAX_AI_ITEMS}
onClick={() => setAiBulkOpen(true)}
title={selected.size > MAX_AI_ITEMS ? `最多支持 ${MAX_AI_ITEMS}` : "对所选条目进行 AI 翻译"}
>
<Languages />
AI {selected.size}
</Button>
{selected.size > MAX_AI_ITEMS && (
<span className="text-xs text-red-600"> {MAX_AI_ITEMS} </span>
)}
<Button
variant="outline-destructive"
disabled={selected.size === 0}
onClick={async () => {
if (!projectId || !structure) return;
if (selected.size === 0) return;
if (!confirm(`确认删除所选 ${selected.size} 个条目?此操作会移除所有语言下的这些键`)) return;
try {
let nextRoot = structure.root;
for (const p of selected) {
nextRoot = removeEntryAtPath(nextRoot, p);
}
await upsertStructure({ projectId, root: nextRoot });
// 从所有语言删除这些键
await Promise.all(Array.from(selected).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<string, string>;
for (const p of selected) delete next[p];
copy[langKey] = next;
}
return copy;
});
setSelected(new Set());
} catch (e) {
setPageError((e as Error)?.message ?? "批量删除失败");
}
}}
title="删除所选条目"
>
<Trash2 />
</Button>
</div>
<div className="flex-1 border rounded-md">
<TableVirtuoso
ref={virtuosoRef}
data={entries}
fixedHeaderContent={headerContent}
itemContent={renderItemContent}
components={virtuosoComponents}
computeItemKey={(_index, entry) => entry.path}
scrollerRef={(el) => { scrollerRootRef.current = el; }}
/> />
</div>
</div>
)} )}
</main> </main>
</div> </div>

View File

@ -20,7 +20,7 @@ export type ProjectSourcesState = {
type ProjectSourcesActions = { type ProjectSourcesActions = {
setSources: (state: ProjectSourcesState) => void; setSources: (state: ProjectSourcesState) => void;
clear: () => void; clear: () => void;
getLanguagePath: (language: string) => string | undefined; getLanguageMeta: (language: string) => LanguageFileMeta | undefined;
}; };
const initialState: ProjectSourcesState = { const initialState: ProjectSourcesState = {
@ -38,8 +38,7 @@ export const useProjectSourcesStore = create<ProjectSourcesState & ProjectSource
...state, ...state,
})), })),
clear: () => set(() => initialState), clear: () => set(() => initialState),
getLanguagePath: (language) => { getLanguageMeta: (language) => {
const meta = get().languages.find((lang) => lang.language === language); return get().languages.find((lang) => lang.language === language);
return meta?.path;
}, },
})); }));