I18n-Translate-It/src/components/biz/export-language-modal.tsx

166 lines
4.8 KiB
TypeScript

import { memo, useEffect, useMemo, useState } from "react";
import { Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { unflattenValues } from "@/lib/i18n-structure";
import { useClipboard } from "@/hooks/use-clipboard";
type Props = {
open: boolean;
onOpenChange: (next: boolean) => void;
languages: string[];
valuesByLang: Record<string, Record<string, string>>;
orderedPaths?: string[]; // 优先按照结构顺序导出
};
function ExportLanguageModalImpl({
open,
onOpenChange,
languages,
valuesByLang,
orderedPaths,
}: Props) {
const [selected, setSelected] = useState<string>("");
const { copied, copy } = useClipboard({ resetAfterMs: 1500 });
useEffect(() => {
if (open) {
setSelected((prev) =>
prev && languages.includes(prev) ? prev : languages[0] ?? ""
);
}
}, [open, languages]);
const jsonText = useMemo(() => {
if (!selected) return "";
const flat = valuesByLang[selected] ?? {};
// 若提供结构顺序,则按结构顺序构建嵌套对象,保证键序与表格一致
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(unflattenValues(flat), null, 2);
} catch {
return "{}";
}
}, [selected, valuesByLang, orderedPaths]);
function handleDownload() {
if (!selected) return;
const blob = new Blob([jsonText || "{}"], {
type: "application/json;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${selected}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
const handleCopy = () => {
if (!jsonText) return;
copy(jsonText);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle> JSON</DialogTitle>
</DialogHeader>
<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>
) : (
languages.map((lang) => (
<button
key={lang}
type="button"
onClick={() => setSelected(lang)}
className={`px-3 h-8 rounded border text-sm ${
selected === lang ? "bg-accent" : ""
}`}
>
{lang}
</button>
))
)}
</div>
<div className="border rounded-md overflow-hidden">
<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={handleCopy} variant="outline" disabled={!selected || !jsonText}>
{copied ? (<><Check /> </>) : "复制 JSON"}
</Button>
<Button onClick={handleDownload} disabled={!selected || !jsonText}>
JSON
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export const ExportLanguageModal = memo(ExportLanguageModalImpl);