Fix: 重构部分逻辑,编辑器表格部分逻辑拆分,数据源改为适配器模式,增加键盘快捷键操作
This commit is contained in:
parent
ff35f5b105
commit
b51da785af
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
} 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;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue