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.
- voice_input/__init__.py +4 -0
- voice_input/__main__.py +5 -0
- voice_input/cli.py +158 -0
- voice_input/config.py +116 -0
- voice_input/server.py +341 -0
- voice_input/templates/index.html +570 -0
- voice_input/utils.py +55 -0
- voice_input-1.0.0.dist-info/METADATA +294 -0
- voice_input-1.0.0.dist-info/RECORD +12 -0
- voice_input-1.0.0.dist-info/WHEEL +5 -0
- voice_input-1.0.0.dist-info/entry_points.txt +2 -0
- voice_input-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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="删除">✕</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)
|