feat(ui): 浮窗与设置页
- 浮窗:边打边预览、流式显示、Enter 上屏(未出结果则等待后上屏)、Tab 切方向、Ctrl+E 切引擎、中文输入法选词时不误触 - 设置页:引擎切换、大模型服务商预设、Key 只写不读、测试按钮、热键录制、常规开关 - dev-mock.js:浏览器直接打开页面时的模拟 xt 接口 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b86b18f9f3
commit
30ce5b7dde
8 files changed
+742
No files matched your search
@@ -0,0 +1,54 @@
|
||||
'use strict';
|
||||
// 仅在浏览器里直接打开页面预览时生效;Electron 里 preload 已提供 window.xt。
|
||||
if (!window.xt) {
|
||||
const listeners = { partial: [], show: [], config: [] };
|
||||
let cfg = {
|
||||
hotkey: 'Alt+Q', engine: 'free', free: { provider: 'microsoft' },
|
||||
llm: { provider: 'deepseek', baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat', apiKeySet: false },
|
||||
livePreview: true, previewDelayMs: 500, restoreClipboard: true, launchAtLogin: false,
|
||||
};
|
||||
const canned = { '我先撤了哈,明天见': "I'm heading out, see you tomorrow!", 'No worries, take your time!': '没事没事,你慢慢来!' };
|
||||
const timers = {};
|
||||
const merge = (a, b) => { for (const k in b) a[k] = b[k] && typeof b[k] === 'object' && !Array.isArray(b[k]) ? merge({ ...(a[k] || {}) }, b[k]) : b[k]; return a; };
|
||||
window.xt = {
|
||||
translate({ id, text, engine }) {
|
||||
const full = canned[text.trim()] || (/[一-鿿]/.test(text) ? `(mock EN) ${text}` : `(模拟中文)${text}`);
|
||||
if ((engine || cfg.engine) === 'llm' && !cfg.llm.apiKeySet) return Promise.reject(new Error('还没填大模型的 API Key,去设置里填一下'));
|
||||
return new Promise((resolve) => {
|
||||
let i = 0;
|
||||
timers[id] = setInterval(() => {
|
||||
i += 3;
|
||||
listeners.partial.forEach((cb) => cb({ id, text: full.slice(0, i) }));
|
||||
if (i >= full.length) { clearInterval(timers[id]); resolve({ id, text: full, engine: engine || cfg.engine, direction: 'auto', ms: 300 }); }
|
||||
}, 40);
|
||||
});
|
||||
},
|
||||
onPartial: (cb) => listeners.partial.push(cb),
|
||||
cancel: (id) => clearInterval(timers[id]),
|
||||
commit: async (text) => console.log('[mock] commit:', text),
|
||||
hide: () => console.log('[mock] hide'),
|
||||
resize: () => {},
|
||||
onShow: (cb) => listeners.show.push(cb),
|
||||
getConfig: async () => structuredClone(cfg),
|
||||
setConfig: async (p) => {
|
||||
if (p.hotkey === 'Ctrl+C') throw new Error('这个组合键被占用了');
|
||||
const { apiKey, ...llm } = p.llm || {};
|
||||
cfg = merge(cfg, { ...p, ...(p.llm ? { llm } : {}) });
|
||||
if (apiKey) cfg.llm.apiKeySet = true;
|
||||
listeners.config.forEach((cb) => cb(structuredClone(cfg)));
|
||||
return structuredClone(cfg);
|
||||
},
|
||||
getProviders: async () => [
|
||||
{ id: 'deepseek', name: 'DeepSeek', baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat', models: ['deepseek-chat'], keyUrl: 'https://platform.deepseek.com/api_keys' },
|
||||
{ id: 'anthropic', name: 'Claude(Anthropic)', baseUrl: 'https://api.anthropic.com', model: 'claude-haiku-4-5', models: ['claude-haiku-4-5', 'claude-sonnet-5-5'], keyUrl: 'https://console.anthropic.com/settings/keys' },
|
||||
{ id: 'ollama', name: 'Ollama(本地)', baseUrl: 'http://localhost:11434/v1', model: 'qwen2.5:7b', models: [], keyOptional: true },
|
||||
{ id: 'custom', name: '自定义(OpenAI 兼容)', baseUrl: '', model: '', models: [] },
|
||||
],
|
||||
testEngine: async ({ engine }) => engine === 'llm' && !cfg.llm.apiKeySet
|
||||
? { ok: false, error: '还没填大模型的 API Key,去设置里填一下' }
|
||||
: { ok: true, sample: "I'm heading out, see you tomorrow!", ms: 420 },
|
||||
openSettings: () => console.log('[mock] openSettings'),
|
||||
onConfigChanged: (cb) => listeners.config.push(cb),
|
||||
};
|
||||
document.documentElement.style.background = '#8a93a3';
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
html, body { background: transparent; overflow: hidden; }
|
||||
body { padding: 10px; }
|
||||
|
||||
.card {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#src {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
max-height: 180px;
|
||||
padding: 15px 18px 8px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
resize: none;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
font: 17px/1.5 var(--font);
|
||||
}
|
||||
#src::placeholder { color: var(--fg-faint); }
|
||||
|
||||
.out {
|
||||
min-height: 0;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
padding: 0 18px;
|
||||
font-size: 17px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--accent);
|
||||
}
|
||||
.out:not(:empty) { padding: 2px 18px 12px; }
|
||||
.out.pending { color: var(--fg-muted); }
|
||||
.out.pending::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 7px; height: 1.05em;
|
||||
margin-left: 2px;
|
||||
vertical-align: -2px;
|
||||
background: currentColor;
|
||||
animation: blink 1s steps(2) infinite;
|
||||
}
|
||||
.out.error { color: var(--danger); font-size: 14px; }
|
||||
.out.error a { color: inherit; text-decoration: underline; cursor: pointer; }
|
||||
.out.waiting::before { content: "翻译完就上屏… "; color: var(--fg-faint); font-size: 13px; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 10px 7px 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--bg-soft);
|
||||
font-size: 12px;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.chip {
|
||||
padding: 3px 9px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--bg);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.chip.llm { background: var(--accent-soft); border-color: transparent; color: var(--accent); }
|
||||
.hints { margin-left: auto; display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||||
.hints kbd + kbd, .hints kbd { margin-left: 6px; }
|
||||
.icon {
|
||||
display: grid; place-items: center;
|
||||
width: 26px; height: 26px;
|
||||
margin-left: 4px;
|
||||
border: 0; border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--fg-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.icon:hover { background: var(--line); color: var(--fg); }
|
||||
|
||||
@media (max-width: 480px) { .hints { display: none; } .icon { margin-left: auto; } }
|
||||
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'">
|
||||
<title>Xtranslate</title>
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
<link rel="stylesheet" href="popup.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="card" id="card">
|
||||
<textarea id="src" rows="1" spellcheck="false" placeholder="打中文出英文,打英文出中文…" autofocus></textarea>
|
||||
<div class="out" id="out" aria-live="polite"></div>
|
||||
<footer>
|
||||
<button class="chip" id="dir" title="切换方向(Tab)">自动</button>
|
||||
<button class="chip" id="engine" title="切换引擎(Ctrl+E)">免费</button>
|
||||
<span class="hints"><kbd>Enter</kbd> 上屏 <kbd>Shift+Enter</kbd> 换行 <kbd>Esc</kbd> 关闭</span>
|
||||
<button class="icon" id="settings" title="设置" aria-label="设置">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
</button>
|
||||
</footer>
|
||||
</main>
|
||||
<script src="dev-mock.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,197 @@
|
||||
'use strict';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const src = $('src');
|
||||
const out = $('out');
|
||||
const card = $('card');
|
||||
const dirBtn = $('dir');
|
||||
const engineBtn = $('engine');
|
||||
|
||||
const DIRS = ['auto', 'zh2en', 'en2zh'];
|
||||
const state = {
|
||||
dir: 'auto',
|
||||
engine: 'free',
|
||||
livePreview: true,
|
||||
delay: 500,
|
||||
seq: 0,
|
||||
cur: null, // {id, text, dir, engine, done, result, error, promise}
|
||||
wantCommit: false,
|
||||
timer: 0,
|
||||
};
|
||||
|
||||
// 和主进程 detectDirection 同一规则,只用于显示"自动"时的实际方向
|
||||
function guessDir(text) {
|
||||
const cjk = (text.match(/[㐀-鿿豈-]/g) || []).length;
|
||||
const latin = (text.match(/[A-Za-z]/g) || []).length;
|
||||
if (!cjk && !latin) return 'zh2en';
|
||||
return cjk * 5 >= latin ? 'zh2en' : 'en2zh';
|
||||
}
|
||||
|
||||
function renderChips() {
|
||||
const arrow = { zh2en: '中 → 英', en2zh: '英 → 中' };
|
||||
dirBtn.textContent = state.dir === 'auto'
|
||||
? (src.value.trim() ? `自动 · ${arrow[guessDir(src.value)]}` : '自动识别')
|
||||
: arrow[state.dir];
|
||||
engineBtn.textContent = state.engine === 'llm' ? '大模型 · 口语' : '免费引擎';
|
||||
engineBtn.classList.toggle('llm', state.engine === 'llm');
|
||||
}
|
||||
|
||||
function renderOut(text, mode) {
|
||||
out.className = 'out' + (mode ? ' ' + mode : '') + (state.wantCommit ? ' waiting' : '');
|
||||
out.textContent = text || '';
|
||||
if (mode === 'error') {
|
||||
const a = document.createElement('a');
|
||||
a.textContent = '去设置';
|
||||
a.onclick = () => window.xt.openSettings();
|
||||
out.append(' ', a);
|
||||
}
|
||||
out.scrollTop = out.scrollHeight;
|
||||
}
|
||||
|
||||
function matchesCurrent(c) {
|
||||
return c && c.text === src.value && c.dir === state.dir && c.engine === state.engine;
|
||||
}
|
||||
|
||||
function cancelCurrent() {
|
||||
const c = state.cur;
|
||||
if (c && !c.done && !c.error) window.xt.cancel(c.id);
|
||||
}
|
||||
|
||||
function translateNow() {
|
||||
clearTimeout(state.timer);
|
||||
const text = src.value;
|
||||
if (!text.trim()) {
|
||||
cancelCurrent();
|
||||
state.cur = null;
|
||||
state.wantCommit = false;
|
||||
renderOut('');
|
||||
return;
|
||||
}
|
||||
if (matchesCurrent(state.cur) && !state.cur.error) return;
|
||||
|
||||
cancelCurrent();
|
||||
const id = ++state.seq;
|
||||
const prev = state.cur && state.cur.result;
|
||||
const c = { id, text, dir: state.dir, engine: state.engine, done: false, result: '', error: null };
|
||||
state.cur = c;
|
||||
// 新请求出结果前,淡显上一次的译文,避免闪烁
|
||||
renderOut(prev || '', 'pending');
|
||||
|
||||
c.promise = window.xt.translate({ id, text, direction: state.dir, engine: state.engine })
|
||||
.then((r) => {
|
||||
if (state.cur !== c) return;
|
||||
c.done = true;
|
||||
c.result = r.text || '';
|
||||
if (state.wantCommit) return commit();
|
||||
renderOut(c.result);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (state.cur !== c) return;
|
||||
c.error = (e && e.message ? e.message : String(e)).replace(/^Error invoking remote method '[^']+': (Error: )?/, '');
|
||||
state.wantCommit = false;
|
||||
renderOut(c.error, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
renderChips();
|
||||
clearTimeout(state.timer);
|
||||
if (!src.value.trim()) return translateNow();
|
||||
if (state.livePreview) state.timer = setTimeout(translateNow, state.delay);
|
||||
}
|
||||
|
||||
function commit() {
|
||||
const text = src.value;
|
||||
if (!text.trim()) return hide();
|
||||
const c = state.cur;
|
||||
if (matchesCurrent(c) && c.done) {
|
||||
if (!c.result) return;
|
||||
state.wantCommit = false;
|
||||
window.xt.commit(c.result);
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
state.wantCommit = true;
|
||||
if (matchesCurrent(c) && !c.error) renderOut(c.result || out.textContent, 'pending');
|
||||
else translateNow();
|
||||
}
|
||||
|
||||
function hide() {
|
||||
cancelCurrent();
|
||||
reset();
|
||||
window.xt.hide();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
clearTimeout(state.timer);
|
||||
state.cur = null;
|
||||
state.wantCommit = false;
|
||||
src.value = '';
|
||||
autoGrow();
|
||||
renderOut('');
|
||||
renderChips();
|
||||
}
|
||||
|
||||
function cycleDir() {
|
||||
state.dir = DIRS[(DIRS.indexOf(state.dir) + 1) % DIRS.length];
|
||||
renderChips();
|
||||
if (src.value.trim()) translateNow();
|
||||
}
|
||||
|
||||
async function toggleEngine() {
|
||||
state.engine = state.engine === 'llm' ? 'free' : 'llm';
|
||||
renderChips();
|
||||
if (src.value.trim()) translateNow();
|
||||
try { await window.xt.setConfig({ engine: state.engine }); } catch (_) { /* 只影响下次默认值 */ }
|
||||
}
|
||||
|
||||
function autoGrow() {
|
||||
src.style.height = 'auto';
|
||||
src.style.height = Math.min(src.scrollHeight, 180) + 'px';
|
||||
}
|
||||
|
||||
src.addEventListener('input', () => { autoGrow(); state.wantCommit = false; schedule(); });
|
||||
|
||||
src.addEventListener('keydown', (e) => {
|
||||
// 中文输入法选词时的回车/Esc 交给输入法
|
||||
if (e.isComposing || e.keyCode === 229) return;
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commit(); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); hide(); }
|
||||
else if (e.key === 'Tab') { e.preventDefault(); cycleDir(); }
|
||||
else if ((e.key === 'e' || e.key === 'E') && (e.ctrlKey || e.metaKey)) { e.preventDefault(); toggleEngine(); }
|
||||
});
|
||||
|
||||
dirBtn.onclick = () => { cycleDir(); src.focus(); };
|
||||
engineBtn.onclick = () => { toggleEngine(); src.focus(); };
|
||||
$('settings').onclick = () => window.xt.openSettings();
|
||||
|
||||
window.xt.onPartial(({ id, text }) => {
|
||||
const c = state.cur;
|
||||
if (c && c.id === id && !c.done) renderOut(text, 'pending');
|
||||
});
|
||||
|
||||
window.xt.onShow(({ engine, direction } = {}) => {
|
||||
reset();
|
||||
if (engine) state.engine = engine;
|
||||
state.dir = direction && DIRS.includes(direction) ? direction : 'auto';
|
||||
renderChips();
|
||||
src.focus();
|
||||
});
|
||||
|
||||
function applyConfig(cfg) {
|
||||
if (!cfg) return;
|
||||
state.engine = cfg.engine || state.engine;
|
||||
state.livePreview = cfg.livePreview !== false;
|
||||
state.delay = Number(cfg.previewDelayMs) || 500;
|
||||
renderChips();
|
||||
}
|
||||
window.xt.onConfigChanged(applyConfig);
|
||||
window.xt.getConfig().then(applyConfig).catch(() => {});
|
||||
|
||||
// 窗口高度跟随内容(外层 body 有 10px 阴影留白)
|
||||
new ResizeObserver(() => {
|
||||
window.xt.resize(Math.ceil(card.getBoundingClientRect().height) + 20);
|
||||
}).observe(card);
|
||||
|
||||
renderChips();
|
||||
src.focus();
|
||||
@@ -0,0 +1,79 @@
|
||||
body { background: var(--bg-soft); font-size: 14px; }
|
||||
.wrap { max-width: 560px; margin: 0 auto; padding: 24px 20px 40px; }
|
||||
header h1 { margin: 0; font-size: 22px; letter-spacing: .2px; }
|
||||
header p { margin: 4px 0 18px; color: var(--fg-muted); }
|
||||
|
||||
section {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
section.off { display: none; }
|
||||
h2 { margin: 0 0 12px; font-size: 13px; font-weight: 600; color: var(--fg-muted); text-transform: uppercase; letter-spacing: .5px; }
|
||||
|
||||
.seg { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.seg label { cursor: pointer; }
|
||||
.seg input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.seg span {
|
||||
display: block; height: 100%;
|
||||
padding: 11px 13px;
|
||||
border: 1.5px solid var(--line);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.seg b { display: block; font-weight: 600; }
|
||||
.seg small { display: block; margin-top: 3px; color: var(--fg-muted); font-size: 12px; }
|
||||
.seg input:checked + span { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.seg input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
.row { display: flex; align-items: center; gap: 10px; margin: 10px 0; }
|
||||
.row > label { flex: 0 0 82px; color: var(--fg-muted); }
|
||||
.row.indent > label { flex-basis: auto; padding-left: 26px; }
|
||||
.row.end { justify-content: flex-start; margin-top: 14px; }
|
||||
input[type=url], input[type=password], input:not([type]), select {
|
||||
flex: 1; min-width: 0;
|
||||
height: 32px; padding: 0 10px;
|
||||
border: 1px solid var(--line); border-radius: 7px;
|
||||
background: var(--bg); color: var(--fg);
|
||||
font: 13px var(--font);
|
||||
}
|
||||
.row.indent select { flex: 0 0 110px; }
|
||||
input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: transparent; }
|
||||
|
||||
.btn {
|
||||
height: 32px; padding: 0 14px;
|
||||
border: 1px solid var(--line); border-radius: 7px;
|
||||
background: var(--bg); cursor: pointer;
|
||||
}
|
||||
.btn:hover { border-color: var(--accent); }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.btn.primary:hover { filter: brightness(1.08); }
|
||||
.btn:disabled { opacity: .6; cursor: progress; }
|
||||
|
||||
.result { font-size: 13px; color: var(--fg-muted); min-width: 0; overflow-wrap: anywhere; }
|
||||
.result.ok { color: var(--ok); }
|
||||
.result.err { color: var(--danger); }
|
||||
|
||||
.tip { margin: 6px 0 0; font-size: 12px; color: var(--fg-muted); }
|
||||
.tip a { color: var(--accent); }
|
||||
|
||||
.hotkey {
|
||||
min-width: 120px; height: 32px; padding: 0 12px;
|
||||
border: 1px dashed var(--line); border-radius: 7px;
|
||||
background: var(--bg-soft); cursor: pointer;
|
||||
font: 600 13px var(--font);
|
||||
}
|
||||
.hotkey.rec { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
.switch { display: flex; align-items: center; gap: 10px; margin: 12px 0; cursor: pointer; }
|
||||
.switch input { appearance: none; position: relative; width: 34px; height: 20px; margin: 0; border-radius: 999px; background: var(--line); cursor: pointer; transition: background .15s; flex: none; }
|
||||
.switch input::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.25); transition: transform .15s; }
|
||||
.switch input:checked { background: var(--accent); }
|
||||
.switch input:checked::after { transform: translateX(14px); }
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.seg { grid-template-columns: 1fr; }
|
||||
.row { flex-wrap: wrap; }
|
||||
.row > label { flex-basis: 100%; }
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'">
|
||||
<title>Xtranslate 设置</title>
|
||||
<link rel="stylesheet" href="theme.css">
|
||||
<link rel="stylesheet" href="settings.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<h1>Xtranslate</h1>
|
||||
<p>中英互译输入法 · 按 <kbd id="hk-hint">Alt+Q</kbd> 随处呼出</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>翻译引擎</h2>
|
||||
<div class="seg" role="radiogroup">
|
||||
<label><input type="radio" name="engine" value="free"><span><b>免费引擎</b><small>无需 Key,开箱即用,译文偏书面</small></span></label>
|
||||
<label><input type="radio" name="engine" value="llm"><span><b>大模型 · 口语</b><small>填自己的 API Key,译文像日常说话</small></span></label>
|
||||
</div>
|
||||
<p class="tip">浮窗里按 <kbd>Ctrl+E</kbd> 可以随时在两种引擎之间切换。</p>
|
||||
</section>
|
||||
|
||||
<section id="free-box">
|
||||
<h2>免费引擎</h2>
|
||||
<div class="row">
|
||||
<label for="free-provider">服务</label>
|
||||
<select id="free-provider">
|
||||
<option value="microsoft">微软翻译(国内可直连)</option>
|
||||
<option value="google">谷歌翻译(国内需代理)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row end"><button class="btn" data-test="free">测试</button><span class="result" id="free-result"></span></div>
|
||||
</section>
|
||||
|
||||
<section id="llm-box">
|
||||
<h2>大模型</h2>
|
||||
<div class="row">
|
||||
<label for="llm-provider">服务商</label>
|
||||
<select id="llm-provider"></select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="llm-base">接口地址</label>
|
||||
<input id="llm-base" type="url" spellcheck="false" placeholder="https://…">
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="llm-model">模型</label>
|
||||
<input id="llm-model" list="llm-models" spellcheck="false" placeholder="模型名">
|
||||
<datalist id="llm-models"></datalist>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="llm-key">API Key</label>
|
||||
<input id="llm-key" type="password" spellcheck="false" autocomplete="off" placeholder="sk-…">
|
||||
</div>
|
||||
<p class="tip" id="key-tip"></p>
|
||||
<div class="row end"><button class="btn primary" data-test="llm">保存并测试</button><span class="result" id="llm-result"></span></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>常规</h2>
|
||||
<div class="row">
|
||||
<label>呼出热键</label>
|
||||
<button class="hotkey" id="hotkey" title="点击后按下新的组合键">Alt+Q</button>
|
||||
<span class="result" id="hotkey-result"></span>
|
||||
</div>
|
||||
<label class="switch"><input type="checkbox" id="live"><span>边打边预览译文</span></label>
|
||||
<div class="row indent">
|
||||
<label for="delay">停顿多久开始翻译</label>
|
||||
<select id="delay">
|
||||
<option value="300">0.3 秒</option>
|
||||
<option value="500">0.5 秒</option>
|
||||
<option value="800">0.8 秒</option>
|
||||
<option value="1200">1.2 秒</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="switch"><input type="checkbox" id="restore"><span>上屏后恢复原来的剪贴板内容</span></label>
|
||||
<label class="switch"><input type="checkbox" id="login"><span>开机自动启动</span></label>
|
||||
</section>
|
||||
</div>
|
||||
<script src="dev-mock.js"></script>
|
||||
<script src="settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,170 @@
|
||||
'use strict';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const xt = window.xt;
|
||||
let providers = [];
|
||||
let cfg = null;
|
||||
|
||||
const errMsg = (e) => (e && e.message ? e.message : String(e)).replace(/^Error invoking remote method '[^']+': (Error: )?/, '');
|
||||
|
||||
function setResult(el, text, kind) {
|
||||
el.textContent = text || '';
|
||||
el.className = 'result' + (kind ? ' ' + kind : '');
|
||||
}
|
||||
|
||||
async function save(partial) {
|
||||
try {
|
||||
cfg = await xt.setConfig(partial);
|
||||
render();
|
||||
return true;
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!cfg) return;
|
||||
for (const r of document.querySelectorAll('input[name=engine]')) r.checked = r.value === cfg.engine;
|
||||
$('free-box').classList.toggle('off', cfg.engine !== 'free');
|
||||
$('llm-box').classList.toggle('off', cfg.engine !== 'llm');
|
||||
$('free-provider').value = cfg.free?.provider || 'microsoft';
|
||||
|
||||
const llm = cfg.llm || {};
|
||||
$('llm-provider').value = llm.provider || 'deepseek';
|
||||
if (document.activeElement !== $('llm-base')) $('llm-base').value = llm.baseUrl || '';
|
||||
if (document.activeElement !== $('llm-model')) $('llm-model').value = llm.model || '';
|
||||
const p = providers.find((x) => x.id === llm.provider) || {};
|
||||
$('llm-models').replaceChildren(...(p.models || []).map((m) => Object.assign(document.createElement('option'), { value: m })));
|
||||
$('llm-key').placeholder = llm.apiKeySet ? '已保存(留空则不修改)' : (p.keyOptional ? '本地模型可不填' : 'sk-…');
|
||||
|
||||
const tip = $('key-tip');
|
||||
tip.replaceChildren();
|
||||
if (p.keyUrl) {
|
||||
const a = Object.assign(document.createElement('a'), { href: p.keyUrl, target: '_blank', textContent: `去 ${p.name} 申请 Key` });
|
||||
tip.append(a, ' Key 只加密保存在本机,只发给你选的服务商。');
|
||||
} else if (p.id === 'ollama') {
|
||||
tip.textContent = '需先在本机运行 Ollama 并拉取模型,例如 ollama pull qwen2.5:7b';
|
||||
}
|
||||
|
||||
$('hotkey').textContent = cfg.hotkey || '未设置';
|
||||
$('hk-hint').textContent = cfg.hotkey || '热键';
|
||||
$('live').checked = cfg.livePreview !== false;
|
||||
$('delay').value = String(cfg.previewDelayMs || 500);
|
||||
$('delay').disabled = !$('live').checked;
|
||||
$('restore').checked = cfg.restoreClipboard !== false;
|
||||
$('login').checked = !!cfg.launchAtLogin;
|
||||
}
|
||||
|
||||
// —— 引擎 ——
|
||||
for (const r of document.querySelectorAll('input[name=engine]')) {
|
||||
r.addEventListener('change', () => save({ engine: r.value }));
|
||||
}
|
||||
$('free-provider').addEventListener('change', (e) => save({ free: { provider: e.target.value } }));
|
||||
|
||||
$('llm-provider').addEventListener('change', (e) => {
|
||||
const p = providers.find((x) => x.id === e.target.value);
|
||||
if (!p) return;
|
||||
const llm = { provider: p.id };
|
||||
if (p.id !== 'custom') Object.assign(llm, { baseUrl: p.baseUrl, model: p.model });
|
||||
setResult($('llm-result'), '');
|
||||
save({ llm });
|
||||
});
|
||||
$('llm-base').addEventListener('change', (e) => save({ llm: { baseUrl: e.target.value.trim() } }));
|
||||
$('llm-model').addEventListener('change', (e) => save({ llm: { model: e.target.value.trim() } }));
|
||||
$('llm-key').addEventListener('change', async (e) => {
|
||||
const key = e.target.value.trim();
|
||||
if (!key) return;
|
||||
await save({ llm: { apiKey: key } });
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
for (const btn of document.querySelectorAll('[data-test]')) {
|
||||
btn.addEventListener('click', async () => {
|
||||
const engine = btn.dataset.test;
|
||||
const out = $(engine + '-result');
|
||||
btn.disabled = true;
|
||||
setResult(out, '测试中…');
|
||||
try {
|
||||
if (engine === 'llm') {
|
||||
const partial = { llm: { baseUrl: $('llm-base').value.trim(), model: $('llm-model').value.trim() } };
|
||||
const key = $('llm-key').value.trim();
|
||||
if (key) partial.llm.apiKey = key;
|
||||
await save(partial);
|
||||
$('llm-key').value = '';
|
||||
}
|
||||
const r = await xt.testEngine({ engine });
|
||||
if (r.ok) setResult(out, `✓ ${r.sample}(${r.ms} ms)`, 'ok');
|
||||
else setResult(out, r.error || '失败', 'err');
|
||||
} catch (e) {
|
||||
setResult(out, errMsg(e), 'err');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// —— 常规 ——
|
||||
$('live').addEventListener('change', (e) => save({ livePreview: e.target.checked }));
|
||||
$('delay').addEventListener('change', (e) => save({ previewDelayMs: Number(e.target.value) }));
|
||||
$('restore').addEventListener('change', (e) => save({ restoreClipboard: e.target.checked }));
|
||||
$('login').addEventListener('change', (e) => save({ launchAtLogin: e.target.checked }));
|
||||
|
||||
// —— 热键录制 ——
|
||||
const KEY_MAP = { ' ': 'Space', ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right', Enter: 'Enter', Backspace: 'Backspace', Delete: 'Delete', Insert: 'Insert', Home: 'Home', End: 'End', PageUp: 'PageUp', PageDown: 'PageDown', '`': '`', '-': '-', '=': '=', '[': '[', ']': ']', ';': ';', "'": "'", ',': ',', '.': '.', '/': '/', '\\': '\\' };
|
||||
|
||||
function toAccelerator(e) {
|
||||
let key = null;
|
||||
if (/^Key[A-Z]$/.test(e.code)) key = e.code.slice(3);
|
||||
else if (/^Digit\d$/.test(e.code)) key = e.code.slice(5);
|
||||
else if (/^F\d{1,2}$/.test(e.key)) key = e.key;
|
||||
else if (KEY_MAP[e.key]) key = KEY_MAP[e.key];
|
||||
if (!key) return null;
|
||||
const mods = [];
|
||||
if (e.ctrlKey) mods.push('Ctrl');
|
||||
if (e.altKey) mods.push('Alt');
|
||||
if (e.shiftKey) mods.push('Shift');
|
||||
if (e.metaKey) mods.push('Super');
|
||||
if (!mods.length && !/^F\d+$/.test(key)) return null; // 单个普通键会吃掉正常打字
|
||||
return [...mods, key].join('+');
|
||||
}
|
||||
|
||||
const hk = $('hotkey');
|
||||
let recording = false;
|
||||
hk.addEventListener('click', () => {
|
||||
recording = true;
|
||||
hk.classList.add('rec');
|
||||
hk.textContent = '请按下组合键…';
|
||||
setResult($('hotkey-result'), 'Esc 取消', '');
|
||||
});
|
||||
hk.addEventListener('blur', stopRec);
|
||||
function stopRec() {
|
||||
if (!recording) return;
|
||||
recording = false;
|
||||
hk.classList.remove('rec');
|
||||
render();
|
||||
}
|
||||
hk.addEventListener('keydown', async (e) => {
|
||||
if (!recording) return;
|
||||
e.preventDefault();
|
||||
if (e.key === 'Escape') { stopRec(); setResult($('hotkey-result'), ''); return; }
|
||||
const acc = toAccelerator(e);
|
||||
if (!acc) return; // 还在按修饰键
|
||||
recording = false;
|
||||
hk.classList.remove('rec');
|
||||
try {
|
||||
await save({ hotkey: acc });
|
||||
setResult($('hotkey-result'), '✓ 已生效', 'ok');
|
||||
} catch (err) {
|
||||
render();
|
||||
setResult($('hotkey-result'), errMsg(err) || '这个组合键被别的软件占用了', 'err');
|
||||
}
|
||||
});
|
||||
|
||||
xt.onConfigChanged((c) => { cfg = c; render(); });
|
||||
|
||||
(async () => {
|
||||
providers = await xt.getProviders();
|
||||
$('llm-provider').replaceChildren(...providers.map((p) => Object.assign(document.createElement('option'), { value: p.id, textContent: p.name })));
|
||||
cfg = await xt.getConfig();
|
||||
render();
|
||||
})();
|
||||
@@ -0,0 +1,44 @@
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--bg-soft: #f4f5f7;
|
||||
--fg: #1d1f23;
|
||||
--fg-muted: #6b7079;
|
||||
--fg-faint: #a3a8b0;
|
||||
--line: #e4e6ea;
|
||||
--accent: #3b6cf6;
|
||||
--accent-soft: #e8eefe;
|
||||
--danger: #d0453a;
|
||||
--ok: #2f9e5b;
|
||||
--shadow: 0 12px 40px rgba(20, 24, 33, .18), 0 2px 6px rgba(20, 24, 33, .08);
|
||||
--radius: 14px;
|
||||
--font: "Segoe UI Variable Text", "Segoe UI", "Microsoft YaHei UI", "PingFang SC", system-ui, sans-serif;
|
||||
color-scheme: light;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #1f2125;
|
||||
--bg-soft: #2a2d32;
|
||||
--fg: #eceef1;
|
||||
--fg-muted: #9aa0a9;
|
||||
--fg-faint: #676c75;
|
||||
--line: #34373d;
|
||||
--accent: #7b9cff;
|
||||
--accent-soft: #2b3350;
|
||||
--danger: #ff7b70;
|
||||
--ok: #5fcf8d;
|
||||
--shadow: 0 12px 40px rgba(0, 0, 0, .5), 0 2px 6px rgba(0, 0, 0, .3);
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; font-family: var(--font); color: var(--fg); }
|
||||
button { font: inherit; color: inherit; }
|
||||
kbd {
|
||||
font: 11px/1 var(--font);
|
||||
padding: 2px 5px;
|
||||
border: 1px solid var(--line);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 4px;
|
||||
color: var(--fg-muted);
|
||||
background: var(--bg-soft);
|
||||
}
|
||||
Reference in new issue
Block a user