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:
hopechen067andClaude Opus 5.5 committed 2026-10-02 01:03:11 -04:00
1 parent b86b18f9f3
commit 30ce5b7dde
8 files changed
+742

No files matched your search

+54
View File
@@ -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';
}
+87
View File
@@ -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; } }
+26
View File
@@ -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>
+197
View File
@@ -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();
+79
View File
@@ -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%; }
}
+85
View File
@@ -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>
+170
View File
@@ -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();
})();
+44
View File
@@ -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);
}