Feat: 单次翻译质量优化提示

This commit is contained in:
奇趣保罗 2026-02-13 17:48:38 +08:00
parent 6cdc3699cf
commit 8335970e2e
3 changed files with 61 additions and 22 deletions

View File

@ -9,7 +9,6 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { toast } from "sonner";
import { ClipboardPaste } from "lucide-react"; import { ClipboardPaste } from "lucide-react";
type Props = { type Props = {
@ -34,7 +33,7 @@ function AddEntryModalImpl({ open, onOpenChange, position, onConfirm, validate,
setErr(null); setErr(null);
setSaving(false); setSaving(false);
setUseClipboard(false); setUseClipboard(false);
// 尝试读取剪贴板内容 // 尝试读取剪贴板内容
checkClipboard(); checkClipboard();
} }
@ -44,12 +43,12 @@ function AddEntryModalImpl({ open, onOpenChange, position, onConfirm, validate,
try { try {
const text = await navigator.clipboard.readText(); const text = await navigator.clipboard.readText();
const parsed = JSON.parse(text); const parsed = JSON.parse(text);
// 验证是否是有效的语言值对象 // 验证是否是有效的语言值对象
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
const keys = Object.keys(parsed); const keys = Object.keys(parsed);
const allStrings = keys.every(key => typeof parsed[key] === 'string'); const allStrings = keys.every(key => typeof parsed[key] === 'string');
if (allStrings && keys.length > 0) { if (allStrings && keys.length > 0) {
setClipboardValues(parsed); setClipboardValues(parsed);
setUseClipboard(true); setUseClipboard(true);
@ -59,7 +58,7 @@ function AddEntryModalImpl({ open, onOpenChange, position, onConfirm, validate,
} catch { } catch {
// 剪贴板内容不是有效的 JSON 或无法访问,忽略 // 剪贴板内容不是有效的 JSON 或无法访问,忽略
} }
setClipboardValues(null); setClipboardValues(null);
setUseClipboard(false); setUseClipboard(false);
} }
@ -73,7 +72,7 @@ function AddEntryModalImpl({ open, onOpenChange, position, onConfirm, validate,
const msg = validate(trimmed); const msg = validate(trimmed);
if (msg) return setErr(msg); if (msg) return setErr(msg);
} }
setSaving(true); setSaving(true);
try { try {
const values = useClipboard && clipboardValues ? clipboardValues : undefined; const values = useClipboard && clipboardValues ? clipboardValues : undefined;
@ -103,7 +102,7 @@ function AddEntryModalImpl({ open, onOpenChange, position, onConfirm, validate,
aria-label="名称" aria-label="名称"
/> />
</div> </div>
{clipboardValues && ( {clipboardValues && (
<div className="space-y-3 p-4 border rounded-md bg-muted/30"> <div className="space-y-3 p-4 border rounded-md bg-muted/30">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -120,7 +119,7 @@ function AddEntryModalImpl({ open, onOpenChange, position, onConfirm, validate,
使 使
</label> </label>
</div> </div>
{useClipboard && ( {useClipboard && (
<div className="space-y-2 pl-6"> <div className="space-y-2 pl-6">
<div className="text-xs text-muted-foreground"></div> <div className="text-xs text-muted-foreground"></div>
@ -156,9 +155,9 @@ function AddEntryModalImpl({ open, onOpenChange, position, onConfirm, validate,
)} )}
</div> </div>
)} )}
{err && <div className="text-sm text-red-600">{err}</div>} {err && <div className="text-sm text-red-600">{err}</div>}
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}> <Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>

View File

@ -1,6 +1,7 @@
import { memo, useEffect, 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 { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { import {
DropdownMenu, DropdownMenu,
@ -34,13 +35,26 @@ type Props = {
) => Promise<void> | void; ) => Promise<void> | void;
}; };
function AiTranslateModalImpl({ open, onOpenChange, languages, paths, prompt, model, initialSelectedLanguages, getExistingByPath, onConfirm }: Props) { function AiTranslateModalImpl({
open,
onOpenChange,
languages,
paths,
prompt,
model,
initialSelectedLanguages,
getExistingByPath,
onConfirm,
}: Props) {
const [inputsByKey, setInputsByKey] = useState<Record<string, string>>({}); 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 [selectedLangs, setSelectedLangs] = useState<string[]>([]); const [selectedLangs, setSelectedLangs] = useState<string[]>([]);
const [overwrite, setOverwrite] = useState(false); const [overwrite, setOverwrite] = useState(false);
const [existingCache, setExistingCache] = useState<Record<string, { lang: string; text: string }[]>>({}); const [contextHint, setContextHint] = useState("");
const [existingCache, setExistingCache] = useState<
Record<string, { lang: string; text: string }[]>
>({});
const MAX_ITEMS = 50; const MAX_ITEMS = 50;
const overLimit = paths.length > MAX_ITEMS; const overLimit = paths.length > MAX_ITEMS;
@ -51,12 +65,14 @@ function AiTranslateModalImpl({ open, onOpenChange, languages, paths, prompt, mo
setInputsByKey(init); setInputsByKey(init);
// 初始化默认勾选语言 // 初始化默认勾选语言
const initSelected = (initialSelectedLanguages && initialSelectedLanguages.length > 0) const initSelected =
? initialSelectedLanguages initialSelectedLanguages && initialSelectedLanguages.length > 0
: languages; ? initialSelectedLanguages
: languages;
setSelectedLangs(initSelected); setSelectedLangs(initSelected);
setOverwrite(false); setOverwrite(false);
setContextHint("");
setExistingCache({}); setExistingCache({});
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -77,7 +93,10 @@ function AiTranslateModalImpl({ open, onOpenChange, languages, paths, prompt, mo
setError(`一次最多支持 ${MAX_ITEMS}`); setError(`一次最多支持 ${MAX_ITEMS}`);
return; return;
} }
const items = paths.map((key) => ({ key, text: (inputsByKey[key] ?? "").trim() })); const items = paths.map((key) => ({
key,
text: (inputsByKey[key] ?? "").trim(),
}));
if (items.some((it) => !it.text)) { if (items.some((it) => !it.text)) {
setError("请为所有条目输入原文"); setError("请为所有条目输入原文");
return; return;
@ -92,9 +111,18 @@ function AiTranslateModalImpl({ open, onOpenChange, languages, paths, prompt, mo
} }
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const result = await requestTranslations({ items, languages: selectedLangs, prompt, model }); const result = await requestTranslations({
items,
languages: selectedLangs,
prompt,
model,
contextHint: contextHint.trim() || undefined,
});
await onConfirm(result, { overwrite, selectedLanguages: selectedLangs }); await onConfirm(result, { overwrite, selectedLanguages: selectedLangs });
setInputsByKey({}); setInputsByKey({});
onOpenChange(false); onOpenChange(false);
} catch (e) { } catch (e) {
@ -158,9 +186,7 @@ function AiTranslateModalImpl({ open, onOpenChange, languages, paths, prompt, mo
) : ( ) : (
(existingCache[p] || []).map((opt) => ( (existingCache[p] || []).map((opt) => (
<> <>
<DropdownMenuLabel> <DropdownMenuLabel>{opt.lang}</DropdownMenuLabel>
{opt.lang}
</DropdownMenuLabel>
<DropdownMenuItem <DropdownMenuItem
key={`${p}:${opt.lang}`} key={`${p}:${opt.lang}`}
onClick={() => onClick={() =>
@ -220,6 +246,19 @@ function AiTranslateModalImpl({ open, onOpenChange, languages, paths, prompt, mo
<span className="text-sm"></span> <span className="text-sm"></span>
</label> </label>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">
</div>
<Textarea
value={contextHint}
onChange={(e) => setContextHint(e.target.value)}
placeholder="例如:这是一个电商 App 的结算页面,「立即购买」希望翻译得更有行动号召力"
rows={2}
className="resize-none text-sm"
/>
</div>
{(error || overLimit) && ( {(error || overLimit) && (
<div className="text-sm text-red-600" role="alert"> <div className="text-sm text-red-600" role="alert">
{error} {error}
@ -249,5 +288,3 @@ function AiTranslateModalImpl({ open, onOpenChange, languages, paths, prompt, mo
} }
export const AiTranslateModal = memo(AiTranslateModalImpl); export const AiTranslateModal = memo(AiTranslateModalImpl);

View File

@ -5,6 +5,7 @@ export type AiTranslateParams = {
languages: string[]; languages: string[];
model?: string; model?: string;
prompt?: string; prompt?: string;
contextHint?: string;
}; };
function getClient() { function getClient() {
@ -22,6 +23,7 @@ export async function requestTranslations({
languages, languages,
model, model,
prompt, prompt,
contextHint,
}: AiTranslateParams): Promise<Record<string, Record<string, string>>> { }: AiTranslateParams): Promise<Record<string, Record<string, string>>> {
const client = getClient(); const client = getClient();
const mdl = const mdl =
@ -38,6 +40,7 @@ export async function requestTranslations({
`请使用当地人的习惯用语,将多条原文同时翻译为这些目标语言:${targetList},注意英语的首字母务必是大写。`, `请使用当地人的习惯用语,将多条原文同时翻译为这些目标语言:${targetList},注意英语的首字母务必是大写。`,
"翻译偏好:", "翻译偏好:",
prompt, prompt,
...(contextHint ? ["用户补充的语境与翻译期望:", contextHint] : []),
"严格要求:", "严格要求:",
"- 仅返回一个 JSON 对象,不包含任何其他内容(例如说明、代码块标记、注释)。", "- 仅返回一个 JSON 对象,不包含任何其他内容(例如说明、代码块标记、注释)。",
"- JSON 的顶层键必须严格等于目标语言代码;其值必须是一个对象,键为条目 key值为该 key 的翻译纯文本。", "- JSON 的顶层键必须严格等于目标语言代码;其值必须是一个对象,键为条目 key值为该 key 的翻译纯文本。",