@qzsy/dsh-bridge 3.1.1 → 3.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 +25 -0
- package/README.md +3 -1
- package/client/client.js +268 -25
- package/client/index.js +209 -21
- package/client/locales.mjs +34 -0
- package/lib/identity.js +1 -0
- package/lib/index.js +16 -0
- package/lib/saas/device-auth.mjs +60 -0
- package/lib/saas/tunnel-client.mjs +769 -764
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
+
## [v3.2.1] - 2026-11-02
|
|
4
|
+
|
|
5
|
+
### 🐛 修复
|
|
6
|
+
|
|
7
|
+
- **网关发布不再掉线**:收到网关 `drain`(滚动重启)后不再停在「已断开」,在途请求结束后自动重连;此前每次网关发布都要人工点「连接」,长连接会一直停在离线。
|
|
8
|
+
- **npm 仓库地址**:`repository.url` 补 `git+` 前缀,符合 npm 规范写法。
|
|
9
|
+
|
|
10
|
+
### ✨ 调整
|
|
11
|
+
|
|
12
|
+
- **网关卡片精简**:在线时只显示一行「网关:地址 + 新窗口打开」;掉线 / 连接中才展开状态徽标、可编辑地址与保存。
|
|
13
|
+
|
|
14
|
+
## [v3.2.0] - 2026-11-02
|
|
15
|
+
|
|
16
|
+
### ✨ 新增
|
|
17
|
+
|
|
18
|
+
- **授权后显示账号信息**:面板在授权成功(已绑定)后新增账号卡片,展示头像、昵称、邮箱/用户名,头像加载失败回退首字母。
|
|
19
|
+
- **使用时长**:展示剩余时长、本月已用、累计已用,以及授权时间与已授权天数;每 60 秒自动刷新,支持手动刷新。
|
|
20
|
+
- **控制面新接口** `POST /api/saas/device/profile`:设备凭据鉴权(与设备令牌同口径),只返回绑定账号的展示字段与时长,隔离边界仍为 `device.userId`;相对路径头像按请求源补全为绝对 URL。
|
|
21
|
+
- **插件 RPC `getProfile`**:面板通过 Host 侧 RPC 拉取账号资料;失败只回给面板,不写入隧道 `lastError`。
|
|
22
|
+
|
|
23
|
+
### 🧪 验证
|
|
24
|
+
|
|
25
|
+
- 单测覆盖账号资料字段归一化、头像协议白名单、RPC 未绑定/未授权拒绝;无感授权回归覆盖绑定后读取账号资料。
|
|
26
|
+
|
|
27
|
+
|
|
3
28
|
## [v3.1.1] - 2026-11-02
|
|
4
29
|
|
|
5
30
|
### 🐛 修复
|
package/README.md
CHANGED
|
@@ -26,6 +26,8 @@ dsh plugin --profile <name> add @qzsy/dsh-bridge
|
|
|
26
26
|
`https://<控制面>/device/authorize?code=...`。
|
|
27
27
|
3. 在浏览器登录 SaaS 账号 → 点「**授权此设备**」→ 回到 DSH 面板。
|
|
28
28
|
4. 面板会自动轮询并完成绑定,**绑定后自动开启隧道**,随后出现「**显示二维码**」按钮。
|
|
29
|
+
绑定成功后,面板顶部会出现**账号卡片**:头像、昵称/邮箱/用户名、剩余时长、本月已用、
|
|
30
|
+
累计已用与授权时间;每 60 秒自动刷新,也可手动「刷新」。
|
|
29
31
|
5. 手机扫码(`https://<控制面>/open/<publicId>`)→ 登录 SaaS 账号 → 自动验证隧道归属并签发一次性
|
|
30
32
|
ticket,跳转进入本机 DSH。
|
|
31
33
|
|
|
@@ -86,7 +88,7 @@ loopback 会话 cookie。来源判定:
|
|
|
86
88
|
| 请求 502 | DSH 本体不可达:确认 `dsh web` 在运行,或配置 `targetPort` |
|
|
87
89
|
| WebSocket 打不开 | 确认网关与反代放行 WS Upgrade;插件会透传 `ws-open/accept/frame/close` |
|
|
88
90
|
|
|
89
|
-
## 已知限制(v3.
|
|
91
|
+
## 已知限制(v3.2.0)
|
|
90
92
|
|
|
91
93
|
- `getStatus` 为面板引导接口,免 adminToken,只返回脱敏状态(设备 id/secret 均掩码,不含访问令牌与二维码;`openUrl` 由 /open/<publicId> 二次校验归属)。
|
|
92
94
|
- adminToken 每次进程启动随机生成,只经物理回环 `/__qzsy_bridge__/loopback-token` 签发;重启 DSH 后需重新领取(面板自动完成)。
|
package/client/client.js
CHANGED
|
@@ -48,6 +48,7 @@ var RPC_ENDPOINTS = Object.freeze({
|
|
|
48
48
|
getAccessUrl: "getAccessUrl",
|
|
49
49
|
startLogin: "startLogin",
|
|
50
50
|
getEntryQr: "getEntryQr",
|
|
51
|
+
getProfile: "getProfile",
|
|
51
52
|
setAccessPassword: "setAccessPassword",
|
|
52
53
|
clearAccessPassword: "clearAccessPassword"
|
|
53
54
|
});
|
|
@@ -57,6 +58,8 @@ var zh = {
|
|
|
57
58
|
title: "\u8FDC\u7A0B\u8BBF\u95EE",
|
|
58
59
|
subtitle: "DSH \u8FDC\u7A0B\u96A7\u9053\u4E91 \xB7 \u5B98\u65B9\u5BA2\u6237\u7AEF",
|
|
59
60
|
gateway: "SaaS \u7F51\u5173\u5730\u5740",
|
|
61
|
+
gatewayLabel: "\u7F51\u5173",
|
|
62
|
+
openInNewWindow: "\u65B0\u7A97\u53E3\u6253\u5F00",
|
|
60
63
|
save: "\u4FDD\u5B58\u7F51\u5173",
|
|
61
64
|
saved: "\u5DF2\u4FDD\u5B58",
|
|
62
65
|
device: "\u8BBE\u5907",
|
|
@@ -101,12 +104,29 @@ var zh = {
|
|
|
101
104
|
autoStart: "\u968F DSH \u542F\u52A8\u81EA\u52A8\u5F00\u542F",
|
|
102
105
|
status: "\u72B6\u6001",
|
|
103
106
|
version: "\u7248\u672C",
|
|
104
|
-
loadFailed: "\u65E0\u6CD5\u8FDE\u63A5\u63D2\u4EF6 RPC"
|
|
107
|
+
loadFailed: "\u65E0\u6CD5\u8FDE\u63A5\u63D2\u4EF6 RPC",
|
|
108
|
+
authorized: "\u5DF2\u6388\u6743",
|
|
109
|
+
balance: "\u5269\u4F59\u65F6\u957F",
|
|
110
|
+
monthUsed: "\u672C\u6708\u5DF2\u7528",
|
|
111
|
+
totalUsed: "\u7D2F\u8BA1\u5DF2\u7528",
|
|
112
|
+
authorizedAt: "\u6388\u6743\u4E8E ${date}",
|
|
113
|
+
authorizedDays: "\u5DF2\u6388\u6743 ${days} \u5929",
|
|
114
|
+
profileLoading: "\u6B63\u5728\u8BFB\u53D6\u8D26\u53F7\u4FE1\u606F\u2026",
|
|
115
|
+
profileFailed: "\u8D26\u53F7\u4FE1\u606F\u8BFB\u53D6\u5931\u8D25",
|
|
116
|
+
accountUnknown: "\u8D26\u53F7",
|
|
117
|
+
refresh: "\u5237\u65B0",
|
|
118
|
+
refreshing: "\u5237\u65B0\u4E2D\u2026",
|
|
119
|
+
durationHours: "${hours} \u5C0F\u65F6",
|
|
120
|
+
durationHoursMinutes: "${hours} \u5C0F\u65F6 ${minutes} \u5206",
|
|
121
|
+
durationMinutes: "${minutes} \u5206\u949F",
|
|
122
|
+
durationUnderMinute: "\u4E0D\u8DB3 1 \u5206\u949F"
|
|
105
123
|
};
|
|
106
124
|
var en = {
|
|
107
125
|
title: "Remote Access",
|
|
108
126
|
subtitle: "DSH Remote Tunnel Cloud \xB7 Official Client",
|
|
109
127
|
gateway: "SaaS gateway URL",
|
|
128
|
+
gatewayLabel: "Gateway",
|
|
129
|
+
openInNewWindow: "Open in new window",
|
|
110
130
|
save: "Save gateway",
|
|
111
131
|
saved: "Saved",
|
|
112
132
|
device: "Device",
|
|
@@ -151,7 +171,22 @@ var en = {
|
|
|
151
171
|
autoStart: "Auto-start with DSH",
|
|
152
172
|
status: "Status",
|
|
153
173
|
version: "Version",
|
|
154
|
-
loadFailed: "Cannot reach plugin RPC"
|
|
174
|
+
loadFailed: "Cannot reach plugin RPC",
|
|
175
|
+
authorized: "Authorized",
|
|
176
|
+
balance: "Time left",
|
|
177
|
+
monthUsed: "Used this month",
|
|
178
|
+
totalUsed: "Total used",
|
|
179
|
+
authorizedAt: "Authorized ${date}",
|
|
180
|
+
authorizedDays: "${days} days since authorization",
|
|
181
|
+
profileLoading: "Loading account\u2026",
|
|
182
|
+
profileFailed: "Failed to load account",
|
|
183
|
+
accountUnknown: "Account",
|
|
184
|
+
refresh: "Refresh",
|
|
185
|
+
refreshing: "Refreshing\u2026",
|
|
186
|
+
durationHours: "${hours} h",
|
|
187
|
+
durationHoursMinutes: "${hours} h ${minutes} min",
|
|
188
|
+
durationMinutes: "${minutes} min",
|
|
189
|
+
durationUnderMinute: "< 1 min"
|
|
155
190
|
};
|
|
156
191
|
function detectLang(host = {}) {
|
|
157
192
|
try {
|
|
@@ -193,7 +228,46 @@ var styles = {
|
|
|
193
228
|
code: { fontSize: 28, fontWeight: 700, letterSpacing: 4, fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", margin: "8px 0" },
|
|
194
229
|
error: { color: "var(--dsw-alias-state-error-primary, #dc2626)", fontSize: 12, lineHeight: 1.6, wordBreak: "break-all" },
|
|
195
230
|
muted: { color: "var(--dsw-alias-label-secondary, #6b7280)", fontSize: 12, lineHeight: 1.6 },
|
|
196
|
-
qr: { width: 168, height: 168, borderRadius: 8, background: "#fff", padding: 6 }
|
|
231
|
+
qr: { width: 168, height: 168, borderRadius: 8, background: "#fff", padding: 6 },
|
|
232
|
+
accountTop: { display: "flex", gap: 12, alignItems: "center" },
|
|
233
|
+
avatar: {
|
|
234
|
+
width: 48,
|
|
235
|
+
height: 48,
|
|
236
|
+
borderRadius: 999,
|
|
237
|
+
overflow: "hidden",
|
|
238
|
+
flex: "0 0 auto",
|
|
239
|
+
display: "flex",
|
|
240
|
+
alignItems: "center",
|
|
241
|
+
justifyContent: "center",
|
|
242
|
+
background: "var(--dsw-alias-bg-secondary, #eef1f5)",
|
|
243
|
+
color: "var(--dsw-alias-label-primary, #111827)",
|
|
244
|
+
fontSize: 18,
|
|
245
|
+
fontWeight: 700
|
|
246
|
+
},
|
|
247
|
+
avatarImg: { width: "100%", height: "100%", objectFit: "cover", display: "block" },
|
|
248
|
+
accountIdentity: { flex: "1 1 220px", minWidth: 0 },
|
|
249
|
+
accountName: { fontSize: 14, fontWeight: 600, lineHeight: 1.5, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
|
|
250
|
+
accountEmail: { fontSize: 12, color: "var(--dsw-alias-label-secondary, #6b7280)", lineHeight: 1.6, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
|
|
251
|
+
statGrid: {
|
|
252
|
+
display: "flex",
|
|
253
|
+
gap: 8,
|
|
254
|
+
flexWrap: "wrap",
|
|
255
|
+
marginTop: 12,
|
|
256
|
+
paddingTop: 12,
|
|
257
|
+
borderTop: "1px solid var(--dsw-alias-border-secondary, #e5e7eb)"
|
|
258
|
+
},
|
|
259
|
+
stat: { flex: "1 1 120px", minWidth: 0 },
|
|
260
|
+
statLabel: { display: "block", fontSize: 12, color: "var(--dsw-alias-label-secondary, #6b7280)", lineHeight: 1.6 },
|
|
261
|
+
statValue: { fontSize: 14, fontWeight: 600, lineHeight: 1.5, wordBreak: "break-word" },
|
|
262
|
+
accountFoot: { display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8, flexWrap: "wrap", marginTop: 10 },
|
|
263
|
+
linkBtn: {
|
|
264
|
+
padding: 0,
|
|
265
|
+
border: "none",
|
|
266
|
+
background: "transparent",
|
|
267
|
+
color: "var(--dsw-alias-brand-primary, #4f6ef7)",
|
|
268
|
+
cursor: "pointer",
|
|
269
|
+
fontSize: 12
|
|
270
|
+
}
|
|
197
271
|
};
|
|
198
272
|
function CopyButton({ value, t }) {
|
|
199
273
|
const [copied, setCopied] = React.useState(false);
|
|
@@ -222,8 +296,101 @@ function UrlRow({ label, url, t }) {
|
|
|
222
296
|
)
|
|
223
297
|
);
|
|
224
298
|
}
|
|
299
|
+
function formatDuration(t, seconds) {
|
|
300
|
+
const total = Math.max(0, Math.floor(Number(seconds) || 0));
|
|
301
|
+
const hours = Math.floor(total / 3600);
|
|
302
|
+
const minutes = Math.floor(total % 3600 / 60);
|
|
303
|
+
if (hours > 0) {
|
|
304
|
+
return minutes > 0 ? t("durationHoursMinutes", { hours, minutes }) : t("durationHours", { hours });
|
|
305
|
+
}
|
|
306
|
+
if (minutes > 0) return t("durationMinutes", { minutes });
|
|
307
|
+
return t("durationUnderMinute");
|
|
308
|
+
}
|
|
309
|
+
function formatDate(iso) {
|
|
310
|
+
if (!iso) return "";
|
|
311
|
+
const date = new Date(iso);
|
|
312
|
+
if (!Number.isFinite(date.getTime())) return "";
|
|
313
|
+
try {
|
|
314
|
+
return date.toLocaleDateString();
|
|
315
|
+
} catch {
|
|
316
|
+
return String(iso).slice(0, 10);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
function AccountAvatar({ url, name: name2 }) {
|
|
320
|
+
const [failed, setFailed] = React.useState(false);
|
|
321
|
+
React.useEffect(() => {
|
|
322
|
+
setFailed(false);
|
|
323
|
+
}, [url]);
|
|
324
|
+
const initial = (name2 || "?").trim().slice(0, 1).toUpperCase() || "?";
|
|
325
|
+
return h(
|
|
326
|
+
"div",
|
|
327
|
+
{ style: styles.avatar },
|
|
328
|
+
url && !failed ? h("img", { src: url, alt: name2 || "", style: styles.avatarImg, onError: () => setFailed(true) }) : h("span", null, initial)
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
function AccountCard({ profile, loading, error, onRefresh, t }) {
|
|
332
|
+
const user = profile?.user;
|
|
333
|
+
const usage = profile?.usage;
|
|
334
|
+
const authorization = profile?.authorization;
|
|
335
|
+
const pending = loading || !profile && !error;
|
|
336
|
+
const boundDate = formatDate(authorization?.boundAt);
|
|
337
|
+
const authorizedText = boundDate ? `${t("authorizedAt", { date: boundDate })}${authorization?.days > 0 ? ` \xB7 ${t("authorizedDays", { days: authorization.days })}` : ""}` : "";
|
|
338
|
+
return h(
|
|
339
|
+
"div",
|
|
340
|
+
{ style: styles.card },
|
|
341
|
+
h(
|
|
342
|
+
"div",
|
|
343
|
+
{ style: styles.accountTop },
|
|
344
|
+
h(AccountAvatar, { url: user?.avatarUrl, name: user?.name }),
|
|
345
|
+
h(
|
|
346
|
+
"div",
|
|
347
|
+
{ style: styles.accountIdentity },
|
|
348
|
+
h("div", { style: styles.accountName }, user?.name || (pending ? t("profileLoading") : t("accountUnknown"))),
|
|
349
|
+
user?.email ? h("div", { style: styles.accountEmail, title: user.email }, user.email) : null,
|
|
350
|
+
user?.username ? h("div", { style: styles.accountEmail }, `@${user.username}`) : null
|
|
351
|
+
),
|
|
352
|
+
h("span", { style: styles.badge }, t("authorized"))
|
|
353
|
+
),
|
|
354
|
+
usage ? h(
|
|
355
|
+
"div",
|
|
356
|
+
{ style: styles.statGrid },
|
|
357
|
+
h(
|
|
358
|
+
"div",
|
|
359
|
+
{ style: styles.stat },
|
|
360
|
+
h("span", { style: styles.statLabel }, t("balance")),
|
|
361
|
+
h("div", { style: styles.statValue }, formatDuration(t, usage.balanceSeconds))
|
|
362
|
+
),
|
|
363
|
+
h(
|
|
364
|
+
"div",
|
|
365
|
+
{ style: styles.stat },
|
|
366
|
+
h("span", { style: styles.statLabel }, t("monthUsed")),
|
|
367
|
+
h("div", { style: styles.statValue }, formatDuration(t, usage.monthUsedSeconds))
|
|
368
|
+
),
|
|
369
|
+
h(
|
|
370
|
+
"div",
|
|
371
|
+
{ style: styles.stat },
|
|
372
|
+
h("span", { style: styles.statLabel }, t("totalUsed")),
|
|
373
|
+
h("div", { style: styles.statValue }, formatDuration(t, usage.totalUsedSeconds))
|
|
374
|
+
)
|
|
375
|
+
) : null,
|
|
376
|
+
error ? h("div", { style: { ...styles.error, marginTop: 8 } }, `${t("profileFailed")}\uFF1A${error}`) : null,
|
|
377
|
+
h(
|
|
378
|
+
"div",
|
|
379
|
+
{ style: styles.accountFoot },
|
|
380
|
+
h("span", { style: styles.muted }, authorizedText),
|
|
381
|
+
h("button", { style: styles.linkBtn, disabled: pending, onClick: onRefresh }, pending ? t("refreshing") : t("refresh"))
|
|
382
|
+
)
|
|
383
|
+
);
|
|
384
|
+
}
|
|
225
385
|
function BridgePanel() {
|
|
226
|
-
const
|
|
386
|
+
const documentLang = (() => {
|
|
387
|
+
try {
|
|
388
|
+
return globalThis.document?.documentElement?.getAttribute?.("lang") ?? "";
|
|
389
|
+
} catch {
|
|
390
|
+
return "";
|
|
391
|
+
}
|
|
392
|
+
})();
|
|
393
|
+
const t = React.useMemo(() => createTranslator({}), [documentLang]);
|
|
227
394
|
const [adminToken, setAdminToken] = React.useState("");
|
|
228
395
|
const rpc = React.useCallback((endpoint, payload, signal) => ctxRpc(endpoint, { ...payload ?? {}, ...adminToken ? { adminToken } : {} }, signal), [adminToken]);
|
|
229
396
|
const [status, setStatus] = React.useState(null);
|
|
@@ -236,6 +403,10 @@ function BridgePanel() {
|
|
|
236
403
|
const [showQr, setShowQr] = React.useState(false);
|
|
237
404
|
const [entryQr, setEntryQr] = React.useState("");
|
|
238
405
|
const [passwordDraft, setPasswordDraft] = React.useState("");
|
|
406
|
+
const [profile, setProfile] = React.useState(null);
|
|
407
|
+
const [profileLoading, setProfileLoading] = React.useState(false);
|
|
408
|
+
const [profileError, setProfileError] = React.useState("");
|
|
409
|
+
const [profileTick, setProfileTick] = React.useState(0);
|
|
239
410
|
const loadAdminToken = React.useCallback(async (port) => {
|
|
240
411
|
if (!port) return;
|
|
241
412
|
try {
|
|
@@ -280,6 +451,39 @@ function BridgePanel() {
|
|
|
280
451
|
cancelled = true;
|
|
281
452
|
};
|
|
282
453
|
}, [adminToken, showQr, status?.openUrl, rpc]);
|
|
454
|
+
React.useEffect(() => {
|
|
455
|
+
if (!adminToken || !status?.device?.bound) {
|
|
456
|
+
setProfile(null);
|
|
457
|
+
setProfileError("");
|
|
458
|
+
setProfileLoading(false);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
let cancelled = false;
|
|
462
|
+
const load = async () => {
|
|
463
|
+
setProfileLoading(true);
|
|
464
|
+
setProfileError("");
|
|
465
|
+
try {
|
|
466
|
+
const res = await rpc(RPC_ENDPOINTS.getProfile);
|
|
467
|
+
if (cancelled) return;
|
|
468
|
+
if (res?.ok && res.value) setProfile(res.value);
|
|
469
|
+
else if (res && res.ok === false) setProfileError(res.error?.message ?? t("profileFailed"));
|
|
470
|
+
} catch (err) {
|
|
471
|
+
if (!cancelled) setProfileError(err?.message ?? String(err));
|
|
472
|
+
} finally {
|
|
473
|
+
if (!cancelled) setProfileLoading(false);
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
load().catch(() => {
|
|
477
|
+
});
|
|
478
|
+
const timer = setInterval(() => {
|
|
479
|
+
load().catch(() => {
|
|
480
|
+
});
|
|
481
|
+
}, 6e4);
|
|
482
|
+
return () => {
|
|
483
|
+
cancelled = true;
|
|
484
|
+
clearInterval(timer);
|
|
485
|
+
};
|
|
486
|
+
}, [adminToken, status?.device?.bound, profileTick, rpc, t]);
|
|
283
487
|
const run = React.useCallback(async (key, fn) => {
|
|
284
488
|
setBusy(key);
|
|
285
489
|
setError("");
|
|
@@ -343,6 +547,18 @@ function BridgePanel() {
|
|
|
343
547
|
const openUrl = status?.openUrl ?? "";
|
|
344
548
|
const passwordSet = Boolean(status?.access?.passwordSet);
|
|
345
549
|
const stateText = connected ? t("ready") : phase === "connecting" || phase === "reconnecting" ? t("connecting") : t("offline");
|
|
550
|
+
const gatewayWebUrl = React.useMemo(() => {
|
|
551
|
+
const raw = String(status?.gatewayUrl || gatewayDraft || "").trim();
|
|
552
|
+
if (!raw) return "";
|
|
553
|
+
try {
|
|
554
|
+
const url = new URL(raw);
|
|
555
|
+
if (url.protocol === "wss:") url.protocol = "https:";
|
|
556
|
+
else if (url.protocol === "ws:") url.protocol = "http:";
|
|
557
|
+
return url.origin;
|
|
558
|
+
} catch {
|
|
559
|
+
return raw.replace(/\/+$/, "");
|
|
560
|
+
}
|
|
561
|
+
}, [status?.gatewayUrl, gatewayDraft]);
|
|
346
562
|
return h(
|
|
347
563
|
"div",
|
|
348
564
|
{ style: styles.wrap },
|
|
@@ -352,35 +568,62 @@ function BridgePanel() {
|
|
|
352
568
|
h("div", { style: { fontSize: 15, fontWeight: 700 } }, t("subtitle")),
|
|
353
569
|
h("div", { style: styles.muted, marginTop: 4 }, `${PACKAGE_NAME} \xB7 v${status?.version ?? "\u2014"}`)
|
|
354
570
|
),
|
|
571
|
+
bound && adminToken ? h(AccountCard, {
|
|
572
|
+
profile,
|
|
573
|
+
loading: profileLoading,
|
|
574
|
+
error: profileError,
|
|
575
|
+
onRefresh: () => setProfileTick((value) => value + 1),
|
|
576
|
+
t
|
|
577
|
+
}) : null,
|
|
578
|
+
// 网关卡片:在线时只留一行「网关:地址 + 新窗口打开」;掉线/连接中才展开
|
|
579
|
+
// 状态徽标与可编辑地址,避免正常状态下一屏塞满诊断信息。
|
|
355
580
|
h(
|
|
356
581
|
"div",
|
|
357
582
|
{ style: styles.card },
|
|
358
|
-
h(
|
|
583
|
+
connected ? h(
|
|
359
584
|
"div",
|
|
360
585
|
{ style: styles.row },
|
|
361
|
-
h("span", { style: styles.
|
|
362
|
-
h("
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
h("
|
|
586
|
+
h("span", { style: { ...styles.muted, whiteSpace: "nowrap" } }, `${t("gatewayLabel")}\uFF1A`),
|
|
587
|
+
h("a", {
|
|
588
|
+
href: gatewayWebUrl,
|
|
589
|
+
target: "_blank",
|
|
590
|
+
rel: "noopener noreferrer",
|
|
591
|
+
style: { flex: 1, minWidth: 180, fontSize: 13, lineHeight: 1.6, wordBreak: "break-all", color: "inherit" }
|
|
592
|
+
}, gatewayWebUrl),
|
|
593
|
+
h("a", {
|
|
594
|
+
href: gatewayWebUrl,
|
|
595
|
+
target: "_blank",
|
|
596
|
+
rel: "noopener noreferrer",
|
|
597
|
+
style: { ...styles.btn, textDecoration: "none", whiteSpace: "nowrap" }
|
|
598
|
+
}, t("openInNewWindow"))
|
|
599
|
+
) : [
|
|
369
600
|
h(
|
|
370
601
|
"div",
|
|
371
602
|
{ style: styles.row },
|
|
372
|
-
h("
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
603
|
+
h("span", { style: styles.badge }, `${t("status")}: ${stateText}`),
|
|
604
|
+
h("span", { style: styles.badge }, bound ? `${t("device")}: ${status?.device?.idMasked ?? ""}` : t("notBound")),
|
|
605
|
+
status?.protocol ? h("span", { style: styles.badge }, `protocol v${status.protocol}`) : null
|
|
606
|
+
),
|
|
607
|
+
h(
|
|
608
|
+
"div",
|
|
609
|
+
{ style: { marginTop: 10 } },
|
|
610
|
+
h("span", { style: styles.label }, t("gateway")),
|
|
611
|
+
h(
|
|
612
|
+
"div",
|
|
613
|
+
{ style: styles.row },
|
|
614
|
+
h("input", {
|
|
615
|
+
style: styles.input,
|
|
616
|
+
value: gatewayDraft,
|
|
617
|
+
placeholder: "wss://dsh.b.nps.qzsyzn.com/_gw/connect",
|
|
618
|
+
onChange: (e) => setGatewayDraft(e.target.value)
|
|
619
|
+
}),
|
|
620
|
+
h("button", { style: styles.btnPrimary, disabled: busy === "gateway" || !gatewayDraft, onClick: saveGateway }, t("save"))
|
|
621
|
+
)
|
|
622
|
+
),
|
|
623
|
+
!adminToken ? h("div", { style: { ...styles.muted, marginTop: 8 } }, t("adminLocalOnly")) : null,
|
|
624
|
+
error ? h("div", { style: { ...styles.error, marginTop: 10 } }, `${t("lastError")}: ${error}`) : null,
|
|
625
|
+
notice ? h("div", { style: { ...styles.muted, marginTop: 8 } }, notice) : null
|
|
626
|
+
]
|
|
384
627
|
),
|
|
385
628
|
h(
|
|
386
629
|
"div",
|