Feat: 加入虚拟列表,导出 JSON 增加排序功能

This commit is contained in:
2025-11-03 20:07:19 +08:00
parent f2acb91f95
commit e1a5be8722
8 changed files with 1073 additions and 67 deletions
+82
View File
@@ -0,0 +1,82 @@
import { memo, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
type Props = {
open: boolean;
onOpenChange: (v: boolean) => void;
title: string;
placeholder?: string;
defaultValue?: string;
confirmText?: string;
onConfirm: (name: string) => void | Promise<void>;
validate?: (name: string) => string | null;
};
function EntryNameModalImpl({ open, onOpenChange, title, placeholder, defaultValue, confirmText = "确认", onConfirm, validate }: Props) {
const [name, setName] = useState("");
const [err, setErr] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (open) {
setName(defaultValue ?? "");
setErr(null);
setSaving(false);
}
}, [open, defaultValue]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const trimmed = name.trim();
if (!trimmed) return setErr("名称不能为空");
if (trimmed.includes(".")) return setErr("名称不能包含 '.'");
if (validate) {
const msg = validate(trimmed);
if (msg) return setErr(msg);
}
setSaving(true);
try {
await onConfirm(trimmed);
onOpenChange(false);
} catch (e) {
setErr((e as Error)?.message ?? "操作失败");
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!saving) onOpenChange(v); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-3">
<Input
placeholder={placeholder}
value={name}
onChange={(e) => setName(e.target.value)}
aria-label="名称"
/>
{err && <div className="text-sm text-red-600">{err}</div>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>取消</Button>
<Button type="submit" disabled={saving}>{confirmText}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export const EntryNameModal = memo(EntryNameModalImpl);
+76 -15
View File
@@ -14,31 +14,84 @@ type Props = {
onOpenChange: (next: boolean) => void;
languages: string[];
valuesByLang: Record<string, Record<string, string>>;
orderedPaths?: string[]; // 优先按照结构顺序导出
};
function ExportLanguageModalImpl({ open, onOpenChange, languages, valuesByLang }: Props) {
function ExportLanguageModalImpl({
open,
onOpenChange,
languages,
valuesByLang,
orderedPaths,
}: Props) {
const [selected, setSelected] = useState<string>("");
useEffect(() => {
if (open) {
setSelected((prev) => (prev && languages.includes(prev) ? prev : languages[0] ?? ""));
setSelected((prev) =>
prev && languages.includes(prev) ? prev : languages[0] ?? ""
);
}
}, [open, languages]);
const jsonText = useMemo(() => {
if (!selected) return "";
const flat = valuesByLang[selected] ?? {};
const nested = unflattenValues(flat);
// 若提供结构顺序,则按结构顺序构建嵌套对象,保证键序与表格一致
if (orderedPaths && orderedPaths.length > 0) {
const root: Record<string, unknown> = {};
const added = new Set<string>();
const setByPath = (path: string, val: string) => {
const parts = path.split(".");
let cur: Record<string, unknown> = root;
for (let i = 0; i < parts.length; i += 1) {
const key = parts[i]!;
const isLeaf = i === parts.length - 1;
if (isLeaf) {
cur[key] = val;
} else {
const next = cur[key];
if (next && typeof next === "object" && !Array.isArray(next)) {
cur = next as Record<string, unknown>;
} else {
const obj: Record<string, unknown> = {};
cur[key] = obj;
cur = obj;
}
}
}
};
for (const p of orderedPaths) {
const v = Object.prototype.hasOwnProperty.call(flat, p) ? flat[p] : "";
setByPath(p, v);
added.add(p);
}
// 追加结构外的剩余键,避免丢数据
for (const p of Object.keys(flat)) {
if (!added.has(p)) setByPath(p, flat[p]!);
}
try {
return JSON.stringify(root, null, 2);
} catch {
return "{}";
}
}
// 回退:无结构顺序时使用普通反扁平
try {
return JSON.stringify(nested, null, 2);
return JSON.stringify(unflattenValues(flat), null, 2);
} catch {
return "{}";
}
}, [selected, valuesByLang]);
}, [selected, valuesByLang, orderedPaths]);
function handleDownload() {
if (!selected) return;
const blob = new Blob([jsonText || "{}"], { type: "application/json;charset=utf-8" });
const blob = new Blob([jsonText || "{}"], {
type: "application/json;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
@@ -58,14 +111,18 @@ function ExportLanguageModalImpl({ open, onOpenChange, languages, valuesByLang }
<div className="space-y-3 min-w-0">
<div className="flex flex-wrap gap-2">
{languages.length === 0 ? (
<div className="text-sm text-muted-foreground">暂无可导出的语言</div>
<div className="text-sm text-muted-foreground">
暂无可导出的语言
</div>
) : (
languages.map((lang) => (
<button
key={lang}
type="button"
onClick={() => setSelected(lang)}
className={`px-3 h-8 rounded border text-sm ${selected === lang ? "bg-accent" : ""}`}
className={`px-3 h-8 rounded border text-sm ${
selected === lang ? "bg-accent" : ""
}`}
>
{lang}
</button>
@@ -73,14 +130,20 @@ function ExportLanguageModalImpl({ open, onOpenChange, languages, valuesByLang }
)}
</div>
<div className="border rounded-md overflow-hidden">
<textarea className="w-full h-[50vh] p-3 font-mono text-sm whitespace-pre" readOnly>
{jsonText}
</textarea>
<textarea
className="w-full h-[50vh] p-3 font-mono text-sm whitespace-pre"
readOnly
value={jsonText}
></textarea>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>关闭</Button>
<Button onClick={handleDownload} disabled={!selected || !jsonText}>导出 JSON</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>
关闭
</Button>
<Button onClick={handleDownload} disabled={!selected || !jsonText}>
导出 JSON
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -88,5 +151,3 @@ function ExportLanguageModalImpl({ open, onOpenChange, languages, valuesByLang }
}
export const ExportLanguageModal = memo(ExportLanguageModalImpl);
+255
View File
@@ -0,0 +1,255 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+73
View File
@@ -250,3 +250,76 @@ export async function listLanguages(projectId: string): Promise<string[]> {
db.close();
}
}
export async function getAllLanguageTranslations(projectId: string): Promise<ProjectLanguageTranslations[]> {
const db = await openDb();
try {
const tx = db.transaction(STORE_TRANSLATIONS, "readonly");
const store = tx.objectStore(STORE_TRANSLATIONS);
const index = store.index("byProject");
const allReq = index.getAll(IDBKeyRange.only(projectId));
const records = await promisifyRequest<ProjectLanguageTranslations[]>(allReq as IDBRequest<ProjectLanguageTranslations[]>);
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
return records;
} finally {
db.close();
}
}
export async function deleteEntryFromAllLanguages(projectId: string, path: string): Promise<void> {
const db = await openDb();
try {
const tx = db.transaction(STORE_TRANSLATIONS, "readwrite");
const store = tx.objectStore(STORE_TRANSLATIONS);
const index = store.index("byProject");
const allReq = index.getAll(IDBKeyRange.only(projectId));
const records = await promisifyRequest<ProjectLanguageTranslations[]>(allReq as IDBRequest<ProjectLanguageTranslations[]>);
for (const rec of records) {
if (rec.values && Object.prototype.hasOwnProperty.call(rec.values, path)) {
const { [path]: _, ...rest } = rec.values;
rec.values = rest;
await promisifyRequest(store.put(rec));
}
}
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
} finally {
db.close();
}
}
export async function renameEntryInAllLanguages(projectId: string, oldPath: string, newPath: string): Promise<void> {
const db = await openDb();
try {
const tx = db.transaction(STORE_TRANSLATIONS, "readwrite");
const store = tx.objectStore(STORE_TRANSLATIONS);
const index = store.index("byProject");
const allReq = index.getAll(IDBKeyRange.only(projectId));
const records = await promisifyRequest<ProjectLanguageTranslations[]>(allReq as IDBRequest<ProjectLanguageTranslations[]>);
for (const rec of records) {
const values = rec.values ?? {};
if (Object.prototype.hasOwnProperty.call(values, oldPath)) {
const val = values[oldPath];
const updated = { ...values };
delete updated[oldPath];
updated[newPath] = val;
rec.values = updated;
await promisifyRequest(store.put(rec));
}
}
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
} finally {
db.close();
}
}
+70
View File
@@ -83,4 +83,74 @@ export function unflattenValues(values: Record<string, string>): Record<string,
return root;
}
function getSegments(path: string): string[] {
return path ? path.split(".") : [];
}
function cloneNode(node: StructureNode): StructureNode {
if (node.type === "group") {
return { key: node.key, type: "group", children: (node.children ?? []).map(cloneNode) };
}
return { key: node.key, type: "entry" };
}
export function findParentAndIndex(root: StructureNode, path: string): { parent: StructureNode; index: number } | null {
const segments = getSegments(path);
let parent: StructureNode = root;
for (let i = 0; i < segments.length - 1; i += 1) {
const seg = segments[i]!;
const next = (parent.children ?? []).find((c) => c.key === seg && c.type === "group");
if (!next) return null;
parent = next;
}
const leafKey = segments[segments.length - 1];
if (leafKey == null) return null;
const index = (parent.children ?? []).findIndex((c) => c.key === leafKey);
if (index < 0) return null;
return { parent, index };
}
export function insertEntrySibling(
root: StructureNode,
targetPath: string,
position: "above" | "below",
newKey: string
): StructureNode {
const cloned = cloneNode(root);
const info = findParentAndIndex(cloned, targetPath);
if (!info) return cloned;
const { parent, index } = info;
if (!parent.children) parent.children = [];
const exists = parent.children.some((c) => c.key === newKey);
if (exists) throw new Error("同级已存在同名条目");
const insertIndex = position === "above" ? index : index + 1;
parent.children.splice(insertIndex, 0, { key: newKey, type: "entry" });
return cloned;
}
export function removeEntryAtPath(root: StructureNode, path: string): StructureNode {
const cloned = cloneNode(root);
const info = findParentAndIndex(cloned, path);
if (!info) return cloned;
const { parent, index } = info;
parent.children?.splice(index, 1);
return cloned;
}
export function renameEntryAtPath(root: StructureNode, path: string, newKey: string): { root: StructureNode; newPath: string } {
const cloned = cloneNode(root);
const info = findParentAndIndex(cloned, path);
if (!info) return { root: cloned, newPath: path };
const { parent, index } = info;
const siblings = parent.children ?? [];
if (siblings.some((c, i) => i !== index && c.key === newKey)) {
throw new Error("同级已存在同名条目");
}
const old = siblings[index]!;
siblings[index] = old.type === "group" ? { ...old, key: newKey } : { key: newKey, type: "entry" };
const segments = getSegments(path);
segments[segments.length - 1] = newKey;
return { root: cloned, newPath: segments.join(".") };
}
+239 -52
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type React from "react";
import { Link, useParams } from "react-router";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -9,12 +10,24 @@ import {
type Project,
type ProjectStructure,
getLanguageTranslations,
upsertStructure,
deleteEntryFromAllLanguages,
renameEntryInAllLanguages,
} from "@/lib/db";
import { ArrowLeft } from "lucide-react";
import { useTranslationInlineEdit } from "@/hooks/biz/use-translation-inline-edit";
import { flattenEntries, type FlatEntry } from "@/lib/i18n-structure";
import { flattenEntries, type FlatEntry, insertEntrySibling, removeEntryAtPath, renameEntryAtPath } from "@/lib/i18n-structure";
import { ImportLanguageModal } from "@/components/biz/import-language-modal";
import { ExportLanguageModal } from "@/components/biz/export-language-modal";
import { EntryNameModal } from "@/components/biz/entry-name-modal";
import { TableVirtuoso, type TableVirtuosoHandle } from "react-virtuoso";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
export default function Editor() {
const { id: projectId } = useParams();
@@ -25,6 +38,50 @@ export default function Editor() {
const [pageError, setPageError] = useState<string | null>(null);
const [importOpen, setImportOpen] = useState(false);
const [exportOpen, setExportOpen] = 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);
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));
}
function scrollToQuery() {
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;
@@ -63,6 +120,107 @@ export default function Editor() {
onError: (m) => setPageError(m),
});
const colWidth = useMemo(() => `${100 / (languages.length + 2)}%`, [languages.length]);
const virtuosoComponents = useMemo(() => ({
Table: (props: React.TableHTMLAttributes<HTMLTableElement>) => <table className="min-w-full text-sm table-fixed" {...props} />,
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} />,
}), []);
const headerContent = useCallback(() => (
<tr>
<th style={{ width: colWidth }} className="text-left px-3 py-2">翻译条目名称</th>
{languages.map((lang) => (
<th key={lang} style={{ width: colWidth }} className="text-left px-3 py-2 whitespace-nowrap">{lang}</th>
))}
<th style={{ width: "5em" }} className="text-left px-3 py-2">操作</th>
</tr>
), [languages, colWidth]);
const renderItemContent = useCallback((_idx: number, entry: FlatEntry) => {
return (
<>
<td style={{ width: colWidth }} className="px-3 py-2 font-mono whitespace-nowrap">{entry.path}</td>
{languages.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}`} style={{ width: colWidth }} className="px-3 py-2 text-foreground/90 align-top">
{isEditing ? (
<Input
ref={inline.inputRef}
value={inline.editingValue}
onChange={(e) => inline.setEditingValue(e.target.value)}
onBlur={inline.saveEdit}
onKeyDown={inline.handleKeyDown}
disabled={isSaving}
className="h-8"
/>
) : (
<button
type="button"
className="w-full text-left min-h-8 leading-8 px-2 rounded hover:bg-accent"
onClick={() => inline.startEdit(entry.path, lang)}
title="点击编辑"
>
{displayValue || <span className="text-muted-foreground">点击编辑</span>}
</button>
)}
</td>
);
})}
<td style={{ width: "5em" }} className="px-3 py-2 align-top">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline">操作</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onClick={() => setAddModal({ open: true, path: entry.path, position: "below" })}>
在下面新增
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setAddModal({ open: true, path: entry.path, position: "above" })}>
在上面新增
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={async () => {
if (!projectId || !structure) return;
if (!confirm("确认删除该条目?此操作会移除所有语言下的该键")) return;
try {
const nextRoot = removeEntryAtPath(structure.root, entry.path);
await upsertStructure({ projectId, root: nextRoot });
await deleteEntryFromAllLanguages(projectId, entry.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;
}
return copy;
});
} catch (e) {
setPageError((e as Error)?.message ?? "删除失败");
}
}}
>
删除
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setRenameModal({ open: true, path: entry.path })}>
重命名
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</>
);
}, [languages, colWidth, inline, projectId, structure, setStructure, setValuesByLang]);
useEffect(() => {
if (!projectId || languages.length === 0) return;
let cancelled = false;
@@ -124,56 +282,35 @@ export default function Editor() {
</div>
) : (
<div>
<div className="overflow-auto border rounded-md">
<table className="min-w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-3 py-2 w-[320px]">翻译条目名称</th>
{languages.map((lang) => (
<th key={lang} className="text-left px-3 py-2 whitespace-nowrap">{lang}</th>
))}
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.path} className="border-t">
<td className="px-3 py-2 font-mono whitespace-nowrap">{entry.path}</td>
{languages.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 ? (
<Input
ref={inline.inputRef}
value={inline.editingValue}
onChange={(e) => inline.setEditingValue(e.target.value)}
onBlur={inline.saveEdit}
onKeyDown={inline.handleKeyDown}
disabled={isSaving}
className="h-8"
/>
) : (
<button
type="button"
className="w-full text-left min-h-8 leading-8 px-2 rounded hover:bg-accent"
onClick={() => inline.startEdit(entry.path, lang)}
title="点击编辑"
>
{displayValue || <span className="text-muted-foreground">点击编辑</span>}
</button>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
<div className="mb-3 flex items-center gap-2">
<Input placeholder="搜索翻译条目名称" value={query} onChange={(e) => setQuery(e.target.value)} />
<Button
variant={fullMatch ? "default" : "outline"}
onClick={() => setFullMatch((v) => !v)}
title="切换全量匹配/模糊匹配"
>
{fullMatch ? "全量匹配" : "模糊匹配"}
</Button>
<Button
variant={caseSensitive ? "default" : "outline"}
onClick={() => setCaseSensitive((v) => !v)}
title="切换大小写敏感"
>
{caseSensitive ? "区分大小写" : "忽略大小写"}
</Button>
<Button variant="outline" onClick={scrollToQuery}>定位</Button>
</div>
<div className="border rounded-md">
<TableVirtuoso
ref={virtuosoRef}
data={entries}
style={{ height: "60vh" }}
fixedHeaderContent={headerContent}
itemContent={renderItemContent}
components={virtuosoComponents}
computeItemKey={(_index, entry) => entry.path}
scrollerRef={(el) => { scrollerRootRef.current = el; }}
/>
</div>
</div>
)}
@@ -191,6 +328,56 @@ export default function Editor() {
onOpenChange={setExportOpen}
languages={languages}
valuesByLang={valuesByLang}
orderedPaths={entries.map((e) => e.path)}
/>
<EntryNameModal
open={!!addModal?.open}
onOpenChange={(v) => setAddModal((cur) => (cur ? { ...cur, open: v } : cur))}
title={addModal?.position === "above" ? "在上方新增条目" : "在下方新增条目"}
placeholder="请输入条目名称(不含点)"
onConfirm={async (name) => {
if (!projectId || !structure || !addModal) return;
const nextRoot = insertEntrySibling(structure.root, addModal.path, addModal.position, name);
await upsertStructure({ projectId, root: nextRoot });
setStructure({ projectId, root: nextRoot });
}}
validate={(name) => {
// 同级重名校验在结构函数中也会做,这里做基础提示即可
if (name.includes('.')) return "名称不能包含 '.'";
return null;
}}
/>
<EntryNameModal
open={!!renameModal?.open}
onOpenChange={(v) => setRenameModal((cur) => (cur ? { ...cur, open: v } : cur))}
title="重命名条目"
placeholder="请输入新名称(不含点)"
defaultValue={renameModal?.path ? renameModal.path.split('.').pop() : ''}
onConfirm={async (newName) => {
if (!projectId || !structure || !renameModal) return;
const { root: nextRoot, newPath } = renameEntryAtPath(structure.root, renameModal.path, newName);
await upsertStructure({ projectId, root: nextRoot });
await renameEntryInAllLanguages(projectId, renameModal.path, newPath);
setStructure({ projectId, root: nextRoot });
setValuesByLang((old) => {
const copy: typeof old = {};
for (const [langKey, vals] of Object.entries(old)) {
if (Object.prototype.hasOwnProperty.call(vals, renameModal.path)) {
const { [renameModal.path]: val, ...rest } = vals;
copy[langKey] = { ...rest, [newPath]: val };
} else {
copy[langKey] = vals;
}
}
return copy;
});
}}
validate={(name) => {
if (name.includes('.')) return "名称不能包含 '.'";
return null;
}}
/>
</div>
);