ai翻译

经验创意 · 150 次浏览
困困君 创建于 2026-05-21 23:47

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


回复内容
暂无回复
回复主贴