dsh-remote-plugin 0.4.4

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,265 @@
1
+ /* DSH Remote 网关/插件管理页 · 零依赖 */
2
+ 'use strict'
3
+
4
+ const $ = (id) => document.getElementById(id)
5
+ // 插件内嵌(/remote/ 或 ?embedded=1)直接进管理面板, 不需要任何令牌门禁;
6
+ // 独立网关模式(/admin/)仍保留令牌输入。路径判断兼容无尾斜杠 /remote。
7
+ const pluginMode = location.pathname === '/remote'
8
+ || location.pathname.startsWith('/remote/')
9
+ || new URLSearchParams(location.search).get('embedded') === '1'
10
+ const API = pluginMode ? '/remote/admin/api' : '/admin/api'
11
+ // 沙箱 iframe/隐私模式里 localStorage 可能抛 SecurityError, 不能让它杀死整个页面
12
+ const store = {
13
+ get(k) { try { return localStorage.getItem(k) } catch { return null } },
14
+ set(k, v) { try { localStorage.setItem(k, v) } catch {} },
15
+ del(k) { try { localStorage.removeItem(k) } catch {} }
16
+ }
17
+ let token = store.get('dshAdminToken') || new URLSearchParams(location.search).get('token') || ''
18
+ let timer = null
19
+ let gatewayRunning = false
20
+ let gatewayBusy = false
21
+ let shownToken = token
22
+
23
+ function toast(text, kind = '') {
24
+ const el = $('toast')
25
+ el.textContent = text
26
+ el.className = 'toast ' + kind
27
+ clearTimeout(toast._t)
28
+ toast._t = setTimeout(() => el.classList.add('hidden'), 2600)
29
+ }
30
+
31
+ function fmtUptime(sec) {
32
+ if (sec < 60) return sec + ' 秒'
33
+ if (sec < 3600) return Math.floor(sec / 60) + ' 分钟'
34
+ if (sec < 86400) return Math.floor(sec / 3600) + ' 小时 ' + Math.floor(sec % 3600 / 60) + ' 分'
35
+ return Math.floor(sec / 86400) + ' 天 ' + Math.floor(sec % 86400 / 3600) + ' 小时'
36
+ }
37
+
38
+ function fmtTime(ts) {
39
+ const d = new Date(ts)
40
+ const p = (n) => String(n).padStart(2, '0')
41
+ return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
42
+ }
43
+
44
+ async function loadState() {
45
+ if (!token && !pluginMode) return
46
+ try {
47
+ const res = await fetch(`${API}/state`, {
48
+ headers: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' }
49
+ })
50
+ if (res.status === 401) throw new Error('AUTH')
51
+ const st = await res.json()
52
+ render(st)
53
+ } catch (e) {
54
+ if (e.message === 'AUTH') {
55
+ toast('令牌无效', 'err')
56
+ logout()
57
+ } else {
58
+ $('conn-badge').textContent = '连接失败'
59
+ $('conn-badge').className = 'conn-badge off'
60
+ }
61
+ }
62
+ }
63
+
64
+ function render(st) {
65
+ const isPlugin = st.mode === 'plugin'
66
+ const isGateway = st.mode === 'gateway'
67
+ shownToken = st.token || (isGateway ? '' : token)
68
+ $('conn-badge').textContent = isPlugin ? '内嵌' : isGateway ? '网关' : '已连接'
69
+ $('conn-badge').className = 'conn-badge on'
70
+ $('token-full').textContent = shownToken || (isPlugin ? '插件模式 · 未接网关, 无需令牌' : '未获取到令牌')
71
+ // 主机端插件模式: 显示真实令牌(复制可用), 只隐藏退出按钮; 令牌门禁本身不存在
72
+ $('btn-copy').classList.toggle('hidden', !shownToken)
73
+ $('btn-logout').classList.toggle('hidden', pluginMode)
74
+ // 网关开关: 仅插件内嵌页提供, 网关运行/停止两种状态
75
+ gatewayRunning = isGateway
76
+ $('btn-gateway').classList.toggle('hidden', !pluginMode)
77
+ $('btn-gateway').textContent = gatewayBusy
78
+ ? (gatewayRunning ? '停止中…' : '启动中…')
79
+ : (gatewayRunning ? '停止网关' : '启动网关')
80
+ $('btn-gateway').disabled = gatewayBusy
81
+ const upOk = st.upstream.reachable
82
+ const hostIPs = (st.lanIPs || []).join('、') || '127.0.0.1'
83
+ const latestHtml = st.latest?.newer
84
+ ? `<div class="v">v${st.latest.version} 可用</div><div class="k">当前 v${st.version} · <a href="${st.latest.url || '#'}" target="_blank" rel="noopener" style="color:var(--orange)">去下载</a></div>`
85
+ : `<div class="v">v${st.version}</div><div class="k">${isPlugin ? 'DSH 内嵌 · 免网关' : st.latest?.error ? '更新检查: ' + st.latest.error : st.latest?.version ? '已是最新(来源检查)' : '未检查更新'}</div>`
86
+ $('stats').innerHTML = `
87
+ <div class="stat-card"><div class="v">v${st.version}</div><div class="k">${isPlugin ? '插件版本' : '网关版本'}</div></div>
88
+ <div class="stat-card ${st.latest?.newer ? 'warn' : 'ok'}">${latestHtml}</div>
89
+ <div class="stat-card ok"><div class="v" style="font-size:15px">${hostIPs}</div><div class="k">主机 IP · ${st.hostname}${isPlugin ? ' (手机连 8787 网关)' : ' (手机连这个地址)'}</div></div>
90
+ <div class="stat-card ${upOk ? 'ok' : 'warn'}"><div class="v">${upOk ? '可达' : '不可达'}</div><div class="k">DSH 上游 ${st.upstream.url}</div></div>
91
+ <div class="stat-card"><div class="v">${st.onlineCount}/${st.deviceCount}</div><div class="k">设备在线 / 累计</div></div>
92
+ <div class="stat-card"><div class="v">${st.totalRequests}</div><div class="k">总请求数</div></div>
93
+ <div class="stat-card"><div class="v">${st.authFailures}</div><div class="k">认证失败</div></div>
94
+ <div class="stat-card"><div class="v">${fmtUptime(st.uptimeSec)}</div><div class="k">运行时长 · ${st.host}:${st.port}</div></div>`
95
+
96
+ $('device-summary').textContent = isPlugin
97
+ ? (st.gatewayInstalled ? '网关已安装 · 当前未运行' : '未检测到网关程序')
98
+ : `${st.devices.length} 个 IP · 每 5 秒刷新`
99
+ if (isPlugin && !st.devices.length) {
100
+ $('device-rows').innerHTML = ''
101
+ const rel = 'https://github.com/Blank-not-black/dsh-Remote/releases/latest/download/'
102
+ const apkBtn = `<a class="mini-btn" href="${rel}dsh-remote.apk" target="_blank" rel="noopener">下载手机 App</a>`
103
+ if (!st.gatewayInstalled) {
104
+ // 只有插件包真的没有内置网关程序时, 才引导下载网关
105
+ const isWin = /windows|win32/i.test(navigator.userAgent)
106
+ const gwAsset = isWin ? 'dsh-remote-win-x64.exe' : 'dsh-remote-linux-x64'
107
+ $('device-empty').innerHTML = `
108
+ <div>本插件包未包含网关程序:下载对应系统的网关并运行</div>
109
+ <div class="empty-actions">
110
+ <a class="mini-btn" href="${rel}${gwAsset}" target="_blank" rel="noopener">下载网关 (${isWin ? 'Windows x64' : 'Linux x64'})</a>
111
+ ${apkBtn}
112
+ </div>
113
+ <div class="muted" style="margin-top:10px">运行网关后回到本页刷新,即可看到设备监控与完整令牌</div>`
114
+ } else {
115
+ $('device-empty').innerHTML = `
116
+ <div>网关已随插件安装,当前未运行 — 点击上方「启动网关」开启</div>
117
+ <div class="empty-actions">${apkBtn}</div>
118
+ <div class="muted" style="margin-top:10px">启动后本页会自动刷新为网关模式(完整设备监控 + 令牌)</div>`
119
+ }
120
+ $('device-empty').classList.remove('hidden')
121
+ } else {
122
+ // 网关模式: 清掉可能残留的引导文案, 设备为空时只显示中性提示
123
+ $('device-empty').textContent = '暂无设备记录'
124
+ $('device-empty').classList.toggle('hidden', st.devices.length > 0)
125
+ $('device-rows').innerHTML = st.devices.map(d => `
126
+ <tr>
127
+ <td><span class="dot ${d.online ? 'on' : 'off'}"></span>${d.online ? '在线' : '离线'}</td>
128
+ <td>${d.note ? `<b>${d.note.replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]))}</b>` : '<span class="muted">—</span>'}<button class="mini-btn" data-note-ip="${d.ip}" data-note="${d.note.replace(/"/g, '&quot;')}" style="margin-left:6px;padding:1px 7px">备注</button></td>
129
+ <td><span class="badge ${d.kind}">${d.kind === 'app' ? '手机App' : d.kind === 'admin' ? '管理页' : d.kind === 'web' ? '浏览器' : '未知'}</span></td>
130
+ <td class="mono">${d.ip}</td>
131
+ <td class="mono">${d.channels.mux ? 'mux' : ''}${d.channels.mux && d.channels.host ? ' · ' : ''}${d.channels.host ? 'host' : ''}${!d.channels.mux && !d.channels.host ? '—' : ''}</td>
132
+ <td>${d.requests}</td>
133
+ <td>${fmtTime(d.lastSeen)}</td>
134
+ <td class="ua" title="${d.ua.replace(/"/g, '&quot;')}">${d.ua || '—'}</td>
135
+ <td>${d.online && d.kind !== 'admin' ? `<button class="mini-btn" data-kick="${d.ip}">断开</button>` : ''}</td>
136
+ </tr>`).join('')
137
+ }
138
+ document.querySelectorAll('[data-kick]').forEach(btn =>
139
+ btn.addEventListener('click', () => kick(btn.dataset.kick)))
140
+ document.querySelectorAll('[data-note-ip]').forEach(btn =>
141
+ btn.addEventListener('click', () => setNote(btn.dataset.noteIp, btn.dataset.note)))
142
+ }
143
+
144
+ async function setNote(ip, current) {
145
+ const name = prompt('给 ' + ip + ' 设置备注(留空清除):', current || '')
146
+ if (name === null) return
147
+ const res = await fetch(`${API}/note`, {
148
+ method: 'POST',
149
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' },
150
+ body: JSON.stringify({ ip, name })
151
+ })
152
+ if (res.ok) {
153
+ toast('备注已保存', 'ok')
154
+ setTimeout(loadState, 300)
155
+ } else {
156
+ toast('保存失败', 'err')
157
+ }
158
+ }
159
+
160
+ async function kick(ip) {
161
+ if (!confirm('断开该设备的连接?')) return
162
+ const res = await fetch(`${API}/kick`, {
163
+ method: 'POST',
164
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' },
165
+ body: JSON.stringify({ ip })
166
+ })
167
+ if (res.ok) {
168
+ toast('已断开 ' + ip, 'ok')
169
+ setTimeout(loadState, 400)
170
+ } else {
171
+ toast('操作失败', 'err')
172
+ }
173
+ }
174
+
175
+ function enter() {
176
+ const t = $('token-input').value.trim()
177
+ if (!t) return
178
+ token = t
179
+ store.set('dshAdminToken', t)
180
+ history.replaceState(null, '', location.pathname)
181
+ showMain()
182
+ loadState()
183
+ timer = setInterval(loadState, 5000)
184
+ }
185
+
186
+ function showMain() {
187
+ $('login-view').classList.add('hidden')
188
+ $('main-view').classList.remove('hidden')
189
+ }
190
+
191
+ function logout() {
192
+ token = ''
193
+ store.del('dshAdminToken')
194
+ clearInterval(timer)
195
+ $('main-view').classList.add('hidden')
196
+ $('login-view').classList.remove('hidden')
197
+ $('conn-badge').textContent = '未认证'
198
+ $('conn-badge').className = 'conn-badge off'
199
+ $('token-input').value = ''
200
+ }
201
+
202
+ $('btn-login').addEventListener('click', enter)
203
+ $('token-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') enter() })
204
+ $('btn-logout').addEventListener('click', logout)
205
+ // 插件内嵌: 收起面板按钮 → postMessage 给父窗口(同源)关闭右侧抽屉
206
+ $('btn-close-drawer').addEventListener('click', () => {
207
+ window.parent.postMessage({ source: 'dsh-remote-admin', type: 'close' }, location.origin)
208
+ })
209
+ $('btn-copy').addEventListener('click', async () => {
210
+ try {
211
+ await navigator.clipboard.writeText(shownToken || token)
212
+ toast('令牌已复制', 'ok')
213
+ } catch {
214
+ toast('复制失败,请手动选择', 'err')
215
+ }
216
+ })
217
+
218
+ $('btn-gateway').addEventListener('click', async () => {
219
+ if (gatewayBusy) return
220
+ gatewayBusy = true
221
+ const btn = $('btn-gateway')
222
+ btn.disabled = true
223
+ btn.textContent = gatewayRunning ? '停止中…' : '启动中…'
224
+ try {
225
+ const res = await fetch(`${API}/gateway`, {
226
+ method: 'POST',
227
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'admin' },
228
+ body: JSON.stringify({ action: gatewayRunning ? 'stop' : 'start' })
229
+ })
230
+ const out = await res.json().catch(() => ({}))
231
+ if (out.ok) {
232
+ toast(out.started ? '网关已启动' : out.running ? '网关已在运行' : gatewayRunning ? '网关已停止' : (out.pending ? '网关启动中,稍后刷新' : '已执行'), 'ok')
233
+ } else {
234
+ toast(out.error || '操作失败', 'err')
235
+ }
236
+ } catch (e) {
237
+ toast('操作失败:' + (e.message || e), 'err')
238
+ }
239
+ gatewayBusy = false
240
+ setTimeout(loadState, 700)
241
+ })
242
+
243
+ function start(showLogin) {
244
+ if (!showLogin) {
245
+ $('login-view').classList.add('hidden')
246
+ } else {
247
+ $('login-view').classList.remove('hidden')
248
+ }
249
+ showMain()
250
+ loadState()
251
+ timer = setInterval(loadState, 5000)
252
+ }
253
+
254
+ if (pluginMode) {
255
+ $('login-view').classList.add('hidden')
256
+ $('btn-console').classList.add('hidden')
257
+ $('btn-close-drawer').classList.remove('hidden')
258
+ start(false)
259
+ } else if (token) {
260
+ $('token-input').value = token
261
+ start(false)
262
+ } else {
263
+ $('main-view').classList.add('hidden')
264
+ $('login-view').classList.remove('hidden')
265
+ }