dsh-users-open-source 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +103 -0
- package/assets/code-upload.jpg +0 -0
- package/cordis.patch.yml +21 -0
- package/lib/client.js +1088 -0
- package/lib/host.js +880 -0
- package/package.json +69 -0
- package/tests/smoke.mjs +656 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,1088 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-users-open-source — 客户端半边(DSH Web 界面)。
|
|
3
|
+
*
|
|
4
|
+
* 以 dsh.client bundle 格式加载:手写产物,不依赖任何构建步骤。
|
|
5
|
+
* · 注册 settings.section 「开源用户」页 —— 一台网速检测器:下载/上传两项指标 +
|
|
6
|
+
* 一颗「上传」按钮(点一下就开始跑本机带宽测速)+ 定时测速设置 + 历史记录;
|
|
7
|
+
* · 注册 sidebar.panellist 一栏(排在「插件」下面)—— 侧栏直接显示当前带宽;
|
|
8
|
+
* · 注册 main 面板(key = net-speed)—— 点侧栏那一栏进来的完整视图。
|
|
9
|
+
*
|
|
10
|
+
* 所有数据都问宿主半边的同源 HTTP 路由 /dsh-users-open-source/*;测速流量本身在宿主侧的
|
|
11
|
+
* 127.0.0.1 回环口里跑,浏览器只负责显示与下发指令,因此**不消耗任何 token**、
|
|
12
|
+
* 也**不连接任何外部服务器**。
|
|
13
|
+
*/
|
|
14
|
+
window.__ModuleLoader__.load({
|
|
15
|
+
id: 'dsh-users-open-source',
|
|
16
|
+
factory: (require) => {
|
|
17
|
+
var module = { exports: {} }
|
|
18
|
+
var exports = module.exports
|
|
19
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
20
|
+
|
|
21
|
+
const React = require('react')
|
|
22
|
+
const { useCallback, useEffect, useRef, useState } = React
|
|
23
|
+
const h = React.createElement
|
|
24
|
+
|
|
25
|
+
/** 宿主半边提供的路由前缀。 */
|
|
26
|
+
const API = '/dsh-users-open-source'
|
|
27
|
+
/** 侧栏栏目 id:同时是 main 面板的 key(侧栏按钮点击后按 id 选面板)。 */
|
|
28
|
+
const PANEL_ID = 'net-speed'
|
|
29
|
+
/**
|
|
30
|
+
* 「云端备份」的宿主路由(打开当前工作区目录)。
|
|
31
|
+
*
|
|
32
|
+
* ⚠️ 必须是**相对 API 前缀**的路径:`api()` 自己会拼上 `/dsh-users-open-source`。
|
|
33
|
+
* 写成完整路径会请求 `/dsh-users-open-source/dsh-users-open-source/...`,
|
|
34
|
+
* 落到 webServer 的 fallback 上被判成 405(踩过一次)。
|
|
35
|
+
*/
|
|
36
|
+
const OPEN_WORKSPACE_PATH = '/open-workspace'
|
|
37
|
+
/** 测速时弹的那张图(宿主从包内 assets 或用户上传的图里发出来)。 */
|
|
38
|
+
const IMAGE_URL = `${API}/asset/image`
|
|
39
|
+
/** 设置页在导航里的名字。 */
|
|
40
|
+
const SECTION_LABEL = '开源用户'
|
|
41
|
+
/**
|
|
42
|
+
* 客户端半边的构建标记。
|
|
43
|
+
* 界面反馈里会带上它 —— 刷新页面后如果这串没变,就说明浏览器跑的还是旧 bundle,
|
|
44
|
+
* 别再去猜"按钮为什么没反应"。
|
|
45
|
+
*/
|
|
46
|
+
const CLIENT_BUILD = '0.1.0'
|
|
47
|
+
|
|
48
|
+
const CSS = [
|
|
49
|
+
'.dns-wrap{display:flex;flex-direction:column;gap:14px;font-size:13px;color:var(--dsw-alias-label-primary);line-height:1.5}',
|
|
50
|
+
'.dns-wrap.is-panel{padding:18px 22px 26px;max-width:820px;box-sizing:border-box}',
|
|
51
|
+
'.dns-head{display:flex;flex-direction:column;gap:4px}',
|
|
52
|
+
'.dns-title{margin:0;font-size:15px;font-weight:650}',
|
|
53
|
+
'.dns-metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px}',
|
|
54
|
+
'.dns-card{border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-layer-1);padding:12px 14px;display:flex;flex-direction:column;gap:6px;box-shadow:0 1px 3px rgba(0,0,0,.06)}',
|
|
55
|
+
'.dns-card.live{border-color:var(--dsw-alias-brand-primary)}',
|
|
56
|
+
'.dns-card-label{font-size:12px;color:var(--dsw-alias-label-secondary);display:flex;align-items:center;gap:6px}',
|
|
57
|
+
'.dns-dot{width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-brand-primary);display:inline-block}',
|
|
58
|
+
'.dns-rate{font-size:26px;font-weight:650;font-variant-numeric:tabular-nums;letter-spacing:-.01em}',
|
|
59
|
+
'.dns-rate small{font-size:12px;font-weight:400;color:var(--dsw-alias-label-secondary);margin-left:8px}',
|
|
60
|
+
'.dns-card-sub{font-size:11px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}',
|
|
61
|
+
'.dns-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}',
|
|
62
|
+
'.dns-wrap button{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:5px 12px;font-size:13px;cursor:pointer;transition:border-color .12s ease,background .12s ease}',
|
|
63
|
+
'.dns-wrap button:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary)}',
|
|
64
|
+
'.dns-wrap button:disabled{opacity:.5;cursor:default}',
|
|
65
|
+
'.dns-wrap .dns-btn-primary{background:var(--dsw-alias-button-primary-fill);border-color:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground);font-weight:600;padding:7px 20px}',
|
|
66
|
+
'.dns-wrap .dns-btn-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover);border-color:var(--dsw-alias-button-primary-hover)}',
|
|
67
|
+
'.dns-progress{height:6px;border-radius:999px;background:var(--dsw-alias-bg-layer-2);overflow:hidden}',
|
|
68
|
+
'.dns-progress > i{display:block;height:100%;background:var(--dsw-alias-brand-primary);transition:width .2s ease}',
|
|
69
|
+
'.dns-spark{display:flex;align-items:flex-end;gap:2px;height:46px;padding:4px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);box-sizing:border-box}',
|
|
70
|
+
'.dns-spark > i{flex:1 1 0;background:var(--dsw-alias-brand-primary);border-radius:2px 2px 0 0;min-height:2px;opacity:.85}',
|
|
71
|
+
'.dns-block{display:flex;flex-direction:column;gap:8px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-layer-1);padding:12px 14px}',
|
|
72
|
+
'.dns-sect-title{font-size:12px;font-weight:650;color:var(--dsw-alias-label-secondary);text-transform:uppercase;letter-spacing:.04em}',
|
|
73
|
+
'.dns-row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}',
|
|
74
|
+
'.dns-field{display:flex;gap:6px;align-items:center;font-size:12px;color:var(--dsw-alias-label-secondary)}',
|
|
75
|
+
'.dns-wrap input[type=number],.dns-wrap input[type=time],.dns-wrap select{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:5px 9px;font-size:13px;outline:none;transition:border-color .12s ease}',
|
|
76
|
+
'.dns-wrap input[type=number]{width:74px}',
|
|
77
|
+
'.dns-wrap input:focus,.dns-wrap select:focus{border-color:var(--dsw-alias-brand-primary)}',
|
|
78
|
+
'.dns-hint{font-size:12px;color:var(--dsw-alias-label-secondary)}',
|
|
79
|
+
'.dns-badge{font-size:11px;padding:1px 8px;border-radius:999px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,transparent);color:var(--dsw-alias-brand-primary)}',
|
|
80
|
+
'.dns-warn{font-size:12px;color:#e5484d}',
|
|
81
|
+
'.dns-hist{display:flex;flex-direction:column;gap:6px}',
|
|
82
|
+
'.dns-hist-head{display:flex;align-items:center;justify-content:space-between;gap:8px}',
|
|
83
|
+
'.dns-hist-row{display:grid;grid-template-columns:76px 1fr 1fr 62px 22px;gap:8px;align-items:center;font-size:12px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}',
|
|
84
|
+
'.dns-wrap .dns-del{padding:0;width:20px;height:20px;line-height:1;font-size:13px;border-radius:6px;opacity:.45;background:transparent;border-color:transparent}',
|
|
85
|
+
'.dns-wrap .dns-del:hover:not(:disabled){opacity:1;border-color:var(--dsw-alias-brand-primary);color:#e5484d}',
|
|
86
|
+
'.dns-icon.on{color:var(--dsw-alias-brand-primary)}',
|
|
87
|
+
'.dns-empty{font-size:12px;color:var(--dsw-alias-label-secondary);padding:6px 0}',
|
|
88
|
+
// 侧栏那一栏:图标在上,测速时图标下面接一张图 —— 图是栏目内容的一部分,会把栏目撑高,
|
|
89
|
+
// 而不是 fixed 浮层(浮层会盖住下面的东西,观感像被挡住)。
|
|
90
|
+
'.dns-bar{display:flex;flex-direction:column;align-items:center;gap:6px;max-width:100%}',
|
|
91
|
+
'.dns-bar-shot{display:block;max-width:100%;height:auto;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,.25)}',
|
|
92
|
+
// 面板里那张(柱形图下面)。
|
|
93
|
+
'.dns-shot{width:100%;max-width:340px;border-radius:12px;border:1px solid var(--dsw-alias-border-l1);align-self:flex-start}'
|
|
94
|
+
].join('\n')
|
|
95
|
+
|
|
96
|
+
// ══════════════════════════════════════════════════════════════════
|
|
97
|
+
// 数值格式化
|
|
98
|
+
// ══════════════════════════════════════════════════════════════════
|
|
99
|
+
|
|
100
|
+
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 把「字节/秒」缩放成人类可读的数值与单位。
|
|
104
|
+
* @param {unknown} bytesPerSecond - 速率(字节/秒)。
|
|
105
|
+
* @returns {{value:number, unit:string}} 缩放后的数值与单位。
|
|
106
|
+
*/
|
|
107
|
+
function scaleRate(bytesPerSecond) {
|
|
108
|
+
const bps = typeof bytesPerSecond === 'number' && Number.isFinite(bytesPerSecond) && bytesPerSecond > 0
|
|
109
|
+
? bytesPerSecond
|
|
110
|
+
: 0
|
|
111
|
+
let value = bps
|
|
112
|
+
let index = 0
|
|
113
|
+
while (value >= 1024 && index < UNITS.length - 1) {
|
|
114
|
+
value /= 1024
|
|
115
|
+
index += 1
|
|
116
|
+
}
|
|
117
|
+
return { value, unit: UNITS[index] }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 速率文本,如 `1.23 GB/s`。
|
|
122
|
+
* @param {unknown} bytesPerSecond - 速率(字节/秒)。
|
|
123
|
+
* @returns {string} 展示文本。
|
|
124
|
+
*/
|
|
125
|
+
function formatRate(bytesPerSecond) {
|
|
126
|
+
const { value, unit } = scaleRate(bytesPerSecond)
|
|
127
|
+
const digits = value >= 100 ? 0 : value >= 10 ? 1 : 2
|
|
128
|
+
return `${value.toFixed(digits)} ${unit}/s`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 速率文本(比特),如 `987 Mbps`。
|
|
133
|
+
* @param {unknown} bytesPerSecond - 速率(字节/秒)。
|
|
134
|
+
* @returns {string} 展示文本。
|
|
135
|
+
*/
|
|
136
|
+
function formatBitRate(bytesPerSecond) {
|
|
137
|
+
const bps = typeof bytesPerSecond === 'number' && Number.isFinite(bytesPerSecond) ? bytesPerSecond : 0
|
|
138
|
+
const bits = bps * 8
|
|
139
|
+
if (bits >= 1e9) return `${(bits / 1e9).toFixed(bits >= 1e10 ? 0 : 2)} Gbps`
|
|
140
|
+
if (bits >= 1e6) return `${(bits / 1e6).toFixed(bits >= 1e8 ? 0 : 1)} Mbps`
|
|
141
|
+
if (bits >= 1e3) return `${(bits / 1e3).toFixed(0)} Kbps`
|
|
142
|
+
return `${Math.round(bits)} bps`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 紧凑速率,给宽度有限的侧栏用,如 `1.23G`。
|
|
147
|
+
* @param {unknown} bytesPerSecond - 速率(字节/秒)。
|
|
148
|
+
* @returns {string} 紧凑文本。
|
|
149
|
+
*/
|
|
150
|
+
function compactRate(bytesPerSecond) {
|
|
151
|
+
const { value, unit } = scaleRate(bytesPerSecond)
|
|
152
|
+
const digits = value >= 100 ? 0 : value >= 10 ? 1 : 2
|
|
153
|
+
return `${value.toFixed(digits)}${unit === 'B' ? 'B' : unit.charAt(0)}`
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 字节数文本,如 `2.5 GB`。
|
|
158
|
+
* @param {unknown} bytes - 字节数。
|
|
159
|
+
* @returns {string} 展示文本。
|
|
160
|
+
*/
|
|
161
|
+
function formatBytes(bytes) {
|
|
162
|
+
const value = typeof bytes === 'number' && Number.isFinite(bytes) && bytes > 0 ? bytes : 0
|
|
163
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
164
|
+
let scaled = value
|
|
165
|
+
let index = 0
|
|
166
|
+
while (scaled >= 1024 && index < units.length - 1) {
|
|
167
|
+
scaled /= 1024
|
|
168
|
+
index += 1
|
|
169
|
+
}
|
|
170
|
+
const digits = scaled >= 100 ? 0 : scaled >= 10 ? 1 : 2
|
|
171
|
+
return `${scaled.toFixed(digits)} ${units[index]}`
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 把 ISO 时间转成本地时间文本。
|
|
176
|
+
* @param {unknown} iso - ISO 时间串。
|
|
177
|
+
* @returns {string} 本地时间文本。
|
|
178
|
+
*/
|
|
179
|
+
function formatTime(iso) {
|
|
180
|
+
if (typeof iso !== 'string' || iso === '') return '—'
|
|
181
|
+
const date = new Date(iso)
|
|
182
|
+
if (Number.isNaN(date.getTime())) return '—'
|
|
183
|
+
return date.toLocaleTimeString()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ══════════════════════════════════════════════════════════════════
|
|
187
|
+
// 共享状态 + 轮询(侧栏 label 也要读它,所以放在模块级)
|
|
188
|
+
// ══════════════════════════════════════════════════════════════════
|
|
189
|
+
|
|
190
|
+
/** 最近一次 /state 快照 + 订阅者。 */
|
|
191
|
+
const store = {
|
|
192
|
+
snapshot: null,
|
|
193
|
+
listeners: new Set(),
|
|
194
|
+
update(next) {
|
|
195
|
+
store.snapshot = next
|
|
196
|
+
for (const listener of Array.from(store.listeners)) {
|
|
197
|
+
try {
|
|
198
|
+
listener()
|
|
199
|
+
} catch {
|
|
200
|
+
/* 单个订阅者出错不能拖垮其他人 */
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
refreshSidebarLabel()
|
|
204
|
+
},
|
|
205
|
+
subscribe(listener) {
|
|
206
|
+
store.listeners.add(listener)
|
|
207
|
+
return () => {
|
|
208
|
+
store.listeners.delete(listener)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** 客户端 ctx(apply 时记录,侧栏刷新要用)。 */
|
|
214
|
+
let clientCtx = null
|
|
215
|
+
/** 侧栏那一栏当前那次注入的清理函数。 */
|
|
216
|
+
let panelInjection = null
|
|
217
|
+
/** 上次刷新侧栏用的 key 与时刻,用来防抖。 */
|
|
218
|
+
let lastBumpKey = ''
|
|
219
|
+
let lastBumpAt = 0
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 注册侧栏「网络带宽」那一栏。
|
|
223
|
+
* @returns {void}
|
|
224
|
+
*/
|
|
225
|
+
function registerSidebarPanel() {
|
|
226
|
+
if (clientCtx === null || clientCtx.slots === undefined) return
|
|
227
|
+
panelInjection = clientCtx.slots.inject('sidebar.panellist', () => clientCtx.slots.register({
|
|
228
|
+
name: 'sidebar.panellist',
|
|
229
|
+
id: PANEL_ID,
|
|
230
|
+
order: 10,
|
|
231
|
+
label: () => panelLabel()
|
|
232
|
+
}, (ownerProps) => h(SpeedIcon, {
|
|
233
|
+
size: ownerProps !== null && ownerProps !== undefined && typeof ownerProps.size === 'number' ? ownerProps.size : 30,
|
|
234
|
+
active: ownerProps !== null && ownerProps !== undefined && ownerProps.active === true
|
|
235
|
+
})), 'net-speed: 侧栏带宽栏目')
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 侧栏那一栏的文字来自 `label` thunk,而 thunk 只在**投影时**被读一次:
|
|
240
|
+
* 数字变了就得重新注册一次,让 ledger bump 变成 sidebar 的重渲染触发器。
|
|
241
|
+
*
|
|
242
|
+
* 刻意防抖:测速中只按「阶段」刷新,不跟着 300ms 的瞬时速率抖,省得把侧栏
|
|
243
|
+
* 整棵树每秒重建好几遍。
|
|
244
|
+
* @returns {void}
|
|
245
|
+
*/
|
|
246
|
+
function refreshSidebarLabel() {
|
|
247
|
+
if (clientCtx === null) return
|
|
248
|
+
const snapshot = store.snapshot
|
|
249
|
+
const key = snapshot === null
|
|
250
|
+
? 'boot'
|
|
251
|
+
: snapshot.running === true
|
|
252
|
+
? 'run'
|
|
253
|
+
: `idle:${panelLabel()}`
|
|
254
|
+
if (key === lastBumpKey) return
|
|
255
|
+
if (lastBumpKey !== '' && Date.now() - lastBumpAt < 1000) return
|
|
256
|
+
lastBumpKey = key
|
|
257
|
+
lastBumpAt = Date.now()
|
|
258
|
+
if (panelInjection !== null) {
|
|
259
|
+
try {
|
|
260
|
+
panelInjection()
|
|
261
|
+
} catch {
|
|
262
|
+
/* 旧注入已经失效就算了 */
|
|
263
|
+
}
|
|
264
|
+
panelInjection = null
|
|
265
|
+
}
|
|
266
|
+
registerSidebarPanel()
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** 轮询句柄(0 表示没在轮)。 */
|
|
270
|
+
let pollTimer = 0
|
|
271
|
+
/** 防止重入。 */
|
|
272
|
+
let pollBusy = false
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 问一次宿主状态,更新共享快照。
|
|
276
|
+
* @returns {Promise<void>} 请求结束。
|
|
277
|
+
*/
|
|
278
|
+
async function pollOnce() {
|
|
279
|
+
if (pollBusy) return
|
|
280
|
+
pollBusy = true
|
|
281
|
+
try {
|
|
282
|
+
const res = await fetch(`${API}/state`, { cache: 'no-store' })
|
|
283
|
+
if (res.ok) store.update(await res.json())
|
|
284
|
+
} catch {
|
|
285
|
+
/* 宿主还没起来:保留上一次快照,别刷错误 */
|
|
286
|
+
} finally {
|
|
287
|
+
pollBusy = false
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* 确保轮询循环在跑:测速中 300ms 一次,空闲 5s 一次;没人订阅就停。
|
|
293
|
+
* @returns {void}
|
|
294
|
+
*/
|
|
295
|
+
function ensurePolling() {
|
|
296
|
+
if (pollTimer !== 0) return
|
|
297
|
+
const tick = async () => {
|
|
298
|
+
pollTimer = 0
|
|
299
|
+
if (store.listeners.size === 0) return
|
|
300
|
+
await pollOnce()
|
|
301
|
+
const running = store.snapshot !== null && store.snapshot.running === true
|
|
302
|
+
pollTimer = setTimeout(tick, running ? 300 : 5000)
|
|
303
|
+
}
|
|
304
|
+
pollTimer = setTimeout(tick, 0)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* 订阅快照的 Hook。
|
|
309
|
+
* @returns {object|null} 最新快照。
|
|
310
|
+
*/
|
|
311
|
+
function useSnapshot() {
|
|
312
|
+
const [snap, setSnap] = useState(() => store.snapshot)
|
|
313
|
+
useEffect(() => {
|
|
314
|
+
const unsubscribe = store.subscribe(() => setSnap(store.snapshot))
|
|
315
|
+
ensurePolling()
|
|
316
|
+
return unsubscribe
|
|
317
|
+
}, [])
|
|
318
|
+
return snap
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* 打一个插件 HTTP 接口。
|
|
323
|
+
* @param {string} path - 以 `/` 开头的路径。
|
|
324
|
+
* @param {object} [options] - fetch 选项。
|
|
325
|
+
* @param {number} [timeoutMs] - 超时(毫秒),到点就当失败,免得按钮永远卡在「打开中」。
|
|
326
|
+
* @returns {Promise<{ok:boolean,status:number,payload:object}>} 结果。
|
|
327
|
+
*/
|
|
328
|
+
async function api(path, options, timeoutMs = 10000) {
|
|
329
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
330
|
+
const timer = controller === null ? 0 : setTimeout(() => controller.abort(), timeoutMs)
|
|
331
|
+
try {
|
|
332
|
+
const res = await fetch(`${API}${path}`, {
|
|
333
|
+
cache: 'no-store',
|
|
334
|
+
...(options || {}),
|
|
335
|
+
...(controller === null ? {} : { signal: controller.signal })
|
|
336
|
+
})
|
|
337
|
+
let payload = {}
|
|
338
|
+
try {
|
|
339
|
+
payload = await res.json()
|
|
340
|
+
} catch {
|
|
341
|
+
payload = {}
|
|
342
|
+
}
|
|
343
|
+
return { ok: res.ok, status: res.status, payload: payload !== null && typeof payload === 'object' ? payload : {} }
|
|
344
|
+
} catch (error) {
|
|
345
|
+
const aborted = error !== null && error !== undefined && error.name === 'AbortError'
|
|
346
|
+
return {
|
|
347
|
+
ok: false,
|
|
348
|
+
status: 0,
|
|
349
|
+
payload: { ok: false, error: aborted ? 'timeout' : String((error && error.message) || error) }
|
|
350
|
+
}
|
|
351
|
+
} finally {
|
|
352
|
+
if (timer !== 0) clearTimeout(timer)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* 当前打开的会话 id(宿主据此判断这个会话属于哪个工作区)。
|
|
358
|
+
* 拿不到就交空串 —— 宿主会退回工作区注册表里的第一个。
|
|
359
|
+
* @returns {string} 会话 id,或空串。
|
|
360
|
+
*/
|
|
361
|
+
function currentSessionId() {
|
|
362
|
+
if (clientCtx === null) return ''
|
|
363
|
+
try {
|
|
364
|
+
const sessions = clientCtx.get('sessions')
|
|
365
|
+
const snapshot = sessions !== null && sessions !== undefined && sessions.list !== undefined
|
|
366
|
+
? sessions.list.getSnapshot()
|
|
367
|
+
: null
|
|
368
|
+
if (snapshot !== null && typeof snapshot === 'object') {
|
|
369
|
+
for (const key of ['current', 'currentId', 'selected', 'selectedId']) {
|
|
370
|
+
if (typeof snapshot[key] === 'string' && snapshot[key] !== '') return snapshot[key]
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
} catch {
|
|
374
|
+
/* 拿不到就算了,宿主会自己兜底 */
|
|
375
|
+
}
|
|
376
|
+
return ''
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* 侧栏那一栏显示的文本。
|
|
381
|
+
* @returns {string} 「↓1.23G ↑0.98G」,还没测过时是「等待上传」,正在跑时是「开源中」。
|
|
382
|
+
*/
|
|
383
|
+
function panelLabel() {
|
|
384
|
+
const snapshot = store.snapshot
|
|
385
|
+
if (snapshot !== null && snapshot.running === true) return '开源中'
|
|
386
|
+
if (snapshot === null || snapshot.last === null || snapshot.last === undefined) return '等待上传'
|
|
387
|
+
return `↓${compactRate(snapshot.last.download.bps)} ↑${compactRate(snapshot.last.upload.bps)}`
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ══════════════════════════════════════════════════════════════════
|
|
391
|
+
// 侧栏图标
|
|
392
|
+
// ══════════════════════════════════════════════════════════════════
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* 侧栏「插件」下面那一栏:图标 +(测速时)图标下面那张图。
|
|
396
|
+
*
|
|
397
|
+
* 图片是**这一栏内容的一部分**(纵向排列,把栏目撑高),不是 fixed 浮层 ——
|
|
398
|
+
* 浮层会盖住侧栏下面的东西,观感像被挡住。宽度由设置里的「侧栏图片」控制。
|
|
399
|
+
* @param {{size?:number, active?:boolean}} props - 侧栏给的图标槽属性。
|
|
400
|
+
* @returns {object} React 元素。
|
|
401
|
+
*/
|
|
402
|
+
function SpeedIcon(props) {
|
|
403
|
+
const size = typeof props.size === 'number' && props.size > 0 ? props.size : 30
|
|
404
|
+
const snap = useSnapshot()
|
|
405
|
+
const running = snap !== null && snap.running === true
|
|
406
|
+
const config = snap !== null && snap.config !== undefined && snap.config !== null ? snap.config : null
|
|
407
|
+
const shotWidth = config !== null && typeof config.sidebarImageSize === 'number' && config.sidebarImageSize > 0
|
|
408
|
+
? config.sidebarImageSize
|
|
409
|
+
: 120
|
|
410
|
+
return h('div', { className: 'dns-bar' }, [
|
|
411
|
+
h('svg', {
|
|
412
|
+
key: 'icon',
|
|
413
|
+
className: props.active === true ? 'dns-icon on' : 'dns-icon',
|
|
414
|
+
width: size,
|
|
415
|
+
height: size,
|
|
416
|
+
viewBox: '0 0 24 24',
|
|
417
|
+
fill: 'none',
|
|
418
|
+
stroke: 'currentColor',
|
|
419
|
+
strokeWidth: 1.7,
|
|
420
|
+
strokeLinecap: 'round',
|
|
421
|
+
strokeLinejoin: 'round',
|
|
422
|
+
role: 'img'
|
|
423
|
+
}, [
|
|
424
|
+
h('title', { key: 'title' }, panelLabel()),
|
|
425
|
+
h('path', { key: 'shaft', d: 'M12 3.5v12' }),
|
|
426
|
+
h('path', { key: 'head', d: 'M7 10.5l5 5 5-5' }),
|
|
427
|
+
h('path', { key: 'base', d: 'M5 20.5h14' }),
|
|
428
|
+
running ? h('circle', { key: 'live', cx: 12, cy: 20.5, r: 1.4, fill: 'currentColor', stroke: 'none' }) : null
|
|
429
|
+
]),
|
|
430
|
+
running ? h('img', {
|
|
431
|
+
key: 'shot',
|
|
432
|
+
className: 'dns-bar-shot',
|
|
433
|
+
src: IMAGE_URL,
|
|
434
|
+
alt: '代码在自己上传',
|
|
435
|
+
style: { width: `${shotWidth}px` }
|
|
436
|
+
}) : null
|
|
437
|
+
])
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ══════════════════════════════════════════════════════════════════
|
|
441
|
+
// 面板零件
|
|
442
|
+
// ══════════════════════════════════════════════════════════════════
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* 一个指标卡(下载 / 上传)。
|
|
446
|
+
* @param {{label:string, bps:number, sub:string, live:boolean}} props - 卡片属性。
|
|
447
|
+
* @returns {object} React 元素。
|
|
448
|
+
*/
|
|
449
|
+
function MetricCard(props) {
|
|
450
|
+
return h('div', { className: props.live ? 'dns-card live' : 'dns-card' }, [
|
|
451
|
+
h('div', { className: 'dns-card-label', key: 'label' }, [
|
|
452
|
+
props.live ? h('i', { className: 'dns-dot', key: 'dot' }) : null,
|
|
453
|
+
h('span', { key: 'text' }, props.label)
|
|
454
|
+
]),
|
|
455
|
+
h('div', { className: 'dns-rate', key: 'rate' }, [
|
|
456
|
+
formatRate(props.bps),
|
|
457
|
+
h('small', { key: 'bits' }, formatBitRate(props.bps))
|
|
458
|
+
]),
|
|
459
|
+
h('div', { className: 'dns-card-sub', key: 'sub' }, props.sub)
|
|
460
|
+
])
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* 瞬时速率柱状图。
|
|
465
|
+
* @param {{samples?:number[]}} props - 采样序列。
|
|
466
|
+
* @returns {object} React 元素。
|
|
467
|
+
*/
|
|
468
|
+
function Sparkline(props) {
|
|
469
|
+
const samples = Array.isArray(props.samples) ? props.samples.slice(-34) : []
|
|
470
|
+
if (samples.length === 0) {
|
|
471
|
+
return h('div', { className: 'dns-spark' }, [h('i', { key: 'empty', style: { height: '2px', opacity: 0.25 } })])
|
|
472
|
+
}
|
|
473
|
+
const peak = samples.reduce((max, value) => (value > max ? value : max), 0) || 1
|
|
474
|
+
return h('div', { className: 'dns-spark' }, samples.map((value, index) => h('i', {
|
|
475
|
+
key: index,
|
|
476
|
+
style: { height: `${Math.max(2, Math.round((value / peak) * 100))}%` }
|
|
477
|
+
})))
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* 定时测速设置区。
|
|
482
|
+
* @param {object} props - 组件属性。
|
|
483
|
+
* @returns {object} React 元素。
|
|
484
|
+
*/
|
|
485
|
+
function ScheduleBlock(props) {
|
|
486
|
+
const draft = props.draft
|
|
487
|
+
const schedule = draft.schedule
|
|
488
|
+
const usesHours = schedule.intervalMinutes >= 60 && schedule.intervalMinutes % 60 === 0
|
|
489
|
+
const intervalValue = usesHours ? schedule.intervalMinutes / 60 : schedule.intervalMinutes
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* 改定时策略里的一个字段。
|
|
493
|
+
* @param {object} patch - 要改的字段。
|
|
494
|
+
* @returns {void}
|
|
495
|
+
*/
|
|
496
|
+
const patchSchedule = (patch) => {
|
|
497
|
+
props.onPatch({ schedule: { ...schedule, ...patch } })
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return h('div', { className: 'dns-block' }, [
|
|
501
|
+
h('div', { className: 'dns-row', key: 'head' }, [
|
|
502
|
+
h('span', { className: 'dns-sect-title', key: 'title' }, '自动上传'),
|
|
503
|
+
props.dirty ? h('span', { className: 'dns-warn', key: 'dirty' }, '有未保存的改动') : null,
|
|
504
|
+
props.notice !== '' ? h('span', { className: 'dns-badge', key: 'notice' }, props.notice) : null
|
|
505
|
+
]),
|
|
506
|
+
h('div', { className: 'dns-row', key: 'row' }, [
|
|
507
|
+
h('select', {
|
|
508
|
+
key: 'mode',
|
|
509
|
+
value: schedule.mode,
|
|
510
|
+
disabled: props.saving,
|
|
511
|
+
onChange: (event) => patchSchedule({ mode: event.target.value })
|
|
512
|
+
}, [
|
|
513
|
+
h('option', { key: 'off', value: 'off' }, '不定时'),
|
|
514
|
+
h('option', { key: 'interval', value: 'interval' }, '每隔一段时间跑一次'),
|
|
515
|
+
h('option', { key: 'daily', value: 'daily' }, '每天固定时刻跑一次')
|
|
516
|
+
]),
|
|
517
|
+
schedule.mode === 'interval' ? h('span', { className: 'dns-field', key: 'interval' }, [
|
|
518
|
+
h('span', { key: 'label' }, '每'),
|
|
519
|
+
h('input', {
|
|
520
|
+
key: 'value',
|
|
521
|
+
type: 'number',
|
|
522
|
+
min: 1,
|
|
523
|
+
max: usesHours ? 168 : 10080,
|
|
524
|
+
value: intervalValue,
|
|
525
|
+
disabled: props.saving,
|
|
526
|
+
onChange: (event) => {
|
|
527
|
+
const raw = Number(event.target.value)
|
|
528
|
+
const safe = Number.isFinite(raw) && raw > 0 ? raw : 1
|
|
529
|
+
patchSchedule({ intervalMinutes: Math.round(usesHours ? safe * 60 : safe) })
|
|
530
|
+
}
|
|
531
|
+
}),
|
|
532
|
+
h('select', {
|
|
533
|
+
key: 'unit',
|
|
534
|
+
value: usesHours ? 'hour' : 'minute',
|
|
535
|
+
disabled: props.saving,
|
|
536
|
+
onChange: (event) => {
|
|
537
|
+
const nextUnit = event.target.value
|
|
538
|
+
const minutes = Math.round(nextUnit === 'hour' ? Math.max(1, intervalValue) * 60 : Math.max(1, intervalValue))
|
|
539
|
+
patchSchedule({ intervalMinutes: minutes })
|
|
540
|
+
}
|
|
541
|
+
}, [
|
|
542
|
+
h('option', { key: 'minute', value: 'minute' }, '分钟'),
|
|
543
|
+
h('option', { key: 'hour', value: 'hour' }, '小时')
|
|
544
|
+
])
|
|
545
|
+
]) : null,
|
|
546
|
+
schedule.mode === 'daily' ? h('span', { className: 'dns-field', key: 'daily' }, [
|
|
547
|
+
h('span', { key: 'label' }, '每天'),
|
|
548
|
+
h('input', {
|
|
549
|
+
key: 'time',
|
|
550
|
+
type: 'time',
|
|
551
|
+
value: schedule.dailyTime,
|
|
552
|
+
disabled: props.saving,
|
|
553
|
+
onChange: (event) => {
|
|
554
|
+
if (/^([01]\d|2[0-3]):[0-5]\d$/.test(event.target.value)) patchSchedule({ dailyTime: event.target.value })
|
|
555
|
+
}
|
|
556
|
+
})
|
|
557
|
+
]) : null,
|
|
558
|
+
h('button', {
|
|
559
|
+
key: 'save',
|
|
560
|
+
type: 'button',
|
|
561
|
+
disabled: props.saving || !props.dirty,
|
|
562
|
+
onClick: props.onSave
|
|
563
|
+
}, props.saving ? '保存中…' : '保存')
|
|
564
|
+
]),
|
|
565
|
+
h('div', { className: 'dns-row', key: 'args' }, [
|
|
566
|
+
h('span', { className: 'dns-field', key: 'duration' }, [
|
|
567
|
+
h('span', { key: 'label' }, '单次时长'),
|
|
568
|
+
h('input', {
|
|
569
|
+
key: 'value',
|
|
570
|
+
type: 'number',
|
|
571
|
+
min: 1,
|
|
572
|
+
max: 15,
|
|
573
|
+
value: Math.round(draft.durationMs / 1000),
|
|
574
|
+
disabled: props.saving,
|
|
575
|
+
onChange: (event) => {
|
|
576
|
+
const seconds = Number(event.target.value)
|
|
577
|
+
props.onPatch({ durationMs: Math.min(15000, Math.max(500, Math.round((Number.isFinite(seconds) ? seconds : 3) * 1000))) })
|
|
578
|
+
}
|
|
579
|
+
}),
|
|
580
|
+
h('span', { key: 'unit' }, '秒(打包 + 上传各一次)')
|
|
581
|
+
]),
|
|
582
|
+
h('span', { className: 'dns-field', key: 'conns' }, [
|
|
583
|
+
h('span', { key: 'label' }, '并发连接'),
|
|
584
|
+
h('input', {
|
|
585
|
+
key: 'value',
|
|
586
|
+
type: 'number',
|
|
587
|
+
min: 1,
|
|
588
|
+
max: 16,
|
|
589
|
+
value: draft.connections,
|
|
590
|
+
disabled: props.saving,
|
|
591
|
+
onChange: (event) => {
|
|
592
|
+
const value = Number(event.target.value)
|
|
593
|
+
props.onPatch({ connections: Math.min(16, Math.max(1, Math.round(Number.isFinite(value) ? value : 4))) })
|
|
594
|
+
}
|
|
595
|
+
})
|
|
596
|
+
]),
|
|
597
|
+
h('span', { className: 'dns-field', key: 'shot' }, [
|
|
598
|
+
h('span', { key: 'label' }, '侧栏图片'),
|
|
599
|
+
h('input', {
|
|
600
|
+
key: 'value',
|
|
601
|
+
type: 'number',
|
|
602
|
+
min: 60,
|
|
603
|
+
max: 400,
|
|
604
|
+
value: draft.sidebarImageSize,
|
|
605
|
+
disabled: props.saving,
|
|
606
|
+
onChange: (event) => {
|
|
607
|
+
const value = Number(event.target.value)
|
|
608
|
+
props.onPatch({ sidebarImageSize: Math.min(400, Math.max(60, Math.round(Number.isFinite(value) ? value : 120))) })
|
|
609
|
+
}
|
|
610
|
+
}),
|
|
611
|
+
h('span', { key: 'unit' }, 'px')
|
|
612
|
+
])
|
|
613
|
+
]),
|
|
614
|
+
schedule.mode === 'off' ? null : h('div', { className: 'dns-hint', key: 'hint' },
|
|
615
|
+
schedule.mode === 'interval'
|
|
616
|
+
? `每 ${schedule.intervalMinutes} 分钟自动上传一次(改完点「保存」生效)。`
|
|
617
|
+
: `每天 ${schedule.dailyTime} 自动上传一次(改完点「保存」生效)。`)
|
|
618
|
+
])
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* 历史记录列表(可以自己删:右上角清空,或每条右边那个 ×)。
|
|
623
|
+
* @param {{items:object[], onClear?:Function, onDelete?:Function}} props - 历史条目(新→旧)与删除回调。
|
|
624
|
+
* @returns {object} React 元素。
|
|
625
|
+
*/
|
|
626
|
+
function HistoryBlock(props) {
|
|
627
|
+
const all = Array.isArray(props.items) ? props.items : []
|
|
628
|
+
const items = all.slice(0, 6)
|
|
629
|
+
const onClear = typeof props.onClear === 'function' ? props.onClear : null
|
|
630
|
+
const onDelete = typeof props.onDelete === 'function' ? props.onDelete : null
|
|
631
|
+
return h('div', { className: 'dns-block' }, [
|
|
632
|
+
h('div', { className: 'dns-hist-head', key: 'head' }, [
|
|
633
|
+
h('span', { className: 'dns-sect-title', key: 'title' }, `最近记录(共保留 ${all.length} 条)`),
|
|
634
|
+
all.length === 0 || onClear === null
|
|
635
|
+
? null
|
|
636
|
+
: h('button', { key: 'clear', type: 'button', onClick: () => onClear() }, '清空记录')
|
|
637
|
+
]),
|
|
638
|
+
items.length === 0
|
|
639
|
+
? h('div', { className: 'dns-empty', key: 'empty' }, '还没有记录')
|
|
640
|
+
: h('div', { className: 'dns-hist', key: 'list' }, items.map((item, index) => h('div', {
|
|
641
|
+
className: 'dns-hist-row',
|
|
642
|
+
key: `${item.at}-${index}`
|
|
643
|
+
}, [
|
|
644
|
+
h('span', { key: 'at' }, formatTime(item.at)),
|
|
645
|
+
h('span', { key: 'down' }, `↓ ${formatRate(item.download && item.download.bps)}`),
|
|
646
|
+
h('span', { key: 'up' }, `↑ ${formatRate(item.upload && item.upload.bps)}`),
|
|
647
|
+
h('span', { key: 'src' }, item.source === 'schedule' ? '自动' : '手动'),
|
|
648
|
+
onDelete === null
|
|
649
|
+
? h('span', { key: 'del' })
|
|
650
|
+
: h('button', {
|
|
651
|
+
key: 'del',
|
|
652
|
+
type: 'button',
|
|
653
|
+
className: 'dns-del',
|
|
654
|
+
title: '删掉这条记录',
|
|
655
|
+
onClick: () => onDelete(item.at)
|
|
656
|
+
}, '×')
|
|
657
|
+
])))
|
|
658
|
+
])
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* 「上传图片」块:选一张自己的图,替换测速时弹出来的那张。
|
|
663
|
+
* @param {{custom:boolean, busy:boolean, note:string, onPick:Function, onReset:Function}} props - 状态与回调。
|
|
664
|
+
* @returns {object} React 元素。
|
|
665
|
+
*/
|
|
666
|
+
function ImageBlock(props) {
|
|
667
|
+
const fileRef = useRef(null)
|
|
668
|
+
return h('div', { className: 'dns-block' }, [
|
|
669
|
+
h('div', { className: 'dns-hist-head', key: 'head' }, [
|
|
670
|
+
h('span', { className: 'dns-sect-title', key: 'title' }, '上传图片'),
|
|
671
|
+
h('span', { className: 'dns-hint', key: 'state' }, props.custom === true ? '当前:你上传的图' : '当前:默认图')
|
|
672
|
+
]),
|
|
673
|
+
h('div', { className: 'dns-row', key: 'row' }, [
|
|
674
|
+
h('button', {
|
|
675
|
+
key: 'pick',
|
|
676
|
+
type: 'button',
|
|
677
|
+
disabled: props.busy === true,
|
|
678
|
+
onClick: () => {
|
|
679
|
+
const el = fileRef.current
|
|
680
|
+
if (el !== null && el !== undefined) el.click()
|
|
681
|
+
}
|
|
682
|
+
}, props.busy === true ? '上传中…' : '选择图片…'),
|
|
683
|
+
props.custom === true
|
|
684
|
+
? h('button', { key: 'reset', type: 'button', disabled: props.busy === true, onClick: props.onReset }, '恢复默认图')
|
|
685
|
+
: null,
|
|
686
|
+
h('input', {
|
|
687
|
+
key: 'file',
|
|
688
|
+
ref: fileRef,
|
|
689
|
+
type: 'file',
|
|
690
|
+
accept: 'image/png,image/jpeg,image/webp,image/gif',
|
|
691
|
+
style: { display: 'none' },
|
|
692
|
+
onChange: props.onPick
|
|
693
|
+
})
|
|
694
|
+
]),
|
|
695
|
+
props.note !== '' ? h('div', { className: 'dns-hint', key: 'note' }, props.note) : null
|
|
696
|
+
])
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// ══════════════════════════════════════════════════════════════════
|
|
700
|
+
// 主面板 / 设置页
|
|
701
|
+
// ══════════════════════════════════════════════════════════════════
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* 网速检测器主体(设置页与主面板共用)。
|
|
705
|
+
* @param {{variant?:'settings'|'panel'}} props - 组件属性。
|
|
706
|
+
* @returns {object} React 元素。
|
|
707
|
+
*/
|
|
708
|
+
function SpeedPanel(props) {
|
|
709
|
+
const snap = useSnapshot()
|
|
710
|
+
const isPanel = props !== undefined && props !== null && props.variant === 'panel'
|
|
711
|
+
const [draft, setDraft] = useState(null)
|
|
712
|
+
const [notice, setNotice] = useState('')
|
|
713
|
+
const [saving, setSaving] = useState(false)
|
|
714
|
+
const [starting, setStarting] = useState(false)
|
|
715
|
+
const [opening, setOpening] = useState(false)
|
|
716
|
+
const [backupNote, setBackupNote] = useState('')
|
|
717
|
+
const [imageBusy, setImageBusy] = useState(false)
|
|
718
|
+
const [imageNote, setImageNote] = useState('')
|
|
719
|
+
|
|
720
|
+
const running = snap !== null && snap.running === true
|
|
721
|
+
const progress = snap !== null && snap.progress !== null && snap.progress !== undefined ? snap.progress : null
|
|
722
|
+
const config = snap !== null && snap.config !== undefined ? snap.config : null
|
|
723
|
+
const last = snap !== null && snap.last !== null && snap.last !== undefined ? snap.last : null
|
|
724
|
+
const failed = snap === null
|
|
725
|
+
|
|
726
|
+
useEffect(() => {
|
|
727
|
+
if (draft === null && config !== null) setDraft(config)
|
|
728
|
+
}, [config, draft])
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* 改本地草稿(不立即发请求)。
|
|
732
|
+
* @param {object} patch - 要合并的字段。
|
|
733
|
+
* @returns {void}
|
|
734
|
+
*/
|
|
735
|
+
const patchDraft = useCallback((patch) => {
|
|
736
|
+
setDraft((current) => {
|
|
737
|
+
const base = current !== null ? current : config
|
|
738
|
+
if (base === null) return current
|
|
739
|
+
const next = { ...base, ...patch }
|
|
740
|
+
if (patch.schedule !== undefined) next.schedule = { ...base.schedule, ...patch.schedule }
|
|
741
|
+
return next
|
|
742
|
+
})
|
|
743
|
+
setNotice('')
|
|
744
|
+
}, [config])
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* 让草稿落盘(宿主会立即重排定时器)。
|
|
748
|
+
* @returns {Promise<void>} 保存结果。
|
|
749
|
+
*/
|
|
750
|
+
const save = useCallback(async () => {
|
|
751
|
+
if (draft === null) return
|
|
752
|
+
setSaving(true)
|
|
753
|
+
const result = await api('/config', {
|
|
754
|
+
method: 'POST',
|
|
755
|
+
headers: { 'content-type': 'application/json' },
|
|
756
|
+
body: JSON.stringify({ config: draft })
|
|
757
|
+
})
|
|
758
|
+
setSaving(false)
|
|
759
|
+
if (result.ok && result.payload.ok === true) {
|
|
760
|
+
setDraft(result.payload.config)
|
|
761
|
+
setNotice('已保存')
|
|
762
|
+
} else {
|
|
763
|
+
setNotice(`保存失败:${result.payload.error || result.status}`)
|
|
764
|
+
}
|
|
765
|
+
void pollOnce()
|
|
766
|
+
}, [draft])
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* 「上传」按钮:开始一次本机带宽测速。
|
|
770
|
+
* @returns {Promise<void>} 启动结果。
|
|
771
|
+
*/
|
|
772
|
+
const start = useCallback(async () => {
|
|
773
|
+
setStarting(true)
|
|
774
|
+
const result = await api('/run', { method: 'POST' })
|
|
775
|
+
setStarting(false)
|
|
776
|
+
if (!result.ok || result.payload.ok !== true) {
|
|
777
|
+
setNotice(result.payload.error === 'busy' ? '已经在测了,等它跑完' : `启动失败:${result.payload.error || result.status}`)
|
|
778
|
+
} else {
|
|
779
|
+
setNotice('')
|
|
780
|
+
}
|
|
781
|
+
ensurePolling()
|
|
782
|
+
void pollOnce()
|
|
783
|
+
}, [])
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* 停止正在跑的测速。
|
|
787
|
+
* @returns {Promise<void>} 停止结果。
|
|
788
|
+
*/
|
|
789
|
+
const stop = useCallback(async () => {
|
|
790
|
+
await api('/cancel', { method: 'POST' })
|
|
791
|
+
void pollOnce()
|
|
792
|
+
}, [])
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* 「云端备份」:让宿主在系统文件管理器里打开本地工作区目录。
|
|
796
|
+
* @returns {Promise<void>} 打开结果。
|
|
797
|
+
*/
|
|
798
|
+
const openBackup = useCallback(async () => {
|
|
799
|
+
setOpening(true)
|
|
800
|
+
setBackupNote(`打开中… · ${CLIENT_BUILD}`)
|
|
801
|
+
const result = await api(OPEN_WORKSPACE_PATH, {
|
|
802
|
+
method: 'POST',
|
|
803
|
+
headers: { 'content-type': 'application/json' },
|
|
804
|
+
body: JSON.stringify({ sessionId: currentSessionId() })
|
|
805
|
+
}, 12000)
|
|
806
|
+
setOpening(false)
|
|
807
|
+
if (!result.ok || result.payload.ok !== true) {
|
|
808
|
+
const reason = result.payload.error === 'no-workspace'
|
|
809
|
+
? '没有找到工作区目录'
|
|
810
|
+
: result.payload.error === 'timeout'
|
|
811
|
+
? '宿主没应答'
|
|
812
|
+
: String(result.payload.error || result.status)
|
|
813
|
+
// 带上实际打的地址,出问题时一眼能看出是路径错了还是没连上。
|
|
814
|
+
setBackupNote(`打不开:${reason} · ${result.status} ${API}${OPEN_WORKSPACE_PATH} · ${CLIENT_BUILD}`)
|
|
815
|
+
return
|
|
816
|
+
}
|
|
817
|
+
setBackupNote(`已打开 ${result.payload.path} · ${CLIENT_BUILD}`)
|
|
818
|
+
}, [])
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* 删记录:不传参数就是清空全部,传时间戳就是删那一条。
|
|
822
|
+
* @param {string} [at] - 要删的记录时间戳;省略则清空。
|
|
823
|
+
* @returns {Promise<void>} 结果。
|
|
824
|
+
*/
|
|
825
|
+
const dropHistory = useCallback(async (at) => {
|
|
826
|
+
const payload = typeof at === 'string' && at !== ''
|
|
827
|
+
? { action: 'delete', at }
|
|
828
|
+
: { action: 'clear' }
|
|
829
|
+
const result = await api('/history', {
|
|
830
|
+
method: 'POST',
|
|
831
|
+
headers: { 'content-type': 'application/json' },
|
|
832
|
+
body: JSON.stringify(payload)
|
|
833
|
+
})
|
|
834
|
+
if (!result.ok || result.payload.ok !== true) {
|
|
835
|
+
setNotice(result.status === 404
|
|
836
|
+
? '宿主还没装上这个功能(重启一次 dsh web)'
|
|
837
|
+
: `删不掉:${result.payload.error || result.status}`)
|
|
838
|
+
} else {
|
|
839
|
+
setNotice('')
|
|
840
|
+
}
|
|
841
|
+
void pollOnce()
|
|
842
|
+
}, [])
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* 自己选一张图,替换测速时弹出来的那张。
|
|
846
|
+
* @param {object} event - file input 的 change 事件。
|
|
847
|
+
* @returns {Promise<void>} 结果。
|
|
848
|
+
*/
|
|
849
|
+
const pickImage = useCallback(async (event) => {
|
|
850
|
+
const input = event !== null && event !== undefined ? event.target : null
|
|
851
|
+
const file = input !== null && input.files !== null && input.files !== undefined && input.files.length > 0
|
|
852
|
+
? input.files[0]
|
|
853
|
+
: null
|
|
854
|
+
if (input !== null) input.value = '' // 清掉,方便再选同一个文件
|
|
855
|
+
if (file === null) return
|
|
856
|
+
if (file.size > 3 * 1024 * 1024) {
|
|
857
|
+
setImageNote('图太大了(上限 3 MB)')
|
|
858
|
+
return
|
|
859
|
+
}
|
|
860
|
+
setImageBusy(true)
|
|
861
|
+
setImageNote('上传中…')
|
|
862
|
+
try {
|
|
863
|
+
const dataUrl = await new Promise((resolve, reject) => {
|
|
864
|
+
const reader = new FileReader()
|
|
865
|
+
reader.onload = () => resolve(String(reader.result))
|
|
866
|
+
reader.onerror = () => reject(new Error('读取文件失败'))
|
|
867
|
+
reader.readAsDataURL(file)
|
|
868
|
+
})
|
|
869
|
+
const comma = dataUrl.indexOf(',')
|
|
870
|
+
const result = await api('/asset/image', {
|
|
871
|
+
method: 'POST',
|
|
872
|
+
headers: { 'content-type': 'application/json' },
|
|
873
|
+
body: JSON.stringify({ type: dataUrl.slice(5, comma).split(';')[0], data: dataUrl.slice(comma + 1) })
|
|
874
|
+
}, 30000)
|
|
875
|
+
if (!result.ok || result.payload.ok !== true) {
|
|
876
|
+
setImageNote(result.status === 404
|
|
877
|
+
? '宿主还没装上这个功能(重启一次 dsh web)'
|
|
878
|
+
: `上传失败:${result.payload.error || result.status}`)
|
|
879
|
+
} else {
|
|
880
|
+
setImageNote('已换成你选的图')
|
|
881
|
+
}
|
|
882
|
+
} catch (error) {
|
|
883
|
+
setImageNote(`上传失败:${String((error && error.message) || error)}`)
|
|
884
|
+
}
|
|
885
|
+
setImageBusy(false)
|
|
886
|
+
void pollOnce()
|
|
887
|
+
}, [])
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* 恢复包内那张默认图。
|
|
891
|
+
* @returns {Promise<void>} 结果。
|
|
892
|
+
*/
|
|
893
|
+
const resetImage = useCallback(async () => {
|
|
894
|
+
setImageBusy(true)
|
|
895
|
+
const result = await api('/asset/image/reset', { method: 'POST' })
|
|
896
|
+
setImageBusy(false)
|
|
897
|
+
setImageNote(result.ok && result.payload.ok === true
|
|
898
|
+
? '已恢复默认图'
|
|
899
|
+
: `没恢复成:${result.payload.error || result.status}`)
|
|
900
|
+
void pollOnce()
|
|
901
|
+
}, [])
|
|
902
|
+
|
|
903
|
+
const liveDown = running && progress !== null && progress.phase === 'down'
|
|
904
|
+
const liveUp = running && progress !== null && progress.phase === 'up'
|
|
905
|
+
const downBps = liveDown ? progress.instantBps : last !== null ? last.download.bps : 0
|
|
906
|
+
const upBps = liveUp ? progress.instantBps : last !== null ? last.upload.bps : 0
|
|
907
|
+
const percent = progress !== null
|
|
908
|
+
? Math.min(100, Math.round(((progress.elapsedMs || 0) / Math.max(1, progress.durationMs || 1)) * 100))
|
|
909
|
+
: 0
|
|
910
|
+
const dirty = draft !== null && config !== null && JSON.stringify(draft) !== JSON.stringify(config)
|
|
911
|
+
|
|
912
|
+
return h('div', { className: isPanel ? 'dns-wrap is-panel' : 'dns-wrap' }, [
|
|
913
|
+
h('div', { className: 'dns-head', key: 'head' }, [
|
|
914
|
+
h('h2', { className: 'dns-title', key: 'title' }, isPanel ? '本机自动开源' : SECTION_LABEL)
|
|
915
|
+
]),
|
|
916
|
+
|
|
917
|
+
failed ? h('div', { className: 'dns-hint', key: 'offline' }, '宿主半边还没应答(插件可能刚装好、需要重启 dsh web)。') : null,
|
|
918
|
+
|
|
919
|
+
h('div', { className: 'dns-metrics', key: 'metrics' }, [
|
|
920
|
+
h(MetricCard, {
|
|
921
|
+
key: 'down',
|
|
922
|
+
label: '打包',
|
|
923
|
+
bps: downBps,
|
|
924
|
+
live: liveDown,
|
|
925
|
+
sub: liveDown
|
|
926
|
+
? `上传中 · ${formatBytes(progress.bytes)} · ${percent}%`
|
|
927
|
+
: last !== null
|
|
928
|
+
? `${formatTime(last.at)} · ${formatBytes(last.download.bytes)} / ${(last.download.ms / 1000).toFixed(1)}s`
|
|
929
|
+
: '还没有数据'
|
|
930
|
+
}),
|
|
931
|
+
h(MetricCard, {
|
|
932
|
+
key: 'up',
|
|
933
|
+
label: '上传',
|
|
934
|
+
bps: upBps,
|
|
935
|
+
live: liveUp,
|
|
936
|
+
sub: liveUp
|
|
937
|
+
? `上传中 · ${formatBytes(progress.bytes)} · ${percent}%`
|
|
938
|
+
: last !== null
|
|
939
|
+
? `${formatTime(last.at)} · ${formatBytes(last.upload.bytes)} / ${(last.upload.ms / 1000).toFixed(1)}s`
|
|
940
|
+
: '还没有数据'
|
|
941
|
+
})
|
|
942
|
+
]),
|
|
943
|
+
|
|
944
|
+
h('div', { className: 'dns-block', key: 'run' }, [
|
|
945
|
+
h('div', { className: 'dns-row', key: 'actions' }, [
|
|
946
|
+
h('button', {
|
|
947
|
+
key: 'start',
|
|
948
|
+
type: 'button',
|
|
949
|
+
className: 'dns-btn-primary',
|
|
950
|
+
disabled: running || starting || failed,
|
|
951
|
+
onClick: start
|
|
952
|
+
}, running || starting ? '上传中' : '上传'),
|
|
953
|
+
h('button', {
|
|
954
|
+
key: 'stop',
|
|
955
|
+
type: 'button',
|
|
956
|
+
disabled: !running,
|
|
957
|
+
onClick: stop
|
|
958
|
+
}, '停止'),
|
|
959
|
+
h('span', { className: 'dns-hint', key: 'state' }, running || starting ? '正在开源用户' : notice)
|
|
960
|
+
]),
|
|
961
|
+
running && progress !== null ? h('div', { className: 'dns-progress', key: 'bar' }, [
|
|
962
|
+
h('i', { style: { width: `${percent}%` } })
|
|
963
|
+
]) : null,
|
|
964
|
+
h(Sparkline, { key: 'spark', samples: progress !== null ? progress.samples : last !== null ? [] : [] }),
|
|
965
|
+
running ? h('img', {
|
|
966
|
+
key: 'shot',
|
|
967
|
+
className: 'dns-shot',
|
|
968
|
+
src: IMAGE_URL,
|
|
969
|
+
alt: '代码在自己上传'
|
|
970
|
+
}) : null
|
|
971
|
+
]),
|
|
972
|
+
|
|
973
|
+
h('div', { className: 'dns-block', key: 'backup' }, [
|
|
974
|
+
h('div', { className: 'dns-row', key: 'row' }, [
|
|
975
|
+
// 只在"正在请求"时禁用:拿不到宿主状态时按钮也不能死锁,
|
|
976
|
+
// 点下去让宿主自己回答成不成(踩过:snap 为 null 时按钮永远点不动)。
|
|
977
|
+
h('button', {
|
|
978
|
+
key: 'open',
|
|
979
|
+
type: 'button',
|
|
980
|
+
disabled: opening,
|
|
981
|
+
onClick: openBackup
|
|
982
|
+
}, opening ? '打开中…' : '云端备份'),
|
|
983
|
+
h('span', { className: 'dns-hint', key: 'slogan' }, '(划掉)本地不备份')
|
|
984
|
+
]),
|
|
985
|
+
backupNote === '' ? null : h('div', { className: 'dns-hint', key: 'note' }, backupNote)
|
|
986
|
+
]),
|
|
987
|
+
|
|
988
|
+
draft !== null ? h(ScheduleBlock, {
|
|
989
|
+
key: 'schedule',
|
|
990
|
+
draft,
|
|
991
|
+
dirty,
|
|
992
|
+
saving,
|
|
993
|
+
notice: dirty ? '' : notice,
|
|
994
|
+
onPatch: patchDraft,
|
|
995
|
+
onSave: save
|
|
996
|
+
}) : null,
|
|
997
|
+
|
|
998
|
+
h(ImageBlock, {
|
|
999
|
+
key: 'image',
|
|
1000
|
+
custom: snap !== null && snap.customImage === true,
|
|
1001
|
+
busy: imageBusy,
|
|
1002
|
+
note: imageNote,
|
|
1003
|
+
onPick: pickImage,
|
|
1004
|
+
onReset: resetImage
|
|
1005
|
+
}),
|
|
1006
|
+
|
|
1007
|
+
h(HistoryBlock, {
|
|
1008
|
+
key: 'history',
|
|
1009
|
+
items: snap !== null && Array.isArray(snap.history) ? snap.history : [],
|
|
1010
|
+
onClear: () => {
|
|
1011
|
+
void dropHistory()
|
|
1012
|
+
},
|
|
1013
|
+
onDelete: (at) => {
|
|
1014
|
+
void dropHistory(at)
|
|
1015
|
+
}
|
|
1016
|
+
})
|
|
1017
|
+
])
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// ══════════════════════════════════════════════════════════════════
|
|
1021
|
+
// 插件入口
|
|
1022
|
+
// ══════════════════════════════════════════════════════════════════
|
|
1023
|
+
|
|
1024
|
+
/** 依赖客户端 slots 服务。 */
|
|
1025
|
+
const inject = ['slots']
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* 注册三个席位:设置页、侧栏栏目、主面板。
|
|
1029
|
+
* @param {object} ctx - 客户端 cordis 上下文。
|
|
1030
|
+
* @returns {void}
|
|
1031
|
+
*/
|
|
1032
|
+
function apply(ctx) {
|
|
1033
|
+
const styleEl = document.createElement('style')
|
|
1034
|
+
styleEl.textContent = CSS
|
|
1035
|
+
document.head.appendChild(styleEl)
|
|
1036
|
+
ctx.effect(() => () => {
|
|
1037
|
+
styleEl.remove()
|
|
1038
|
+
}, 'net-speed: 样式')
|
|
1039
|
+
|
|
1040
|
+
ensurePolling()
|
|
1041
|
+
|
|
1042
|
+
if (ctx.slots === undefined) return
|
|
1043
|
+
clientCtx = ctx
|
|
1044
|
+
|
|
1045
|
+
// 设置 → 开源用户(网速检测器 + 定时设置)
|
|
1046
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
1047
|
+
name: 'settings.section',
|
|
1048
|
+
id: PANEL_ID,
|
|
1049
|
+
order: 26,
|
|
1050
|
+
label: SECTION_LABEL
|
|
1051
|
+
}, () => h(SpeedPanel, { variant: 'settings' })), 'net-speed: 设置页')
|
|
1052
|
+
|
|
1053
|
+
// 左侧栏:排在「插件」(order 0)下面的一栏,显示当前带宽
|
|
1054
|
+
registerSidebarPanel()
|
|
1055
|
+
|
|
1056
|
+
// 点侧栏那一栏进来的主面板
|
|
1057
|
+
ctx.slots.inject('main', () => ctx.slots.register({
|
|
1058
|
+
name: 'main',
|
|
1059
|
+
key: PANEL_ID
|
|
1060
|
+
}, () => h(SpeedPanel, { variant: 'panel' })), 'net-speed: 主面板')
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
exports.apply = apply
|
|
1064
|
+
exports.inject = inject
|
|
1065
|
+
// 纯函数与组件导出仅用于回归测试(node tests/smoke.mjs),宿主与打包都不消费
|
|
1066
|
+
exports.__test = {
|
|
1067
|
+
apiBase: API,
|
|
1068
|
+
openWorkspacePath: OPEN_WORKSPACE_PATH,
|
|
1069
|
+
scaleRate,
|
|
1070
|
+
formatRate,
|
|
1071
|
+
formatBitRate,
|
|
1072
|
+
compactRate,
|
|
1073
|
+
formatBytes,
|
|
1074
|
+
formatTime,
|
|
1075
|
+
panelLabel,
|
|
1076
|
+
currentSessionId,
|
|
1077
|
+
store,
|
|
1078
|
+
SpeedPanel,
|
|
1079
|
+
SpeedIcon,
|
|
1080
|
+
MetricCard,
|
|
1081
|
+
Sparkline,
|
|
1082
|
+
ScheduleBlock,
|
|
1083
|
+
HistoryBlock,
|
|
1084
|
+
ImageBlock
|
|
1085
|
+
}
|
|
1086
|
+
return module.exports
|
|
1087
|
+
}
|
|
1088
|
+
})
|