@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 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.1.0)
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 t = createTranslator({});
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.badge }, `${t("status")}: ${stateText}`),
362
- h("span", { style: styles.badge }, bound ? `${t("device")}: ${status?.device?.idMasked ?? ""}` : t("notBound")),
363
- status?.protocol ? h("span", { style: styles.badge }, `protocol v${status.protocol}`) : null
364
- ),
365
- h(
366
- "div",
367
- { style: { marginTop: 10 } },
368
- h("span", { style: styles.label }, t("gateway")),
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("input", {
373
- style: styles.input,
374
- value: gatewayDraft,
375
- placeholder: "wss://dsh.b.nps.qzsyzn.com/_gw/connect",
376
- onChange: (e) => setGatewayDraft(e.target.value)
377
- }),
378
- h("button", { style: styles.btnPrimary, disabled: busy === "gateway" || !gatewayDraft, onClick: saveGateway }, t("save"))
379
- )
380
- ),
381
- !adminToken ? h("div", { style: { ...styles.muted, marginTop: 8 } }, t("adminLocalOnly")) : null,
382
- error ? h("div", { style: { ...styles.error, marginTop: 10 } }, `${t("lastError")}: ${error}`) : null,
383
- notice ? h("div", { style: { ...styles.muted, marginTop: 8 } }, notice) : null
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",