
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" />
<meta name="color-scheme" content="dark" />
<title>AI 翻译助手</title>
<style>
:root{
--txt:#eaf2ff;
--muted:rgba(234,242,255,.66);
--muted2:rgba(234,242,255,.43);
--line:rgba(160,200,255,.18);
--accent:#82b7ff;
--good:#39d98a;
--warn:#ffb347;
--bad:#ff6b6b;
--panel:rgba(255,255,255,.045);
--panel2:rgba(255,255,255,.075);
--shadow:0 14px 34px rgba(0,0,0,.34), inset 0 1px 0 rgba(255,255,255,.05);
}
html,body{
margin:0;
width:100%;
height:100%;
overflow:hidden;
background:transparent;
font-family:"Microsoft YaHei", sans-serif;
-webkit-font-smoothing:antialiased;
text-rendering:optimizeLegibility;
}
*{box-sizing:border-box}
button,input,textarea,select{font:inherit}
.card{
width:100%;
height:100%;
position:relative;
overflow:hidden;
border-radius:18px;
border:1px solid var(--line);
background:
radial-gradient(120% 100% at 0% 0%, rgba(130,183,255,.16), transparent 58%),
radial-gradient(90% 120% at 100% 0%, rgba(167,139,250,.12), transparent 50%),
linear-gradient(160deg, rgba(15,25,39,.98) 0%, rgba(9,16,27,.98) 100%);
box-shadow:var(--shadow);
color:var(--txt);
padding:12px;
display:flex;
flex-direction:column;
gap:9px;
}
.card::before{
content:"";
position:absolute;
inset:0;
border-radius:18px;
padding:1px;
background:linear-gradient(180deg, rgba(255,255,255,.13), rgba(255,255,255,0) 28%, rgba(255,255,255,0) 80%, rgba(255,255,255,.06));
-webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite:xor;
mask-composite:exclude;
pointer-events:none;
opacity:.85;
}
.top{
position:relative;
z-index:1;
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
min-height:30px;
}
.title{
min-width:0;
display:flex;
flex-direction:column;
gap:2px;
}
.title strong{
font-size:15px;
letter-spacing:.2px;
line-height:1.1;
white-space:nowrap;
}
.title span{
font-size:10px;
color:var(--muted2);
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
max-width:260px;
}
.top-actions{
display:flex;
align-items:center;
gap:7px;
flex:0 0 auto;
}
.btn{
height:28px;
padding:0 10px;
border-radius:10px;
color:var(--txt);
background:linear-gradient(180deg, rgba(130,183,255,.18), rgba(130,183,255,.09));
border:1px solid rgba(130,183,255,.24);
box-shadow:inset 0 1px 0 rgba(255,255,255,.08);
cursor:pointer;
font-size:12px;
font-weight:700;
transition:transform .12s ease, opacity .12s ease, background .12s ease;
white-space:nowrap;
}
.btn:hover{
transform:translateY(-1px);
background:linear-gradient(180deg, rgba(130,183,255,.25), rgba(130,183,255,.12));
}
.btn:active{transform:translateY(0);opacity:.9}
.btn[disabled]{opacity:.55;cursor:not-allowed;transform:none}
.btn.secondary{
color:var(--muted);
background:rgba(255,255,255,.045);
border-color:rgba(255,255,255,.10);
}
.btn.icon{
width:30px;
padding:0;
font-size:15px;
}
.settings{
position:relative;
z-index:1;
display:none;
grid-template-columns:1fr 1fr;
gap:7px;
padding:8px;
border-radius:14px;
background:rgba(0,0,0,.16);
border:1px solid rgba(255,255,255,.07);
}
.settings.show{display:grid}
.field{
min-width:0;
display:flex;
flex-direction:column;
gap:4px;
}
.field.full{grid-column:1 / -1}
.label{
font-size:10px;
color:var(--muted2);
letter-spacing:.2px;
}
.input,.select,.textarea{
width:100%;
min-width:0;
border:1px solid rgba(160,200,255,.14);
background:rgba(3,8,15,.54);
color:var(--txt);
outline:none;
border-radius:10px;
box-shadow:inset 0 1px 0 rgba(255,255,255,.04);
}
.input,.select{
height:30px;
padding:0 9px;
font-size:12px;
}
.select option{
background:#111b2a;
color:#eaf2ff;
}
.textarea{
resize:none;
padding:9px 10px;
line-height:1.5;
font-size:12px;
}
.input:focus,.select:focus,.textarea:focus{
border-color:rgba(130,183,255,.42);
box-shadow:0 0 0 2px rgba(130,183,255,.10), inset 0 1px 0 rgba(255,255,255,.05);
}
.main{
position:relative;
z-index:1;
flex:1 1 auto;
min-height:0;
display:grid;
grid-template-columns:1fr 1fr;
gap:9px;
}
.pane{
min-width:0;
min-height:0;
display:flex;
flex-direction:column;
gap:6px;
}
.pane-head{
height:24px;
display:flex;
align-items:center;
justify-content:space-between;
gap:8px;
color:var(--muted);
font-size:11px;
}
.mini-actions{
display:flex;
gap:5px;
align-items:center;
}
.mini{
height:22px;
padding:0 7px;
border-radius:8px;
border:1px solid rgba(255,255,255,.08);
background:rgba(255,255,255,.04);
color:var(--muted);
cursor:pointer;
font-size:10px;
}
.mini:hover{
color:var(--txt);
background:rgba(255,255,255,.07);
}
.text-box{
flex:1 1 auto;
min-height:0;
position:relative;
}
.text-box textarea{
height:100%;
}
.output{
width:100%;
height:100%;
overflow:auto;
border:1px solid rgba(160,200,255,.14);
background:rgba(3,8,15,.50);
color:var(--txt);
border-radius:12px;
padding:10px;
font-size:12px;
line-height:1.55;
white-space:pre-wrap;
word-break:break-word;
}
.output.placeholder{
color:var(--muted2);
}
.bottom{
position:relative;
z-index:1;
min-height:26px;
display:flex;
align-items:center;
justify-content:space-between;
gap:8px;
}
.status{
min-width:0;
color:var(--muted2);
font-size:10px;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
}
.status.good{color:rgba(57,217,138,.85)}
.status.warn{color:rgba(255,179,71,.9)}
.status.bad{color:rgba(255,107,107,.9)}
.loader{
display:inline-flex;
align-items:center;
gap:7px;
}
.spinner{
width:12px;
height:12px;
border-radius:50%;
border:2px solid rgba(255,255,255,.15);
border-top-color:rgba(130,183,255,.95);
animation:spin .85s linear infinite;
}
@keyframes spin{to{transform:rotate(360deg)}}
.compact .settings{
grid-template-columns:1fr;
}
@media (max-width:420px){
.main{grid-template-columns:1fr}
.settings{grid-template-columns:1fr}
.title span{max-width:180px}
}
::-webkit-scrollbar{width:6px;height:6px}
::-webkit-scrollbar-track{background:rgba(255,255,255,.035);border-radius:999px}
::-webkit-scrollbar-thumb{background:rgba(130,183,255,.25);border-radius:999px}
::-webkit-scrollbar-thumb:hover{background:rgba(130,183,255,.38)}
</style>
</head>
<body>
<div class="card">
<div class="top">
<div class="title">
<strong>AI 翻译</strong>
<span id="subTitle">OpenAI 兼容接口 · 自动保存配置</span>
</div>
<div class="top-actions">
<button class="btn secondary icon" id="settingsBtn" type="button" title="接口设置">⚙</button>
<button class="btn" id="translateBtn" type="button">翻译</button>
</div>
</div>
<div class="settings" id="settingsPanel">
<div class="field full">
<div class="label">Base URL</div>
<input class="input" id="baseUrlInput" placeholder="https://api.openai.com/v1" spellcheck="false" />
</div>
<div class="field">
<div class="label">API Key</div>
<input class="input" id="apiKeyInput" placeholder="sk-..." spellcheck="false" type="password" />
</div>
<div class="field">
<div class="label">Model</div>
<input class="input" id="modelInput" placeholder="gpt-4o-mini / deepseek-chat" spellcheck="false" />
</div>
<div class="field">
<div class="label">目标语言</div>
<select class="select" id="targetLangSelect">
<option value="中文">中文</option>
<option value="英文">英文</option>
<option value="日文">日文</option>
<option value="韩文">韩文</option>
<option value="法文">法文</option>
<option value="德文">德文</option>
<option value="西班牙文">西班牙文</option>
<option value="俄文">俄文</option>
<option value="自动判断并翻译成中文">自动→中文</option>
<option value="自动判断并翻译成英文">自动→英文</option>
</select>
</div>
<div class="field">
<div class="label">风格</div>
<select class="select" id="styleSelect">
<option value="自然准确">自然准确</option>
<option value="口语化">口语化</option>
<option value="正式书面">正式书面</option>
<option value="技术文档">技术文档</option>
<option value="保留原格式">保留原格式</option>
</select>
</div>
</div>
<div class="main">
<div class="pane">
<div class="pane-head">
<span>原文</span>
<div class="mini-actions">
<button class="mini" id="pasteBtn" type="button">粘贴</button>
<button class="mini" id="clearBtn" type="button">清空</button>
</div>
</div>
<div class="text-box">
<textarea class="textarea" id="sourceText" placeholder="输入或粘贴要翻译的文本。支持短句、段落、Markdown、代码注释。" spellcheck="false"></textarea>
</div>
</div>
<div class="pane">
<div class="pane-head">
<span>译文</span>
<div class="mini-actions">
<button class="mini" id="copyBtn" type="button">复制</button>
</div>
</div>
<div class="text-box">
<div class="output placeholder" id="resultText">译文会显示在这里。</div>
</div>
</div>
</div>
<div class="bottom">
<div class="status" id="statusText">就绪</div>
<button class="mini" id="saveConfigBtn" type="button">保存设置</button>
</div>
</div>
<script>
(() => {
const CONFIG_KEY = "fudao.ai.translate.config.v1";
const DRAFT_KEY = "fudao.ai.translate.draft.v1";
const $ = (id) => document.getElementById(id);
const els = {
settingsBtn: $("settingsBtn"),
settingsPanel: $("settingsPanel"),
translateBtn: $("translateBtn"),
baseUrl: $("baseUrlInput"),
apiKey: $("apiKeyInput"),
model: $("modelInput"),
targetLang: $("targetLangSelect"),
style: $("styleSelect"),
source: $("sourceText"),
result: $("resultText"),
status: $("statusText"),
saveConfig: $("saveConfigBtn"),
pasteBtn: $("pasteBtn"),
clearBtn: $("clearBtn"),
copyBtn: $("copyBtn"),
subTitle: $("subTitle")
};
const state = {
busy: false,
config: {
baseUrl: "",
apiKey: "",
model: "",
targetLang: "中文",
style: "自然准确"
}
};
function setStatus(text, type) {
els.status.className = "status" + (type ? " " + type : "");
els.status.innerHTML = text || "就绪";
}
function setBusy(yes) {
state.busy = yes;
els.translateBtn.disabled = yes;
els.translateBtn.textContent = yes ? "翻译中" : "翻译";
if (yes) {
setStatus('<span class="loader"><span class="spinner"></span><span>正在调用大模型翻译…</span></span>', "");
}
}
function normalizeBaseUrl(url) {
url = (url || "").trim();
if (!url) return "";
while (url.endsWith("/")) url = url.slice(0, -1);
return url;
}
function endpointFromBaseUrl(url) {
url = normalizeBaseUrl(url);
if (!url) return "";
if (/\/chat\/completions$/i.test(url)) return url;
if (/\/v1$/i.test(url)) return url + "/chat/completions";
return url + "/v1/chat/completions";
}
function safeParse(raw) {
if (!raw) return null;
if (typeof raw === "object") return raw;
try { return JSON.parse(raw); } catch (_) { return null; }
}
function unwrapStateValue(res) {
if (res == null) return "";
if (typeof res === "string") return res;
if (typeof res === "object") {
if (typeof res.value === "string") return res.value;
if (typeof res.text === "string") return res.text;
if (typeof res.data === "string") return res.data;
try { return JSON.stringify(res); } catch (_) { return ""; }
}
return String(res);
}
async function invoke(method, args) {
const host = window.fudao && typeof window.fudao.invoke === "function"
? window.fudao
: (window.yanm && typeof window.yanm.invoke === "function" ? window.yanm : null);
if (!host) throw new Error("宿主未就绪");
const ret = host.invoke(method, args || {});
return ret && typeof ret.then === "function" ? await ret : ret;
}
async function readState(key, def) {
try {
const res = await invoke("state.read", { key, defaultValue: def || "" });
const v = unwrapStateValue(res);
return v === "" ? (def || "") : v;
} catch (_) {
try { return localStorage.getItem(key) || (def || ""); } catch (_) { return def || ""; }
}
}
async function writeState(key, value) {
const text = value == null ? "" : String(value);
try { localStorage.setItem(key, text); } catch (_) {}
try { await invoke("state.write", { key, value: text }); } catch (_) {}
}
function readFormConfig() {
return {
baseUrl: normalizeBaseUrl(els.baseUrl.value),
apiKey: els.apiKey.value.trim(),
model: els.model.value.trim(),
targetLang: els.targetLang.value,
style: els.style.value
};
}
function applyConfig(config) {
state.config = Object.assign({}, state.config, config || {});
els.baseUrl.value = state.config.baseUrl || "";
els.apiKey.value = state.config.apiKey || "";
els.model.value = state.config.model || "";
els.targetLang.value = state.config.targetLang || "中文";
els.style.value = state.config.style || "自然准确";
updateSubTitle();
}
function updateSubTitle() {
const model = els.model.value.trim();
const lang = els.targetLang.value;
els.subTitle.textContent = (model ? model : "未设置模型") + " · 译为 " + lang;
}
async function saveConfig() {
const config = readFormConfig();
applyConfig(config);
await writeState(CONFIG_KEY, JSON.stringify(config));
setStatus("设置已保存", "good");
}
async function saveDraft() {
const draft = {
source: els.source.value,
result: els.result.classList.contains("placeholder") ? "" : els.result.textContent
};
await writeState(DRAFT_KEY, JSON.stringify(draft));
}
function buildMessages(text, targetLang, style) {
const system = [
"你是一个专业翻译助手。",
"只输出译文,不要解释,不要加标题。",
"尽量保留原文段落、列表、Markdown、代码块和专有名词格式。",
"目标语言:" + targetLang + "。",
"翻译风格:" + style + "。"
].join("\n");
const user = "请翻译以下内容:\n\n" + text;
return [
{ role: "system", content: system },
{ role: "user", content: user }
];
}
function parseOpenAIResult(obj) {
if (!obj) throw new Error("空响应");
if (obj.error) {
if (typeof obj.error === "string") throw new Error(obj.error);
throw new Error(obj.error.message || JSON.stringify(obj.error));
}
if (obj.choices && obj.choices.length > 0) {
const msg = obj.choices[0].message;
if (msg && typeof msg.content === "string") return msg.content.trim();
if (typeof obj.choices[0].text === "string") return obj.choices[0].text.trim();
}
throw new Error("响应中未找到 choices[0].message.content");
}
async function callTranslateApi(text) {
const config = readFormConfig();
applyConfig(config);
if (!config.baseUrl) throw new Error("请先设置 Base URL");
if (!config.apiKey) throw new Error("请先设置 API Key");
if (!config.model) throw new Error("请先设置 Model");
const endpoint = endpointFromBaseUrl(config.baseUrl);
const body = {
model: config.model,
messages: buildMessages(text, config.targetLang, config.style),
temperature: 0.2,
stream: false
};
const res = await invoke("http.post", {
url: endpoint,
contentType: "application/json; charset=utf-8",
headers: {
"Authorization": "Bearer " + config.apiKey,
"Accept": "application/json"
},
body: JSON.stringify(body)
});
if (typeof res === "string") {
return parseOpenAIResult(JSON.parse(res));
}
if (res && typeof res === "object") {
if (res.ok === false) {
throw new Error(res.error || res.text || ("HTTP " + (res.status || "ERR")));
}
if (typeof res.text === "string") {
return parseOpenAIResult(JSON.parse(res.text));
}
if (typeof res.body === "string") {
return parseOpenAIResult(JSON.parse(res.body));
}
if (res.choices) {
return parseOpenAIResult(res);
}
return parseOpenAIResult(safeParse(JSON.stringify(res)));
}
throw new Error("未知响应格式");
}
async function translate() {
if (state.busy) return;
const text = els.source.value.trim();
if (!text) {
setStatus("请输入要翻译的内容", "warn");
return;
}
setBusy(true);
try {
await saveConfig();
const result = await callTranslateApi(text);
els.result.classList.remove("placeholder");
els.result.textContent = result || "无内容";
setStatus("翻译完成", "good");
await saveDraft();
} catch (err) {
const msg = err && err.message ? err.message : "未知错误";
setStatus("翻译失败:" + msg, "bad");
} finally {
setBusy(false);
}
}
async function pasteText() {
try {
const res = await invoke("clipboard.read", {});
const text = unwrapStateValue(res);
if (text) {
els.source.value = text;
setStatus("已粘贴剪贴板文本", "good");
} else {
setStatus("剪贴板为空", "warn");
}
} catch (_) {
setStatus("读取剪贴板失败", "bad");
}
}
async function copyResult() {
const text = els.result.classList.contains("placeholder") ? "" : els.result.textContent;
if (!text.trim()) {
setStatus("没有可复制的译文", "warn");
return;
}
try {
await invoke("clipboard.write", { text });
setStatus("译文已复制", "good");
} catch (_) {
try {
await navigator.clipboard.writeText(text);
setStatus("译文已复制", "good");
} catch (e) {
setStatus("复制失败", "bad");
}
}
}
function clearText() {
els.source.value = "";
els.result.classList.add("placeholder");
els.result.textContent = "译文会显示在这里。";
setStatus("已清空", "");
saveDraft();
}
async function loadAll() {
const configRaw = await readState(CONFIG_KEY, "");
const config = safeParse(configRaw);
if (config) applyConfig(config);
const draftRaw = await readState(DRAFT_KEY, "");
const draft = safeParse(draftRaw);
if (draft) {
els.source.value = draft.source || "";
if (draft.result) {
els.result.classList.remove("placeholder");
els.result.textContent = draft.result;
}
}
}
function bind() {
els.settingsBtn.addEventListener("click", () => {
els.settingsPanel.classList.toggle("show");
});
els.saveConfig.addEventListener("click", saveConfig);
els.translateBtn.addEventListener("click", translate);
els.pasteBtn.addEventListener("click", pasteText);
els.copyBtn.addEventListener("click", copyResult);
els.clearBtn.addEventListener("click", clearText);
els.source.addEventListener("input", () => {
window.clearTimeout(els.source.__saveTimer);
els.source.__saveTimer = window.setTimeout(saveDraft, 500);
});
[els.baseUrl, els.apiKey, els.model, els.targetLang, els.style].forEach(el => {
el.addEventListener("change", () => {
updateSubTitle();
saveConfig();
});
});
window.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
translate();
}
});
}
async function boot() {
bind();
applyConfig(state.config);
await loadAll();
setStatus("就绪 · Ctrl+Enter 翻译", "");
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once:true });
} else {
boot();
}
})();
</script>
</body>
</html>