Feat: 新增 AI 翻译功能,导出 JSON 复制到剪贴板
This commit is contained in:
parent
e1a5be8722
commit
f0c9dbfd36
|
|
@ -0,0 +1,2 @@
|
|||
VITE_OPENAI_BASE_URL="https://openrouter.ai/api/v1"
|
||||
VITE_OPENAI_API_KEY="sk-or-v1-xxxxx"
|
||||
|
|
@ -22,3 +22,6 @@ dist-ssr
|
|||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.env
|
||||
.env.production
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"prod": "tsc -b && vite build --base=/translate-it/",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
|
|
@ -17,6 +18,7 @@
|
|||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.552.0",
|
||||
"openai": "^6.7.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router": "^7.9.5",
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ importers:
|
|||
lucide-react:
|
||||
specifier: ^0.552.0
|
||||
version: 0.552.0(react@19.2.0)
|
||||
openai:
|
||||
specifier: ^6.7.0
|
||||
version: 6.7.0
|
||||
react:
|
||||
specifier: ^19.1.1
|
||||
version: 19.2.0
|
||||
|
|
@ -1450,6 +1453,18 @@ packages:
|
|||
node-releases@2.0.27:
|
||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||
|
||||
openai@6.7.0:
|
||||
resolution: {integrity: sha512-mgSQXa3O/UXTbA8qFzoa7aydbXBJR5dbLQXCRapAOtoNT+v69sLdKMZzgiakpqhclRnhPggPAXoniVGn2kMY2A==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
ws: ^8.18.0
|
||||
zod: ^3.25 || ^4.0
|
||||
peerDependenciesMeta:
|
||||
ws:
|
||||
optional: true
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
|
@ -2995,6 +3010,8 @@ snapshots:
|
|||
|
||||
node-releases@2.0.27: {}
|
||||
|
||||
openai@6.7.0: {}
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
import { memo, 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";
|
||||
import { requestTranslations } from "@/lib/ai";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (next: boolean) => void;
|
||||
languages: string[];
|
||||
path: string;
|
||||
onConfirm: (result: Record<string, string>) => Promise<void> | void;
|
||||
};
|
||||
|
||||
function AiTranslateModalImpl({ open, onOpenChange, languages, path, onConfirm }: Props) {
|
||||
const [text, setText] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const payload = text.trim();
|
||||
if (!payload) {
|
||||
setError("请输入待翻译文本");
|
||||
return;
|
||||
}
|
||||
if (languages.length === 0) {
|
||||
setError("当前项目暂无目标语言");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await requestTranslations({ text: payload, languages });
|
||||
// 二次校验 keys 完整性
|
||||
for (const l of languages) {
|
||||
if (!Object.prototype.hasOwnProperty.call(result, l)) {
|
||||
throw new Error("AI 返回的 key 不完整");
|
||||
}
|
||||
}
|
||||
await onConfirm(result);
|
||||
setText("");
|
||||
onOpenChange(false);
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "AI 翻译失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (!loading) {
|
||||
onOpenChange(v);
|
||||
if (!v) setError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`AI 翻译 — ${path || "条目"}`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm text-muted-foreground">待翻译文本</label>
|
||||
<Input
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="请输入原文本"
|
||||
aria-label="待翻译文本"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
目标语言:{languages.join(", ") || "无"}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-sm text-red-600" role="alert">{error}</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "翻译中..." : "生成翻译"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export const AiTranslateModal = memo(AiTranslateModalImpl);
|
||||
|
||||
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { memo, useEffect, useMemo, useState } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -8,6 +9,7 @@ import {
|
|||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { unflattenValues } from "@/lib/i18n-structure";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
|
|
@ -25,6 +27,7 @@ function ExportLanguageModalImpl({
|
|||
orderedPaths,
|
||||
}: Props) {
|
||||
const [selected, setSelected] = useState<string>("");
|
||||
const { copied, copy } = useClipboard({ resetAfterMs: 1500 });
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
|
|
@ -102,6 +105,12 @@ function ExportLanguageModalImpl({
|
|||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!jsonText) return;
|
||||
|
||||
copy(jsonText);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
|
|
@ -141,6 +150,9 @@ function ExportLanguageModalImpl({
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
type UseClipboardOptions = {
|
||||
resetAfterMs?: number;
|
||||
};
|
||||
|
||||
export function useClipboard(options?: UseClipboardOptions) {
|
||||
const resetAfterMs = options?.resetAfterMs ?? 1500;
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current != null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const copy = useCallback(async (text: string): Promise<boolean> => {
|
||||
try {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "-1000px";
|
||||
textarea.style.left = "-1000px";
|
||||
textarea.setAttribute("readonly", "");
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
if (!ok) return false;
|
||||
}
|
||||
setCopied(true);
|
||||
if (timerRef.current != null) window.clearTimeout(timerRef.current);
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
setCopied(false);
|
||||
timerRef.current = null;
|
||||
}, resetAfterMs);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [resetAfterMs]);
|
||||
|
||||
return { copied, copy } as const;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import OpenAI from "openai";
|
||||
|
||||
export type AiTranslateParams = {
|
||||
text: string;
|
||||
languages: string[];
|
||||
model?: string;
|
||||
};
|
||||
|
||||
function getClient() {
|
||||
const apiKey = import.meta.env?.VITE_OPENAI_API_KEY as string | undefined;
|
||||
const baseURL = import.meta.env?.VITE_OPENAI_BASE_URL as string | undefined;
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error("缺少环境变量 VITE_OPENAI_API_KEY");
|
||||
}
|
||||
return new OpenAI({ apiKey, dangerouslyAllowBrowser: true, baseURL });
|
||||
}
|
||||
|
||||
export async function requestTranslations({
|
||||
text,
|
||||
languages,
|
||||
model,
|
||||
}: AiTranslateParams): Promise<Record<string, string>> {
|
||||
const client = getClient();
|
||||
const mdl =
|
||||
model ||
|
||||
(import.meta.env?.VITE_OPENAI_MODEL as string) ||
|
||||
// "gpt-4o-mini";
|
||||
"openai/gpt-5";
|
||||
|
||||
const targetList = JSON.stringify(languages);
|
||||
const instruction = [
|
||||
"你是一个专业的翻译助手。",
|
||||
`请将用户输入的文本翻译为这些目标语言:${targetList},注意英语的首字母务必是大写。`,
|
||||
"严格要求:",
|
||||
"- 仅返回一个 JSON 对象,不包含任何其他内容(例如说明、代码块标记、注释)。",
|
||||
"- JSON 的键必须严格等于目标语言代码,值为对应翻译的纯文本字符串。",
|
||||
"- 如果某一语言确实无法翻译,则将该键的值设置为原文。",
|
||||
'示例:{"en":"Cat","zh-Hans":"猫"}',
|
||||
].join("\n");
|
||||
|
||||
const prompt = [instruction, "\n用户文本:\n" + text].join("\n\n");
|
||||
|
||||
const response = await client.responses.create({
|
||||
model: mdl,
|
||||
input: prompt,
|
||||
response_format: { type: "json_object" },
|
||||
} as any);
|
||||
|
||||
const raw =
|
||||
(response as any).output_text ??
|
||||
(response as any).content?.[0]?.text ??
|
||||
(response as any).choices?.[0]?.message?.content ??
|
||||
"";
|
||||
|
||||
if (typeof raw !== "string" || raw.trim() === "") {
|
||||
throw new Error("AI 返回为空");
|
||||
}
|
||||
|
||||
let obj: unknown;
|
||||
try {
|
||||
obj = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error("AI 返回的 JSON 解析失败");
|
||||
}
|
||||
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
||||
throw new Error("AI 返回的 JSON 结构非法");
|
||||
}
|
||||
|
||||
const result = obj as Record<string, unknown>;
|
||||
for (const lang of languages) {
|
||||
if (!Object.prototype.hasOwnProperty.call(result, lang)) {
|
||||
throw new Error("AI 返回的 key 不完整");
|
||||
}
|
||||
const v = result[lang];
|
||||
if (typeof v !== "string") {
|
||||
throw new Error("AI 返回的某些值类型不是字符串");
|
||||
}
|
||||
}
|
||||
|
||||
return languages.reduce<Record<string, string>>((acc, l) => {
|
||||
acc[l] = String((result as any)[l] ?? "");
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { createBrowserRouter } from "react-router";
|
||||
import { createHashRouter } from "react-router";
|
||||
import { RouterProvider } from "react-router/dom";
|
||||
import "./index.css";
|
||||
import Home from "./pages/home.tsx";
|
||||
import Editor from "./pages/editor.tsx";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
const router = createHashRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <Home />,
|
||||
|
|
|
|||
|
|
@ -10,16 +10,18 @@ import {
|
|||
type Project,
|
||||
type ProjectStructure,
|
||||
getLanguageTranslations,
|
||||
upsertLanguageTranslations,
|
||||
upsertStructure,
|
||||
deleteEntryFromAllLanguages,
|
||||
renameEntryInAllLanguages,
|
||||
} from "@/lib/db";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { ArrowBigDownDash, ArrowBigUpDash, ArrowLeft, Brackets, CaseSensitive, Languages, LocateFixed, MoreVertical, PencilLine, Trash2 } from "lucide-react";
|
||||
import { useTranslationInlineEdit } from "@/hooks/biz/use-translation-inline-edit";
|
||||
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 { AiTranslateModal } from "@/components/biz/ai-translate-modal";
|
||||
import { TableVirtuoso, type TableVirtuosoHandle } from "react-virtuoso";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -45,6 +47,7 @@ export default function Editor() {
|
|||
const [query, setQuery] = useState("");
|
||||
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||
const [fullMatch, setFullMatch] = useState(false);
|
||||
const [aiModal, setAiModal] = useState<{ open: boolean; path: string } | null>(null);
|
||||
|
||||
function highlightRow(index: number) {
|
||||
const tryFindAndAnimate = (attempt = 0) => {
|
||||
|
|
@ -71,7 +74,10 @@ export default function Editor() {
|
|||
requestAnimationFrame(() => tryFindAndAnimate(0));
|
||||
}
|
||||
|
||||
function scrollToQuery() {
|
||||
function scrollToQuery(ev: React.FormEvent<HTMLFormElement>) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (!query) return;
|
||||
const idx = entries.findIndex((e) => {
|
||||
const hay = caseSensitive ? e.path : e.path.toLowerCase();
|
||||
|
|
@ -176,17 +182,32 @@ export default function Editor() {
|
|||
<td style={{ width: "5em" }} className="px-3 py-2 align-top">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="outline">操作</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
操作
|
||||
<MoreVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem onClick={() => setAddModal({ open: true, path: entry.path, position: "below" })}>
|
||||
<ArrowBigDownDash />
|
||||
在下面新增
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setAddModal({ open: true, path: entry.path, position: "above" })}>
|
||||
<ArrowBigUpDash />
|
||||
在上面新增
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setAiModal({ open: true, path: entry.path })}>
|
||||
<Languages />
|
||||
AI 翻译
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setRenameModal({ open: true, path: entry.path })}>
|
||||
<PencilLine />
|
||||
重命名
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
if (!projectId || !structure) return;
|
||||
if (!confirm("确认删除该条目?此操作会移除所有语言下的该键")) return;
|
||||
|
|
@ -209,11 +230,9 @@ export default function Editor() {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setRenameModal({ open: true, path: entry.path })}>
|
||||
重命名
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</td>
|
||||
|
|
@ -282,13 +301,14 @@ export default function Editor() {
|
|||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<form className="mb-3 flex items-center gap-2" onSubmit={scrollToQuery}>
|
||||
<Input placeholder="搜索翻译条目名称" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||
<Button
|
||||
variant={fullMatch ? "default" : "outline"}
|
||||
onClick={() => setFullMatch((v) => !v)}
|
||||
title="切换全量匹配/模糊匹配"
|
||||
>
|
||||
<Brackets />
|
||||
{fullMatch ? "全量匹配" : "模糊匹配"}
|
||||
</Button>
|
||||
<Button
|
||||
|
|
@ -296,10 +316,14 @@ export default function Editor() {
|
|||
onClick={() => setCaseSensitive((v) => !v)}
|
||||
title="切换大小写敏感"
|
||||
>
|
||||
<CaseSensitive />
|
||||
{caseSensitive ? "区分大小写" : "忽略大小写"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={scrollToQuery}>定位</Button>
|
||||
</div>
|
||||
<Button variant="outline" type="submit">
|
||||
<LocateFixed />
|
||||
定位
|
||||
</Button>
|
||||
</form>
|
||||
<div className="border rounded-md">
|
||||
<TableVirtuoso
|
||||
ref={virtuosoRef}
|
||||
|
|
@ -331,6 +355,33 @@ export default function Editor() {
|
|||
orderedPaths={entries.map((e) => e.path)}
|
||||
/>
|
||||
|
||||
<AiTranslateModal
|
||||
open={!!aiModal?.open}
|
||||
onOpenChange={(v) => setAiModal((cur) => (cur ? { ...cur, open: v } : cur))}
|
||||
languages={languages}
|
||||
path={aiModal?.path ?? ""}
|
||||
onConfirm={async (translations) => {
|
||||
if (!projectId || !aiModal) return;
|
||||
const targetPath = aiModal.path;
|
||||
const updates: Record<string, Record<string, string>> = {};
|
||||
try {
|
||||
await Promise.all(
|
||||
languages.map(async (lang) => {
|
||||
const prev = valuesByLang[lang] ?? {};
|
||||
const next = { ...prev, [targetPath]: translations[lang] };
|
||||
updates[lang] = next;
|
||||
await upsertLanguageTranslations(projectId, lang, next);
|
||||
})
|
||||
);
|
||||
setValuesByLang((old) => ({ ...old, ...updates }));
|
||||
} catch (e) {
|
||||
const msg = (e as Error)?.message ?? "保存翻译失败";
|
||||
setPageError(msg);
|
||||
throw e;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<EntryNameModal
|
||||
open={!!addModal?.open}
|
||||
onOpenChange={(v) => setAddModal((cur) => (cur ? { ...cur, open: v } : cur))}
|
||||
|
|
|
|||
Loading…
Reference in New Issue