voice-input 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,570 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
6
+ <meta name="apple-mobile-web-app-capable" content="yes">
7
+ <meta name="mobile-web-app-capable" content="yes">
8
+ <meta name="theme-color" content="#f5f5f7">
9
+ <title>语音输入</title>
10
+ <style>
11
+ :root {
12
+ --bg: #f5f5f7;
13
+ --card: #ffffff;
14
+ --border: #e5e5ea;
15
+ --text: #1d1d1f;
16
+ --text2: #86868b;
17
+ --accent: #007aff;
18
+ --accent-active: #0056cc;
19
+ --success: #34c759;
20
+ --error: #ff3b30;
21
+ --warn: #ff9500;
22
+ --radius: 14px;
23
+ --shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06);
24
+ --safe-top: env(safe-area-inset-top, 0px);
25
+ --safe-bottom: env(safe-area-inset-bottom, 0px);
26
+ --safe-left: env(safe-area-inset-left, 0px);
27
+ --safe-right: env(safe-area-inset-right, 0px);
28
+ }
29
+ * { box-sizing: border-box; margin: 0; padding: 0; }
30
+ html { height: 100%; }
31
+ body {
32
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
33
+ background: var(--bg);
34
+ color: var(--text);
35
+ min-height: 100%;
36
+ padding: calc(12px + var(--safe-top)) calc(12px + var(--safe-right)) calc(20px + var(--safe-bottom)) calc(12px + var(--safe-left));
37
+ -webkit-font-smoothing: antialiased;
38
+ line-height: 1.5;
39
+ }
40
+ .container { max-width: 600px; margin: 0 auto; }
41
+
42
+ /* Header */
43
+ .header { text-align: center; padding: 16px 0 4px; }
44
+ .header h1 { font-size: 21px; font-weight: 700; letter-spacing: -0.3px; }
45
+ .header .server-info { font-size: 12px; color: var(--text2); margin-top: 2px; }
46
+
47
+ /* Card */
48
+ .card {
49
+ background: var(--card);
50
+ border-radius: var(--radius);
51
+ box-shadow: var(--shadow);
52
+ padding: 14px;
53
+ margin: 10px 0;
54
+ }
55
+
56
+ /* Textarea */
57
+ .input-area { position: relative; }
58
+ .input-area textarea {
59
+ width: 100%;
60
+ min-height: 120px;
61
+ font-size: 16px;
62
+ line-height: 1.5;
63
+ padding: 12px;
64
+ border: 2px solid var(--border);
65
+ border-radius: 12px;
66
+ background: var(--bg);
67
+ color: var(--text);
68
+ resize: vertical;
69
+ outline: none;
70
+ transition: border-color .2s;
71
+ -webkit-appearance: none;
72
+ appearance: none;
73
+ }
74
+ .input-area textarea:focus { border-color: var(--accent); }
75
+ .input-area .char-count {
76
+ position: absolute; right: 10px; bottom: 8px;
77
+ font-size: 11px; color: var(--text2); pointer-events: none;
78
+ }
79
+
80
+ /* Token */
81
+ .token-row input {
82
+ width: 100%; font-size: 15px; padding: 11px 12px;
83
+ border: 2px solid var(--border); border-radius: 12px; background: var(--bg);
84
+ outline: none; transition: border-color .2s;
85
+ -webkit-appearance: none; appearance: none;
86
+ }
87
+ .token-row input:focus { border-color: var(--accent); }
88
+ .token-row { margin-bottom: 10px; }
89
+
90
+ /* Mode selector */
91
+ .mode-group {
92
+ display: flex; background: var(--bg); border-radius: 10px;
93
+ padding: 3px; gap: 2px;
94
+ }
95
+ .mode-group label {
96
+ flex: 1; text-align: center; font-size: 13px; font-weight: 500;
97
+ padding: 8px 4px; border-radius: 8px; cursor: pointer;
98
+ transition: all .2s; color: var(--text2);
99
+ user-select: none; -webkit-user-select: none;
100
+ }
101
+ .mode-group input[type="radio"] { display: none; }
102
+ .mode-group input[type="radio"]:checked + label {
103
+ background: var(--card); color: var(--text);
104
+ box-shadow: 0 1px 3px rgba(0,0,0,.1);
105
+ }
106
+ .mode-title { font-size: 13px; font-weight: 600; color: var(--text2); margin-bottom: 8px; }
107
+
108
+ /* Toggle switch */
109
+ .toggle-row {
110
+ display: flex; align-items: center; justify-content: space-between;
111
+ padding: 4px 0; margin-top: 10px; gap: 12px;
112
+ }
113
+ .toggle-row .toggle-label { flex: 1; min-width: 0; }
114
+ .toggle-row .toggle-label span { font-size: 14px; font-weight: 500; display: block; }
115
+ .toggle-row .hint { font-size: 11px; color: var(--text2); font-weight: 400; line-height: 1.3; }
116
+ .switch { position: relative; width: 51px; height: 31px; flex-shrink: 0; }
117
+ .switch input { opacity: 0; width: 0; height: 0; }
118
+ .switch .slider {
119
+ position: absolute; inset: 0; background: #e9e9eb;
120
+ border-radius: 16px; transition: background .3s; cursor: pointer;
121
+ }
122
+ .switch .slider::before {
123
+ content: ''; position: absolute; width: 27px; height: 27px;
124
+ left: 2px; top: 2px; background: #fff; border-radius: 50%;
125
+ box-shadow: 0 1px 3px rgba(0,0,0,.2); transition: transform .3s;
126
+ }
127
+ .switch input:checked + .slider { background: var(--success); }
128
+ .switch input:checked + .slider::before { transform: translateX(20px); }
129
+
130
+ /* Delay slider */
131
+ .delay-row {
132
+ display: none; align-items: center; gap: 10px;
133
+ margin-top: 8px; padding: 8px 0 0;
134
+ }
135
+ .delay-row.show { display: flex; }
136
+ .delay-row label { font-size: 12px; color: var(--text2); white-space: nowrap; }
137
+ .delay-row input[type="range"] {
138
+ flex: 1; height: 4px; -webkit-appearance: none; appearance: none;
139
+ background: var(--border); border-radius: 2px; outline: none;
140
+ }
141
+ .delay-row input[type="range"]::-webkit-slider-thumb {
142
+ -webkit-appearance: none; appearance: none;
143
+ width: 22px; height: 22px; border-radius: 50%;
144
+ background: var(--accent); cursor: pointer;
145
+ box-shadow: 0 1px 3px rgba(0,0,0,.2);
146
+ }
147
+ .delay-row .delay-val { font-size: 13px; font-weight: 600; min-width: 32px; text-align: right; }
148
+
149
+ /* Send button */
150
+ .send-btn {
151
+ width: 100%; padding: 14px; font-size: 17px; font-weight: 600;
152
+ border: none; border-radius: 12px; background: var(--accent); color: #fff;
153
+ cursor: pointer; transition: background .2s, transform .1s;
154
+ -webkit-appearance: none; appearance: none; margin-top: 10px;
155
+ }
156
+ .send-btn:active { background: var(--accent-active); transform: scale(0.98); }
157
+ .send-btn:disabled { opacity: 0.5; }
158
+
159
+ /* Toast */
160
+ .toast {
161
+ position: fixed; top: calc(12px + var(--safe-top)); left: 50%;
162
+ transform: translateX(-50%) translateY(-80px);
163
+ padding: 10px 18px; border-radius: 12px; font-size: 14px; font-weight: 500;
164
+ color: #fff; z-index: 9999;
165
+ transition: transform .35s cubic-bezier(.4,0,.2,1), opacity .35s;
166
+ opacity: 0; pointer-events: none; max-width: 90vw; text-align: center;
167
+ box-shadow: 0 4px 12px rgba(0,0,0,.15);
168
+ }
169
+ .toast.show { transform: translateX(-50%) translateY(0); opacity: 1; }
170
+ .toast.ok { background: var(--success); }
171
+ .toast.err { background: var(--error); }
172
+
173
+ /* Section title */
174
+ .section-hdr {
175
+ display: flex; align-items: center; justify-content: space-between;
176
+ user-select: none; -webkit-user-select: none;
177
+ }
178
+ .section-hdr h3 { font-size: 14px; font-weight: 600; }
179
+
180
+ /* History */
181
+ .hist-toolbar {
182
+ display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 6px;
183
+ }
184
+ .hist-toolbar input[type="text"],
185
+ .hist-toolbar input[type="date"] {
186
+ flex: 1; min-width: 0; font-size: 13px; padding: 7px 10px;
187
+ border: 1px solid var(--border); border-radius: 8px; background: var(--bg);
188
+ outline: none; -webkit-appearance: none; appearance: none;
189
+ }
190
+ .hist-toolbar input:focus { border-color: var(--accent); }
191
+ .hist-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
192
+ .hist-actions button {
193
+ font-size: 12px; padding: 5px 10px; border: 1px solid var(--border);
194
+ border-radius: 8px; background: var(--card); color: var(--text);
195
+ cursor: pointer; white-space: nowrap;
196
+ }
197
+ .hist-actions button:active { background: var(--bg); }
198
+ .hist-actions .danger { color: var(--error); border-color: var(--error); }
199
+
200
+ .history-list { max-height: 300px; overflow-y: auto; -webkit-overflow-scrolling: touch; }
201
+ .history-item {
202
+ display: flex; align-items: flex-start; gap: 8px;
203
+ padding: 8px 0; border-bottom: 1px solid var(--border);
204
+ font-size: 13px; line-height: 1.4;
205
+ }
206
+ .history-item:last-child { border-bottom: none; }
207
+ .history-item .hi-body { flex: 1; min-width: 0; word-break: break-all; }
208
+ .history-item .hi-time { font-size: 11px; color: var(--text2); }
209
+ .history-item .hi-del {
210
+ flex-shrink: 0; width: 28px; height: 28px; border: none;
211
+ background: none; color: var(--text2); font-size: 16px; cursor: pointer;
212
+ display: flex; align-items: center; justify-content: center;
213
+ border-radius: 6px;
214
+ }
215
+ .history-item .hi-del:active { background: var(--bg); color: var(--error); }
216
+ .history-empty { text-align: center; color: var(--text2); font-size: 13px; padding: 16px 0; }
217
+
218
+ /* Responsive: small phones */
219
+ @media (max-width: 374px) {
220
+ body { padding: calc(8px + var(--safe-top)) calc(8px + var(--safe-right)) calc(16px + var(--safe-bottom)) calc(8px + var(--safe-left)); }
221
+ .card { padding: 12px; margin: 8px 0; }
222
+ .header h1 { font-size: 19px; }
223
+ .input-area textarea { min-height: 100px; font-size: 15px; padding: 10px; }
224
+ .send-btn { padding: 12px; font-size: 16px; }
225
+ .mode-group label { font-size: 12px; padding: 7px 3px; }
226
+ }
227
+
228
+ /* Responsive: tablets and wider */
229
+ @media (min-width: 768px) {
230
+ .container { max-width: 560px; }
231
+ .card { padding: 18px; margin: 12px 0; }
232
+ .input-area textarea { min-height: 160px; font-size: 17px; }
233
+ }
234
+
235
+ /* Landscape on phones */
236
+ @media (max-height: 500px) and (orientation: landscape) {
237
+ .header { padding: 8px 0 2px; }
238
+ .header h1 { font-size: 18px; }
239
+ .card { padding: 10px; margin: 6px 0; }
240
+ .input-area textarea { min-height: 80px; }
241
+ .send-btn { padding: 10px; }
242
+ }
243
+ </style>
244
+ </head>
245
+ <body>
246
+ <div class="container">
247
+
248
+ <div class="header">
249
+ <h1>语音输入</h1>
250
+ <div class="server-info">{{ server_ip }}:{{ port }}</div>
251
+ </div>
252
+
253
+ <div id="toast" class="toast"></div>
254
+
255
+ {% if require_token %}
256
+ <div class="card token-row">
257
+ <input id="token" type="password" placeholder="输入 Token" autocomplete="off">
258
+ </div>
259
+ {% endif %}
260
+
261
+ <!-- Input -->
262
+ <div class="card">
263
+ <div class="input-area">
264
+ <textarea id="text" placeholder="点击此处,使用语音输入法输入文字,然后点击发送..." autocomplete="off" autocorrect="off" spellcheck="false"></textarea>
265
+ <div class="char-count" id="charCount">0</div>
266
+ </div>
267
+ <button class="send-btn" id="send">发送到电脑</button>
268
+ </div>
269
+
270
+ <!-- Settings -->
271
+ <div class="card">
272
+ <div class="mode-title">发送模式</div>
273
+ <div class="mode-group">
274
+ <input type="radio" name="mode" id="mode_copy" value="copy">
275
+ <label for="mode_copy">仅复制</label>
276
+ <input type="radio" name="mode" id="mode_paste" value="paste" {{ 'checked' if auto_paste else '' }}>
277
+ <label for="mode_paste">自动粘贴</label>
278
+ <input type="radio" name="mode" id="mode_terminal" value="paste_terminal">
279
+ <label for="mode_terminal">终端粘贴</label>
280
+ </div>
281
+ {% if not auto_paste %}
282
+ <script>document.getElementById('mode_copy').checked = true;</script>
283
+ {% endif %}
284
+
285
+ <div class="toggle-row">
286
+ <div class="toggle-label">
287
+ <span>自动发送</span>
288
+ <span class="hint" id="autoSendHint">输入停顿后自动发送</span>
289
+ </div>
290
+ <label class="switch">
291
+ <input type="checkbox" id="autoSend">
292
+ <span class="slider"></span>
293
+ </label>
294
+ </div>
295
+ <div class="delay-row" id="delayRow">
296
+ <label>延迟</label>
297
+ <input type="range" id="delaySlider" min="0.5" max="5" step="0.5" value="1.5">
298
+ <span class="delay-val" id="delayVal">1.5s</span>
299
+ </div>
300
+
301
+ <div class="toggle-row">
302
+ <div class="toggle-label">
303
+ <span>发送后清空</span>
304
+ <span class="hint">发送成功后自动清空输入框</span>
305
+ </div>
306
+ <label class="switch">
307
+ <input type="checkbox" id="autoClear" checked>
308
+ <span class="slider"></span>
309
+ </label>
310
+ </div>
311
+
312
+ <div class="toggle-row">
313
+ <div class="toggle-label">
314
+ <span>恢复剪贴板</span>
315
+ <span class="hint">粘贴后恢复电脑原有剪贴板内容(仅复制模式下无效)</span>
316
+ </div>
317
+ <label class="switch">
318
+ <input type="checkbox" id="restoreClip" checked>
319
+ <span class="slider"></span>
320
+ </label>
321
+ </div>
322
+ </div>
323
+
324
+ <!-- History -->
325
+ <div class="card">
326
+ <div class="section-hdr">
327
+ <h3>发送历史</h3>
328
+ <label class="switch">
329
+ <input type="checkbox" id="historyEnabled">
330
+ <span class="slider"></span>
331
+ </label>
332
+ </div>
333
+ <div id="historyPanel" style="display:none; margin-top:10px;">
334
+ <div class="hist-toolbar">
335
+ <input type="text" id="histSearch" placeholder="搜索...">
336
+ <input type="date" id="histDate">
337
+ </div>
338
+ <div class="hist-actions">
339
+ <button id="histExportJson">导出 JSON</button>
340
+ <button id="histExportCsv">导出 CSV</button>
341
+ <button id="histClear" class="danger">清空全部</button>
342
+ </div>
343
+ <div class="history-list" id="historyList">
344
+ <div class="history-empty">暂无记录</div>
345
+ </div>
346
+ </div>
347
+ </div>
348
+
349
+ </div><!-- /container -->
350
+
351
+ <script>
352
+ (function() {
353
+ const $ = id => document.getElementById(id);
354
+ const LS = key => localStorage.getItem('vi_' + key);
355
+ const LS_SET = (key, val) => localStorage.setItem('vi_' + key, val);
356
+
357
+ // ===== Toast =====
358
+ let toastTimer = null;
359
+ function toast(msg, ok) {
360
+ const el = $('toast');
361
+ el.textContent = msg;
362
+ el.className = 'toast ' + (ok ? 'ok' : 'err') + ' show';
363
+ clearTimeout(toastTimer);
364
+ toastTimer = setTimeout(() => el.classList.remove('show'), 2500);
365
+ }
366
+
367
+ // ===== Token =====
368
+ const tokenEl = $('token');
369
+ if (tokenEl) {
370
+ tokenEl.value = LS('token') || '';
371
+ tokenEl.addEventListener('input', () => LS_SET('token', tokenEl.value));
372
+ }
373
+
374
+ // ===== Restore settings =====
375
+ const savedMode = LS('mode');
376
+ if (savedMode) {
377
+ const r = document.querySelector('input[name="mode"][value="' + savedMode + '"]');
378
+ if (r) r.checked = true;
379
+ }
380
+ document.querySelectorAll('input[name="mode"]').forEach(r => {
381
+ r.addEventListener('change', () => LS_SET('mode', r.value));
382
+ });
383
+
384
+ const autoSendEl = $('autoSend');
385
+ const autoClearEl = $('autoClear');
386
+ const restoreClipEl = $('restoreClip');
387
+ const delaySlider = $('delaySlider');
388
+ const delayVal = $('delayVal');
389
+ const delayRow = $('delayRow');
390
+ const histEnabledEl = $('historyEnabled');
391
+
392
+ function restoreToggle(el, key, def) {
393
+ const v = LS(key);
394
+ el.checked = v !== null ? v === '1' : def;
395
+ }
396
+ restoreToggle(autoSendEl, 'autoSend', false);
397
+ restoreToggle(autoClearEl, 'autoClear', true);
398
+ restoreToggle(restoreClipEl, 'restoreClip', true);
399
+ restoreToggle(histEnabledEl, 'historyEnabled', false);
400
+
401
+ autoSendEl.addEventListener('change', () => { LS_SET('autoSend', autoSendEl.checked ? '1' : '0'); updateDelayRow(); });
402
+ autoClearEl.addEventListener('change', () => LS_SET('autoClear', autoClearEl.checked ? '1' : '0'));
403
+ restoreClipEl.addEventListener('change', () => LS_SET('restoreClip', restoreClipEl.checked ? '1' : '0'));
404
+ histEnabledEl.addEventListener('change', () => {
405
+ LS_SET('historyEnabled', histEnabledEl.checked ? '1' : '0');
406
+ $('historyPanel').style.display = histEnabledEl.checked ? 'block' : 'none';
407
+ if (histEnabledEl.checked) loadHistory();
408
+ });
409
+ $('historyPanel').style.display = histEnabledEl.checked ? 'block' : 'none';
410
+
411
+ // ===== Delay slider =====
412
+ const savedDelay = LS('autoSendDelay');
413
+ if (savedDelay) delaySlider.value = savedDelay;
414
+ function getDelay() { return parseFloat(delaySlider.value) || 1.5; }
415
+ function updateDelayRow() {
416
+ delayRow.classList.toggle('show', autoSendEl.checked);
417
+ delayVal.textContent = getDelay() + 's';
418
+ $('autoSendHint').textContent = autoSendEl.checked
419
+ ? '输入停顿 ' + getDelay() + ' 秒后自动发送'
420
+ : '输入停顿后自动发送';
421
+ }
422
+ delaySlider.addEventListener('input', () => {
423
+ LS_SET('autoSendDelay', delaySlider.value);
424
+ updateDelayRow();
425
+ });
426
+ updateDelayRow();
427
+
428
+ // ===== Char count =====
429
+ const textEl = $('text');
430
+ const charCountEl = $('charCount');
431
+ textEl.addEventListener('input', () => { charCountEl.textContent = textEl.value.length; });
432
+
433
+ // ===== Auto-send debounce =====
434
+ let autoSendTimer = null;
435
+ textEl.addEventListener('input', () => {
436
+ clearTimeout(autoSendTimer);
437
+ if (autoSendEl.checked && textEl.value.trim()) {
438
+ autoSendTimer = setTimeout(() => doSend(), getDelay() * 1000);
439
+ }
440
+ });
441
+
442
+ // ===== Send =====
443
+ let sending = false;
444
+ async function doSend() {
445
+ if (sending) return;
446
+ const text = textEl.value || '';
447
+ if (!text.trim()) { toast('请先输入内容', false); return; }
448
+
449
+ const mode = document.querySelector('input[name="mode"]:checked');
450
+ const action = mode ? mode.value : 'paste';
451
+ const payload = { text, timestamp: Date.now(), device_id: 'phone_web', action,
452
+ restore_clipboard: (action !== 'copy' && restoreClipEl.checked) };
453
+ const headers = { 'Content-Type': 'application/json' };
454
+ if (tokenEl && tokenEl.value.trim()) headers['X-Auth-Token'] = tokenEl.value.trim();
455
+
456
+ sending = true;
457
+ $('send').disabled = true;
458
+ $('send').textContent = '发送中...';
459
+
460
+ try {
461
+ const res = await fetch('/input', { method: 'POST', headers, body: JSON.stringify(payload) });
462
+ const j = await res.json().catch(() => null);
463
+ if (res.ok) {
464
+ toast('已发送到电脑', true);
465
+ if (autoClearEl.checked) { textEl.value = ''; charCountEl.textContent = '0'; }
466
+ if (histEnabledEl.checked) loadHistory();
467
+ } else {
468
+ toast('失败: ' + (j && j.message ? j.message : res.status), false);
469
+ }
470
+ } catch (e) {
471
+ toast('网络错误: ' + e.message, false);
472
+ } finally {
473
+ sending = false;
474
+ $('send').disabled = false;
475
+ $('send').textContent = '发送到电脑';
476
+ }
477
+ }
478
+ $('send').addEventListener('click', doSend);
479
+
480
+ // ===== History =====
481
+ let allItems = [];
482
+
483
+ async function loadHistory() {
484
+ try {
485
+ const res = await fetch('/history');
486
+ const j = await res.json();
487
+ allItems = (j.items || []);
488
+ renderHistory();
489
+ } catch (e) { /* silent */ }
490
+ }
491
+
492
+ function renderHistory() {
493
+ const list = $('historyList');
494
+ let items = allItems;
495
+
496
+ // Search filter
497
+ const q = ($('histSearch').value || '').trim().toLowerCase();
498
+ if (q) items = items.filter(i => (i.text || '').toLowerCase().includes(q));
499
+
500
+ // Date filter
501
+ const d = $('histDate').value;
502
+ if (d) {
503
+ const dayStart = new Date(d).getTime();
504
+ const dayEnd = dayStart + 86400000;
505
+ items = items.filter(i => i.server_time >= dayStart && i.server_time < dayEnd);
506
+ }
507
+
508
+ if (items.length === 0) {
509
+ list.innerHTML = '<div class="history-empty">暂无记录</div>';
510
+ return;
511
+ }
512
+ list.innerHTML = items.map(item => {
513
+ const t = new Date(item.server_time).toLocaleString('zh-CN', {
514
+ month: '2-digit', day: '2-digit',
515
+ hour: '2-digit', minute: '2-digit', second: '2-digit'
516
+ });
517
+ const preview = item.text.length > 100 ? item.text.slice(0, 100) + '...' : item.text;
518
+ return '<div class="history-item">' +
519
+ '<div class="hi-body"><span class="hi-time">' + t + '</span><br>' + escHtml(preview) + '</div>' +
520
+ '<button class="hi-del" data-id="' + item.id + '" title="删除">&#x2715;</button>' +
521
+ '</div>';
522
+ }).join('');
523
+ }
524
+
525
+ function escHtml(s) {
526
+ const d = document.createElement('div');
527
+ d.textContent = s;
528
+ return d.innerHTML;
529
+ }
530
+
531
+ $('histSearch').addEventListener('input', renderHistory);
532
+ $('histDate').addEventListener('change', renderHistory);
533
+
534
+ // Delete single item
535
+ $('historyList').addEventListener('click', async (e) => {
536
+ const btn = e.target.closest('.hi-del');
537
+ if (!btn) return;
538
+ const id = btn.getAttribute('data-id');
539
+ try {
540
+ await fetch('/history/' + id, { method: 'DELETE' });
541
+ allItems = allItems.filter(i => String(i.id) !== String(id));
542
+ renderHistory();
543
+ } catch (ex) { toast('删除失败', false); }
544
+ });
545
+
546
+ // Clear all
547
+ $('histClear').addEventListener('click', async () => {
548
+ if (!confirm('确定清空全部历史记录?')) return;
549
+ try {
550
+ await fetch('/history', { method: 'DELETE' });
551
+ allItems = [];
552
+ renderHistory();
553
+ toast('已清空', true);
554
+ } catch (ex) { toast('清空失败', false); }
555
+ });
556
+
557
+ // Export
558
+ $('histExportJson').addEventListener('click', () => {
559
+ window.open('/history/export?format=json', '_blank');
560
+ });
561
+ $('histExportCsv').addEventListener('click', () => {
562
+ window.open('/history/export?format=csv', '_blank');
563
+ });
564
+
565
+ // Auto-focus
566
+ setTimeout(() => textEl.focus(), 300);
567
+ })();
568
+ </script>
569
+ </body>
570
+ </html>
voice_input/utils.py ADDED
@@ -0,0 +1,55 @@
1
+ """工具函数"""
2
+
3
+ import socket
4
+ import logging
5
+ import hmac
6
+ from ipaddress import ip_address, ip_network
7
+
8
+
9
+ def get_local_ip() -> str:
10
+ """获取本机局域网 IP 地址"""
11
+ try:
12
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
13
+ s.connect(("8.8.8.8", 80))
14
+ local_ip = s.getsockname()[0]
15
+ s.close()
16
+ return local_ip
17
+ except Exception as e:
18
+ logging.error(f"获取本地IP失败: {e}")
19
+ return "127.0.0.1"
20
+
21
+
22
+ def get_client_ip(req) -> str:
23
+ """从请求中提取客户端真实 IP(支持反向代理)"""
24
+ forwarded_for = req.headers.get("X-Forwarded-For", "").strip()
25
+ if forwarded_for:
26
+ return forwarded_for.split(",")[0].strip()
27
+ return req.remote_addr or "0.0.0.0"
28
+
29
+
30
+ def is_ip_allowed(ip: str, allowed_networks: list) -> bool:
31
+ """检查 IP 地址是否在白名单中"""
32
+ try:
33
+ client_ip = ip_address(ip)
34
+ for network in allowed_networks:
35
+ network = (network or "").strip()
36
+ if not network:
37
+ continue
38
+ if client_ip in ip_network(network, strict=False):
39
+ return True
40
+ return False
41
+ except ValueError:
42
+ return False
43
+
44
+
45
+ def is_token_valid(req, data: dict, expected_token: str, require: bool) -> bool:
46
+ """校验请求中的 token"""
47
+ if not expected_token:
48
+ return not require
49
+ header_token = (req.headers.get("X-Auth-Token") or "").strip()
50
+ query_token = (req.args.get("token") or "").strip()
51
+ body_token = ""
52
+ if isinstance(data, dict):
53
+ body_token = str(data.get("token", "")).strip()
54
+ provided = header_token or query_token or body_token
55
+ return bool(provided) and hmac.compare_digest(provided, expected_token)