Feat: 多选条目翻译,最多同时翻译 50 条
This commit is contained in:
parent
85d197f149
commit
9766bc2411
|
|
@ -1,4 +1,4 @@
|
||||||
import { memo, useState } from "react";
|
import { memo, useEffect, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
|
|
@ -14,21 +14,36 @@ type Props = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (next: boolean) => void;
|
onOpenChange: (next: boolean) => void;
|
||||||
languages: string[];
|
languages: string[];
|
||||||
path: string;
|
paths: string[];
|
||||||
prompt: string | undefined;
|
prompt: string | undefined;
|
||||||
onConfirm: (result: Record<string, string>) => Promise<void> | void;
|
onConfirm: (result: Record<string, Record<string, string>>) => Promise<void> | void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function AiTranslateModalImpl({ open, onOpenChange, languages, path, prompt, onConfirm }: Props) {
|
function AiTranslateModalImpl({ open, onOpenChange, languages, paths, prompt, onConfirm }: Props) {
|
||||||
const [text, setText] = useState("");
|
const [inputsByKey, setInputsByKey] = useState<Record<string, string>>({});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const MAX_ITEMS = 50;
|
||||||
|
const overLimit = paths.length > MAX_ITEMS;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
const init: Record<string, string> = {};
|
||||||
|
for (const p of paths) init[p] = inputsByKey[p] ?? "";
|
||||||
|
setInputsByKey(init);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, paths.join("|")]);
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const payload = text.trim();
|
if (overLimit) {
|
||||||
if (!payload) {
|
setError(`一次最多支持 ${MAX_ITEMS} 条`);
|
||||||
setError("请输入待翻译文本");
|
return;
|
||||||
|
}
|
||||||
|
const items = paths.map((key) => ({ key, text: (inputsByKey[key] ?? "").trim() }));
|
||||||
|
if (items.some((it) => !it.text)) {
|
||||||
|
setError("请为所有条目输入原文");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (languages.length === 0) {
|
if (languages.length === 0) {
|
||||||
|
|
@ -38,15 +53,9 @@ function AiTranslateModalImpl({ open, onOpenChange, languages, path, prompt, onC
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const result = await requestTranslations({ text: payload, languages, prompt });
|
const result = await requestTranslations({ items, languages, prompt });
|
||||||
// 二次校验 keys 完整性
|
|
||||||
for (const l of languages) {
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(result, l)) {
|
|
||||||
throw new Error("AI 返回的 key 不完整");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await onConfirm(result);
|
await onConfirm(result);
|
||||||
setText("");
|
setInputsByKey({});
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error)?.message ?? "AI 翻译失败");
|
setError((e as Error)?.message ?? "AI 翻译失败");
|
||||||
|
|
@ -65,24 +74,29 @@ function AiTranslateModalImpl({ open, onOpenChange, languages, path, prompt, onC
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{`AI 翻译 — ${path || "条目"}`}</DialogTitle>
|
<DialogTitle>{`AI 翻译 — 已选 ${paths.length} 条`}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form onSubmit={handleSubmit} className="space-y-3">
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
<div>
|
<div className="space-y-4 max-h-[50vh] overflow-auto pr-1">
|
||||||
<label className="text-sm text-muted-foreground">待翻译文本</label>
|
{paths.map((p) => (
|
||||||
<Input
|
<div key={p} className="flex flex-col gap-2">
|
||||||
value={text}
|
<div className="truncate font-mono text-xs text-muted-foreground">{p}</div>
|
||||||
onChange={(e) => setText(e.target.value)}
|
<Input
|
||||||
placeholder="请输入原文本"
|
className="grow"
|
||||||
aria-label="待翻译文本"
|
value={inputsByKey[p] ?? ""}
|
||||||
/>
|
onChange={(e) => setInputsByKey((old) => ({ ...old, [p]: e.target.value }))}
|
||||||
|
placeholder="请输入原文本"
|
||||||
|
aria-label={`原文 ${p}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
目标语言:{languages.join(", ") || "无"}
|
目标语言:{languages.join(", ") || "无"}
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{(error || overLimit) && (
|
||||||
<div className="text-sm text-red-600" role="alert">{error}</div>
|
<div className="text-sm text-red-600" role="alert">{error}</div>
|
||||||
)}
|
)}
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|
@ -97,7 +111,7 @@ function AiTranslateModalImpl({ open, onOpenChange, languages, path, prompt, onC
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={loading}>
|
<Button type="submit" disabled={loading || overLimit}>
|
||||||
{loading ? "翻译中..." : "生成翻译"}
|
{loading ? "翻译中..." : "生成翻译"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ const buttonVariants = cva(
|
||||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
outline:
|
outline:
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
"outline-destructive":
|
||||||
|
"border border-destructive text-destructive hover:bg-destructive/10 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost:
|
ghost:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import OpenAI from "openai";
|
import OpenAI from "openai";
|
||||||
|
|
||||||
export type AiTranslateParams = {
|
export type AiTranslateParams = {
|
||||||
text: string;
|
items: { key: string; text: string }[];
|
||||||
languages: string[];
|
languages: string[];
|
||||||
model?: string;
|
model?: string;
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
|
|
@ -18,11 +18,11 @@ function getClient() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requestTranslations({
|
export async function requestTranslations({
|
||||||
text,
|
items,
|
||||||
languages,
|
languages,
|
||||||
model,
|
model,
|
||||||
prompt,
|
prompt,
|
||||||
}: AiTranslateParams): Promise<Record<string, string>> {
|
}: AiTranslateParams): Promise<Record<string, Record<string, string>>> {
|
||||||
const client = getClient();
|
const client = getClient();
|
||||||
const mdl =
|
const mdl =
|
||||||
model ||
|
model ||
|
||||||
|
|
@ -31,19 +31,25 @@ export async function requestTranslations({
|
||||||
"openai/gpt-5";
|
"openai/gpt-5";
|
||||||
|
|
||||||
const targetList = JSON.stringify(languages);
|
const targetList = JSON.stringify(languages);
|
||||||
|
const itemsJson = JSON.stringify(items, null, 0);
|
||||||
|
const keysJson = JSON.stringify(items.map((it) => it.key));
|
||||||
const instruction = [
|
const instruction = [
|
||||||
"你是一个专业的翻译助手。",
|
"你是一个专业的翻译助手。",
|
||||||
`请将用户输入的文本翻译为这些目标语言:${targetList},注意英语的首字母务必是大写。`,
|
`请将多条原文同时翻译为这些目标语言:${targetList},注意英语的首字母务必是大写。`,
|
||||||
"翻译偏好:",
|
"翻译偏好:",
|
||||||
prompt,
|
prompt,
|
||||||
"严格要求:",
|
"严格要求:",
|
||||||
"- 仅返回一个 JSON 对象,不包含任何其他内容(例如说明、代码块标记、注释)。",
|
"- 仅返回一个 JSON 对象,不包含任何其他内容(例如说明、代码块标记、注释)。",
|
||||||
"- JSON 的键必须严格等于目标语言代码,值为对应翻译的纯文本字符串。",
|
"- JSON 的顶层键必须严格等于目标语言代码;其值必须是一个对象,键为条目 key,值为该 key 的翻译纯文本。",
|
||||||
"- 如果某一语言确实无法翻译,则将该键的值设置为原文。",
|
"- 对所有提供的 key 都必须返回译文;若无法翻译,使用原文。",
|
||||||
'示例:{"en":"Cat","zh-Hans":"猫"}',
|
'返回 JSON 示例:{"en":{"a.b":"Text"},"zh-Hans":{"a.b":"文本"}}',
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
const promptResult = [instruction, "\n用户文本:\n" + text].join("\n\n");
|
const promptResult = [
|
||||||
|
instruction,
|
||||||
|
"\n条目数组(含 key 与原文):\n" + itemsJson,
|
||||||
|
"\n仅对上述 keys 翻译并按 key 原样返回(keys 列表):\n" + keysJson,
|
||||||
|
].join("\n\n");
|
||||||
|
|
||||||
const response = await client.responses.create({
|
const response = await client.responses.create({
|
||||||
model: mdl,
|
model: mdl,
|
||||||
|
|
@ -74,16 +80,29 @@ export async function requestTranslations({
|
||||||
const result = obj as Record<string, unknown>;
|
const result = obj as Record<string, unknown>;
|
||||||
for (const lang of languages) {
|
for (const lang of languages) {
|
||||||
if (!Object.prototype.hasOwnProperty.call(result, lang)) {
|
if (!Object.prototype.hasOwnProperty.call(result, lang)) {
|
||||||
throw new Error("AI 返回的 key 不完整");
|
throw new Error("AI 返回的语言 key 不完整");
|
||||||
}
|
}
|
||||||
const v = result[lang];
|
const v = result[lang];
|
||||||
if (typeof v !== "string") {
|
if (!v || typeof v !== "object" || Array.isArray(v)) {
|
||||||
throw new Error("AI 返回的某些值类型不是字符串");
|
throw new Error("AI 返回的语言值应为对象");
|
||||||
|
}
|
||||||
|
const langMap = v as Record<string, unknown>;
|
||||||
|
for (const it of items) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(langMap, it.key)) {
|
||||||
|
throw new Error("AI 返回缺少某些条目的翻译");
|
||||||
|
}
|
||||||
|
if (typeof langMap[it.key] !== "string") {
|
||||||
|
throw new Error("AI 返回的某些值类型不是字符串");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return languages.reduce<Record<string, string>>((acc, l) => {
|
const out: Record<string, Record<string, string>> = {};
|
||||||
acc[l] = String((result as any)[l] ?? "");
|
for (const lang of languages) {
|
||||||
return acc;
|
const lm = result[lang] as Record<string, unknown>;
|
||||||
}, {});
|
const mapped: Record<string, string> = {};
|
||||||
|
for (const it of items) mapped[it.key] = String(lm[it.key] ?? "");
|
||||||
|
out[lang] = mapped;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import {
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useClipboard } from "@/hooks/use-clipboard";
|
import { useClipboard } from "@/hooks/use-clipboard";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
export default function Editor() {
|
export default function Editor() {
|
||||||
const { id: projectId } = useParams();
|
const { id: projectId } = useParams();
|
||||||
|
|
@ -54,7 +55,9 @@ export default function Editor() {
|
||||||
const [caseSensitive, setCaseSensitive] = useState(false);
|
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||||
const [fullMatch, setFullMatch] = useState(false);
|
const [fullMatch, setFullMatch] = useState(false);
|
||||||
const [aiModal, setAiModal] = useState<{ open: boolean; path: string } | null>(null);
|
const [aiModal, setAiModal] = useState<{ open: boolean; path: string } | null>(null);
|
||||||
|
const [aiBulkOpen, setAiBulkOpen] = useState(false);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const { copy } = useClipboard();
|
const { copy } = useClipboard();
|
||||||
|
|
||||||
|
|
@ -145,15 +148,30 @@ export default function Editor() {
|
||||||
TableFoot: (props: React.HTMLAttributes<HTMLTableSectionElement>) => <tfoot {...props} />,
|
TableFoot: (props: React.HTMLAttributes<HTMLTableSectionElement>) => <tfoot {...props} />,
|
||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
|
const allSelected = useMemo(() => entries.length > 0 && selected.size === entries.length, [entries, selected]);
|
||||||
|
const MAX_AI_ITEMS = 50;
|
||||||
|
|
||||||
const headerContent = useCallback(() => (
|
const headerContent = useCallback(() => (
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style={{ width: 36 }} className="px-3 py-2">
|
||||||
|
<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: 200 }} className="text-left px-3 py-2">翻译条目名称</th>
|
<th style={{ width: 200 }} className="text-left px-3 py-2">翻译条目名称</th>
|
||||||
{languages.map((lang) => (
|
{languages.map((lang) => (
|
||||||
<th key={lang} style={{ width: colWidth }} className="text-left px-3 py-2">{lang}</th>
|
<th key={lang} style={{ width: colWidth }} className="text-left px-3 py-2">{lang}</th>
|
||||||
))}
|
))}
|
||||||
<th style={{ width: 80 }} className="text-left px-3 py-2">操作</th>
|
<th style={{ width: 80 }} className="text-left px-3 py-2">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
), [languages, colWidth]);
|
), [languages, colWidth, allSelected, entries]);
|
||||||
|
|
||||||
const renderItemContent = useCallback((_idx: number, entry: FlatEntry) => {
|
const renderItemContent = useCallback((_idx: number, entry: FlatEntry) => {
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
|
|
@ -163,6 +181,18 @@ export default function Editor() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<td style={{ width: 36 }} className="px-3 py-2">
|
||||||
|
<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 style={{ width: 200 }} className="px-3 py-2 font-mono">
|
<td style={{ width: 200 }} className="px-3 py-2 font-mono">
|
||||||
<button type="button" className="w-full text-left min-h-8 leading-8 px-2 rounded hover:bg-accent" onClick={handleCopy} title="点击复制">
|
<button type="button" className="w-full text-left min-h-8 leading-8 px-2 rounded hover:bg-accent" onClick={handleCopy} title="点击复制">
|
||||||
{entry.path}
|
{entry.path}
|
||||||
|
|
@ -344,6 +374,55 @@ export default function Editor() {
|
||||||
定位
|
定位
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<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">
|
<div className="flex-1 border rounded-md">
|
||||||
<TableVirtuoso
|
<TableVirtuoso
|
||||||
ref={virtuosoRef}
|
ref={virtuosoRef}
|
||||||
|
|
@ -395,17 +474,18 @@ export default function Editor() {
|
||||||
open={!!aiModal?.open}
|
open={!!aiModal?.open}
|
||||||
onOpenChange={(v) => setAiModal((cur) => (cur ? { ...cur, open: v } : cur))}
|
onOpenChange={(v) => setAiModal((cur) => (cur ? { ...cur, open: v } : cur))}
|
||||||
languages={languages}
|
languages={languages}
|
||||||
path={aiModal?.path ?? ""}
|
paths={aiModal?.path ? [aiModal.path] : []}
|
||||||
prompt={project?.preferences?.aiPrompt}
|
prompt={project?.preferences?.aiPrompt}
|
||||||
onConfirm={async (translations) => {
|
onConfirm={async (translations) => {
|
||||||
if (!projectId || !aiModal) return;
|
if (!projectId || !aiModal) return;
|
||||||
const targetPath = aiModal.path;
|
|
||||||
const updates: Record<string, Record<string, string>> = {};
|
const updates: Record<string, Record<string, string>> = {};
|
||||||
try {
|
try {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
languages.map(async (lang) => {
|
languages.map(async (lang) => {
|
||||||
const prev = valuesByLang[lang] ?? {};
|
const prev = valuesByLang[lang] ?? {};
|
||||||
const next = { ...prev, [targetPath]: translations[lang] };
|
const next = { ...prev } as Record<string, string>;
|
||||||
|
const langMap = translations[lang] || {};
|
||||||
|
for (const [k, v] of Object.entries(langMap)) next[k] = v;
|
||||||
updates[lang] = next;
|
updates[lang] = next;
|
||||||
await upsertLanguageTranslations(projectId, lang, next);
|
await upsertLanguageTranslations(projectId, lang, next);
|
||||||
})
|
})
|
||||||
|
|
@ -419,6 +499,34 @@ export default function Editor() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AiTranslateModal
|
||||||
|
open={aiBulkOpen}
|
||||||
|
onOpenChange={setAiBulkOpen}
|
||||||
|
languages={languages}
|
||||||
|
paths={Array.from(selected)}
|
||||||
|
prompt={project?.preferences?.aiPrompt}
|
||||||
|
onConfirm={async (translations) => {
|
||||||
|
if (!projectId || selected.size === 0) return;
|
||||||
|
try {
|
||||||
|
const updatesByLang: Record<string, Record<string, string>> = {};
|
||||||
|
await Promise.all(
|
||||||
|
languages.map(async (lang) => {
|
||||||
|
const prev = valuesByLang[lang] ?? {};
|
||||||
|
const next = { ...prev } as Record<string, string>;
|
||||||
|
const langMap = translations[lang] || {};
|
||||||
|
for (const [k, v] of Object.entries(langMap)) next[k] = v;
|
||||||
|
updatesByLang[lang] = next;
|
||||||
|
await upsertLanguageTranslations(projectId, lang, next);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setValuesByLang((old) => ({ ...old, ...updatesByLang }));
|
||||||
|
} catch (e) {
|
||||||
|
setPageError((e as Error)?.message ?? "批量 AI 翻译保存失败");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<EntryNameModal
|
<EntryNameModal
|
||||||
open={!!addModal?.open}
|
open={!!addModal?.open}
|
||||||
onOpenChange={(v) => setAddModal((cur) => (cur ? { ...cur, open: v } : cur))}
|
onOpenChange={(v) => setAddModal((cur) => (cur ? { ...cur, open: v } : cur))}
|
||||||
|
|
@ -427,6 +535,8 @@ export default function Editor() {
|
||||||
onConfirm={async (name) => {
|
onConfirm={async (name) => {
|
||||||
if (!projectId || !structure || !addModal) return;
|
if (!projectId || !structure || !addModal) return;
|
||||||
const nextRoot = insertEntrySibling(structure.root, addModal.path, addModal.position, name);
|
const nextRoot = insertEntrySibling(structure.root, addModal.path, addModal.position, name);
|
||||||
|
console.log("nextRoot", addModal, nextRoot);
|
||||||
|
|
||||||
await upsertStructure({ projectId, root: nextRoot });
|
await upsertStructure({ projectId, root: nextRoot });
|
||||||
setStructure({ projectId, root: nextRoot });
|
setStructure({ projectId, root: nextRoot });
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue