Feat: 新增 AI 翻译功能,导出 JSON 复制到剪贴板

This commit is contained in:
奇趣保罗 2025-11-04 17:50:57 +08:00
parent e1a5be8722
commit f0c9dbfd36
10 changed files with 348 additions and 11 deletions

2
.env.default Normal file
View File

@ -0,0 +1,2 @@
VITE_OPENAI_BASE_URL="https://openrouter.ai/api/v1"
VITE_OPENAI_API_KEY="sk-or-v1-xxxxx"

3
.gitignore vendored
View File

@ -22,3 +22,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.env
.env.production

View File

@ -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",

View File

@ -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

View File

@ -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);

View File

@ -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>

View File

@ -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;
}

85
src/lib/ai.ts Normal file
View File

@ -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;
}, {});
}

View File

@ -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 />,

View File

@ -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))}