dsh-mini-remote 0.2.0 → 0.2.1
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/CHANGELOG.md +41 -0
- package/README.en.md +198 -147
- package/README.md +198 -154
- package/client/client.js +1931 -3
- package/lib/build.js +32 -1
- package/lib/devices.js +372 -0
- package/lib/index.js +1625 -32
- package/lib/mirror.js +296 -3
- package/lib/page.html +938 -71
- package/lib/page.js +4 -1
- package/lib/remotes.js +279 -0
- package/lib/server.js +937 -55
- package/package.json +1 -1
package/client/client.js
CHANGED
|
@@ -21,12 +21,67 @@ window.__ModuleLoader__.load({
|
|
|
21
21
|
var h = React.createElement;
|
|
22
22
|
var useState = React.useState;
|
|
23
23
|
var useEffect = React.useEffect;
|
|
24
|
+
var useRef = React.useRef;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 主输入设备有没有「悬停」能力。
|
|
28
|
+
*
|
|
29
|
+
* 顶上那条横条要不要自动收起来,取决于这一条:**触摸屏没有悬停这回事**,藏起来就成了
|
|
30
|
+
* 摸不到的东西——而它上面挂着「← 返回列表」,是那一屏唯一的出口。
|
|
31
|
+
* 和 `lib/page.html` 那条镜像顶栏同一条口径(那边用媒体查询,这边是 inline style,
|
|
32
|
+
* 所以在加载时算一次就够)。
|
|
33
|
+
*
|
|
34
|
+
* **算不出来就当作没有悬停** → 横条常驻。宁可多占一行,不可让人出不去。
|
|
35
|
+
*/
|
|
36
|
+
var HAS_HOVER = (function () {
|
|
37
|
+
try {
|
|
38
|
+
return window.matchMedia('(hover: hover) and (pointer: fine)').matches;
|
|
39
|
+
} catch (err) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
})();
|
|
24
43
|
|
|
25
44
|
var PAIRING_PATH = '/mini-remote/pairing';
|
|
26
45
|
var TOKEN_PATH = '/mini-remote/token';
|
|
27
46
|
var TUNNEL_PATH = '/mini-remote/tunnel';
|
|
28
47
|
var SERVE_PATH = '/mini-remote/serve';
|
|
29
48
|
var FEISHU_PATH = '/mini-remote/feishu';
|
|
49
|
+
// **这里原来有一条本机取票的地址常量**:面板点开时拿它去换一张「本机电脑端界面」的票。
|
|
50
|
+
// 2026-10-17 退休了——那段道理写在 `MiniRemotePanel` 的注释里。宿主侧那条路由一个字没动
|
|
51
|
+
// (还在 lib/index.js 上,跨机那条路复用的取票/嵌框/过门也照旧),只是界面上没人走它了。
|
|
52
|
+
// 「允许别的电脑连接」那个开关(宿主侧路由,只允许本机调用,见 lib/index.js)。
|
|
53
|
+
// 它收 `{ enabled }`,回的也是整份配对信息(和公网那个开关同形状)。
|
|
54
|
+
// 关着的时候,配对和跨机取票两条路由一条都不接——这是用户能读懂的"关"。
|
|
55
|
+
var REMOTE_ACCESS_PATH = '/mini-remote/remote-access';
|
|
56
|
+
// 「设备名册」那三条(宿主侧路由,只允许本机调用,见 lib/index.js):
|
|
57
|
+
// GET /mini-remote/devices → { ok:true, devices:[{id,name,kind,createdAt,lastSeen}] }
|
|
58
|
+
// POST /mini-remote/devices/rename → { id, name } → { ok:true, device }
|
|
59
|
+
// POST /mini-remote/devices/revoke → { id } → { ok:true, id, dropped }
|
|
60
|
+
// **回执里永远没有 keyHash、也没有凭据本身**——名册那份东西只在电脑上、只在服务端。
|
|
61
|
+
var DEVICES_PATH = '/mini-remote/devices';
|
|
62
|
+
var DEVICE_RENAME_PATH = '/mini-remote/devices/rename';
|
|
63
|
+
var DEVICE_REVOKE_PATH = '/mini-remote/devices/revoke';
|
|
64
|
+
// 「我能连的电脑」那四条(**B 侧**:这台电脑连得上去哪几台别的电脑。宿主侧路由,
|
|
65
|
+
// 只允许本机调用,见 lib/index.js 与 lib/remotes.js):
|
|
66
|
+
// GET /mini-remote/remotes → { ok:true, remotes:[{id,name,baseUrl,addedAt,lastOk}] }
|
|
67
|
+
// POST /mini-remote/remotes/add → { name?, baseUrl, token } → { ok:true, remote }
|
|
68
|
+
// POST /mini-remote/remotes/window → { id } → { ok:true, url, expiresIn } ← 取票(= 重连)
|
|
69
|
+
// POST /mini-remote/remotes/forget → { id } → { ok:true, id, note } ← 断开(只动本地)
|
|
70
|
+
// **回执里永远没有凭据**(见 lib/remotes.js 的 publicRecord):界面拿得到的只有
|
|
71
|
+
// id、名字、地址和两个时间;凭据只活在宿主进程里,去对面取票时放进请求头。
|
|
72
|
+
var REMOTES_PATH = '/mini-remote/remotes';
|
|
73
|
+
var REMOTE_ADD_PATH = '/mini-remote/remotes/add';
|
|
74
|
+
var REMOTE_WINDOW_PATH = '/mini-remote/remotes/window';
|
|
75
|
+
var REMOTE_FORGET_PATH = '/mini-remote/remotes/forget';
|
|
76
|
+
// 「自检」那一条(宿主侧路由,只允许本机调用,见 lib/index.js):
|
|
77
|
+
// GET /mini-remote/selfcheck → 这台电脑在跑哪一版、哪些接口在、开关什么状态
|
|
78
|
+
// **返回值里没有一个字节的凭据**:它只数条数、只报"在不在听、绑了哪些地址"。
|
|
79
|
+
var SELFCHECK_PATH = '/mini-remote/selfcheck';
|
|
80
|
+
|
|
81
|
+
// 侧栏那个图标和中间那一整屏面板共用的一个键。
|
|
82
|
+
// 契约就是这么定的:侧栏条目的 `id` 按原样去寻址 main 槽里 key 相同的那个面板
|
|
83
|
+
// (dsh-client-ui-sidebar 的契约原文:Each list id addresses the matching main panel)。
|
|
84
|
+
var PANEL_ID = 'mini-remote';
|
|
30
85
|
|
|
31
86
|
// 颜色走 DSH 主题变量(真名前缀是 --dsw-alias-,深浅色自动跟随)。
|
|
32
87
|
// 每个变量都带一个浅色兜底值,变量缺失时界面仍然可读。
|
|
@@ -49,9 +104,339 @@ window.__ModuleLoader__.load({
|
|
|
49
104
|
toggleRow: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 10, fontSize: 13, cursor: 'pointer' },
|
|
50
105
|
// cloudflared 的报错是多行原文,换行要留住,否则一长条读不了。
|
|
51
106
|
detail: { color: 'var(--dsw-alias-state-warn-primary,#b45309)', fontSize: 12, lineHeight: 1.6, marginTop: 6, whiteSpace: 'pre-wrap', wordBreak: 'break-word' },
|
|
107
|
+
// 「远程窗口」面板:整屏嵌一个 iframe。取票那一步失败时,这两种样式就是全部内容
|
|
108
|
+
// ——那时它得读起来像一句话,而不是一个空框。
|
|
109
|
+
panelMsg: { boxSizing: 'border-box', padding: '80px 24px', textAlign: 'center', color: 'var(--dsw-alias-label-tertiary,#8b93a1)', fontSize: 14, lineHeight: 1.6 },
|
|
110
|
+
// 名单那一屏(2026-10-17):**面板的第一屏**。行样式复用设置页那几样
|
|
111
|
+
// (deviceRow/deviceName/remoteUrl),这里只多一个居中的容器和一行标题。
|
|
112
|
+
listWrap: { boxSizing: 'border-box', width: '100%', maxWidth: 760, margin: '0 auto', padding: '28px 24px' },
|
|
113
|
+
listHead: { fontWeight: 600, fontSize: 14, marginBottom: 6 },
|
|
114
|
+
// 「某一台的界面」那一屏:顶上一条横条(「← 返回列表」+ 这台电脑叫什么 + 「再试一次」),
|
|
115
|
+
// 下面才是那个铺满的框。**横条在每一种状态下都要在**(正在换票、出错、框没起来),
|
|
116
|
+
// 不然用户会卡在那一屏里出不来。
|
|
117
|
+
//
|
|
118
|
+
// **框正常显示时它会自动收起**(鼠标碰顶才滑出,2026-10-17 用户要求):
|
|
119
|
+
// 那一行平时占着四十来像素,而这块面板是拿来"看另一台电脑的界面"的,能多给一行是一行。
|
|
120
|
+
// 收起用 `max-height: 0`、**不是透明**——透明还占着高度,框就长不上来(同 lib/page.html 那套)。
|
|
121
|
+
screen: { position: 'relative', display: 'flex', flexDirection: 'column', boxSizing: 'border-box', width: '100%', height: '100%', minHeight: 320 },
|
|
122
|
+
/** 收起时最上面那条 16px 的感应带:鼠标碰到就把它勾出来。 */
|
|
123
|
+
screenHot: { position: 'absolute', left: 0, right: 0, top: 0, height: 16, zIndex: 2 },
|
|
124
|
+
/** 横条外壳:收起时高度归零,占位跟着让出来(高度是过渡的,不跳)。 */
|
|
125
|
+
screenBarWrap: { flex: 'none', overflow: 'hidden', transition: 'max-height .25s ease' },
|
|
126
|
+
screenBar: { flex: 'none', display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px', borderBottom: '1px solid var(--dsw-alias-border-l2,#e5e7eb)' },
|
|
127
|
+
screenName: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary,#8b93a1)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
|
128
|
+
// 框铺满面板:**父容器高度拿不准**(可能是定高,也可能是被内容撑开的),
|
|
129
|
+
// 所以外层那一条给一个 320 的兜底高度,这一条自己 flex 撑满剩下的那一块。
|
|
130
|
+
frameWrap: { position: 'relative', boxSizing: 'border-box', width: '100%', flex: '1 1 auto', minHeight: 320 },
|
|
131
|
+
frame: { position: 'absolute', inset: 0, width: '100%', height: '100%', border: 0, display: 'block', background: 'var(--dsw-alias-bg-base,#fff)' },
|
|
132
|
+
// 自动重取那一次(2026-10-17 补):**框上面先摆一条看得见的说明**,框还是照旧铺满下面。
|
|
133
|
+
// 为什么非得有这一条:重试期间那一块本来就是白的/空的,不说话就等于"无声地又转一圈",
|
|
134
|
+
// 用户会以为界面卡住了(见 REMOTE_AUTO_RETRY_TEXT)。
|
|
135
|
+
frameBox: { display: 'flex', flexDirection: 'column', boxSizing: 'border-box', width: '100%', flex: '1 1 auto', minHeight: 0 },
|
|
136
|
+
frameNote: { flex: 'none', padding: '6px 12px', fontSize: 12, lineHeight: 1.6, color: 'var(--dsw-alias-label-tertiary,#8b93a1)', borderBottom: '1px solid var(--dsw-alias-border-l2,#e5e7eb)' },
|
|
137
|
+
// 「设备」那一块:一行一台。名字和说明在左边(flex:1,窄面板下先让它占满),
|
|
138
|
+
// 动作在右边;撤销那次「二次确认」就摊在这一行下面(这个文件里没有弹窗,
|
|
139
|
+
// 全站都是页内两步——跟着来)。
|
|
140
|
+
deviceRow: { display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center', marginTop: 12 },
|
|
141
|
+
deviceMain: { flex: '1 1 200px', minWidth: 0 },
|
|
142
|
+
deviceName: { fontWeight: 600, fontSize: 13, wordBreak: 'break-word' },
|
|
143
|
+
deviceActs: { display: 'flex', gap: 8, flex: 'none' },
|
|
144
|
+
deviceConfirm: { flex: '1 1 100%', display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' },
|
|
145
|
+
// 「其他电脑」那一块:地址单独一行,等宽字体——一长条地址用正文那套字读起来费劲,
|
|
146
|
+
// 而它又是这一行里最要紧的一条(用户要拿它和对面那台电脑上的地址对一眼)。
|
|
147
|
+
remoteUrl: { fontFamily: 'ui-monospace,Menlo,monospace', fontSize: 12, lineHeight: 1.5, wordBreak: 'break-all', color: 'var(--dsw-alias-label-tertiary,#8b93a1)' },
|
|
148
|
+
// 跨机那几句话可能带换行:宿主是**原样转述**对面说的话(`REMOTE_REVOKED_HINT`
|
|
149
|
+
// 就有两行,"接不上那台电脑"那句也有两行)。不设 pre-wrap 的话两行会连成一句,
|
|
150
|
+
// 读起来像中间漏了字。
|
|
151
|
+
say: { fontSize: 12, lineHeight: 1.6, marginTop: 4, whiteSpace: 'pre-wrap', wordBreak: 'break-word' },
|
|
152
|
+
// 「自检」那一块的摘要行:整块里最该被看见的一行(哪一版 · 进程何时起的 · 接口几条在)。
|
|
153
|
+
// 用正文色而不是灰字——它要和上面那些说明文字拉开一点层次。
|
|
154
|
+
selfSummary: { fontSize: 12, lineHeight: 1.7, marginTop: 8, color: 'var(--dsw-alias-label-primary,inherit)', wordBreak: 'break-word' },
|
|
155
|
+
// 「自检」里那几样机器味的东西(接口清单、数据目录):等宽字体 + 保留换行。
|
|
156
|
+
// 一长条路径和十几个接口名用正文那套字读起来分不清,而它们就是要一眼扫过去的。
|
|
157
|
+
code: { fontFamily: 'ui-monospace,Menlo,monospace', fontSize: 12, lineHeight: 1.7, marginTop: 4, whiteSpace: 'pre-wrap', wordBreak: 'break-all', color: 'var(--dsw-alias-label-tertiary,#8b93a1)' },
|
|
52
158
|
};
|
|
53
159
|
|
|
54
|
-
|
|
160
|
+
// —— 跨机 · 「其他电脑」这一块的**状态**(2026-10-16 跨机第三步的界面)。
|
|
161
|
+
//
|
|
162
|
+
// 为什么状态放在组件外面:点那一行的人在**设置页那一节**里,被打开的那一整屏在
|
|
163
|
+
// **中间那个面板**里(main 槽里 key=PANEL_ID 的那一个)——两个组件在不同的槽里,
|
|
164
|
+
// 互相看不见。所以这一份放在这里,两边都读它、两边都订它。
|
|
165
|
+
//
|
|
166
|
+
// 只有四样东西,而且**没有"永远转圈"这一档**:点开的是哪一台、正在换票的是哪一台、
|
|
167
|
+
// 换到的那张票、换不到时那一句人话(见 fetchRemoteTicket)。
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 用户点开的是**哪一台**(`{ id, name }`;null = 没在看别的电脑)。
|
|
171
|
+
*
|
|
172
|
+
* 它和下面那张票是两件事,分开存**是必须的**:换票失败时(对面撤销了、地址不通),
|
|
173
|
+
* 票还是上一台的那一张——要是照票去画那一屏,屏幕上出现的会是**另一台电脑**的界面,
|
|
174
|
+
* 而用户刚点的这台连一句话都没说。
|
|
175
|
+
*/
|
|
176
|
+
var remoteTarget = null;
|
|
177
|
+
/** 已经换到的那张票:`{ id, name, url, at, expiresIn }`;null = 手上没有票。 */
|
|
178
|
+
var remoteWin = null;
|
|
179
|
+
/** 正在为哪一台换票(`''` = 没有在换)。界面照它说「正在换票…」。 */
|
|
180
|
+
var remoteBusyId = '';
|
|
181
|
+
/** `id → { text, revoked }`:那台电脑上一次为什么没成,一句话。 */
|
|
182
|
+
var remoteProblems = {};
|
|
183
|
+
|
|
184
|
+
// —— 「框没打开就自动重取一张票、再试一次」用的两样(2026-10-17 补)。
|
|
185
|
+
//
|
|
186
|
+
// 为什么要有它:2026-10-17 拆掉了"面板每 25 秒自己换一张票"那个机制(票只在开门那一下
|
|
187
|
+
// 用一次,之后那一枚窗口会话 cookie 没有寿命——见 `MiniRemotePanel` 里那一段)。拆得对,
|
|
188
|
+
// 但当时**没有补上任何自动恢复**:只剩下用户手点「再试一次」。于是又多出一个洞——
|
|
189
|
+
// **框的第一次加载被拖过票的寿命(30 秒)时**,票在那一刻已经作废,框就白着,
|
|
190
|
+
// 而旧机制那一下"到点重取"本来能顺带把它救回来。这一条就是补上的那个约束。
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* 这一次「人按下去」的自动重取**用掉了没有**。
|
|
194
|
+
*
|
|
195
|
+
* 只有 20 秒兜底到点那一下会花掉它(每花一次就自动重取一张票、再给框一次机会)。
|
|
196
|
+
* **每一次人主动发起的取票都会把它重置**(名单上点一台 / 设置页点「重连」/ 那一屏的
|
|
197
|
+
* 「再试一次」,都不带 `auto`,见 `fetchRemoteTicket`)——所以"自动重试"永远跟着一次
|
|
198
|
+
* 人的动作,最多一次,**绝不会变成"每 20 秒自己转一圈"那种新的无界行为**。
|
|
199
|
+
*/
|
|
200
|
+
var remoteAutoRetrySpent = false;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 屏幕上这一趟正在转的圈 / 立着的框,是不是"第一次没打开、自动再试一次"那一次。
|
|
204
|
+
*
|
|
205
|
+
* 由自动那一次取票置起(`{ auto: true }`),由框真正 load 回来那一下放下。
|
|
206
|
+
* 界面照它换一句话说(见 `REMOTE_AUTO_RETRY_TEXT`)——**不许无声地又转一圈**。
|
|
207
|
+
*/
|
|
208
|
+
var remoteAutoRetry = false;
|
|
209
|
+
|
|
210
|
+
/** 谁想知道上面这几样变了(面板要先重画一遍)。 */
|
|
211
|
+
var remoteWatchers = [];
|
|
212
|
+
|
|
213
|
+
function notifyRemote() {
|
|
214
|
+
var list = remoteWatchers.slice();
|
|
215
|
+
for (var i = 0; i < list.length; i += 1) {
|
|
216
|
+
try { list[i](); } catch (err) { /* 一个订阅者出事不该拦住别人 */ }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 订阅那三样。返回退订函数——**组件卸载时必须退订**,否则留住一个死组件。 */
|
|
221
|
+
function subscribeRemote(fn) {
|
|
222
|
+
remoteWatchers.push(fn);
|
|
223
|
+
return function () {
|
|
224
|
+
var at = remoteWatchers.indexOf(fn);
|
|
225
|
+
if (at >= 0) remoteWatchers.splice(at, 1);
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 对面把这条记录撤销了时,界面上那句话。
|
|
231
|
+
*
|
|
232
|
+
* 它和宿主写的那句(`REMOTE_REVOKED_HINT`,"那台电脑已经不认这枚凭据了……")**同时**
|
|
233
|
+
* 出现:这一句说的是"现在是什么状况",宿主那一句说的是"接下来怎么办"。用户手上真正
|
|
234
|
+
* 能做的只有一件事——拿对面的口令重新配一次,所以这一句必须摆在这一行上。
|
|
235
|
+
*/
|
|
236
|
+
var REMOTE_REVOKED_ROW = '这台电脑已经被对方撤销了,要重新配对';
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 「对面撤销了」这个判据为什么只能认字。
|
|
240
|
+
*
|
|
241
|
+
* 宿主那一半把对面回的 **401 翻成了 502**,并且换成自己写的那句话(见 lib/index.js 的
|
|
242
|
+
* `REMOTE_REVOKED_HINT`)——**401 本身到不了界面**。所以那句话是界面上唯一能认出
|
|
243
|
+
* "对面撤销了"的信号,认它最独特的那半句。认不出(对面换了说法、或者是别的错)就
|
|
244
|
+
* 照原话显示,**绝不猜**:猜错的代价是让人去重新配对一台其实好着的电脑。
|
|
245
|
+
*/
|
|
246
|
+
var REMOTE_REVOKED_MARK = '已经不认这枚凭据';
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 那一屏等多久还没打开,就算"框没打开":先自动重取一张票再试一次,第二次到点才给人话。
|
|
250
|
+
*
|
|
251
|
+
* 为什么需要它:框里是**另一个源**(那台电脑的地址),我们一个字节都读不到
|
|
252
|
+
* (2026-10-16 在无头 Edge 上量过:跨源的 `contentDocument` 是 null)。能等的只有
|
|
253
|
+
* "框自己有没有 load"。量出来的事实是——**502、端口上没人应答都会照常 load**
|
|
254
|
+
* (浏览器把错误页也算一次成功的文档),只有"连上了却一直不回话"这种才是真等不到。
|
|
255
|
+
* 所以这一条**不是**用来判"对面坏了没有"的,它兜的是"一个空白框永远挂在那儿"。
|
|
256
|
+
*
|
|
257
|
+
* 到点之后做什么(2026-10-17 补的那条约束):
|
|
258
|
+
* · 这一次人的动作**还没用过**自动重取 → 先自动重取一张票、再给框一次机会
|
|
259
|
+
* ——票是一次性的、30 秒就作废,"第一次加载被拖过 30 秒"那个洞就靠这一下救回来
|
|
260
|
+
* (见 `remoteAutoRetrySpent`);
|
|
261
|
+
* · 自动那一趟也没成、又到点 → 才把框换成一句人话 + 「再试一次」,**不能是个转不完的圈**。
|
|
262
|
+
*/
|
|
263
|
+
var REMOTE_FRAME_WAIT_MS = 20000;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* 自动重取那一次在屏幕上写的那句话(2026-10-17 补)。
|
|
267
|
+
*
|
|
268
|
+
* 为什么非有不可:换票那一刻和重试那个框的这 20 秒里,那一块本来就是白的/空的
|
|
269
|
+
* (`MiniRemotePanel` 里那两处渲染用它顶着)。不说话就等于"无声地又转一圈",
|
|
270
|
+
* 用户会以为界面卡住了——这正是这一条补丁自己要防的坏法。
|
|
271
|
+
*/
|
|
272
|
+
var REMOTE_AUTO_RETRY_TEXT = '第一次没打开,正在再试一次…';
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 去宿主那儿换一张票,最多等多久(**这一条请求自己的超时**)。
|
|
276
|
+
*
|
|
277
|
+
* 为什么定 15 秒:宿主那一半替我们去问那台电脑时**自己有一条 8 秒的上限**
|
|
278
|
+
* (lib/index.js 的 `REMOTE_CALL_TIMEOUT_MS` 与 `callRemote` 里那个 `AbortSignal.timeout`),
|
|
279
|
+
* 所以正常情况下最坏 ~8 秒就会带着一句人话回来。15 秒 = 那条上限 + 一段余量
|
|
280
|
+
* (宿主进程正忙、本机这一跳排在长任务后面)。**比 8 秒小是不对的**:那会把
|
|
281
|
+
* "慢一点但会成"的正当请求掐掉,用户白点一次。
|
|
282
|
+
*
|
|
283
|
+
* 这一条防的是**另一个形状**的坏:宿主整个不回话(本机这个服务卡住、这一跳根本没被处理)
|
|
284
|
+
* ——那种情况下请求不会自己失败,界面就永远停在"正在向那台电脑换一张通行证…",
|
|
285
|
+
* 那正是这个功能最要防的"转不完的圈"。到点主动掐掉,换成一句人话 + 「再试一次」。
|
|
286
|
+
*/
|
|
287
|
+
var REMOTE_TICKET_WAIT_MS = 15000;
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* 等不到宿主回话时,那一屏上写的那句话(落到 `remoteProblems[id]` 里走现成那条路:
|
|
291
|
+
* **一句人话 + 「再试一次」+ 顶上那条「← 返回列表」**,见 remoteProblemPanel 与 remoteScreen)。
|
|
292
|
+
*/
|
|
293
|
+
var REMOTE_TIMEOUT_TEXT = '等这台电脑上的遥控服务回话,等了 ' + Math.round(REMOTE_TICKET_WAIT_MS / 1000) + ' 秒还没等到。\n'
|
|
294
|
+
+ '它可能正忙,或者这会儿本机的服务卡住了。点下面的「再试一次」再换一张通行证。';
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* 去宿主那儿换一张票(**「重连」就是再调一次它**)。返回 `Promise<{ok,error?}>`。
|
|
298
|
+
*
|
|
299
|
+
* ## 每一条路都以一句话结束
|
|
300
|
+
*
|
|
301
|
+
* 「转不完的圈」是这个功能最要防的一种坏法:方案里记着那个前车之鉴——宿主回 502
|
|
302
|
+
* 时,界面外壳**不会重试**,于是永远停在「Loading plugins…」,不自愈。所以这个函数
|
|
303
|
+
* **只有两种结局**:票放进 `remoteWin`,或者一句人能照着做的话放进 `remoteProblems[id]`。
|
|
304
|
+
* 网络层失败、非 200、回执里没有地址,一条都不例外。
|
|
305
|
+
*
|
|
306
|
+
* ## 401 / 502 是怎么变成人话的
|
|
307
|
+
*
|
|
308
|
+
* 401 到不了这里(见 `REMOTE_REVOKED_MARK` 那一段),到的是宿主翻好的 502 +
|
|
309
|
+
* 那句话:这里①**照原话显示**,②认出那句话之后**额外**把这一行标成"已经被对方
|
|
310
|
+
* 撤销了",让用户知道下一步是重新配对,而不是反复点「重连」。
|
|
311
|
+
* 别的 502(对面 503 / 403 / 地址不通)宿主也各写好了人话(见 lib/index.js 的
|
|
312
|
+
* `remoteStatusText` 与 `callRemote`),照原话显示即可——**界面这一层不自己编**。
|
|
313
|
+
*
|
|
314
|
+
* ## 等不到回话:这一条请求**自己带一个超时**
|
|
315
|
+
*
|
|
316
|
+
* 见 `REMOTE_TICKET_WAIT_MS`。到点我们主动掐掉这一趟,而且**认得出是"我们等不及了"**
|
|
317
|
+
* ——那不是网络自己断的,给的人话也必须是另一句("等了 15 秒还没等到")。
|
|
318
|
+
*
|
|
319
|
+
* ## 三个入口 + 一个自动入口(2026-10-17 补)
|
|
320
|
+
*
|
|
321
|
+
* 人按的三个(名单上点一台 / 设置页点「重连」/ 那一屏的「再试一次」)都不带 `opts`;
|
|
322
|
+
* 第四个是**框没打开时那一下自动重取**,带 `{ auto: true }`(见 `remoteAutoRetrySpent`)。
|
|
323
|
+
* 两者的差别只有一处:自动那一次**不重置**那份预算,所以自动重取绝不会自己接着自己转。
|
|
324
|
+
*/
|
|
325
|
+
function fetchRemoteTicket(id, name, opts) {
|
|
326
|
+
// **是不是"框没打开"那一下的自动重取**(2026-10-17 补,见 remoteAutoRetrySpent/remoteAutoRetry)。
|
|
327
|
+
// 人按的每一次(不带 auto)都把那份预算重置:自动重取于是**永远跟着一次人的动作、最多一次**;
|
|
328
|
+
// 而「再试一次」是人按的,所以它照旧可以一直点下去(无限次)。
|
|
329
|
+
var auto = !!(opts && opts.auto);
|
|
330
|
+
remoteAutoRetry = auto;
|
|
331
|
+
if (!auto) remoteAutoRetrySpent = false;
|
|
332
|
+
remoteBusyId = id;
|
|
333
|
+
// 手上前一张票作废:它要么是**上一台**的,要么已经过期了。留着它唯一的后果是
|
|
334
|
+
// 屏幕上出现一台用户没在看的电脑。
|
|
335
|
+
remoteWin = null;
|
|
336
|
+
// 上一次那句话先撤掉:正在重试的时候,那一行不该还挂着上一次的结论。
|
|
337
|
+
delete remoteProblems[id];
|
|
338
|
+
notifyRemote();
|
|
339
|
+
// `spent`:这一趟已经落地(成功/失败/超时)。一立起来,**后面再来什么回音都不改了**
|
|
340
|
+
// ——超时之后才回来的那份回执,拿它去开框等于让屏幕上突然冒出一个用户已经放弃的界面。
|
|
341
|
+
var spent = false;
|
|
342
|
+
var stopper = typeof AbortController === 'function' ? new AbortController() : null;
|
|
343
|
+
var timer = null;
|
|
344
|
+
/**
|
|
345
|
+
* 这一趟落地成"没成":一句人话进 `remoteProblems[id]`,并清掉"正在换票"。
|
|
346
|
+
* 和别的失败走的是**同一条路**——那一屏照旧是人话 + 「再试一次」+ 「← 返回列表」。
|
|
347
|
+
*/
|
|
348
|
+
var fail = function (text) {
|
|
349
|
+
if (spent) return { ok: false, error: text, revoked: false };
|
|
350
|
+
spent = true;
|
|
351
|
+
clearTimeout(timer);
|
|
352
|
+
remoteBusyId = '';
|
|
353
|
+
remoteProblems[id] = { text: text, revoked: false };
|
|
354
|
+
notifyRemote();
|
|
355
|
+
return { ok: false, error: text, revoked: false };
|
|
356
|
+
};
|
|
357
|
+
// **这一条请求自己的超时**(时长与理由见 REMOTE_TICKET_WAIT_MS)。
|
|
358
|
+
// 掐不掉那条请求时(老浏览器没有 AbortController)状态也照落:迟到的回执被 `spent`
|
|
359
|
+
// 挡住,界面绝不会停在"正在换票…"上。
|
|
360
|
+
timer = setTimeout(function () {
|
|
361
|
+
if (stopper) stopper.abort();
|
|
362
|
+
fail(REMOTE_TIMEOUT_TEXT);
|
|
363
|
+
}, REMOTE_TICKET_WAIT_MS);
|
|
364
|
+
return fetch(REMOTE_WINDOW_PATH, {
|
|
365
|
+
method: 'POST',
|
|
366
|
+
headers: { 'content-type': 'application/json' },
|
|
367
|
+
body: JSON.stringify({ id: id }),
|
|
368
|
+
signal: stopper ? stopper.signal : undefined,
|
|
369
|
+
}).then(function (res) {
|
|
370
|
+
return res.json().then(
|
|
371
|
+
function (b) { return { status: res.status, body: b }; },
|
|
372
|
+
function () { return { status: res.status, body: null }; },
|
|
373
|
+
);
|
|
374
|
+
}).then(function (r) {
|
|
375
|
+
if (spent) return { ok: false, error: REMOTE_TIMEOUT_TEXT, revoked: false };
|
|
376
|
+
spent = true;
|
|
377
|
+
clearTimeout(timer);
|
|
378
|
+
var b = (r.body && typeof r.body === 'object') ? r.body : null;
|
|
379
|
+
remoteBusyId = '';
|
|
380
|
+
if (b && b.ok === true && typeof b.url === 'string' && b.url) {
|
|
381
|
+
remoteWin = {
|
|
382
|
+
id: id,
|
|
383
|
+
name: name || '',
|
|
384
|
+
url: b.url,
|
|
385
|
+
at: Date.now(),
|
|
386
|
+
// 票的寿命(宿主说了算,真机上是 30 秒)。**界面不再照它定期重取**:票只在
|
|
387
|
+
// "开这一台"那一刻用一次(进门那一下兑成窗口会话 cookie),地址一旦交给框就不再变。
|
|
388
|
+
// 留着这两个字段是如实记下宿主回执的形状,也方便出事时对时间。
|
|
389
|
+
expiresIn: Number(b.expiresIn) || 0,
|
|
390
|
+
};
|
|
391
|
+
notifyRemote();
|
|
392
|
+
return { ok: true };
|
|
393
|
+
}
|
|
394
|
+
var text = (b && b.error) || ('换票失败:这台电脑上的服务返回了 HTTP ' + r.status + '。');
|
|
395
|
+
remoteProblems[id] = { text: text, revoked: text.indexOf(REMOTE_REVOKED_MARK) >= 0 };
|
|
396
|
+
notifyRemote();
|
|
397
|
+
return { ok: false, error: text, revoked: remoteProblems[id].revoked };
|
|
398
|
+
}).catch(function (err) {
|
|
399
|
+
if (spent) return { ok: false, error: REMOTE_TIMEOUT_TEXT, revoked: false };
|
|
400
|
+
return fail('连不上这台电脑上的遥控服务:' + ((err && err.message) || String(err)));
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* 从「某一台的界面」**退回名单**:手上这一趟放下,人还留在我们这一屏里(2026-10-17)。
|
|
406
|
+
*
|
|
407
|
+
* 和下面的 `leaveRemotePanel` 是两件事:那个是**离开这一屏**(回到进来之前那一屏),
|
|
408
|
+
* 这个是**只上一层**。两处都用到它:界面顶上那枚「← 返回列表」,和"再点一下侧栏那一条"
|
|
409
|
+
* (见 onDocumentClick)。
|
|
410
|
+
*/
|
|
411
|
+
function leaveRemoteScreen() {
|
|
412
|
+
remoteTarget = null;
|
|
413
|
+
remoteWin = null;
|
|
414
|
+
notifyRemote();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* 从名单上点开一台:**先记住是哪一台**,再去换一张票(那一下的成败都落在这一份状态里)。
|
|
419
|
+
*
|
|
420
|
+
* 顺序是刻意的:先写 `remoteTarget` 再取票,取票失败时那一屏才说得清"是哪一台没成"
|
|
421
|
+
* ——照票去画的话,屏幕上会出现上一台电脑的界面(见 `remoteTarget` 那段)。
|
|
422
|
+
* 设置页那一块(`connectRemote`)走的是同一条路的前两步,第三步它自己接(切到这一屏 + 关掉设置页)。
|
|
423
|
+
*/
|
|
424
|
+
function openRemoteScreen(one) {
|
|
425
|
+
if (remoteBusyId) return;
|
|
426
|
+
remoteTarget = { id: one.id, name: one.name || '' };
|
|
427
|
+
fetchRemoteTicket(one.id, one.name);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* 设置页那一节。
|
|
432
|
+
*
|
|
433
|
+
* `props.close` 是**设置页外壳递进来的**(dsh-client-ui-settings-general 的
|
|
434
|
+
* `renderSlot("settings.section", { close: onClose })`):设置页是一个盖在整个界面
|
|
435
|
+
* 上的浮层,要"整屏显示别的电脑"就得先把它关掉——不关的话切过去的那一屏在它后面,
|
|
436
|
+
* 用户看到的是"点了没反应"。拿不到(极简假壳里)也不要紧:那一块会如实说清
|
|
437
|
+
* "点侧栏那枚图标去看"(见 connectRemote)。
|
|
438
|
+
*/
|
|
439
|
+
function MiniRemoteSettingsTab(props) {
|
|
55
440
|
var infoState = useState(null);
|
|
56
441
|
var info = infoState[0];
|
|
57
442
|
var setInfo = infoState[1];
|
|
@@ -92,6 +477,59 @@ window.__ModuleLoader__.load({
|
|
|
92
477
|
var guideState = useState(false);
|
|
93
478
|
var guideOpen = guideState[0];
|
|
94
479
|
var setGuideOpen = guideState[1];
|
|
480
|
+
// 设备名册(第三步):服务端那份名单、读不到时的原因,以及"正在改名"和
|
|
481
|
+
// "正在二次确认撤销"这两个**一次只可能有一处**的状态。
|
|
482
|
+
// 两个都存 id,不存整条记录——记录会随每次刷新变,存下来就会拿着一份旧名字去改。
|
|
483
|
+
var devicesState = useState(null);
|
|
484
|
+
var devices = devicesState[0];
|
|
485
|
+
var setDevices = devicesState[1];
|
|
486
|
+
var devErrState = useState(null);
|
|
487
|
+
var devicesError = devErrState[0];
|
|
488
|
+
var setDevicesError = devErrState[1];
|
|
489
|
+
var renameState = useState(null);
|
|
490
|
+
var renaming = renameState[0];
|
|
491
|
+
var setRenaming = renameState[1];
|
|
492
|
+
var confirmState = useState(null);
|
|
493
|
+
var confirming = confirmState[0];
|
|
494
|
+
var setConfirming = confirmState[1];
|
|
495
|
+
// 「其他电脑」那一块(跨机第三步的界面):我连得上哪几台电脑。名单从宿主那份来
|
|
496
|
+
// (`GET /mini-remote/remotes`),动作两条——添加、断开(断开要二次确认)。
|
|
497
|
+
// 「点这一行」和「重连」是同一件事:去宿主那儿换一张票(见 fetchRemoteTicket)。
|
|
498
|
+
var remotesState = useState(null);
|
|
499
|
+
var remotes = remotesState[0];
|
|
500
|
+
var setRemotes = remotesState[1];
|
|
501
|
+
var remotesErrState = useState(null);
|
|
502
|
+
var remotesError = remotesErrState[0];
|
|
503
|
+
var setRemotesError = remotesErrState[1];
|
|
504
|
+
// 断开之后宿主给的那句话(说清"对面那台电脑上还留着这条记录")。原样显示。
|
|
505
|
+
var remoteNoteState = useState(null);
|
|
506
|
+
var remoteNote = remoteNoteState[0];
|
|
507
|
+
var setRemoteNote = remoteNoteState[1];
|
|
508
|
+
// 添加那一块:`null` = 收着;一个草稿 = 摊开着(三个框 + 一次确认)。
|
|
509
|
+
var addState = useState(null);
|
|
510
|
+
var addDraft = addState[0];
|
|
511
|
+
var setAddDraft = addState[1];
|
|
512
|
+
var addErrState = useState(null);
|
|
513
|
+
var addError = addErrState[0];
|
|
514
|
+
var setAddError = addErrState[1];
|
|
515
|
+
// 正在二次确认「断开」的那一台(存 id,不存整条记录——记录每次刷新都会变)。
|
|
516
|
+
var forgetState = useState(null);
|
|
517
|
+
var forgetting = forgetState[0];
|
|
518
|
+
var setForgetting = forgetState[1];
|
|
519
|
+
// 「自检」那一块(2026-10-17):这台电脑在跑哪一版、哪些接口在、开关什么状态。
|
|
520
|
+
// 一个快照 + 一个「重新自检」按钮——手机服务可能刚起来、路由可能刚变,
|
|
521
|
+
// 用户得能当场再看一眼,而不是关掉这一页再进来一次。
|
|
522
|
+
var selfState = useState(null);
|
|
523
|
+
var selfcheck = selfState[0];
|
|
524
|
+
var setSelfcheck = selfState[1];
|
|
525
|
+
var selfErrState = useState(null);
|
|
526
|
+
var selfError = selfErrState[0];
|
|
527
|
+
var setSelfError = selfErrState[1];
|
|
528
|
+
// 默认展开:这一块是"出问题时才来看"的,进来了就该直接看见那几行,
|
|
529
|
+
// 而不是先点一下。内容不长(十来行),不会把上面要操作的几块挤走。
|
|
530
|
+
var selfOpenState = useState(true);
|
|
531
|
+
var selfOpen = selfOpenState[0];
|
|
532
|
+
var setSelfOpen = selfOpenState[1];
|
|
95
533
|
|
|
96
534
|
// 同源 GET,不带任何 token:这台电脑自己读得到,别人的电脑读不到。
|
|
97
535
|
useEffect(function () {
|
|
@@ -151,7 +589,347 @@ window.__ModuleLoader__.load({
|
|
|
151
589
|
} catch (e) { /* 剪贴板不可用:保持原样 */ }
|
|
152
590
|
};
|
|
153
591
|
|
|
154
|
-
|
|
592
|
+
/**
|
|
593
|
+
* 读设备名单。
|
|
594
|
+
*
|
|
595
|
+
* 每次动作之后都**重新读一遍服务端那份**,而不是在本地改一改数组(改名就把
|
|
596
|
+
* 名字换掉、撤销就把那一行删掉):屏幕上显示的必须就是服务端此刻认的那份名册。
|
|
597
|
+
* 本地拼一份"看起来对"的列表,迟早和真名册分叉——而分叉的表现正是这一节最不能
|
|
598
|
+
* 出的那种错:「界面上删了,那台手机还能进」。
|
|
599
|
+
*/
|
|
600
|
+
var loadDevices = function () {
|
|
601
|
+
return fetch(DEVICES_PATH)
|
|
602
|
+
.then(function (res) {
|
|
603
|
+
return res.json().then(
|
|
604
|
+
function (body) { return { status: res.status, body: body }; },
|
|
605
|
+
function () { return { status: res.status, body: null }; },
|
|
606
|
+
);
|
|
607
|
+
})
|
|
608
|
+
.then(function (r) {
|
|
609
|
+
var b = (r.body && typeof r.body === 'object') ? r.body : null;
|
|
610
|
+
if (b && b.ok && Array.isArray(b.devices)) {
|
|
611
|
+
setDevices(b.devices);
|
|
612
|
+
setDevicesError(null);
|
|
613
|
+
} else if (r.status === 404 || r.status === 405) {
|
|
614
|
+
// **这条路不在这儿**(宿主那一半是旧版,或者设置页没走到我们那台服务器)。
|
|
615
|
+
// 这一支单独写,是因为那种情况下服务端回的是一句给程序看的「not found」——
|
|
616
|
+
// 原样摆在设置页上,用户看到的就是一条外星文,而下一步其实只有一句话:重启。
|
|
617
|
+
setDevicesError('读设备名单失败:这台电脑上的服务没有这个接口(HTTP ' + r.status + ')。重启一次 DSH 再试。');
|
|
618
|
+
} else {
|
|
619
|
+
setDevicesError((b && b.error) || ('读设备名单失败:服务返回了 HTTP ' + r.status + '。'));
|
|
620
|
+
}
|
|
621
|
+
})
|
|
622
|
+
.catch(function (err) {
|
|
623
|
+
setDevicesError('读设备名单失败:' + ((err && err.message) || String(err)));
|
|
624
|
+
});
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* 名单在**配对信息到了之后**才拉。
|
|
629
|
+
*
|
|
630
|
+
* 判据只看"配对信息回来没有",**不看它的 ok**:`ok:false` 说的是"这台电脑现在
|
|
631
|
+
* 没有手机连得上的地址"(比如只绑了回环),而设备名册跟那件事一点关系都没有——
|
|
632
|
+
* 地址连不上的时候,用户恰恰更需要看到名单、把旧设备撤掉。三条路由只要有
|
|
633
|
+
* webServer 就在,和手机服务绑了哪个地址无关。
|
|
634
|
+
*/
|
|
635
|
+
useEffect(function () {
|
|
636
|
+
if (!info) return undefined;
|
|
637
|
+
loadDevices();
|
|
638
|
+
// 「其他电脑」那份名单和配对信息也没有关系(它是"我连得上谁"),同一趟一起读。
|
|
639
|
+
loadRemotes();
|
|
640
|
+
// 自检同理:它读的是这台电脑自己的底细(版本、进程、路由、开关、端口),
|
|
641
|
+
// 和"手机连不连得上"一点关系都没有——所以配对信息一回来就一起读。
|
|
642
|
+
loadSelfcheck();
|
|
643
|
+
return undefined;
|
|
644
|
+
}, [info]);
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* 一次 POST + 解析 JSON 的短写法。
|
|
648
|
+
*
|
|
649
|
+
* **只给这一节新加的两条动作(改名 / 撤销)用**:文件里别处那些各自还带着自己的
|
|
650
|
+
* 失败分支(有的要把 appSecret 留着、有的要带一条开启链接),硬拧成一股反而看不清。
|
|
651
|
+
* 这里两条的回执形状一样,才值得这一个函数。
|
|
652
|
+
*/
|
|
653
|
+
var postJson = function (path, body) {
|
|
654
|
+
return fetch(path, {
|
|
655
|
+
method: 'POST',
|
|
656
|
+
headers: { 'content-type': 'application/json' },
|
|
657
|
+
body: JSON.stringify(body),
|
|
658
|
+
}).then(function (res) {
|
|
659
|
+
return res.json().then(
|
|
660
|
+
function (b) { return { status: res.status, body: b }; },
|
|
661
|
+
function () { return { status: res.status, body: null }; },
|
|
662
|
+
);
|
|
663
|
+
});
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
/** 点「改名」:把那台设备切到编辑态。**同时收起别的确认框**(一次只做一件事)。 */
|
|
667
|
+
var startRename = function (one) {
|
|
668
|
+
setConfirming(null);
|
|
669
|
+
setDevicesError(null);
|
|
670
|
+
setRenaming({ id: one.id, draft: typeof one.name === 'string' ? one.name : '' });
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
/** 存新名字。名字只是标签,存完照旧认人——所以这里不掐线、不重连。 */
|
|
674
|
+
var saveRename = function () {
|
|
675
|
+
if (!renaming) return;
|
|
676
|
+
var next = (renaming.draft || '').trim();
|
|
677
|
+
if (!next) {
|
|
678
|
+
setDevicesError('名字不能是空的。');
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
setBusy(true);
|
|
682
|
+
postJson(DEVICE_RENAME_PATH, { id: renaming.id, name: next })
|
|
683
|
+
.then(function (r) {
|
|
684
|
+
var b = (r.body && typeof r.body === 'object') ? r.body : null;
|
|
685
|
+
if (!b || b.ok !== true) {
|
|
686
|
+
setDevicesError((b && b.error) || ('改名失败:服务返回了 HTTP ' + r.status + '。'));
|
|
687
|
+
return undefined;
|
|
688
|
+
}
|
|
689
|
+
setDevicesError(null);
|
|
690
|
+
setRenaming(null);
|
|
691
|
+
// **改完再列一次**:屏幕上那份必须来自服务端,不能是我们以为改成了什么。
|
|
692
|
+
return loadDevices();
|
|
693
|
+
})
|
|
694
|
+
.catch(function (err) {
|
|
695
|
+
setDevicesError('改名失败:' + ((err && err.message) || String(err)));
|
|
696
|
+
})
|
|
697
|
+
.then(function () { setBusy(false); });
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* 真的撤销一台设备。**只有二次确认之后才会走到这儿**(见 devicesBlock:
|
|
702
|
+
* 点「撤销」只是把那句后果摊出来,要点「确认撤销」才发这一条)。
|
|
703
|
+
*
|
|
704
|
+
* 服务端那一步会删记录 + 掐掉它手上的活连接(见 lib/index.js 与 lib/server.js
|
|
705
|
+
* 的 dropDeviceConnections)。这边只管如实转述结果,成功之后重新列一次。
|
|
706
|
+
*/
|
|
707
|
+
var revokeDevice = function (id) {
|
|
708
|
+
setBusy(true);
|
|
709
|
+
postJson(DEVICE_REVOKE_PATH, { id: id })
|
|
710
|
+
.then(function (r) {
|
|
711
|
+
var b = (r.body && typeof r.body === 'object') ? r.body : null;
|
|
712
|
+
if (!b || b.ok !== true) {
|
|
713
|
+
setDevicesError((b && b.error) || ('撤销失败:服务返回了 HTTP ' + r.status + '。'));
|
|
714
|
+
return undefined;
|
|
715
|
+
}
|
|
716
|
+
setDevicesError(null);
|
|
717
|
+
setConfirming(null);
|
|
718
|
+
return loadDevices();
|
|
719
|
+
})
|
|
720
|
+
.catch(function (err) {
|
|
721
|
+
setDevicesError('撤销失败:' + ((err && err.message) || String(err)));
|
|
722
|
+
})
|
|
723
|
+
.then(function () { setBusy(false); });
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
// 「其他电脑」那三样状态在组件**外面**(见那一块的开头):变了就重画一遍。
|
|
727
|
+
// 这一节里点一下「重连」,正在换票 / 换到了 / 没换成**都发生在这里**——
|
|
728
|
+
// 那一行要当场跟着变(尤其是没换成时那句话,它就摆在这一行底下)。
|
|
729
|
+
// 值本身不用,`setPoke` 那一下就是目的。
|
|
730
|
+
var pokeRemoteState = useState(0);
|
|
731
|
+
useEffect(function () {
|
|
732
|
+
return subscribeRemote(function () { pokeRemoteState[1](function (n) { return n + 1; }); });
|
|
733
|
+
}, []);
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* 读「我能连的电脑」那份名单。
|
|
737
|
+
*
|
|
738
|
+
* 和 `loadDevices` 同一条规矩:每次动作之后**重新读服务端那份**,不在本地改数组。
|
|
739
|
+
* 「界面上删了、其实还留着」在这个功能里同样是最难查的一种错(那一行照旧能点开
|
|
740
|
+
* 对面那台电脑)。
|
|
741
|
+
*/
|
|
742
|
+
var loadRemotes = function () {
|
|
743
|
+
return fetch(REMOTES_PATH)
|
|
744
|
+
.then(function (res) {
|
|
745
|
+
return res.json().then(
|
|
746
|
+
function (body) { return { status: res.status, body: body }; },
|
|
747
|
+
function () { return { status: res.status, body: null }; },
|
|
748
|
+
);
|
|
749
|
+
})
|
|
750
|
+
.then(function (r) {
|
|
751
|
+
var b = (r.body && typeof r.body === 'object') ? r.body : null;
|
|
752
|
+
if (b && b.ok && Array.isArray(b.remotes)) {
|
|
753
|
+
setRemotes(b.remotes);
|
|
754
|
+
setRemotesError(null);
|
|
755
|
+
} else if (r.status === 404 || r.status === 405) {
|
|
756
|
+
// 这条路不在这儿:宿主那一半是旧版。服务端回的是给程序看的 `not found`,
|
|
757
|
+
// 原样摆出来用户读不懂,而下一步只有一句话:重启。
|
|
758
|
+
setRemotesError('读「其他电脑」名单失败:这台电脑上的服务没有这个接口(HTTP ' + r.status + ')。重启一次 DSH 再试。');
|
|
759
|
+
} else {
|
|
760
|
+
setRemotesError((b && b.error) || ('读「其他电脑」名单失败:服务返回了 HTTP ' + r.status + '。'));
|
|
761
|
+
}
|
|
762
|
+
})
|
|
763
|
+
.catch(function (err) {
|
|
764
|
+
setRemotesError('读「其他电脑」名单失败:' + ((err && err.message) || String(err)));
|
|
765
|
+
});
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* 读「自检」那一份(`GET /mini-remote/selfcheck`,只允许本机)。
|
|
770
|
+
*
|
|
771
|
+
* 三种结局分开说,一种都不许含糊:
|
|
772
|
+
* · 读到了 → 把那一份摊开;
|
|
773
|
+
* · **404/405 = 这条路不在这儿**(宿主那一半是旧版)。这一支要单独说,因为
|
|
774
|
+
* 服务端回的是给程序看的 `not found`,原样摆出来用户读不懂,而它能得到的
|
|
775
|
+
* 最有用的信息恰恰是那句话:**装是装上了,但跑的还是旧的,重启一次 DSH**。
|
|
776
|
+
* · 别的错(网络断了、服务没起来)→ 照登原因。
|
|
777
|
+
*
|
|
778
|
+
* 读不到**不吞**:这一块照样出现,只是那句原因摆在那儿(见 selfcheckBlock)。
|
|
779
|
+
*/
|
|
780
|
+
var loadSelfcheck = function () {
|
|
781
|
+
return fetch(SELFCHECK_PATH)
|
|
782
|
+
.then(function (res) {
|
|
783
|
+
return res.json().then(
|
|
784
|
+
function (body) { return { status: res.status, body: body }; },
|
|
785
|
+
function () { return { status: res.status, body: null }; },
|
|
786
|
+
);
|
|
787
|
+
})
|
|
788
|
+
.then(function (r) {
|
|
789
|
+
var b = (r.body && typeof r.body === 'object') ? r.body : null;
|
|
790
|
+
if (b && b.ok === true) {
|
|
791
|
+
setSelfcheck(b);
|
|
792
|
+
setSelfError(null);
|
|
793
|
+
} else if (r.status === 404 || r.status === 405) {
|
|
794
|
+
setSelfError('这台电脑上的服务没有「自检」这个接口(HTTP ' + r.status + ')。'
|
|
795
|
+
+ '装上的是新版、跑的却可能是旧版——重启一次 DSH 才会加载新版本。');
|
|
796
|
+
} else {
|
|
797
|
+
setSelfError((b && b.error) || ('读自检失败:服务返回了 HTTP ' + r.status + '。'));
|
|
798
|
+
}
|
|
799
|
+
})
|
|
800
|
+
.catch(function (err) {
|
|
801
|
+
setSelfError('读自检失败:' + ((err && err.message) || String(err)));
|
|
802
|
+
});
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
/** 摊开添加那一块。**同时收起别的确认**(一次只做一件事,照上面的改名/撤销)。 */
|
|
806
|
+
var openAdd = function () {
|
|
807
|
+
setForgetting(null);
|
|
808
|
+
setRemoteNote(null);
|
|
809
|
+
setAddError(null);
|
|
810
|
+
setAddDraft({ name: '', baseUrl: '', token: '' });
|
|
811
|
+
};
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* 添加一台电脑:拿它的**口令**换一枚自己的凭据(换到了才落盘,见 lib/index.js)。
|
|
815
|
+
*
|
|
816
|
+
* 前端这两道拦的只是"太明显了"的空格(当场说清,不用等一趟网络);真正的门在宿主
|
|
817
|
+
* 那一边,这里改了那边不改照样进不去——和改密码那一块同一条口径。
|
|
818
|
+
*/
|
|
819
|
+
var saveAdd = function () {
|
|
820
|
+
if (!addDraft || busy) return;
|
|
821
|
+
var baseUrl = (addDraft.baseUrl || '').trim();
|
|
822
|
+
var token = (addDraft.token || '').trim();
|
|
823
|
+
if (!baseUrl) {
|
|
824
|
+
setAddError('要填那台电脑的地址——就是它的「手机遥控」页那条(http:// 地址:3090,或者 https:// 的 Tailscale 地址;'
|
|
825
|
+
+ '不要带 /mini 和它后面的参数)。');
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
if (!token) {
|
|
829
|
+
setAddError('要填那台电脑的口令(在它的「手机遥控」设置里能复制到)。');
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
setAddError(null);
|
|
833
|
+
setBusy(true);
|
|
834
|
+
postJson(REMOTE_ADD_PATH, {
|
|
835
|
+
name: (addDraft.name || '').trim(),
|
|
836
|
+
baseUrl: baseUrl,
|
|
837
|
+
token: token,
|
|
838
|
+
})
|
|
839
|
+
.then(function (r) {
|
|
840
|
+
var b = (r.body && typeof r.body === 'object') ? r.body : null;
|
|
841
|
+
if (!b || b.ok !== true) {
|
|
842
|
+
setAddError((b && b.error) || ('添加失败:服务返回了 HTTP ' + r.status + '。'));
|
|
843
|
+
return undefined;
|
|
844
|
+
}
|
|
845
|
+
setAddDraft(null);
|
|
846
|
+
setRemoteNote(null);
|
|
847
|
+
// **加完再列一次**:屏幕上那份必须来自服务端(名字是它给的默认值)。
|
|
848
|
+
return loadRemotes();
|
|
849
|
+
})
|
|
850
|
+
.catch(function (err) {
|
|
851
|
+
setAddError('添加失败:' + ((err && err.message) || String(err)));
|
|
852
|
+
})
|
|
853
|
+
.then(function () { setBusy(false); });
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* 真的断开一台电脑:**只删这台电脑上的那一条**(对面那份记录照旧在,见 lib/remotes.js)。
|
|
858
|
+
*
|
|
859
|
+
* 只有二次确认之后才会走到这儿。成功之后把宿主给的那句话(`note`)摆出来——
|
|
860
|
+
* 它说的是"对面还留着这条记录,要彻底收回得去那边撤销"。这是最容易留下的误会:
|
|
861
|
+
* 用户以为断开等于收回了权限。
|
|
862
|
+
*/
|
|
863
|
+
var forgetRemote = function (id) {
|
|
864
|
+
setBusy(true);
|
|
865
|
+
postJson(REMOTE_FORGET_PATH, { id: id })
|
|
866
|
+
.then(function (r) {
|
|
867
|
+
var b = (r.body && typeof r.body === 'object') ? r.body : null;
|
|
868
|
+
if (!b || b.ok !== true) {
|
|
869
|
+
setRemotesError((b && b.error) || ('断开失败:服务返回了 HTTP ' + r.status + '。'));
|
|
870
|
+
return undefined;
|
|
871
|
+
}
|
|
872
|
+
setRemotesError(null);
|
|
873
|
+
setForgetting(null);
|
|
874
|
+
setRemoteNote(typeof b.note === 'string' ? b.note : null);
|
|
875
|
+
// 那一台的"上一次为什么没成"跟着这条记录一起没了。
|
|
876
|
+
delete remoteProblems[id];
|
|
877
|
+
notifyRemote();
|
|
878
|
+
return loadRemotes();
|
|
879
|
+
})
|
|
880
|
+
.catch(function (err) {
|
|
881
|
+
setRemotesError('断开失败:' + ((err && err.message) || String(err)));
|
|
882
|
+
})
|
|
883
|
+
.then(function () { setBusy(false); });
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* 「点这一行」和「重连」都走它:**取票 → 把地址交给那一整屏**。
|
|
888
|
+
*
|
|
889
|
+
* ## 为什么先取票、成功了才切过去
|
|
890
|
+
*
|
|
891
|
+
* 取票是这条路上唯一会失败的一步(对面关着、地址不通、被撤销了、它的界面拿不出来
|
|
892
|
+
* ——见 lib/index.js 那几条路由)。**失败时用户还留在设置页上,那句话就摆在刚才点的
|
|
893
|
+
* 那一行底下**——比先跳到一整屏空白再告诉他出事了好得多。
|
|
894
|
+
*
|
|
895
|
+
* ## 切不过去也不装作没事
|
|
896
|
+
*
|
|
897
|
+
* 切那一屏要两样东西:官方布局服务的把手(`layoutFace`),和设置页外壳递进来的
|
|
898
|
+
* `close`(设置页是一个盖在界面上的浮层,不关掉它,切过去的那一屏还在它后面,
|
|
899
|
+
* 用户看到的是"什么都没发生")。两样缺一,就把"去侧栏那枚图标点一下"这条路指给他
|
|
900
|
+
* ——票已经在手上了,那一下照旧打开。
|
|
901
|
+
*/
|
|
902
|
+
var connectRemote = function (one) {
|
|
903
|
+
if (remoteBusyId) return;
|
|
904
|
+
// **先记住"要打开的是哪一台"**:换票失败时那一屏照它说话(不然会说上一台的事)。
|
|
905
|
+
remoteTarget = { id: one.id, name: one.name || '' };
|
|
906
|
+
fetchRemoteTicket(one.id, one.name).then(function (r) {
|
|
907
|
+
if (!r.ok) return;
|
|
908
|
+
if (goToRemotePanel()) return;
|
|
909
|
+
remoteProblems[one.id] = {
|
|
910
|
+
text: '票已经换到了,但这个界面没能自己切到那一屏。关掉设置页,点侧栏那枚「远程窗口」,'
|
|
911
|
+
+ '就能看到那台电脑的界面。',
|
|
912
|
+
revoked: false,
|
|
913
|
+
};
|
|
914
|
+
notifyRemote();
|
|
915
|
+
});
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
/** 把那一整屏打开:先切面板、再关设置页(顺序的理由见上面那一段)。 */
|
|
919
|
+
var goToRemotePanel = function () {
|
|
920
|
+
if (!layoutFace) return false;
|
|
921
|
+
try {
|
|
922
|
+
layoutFace.selectPanel(PANEL_ID);
|
|
923
|
+
} catch (err) {
|
|
924
|
+
// 布局服务认得的面板里没有我们那个 key(官方那一列还没装配好):如实说。
|
|
925
|
+
return false;
|
|
926
|
+
}
|
|
927
|
+
var close = props && props.close;
|
|
928
|
+
if (typeof close === 'function') {
|
|
929
|
+
try { close(); } catch (err) { /* 外壳不让我关:那一屏已经在后面打开了 */ }
|
|
930
|
+
}
|
|
931
|
+
return true;
|
|
932
|
+
};
|
|
155
933
|
// 不用等一趟网络;真正的门在服务端,这里改了那边不改也进不去。
|
|
156
934
|
var MIN_PASSWORD_LEN = 12;
|
|
157
935
|
|
|
@@ -215,6 +993,41 @@ window.__ModuleLoader__.load({
|
|
|
215
993
|
.then(function () { setBusy(false); });
|
|
216
994
|
};
|
|
217
995
|
|
|
996
|
+
/**
|
|
997
|
+
* 开/关「允许别的电脑连接」。
|
|
998
|
+
*
|
|
999
|
+
* 和公网那个开关**同一个形状**:POST 一个 `{enabled}`,回来的还是整份配对信息
|
|
1000
|
+
* (服务端算好的那一份),界面直接拿它刷新——本地自己改状态的话,那个开关
|
|
1001
|
+
* 迟早和 settings.json 里那份分叉,而分叉的表现正是这一节最不能出的那种错:
|
|
1002
|
+
* 「界面上开着,其实关着」。
|
|
1003
|
+
*
|
|
1004
|
+
* 失败不动别的状态,只把那句话摆出来(`failure`)。**默认关**:用户不动它,
|
|
1005
|
+
* 这台电脑就不接受别的电脑连过来——装好不会自动打开。
|
|
1006
|
+
*/
|
|
1007
|
+
var toggleRemoteAccess = function (enabled) {
|
|
1008
|
+
if (busy) return;
|
|
1009
|
+
setBusy(true);
|
|
1010
|
+
fetch(REMOTE_ACCESS_PATH, {
|
|
1011
|
+
method: 'POST',
|
|
1012
|
+
headers: { 'content-type': 'application/json' },
|
|
1013
|
+
body: JSON.stringify({ enabled: enabled }),
|
|
1014
|
+
})
|
|
1015
|
+
.then(function (res) {
|
|
1016
|
+
return res.json().then(
|
|
1017
|
+
function (body) { return { status: res.status, body: body }; },
|
|
1018
|
+
function () { return { status: res.status, body: null }; },
|
|
1019
|
+
);
|
|
1020
|
+
})
|
|
1021
|
+
.then(function (r) {
|
|
1022
|
+
if (r.body && typeof r.body === 'object') { setInfo(r.body); setFailure(null); }
|
|
1023
|
+
else setFailure('切换「允许别的电脑连接」失败:服务返回了 HTTP ' + r.status + '。');
|
|
1024
|
+
})
|
|
1025
|
+
.catch(function (err) {
|
|
1026
|
+
setFailure('切换「允许别的电脑连接」失败:' + ((err && err.message) || String(err)));
|
|
1027
|
+
})
|
|
1028
|
+
.then(function () { setBusy(false); });
|
|
1029
|
+
};
|
|
1030
|
+
|
|
218
1031
|
/**
|
|
219
1032
|
* 开/关 Tailscale 的 HTTPS 地址。
|
|
220
1033
|
*
|
|
@@ -342,11 +1155,55 @@ window.__ModuleLoader__.load({
|
|
|
342
1155
|
save: saveFeishu,
|
|
343
1156
|
guideOpen: guideOpen,
|
|
344
1157
|
toggleGuide: function () { setGuideOpen(function (v) { return !v; }); },
|
|
1158
|
+
}, {
|
|
1159
|
+
// 「设备」那一块:名单 + 两个动作 + 只属于这一块的失败原因。
|
|
1160
|
+
list: devices,
|
|
1161
|
+
error: devicesError,
|
|
1162
|
+
renaming: renaming,
|
|
1163
|
+
confirming: confirming,
|
|
1164
|
+
busy: busy,
|
|
1165
|
+
startRename: startRename,
|
|
1166
|
+
setRenaming: setRenaming,
|
|
1167
|
+
cancelRename: function () { setRenaming(null); setDevicesError(null); },
|
|
1168
|
+
saveRename: saveRename,
|
|
1169
|
+
askRevoke: function (id) { setRenaming(null); setDevicesError(null); setConfirming(id); },
|
|
1170
|
+
cancelRevoke: function () { setConfirming(null); },
|
|
1171
|
+
revoke: revokeDevice,
|
|
1172
|
+
}, {
|
|
1173
|
+
// 跨机那个总开关(2026-10-16 第二步)。和 serve/feishu/dev 一样摊平成一个
|
|
1174
|
+
// 对象传:状态在 `info.remote` 里跟配对信息一起下来,动作就是上面那个 POST。
|
|
1175
|
+
toggle: toggleRemoteAccess,
|
|
1176
|
+
}, {
|
|
1177
|
+
// 「其他电脑」那一块:名单 + 添加 + 两个动作,以及只属于那一块的两句话
|
|
1178
|
+
// (读名单失败的原因、断开之后那句"对面还留着")。
|
|
1179
|
+
list: remotes,
|
|
1180
|
+
error: remotesError,
|
|
1181
|
+
note: remoteNote,
|
|
1182
|
+
add: addDraft,
|
|
1183
|
+
addError: addError,
|
|
1184
|
+
forgetting: forgetting,
|
|
1185
|
+
busy: busy,
|
|
1186
|
+
openAdd: openAdd,
|
|
1187
|
+
setAddDraft: setAddDraft,
|
|
1188
|
+
cancelAdd: function () { setAddDraft(null); setAddError(null); },
|
|
1189
|
+
saveAdd: saveAdd,
|
|
1190
|
+
askForget: function (id) { setAddDraft(null); setRemotesError(null); setForgetting(id); },
|
|
1191
|
+
cancelForget: function () { setForgetting(null); },
|
|
1192
|
+
forget: forgetRemote,
|
|
1193
|
+
connect: connectRemote,
|
|
1194
|
+
}, {
|
|
1195
|
+
// 「自检」那一块:一份快照 + 展开/收起 + 重新自检。读不到时 error 里那句话
|
|
1196
|
+
// 就摆在这一块里(它照样出现,见 selfcheckBlock)。
|
|
1197
|
+
data: selfcheck,
|
|
1198
|
+
error: selfError,
|
|
1199
|
+
open: selfOpen,
|
|
1200
|
+
toggle: function () { setSelfOpen(function (v) { return !v; }); },
|
|
1201
|
+
reload: loadSelfcheck,
|
|
345
1202
|
}),
|
|
346
1203
|
);
|
|
347
1204
|
}
|
|
348
1205
|
|
|
349
|
-
function content(info, failure, copied, copy, busy, toggle, pwd, serve, feishu) {
|
|
1206
|
+
function content(info, failure, copied, copy, busy, toggle, pwd, serve, feishu, dev, remote, rmt, self) {
|
|
350
1207
|
if (failure) return h('div', { style: styles.error }, failure);
|
|
351
1208
|
if (!info) return h('div', { style: styles.loading }, '正在读取配对信息…');
|
|
352
1209
|
|
|
@@ -377,9 +1234,446 @@ window.__ModuleLoader__.load({
|
|
|
377
1234
|
// 飞书那一块:**只要服务端带了 feishu 就出现**(成功、失败、服务还没起来那几条
|
|
378
1235
|
// 路径上都带着它)。它是独立的一条通路,手机服务有没有起来都不该拦着用户改它。
|
|
379
1236
|
if (info.feishu) blocks.push(feishuBlock(info.feishu, feishu));
|
|
1237
|
+
// 设备那一块:**放在最后**。它是"谁进得来"的账本,改名/撤销都在这里;
|
|
1238
|
+
// 名单还没读到(服务端没给 / 读失败)而报错也没有时,整块不出现——
|
|
1239
|
+
// 不摆一个空壳,也不把上面几块的位置动一寸。
|
|
1240
|
+
// 跨机那个总开关**紧挨在名单前面**:这两块说的是同一件事的两半——上面是
|
|
1241
|
+
// "许不许别的电脑连过来",下面是"已经连过来的有哪几台"。顺序反过来的话,
|
|
1242
|
+
// 用户先在名单里看见一台不认识的电脑,才在上方找到那个总开关。
|
|
1243
|
+
if (info.remote && remote) blocks.push(remoteBlock(info.remote, busy, remote.toggle));
|
|
1244
|
+
if (dev && (dev.list !== null || dev.error)) blocks.push(devicesBlock(dev));
|
|
1245
|
+
// 「其他电脑」那一块:**排在「设备」后面**。两块说的是相反的两件事——上面那份是
|
|
1246
|
+
// "谁进得来"(别人连我这台),这一份是"我连得上谁"(我从这台去连别人)。顺序上先
|
|
1247
|
+
// 说别人进来的(那是权限边界,也是这一页的主语),再说我出去的。
|
|
1248
|
+
if (rmt && (rmt.list !== null || rmt.error)) blocks.push(remotesBlock(rmt));
|
|
1249
|
+
// 「自检」那一块:**永远排在最末**,而且只要服务端答了话(或者答不上话但说清了
|
|
1250
|
+
// 原因)就出现。它是这一页的"体检报告"——不抢前面那些要操作的位置,
|
|
1251
|
+
// 但也不能因为"读不到"而整块消失(那正好是最需要它的时候)。
|
|
1252
|
+
if (self) blocks.push(selfcheckBlock(self));
|
|
380
1253
|
return h('div', null, blocks);
|
|
381
1254
|
}
|
|
382
1255
|
|
|
1256
|
+
/** 时间戳 → 「2026-10-16 14:20」。取不到(0/坏值)回 null,由调用方去说「还没有记录」。 */
|
|
1257
|
+
function formatTime(ms) {
|
|
1258
|
+
var n = Number(ms);
|
|
1259
|
+
if (!n) return null;
|
|
1260
|
+
var t = new Date(n);
|
|
1261
|
+
if (isNaN(t.getTime())) return null;
|
|
1262
|
+
var p = function (v) { return (v < 10 ? '0' : '') + v; };
|
|
1263
|
+
return t.getFullYear() + '-' + p(t.getMonth() + 1) + '-' + p(t.getDate())
|
|
1264
|
+
+ ' ' + p(t.getHours()) + ':' + p(t.getMinutes());
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* 一台设备那一行里那句小字:**类型 · 创建时间 · 最近使用时间**。
|
|
1269
|
+
*
|
|
1270
|
+
* `lastSeen` 为 0 时说「还没有记录」而不是编一个时间:老记录(第三步之前写下的
|
|
1271
|
+
* 那几条)本来就没有这个字段,编成"创建时间"等于凭空造一次使用。
|
|
1272
|
+
*/
|
|
1273
|
+
function deviceMeta(one) {
|
|
1274
|
+
// 类型只影响这一句小字(认人从不看它,见 lib/devices.js)。认得的两种说人话;
|
|
1275
|
+
// **认不出的不吞**——原样打出来,比一律写成"设备"容易查。
|
|
1276
|
+
var kind = one.kind === 'phone' ? '手机'
|
|
1277
|
+
: (one.kind === 'desktop' ? '另一台电脑' : String(one.kind || '设备'));
|
|
1278
|
+
var made = formatTime(one.createdAt);
|
|
1279
|
+
var seen = formatTime(one.lastSeen);
|
|
1280
|
+
var parts = [kind];
|
|
1281
|
+
parts.push(made ? '创建于 ' + made : '创建时间读不到');
|
|
1282
|
+
parts.push(seen ? '最近使用 ' + seen : '最近使用 还没有记录');
|
|
1283
|
+
return parts.join(' · ');
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* 「设备」那一块:**谁进得来、什么时候进的、改个名字、撤销一台**(2026-10-16 第三步)。
|
|
1288
|
+
*
|
|
1289
|
+
* ## 为什么撤销的入口在电脑上(用户拍板的)
|
|
1290
|
+
*
|
|
1291
|
+
* 手机页上"删掉自己"有个死结:删完当场进不去,而下一步正是要在那台手机上做什么。
|
|
1292
|
+
* 电脑这边没这个问题——撤销的就是别人(或者手上这台的旧记录)。
|
|
1293
|
+
*
|
|
1294
|
+
* ## 撤销为什么要点两下
|
|
1295
|
+
*
|
|
1296
|
+
* 删了那台设备就得**重新扫码**(凭据找不回来,这是设计的一部分,不是缺陷)。
|
|
1297
|
+
* 一次点击就造成这个后果太重了,所以第一下只把后果摊在那一行下面,第二下
|
|
1298
|
+
* (「确认撤销」)才真的发出去。**不用 window.confirm**:那种弹窗在微信这类内嵌
|
|
1299
|
+
* 浏览器里会被直接屏蔽,屏蔽时万一返回真值就等于确认被跳过(手机页那边为同一件
|
|
1300
|
+
* 事写过同一段话)。
|
|
1301
|
+
*
|
|
1302
|
+
* ## 名字和撤销的性质不一样
|
|
1303
|
+
*
|
|
1304
|
+
* 名字只是标签(见 lib/devices.js 开头):改了照旧认人。所以「改名」一个点击就够,
|
|
1305
|
+
* 不用确认。
|
|
1306
|
+
*/
|
|
1307
|
+
function devicesBlock(d) {
|
|
1308
|
+
var list = Array.isArray(d.list) ? d.list : [];
|
|
1309
|
+
var rows = list.map(function (one) {
|
|
1310
|
+
var editing = Boolean(d.renaming && d.renaming.id === one.id);
|
|
1311
|
+
var asking = d.confirming === one.id;
|
|
1312
|
+
return h('div', { key: one.id, style: styles.deviceRow },
|
|
1313
|
+
h('div', { style: styles.deviceMain },
|
|
1314
|
+
editing
|
|
1315
|
+
? h('input', {
|
|
1316
|
+
type: 'text',
|
|
1317
|
+
value: d.renaming.draft,
|
|
1318
|
+
spellCheck: false,
|
|
1319
|
+
placeholder: '给这台设备起个名字',
|
|
1320
|
+
disabled: Boolean(d.busy),
|
|
1321
|
+
onChange: function (e) { d.setRenaming({ id: one.id, draft: e.target.value }); },
|
|
1322
|
+
style: styles.input,
|
|
1323
|
+
})
|
|
1324
|
+
: h('div', { style: styles.deviceName }, one.name ? one.name : '(这台设备没有名字)'),
|
|
1325
|
+
h('div', { style: styles.muted }, deviceMeta(one)),
|
|
1326
|
+
),
|
|
1327
|
+
h('div', { style: styles.deviceActs },
|
|
1328
|
+
editing
|
|
1329
|
+
? h('button', {
|
|
1330
|
+
type: 'button', disabled: Boolean(d.busy), onClick: function () { d.saveRename(); }, style: styles.btn,
|
|
1331
|
+
}, d.busy ? '保存中…' : '保存')
|
|
1332
|
+
: h('button', {
|
|
1333
|
+
type: 'button', disabled: Boolean(d.busy), onClick: function () { d.startRename(one); }, style: styles.btn,
|
|
1334
|
+
}, '改名'),
|
|
1335
|
+
editing
|
|
1336
|
+
? h('button', {
|
|
1337
|
+
type: 'button', disabled: Boolean(d.busy), onClick: function () { d.cancelRename(); }, style: styles.btn,
|
|
1338
|
+
}, '取消')
|
|
1339
|
+
: h('button', {
|
|
1340
|
+
type: 'button', disabled: Boolean(d.busy), onClick: function () { d.askRevoke(one.id); }, style: styles.btn,
|
|
1341
|
+
}, '撤销'),
|
|
1342
|
+
),
|
|
1343
|
+
// 二次确认:**把后果写在按钮旁边**,不是写一句"确定吗"。
|
|
1344
|
+
asking ? h('div', { style: styles.deviceConfirm },
|
|
1345
|
+
h('div', { style: styles.warn }, '撤销后,这台设备要重新扫码才能再用。'),
|
|
1346
|
+
h('button', {
|
|
1347
|
+
type: 'button', disabled: Boolean(d.busy), onClick: function () { d.revoke(one.id); }, style: styles.btn,
|
|
1348
|
+
}, d.busy ? '撤销中…' : '确认撤销'),
|
|
1349
|
+
h('button', {
|
|
1350
|
+
type: 'button', disabled: Boolean(d.busy), onClick: function () { d.cancelRevoke(); }, style: styles.btn,
|
|
1351
|
+
}, '取消'),
|
|
1352
|
+
) : null,
|
|
1353
|
+
);
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
return h('div', { key: 'devices', style: styles.block },
|
|
1357
|
+
h('div', { style: styles.entryTitle }, '设备'),
|
|
1358
|
+
h('div', { style: styles.muted },
|
|
1359
|
+
'每一台手机第一次扫码进来时会自动登记,之后不用再扫码。撤销之后,那台设备要重新扫码才能再用。'),
|
|
1360
|
+
d.error ? h('div', { style: styles.error }, d.error) : null,
|
|
1361
|
+
rows.length
|
|
1362
|
+
? h('div', null, rows)
|
|
1363
|
+
: (d.error ? null : h('div', { style: styles.muted, marginTop: 10 }, '这台电脑上还没有登记过的设备。')),
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* 「其他电脑」那一块:**这台电脑连得上去哪几台别的电脑**(2026-10-16 跨机第三步的界面)。
|
|
1369
|
+
*
|
|
1370
|
+
* ## 和上面那一块正好相反
|
|
1371
|
+
*
|
|
1372
|
+
* 「设备」是**谁进得来**(别人连我这台);这一块是**我连得上谁**(我从这台去连别人)。
|
|
1373
|
+
* 两块都摆在设置页上,所以标题得一眼分得开。
|
|
1374
|
+
*
|
|
1375
|
+
* ## 一行一台,那一行本身就是入口
|
|
1376
|
+
*
|
|
1377
|
+
* 点那一行 = 打开那台电脑的界面(先取票,见 `connectRemote`):取票 → 把地址交给框 →
|
|
1378
|
+
* 切到「远程窗口」那一屏 → 把设置页这个浮层关掉。**2026-10-17 起那一屏点开的第一层
|
|
1379
|
+
* 也是同一份名单**(见 MiniRemotePanel):在这儿点,就跳过名单直接进那一台。
|
|
1380
|
+
* 行里另给两个动作:「重连」(票是一次性的、30 秒就过期,"再换一张"总得有个明确入口)
|
|
1381
|
+
* 和「断开」。
|
|
1382
|
+
* **「重连」和"点这一行"是同一件事**(重连 = 再取一次票,见 lib/index.js 那一段)。
|
|
1383
|
+
*
|
|
1384
|
+
* ## 「断开」要点两下
|
|
1385
|
+
*
|
|
1386
|
+
* 照上面「撤销」的做法(这个文件里没有弹窗,全站都是页内两步):第一下只把后果摊在
|
|
1387
|
+
* 那一行下面。**而且要说清"对面还留着"**——断开只动这台电脑,用户很容易以为它等于
|
|
1388
|
+
* 收回了对面的权限。
|
|
1389
|
+
*
|
|
1390
|
+
* ## 这一块上没有"转圈"
|
|
1391
|
+
*
|
|
1392
|
+
* 正在换票时那一行写「取票中…」,失败了就写一句人能照着做的话(`remoteProblems`;
|
|
1393
|
+
* 对面撤销了另有专门的一句)。**没有任何一格会永远停在"加载中"**——见 fetchRemoteTicket。
|
|
1394
|
+
*/
|
|
1395
|
+
function remotesBlock(r) {
|
|
1396
|
+
var list = Array.isArray(r.list) ? r.list : [];
|
|
1397
|
+
|
|
1398
|
+
var rows = list.map(function (one) {
|
|
1399
|
+
var asking = r.forgetting === one.id;
|
|
1400
|
+
var problem = remoteProblems[one.id];
|
|
1401
|
+
var working = remoteBusyId === one.id;
|
|
1402
|
+
var lastOk = formatTime(one.lastOk);
|
|
1403
|
+
return h('div', {
|
|
1404
|
+
key: one.id,
|
|
1405
|
+
style: Object.assign({}, styles.deviceRow, { cursor: 'pointer' }),
|
|
1406
|
+
// 点这一行 = 打开那台电脑的界面。**点在行里的按钮上不算"点这一行"**——
|
|
1407
|
+
// 不然一按「断开」就会顺手去取票(那是两件相反的事)。
|
|
1408
|
+
onClick: function (e) {
|
|
1409
|
+
var t = e && e.target;
|
|
1410
|
+
if (t && typeof t.closest === 'function' && t.closest('button')) return;
|
|
1411
|
+
r.connect(one);
|
|
1412
|
+
},
|
|
1413
|
+
},
|
|
1414
|
+
h('div', { style: styles.deviceMain },
|
|
1415
|
+
h('div', { style: styles.deviceName }, one.name ? one.name : '(这台电脑没有名字)'),
|
|
1416
|
+
h('div', { style: styles.remoteUrl }, one.baseUrl || ''),
|
|
1417
|
+
h('div', { style: styles.muted }, lastOk ? '最近成功 ' + lastOk : '最近成功 还没有记录'),
|
|
1418
|
+
),
|
|
1419
|
+
h('div', { style: styles.deviceActs },
|
|
1420
|
+
h('button', {
|
|
1421
|
+
type: 'button',
|
|
1422
|
+
disabled: Boolean(r.busy || working),
|
|
1423
|
+
onClick: function () { r.connect(one); },
|
|
1424
|
+
style: styles.btn,
|
|
1425
|
+
}, working ? '取票中…' : '重连'),
|
|
1426
|
+
h('button', {
|
|
1427
|
+
type: 'button',
|
|
1428
|
+
disabled: Boolean(r.busy),
|
|
1429
|
+
onClick: function () { r.askForget(one.id); },
|
|
1430
|
+
style: styles.btn,
|
|
1431
|
+
}, '断开'),
|
|
1432
|
+
),
|
|
1433
|
+
working ? h('div', { style: styles.muted }, '正在向那台电脑换一张通行证…') : null,
|
|
1434
|
+
// 上一次为什么没成。**另起一行**(`deviceConfirm` 那个 `flex:1 1 100%`),不然这句话
|
|
1435
|
+
// 会挤在按钮旁边,断成两截。对面撤销了这一条**单独一句**摆在最前面:用户下一步该做的
|
|
1436
|
+
// 是重新配对,而不是反复点「重连」;宿主那两行原文照登,里面写着"拿口令重新添加"。
|
|
1437
|
+
problem ? h('div', {
|
|
1438
|
+
style: problem.revoked
|
|
1439
|
+
? Object.assign({}, styles.warn, styles.say, styles.deviceConfirm)
|
|
1440
|
+
: Object.assign({}, styles.error, styles.say, styles.deviceConfirm),
|
|
1441
|
+
}, problem.revoked ? REMOTE_REVOKED_ROW + '。' : problem.text) : null,
|
|
1442
|
+
problem && problem.revoked ? h('div', {
|
|
1443
|
+
style: Object.assign({}, styles.muted, styles.say, styles.deviceConfirm),
|
|
1444
|
+
}, problem.text) : null,
|
|
1445
|
+
asking ? h('div', { style: styles.deviceConfirm },
|
|
1446
|
+
h('div', { style: styles.warn }, '断开之后,这台电脑就不再连它了:这条记录和本地存的那枚凭据一起删掉。'
|
|
1447
|
+
+ '对面那台电脑上还留着「这台电脑」这条记录——要彻底收回,得去它那边撤销那一台。'),
|
|
1448
|
+
h('button', {
|
|
1449
|
+
type: 'button', disabled: Boolean(r.busy), onClick: function () { r.forget(one.id); }, style: styles.btn,
|
|
1450
|
+
}, r.busy ? '断开中…' : '确认断开'),
|
|
1451
|
+
h('button', {
|
|
1452
|
+
type: 'button', disabled: Boolean(r.busy), onClick: function () { r.cancelForget(); }, style: styles.btn,
|
|
1453
|
+
}, '取消'),
|
|
1454
|
+
) : null,
|
|
1455
|
+
);
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
// 添加那一块:两个框(地址、口令)+ 一个可选的名字,**一次确认**才发出去
|
|
1459
|
+
// (口令是密码性质的,打字打到一半就发一趟不合适)。口令打码,和别处一致。
|
|
1460
|
+
var addUi = r.add ? h('div', null,
|
|
1461
|
+
field('那台电脑的地址(它「手机遥控」页那条,不带 /mini)', r.add.baseUrl, 'http://…', function (v) {
|
|
1462
|
+
r.setAddDraft(Object.assign({}, r.add, { baseUrl: v }));
|
|
1463
|
+
}, 'text'),
|
|
1464
|
+
field('那台电脑的口令(在它的「手机遥控」设置里复制)', r.add.token, '', function (v) {
|
|
1465
|
+
r.setAddDraft(Object.assign({}, r.add, { token: v }));
|
|
1466
|
+
}, 'password'),
|
|
1467
|
+
field('给它起个名字(可留空,留空就用地址当名字)', r.add.name, '', function (v) {
|
|
1468
|
+
r.setAddDraft(Object.assign({}, r.add, { name: v }));
|
|
1469
|
+
}, 'text'),
|
|
1470
|
+
h('div', { style: { marginTop: 12, display: 'flex', gap: 8, flexWrap: 'wrap' } },
|
|
1471
|
+
h('button', {
|
|
1472
|
+
type: 'button', disabled: Boolean(r.busy), onClick: r.saveAdd, style: styles.btn,
|
|
1473
|
+
}, r.busy ? '配对中…' : '确认添加'),
|
|
1474
|
+
h('button', {
|
|
1475
|
+
type: 'button', disabled: Boolean(r.busy), onClick: r.cancelAdd, style: styles.btn,
|
|
1476
|
+
}, '取消'),
|
|
1477
|
+
),
|
|
1478
|
+
r.addError ? h('div', { style: Object.assign({}, styles.error, styles.say) }, r.addError) : null,
|
|
1479
|
+
) : null;
|
|
1480
|
+
|
|
1481
|
+
return h('div', { key: 'remotes', style: styles.block },
|
|
1482
|
+
h('div', { style: styles.entryTitle }, '其他电脑'),
|
|
1483
|
+
h('div', { style: styles.muted }, '这台电脑连得上去的别的电脑。配对过一台之后,点它那一行就能'
|
|
1484
|
+
+ '在这儿打开它的界面——和上面那份名单正好相反:上面是「谁进得来」,这里是「我连得上谁」。'),
|
|
1485
|
+
h('div', { style: styles.warn }, '要连的那台电脑得先把它的「允许别的电脑连接」打开,'
|
|
1486
|
+
+ '而且两边要能在网络上互相够到(同一个 Wi-Fi、或者同一条 Tailscale 网络)。'),
|
|
1487
|
+
r.error ? h('div', { style: Object.assign({}, styles.error, styles.say) }, r.error) : null,
|
|
1488
|
+
// 断开之后宿主给的那句话:说清"对面还留着"(最容易留下的误会)。
|
|
1489
|
+
r.note ? h('div', { style: Object.assign({}, styles.muted, styles.say) }, r.note) : null,
|
|
1490
|
+
rows.length
|
|
1491
|
+
? h('div', null, rows)
|
|
1492
|
+
: (r.error ? null : h('div', { style: styles.muted, marginTop: 10 }, '这台电脑还没连过别的电脑。')),
|
|
1493
|
+
h('div', { style: { marginTop: 12 } },
|
|
1494
|
+
h('button', { type: 'button', onClick: r.openAdd, style: styles.btn },
|
|
1495
|
+
r.add ? '收起这一块' : '添加一台电脑'),
|
|
1496
|
+
),
|
|
1497
|
+
addUi,
|
|
1498
|
+
);
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/**
|
|
1502
|
+
* 「自检」那一块(2026-10-17):**这台电脑到底在跑什么**。
|
|
1503
|
+
*
|
|
1504
|
+
* ## 为什么值得摆在设置页上
|
|
1505
|
+
*
|
|
1506
|
+
* 那天排两台真机的故障都卡在同一件事上——**看不见那台机器在跑什么**:一台的桌面端
|
|
1507
|
+
* 连开了一整天,加载的是"新功能写下来之前"的代码;另一台装的插件是旧版本,新接口
|
|
1508
|
+
* 根本没出现。当时只能靠用户来回反馈、靠一个个地址去敲。这一块把那些答案摆在一处:
|
|
1509
|
+
* 哪一版、宿主进程什么时候起的、哪些接口在、开关什么状态、服务听哪儿、数据在哪。
|
|
1510
|
+
*
|
|
1511
|
+
* ## 那一句警告**不展开也得看得见**
|
|
1512
|
+
*
|
|
1513
|
+
* 「进程比代码旧」是那次故障最常见的样子(装是装上了,跑的却还是旧的),而它对应的
|
|
1514
|
+
* 动作只有一个:重启 DSH。所以那句话摆在摘要上面、**不受展开/收起影响**——
|
|
1515
|
+
* 要用户点开才看得见,等于把它藏起来。
|
|
1516
|
+
*
|
|
1517
|
+
* ## 读不到也要出现
|
|
1518
|
+
*
|
|
1519
|
+
* 这一块**没有"读不到就不显示"这一档**:读不到(宿主是旧版、服务没起来)时,
|
|
1520
|
+
* 那句原因就摆在这儿,下面照样有一个「重新自检」。整块消失的话,用户看到的是
|
|
1521
|
+
* 「设置页上根本没有自检」——那和"没装这个功能"分不清。
|
|
1522
|
+
*
|
|
1523
|
+
* ## 除了那一句警告,别的都不许用感叹号或大红字
|
|
1524
|
+
*
|
|
1525
|
+
* 它是体检报告,不是警报:正常的时候(插件是最新的)就该安静地读过去。
|
|
1526
|
+
*/
|
|
1527
|
+
function selfcheckBlock(sc) {
|
|
1528
|
+
var st = sc.data;
|
|
1529
|
+
var code = (st && st.code) || {};
|
|
1530
|
+
var proc = (st && st.process) || {};
|
|
1531
|
+
var phone = (st && st.phoneService) || {};
|
|
1532
|
+
var rosters = (st && st.rosters) || {};
|
|
1533
|
+
|
|
1534
|
+
/** 运行了多久,说成人话(诊断的时候"连开了多久"本身就是一个线索)。 */
|
|
1535
|
+
function uptimeLine(sec) {
|
|
1536
|
+
var n = Number(sec);
|
|
1537
|
+
if (!isFinite(n) || n < 0) return '';
|
|
1538
|
+
if (n < 120) return '(刚起来 ' + Math.round(n) + ' 秒)';
|
|
1539
|
+
var mins = Math.round(n / 60);
|
|
1540
|
+
if (mins < 120) return '(已运行 ' + mins + ' 分钟)';
|
|
1541
|
+
var hours = Math.floor(mins / 60);
|
|
1542
|
+
var rest = mins % 60;
|
|
1543
|
+
return '(已运行 ' + hours + ' 小时' + (rest ? ' ' + rest + ' 分' : '') + ')';
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
/**
|
|
1547
|
+
* 多久以前,说成人话。
|
|
1548
|
+
*
|
|
1549
|
+
* 诊断的时候「三分钟前」比「19:42:07」有用:用户记不住自己什么时候点过什么,
|
|
1550
|
+
* 但他知道"刚才"和"昨天"的区别。
|
|
1551
|
+
*/
|
|
1552
|
+
function agoText(ms) {
|
|
1553
|
+
var n = Number(ms);
|
|
1554
|
+
if (!isFinite(n)) return '';
|
|
1555
|
+
var sec = Math.round((Date.now() - n) / 1000);
|
|
1556
|
+
if (sec < 60) return '刚刚';
|
|
1557
|
+
var min = Math.round(sec / 60);
|
|
1558
|
+
if (min < 60) return min + ' 分钟前';
|
|
1559
|
+
var hour = Math.round(min / 60);
|
|
1560
|
+
if (hour < 24) return hour + ' 小时前';
|
|
1561
|
+
return Math.round(hour / 24) + ' 天前';
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/** 接口那一行的说法:几条在、几条不在。空表只说"未知",不编一个 0。 */
|
|
1565
|
+
function routeLine(routes) {
|
|
1566
|
+
var list = Array.isArray(routes) ? routes : [];
|
|
1567
|
+
if (!list.length) return '未知';
|
|
1568
|
+
var live = list.filter(function (one) { return one && one.mounted !== false; });
|
|
1569
|
+
return live.length === list.length
|
|
1570
|
+
? ('一共 ' + list.length + ' 条,都在')
|
|
1571
|
+
: ('一共 ' + list.length + ' 条,其中 ' + (list.length - live.length) + ' 条不在');
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
function list(labels) {
|
|
1575
|
+
return labels.filter(Boolean).join('、');
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
if (!st) {
|
|
1579
|
+
// 还没读到 / 读不到:这一块照旧出现,把那句原因摆出来(尤其是"没有这个接口
|
|
1580
|
+
// = 宿主是旧版"那一条,它是这一块最有用的一句话)。
|
|
1581
|
+
return h('div', { key: 'selfcheck', style: styles.block },
|
|
1582
|
+
h('div', { style: styles.entryTitle }, '自检'),
|
|
1583
|
+
h('div', { style: styles.muted }, '这一块只给这台电脑看(只有本机能读),里面没有一个字是密码或密钥。'),
|
|
1584
|
+
h('div', { style: Object.assign({}, styles.error, styles.say) },
|
|
1585
|
+
sc.error || '正在自检…'),
|
|
1586
|
+
h('div', { style: { marginTop: 12 } },
|
|
1587
|
+
h('button', { type: 'button', onClick: sc.reload, style: styles.btn }, '重新自检')),
|
|
1588
|
+
);
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// 「进程比代码旧」——这一句不受展开/收起影响(见上面那段)。
|
|
1592
|
+
var alert = code.stale === true && code.message
|
|
1593
|
+
? h('div', { style: styles.warn }, code.message)
|
|
1594
|
+
: null;
|
|
1595
|
+
|
|
1596
|
+
// 最近出错的那一叠(自检里的 `errors`:**最新在前**,最多 20 条)。
|
|
1597
|
+
// 空数组是正常的——那是"这次开机以来没出过错",不是"取不到"。
|
|
1598
|
+
var errorRows = Array.isArray(st.errors) ? st.errors : [];
|
|
1599
|
+
|
|
1600
|
+
var summary = h('div', { style: styles.selfSummary },
|
|
1601
|
+
'插件版本 ' + (st.version || '未知')
|
|
1602
|
+
+ ' · 宿主进程启动于 ' + (formatTime(proc.startedAtMs) || '未知')
|
|
1603
|
+
+ ' · 接口' + routeLine(st.routes)
|
|
1604
|
+
// 有错才提一句:绝大多数的正常时刻,这一行不该被"最近出错 0 条"这种噪音占满。
|
|
1605
|
+
+ (errorRows.length ? ' · 最近出错 ' + errorRows.length + ' 条' : ''),
|
|
1606
|
+
);
|
|
1607
|
+
|
|
1608
|
+
var routePaths = Array.isArray(st.routes) ? st.routes : [];
|
|
1609
|
+
var details = h('div', null,
|
|
1610
|
+
h('div', { style: styles.muted },
|
|
1611
|
+
'宿主进程(就是跑着这个插件的那个 DSH)启动于:' + (formatTime(proc.startedAtMs) || '未知')
|
|
1612
|
+
+ uptimeLine(proc.uptimeSec)),
|
|
1613
|
+
h('div', { style: styles.muted },
|
|
1614
|
+
'插件文件最后改动于:' + (formatTime(code.modifiedAtMs) || '未知')
|
|
1615
|
+
+ (code.file ? '(' + code.file + ')' : '')),
|
|
1616
|
+
h('div', { style: styles.muted },
|
|
1617
|
+
'接口清单一共 ' + routePaths.length + ' 条' + (routePaths.length ? ':' : '(读不到)')),
|
|
1618
|
+
routePaths.length
|
|
1619
|
+
// 一行一条要占掉十几行(这一块默认是展开的),挤在一起又读不清:
|
|
1620
|
+
// 用顿号连起来让它自己折行,一眼扫得完,又不会把面板撑得老长。
|
|
1621
|
+
? h('div', { style: styles.code }, routePaths.map(function (one) {
|
|
1622
|
+
return String(one && one.path) + (one && one.mounted === false ? '(现在不在)' : '');
|
|
1623
|
+
}).join('、'))
|
|
1624
|
+
: null,
|
|
1625
|
+
h('div', { style: styles.muted },
|
|
1626
|
+
'「允许别的电脑连接」:' + (st.allowRemoteComputers ? '开' : '关(默认关)')),
|
|
1627
|
+
h('div', { style: styles.muted },
|
|
1628
|
+
'手机服务:' + (phone.listening
|
|
1629
|
+
? ('在听端口 ' + phone.port + ',绑了 ' + ((phone.addresses || []).join('、') || '未知'))
|
|
1630
|
+
: ('没起来' + (phone.error ? '(' + phone.error + ')' : '')))),
|
|
1631
|
+
h('div', { style: styles.muted },
|
|
1632
|
+
'跨机那两条接口在手机服务那一半也挂了一份:'
|
|
1633
|
+
+ list([
|
|
1634
|
+
phone.hostHalf && phone.hostHalf.pair ? '配对 在' : '配对 不在',
|
|
1635
|
+
phone.hostHalf && phone.hostHalf.remoteWindow ? '取票 在' : '取票 不在',
|
|
1636
|
+
])),
|
|
1637
|
+
h('div', { style: styles.muted }, '数据目录(这台电脑上的那份,不随插件卸载而删):'),
|
|
1638
|
+
h('div', { style: styles.code }, String(st.dataDir || '未知')),
|
|
1639
|
+
h('div', { style: styles.muted },
|
|
1640
|
+
'名册(只报条数,不报内容):设备 ' + (rosters.devices === null || rosters.devices === undefined ? '未知' : rosters.devices + ' 条')
|
|
1641
|
+
+ ' · 我能连的电脑 ' + (rosters.remotes === null || rosters.remotes === undefined ? '未知' : rosters.remotes + ' 条')),
|
|
1642
|
+
// 最近出错:这一块是「刚才到底发生了什么」的唯一出口。
|
|
1643
|
+
// 错误原来只打到 stdout——用户看着图形界面,终端里写了什么他一个字都看不到。
|
|
1644
|
+
errorRows.length
|
|
1645
|
+
? h('div', null,
|
|
1646
|
+
h('div', { style: styles.muted },
|
|
1647
|
+
'最近出错(最新在前,' + agoText(errorRows[0] && errorRows[0].at) + '),不落盘、重启就清空:'),
|
|
1648
|
+
h('div', { style: styles.code }, errorRows.slice(0, 5).map(function (one) {
|
|
1649
|
+
return '· ' + String((one && one.message) || '');
|
|
1650
|
+
}).join('\n')
|
|
1651
|
+
+ (errorRows.length > 5 ? '\n…还有 ' + (errorRows.length - 5) + ' 条' : '')),
|
|
1652
|
+
h('div', { style: styles.muted },
|
|
1653
|
+
'要看更早的,去启动这个 DSH 的那个终端找 [dsh-mini-remote] 开头的行。'),
|
|
1654
|
+
)
|
|
1655
|
+
: h('div', { style: styles.muted }, '最近没有出错记录。'),
|
|
1656
|
+
);
|
|
1657
|
+
|
|
1658
|
+
return h('div', { key: 'selfcheck', style: styles.block },
|
|
1659
|
+
h('div', { style: styles.entryTitle }, '自检'),
|
|
1660
|
+
h('div', { style: styles.muted }, '这一块只给这台电脑看(只有本机能读),里面没有一个字是密码或密钥。'),
|
|
1661
|
+
alert,
|
|
1662
|
+
summary,
|
|
1663
|
+
sc.error ? h('div', { style: Object.assign({}, styles.error, styles.say) }, sc.error) : null,
|
|
1664
|
+
sc.open ? details : null,
|
|
1665
|
+
h('div', { style: { marginTop: 12 } },
|
|
1666
|
+
h('button', { type: 'button', onClick: sc.toggle, style: styles.btn },
|
|
1667
|
+
sc.open ? '收起自检' : '展开自检'),
|
|
1668
|
+
h('button', {
|
|
1669
|
+
type: 'button',
|
|
1670
|
+
onClick: sc.reload,
|
|
1671
|
+
style: Object.assign({}, styles.btn, { marginLeft: 8 }),
|
|
1672
|
+
}, '重新自检'),
|
|
1673
|
+
),
|
|
1674
|
+
);
|
|
1675
|
+
}
|
|
1676
|
+
|
|
383
1677
|
/**
|
|
384
1678
|
* 飞书那一块:开关、两个凭据、两个允许名单。
|
|
385
1679
|
*
|
|
@@ -663,6 +1957,47 @@ window.__ModuleLoader__.load({
|
|
|
663
1957
|
);
|
|
664
1958
|
}
|
|
665
1959
|
|
|
1960
|
+
/**
|
|
1961
|
+
* 「允许别的电脑连接」那一节(2026-10-16 跨机第二步)。
|
|
1962
|
+
*
|
|
1963
|
+
* ## 为什么它得在设置页里、而且默认关
|
|
1964
|
+
*
|
|
1965
|
+
* 打开它等于**把整个「电脑端界面」交给另一台电脑**(会话记录、模型配置、插件装卸
|
|
1966
|
+
* 全在里面)——比手机那条路能碰到的更多。这种边界不能替用户默认打开,所以:
|
|
1967
|
+
* 默认关,而且装好之后**不会自己打开**。
|
|
1968
|
+
*
|
|
1969
|
+
* ## 关着的时候要说清"关的是什么"
|
|
1970
|
+
*
|
|
1971
|
+
* 不是"少录一项配置",而是**这台电脑不接受别的电脑连过来**:配对和取票两条都不接。
|
|
1972
|
+
* 说清楚这一句,用户才敢让它是关着的。
|
|
1973
|
+
*
|
|
1974
|
+
* ## 开着的时候要给一句实话
|
|
1975
|
+
*
|
|
1976
|
+
* 那台电脑拿到的是完整的桌面界面。用户很可能以为"只是看个窗口",所以这一句风险
|
|
1977
|
+
* 必须摆出来——这是唯一一处能提前告诉他的地方。
|
|
1978
|
+
*/
|
|
1979
|
+
function remoteBlock(st, busy, toggle) {
|
|
1980
|
+
var on = Boolean(st && st.allowed);
|
|
1981
|
+
return h('div', { key: 'remote', style: styles.block },
|
|
1982
|
+
h('div', { style: styles.entryTitle }, '允许别的电脑连接'),
|
|
1983
|
+
h('div', { style: styles.muted }, '打开之后,另一台电脑可以用这台电脑上的密码配一次对,'
|
|
1984
|
+
+ '以后就能从那边打开你这台电脑的「电脑端界面」。'),
|
|
1985
|
+
h('label', { style: styles.toggleRow },
|
|
1986
|
+
h('input', {
|
|
1987
|
+
type: 'checkbox',
|
|
1988
|
+
checked: on,
|
|
1989
|
+
disabled: Boolean(busy),
|
|
1990
|
+
onChange: function (e) { toggle(e.target.checked); },
|
|
1991
|
+
}),
|
|
1992
|
+
h('span', null, on ? '已开启' : '开启'),
|
|
1993
|
+
),
|
|
1994
|
+
h('div', { style: styles.muted }, on
|
|
1995
|
+
? '已开启。别的电脑现在连得进来——不想让哪一台进,就在下面的名单里把它撤销。'
|
|
1996
|
+
: '没开(默认)。别的电脑连不进来,连配对也不受理——这台电脑现在只接受本机和手机。'),
|
|
1997
|
+
on ? h('div', { style: styles.warn }, '那台电脑拿到的是你这台电脑的完整界面:会话记录、模型设置、插件都在里面。只给你信得过的电脑开。') : null,
|
|
1998
|
+
);
|
|
1999
|
+
}
|
|
2000
|
+
|
|
666
2001
|
function tunnelBlock(tunnel, busy, toggle) {
|
|
667
2002
|
var enabled = Boolean(tunnel.enabled);
|
|
668
2003
|
var working = Boolean(busy || tunnel.starting);
|
|
@@ -781,7 +2116,600 @@ window.__ModuleLoader__.load({
|
|
|
781
2116
|
);
|
|
782
2117
|
}
|
|
783
2118
|
|
|
2119
|
+
/**
|
|
2120
|
+
* 那一屏开不成时的整屏内容:**一句人话 + 一个「再试一次」**。
|
|
2121
|
+
*
|
|
2122
|
+
* 这一屏绝对不能是"空白框 + 转圈"——那是这个功能最要防的坏法(方案里记着的前车之鉴:
|
|
2123
|
+
* 宿主回 502 时外壳不会重试,于是永远停在「Loading plugins…」,不自愈)。所以:
|
|
2124
|
+
* 说清是哪一台、为什么没成(宿主那句话原样转述)、下一步点哪儿。
|
|
2125
|
+
*/
|
|
2126
|
+
function remoteProblemPanel(id, name, problem) {
|
|
2127
|
+
return h('div', { style: styles.panelMsg },
|
|
2128
|
+
problem.revoked ? h('div', null, REMOTE_REVOKED_ROW + '。') : null,
|
|
2129
|
+
h('div', { style: Object.assign({}, styles.muted, styles.say) }, problem.text),
|
|
2130
|
+
h('div', { style: { marginTop: 18 } },
|
|
2131
|
+
h('button', {
|
|
2132
|
+
type: 'button',
|
|
2133
|
+
onClick: function () { fetchRemoteTicket(id, name); },
|
|
2134
|
+
style: styles.btn,
|
|
2135
|
+
}, '再试一次'),
|
|
2136
|
+
),
|
|
2137
|
+
);
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
/**
|
|
2141
|
+
* 「远程窗口」面板——侧栏那个图标点开之后,占满中间那一整列的那一屏。
|
|
2142
|
+
*
|
|
2143
|
+
* ## 这一屏有两层(2026-10-17 起)
|
|
2144
|
+
*
|
|
2145
|
+
* · **上面一层:一份「其他电脑」名单**(`MiniRemoteList`)。侧栏那枚图标点开先看到
|
|
2146
|
+
* 的就是它——一行一台,点一行就把那一台的界面显示在这一屏里。
|
|
2147
|
+
* · **下面一层:选中那一台的「电脑端界面」**(另一个源,经它自己的镜像门嵌进来)。
|
|
2148
|
+
*
|
|
2149
|
+
* ## 为什么不再显示"本机"了(用户拍板的一条产品修正)
|
|
2150
|
+
*
|
|
2151
|
+
* 原来点开显示的是**本机**的电脑端界面——那是跨机还没做出来时的中间验证(脚手架):
|
|
2152
|
+
* 用它证明"取票 → 嵌框 → 过门"这一套真的能通。跨机做通之后它就没意义了,
|
|
2153
|
+
* 用户的原话是:「远程窗口我们为什么要设置为显示当前电脑的dsh页面,这没有意义啊」
|
|
2154
|
+
* ——**人就坐在这台电脑前面,为什么还要在这块面板里再看一遍自己的界面。**
|
|
2155
|
+
*
|
|
2156
|
+
* 所以本机那条**从界面上退休**:面板挂载时不再去换本机那张票。**底下那套零件一个字没删**
|
|
2157
|
+
* ——宿主侧那条路由还在 `lib/index.js` 上,跨机这条路复用的取票/嵌框/过门也照旧
|
|
2158
|
+
* (`lib/` 里一个字都没动)。
|
|
2159
|
+
*
|
|
2160
|
+
* ## 为什么要先取一张票,而不是直接把地址写进 src
|
|
2161
|
+
*
|
|
2162
|
+
* 那个地址后面要跟凭据。**令牌绝不能进 iframe 地址**——浏览历史、截图、别人拿到这台
|
|
2163
|
+
* 电脑就能翻出来。所以走两条腿:
|
|
2164
|
+
* · 宿主那一半(同一台电脑、本来就拿着令牌)去换一张**一次性票**(30 秒、用完即废);
|
|
2165
|
+
* · 界面这半边只拿得到「票 + 地址」,拼进 src 的只有票。
|
|
2166
|
+
*
|
|
2167
|
+
* 票进门那一刻会被兑现成一枚**窗口会话 cookie**(框里那些请求全靠它认人),
|
|
2168
|
+
* 而票自己**只授权那一次**;地址里留着的那张票之后只用来回答「这一页是我嵌出来的吗」
|
|
2169
|
+
* ——桌面端是**跨站**嵌进来的,只能靠票来定「许不许被嵌」。**令牌全程没进过浏览器。**
|
|
2170
|
+
*
|
|
2171
|
+
* **票在"开这一台"那一刻换一次,之后不动**(2026-10-17 改):名单上点一台、设置页点
|
|
2172
|
+
* 「重连」、那一屏的「再试一次」——三个入口各换一次(都在 `fetchRemoteTicket` 里)。
|
|
2173
|
+
* **面板自己不会再去换第二张**:那一枚窗口会话 cookie 没有时间限制,按票的寿命定期
|
|
2174
|
+
* 重取只会把**框整个重载一遍**(来龙去脉写在下面那个"原来有个 effect"那一段里)。
|
|
2175
|
+
*/
|
|
2176
|
+
/**
|
|
2177
|
+
* 面板里那一份「其他电脑」名单(2026-10-17):侧栏那枚图标点开之后看到的**第一屏**。
|
|
2178
|
+
*
|
|
2179
|
+
* 它是 `MiniRemotePanel` 里那一段的"画法",不是另一个组件:hook 全排在面板那一头
|
|
2180
|
+
* (见那里的注释),这里只管把已经读到的三样东西画出来。
|
|
2181
|
+
*
|
|
2182
|
+
* ## 三种结局,一个转不完的圈都没有
|
|
2183
|
+
*
|
|
2184
|
+
* · 读到了、有内容 → 一行一台(名字 + 地址 + 最近一次成功);
|
|
2185
|
+
* · 读到了、一台也没有 → 「还没连过别的电脑。去『设置 → 手机遥控 → 其他电脑』里加一台。」;
|
|
2186
|
+
* · 读不到 → 照登原因(和设置页那一块同一条口径:404/405 = 宿主是旧版,下一步重启 DSH)。
|
|
2187
|
+
*
|
|
2188
|
+
* "读不到"和"一台都没有"是两件事,**绝不能说成同一句**:把读失败说成"还没连过",
|
|
2189
|
+
* 用户会去重配一台其实早就配好的电脑。
|
|
2190
|
+
*/
|
|
2191
|
+
function remoteListScreen(list, error, again) {
|
|
2192
|
+
if (error) {
|
|
2193
|
+
return h('div', { style: styles.panelMsg },
|
|
2194
|
+
h('div', { style: Object.assign({}, styles.muted, styles.say) }, error),
|
|
2195
|
+
h('div', { style: { marginTop: 18 } }, again),
|
|
2196
|
+
);
|
|
2197
|
+
}
|
|
2198
|
+
if (list === null) return h('div', { style: styles.panelMsg }, '正在读取「其他电脑」名单…');
|
|
2199
|
+
if (!list.length) {
|
|
2200
|
+
return h('div', { style: styles.panelMsg },
|
|
2201
|
+
h('div', null, '还没连过别的电脑。去『设置 → 手机遥控 → 其他电脑』里加一台。'),
|
|
2202
|
+
h('div', { style: { marginTop: 18 } }, again),
|
|
2203
|
+
);
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
var rows = list.map(function (one) {
|
|
2207
|
+
var lastOk = formatTime(one.lastOk);
|
|
2208
|
+
var working = remoteBusyId === one.id;
|
|
2209
|
+
return h('div', {
|
|
2210
|
+
key: one.id,
|
|
2211
|
+
style: Object.assign({}, styles.deviceRow, { cursor: 'pointer' }),
|
|
2212
|
+
onClick: function () { openRemoteScreen(one); },
|
|
2213
|
+
},
|
|
2214
|
+
h('div', { style: styles.deviceMain },
|
|
2215
|
+
h('div', { style: styles.deviceName }, one.name ? one.name : '(这台电脑没有名字)'),
|
|
2216
|
+
h('div', { style: styles.remoteUrl }, one.baseUrl || ''),
|
|
2217
|
+
h('div', { style: styles.muted }, lastOk ? '最近成功 ' + lastOk : '最近成功 还没有记录'),
|
|
2218
|
+
),
|
|
2219
|
+
working ? h('div', { style: styles.muted }, '取票中…') : null,
|
|
2220
|
+
);
|
|
2221
|
+
});
|
|
2222
|
+
|
|
2223
|
+
return h('div', { style: styles.listWrap },
|
|
2224
|
+
h('div', { style: styles.listHead }, '其他电脑'),
|
|
2225
|
+
h('div', { style: styles.muted }, '点一台,就把它的界面显示在这儿。'),
|
|
2226
|
+
h('div', null, rows),
|
|
2227
|
+
h('div', { style: { marginTop: 12 } }, again),
|
|
2228
|
+
);
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
function MiniRemotePanel() {
|
|
2232
|
+
// —— 名单那一层要的三样东西。**hook 全排在函数开头**:这一屏有两层,下面按
|
|
2233
|
+
// 「在看某一台 / 在看名单」分两头返回;hook 排在分支之后的话,切换那一瞬间
|
|
2234
|
+
// React 会看到 hook 个数变了(那是真机上会炸的一种错)。
|
|
2235
|
+
var listState = useState(null); // null = 还在读;[] = 读到了,一台也没有
|
|
2236
|
+
var list = listState[0];
|
|
2237
|
+
var setList = listState[1];
|
|
2238
|
+
var listErrorState = useState(null);
|
|
2239
|
+
var listError = listErrorState[0];
|
|
2240
|
+
var setListError = listErrorState[1];
|
|
2241
|
+
var tryState = useState(0); // 每按一次「重新读一遍」+1 = 重读一次
|
|
2242
|
+
var tries = tryState[0];
|
|
2243
|
+
var setTries = tryState[1];
|
|
2244
|
+
// 框一直没打开时的兜底(见 REMOTE_FRAME_WAIT_MS):到点把空白框换成一句人话。
|
|
2245
|
+
var stallState = useState(false);
|
|
2246
|
+
var stalled = stallState[0];
|
|
2247
|
+
var setStalled = stallState[1];
|
|
2248
|
+
// 兜底那个定时器的把手。**它必须活在 effect 外面**:要把它掐掉的那一下在 `onLoad` 里,
|
|
2249
|
+
// 和立钟的那个 effect 隔着一次渲染——留在一个只属于那个 effect 的闭包里,`onLoad` 够不着。
|
|
2250
|
+
// 2026-10-17 修的就是这个:框加载好了,那个钟却还挂着,到点照样把正常的界面换掉。
|
|
2251
|
+
var stallTimerRef = useRef(null);
|
|
2252
|
+
// 顶上那条横条现在是不是滑出来了(见 `remoteScreen`)。**只有"框正常显示 + 有悬停能力"
|
|
2253
|
+
// 时才可能收起**,其余状态它常驻——出错那一屏全靠它的「再试一次」。
|
|
2254
|
+
var barState = useState(false);
|
|
2255
|
+
var barOpen = barState[0];
|
|
2256
|
+
var setBarOpen = barState[1];
|
|
2257
|
+
// 两个定时器:显示等 0.18 秒(鼠标可能只是路过屏幕顶部),收起等 0.2 秒
|
|
2258
|
+
// (光标常常是从感应带往下挪进横条里,中途路过那一下不该算"离开")。
|
|
2259
|
+
var barShowTimer = useRef(null);
|
|
2260
|
+
var barHideTimer = useRef(null);
|
|
2261
|
+
// 订阅「其他电脑」那三样(哪一台在换、换到没有、为什么没成):变了就重画一遍。
|
|
2262
|
+
// 值本身不用,`setPoke` 那一下就是目的。
|
|
2263
|
+
var pokeState = useState(0);
|
|
2264
|
+
var setPoke = pokeState[1];
|
|
2265
|
+
|
|
2266
|
+
useEffect(function () {
|
|
2267
|
+
return subscribeRemote(function () { setPoke(function (n) { return n + 1; }); });
|
|
2268
|
+
}, []);
|
|
2269
|
+
|
|
2270
|
+
/**
|
|
2271
|
+
* 读名单。**读的是设置页那一块同一个接口**(`GET /mini-remote/remotes`):两边不可能各说各话。
|
|
2272
|
+
*
|
|
2273
|
+
* 为什么这一屏要自己读一遍:设置页那一份住在设置页的浮层里,这一份住在 main 槽里
|
|
2274
|
+
* ——两个组件在不同的槽里,互相看不见(和上面那份跨机状态同一个理由)。
|
|
2275
|
+
*
|
|
2276
|
+
* 为什么挂在 `[tries]` 上:这一屏可能一直挂在后台(面板没被卸载),而用户是在设置页里
|
|
2277
|
+
* 把电脑加进去的。给一个明确的"再看一眼"的入口(那枚「重新读一遍」),比指望外壳
|
|
2278
|
+
* 什么时候重挂一遍可靠。
|
|
2279
|
+
*/
|
|
2280
|
+
useEffect(function () {
|
|
2281
|
+
var alive = true;
|
|
2282
|
+
setListError(null);
|
|
2283
|
+
fetch(REMOTES_PATH)
|
|
2284
|
+
.then(function (res) {
|
|
2285
|
+
return res.json().then(
|
|
2286
|
+
function (body) { return { status: res.status, body: body }; },
|
|
2287
|
+
function () { return { status: res.status, body: null }; },
|
|
2288
|
+
);
|
|
2289
|
+
})
|
|
2290
|
+
.then(function (r) {
|
|
2291
|
+
if (!alive) return;
|
|
2292
|
+
var b = (r.body && typeof r.body === 'object') ? r.body : null;
|
|
2293
|
+
if (b && b.ok && Array.isArray(b.remotes)) { setList(b.remotes); return; }
|
|
2294
|
+
// 宿主那一半是旧版(接口都不在)时,服务端回的是给程序看的 `not found`:
|
|
2295
|
+
// 原样摆出来用户读不懂,而下一步只有一句话——重启。和设置页那一块说法一致。
|
|
2296
|
+
setListError(r.status === 404 || r.status === 405
|
|
2297
|
+
? '读「其他电脑」名单失败:这台电脑上的服务没有这个接口(HTTP ' + r.status + ')。重启一次 DSH 再试。'
|
|
2298
|
+
: ((b && b.error) || ('读「其他电脑」名单失败:服务返回了 HTTP ' + r.status + '。')));
|
|
2299
|
+
})
|
|
2300
|
+
.catch(function (err) {
|
|
2301
|
+
if (!alive) return;
|
|
2302
|
+
setListError('读「其他电脑」名单失败:' + ((err && err.message) || String(err)));
|
|
2303
|
+
});
|
|
2304
|
+
return function () { alive = false; };
|
|
2305
|
+
}, [tries]);
|
|
2306
|
+
|
|
2307
|
+
var remoteId = remoteTarget ? remoteTarget.id : '';
|
|
2308
|
+
var remoteName = remoteTarget ? remoteTarget.name : '';
|
|
2309
|
+
var remoteUrl = (remoteWin && remoteWin.id === remoteId && remoteWin.url) ? remoteWin.url : '';
|
|
2310
|
+
|
|
2311
|
+
// 换一台 / 退回名单 / 重新进来:顶上那条回到收起状态。
|
|
2312
|
+
// 不重置的话,下次点开是展开的——而"展开"本该只由鼠标碰顶那一下产生。
|
|
2313
|
+
//
|
|
2314
|
+
// **只在真的展开着时才写**:本来就收起的时候这一下是空操作,别拿它去换一次重渲染
|
|
2315
|
+
// (替身 React 里 setState 不做同值跳过,真机上也只是白白多画一帧)。
|
|
2316
|
+
useEffect(function () {
|
|
2317
|
+
if (barOpen) setBarOpen(false);
|
|
2318
|
+
clearTimeout(barShowTimer.current);
|
|
2319
|
+
clearTimeout(barHideTimer.current);
|
|
2320
|
+
barShowTimer.current = null;
|
|
2321
|
+
barHideTimer.current = null;
|
|
2322
|
+
}, [remoteId]);
|
|
2323
|
+
|
|
2324
|
+
// 卸载时把两个钟收掉:留着它们,200 毫秒后会在一个已经没了的组件上 setState。
|
|
2325
|
+
useEffect(function () {
|
|
2326
|
+
return function () {
|
|
2327
|
+
clearTimeout(barShowTimer.current);
|
|
2328
|
+
clearTimeout(barHideTimer.current);
|
|
2329
|
+
};
|
|
2330
|
+
}, []);
|
|
2331
|
+
|
|
2332
|
+
/** 把顶上那条勾出来。等 0.18 秒:鼠标可能只是路过屏幕顶部。 */
|
|
2333
|
+
function scheduleBarShow() {
|
|
2334
|
+
if (barShowTimer.current) return;
|
|
2335
|
+
clearTimeout(barHideTimer.current);
|
|
2336
|
+
barHideTimer.current = null;
|
|
2337
|
+
barShowTimer.current = setTimeout(function () {
|
|
2338
|
+
barShowTimer.current = null;
|
|
2339
|
+
setBarOpen(true);
|
|
2340
|
+
}, 180);
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
/** 收起来。等 0.2 秒:光标常常是从感应带往下挪进横条里,中途路过那一下不算离开。 */
|
|
2344
|
+
function scheduleBarHide() {
|
|
2345
|
+
clearTimeout(barShowTimer.current);
|
|
2346
|
+
barShowTimer.current = null;
|
|
2347
|
+
clearTimeout(barHideTimer.current);
|
|
2348
|
+
barHideTimer.current = setTimeout(function () {
|
|
2349
|
+
barHideTimer.current = null;
|
|
2350
|
+
setBarOpen(false);
|
|
2351
|
+
}, 200);
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
/** 光标已经进到横条上了:那个 0.2 秒的钟撤掉,别把它收走。 */
|
|
2355
|
+
function cancelBarHide() {
|
|
2356
|
+
clearTimeout(barHideTimer.current);
|
|
2357
|
+
barHideTimer.current = null;
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
/**
|
|
2361
|
+
* 那一屏的外框:**顶上有条横条**(「← 返回列表」+ 这是哪一台 + 一枚「再试一次」),
|
|
2362
|
+
* 下面才是内容。
|
|
2363
|
+
*
|
|
2364
|
+
* ## 什么时候常驻、什么时候能收起(2026-10-17 用户要求)
|
|
2365
|
+
*
|
|
2366
|
+
* 收起(鼠标碰顶才滑出)**只在一种状态下成立:框真的显示出来了**。
|
|
2367
|
+
* 正在换票、对方撤销了、框一直没起来——那几屏本来就是空白,横条上挂着唯一的出路
|
|
2368
|
+
* (「← 返回列表」和「再试一次」),**藏起来等于把人关在里面**。所以那几种一律常驻。
|
|
2369
|
+
*
|
|
2370
|
+
* 没有悬停能力的设备(触摸屏)也一律常驻:触摸没有"碰一下顶"这回事,
|
|
2371
|
+
* 藏了就是摸不到(和 `lib/page.html` 那条镜像顶栏同一条口径)。
|
|
2372
|
+
*
|
|
2373
|
+
* ## 感应带那 16px 不能省
|
|
2374
|
+
*
|
|
2375
|
+
* 鼠标一进到框里那份文档,事件就进了那份文档,外层一个都收不到——不盖一条的话,
|
|
2376
|
+
* 最上面就没有任何东西能"碰到"。它透明、只 16px,压在框顶上那道空白上。
|
|
2377
|
+
* 收起时横条的**位置跟着让出来**(`max-height: 0`),框自己长上来填满,不跳。
|
|
2378
|
+
*
|
|
2379
|
+
* 顶上那枚「再试一次」**永远在**:它兜的是这一屏最没法自己判的两种情形
|
|
2380
|
+
* ——对面回的是一张错误页,或者文档到了里面却是空的。跨源的框读不到里面
|
|
2381
|
+
* (量过的事实:`contentDocument` 是 null),"里面是不是坏的"我们**看不见,也不猜**;
|
|
2382
|
+
* 能保证的是:**不管框里是什么,用户手上永远有一条出去的路**。
|
|
2383
|
+
*
|
|
2384
|
+
* @param {string} name 那台电脑叫什么
|
|
2385
|
+
* @param {*} inner 横条下面那一块(框 / 一句人话 / 「正在换票」)
|
|
2386
|
+
* @param {boolean} canAutoHide 横条允许自动收起吗——**只有"框已经显示出来"时传 true**
|
|
2387
|
+
*/
|
|
2388
|
+
function remoteScreen(name, inner, canAutoHide) {
|
|
2389
|
+
// 两个条件都得成立才收:这一屏允许(canAutoHide)且这台设备能悬停(HAS_HOVER)。
|
|
2390
|
+
var collapsible = Boolean(canAutoHide) && HAS_HOVER;
|
|
2391
|
+
var hidden = collapsible && !barOpen;
|
|
2392
|
+
|
|
2393
|
+
return h('div', { style: styles.screen },
|
|
2394
|
+
// 感应带:盖在最顶上那 16px 上(见上面那段"不能省")。
|
|
2395
|
+
collapsible ? h('div', {
|
|
2396
|
+
style: styles.screenHot,
|
|
2397
|
+
onMouseEnter: scheduleBarShow,
|
|
2398
|
+
// 显示这一屏那一刻鼠标可能就停在顶上(那时不会再有 mouseenter)——动一下也算数。
|
|
2399
|
+
onMouseMove: scheduleBarShow,
|
|
2400
|
+
onMouseLeave: scheduleBarHide,
|
|
2401
|
+
}) : null,
|
|
2402
|
+
h('div', {
|
|
2403
|
+
style: Object.assign({}, styles.screenBarWrap, { maxHeight: hidden ? 0 : 100 }),
|
|
2404
|
+
onMouseEnter: cancelBarHide,
|
|
2405
|
+
onMouseLeave: collapsible ? scheduleBarHide : null,
|
|
2406
|
+
// Tab 走到横条里那几枚按钮上时也要把它亮出来——
|
|
2407
|
+
// 不留"焦点在这、按钮看不见"的坑(手机镜像页那条也是这么兜的)。
|
|
2408
|
+
onFocus: collapsible
|
|
2409
|
+
? function () { cancelBarHide(); setBarOpen(true); }
|
|
2410
|
+
: null,
|
|
2411
|
+
},
|
|
2412
|
+
h('div', { style: styles.screenBar },
|
|
2413
|
+
h('button', {
|
|
2414
|
+
type: 'button',
|
|
2415
|
+
onClick: function () { leaveRemoteScreen(); },
|
|
2416
|
+
style: styles.btn,
|
|
2417
|
+
}, '← 返回列表'),
|
|
2418
|
+
h('div', { style: styles.screenName }, name ? name : '另一台电脑'),
|
|
2419
|
+
h('button', {
|
|
2420
|
+
type: 'button',
|
|
2421
|
+
disabled: Boolean(remoteBusyId),
|
|
2422
|
+
onClick: function () {
|
|
2423
|
+
// 正在换票就别再叠一趟(和名单上点一行同一个口径,见 openRemoteScreen):
|
|
2424
|
+
// 同时飞两条取票请求,后回来的那张票会把前一张盖掉。
|
|
2425
|
+
if (remoteBusyId) return;
|
|
2426
|
+
fetchRemoteTicket(remoteId, remoteName);
|
|
2427
|
+
},
|
|
2428
|
+
// 靠右(marginLeft auto):台名那一格会被挤扁,这一枚不许跟着一起被挤掉。
|
|
2429
|
+
style: Object.assign({}, styles.btn, { marginLeft: 'auto', flex: 'none' }),
|
|
2430
|
+
}, '再试一次'),
|
|
2431
|
+
),
|
|
2432
|
+
),
|
|
2433
|
+
inner,
|
|
2434
|
+
);
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
// —— **这里原来有一个"票不新鲜了就现换一张"的 effect**(2026-10-17 拆掉)。
|
|
2438
|
+
//
|
|
2439
|
+
// 它看着很稳妥(票是一次性的、30 秒就过期),后果却是:**票一变,框的地址就变,
|
|
2440
|
+
// 于是整个框重载一遍**——用户只是想一直看着那一台电脑,屏幕上却是每约 25 秒
|
|
2441
|
+
// (30 秒寿命 − 5 秒余量)刷新一次。而"重新取票"根本不必要:
|
|
2442
|
+
//
|
|
2443
|
+
// · 票**只在第一次打开那一刻**需要:那一下进门的请求把它兑成一枚**窗口会话 cookie**
|
|
2444
|
+
// (lib/server.js:1639-1643 种的那一枚),框里之后的请求(静态资源、`api/*`、
|
|
2445
|
+
// WebSocket)全靠那枚 cookie 认人,**票不再参与**;
|
|
2446
|
+
// · 那枚 cookie **没有时间限制**:会话体只有"形状 + 本进程签名"两项判据,没有
|
|
2447
|
+
// 到期时刻(lib/mirror.js:1131-1147),密钥是每个宿主进程现生成的
|
|
2448
|
+
// (lib/mirror.js:1112);`lib/mirror.js:1200` 写着"没有固定寿命,跟着这个宿主进程走",
|
|
2449
|
+
// lib/server.js:1640-1643 也**故意不写 Max-Age**。它只在两件事上失效:**DSH 重启**、
|
|
2450
|
+
// 或者**对面把这条设备记录撤销了**(lib/mirror.js:1108-1110 + 每次放行现查名册);
|
|
2451
|
+
// · 地址里留着的那张票随后只用来回答"这一页是我嵌出来的吗"(取景判据),而兑过的票
|
|
2452
|
+
// 进 `grants` 之后**同样没有寿命**(lib/mirror.js:1229/1293)——所以**同一条地址
|
|
2453
|
+
// 再加载一次照样进得去**,不需要换一张新票。
|
|
2454
|
+
//
|
|
2455
|
+
// 所以票改成"**开这一台那一刻换一次,之后不动**":三个入口各换一次(名单上点一台 /
|
|
2456
|
+
// 设置页点「重连」/ 那一屏的「再试一次」,都在 `fetchRemoteTicket` 里)。
|
|
2457
|
+
// **面板自己不再换第二张**——地址不变,框就不重载。要重新开一扇窗(对面重启过、
|
|
2458
|
+
// 撤销过又配回来了),入口是「重连」和「再试一次」,都是用户按下去的动作。
|
|
2459
|
+
|
|
2460
|
+
// 框等不到 load:到点**先自动重取一张票再试一次**,第二次到点才说人话
|
|
2461
|
+
// (见 REMOTE_FRAME_WAIT_MS 与 remoteAutoRetrySpent 里那两条)。
|
|
2462
|
+
//
|
|
2463
|
+
// **这个兜底只兜"框里一直没有任何文档"这一种**:框一旦 load 过(哪怕对面回的是一张
|
|
2464
|
+
// 错误页),`onLoad` 那一下就把这个钟掐掉——**它之后永远不许再响**。
|
|
2465
|
+
// 2026-10-17 之前它只在依赖变化/卸载时清,于是真机形态里出现过这种坏法:框明明
|
|
2466
|
+
// 已经加载好了,t≈20s 那一屏照样被换成「一直没打开」——一句错话盖住正常的界面。
|
|
2467
|
+
//
|
|
2468
|
+
// 那条"错误页 / 空白文档也算回来了"的取舍照旧没变(见下面 onLoad 那一段):跨源读不到
|
|
2469
|
+
// 里面,判不出来就不猜。补上自动重取**不等于**能做这个判断——自动重取兜的只是
|
|
2470
|
+
// "框里一个文档都没有"这一种;另外两种的出路是顶上那枚永远在的「再试一次」。
|
|
2471
|
+
useEffect(function () {
|
|
2472
|
+
if (!remoteId || !remoteUrl) return undefined;
|
|
2473
|
+
setStalled(false);
|
|
2474
|
+
stallTimerRef.current = setTimeout(function () {
|
|
2475
|
+
// **先自动重取一张票、再试一次(只这一次)**:票是一次性的、真机上 30 秒就作废,
|
|
2476
|
+
// 第一次加载被拖过那 30 秒时套着旧票重载是白费,换一张新的才有救(2026-10-17 补)。
|
|
2477
|
+
// 花掉那份预算的意思是"这一次人的动作里到此为止":它绝不会自己接着自己转下去。
|
|
2478
|
+
if (!remoteAutoRetrySpent) {
|
|
2479
|
+
remoteAutoRetrySpent = true;
|
|
2480
|
+
// 票一换、地址就变:框整个重立一次,这个 effect 会照新地址再挂一个钟
|
|
2481
|
+
// ——所以"第二次到点"是真的又过了这 20 秒,不是紧接着。
|
|
2482
|
+
fetchRemoteTicket(remoteId, remoteName, { auto: true });
|
|
2483
|
+
return;
|
|
2484
|
+
}
|
|
2485
|
+
// 自动那一趟也没成:**这才**把框换成那句人话 + 「再试一次」(下面 stalled 那一支)。
|
|
2486
|
+
setStalled(true);
|
|
2487
|
+
}, REMOTE_FRAME_WAIT_MS);
|
|
2488
|
+
return function () {
|
|
2489
|
+
// 换一台 / 返回列表 / 卸载都走这里:手上那个钟照旧清掉(别留下会自己响的定时器)。
|
|
2490
|
+
clearTimeout(stallTimerRef.current);
|
|
2491
|
+
stallTimerRef.current = null;
|
|
2492
|
+
};
|
|
2493
|
+
}, [remoteId, remoteUrl]);
|
|
2494
|
+
|
|
2495
|
+
if (remoteId) {
|
|
2496
|
+
var problem = remoteProblems[remoteId];
|
|
2497
|
+
if (remoteBusyId === remoteId) {
|
|
2498
|
+
// 自动重取那一次要说清是"第一次没打开、正在再试一次"(见 REMOTE_AUTO_RETRY_TEXT):
|
|
2499
|
+
// 换票那一刻那一块本来就是空的,不说话就等于无声地又转一圈。
|
|
2500
|
+
return remoteScreen(remoteName, h('div', { style: styles.panelMsg },
|
|
2501
|
+
remoteAutoRetry ? REMOTE_AUTO_RETRY_TEXT : '正在向那台电脑换一张通行证…'));
|
|
2502
|
+
}
|
|
2503
|
+
if (problem) {
|
|
2504
|
+
return remoteScreen(remoteName, remoteProblemPanel(remoteId, remoteName, problem));
|
|
2505
|
+
}
|
|
2506
|
+
// 手上还没有这一台的地址(正在换票,或者还没换):**这时一个框都不立**。
|
|
2507
|
+
// 地址一拿到就只认它——**不再按票的寿命判"新不新鲜"**(那正是"每 25 秒把框
|
|
2508
|
+
// 重载一遍"的来路,见上面那一段):票在框第一次加载那一刻就兑成了窗口会话 cookie,
|
|
2509
|
+
// 那枚 cookie 没有时间限制。
|
|
2510
|
+
if (!remoteUrl) {
|
|
2511
|
+
return remoteScreen(remoteName, h('div', { style: styles.panelMsg }, '正在向那台电脑换一张通行证…'));
|
|
2512
|
+
}
|
|
2513
|
+
if (stalled) {
|
|
2514
|
+
// 走到这儿一定已经自动重取过一次了(见上面那个钟里那一段):把那一下说出来,
|
|
2515
|
+
// 不然"等了 20 秒"这句话和用户真实等的时间对不上。
|
|
2516
|
+
return remoteScreen(remoteName, remoteProblemPanel(remoteId, remoteName, {
|
|
2517
|
+
revoked: false,
|
|
2518
|
+
text: '那台电脑的界面一直没打开(等了 ' + Math.round(REMOTE_FRAME_WAIT_MS / 1000) + ' 秒没看到它,'
|
|
2519
|
+
+ '自动再试了一次还是没看到)。\n'
|
|
2520
|
+
+ '它可能刚重启、或者这会儿连不上。点下面的「再试一次」换一张通行证再开一次。',
|
|
2521
|
+
}));
|
|
2522
|
+
}
|
|
2523
|
+
return remoteScreen(remoteName, h('div', { style: styles.frameBox },
|
|
2524
|
+
// 重试那一次的说明条(见 REMOTE_AUTO_RETRY_TEXT):框上面先顶一条,框照旧铺满下面。
|
|
2525
|
+
// 框 load 回来那一下它就收掉(见 onLoad)。
|
|
2526
|
+
remoteAutoRetry ? h('div', { style: styles.frameNote }, REMOTE_AUTO_RETRY_TEXT) : null,
|
|
2527
|
+
h('div', { style: styles.frameWrap },
|
|
2528
|
+
h('iframe', {
|
|
2529
|
+
src: remoteUrl,
|
|
2530
|
+
title: '另一台电脑的界面',
|
|
2531
|
+
// 框里是另一个源,里面一个字节都读不到(量过:跨源的 contentDocument 是 null)。
|
|
2532
|
+
// 所以"它回来了没有"只能靠这一下——但**502 和连不上也会照常 load**,
|
|
2533
|
+
// 所以它只用来取消下面那个兜底,不用来宣布成功。
|
|
2534
|
+
//
|
|
2535
|
+
// **它触发的意思是"框里有一个文档了",不是"那台电脑的界面打开了"。**
|
|
2536
|
+
// 取舍(2026-10-17):这一下必须把兜底掐掉——不清的话,一个加载好好的界面
|
|
2537
|
+
// 也会在 20 秒后被一句「一直没打开」盖住(那才是用户真被伤到的那一面)。
|
|
2538
|
+
// 代价(照旧没变,也**不打算靠猜去补**):对面回一张错误页(502 之类)、或者
|
|
2539
|
+
// 文档到了里面是空的,都算"回来了",这一屏不会换成我们那句人话。
|
|
2540
|
+
// 出路是顶上那枚**永远在**的「再试一次」(见 remoteScreen)。
|
|
2541
|
+
onLoad: function () {
|
|
2542
|
+
clearTimeout(stallTimerRef.current);
|
|
2543
|
+
stallTimerRef.current = null;
|
|
2544
|
+
setStalled(false);
|
|
2545
|
+
// 框真的回来了:那句"正在再试一次"就该收掉(见 remoteAutoRetry)。
|
|
2546
|
+
if (remoteAutoRetry) {
|
|
2547
|
+
remoteAutoRetry = false;
|
|
2548
|
+
notifyRemote();
|
|
2549
|
+
}
|
|
2550
|
+
},
|
|
2551
|
+
style: styles.frame,
|
|
2552
|
+
}))), true);
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
// 没在看某一台:**名单**(这一屏的第一层)。
|
|
2556
|
+
var again = h('button', {
|
|
2557
|
+
type: 'button',
|
|
2558
|
+
onClick: function () { setTries(function (n) { return n + 1; }); },
|
|
2559
|
+
style: styles.btn,
|
|
2560
|
+
}, '重新读一遍');
|
|
2561
|
+
return remoteListScreen(list, listError, again);
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
// —— 「再点一下退出远程窗口」需要的三样东西:一个能跟官方布局服务说话的把手、
|
|
2565
|
+
// 「进远程窗口之前站在哪个面板」这份记忆,和「这一下点的是我们那一条」的认法。
|
|
2566
|
+
//
|
|
2567
|
+
// **为什么得自己接这一下**:侧栏那一行(按钮、onClick、悬停提示)是官方壳自己画的
|
|
2568
|
+
// ——dsh-client-ui-sidebar 的 PanelRow,它 onClick 里调的是官方自己的 selectPanel。
|
|
2569
|
+
// 我们注册的只是那一行里的**一枚图标**,拿不到那一行的点击回调。
|
|
2570
|
+
// 壳没有开关式的第二态:同一个 id 再选一次就是原值写入,画面不会变——这就是
|
|
2571
|
+
// 「点了没反应」的机制。所以退出那一下只能我们自己接。
|
|
2572
|
+
var layoutFace = null; // 官方布局服务 ctx.layout;壳还没装配好时是 null
|
|
2573
|
+
var panelBeforeRemote = null; // 进远程窗口之前选中的面板;null = 会话视图(官方默认)
|
|
2574
|
+
|
|
2575
|
+
// 我们那枚图标身上的记号。认「这一条是不是我们那一条」用 DOM 认,不认标题文字
|
|
2576
|
+
// ——标题是中文常量,换语言/改文案就失效了。
|
|
2577
|
+
var ICON_MARK = 'data-dsh-mini-remote';
|
|
2578
|
+
|
|
2579
|
+
/** 记住「上一个不是远程窗口的面板」——退出时回到它。 */
|
|
2580
|
+
function rememberPanel() {
|
|
2581
|
+
if (!layoutFace) return;
|
|
2582
|
+
var info = layoutFace.panelInfo.getSnapshot();
|
|
2583
|
+
var id = info && info.activePanelId;
|
|
2584
|
+
if (id !== PANEL_ID) panelBeforeRemote = id;
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
/**
|
|
2588
|
+
* 退出远程窗口:回到进来之前那一个面板。
|
|
2589
|
+
* 拿不到(那个面板的插件被关了之类)就退回会话视图——官方把 null 当「没有选中任何
|
|
2590
|
+
* 全局面板」,也就是平时那一屏会话,不是别的东西。
|
|
2591
|
+
*/
|
|
2592
|
+
function leaveRemotePanel() {
|
|
2593
|
+
// 退出就把"另一台电脑"这一趟放下:下次点侧栏那一条,看到的是**名单**(这一屏的第一层)。
|
|
2594
|
+
// 不放的话,隔了半天再点那一条会去开一台早就没在看的电脑。
|
|
2595
|
+
if (remoteTarget || remoteWin) {
|
|
2596
|
+
remoteTarget = null;
|
|
2597
|
+
remoteWin = null;
|
|
2598
|
+
notifyRemote();
|
|
2599
|
+
}
|
|
2600
|
+
if (!layoutFace) return;
|
|
2601
|
+
try {
|
|
2602
|
+
layoutFace.selectPanel(panelBeforeRemote);
|
|
2603
|
+
} catch (err) {
|
|
2604
|
+
try { layoutFace.selectPanel(null); } catch (err2) { /* 布局服务已经收摊:什么也不做 */ }
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
/**
|
|
2609
|
+
* 「再点一下侧栏那一条」——**上一层**(2026-10-17 改)。
|
|
2610
|
+
*
|
|
2611
|
+
* 这一屏有两层:上面是「其他电脑」名单,下面是其中一台的界面。所以这一下:
|
|
2612
|
+
* · 人在**某一台的界面**里 → 退回**名单**(还在这一屏里,和顶上那枚「← 返回列表」同一件事);
|
|
2613
|
+
* · 人已经在**名单**上 → 退出这一屏,回到进来之前那一屏。
|
|
2614
|
+
* 一层一层退,用户不用先找到那枚按钮才出得去。
|
|
2615
|
+
*
|
|
2616
|
+
* **为什么不接在图标上**(2026-10-15 在真页面上量出来的):侧栏那一条是官方壳画的,
|
|
2617
|
+
* 我们在这条行里只有一枚 16×16 的图标,整行却有 252×36。点在文字「远程窗口」或
|
|
2618
|
+
* 行内空白上时,点子**根本不经过图标**(元素命中测绘:文字中心底下是官方的
|
|
2619
|
+
* span._panelTitle);官方那一下又是同一个 id 原值写入,等于没动。
|
|
2620
|
+
* 实测同一页里:点图标 → 退出;点文字/行中心 → 什么也不发生。用户报的就是后者。
|
|
2621
|
+
*
|
|
2622
|
+
* 所以听整行。挂在 document 的**捕获**阶段,是从最早的地方下手:
|
|
2623
|
+
* · 点的哪儿都在同一条路径上,文字、空白、键盘回车(click 落在按钮上)一起覆盖;
|
|
2624
|
+
* · 同一个节点上别人 stopPropagation 也拦不掉我们(stopImmediatePropagation 才会)。
|
|
2625
|
+
* 只在「这一条已经是当前面板」时动手,否则原样放行,官方那一下照旧把人送进面板。
|
|
2626
|
+
*/
|
|
2627
|
+
function onDocumentClick(e) {
|
|
2628
|
+
if (!layoutFace) return;
|
|
2629
|
+
var target = e.target;
|
|
2630
|
+
var row = target && typeof target.closest === 'function' ? target.closest('button') : null;
|
|
2631
|
+
if (!row || !row.querySelector('[' + ICON_MARK + ']')) return; // 不是我们那一条
|
|
2632
|
+
var info = layoutFace.panelInfo.getSnapshot();
|
|
2633
|
+
if (!info || info.activePanelId !== PANEL_ID) return; // 还没进去:让官方照常办
|
|
2634
|
+
e.stopPropagation(); // 拦掉官方「又选中自己」那一下
|
|
2635
|
+
// 在某一台的界面里:这一下是**退回名单**(还在这一屏里);已经在名单上才是退出。
|
|
2636
|
+
if (remoteTarget) { leaveRemoteScreen(); return; }
|
|
2637
|
+
leaveRemotePanel();
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
/**
|
|
2641
|
+
* 侧栏那排图标里的一个。侧栏把图标塞进一个正方格里,
|
|
2642
|
+
* 边长由它给(props.size,展开态 16、收起态 18),选中态由它自己画在按钮上,
|
|
2643
|
+
* 所以这里只管颜色跟着 currentColor 走、尺寸跟着 size 走。
|
|
2644
|
+
*/
|
|
2645
|
+
function MiniRemoteIcon(props) {
|
|
2646
|
+
var size = typeof props.size === 'number' ? props.size : 16;
|
|
2647
|
+
return h('svg', {
|
|
2648
|
+
width: size,
|
|
2649
|
+
height: size,
|
|
2650
|
+
viewBox: '0 0 16 16',
|
|
2651
|
+
fill: 'none',
|
|
2652
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
2653
|
+
'aria-hidden': 'true',
|
|
2654
|
+
focusable: 'false',
|
|
2655
|
+
// 「这一条是我们的」这个记号,给退出那一下的点击判定用(见 onDocumentClick)。
|
|
2656
|
+
'data-dsh-mini-remote': '1',
|
|
2657
|
+
},
|
|
2658
|
+
// 一块屏幕的形状(远程窗口),下面一小段底座。
|
|
2659
|
+
h('rect', { x: 1.75, y: 2.75, width: 12.5, height: 8.5, rx: 2, stroke: 'currentColor', strokeWidth: 1.5 }),
|
|
2660
|
+
h('path', { d: 'M6.25 13.25h3.5', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round' }),
|
|
2661
|
+
);
|
|
2662
|
+
}
|
|
2663
|
+
|
|
784
2664
|
function apply(ctx) {
|
|
2665
|
+
// —— 「再点一下退出」的接线:拿官方布局服务的把手,并盯住「上一个面板」。
|
|
2666
|
+
// 布局服务(ctx.layout)由官方布局插件提供,不是我们依赖的槽位;照着官方插件
|
|
2667
|
+
// (dsh-client-ui-approval 等地)的写法用 ctx.inject 等它就绪,就绪前回调不跑、
|
|
2668
|
+
// 那两枚函数都是空操作。没有 ctx.inject 的极简假壳(比如离线校验里的那个替身)
|
|
2669
|
+
// 直接跳过这一段:图标照画、照常被壳选中,只是没有「再点一次退出」。
|
|
2670
|
+
if (typeof ctx.inject === 'function') {
|
|
2671
|
+
ctx.inject(['layout'], function (scope) {
|
|
2672
|
+
var layout = scope.layout;
|
|
2673
|
+
if (!layout || !layout.panelInfo) return;
|
|
2674
|
+
layoutFace = layout;
|
|
2675
|
+
scope.effect(function () {
|
|
2676
|
+
rememberPanel();
|
|
2677
|
+
return layout.panelInfo.subscribe(rememberPanel);
|
|
2678
|
+
}, 'dsh-mini-remote: 记住进入远程窗口之前的面板');
|
|
2679
|
+
// 退出那一下:整条行的点击(文字、空白、键盘回车都算),只在已是当前面板时动手。
|
|
2680
|
+
scope.effect(function () {
|
|
2681
|
+
document.addEventListener('click', onDocumentClick, true);
|
|
2682
|
+
return function () { document.removeEventListener('click', onDocumentClick, true); };
|
|
2683
|
+
}, 'dsh-mini-remote: 再点一下那一条就退出远程窗口');
|
|
2684
|
+
});
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
// —— 侧栏图标 + 中间那一屏面板,一对配套的槽位。
|
|
2688
|
+
// main 是 keyed 槽:注册的键写在 `key` 上(不是 id)。官方对这种槽的示例是
|
|
2689
|
+
// ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: '…' }, Comp))
|
|
2690
|
+
// 已占用的键只有 `conversation`;`mini-remote` 是新键,不顶掉任何官方界面。
|
|
2691
|
+
// 两个槽都由别的插件声明(main 归 dsh-client-ui-layout、panellist 归 dsh-client-ui-sidebar),
|
|
2692
|
+
// 所以照上面 settings.section 的老规矩:等声明出现再注册。
|
|
2693
|
+
ctx.slots.inject('main', function () {
|
|
2694
|
+
return ctx.slots.register({ name: 'main', key: PANEL_ID }, MiniRemotePanel);
|
|
2695
|
+
});
|
|
2696
|
+
|
|
2697
|
+
// sidebar.panellist 是 list 槽:要 `id`(必需),可选 `order` 和 `label`。
|
|
2698
|
+
// id 必须和 main 的 key 一模一样,侧栏点下来才找得到面板。
|
|
2699
|
+
// order 让它排在官方那两枚之后(官方用 0 和 10;缺省是 0)。
|
|
2700
|
+
ctx.slots.inject('sidebar.panellist', function () {
|
|
2701
|
+
return ctx.slots.register(
|
|
2702
|
+
{
|
|
2703
|
+
name: 'sidebar.panellist',
|
|
2704
|
+
id: PANEL_ID,
|
|
2705
|
+
order: 20,
|
|
2706
|
+
// 传函数:外壳用 resolveSlotLabel 求值(字符串也支持),跟设置页那条保持一致。
|
|
2707
|
+
label: () => '远程窗口',
|
|
2708
|
+
},
|
|
2709
|
+
MiniRemoteIcon,
|
|
2710
|
+
);
|
|
2711
|
+
});
|
|
2712
|
+
|
|
785
2713
|
// settings.section 由设置域插件声明;这里等声明出现再注册。
|
|
786
2714
|
ctx.slots.inject('settings.section', function () {
|
|
787
2715
|
return ctx.slots.register(
|