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
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
|
"prod": "tsc -b && vite build --base=/translate-it/",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
|
|
@ -17,6 +18,7 @@
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.552.0",
|
"lucide-react": "^0.552.0",
|
||||||
|
"openai": "^6.7.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-router": "^7.9.5",
|
"react-router": "^7.9.5",
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ importers:
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.552.0
|
specifier: ^0.552.0
|
||||||
version: 0.552.0(react@19.2.0)
|
version: 0.552.0(react@19.2.0)
|
||||||
|
openai:
|
||||||
|
specifier: ^6.7.0
|
||||||
|
version: 6.7.0
|
||||||
react:
|
react:
|
||||||
specifier: ^19.1.1
|
specifier: ^19.1.1
|
||||||
version: 19.2.0
|
version: 19.2.0
|
||||||
|
|
@ -1450,6 +1453,18 @@ packages:
|
||||||
node-releases@2.0.27:
|
node-releases@2.0.27:
|
||||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
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:
|
optionator@0.9.4:
|
||||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
@ -2995,6 +3010,8 @@ snapshots:
|
||||||
|
|
||||||
node-releases@2.0.27: {}
|
node-releases@2.0.27: {}
|
||||||
|
|
||||||
|
openai@6.7.0: {}
|
||||||
|
|
||||||
optionator@0.9.4:
|
optionator@0.9.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
deep-is: 0.1.4
|
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 { memo, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -8,6 +9,7 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { unflattenValues } from "@/lib/i18n-structure";
|
import { unflattenValues } from "@/lib/i18n-structure";
|
||||||
|
import { useClipboard } from "@/hooks/use-clipboard";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -25,6 +27,7 @@ function ExportLanguageModalImpl({
|
||||||
orderedPaths,
|
orderedPaths,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [selected, setSelected] = useState<string>("");
|
const [selected, setSelected] = useState<string>("");
|
||||||
|
const { copied, copy } = useClipboard({ resetAfterMs: 1500 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
|
@ -102,6 +105,12 @@ function ExportLanguageModalImpl({
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
if (!jsonText) return;
|
||||||
|
|
||||||
|
copy(jsonText);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-3xl">
|
<DialogContent className="max-w-3xl">
|
||||||
|
|
@ -141,6 +150,9 @@ function ExportLanguageModalImpl({
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
关闭
|
关闭
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button onClick={handleCopy} variant="outline" disabled={!selected || !jsonText}>
|
||||||
|
{copied ? (<><Check /> 已复制</>) : "复制 JSON"}
|
||||||
|
</Button>
|
||||||
<Button onClick={handleDownload} disabled={!selected || !jsonText}>
|
<Button onClick={handleDownload} disabled={!selected || !jsonText}>
|
||||||
导出 JSON
|
导出 JSON
|
||||||
</Button>
|
</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 { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { createBrowserRouter } from "react-router";
|
import { createHashRouter } from "react-router";
|
||||||
import { RouterProvider } from "react-router/dom";
|
import { RouterProvider } from "react-router/dom";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import Home from "./pages/home.tsx";
|
import Home from "./pages/home.tsx";
|
||||||
import Editor from "./pages/editor.tsx";
|
import Editor from "./pages/editor.tsx";
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createHashRouter([
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
element: <Home />,
|
element: <Home />,
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,18 @@ import {
|
||||||
type Project,
|
type Project,
|
||||||
type ProjectStructure,
|
type ProjectStructure,
|
||||||
getLanguageTranslations,
|
getLanguageTranslations,
|
||||||
|
upsertLanguageTranslations,
|
||||||
upsertStructure,
|
upsertStructure,
|
||||||
deleteEntryFromAllLanguages,
|
deleteEntryFromAllLanguages,
|
||||||
renameEntryInAllLanguages,
|
renameEntryInAllLanguages,
|
||||||
} from "@/lib/db";
|
} 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 { useTranslationInlineEdit } from "@/hooks/biz/use-translation-inline-edit";
|
||||||
import { flattenEntries, type FlatEntry, insertEntrySibling, removeEntryAtPath, renameEntryAtPath } from "@/lib/i18n-structure";
|
import { flattenEntries, type FlatEntry, insertEntrySibling, removeEntryAtPath, renameEntryAtPath } from "@/lib/i18n-structure";
|
||||||
import { ImportLanguageModal } from "@/components/biz/import-language-modal";
|
import { ImportLanguageModal } from "@/components/biz/import-language-modal";
|
||||||
import { ExportLanguageModal } from "@/components/biz/export-language-modal";
|
import { ExportLanguageModal } from "@/components/biz/export-language-modal";
|
||||||
import { EntryNameModal } from "@/components/biz/entry-name-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 { TableVirtuoso, type TableVirtuosoHandle } from "react-virtuoso";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|
@ -45,6 +47,7 @@ export default function Editor() {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
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);
|
||||||
|
|
||||||
function highlightRow(index: number) {
|
function highlightRow(index: number) {
|
||||||
const tryFindAndAnimate = (attempt = 0) => {
|
const tryFindAndAnimate = (attempt = 0) => {
|
||||||
|
|
@ -71,7 +74,10 @@ export default function Editor() {
|
||||||
requestAnimationFrame(() => tryFindAndAnimate(0));
|
requestAnimationFrame(() => tryFindAndAnimate(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToQuery() {
|
function scrollToQuery(ev: React.FormEvent<HTMLFormElement>) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
if (!query) return;
|
if (!query) return;
|
||||||
const idx = entries.findIndex((e) => {
|
const idx = entries.findIndex((e) => {
|
||||||
const hay = caseSensitive ? e.path : e.path.toLowerCase();
|
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">
|
<td style={{ width: "5em" }} className="px-3 py-2 align-top">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button size="sm" variant="outline">操作</Button>
|
<Button size="sm" variant="outline">
|
||||||
|
操作
|
||||||
|
<MoreVertical />
|
||||||
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-44">
|
<DropdownMenuContent align="end" className="w-44">
|
||||||
<DropdownMenuItem onClick={() => setAddModal({ open: true, path: entry.path, position: "below" })}>
|
<DropdownMenuItem onClick={() => setAddModal({ open: true, path: entry.path, position: "below" })}>
|
||||||
|
<ArrowBigDownDash />
|
||||||
在下面新增
|
在下面新增
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setAddModal({ open: true, path: entry.path, position: "above" })}>
|
<DropdownMenuItem onClick={() => setAddModal({ open: true, path: entry.path, position: "above" })}>
|
||||||
|
<ArrowBigUpDash />
|
||||||
在上面新增
|
在上面新增
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => setAiModal({ open: true, path: entry.path })}>
|
||||||
|
<Languages />
|
||||||
|
AI 翻译
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => setRenameModal({ open: true, path: entry.path })}>
|
||||||
|
<PencilLine />
|
||||||
|
重命名
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!projectId || !structure) return;
|
if (!projectId || !structure) return;
|
||||||
if (!confirm("确认删除该条目?此操作会移除所有语言下的该键")) return;
|
if (!confirm("确认删除该条目?此操作会移除所有语言下的该键")) return;
|
||||||
|
|
@ -209,11 +230,9 @@ export default function Editor() {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Trash2 />
|
||||||
删除
|
删除
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setRenameModal({ open: true, path: entry.path })}>
|
|
||||||
重命名
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -282,13 +301,14 @@ export default function Editor() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<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)} />
|
<Input placeholder="搜索翻译条目名称" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||||
<Button
|
<Button
|
||||||
variant={fullMatch ? "default" : "outline"}
|
variant={fullMatch ? "default" : "outline"}
|
||||||
onClick={() => setFullMatch((v) => !v)}
|
onClick={() => setFullMatch((v) => !v)}
|
||||||
title="切换全量匹配/模糊匹配"
|
title="切换全量匹配/模糊匹配"
|
||||||
>
|
>
|
||||||
|
<Brackets />
|
||||||
{fullMatch ? "全量匹配" : "模糊匹配"}
|
{fullMatch ? "全量匹配" : "模糊匹配"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -296,10 +316,14 @@ export default function Editor() {
|
||||||
onClick={() => setCaseSensitive((v) => !v)}
|
onClick={() => setCaseSensitive((v) => !v)}
|
||||||
title="切换大小写敏感"
|
title="切换大小写敏感"
|
||||||
>
|
>
|
||||||
|
<CaseSensitive />
|
||||||
{caseSensitive ? "区分大小写" : "忽略大小写"}
|
{caseSensitive ? "区分大小写" : "忽略大小写"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={scrollToQuery}>定位</Button>
|
<Button variant="outline" type="submit">
|
||||||
</div>
|
<LocateFixed />
|
||||||
|
定位
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
<div className="border rounded-md">
|
<div className="border rounded-md">
|
||||||
<TableVirtuoso
|
<TableVirtuoso
|
||||||
ref={virtuosoRef}
|
ref={virtuosoRef}
|
||||||
|
|
@ -331,6 +355,33 @@ export default function Editor() {
|
||||||
orderedPaths={entries.map((e) => e.path)}
|
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
|
<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))}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue