Fix: 重构部分逻辑,编辑器表格部分逻辑拆分,数据源改为适配器模式,增加键盘快捷键操作
This commit is contained in:
parent
ff35f5b105
commit
b51da785af
|
|
@ -3,6 +3,7 @@ import {
|
|||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarMenu,
|
||||
MenubarSeparator,
|
||||
MenubarShortcut,
|
||||
MenubarTrigger,
|
||||
} from "@/components/ui/menubar";
|
||||
|
|
@ -10,6 +11,7 @@ import { ChevronDown } from "lucide-react";
|
|||
|
||||
export type CommonMenubarItem =
|
||||
| "read"
|
||||
| "read-all"
|
||||
| "save"
|
||||
| "import-with-directory"
|
||||
| "import-with-files"
|
||||
|
|
@ -29,10 +31,14 @@ function CommonMenubar({ disabledItems, onClickItem }: CommonMenubarProps) {
|
|||
</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onClick={() => onClickItem("read")} disabled={disabledItems?.read}>
|
||||
读取 <MenubarShortcut>⌘R</MenubarShortcut>
|
||||
读取单个文件 <MenubarShortcut>⌘R</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem onClick={() => onClickItem("read-all")} disabled={disabledItems?.read}>
|
||||
自动读取并更新结构 <MenubarShortcut>⌘⇧R</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem onClick={() => onClickItem("save")} disabled={disabledItems?.save}>
|
||||
保存 <MenubarShortcut>⌘S</MenubarShortcut>
|
||||
保存所有文件 <MenubarShortcut>⌘S</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
|
|
@ -54,7 +60,9 @@ function CommonMenubar({ disabledItems, onClickItem }: CommonMenubarProps) {
|
|||
导出 <ChevronDown className="size-4 opacity-30" />
|
||||
</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onClick={() => onClickItem("export")} disabled={disabledItems?.export}>导出 JSON</MenubarItem>
|
||||
<MenubarItem onClick={() => onClickItem("export")} disabled={disabledItems?.export}>
|
||||
手动导出 JSON
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -175,9 +175,15 @@ export function ProjectSourcesWizard({
|
|||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
请选择一个目录,其中包含多个语言子目录(如{" "}
|
||||
<code>en/messages.json</code>、<code>zh-CN/messages.json</code>)。
|
||||
请选择一个目录,其中包含多个语言子目录。示例:
|
||||
</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">
|
||||
<Button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -92,4 +92,6 @@ export function useTranslationInlineEdit(opts: {
|
|||
} as const;
|
||||
}
|
||||
|
||||
export type TranslationInlineEditHandlers = ReturnType<typeof useTranslationInlineEdit>;
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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} 未绑定文件或写入失败`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,8 +1,5 @@
|
|||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
getProject,
|
||||
getStructure,
|
||||
|
|
@ -17,7 +14,7 @@ import {
|
|||
updateProject,
|
||||
deleteProjectDeep,
|
||||
} 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 { buildStructureFromObject, flattenEntries, flattenValues, type FlatEntry, insertEntrySibling, removeEntryAtPath, renameEntryAtPath, moveEntryByOffset } from "@/lib/i18n-structure";
|
||||
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 { ProjectSettingsModal } from "@/components/biz/project-settings-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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useFileConnections, writeLanguageToConnectedFile } from "@/store/file-connection";
|
||||
import { useFileConnections } from "@/store/file-connection";
|
||||
import { generateLanguageJson } from "@/lib/utils";
|
||||
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 { ProjectTabsBar } from "@/components/biz/project-tabs-bar";
|
||||
import { ProjectSourcesWizard } from "@/components/biz/project-sources-wizard";
|
||||
import { isTauriEnv } from "@/lib/is-tauri";
|
||||
import { loadNativeProjectSources, saveNativeLanguageFile, type LoadedProjectSources, toProjectSourcesState } from "@/lib/native-sources";
|
||||
import { loadNativeProjectSources, type LoadedProjectSources, toProjectSourcesState } from "@/lib/native-sources";
|
||||
import { useProjectSourcesStore } from "@/store/sources-store";
|
||||
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 = {
|
||||
projectId?: string;
|
||||
|
|
@ -65,69 +52,39 @@ export default function Editor({ projectId }: EditorProps) {
|
|||
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 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 [aiBulkOpen, setAiBulkOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
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 { 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 setSourcesMeta = useProjectSourcesStore((state) => state.setSources);
|
||||
const clearSourcesMeta = useProjectSourcesStore((state) => state.clear);
|
||||
const getLanguagePath = useProjectSourcesStore((state) => state.getLanguagePath);
|
||||
// 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 sourcesState = useProjectSourcesStore((state) => state);
|
||||
const openProjectTab = useProjectTabsStore((state) => state.openProjectTab);
|
||||
const upsertProjectMeta = useProjectTabsStore((state) => state.upsertProjectMeta);
|
||||
const forgetProjectInTabs = useProjectTabsStore((state) => state.forgetProject);
|
||||
const activateHomeTab = useProjectTabsStore((state) => state.activateHome);
|
||||
const adapter = useLanguageAdapter(projectId);
|
||||
|
||||
function highlightRow(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);
|
||||
}
|
||||
};
|
||||
// 等一帧,确保滚动定位后的 DOM 稳定
|
||||
requestAnimationFrame(() => tryFindAndAnimate(0));
|
||||
}
|
||||
|
||||
const applyLoadedSources =
|
||||
async (loaded: LoadedProjectSources, opts?: { silent?: boolean }) => {
|
||||
const applyLoadedSources = useCallback(async (loaded: LoadedProjectSources, opts?: { silent?: boolean }) => {
|
||||
if (!projectId) return false;
|
||||
setSourcesMeta(toProjectSourcesState(loaded));
|
||||
|
||||
console.log("loaded", loaded);
|
||||
|
||||
const nextValues: Record<string, Record<string, string>> = {};
|
||||
for (const file of loaded.languages) {
|
||||
if (!file.content) continue;
|
||||
|
|
@ -141,15 +98,22 @@ export default function Editor({ projectId }: EditorProps) {
|
|||
const flattened = flattenValues(parsed);
|
||||
nextValues[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 {
|
||||
const root = buildStructureFromObject(parsed);
|
||||
await upsertStructure({ projectId, root });
|
||||
setStructure({ projectId, root });
|
||||
void upsertStructure({ projectId, root });
|
||||
return { projectId, root };
|
||||
} catch (err) {
|
||||
console.error("生成结构失败", err);
|
||||
return currentStructure;
|
||||
}
|
||||
}
|
||||
return currentStructure;
|
||||
});
|
||||
}
|
||||
}
|
||||
const loadedLanguages = Object.keys(nextValues);
|
||||
if (loadedLanguages.length === 0) {
|
||||
|
|
@ -163,20 +127,123 @@ export default function Editor({ projectId }: EditorProps) {
|
|||
});
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}, [projectId, setSourcesMeta]);
|
||||
|
||||
const syncNativeSources = useCallback(
|
||||
const syncFromAdapter = useCallback(
|
||||
async (opts?: { silent?: boolean }) => {
|
||||
if (!projectId || !isTauriEnv()) return false;
|
||||
try {
|
||||
const loaded = await loadNativeProjectSources(projectId);
|
||||
return await applyLoadedSources(loaded, opts);
|
||||
} catch (err) {
|
||||
console.error("同步本地语言文件失败", err);
|
||||
if (!projectId) return false;
|
||||
if (!adapter) {
|
||||
if (!opts?.silent) toast.info("请先配置语言文件来源");
|
||||
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(
|
||||
|
|
@ -186,57 +253,7 @@ export default function Editor({ projectId }: EditorProps) {
|
|||
[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(() => {
|
||||
if (!projectId) return;
|
||||
|
|
@ -247,79 +264,74 @@ export default function Editor({ projectId }: EditorProps) {
|
|||
clearSourcesMeta();
|
||||
}, [projectId, clearSourcesMeta]);
|
||||
|
||||
// 只在 projectId 变化时触发,避免因 refresh 引用变化导致的循环
|
||||
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
|
||||
}, [projectId]);
|
||||
}, [projectId]); // 只依赖 projectId
|
||||
|
||||
const entries: FlatEntry[] = useMemo(() => {
|
||||
if (!structure) return [];
|
||||
return flattenEntries(structure.root);
|
||||
}, [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 () => {
|
||||
if (!projectId || !structure) return;
|
||||
const targetLanguages = new Set<string>(Object.keys(connSnap.connections));
|
||||
if (hasNativeSources) {
|
||||
nativeLanguageTargets.forEach((lang) => targetLanguages.add(lang));
|
||||
}
|
||||
if (targetLanguages.size === 0) {
|
||||
toast.info("没有可保存的语言");
|
||||
if (!adapter) {
|
||||
toast.error("当前项目未配置可写入的语言文件");
|
||||
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);
|
||||
try {
|
||||
const orderedPaths = flattenEntries(structure.root).map((e) => e.path);
|
||||
const results = await Promise.allSettled(
|
||||
Array.from(targetLanguages).map(async (lang) => {
|
||||
const jsonText = generateLanguageJson(valuesByLang, lang, orderedPaths);
|
||||
const hasNativePath = hasNativeSources && isTauriEnv() && !!getLanguagePath(lang);
|
||||
if (hasNativePath) {
|
||||
await saveNativeLanguageFile(projectId, lang, jsonText);
|
||||
return lang;
|
||||
}
|
||||
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(", ")}`);
|
||||
const payloads = targetLanguages.map((lang) => ({
|
||||
language: lang,
|
||||
content: generateLanguageJson(valuesByLang, lang, orderedPaths),
|
||||
}));
|
||||
|
||||
if (adapter.supportsBulkWrite && adapter.writeAll) {
|
||||
await adapter.writeAll(payloads);
|
||||
} 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 {
|
||||
setSavingAll(false);
|
||||
}
|
||||
}, [projectId, structure, connSnap.connections, valuesByLang, hasNativeSources, nativeLanguageTargets, getLanguagePath]);
|
||||
}, [adapter, connSnap.connections, projectId, structure, valuesByLang]);
|
||||
|
||||
function computeSuggestedLanguages(pathsInput: string[]): string[] {
|
||||
if (languages.length === 0 || pathsInput.length === 0) return [];
|
||||
|
|
@ -358,199 +370,84 @@ export default function Editor({ projectId }: EditorProps) {
|
|||
return result;
|
||||
}, [languages, valuesByLang]);
|
||||
|
||||
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 handleOpenAddEntry = useCallback((path: string, position: "above" | "below") => {
|
||||
setAddModal({ open: true, path, position });
|
||||
}, []);
|
||||
|
||||
const allSelected = useMemo(() => entries.length > 0 && selected.size === entries.length, [entries, selected]);
|
||||
const MAX_AI_ITEMS = 50;
|
||||
const handleOpenAiTranslate = useCallback((path: string) => {
|
||||
setAiModal({ open: true, path });
|
||||
}, []);
|
||||
|
||||
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>
|
||||
), [displayedLanguages, allSelected, entries]);
|
||||
const handleOpenRenameEntry = useCallback((path: string) => {
|
||||
setRenameModal({ open: true, path });
|
||||
}, []);
|
||||
|
||||
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 = 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;
|
||||
const handleMoveEntry = useCallback(async (path: string, offset: number) => {
|
||||
if (!projectId || !structure || offset === 0) return;
|
||||
try {
|
||||
const nextRoot = removeEntryAtPath(structure.root, entry.path);
|
||||
const nextRoot = moveEntryByOffset(structure.root, path, offset);
|
||||
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 });
|
||||
setValuesByLang((old) => {
|
||||
const copy: typeof old = {};
|
||||
for (const [langKey, vals] of Object.entries(old)) {
|
||||
const rest = { ...vals } as Record<string, string>;
|
||||
delete rest[entry.path];
|
||||
copy[langKey] = rest;
|
||||
const next = { ...vals } as Record<string, string>;
|
||||
delete next[path];
|
||||
copy[langKey] = next;
|
||||
}
|
||||
return copy;
|
||||
});
|
||||
setSelected((prev) => {
|
||||
if (!prev.has(path)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(path);
|
||||
return next;
|
||||
});
|
||||
} catch (e) {
|
||||
setPageError((e as Error)?.message ?? "删除失败");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}, [displayedLanguages, inline, projectId, structure, setStructure, setValuesByLang, copy, selected, moveCountUp, moveCountDown, handleMove]);
|
||||
}, [projectId, setSelected, setValuesByLang, structure]);
|
||||
|
||||
const handleDeleteSelectedEntries = useCallback(async (paths: string[]) => {
|
||||
if (!projectId || !structure || paths.length === 0) return;
|
||||
try {
|
||||
let nextRoot = structure.root;
|
||||
for (const p of paths) {
|
||||
nextRoot = removeEntryAtPath(nextRoot, p);
|
||||
}
|
||||
await upsertStructure({ projectId, root: nextRoot });
|
||||
await Promise.all(paths.map((p) => deleteEntryFromAllLanguages(projectId, p)));
|
||||
setStructure({ projectId, root: nextRoot });
|
||||
setValuesByLang((old) => {
|
||||
const copy: typeof old = {};
|
||||
for (const [langKey, vals] of Object.entries(old)) {
|
||||
const next = { ...vals } as Record<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(() => {
|
||||
if (!projectId || languages.length === 0) return;
|
||||
|
|
@ -570,21 +467,25 @@ export default function Editor({ projectId }: EditorProps) {
|
|||
|
||||
const disabledItems = useMemo(() => {
|
||||
return {
|
||||
read: !structure,
|
||||
save: !structure,
|
||||
"import-with-directory": !structure,
|
||||
"import-with-files": !structure,
|
||||
read: !adapter,
|
||||
"read-all": !adapter,
|
||||
save: savingAll || !structure || !adapter || (adapter.mode === "browser" && Object.keys(connSnap.connections).length === 0),
|
||||
"import-with-directory": false,
|
||||
"import-with-files": false,
|
||||
export: languages.length === 0,
|
||||
};
|
||||
}, [structure, languages.length]);
|
||||
}, [adapter, structure, languages.length]);
|
||||
|
||||
const onClickItem = useCallback((item: CommonMenubarItem) => {
|
||||
switch (item) {
|
||||
case "read":
|
||||
// setSourcesWizardOpen(true);
|
||||
void syncExternalSources({ silent: false });
|
||||
break;
|
||||
case "read-all":
|
||||
void syncExternalSources({ silent: false, reloadConfig: true });
|
||||
break;
|
||||
case "save":
|
||||
handleSaveAllConnected();
|
||||
void handleSaveAllConnected();
|
||||
break;
|
||||
case "import-with-directory":
|
||||
setSourcesWizardOpen(true);
|
||||
|
|
@ -596,7 +497,14 @@ export default function Editor({ projectId }: EditorProps) {
|
|||
setExportOpen(true);
|
||||
break;
|
||||
}
|
||||
}, [setSourcesWizardOpen, handleSaveAllConnected]);
|
||||
}, [handleSaveAllConnected, setSourcesWizardOpen, setImportOpen, setExportOpen, syncExternalSources]);
|
||||
|
||||
// 注册键盘快捷方式
|
||||
useEditorKeyboardShortcuts({
|
||||
onClickItem,
|
||||
disabledItems,
|
||||
enabled: !loading && !!projectId,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background">
|
||||
|
|
@ -606,23 +514,6 @@ export default function Editor({ projectId }: EditorProps) {
|
|||
<CommonMenubar disabledItems={disabledItems} onClickItem={onClickItem} />
|
||||
<div className="flex items-center justify-end gap-2 text-foreground">
|
||||
<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 && (
|
||||
<Button variant="ghost" onClick={() => setSettingsOpen(true)}>
|
||||
<Settings />
|
||||
|
|
@ -652,126 +543,20 @@ export default function Editor({ projectId }: EditorProps) {
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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={() => 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; }}
|
||||
<EditorTable
|
||||
entries={entries}
|
||||
languages={languages}
|
||||
selected={selected}
|
||||
setSelected={setSelected}
|
||||
inlineEdit={inlineEdit}
|
||||
onOpenAddEntry={handleOpenAddEntry}
|
||||
onOpenAiTranslate={handleOpenAiTranslate}
|
||||
onOpenBulkAiTranslate={handleOpenBulkAiTranslate}
|
||||
onOpenRenameEntry={handleOpenRenameEntry}
|
||||
onMoveEntry={handleMoveEntry}
|
||||
onDeleteEntry={handleDeleteEntry}
|
||||
onDeleteSelected={handleDeleteSelectedEntries}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export type ProjectSourcesState = {
|
|||
type ProjectSourcesActions = {
|
||||
setSources: (state: ProjectSourcesState) => void;
|
||||
clear: () => void;
|
||||
getLanguagePath: (language: string) => string | undefined;
|
||||
getLanguageMeta: (language: string) => LanguageFileMeta | undefined;
|
||||
};
|
||||
|
||||
const initialState: ProjectSourcesState = {
|
||||
|
|
@ -38,8 +38,7 @@ export const useProjectSourcesStore = create<ProjectSourcesState & ProjectSource
|
|||
...state,
|
||||
})),
|
||||
clear: () => set(() => initialState),
|
||||
getLanguagePath: (language) => {
|
||||
const meta = get().languages.find((lang) => lang.language === language);
|
||||
return meta?.path;
|
||||
getLanguageMeta: (language) => {
|
||||
return get().languages.find((lang) => lang.language === language);
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
Loading…
Reference in New Issue