From 30ce5b7dde4fb27d9f1d0f3ed01a58ad435fd33b Mon Sep 17 00:00:00 2001 From: hopechen067 <59017045+hopechen067@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:03:11 -0400 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E6=B5=AE=E7=AA=97=E4=B8=8E?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 浮窗:边打边预览、流式显示、Enter 上屏(未出结果则等待后上屏)、Tab 切方向、Ctrl+E 切引擎、中文输入法选词时不误触 - 设置页:引擎切换、大模型服务商预设、Key 只写不读、测试按钮、热键录制、常规开关 - dev-mock.js:浏览器直接打开页面时的模拟 xt 接口 Co-Authored-By: Claude Opus 5.5 --- src/renderer/dev-mock.js | 54 ++++++++++ src/renderer/popup.css | 87 ++++++++++++++++ src/renderer/popup.html | 26 +++++ src/renderer/popup.js | 197 +++++++++++++++++++++++++++++++++++++ src/renderer/settings.css | 79 +++++++++++++++ src/renderer/settings.html | 85 ++++++++++++++++ src/renderer/settings.js | 170 ++++++++++++++++++++++++++++++++ src/renderer/theme.css | 44 +++++++++ 8 files changed, 742 insertions(+) create mode 100644 src/renderer/dev-mock.js create mode 100644 src/renderer/popup.css create mode 100644 src/renderer/popup.html create mode 100644 src/renderer/popup.js create mode 100644 src/renderer/settings.css create mode 100644 src/renderer/settings.html create mode 100644 src/renderer/settings.js create mode 100644 src/renderer/theme.css diff --git a/src/renderer/dev-mock.js b/src/renderer/dev-mock.js new file mode 100644 index 0000000..a3f40a4 --- /dev/null +++ b/src/renderer/dev-mock.js @@ -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'; +} diff --git a/src/renderer/popup.css b/src/renderer/popup.css new file mode 100644 index 0000000..ebfcae0 --- /dev/null +++ b/src/renderer/popup.css @@ -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; } } diff --git a/src/renderer/popup.html b/src/renderer/popup.html new file mode 100644 index 0000000..5a1565a --- /dev/null +++ b/src/renderer/popup.html @@ -0,0 +1,26 @@ + + + + + + Xtranslate + + + + +
+ +
+
+ + + Enter 上屏 Shift+Enter 换行 Esc 关闭 + +
+
+ + + + diff --git a/src/renderer/popup.js b/src/renderer/popup.js new file mode 100644 index 0000000..7b5ccf0 --- /dev/null +++ b/src/renderer/popup.js @@ -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(); diff --git a/src/renderer/settings.css b/src/renderer/settings.css new file mode 100644 index 0000000..b375a8a --- /dev/null +++ b/src/renderer/settings.css @@ -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%; } +} diff --git a/src/renderer/settings.html b/src/renderer/settings.html new file mode 100644 index 0000000..ec8ab87 --- /dev/null +++ b/src/renderer/settings.html @@ -0,0 +1,85 @@ + + + + + + Xtranslate 设置 + + + + +
+
+

Xtranslate

+

中英互译输入法 · 按 Alt+Q 随处呼出

+
+ +
+

翻译引擎

+
+ + +
+

浮窗里按 Ctrl+E 可以随时在两种引擎之间切换。

+
+ +
+

免费引擎

+
+ + +
+
+
+ +
+

大模型

+
+ + +
+
+ + +
+
+ + + +
+
+ + +
+

+
+
+ +
+

常规

+
+ + + +
+ +
+ + +
+ + +
+
+ + + + diff --git a/src/renderer/settings.js b/src/renderer/settings.js new file mode 100644 index 0000000..0eac8bb --- /dev/null +++ b/src/renderer/settings.js @@ -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(); +})(); diff --git a/src/renderer/theme.css b/src/renderer/theme.css new file mode 100644 index 0000000..49d06c3 --- /dev/null +++ b/src/renderer/theme.css @@ -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); +}