449 lines
17 KiB
TypeScript
449 lines
17 KiB
TypeScript
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, ClipboardPaste, Copy, 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("复制成功");
|
||
};
|
||
|
||
const handlePasteAllValues = async () => {
|
||
if (!navigator.clipboard?.readText) {
|
||
toast.error("当前环境不支持读取剪贴板");
|
||
return;
|
||
}
|
||
|
||
let parsed: unknown;
|
||
try {
|
||
const text = await navigator.clipboard.readText();
|
||
parsed = JSON.parse(text);
|
||
} catch {
|
||
toast.error("读取或解析剪贴板失败");
|
||
return;
|
||
}
|
||
|
||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||
toast.error("剪贴板内容不是有效的语言值 JSON");
|
||
return;
|
||
}
|
||
|
||
const valuesToPaste: Record<string, string> = {};
|
||
for (const [lang, value] of Object.entries(parsed)) {
|
||
if (languages.includes(lang) && typeof value === "string") {
|
||
valuesToPaste[lang] = value;
|
||
}
|
||
}
|
||
|
||
try {
|
||
const pastedCount = await inlineEdit.updateValuesForPath(entry.path, valuesToPaste);
|
||
if (pastedCount === 0) {
|
||
toast.error("剪贴板中没有可粘贴的语言值");
|
||
return;
|
||
}
|
||
|
||
toast.success(`已粘贴 ${pastedCount} 种语言的值`);
|
||
} catch {
|
||
toast.error("粘贴语言值失败");
|
||
}
|
||
};
|
||
|
||
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
|
||
onClick={() => {
|
||
const allValues: Record<string, string> = {};
|
||
for (const lang of languages) {
|
||
const value = inlineEdit.getDisplayValue(entry.path, lang);
|
||
allValues[lang] = value;
|
||
}
|
||
const jsonStr = JSON.stringify(allValues, null, 2);
|
||
copy(jsonStr);
|
||
toast.success("已复制所有语言的值");
|
||
}}
|
||
>
|
||
<Copy />
|
||
复制所有语言值
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem onClick={() => { void handlePasteAllValues(); }}>
|
||
<ClipboardPaste />
|
||
粘贴语言值
|
||
</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, languages, 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>
|
||
);
|
||
}
|