@qzsy/dsh-bridge 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # 更新日志
2
2
 
3
+ ## [v3.2.0] - 2026-11-02
4
+
5
+ ### ✨ 新增
6
+
7
+ - **授权后显示账号信息**:面板在授权成功(已绑定)后新增账号卡片,展示头像、昵称、邮箱/用户名,头像加载失败回退首字母。
8
+ - **使用时长**:展示剩余时长、本月已用、累计已用,以及授权时间与已授权天数;每 60 秒自动刷新,支持手动刷新。
9
+ - **控制面新接口** `POST /api/saas/device/profile`:设备凭据鉴权(与设备令牌同口径),只返回绑定账号的展示字段与时长,隔离边界仍为 `device.userId`;相对路径头像按请求源补全为绝对 URL。
10
+ - **插件 RPC `getProfile`**:面板通过 Host 侧 RPC 拉取账号资料;失败只回给面板,不写入隧道 `lastError`。
11
+
12
+ ### 🧪 验证
13
+
14
+ - 单测覆盖账号资料字段归一化、头像协议白名单、RPC 未绑定/未授权拒绝;无感授权回归覆盖绑定后读取账号资料。
15
+
16
+
17
+ ## [v3.1.1] - 2026-11-02
18
+
19
+ ### 🐛 修复
20
+
21
+ - 修复启用时 `TypeError: Invalid effect` 导致插件无法激活:`apply()` 不再返回实例对象(宿主会把 async apply 的解析值当作 disposer),改为通过 `getApplyState()` 暴露给单测。
22
+
23
+
3
24
  ## [v3.0.2] - 2026-11-01
4
25
 
5
26
  ### 🐛 修复
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
  });
@@ -101,7 +102,22 @@ var zh = {
101
102
  autoStart: "\u968F DSH \u542F\u52A8\u81EA\u52A8\u5F00\u542F",
102
103
  status: "\u72B6\u6001",
103
104
  version: "\u7248\u672C",
104
- loadFailed: "\u65E0\u6CD5\u8FDE\u63A5\u63D2\u4EF6 RPC"
105
+ loadFailed: "\u65E0\u6CD5\u8FDE\u63A5\u63D2\u4EF6 RPC",
106
+ authorized: "\u5DF2\u6388\u6743",
107
+ balance: "\u5269\u4F59\u65F6\u957F",
108
+ monthUsed: "\u672C\u6708\u5DF2\u7528",
109
+ totalUsed: "\u7D2F\u8BA1\u5DF2\u7528",
110
+ authorizedAt: "\u6388\u6743\u4E8E ${date}",
111
+ authorizedDays: "\u5DF2\u6388\u6743 ${days} \u5929",
112
+ profileLoading: "\u6B63\u5728\u8BFB\u53D6\u8D26\u53F7\u4FE1\u606F\u2026",
113
+ profileFailed: "\u8D26\u53F7\u4FE1\u606F\u8BFB\u53D6\u5931\u8D25",
114
+ accountUnknown: "\u8D26\u53F7",
115
+ refresh: "\u5237\u65B0",
116
+ refreshing: "\u5237\u65B0\u4E2D\u2026",
117
+ durationHours: "${hours} \u5C0F\u65F6",
118
+ durationHoursMinutes: "${hours} \u5C0F\u65F6 ${minutes} \u5206",
119
+ durationMinutes: "${minutes} \u5206\u949F",
120
+ durationUnderMinute: "\u4E0D\u8DB3 1 \u5206\u949F"
105
121
  };
106
122
  var en = {
107
123
  title: "Remote Access",
@@ -151,7 +167,22 @@ var en = {
151
167
  autoStart: "Auto-start with DSH",
152
168
  status: "Status",
153
169
  version: "Version",
154
- loadFailed: "Cannot reach plugin RPC"
170
+ loadFailed: "Cannot reach plugin RPC",
171
+ authorized: "Authorized",
172
+ balance: "Time left",
173
+ monthUsed: "Used this month",
174
+ totalUsed: "Total used",
175
+ authorizedAt: "Authorized ${date}",
176
+ authorizedDays: "${days} days since authorization",
177
+ profileLoading: "Loading account\u2026",
178
+ profileFailed: "Failed to load account",
179
+ accountUnknown: "Account",
180
+ refresh: "Refresh",
181
+ refreshing: "Refreshing\u2026",
182
+ durationHours: "${hours} h",
183
+ durationHoursMinutes: "${hours} h ${minutes} min",
184
+ durationMinutes: "${minutes} min",
185
+ durationUnderMinute: "< 1 min"
155
186
  };
156
187
  function detectLang(host = {}) {
157
188
  try {
@@ -193,7 +224,46 @@ var styles = {
193
224
  code: { fontSize: 28, fontWeight: 700, letterSpacing: 4, fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", margin: "8px 0" },
194
225
  error: { color: "var(--dsw-alias-state-error-primary, #dc2626)", fontSize: 12, lineHeight: 1.6, wordBreak: "break-all" },
195
226
  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 }
227
+ qr: { width: 168, height: 168, borderRadius: 8, background: "#fff", padding: 6 },
228
+ accountTop: { display: "flex", gap: 12, alignItems: "center" },
229
+ avatar: {
230
+ width: 48,
231
+ height: 48,
232
+ borderRadius: 999,
233
+ overflow: "hidden",
234
+ flex: "0 0 auto",
235
+ display: "flex",
236
+ alignItems: "center",
237
+ justifyContent: "center",
238
+ background: "var(--dsw-alias-bg-secondary, #eef1f5)",
239
+ color: "var(--dsw-alias-label-primary, #111827)",
240
+ fontSize: 18,
241
+ fontWeight: 700
242
+ },
243
+ avatarImg: { width: "100%", height: "100%", objectFit: "cover", display: "block" },
244
+ accountIdentity: { flex: "1 1 220px", minWidth: 0 },
245
+ accountName: { fontSize: 14, fontWeight: 600, lineHeight: 1.5, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
246
+ accountEmail: { fontSize: 12, color: "var(--dsw-alias-label-secondary, #6b7280)", lineHeight: 1.6, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
247
+ statGrid: {
248
+ display: "flex",
249
+ gap: 8,
250
+ flexWrap: "wrap",
251
+ marginTop: 12,
252
+ paddingTop: 12,
253
+ borderTop: "1px solid var(--dsw-alias-border-secondary, #e5e7eb)"
254
+ },
255
+ stat: { flex: "1 1 120px", minWidth: 0 },
256
+ statLabel: { display: "block", fontSize: 12, color: "var(--dsw-alias-label-secondary, #6b7280)", lineHeight: 1.6 },
257
+ statValue: { fontSize: 14, fontWeight: 600, lineHeight: 1.5, wordBreak: "break-word" },
258
+ accountFoot: { display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8, flexWrap: "wrap", marginTop: 10 },
259
+ linkBtn: {
260
+ padding: 0,
261
+ border: "none",
262
+ background: "transparent",
263
+ color: "var(--dsw-alias-brand-primary, #4f6ef7)",
264
+ cursor: "pointer",
265
+ fontSize: 12
266
+ }
197
267
  };
198
268
  function CopyButton({ value, t }) {
199
269
  const [copied, setCopied] = React.useState(false);
@@ -222,8 +292,101 @@ function UrlRow({ label, url, t }) {
222
292
  )
223
293
  );
224
294
  }
295
+ function formatDuration(t, seconds) {
296
+ const total = Math.max(0, Math.floor(Number(seconds) || 0));
297
+ const hours = Math.floor(total / 3600);
298
+ const minutes = Math.floor(total % 3600 / 60);
299
+ if (hours > 0) {
300
+ return minutes > 0 ? t("durationHoursMinutes", { hours, minutes }) : t("durationHours", { hours });
301
+ }
302
+ if (minutes > 0) return t("durationMinutes", { minutes });
303
+ return t("durationUnderMinute");
304
+ }
305
+ function formatDate(iso) {
306
+ if (!iso) return "";
307
+ const date = new Date(iso);
308
+ if (!Number.isFinite(date.getTime())) return "";
309
+ try {
310
+ return date.toLocaleDateString();
311
+ } catch {
312
+ return String(iso).slice(0, 10);
313
+ }
314
+ }
315
+ function AccountAvatar({ url, name: name2 }) {
316
+ const [failed, setFailed] = React.useState(false);
317
+ React.useEffect(() => {
318
+ setFailed(false);
319
+ }, [url]);
320
+ const initial = (name2 || "?").trim().slice(0, 1).toUpperCase() || "?";
321
+ return h(
322
+ "div",
323
+ { style: styles.avatar },
324
+ url && !failed ? h("img", { src: url, alt: name2 || "", style: styles.avatarImg, onError: () => setFailed(true) }) : h("span", null, initial)
325
+ );
326
+ }
327
+ function AccountCard({ profile, loading, error, onRefresh, t }) {
328
+ const user = profile?.user;
329
+ const usage = profile?.usage;
330
+ const authorization = profile?.authorization;
331
+ const pending = loading || !profile && !error;
332
+ const boundDate = formatDate(authorization?.boundAt);
333
+ const authorizedText = boundDate ? `${t("authorizedAt", { date: boundDate })}${authorization?.days > 0 ? ` \xB7 ${t("authorizedDays", { days: authorization.days })}` : ""}` : "";
334
+ return h(
335
+ "div",
336
+ { style: styles.card },
337
+ h(
338
+ "div",
339
+ { style: styles.accountTop },
340
+ h(AccountAvatar, { url: user?.avatarUrl, name: user?.name }),
341
+ h(
342
+ "div",
343
+ { style: styles.accountIdentity },
344
+ h("div", { style: styles.accountName }, user?.name || (pending ? t("profileLoading") : t("accountUnknown"))),
345
+ user?.email ? h("div", { style: styles.accountEmail, title: user.email }, user.email) : null,
346
+ user?.username ? h("div", { style: styles.accountEmail }, `@${user.username}`) : null
347
+ ),
348
+ h("span", { style: styles.badge }, t("authorized"))
349
+ ),
350
+ usage ? h(
351
+ "div",
352
+ { style: styles.statGrid },
353
+ h(
354
+ "div",
355
+ { style: styles.stat },
356
+ h("span", { style: styles.statLabel }, t("balance")),
357
+ h("div", { style: styles.statValue }, formatDuration(t, usage.balanceSeconds))
358
+ ),
359
+ h(
360
+ "div",
361
+ { style: styles.stat },
362
+ h("span", { style: styles.statLabel }, t("monthUsed")),
363
+ h("div", { style: styles.statValue }, formatDuration(t, usage.monthUsedSeconds))
364
+ ),
365
+ h(
366
+ "div",
367
+ { style: styles.stat },
368
+ h("span", { style: styles.statLabel }, t("totalUsed")),
369
+ h("div", { style: styles.statValue }, formatDuration(t, usage.totalUsedSeconds))
370
+ )
371
+ ) : null,
372
+ error ? h("div", { style: { ...styles.error, marginTop: 8 } }, `${t("profileFailed")}\uFF1A${error}`) : null,
373
+ h(
374
+ "div",
375
+ { style: styles.accountFoot },
376
+ h("span", { style: styles.muted }, authorizedText),
377
+ h("button", { style: styles.linkBtn, disabled: pending, onClick: onRefresh }, pending ? t("refreshing") : t("refresh"))
378
+ )
379
+ );
380
+ }
225
381
  function BridgePanel() {
226
- const t = createTranslator({});
382
+ const documentLang = (() => {
383
+ try {
384
+ return globalThis.document?.documentElement?.getAttribute?.("lang") ?? "";
385
+ } catch {
386
+ return "";
387
+ }
388
+ })();
389
+ const t = React.useMemo(() => createTranslator({}), [documentLang]);
227
390
  const [adminToken, setAdminToken] = React.useState("");
228
391
  const rpc = React.useCallback((endpoint, payload, signal) => ctxRpc(endpoint, { ...payload ?? {}, ...adminToken ? { adminToken } : {} }, signal), [adminToken]);
229
392
  const [status, setStatus] = React.useState(null);
@@ -236,6 +399,10 @@ function BridgePanel() {
236
399
  const [showQr, setShowQr] = React.useState(false);
237
400
  const [entryQr, setEntryQr] = React.useState("");
238
401
  const [passwordDraft, setPasswordDraft] = React.useState("");
402
+ const [profile, setProfile] = React.useState(null);
403
+ const [profileLoading, setProfileLoading] = React.useState(false);
404
+ const [profileError, setProfileError] = React.useState("");
405
+ const [profileTick, setProfileTick] = React.useState(0);
239
406
  const loadAdminToken = React.useCallback(async (port) => {
240
407
  if (!port) return;
241
408
  try {
@@ -280,6 +447,39 @@ function BridgePanel() {
280
447
  cancelled = true;
281
448
  };
282
449
  }, [adminToken, showQr, status?.openUrl, rpc]);
450
+ React.useEffect(() => {
451
+ if (!adminToken || !status?.device?.bound) {
452
+ setProfile(null);
453
+ setProfileError("");
454
+ setProfileLoading(false);
455
+ return;
456
+ }
457
+ let cancelled = false;
458
+ const load = async () => {
459
+ setProfileLoading(true);
460
+ setProfileError("");
461
+ try {
462
+ const res = await rpc(RPC_ENDPOINTS.getProfile);
463
+ if (cancelled) return;
464
+ if (res?.ok && res.value) setProfile(res.value);
465
+ else if (res && res.ok === false) setProfileError(res.error?.message ?? t("profileFailed"));
466
+ } catch (err) {
467
+ if (!cancelled) setProfileError(err?.message ?? String(err));
468
+ } finally {
469
+ if (!cancelled) setProfileLoading(false);
470
+ }
471
+ };
472
+ load().catch(() => {
473
+ });
474
+ const timer = setInterval(() => {
475
+ load().catch(() => {
476
+ });
477
+ }, 6e4);
478
+ return () => {
479
+ cancelled = true;
480
+ clearInterval(timer);
481
+ };
482
+ }, [adminToken, status?.device?.bound, profileTick, rpc, t]);
283
483
  const run = React.useCallback(async (key, fn) => {
284
484
  setBusy(key);
285
485
  setError("");
@@ -352,6 +552,13 @@ function BridgePanel() {
352
552
  h("div", { style: { fontSize: 15, fontWeight: 700 } }, t("subtitle")),
353
553
  h("div", { style: styles.muted, marginTop: 4 }, `${PACKAGE_NAME} \xB7 v${status?.version ?? "\u2014"}`)
354
554
  ),
555
+ bound && adminToken ? h(AccountCard, {
556
+ profile,
557
+ loading: profileLoading,
558
+ error: profileError,
559
+ onRefresh: () => setProfileTick((value) => value + 1),
560
+ t
561
+ }) : null,
355
562
  h(
356
563
  "div",
357
564
  { style: styles.card },
package/client/index.js CHANGED
@@ -24,6 +24,45 @@ const styles = {
24
24
  error: { color: 'var(--dsw-alias-state-error-primary, #dc2626)', fontSize: 12, lineHeight: 1.6, wordBreak: 'break-all' },
25
25
  muted: { color: 'var(--dsw-alias-label-secondary, #6b7280)', fontSize: 12, lineHeight: 1.6 },
26
26
  qr: { width: 168, height: 168, borderRadius: 8, background: '#fff', padding: 6 },
27
+ accountTop: { display: 'flex', gap: 12, alignItems: 'center' },
28
+ avatar: {
29
+ width: 48,
30
+ height: 48,
31
+ borderRadius: 999,
32
+ overflow: 'hidden',
33
+ flex: '0 0 auto',
34
+ display: 'flex',
35
+ alignItems: 'center',
36
+ justifyContent: 'center',
37
+ background: 'var(--dsw-alias-bg-secondary, #eef1f5)',
38
+ color: 'var(--dsw-alias-label-primary, #111827)',
39
+ fontSize: 18,
40
+ fontWeight: 700,
41
+ },
42
+ avatarImg: { width: '100%', height: '100%', objectFit: 'cover', display: 'block' },
43
+ accountIdentity: { flex: '1 1 220px', minWidth: 0 },
44
+ accountName: { fontSize: 14, fontWeight: 600, lineHeight: 1.5, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
45
+ accountEmail: { fontSize: 12, color: 'var(--dsw-alias-label-secondary, #6b7280)', lineHeight: 1.6, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
46
+ statGrid: {
47
+ display: 'flex',
48
+ gap: 8,
49
+ flexWrap: 'wrap',
50
+ marginTop: 12,
51
+ paddingTop: 12,
52
+ borderTop: '1px solid var(--dsw-alias-border-secondary, #e5e7eb)',
53
+ },
54
+ stat: { flex: '1 1 120px', minWidth: 0 },
55
+ statLabel: { display: 'block', fontSize: 12, color: 'var(--dsw-alias-label-secondary, #6b7280)', lineHeight: 1.6 },
56
+ statValue: { fontSize: 14, fontWeight: 600, lineHeight: 1.5, wordBreak: 'break-word' },
57
+ accountFoot: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginTop: 10 },
58
+ linkBtn: {
59
+ padding: 0,
60
+ border: 'none',
61
+ background: 'transparent',
62
+ color: 'var(--dsw-alias-brand-primary, #4f6ef7)',
63
+ cursor: 'pointer',
64
+ fontSize: 12,
65
+ },
27
66
  };
28
67
 
29
68
  function CopyButton({ value, t }) {
@@ -50,8 +89,84 @@ function UrlRow({ label, url, t }) {
50
89
  );
51
90
  }
52
91
 
92
+ function formatDuration(t, seconds) {
93
+ const total = Math.max(0, Math.floor(Number(seconds) || 0));
94
+ const hours = Math.floor(total / 3600);
95
+ const minutes = Math.floor((total % 3600) / 60);
96
+ if (hours > 0) {
97
+ return minutes > 0 ? t('durationHoursMinutes', { hours, minutes }) : t('durationHours', { hours });
98
+ }
99
+ if (minutes > 0) return t('durationMinutes', { minutes });
100
+ return t('durationUnderMinute');
101
+ }
102
+
103
+ function formatDate(iso) {
104
+ if (!iso) return '';
105
+ const date = new Date(iso);
106
+ if (!Number.isFinite(date.getTime())) return '';
107
+ try { return date.toLocaleDateString(); } catch { return String(iso).slice(0, 10); }
108
+ }
109
+
110
+ function AccountAvatar({ url, name }) {
111
+ const [failed, setFailed] = React.useState(false);
112
+ React.useEffect(() => { setFailed(false); }, [url]);
113
+ const initial = (name || '?').trim().slice(0, 1).toUpperCase() || '?';
114
+ return h('div', { style: styles.avatar },
115
+ url && !failed
116
+ ? h('img', { src: url, alt: name || '', style: styles.avatarImg, onError: () => setFailed(true) })
117
+ : h('span', null, initial),
118
+ );
119
+ }
120
+
121
+ function AccountCard({ profile, loading, error, onRefresh, t }) {
122
+ const user = profile?.user;
123
+ const usage = profile?.usage;
124
+ const authorization = profile?.authorization;
125
+ const pending = loading || (!profile && !error);
126
+ const boundDate = formatDate(authorization?.boundAt);
127
+ const authorizedText = boundDate
128
+ ? `${t('authorizedAt', { date: boundDate })}${authorization?.days > 0 ? ` · ${t('authorizedDays', { days: authorization.days })}` : ''}`
129
+ : '';
130
+ return h('div', { style: styles.card },
131
+ h('div', { style: styles.accountTop },
132
+ h(AccountAvatar, { url: user?.avatarUrl, name: user?.name }),
133
+ h('div', { style: styles.accountIdentity },
134
+ h('div', { style: styles.accountName }, user?.name || (pending ? t('profileLoading') : t('accountUnknown'))),
135
+ user?.email ? h('div', { style: styles.accountEmail, title: user.email }, user.email) : null,
136
+ user?.username ? h('div', { style: styles.accountEmail }, `@${user.username}`) : null,
137
+ ),
138
+ h('span', { style: styles.badge }, t('authorized')),
139
+ ),
140
+ usage
141
+ ? h('div', { style: styles.statGrid },
142
+ h('div', { style: styles.stat },
143
+ h('span', { style: styles.statLabel }, t('balance')),
144
+ h('div', { style: styles.statValue }, formatDuration(t, usage.balanceSeconds)),
145
+ ),
146
+ h('div', { style: styles.stat },
147
+ h('span', { style: styles.statLabel }, t('monthUsed')),
148
+ h('div', { style: styles.statValue }, formatDuration(t, usage.monthUsedSeconds)),
149
+ ),
150
+ h('div', { style: styles.stat },
151
+ h('span', { style: styles.statLabel }, t('totalUsed')),
152
+ h('div', { style: styles.statValue }, formatDuration(t, usage.totalUsedSeconds)),
153
+ ),
154
+ )
155
+ : null,
156
+ error ? h('div', { style: { ...styles.error, marginTop: 8 } }, `${t('profileFailed')}:${error}`) : null,
157
+ h('div', { style: styles.accountFoot },
158
+ h('span', { style: styles.muted }, authorizedText),
159
+ h('button', { style: styles.linkBtn, disabled: pending, onClick: onRefresh }, pending ? t('refreshing') : t('refresh')),
160
+ ),
161
+ );
162
+ }
163
+
53
164
  function BridgePanel() {
54
- const t = createTranslator({});
165
+ const documentLang = (() => {
166
+ try { return globalThis.document?.documentElement?.getAttribute?.('lang') ?? ''; } catch { return ''; }
167
+ })();
168
+ // 词典按宿主页面语言缓存:每次渲染新建 translator 会让依赖它的 effect 反复重建。
169
+ const t = React.useMemo(() => createTranslator({}), [documentLang]);
55
170
  // 注意:adminToken 必须先声明——后面 useCallback 的依赖数组会在 Hook 调用时立即求值,
56
171
  // 若 state 声明在其后,会在渲染时触发 TDZ(Cannot access before initialization)导致整页白屏。
57
172
  const [adminToken, setAdminToken] = React.useState('');
@@ -67,6 +182,10 @@ function BridgePanel() {
67
182
  const [showQr, setShowQr] = React.useState(false);
68
183
  const [entryQr, setEntryQr] = React.useState('');
69
184
  const [passwordDraft, setPasswordDraft] = React.useState('');
185
+ const [profile, setProfile] = React.useState(null);
186
+ const [profileLoading, setProfileLoading] = React.useState(false);
187
+ const [profileError, setProfileError] = React.useState('');
188
+ const [profileTick, setProfileTick] = React.useState(0);
70
189
 
71
190
  const loadAdminToken = React.useCallback(async (port) => {
72
191
  if (!port) return;
@@ -108,6 +227,34 @@ function BridgePanel() {
108
227
  return () => { cancelled = true; };
109
228
  }, [adminToken, showQr, status?.openUrl, rpc]);
110
229
 
230
+ // 授权成功后显示账号资料与时长:首次进入 / 手动刷新 / 每 60 秒拉一次。
231
+ React.useEffect(() => {
232
+ if (!adminToken || !status?.device?.bound) {
233
+ setProfile(null);
234
+ setProfileError('');
235
+ setProfileLoading(false);
236
+ return;
237
+ }
238
+ let cancelled = false;
239
+ const load = async () => {
240
+ setProfileLoading(true);
241
+ setProfileError('');
242
+ try {
243
+ const res = await rpc(RPC_ENDPOINTS.getProfile);
244
+ if (cancelled) return;
245
+ if (res?.ok && res.value) setProfile(res.value);
246
+ else if (res && res.ok === false) setProfileError(res.error?.message ?? t('profileFailed'));
247
+ } catch (err) {
248
+ if (!cancelled) setProfileError(err?.message ?? String(err));
249
+ } finally {
250
+ if (!cancelled) setProfileLoading(false);
251
+ }
252
+ };
253
+ load().catch(() => {});
254
+ const timer = setInterval(() => { load().catch(() => {}); }, 60_000);
255
+ return () => { cancelled = true; clearInterval(timer); };
256
+ }, [adminToken, status?.device?.bound, profileTick, rpc, t]);
257
+
111
258
  const run = React.useCallback(async (key, fn) => {
112
259
  setBusy(key);
113
260
  setError('');
@@ -165,6 +312,14 @@ function BridgePanel() {
165
312
  h('div', { style: styles.muted, marginTop: 4 }, `${PACKAGE_NAME} · v${status?.version ?? '—'}`),
166
313
  ),
167
314
 
315
+ bound && adminToken ? h(AccountCard, {
316
+ profile,
317
+ loading: profileLoading,
318
+ error: profileError,
319
+ onRefresh: () => setProfileTick((value) => value + 1),
320
+ t,
321
+ }) : null,
322
+
168
323
  h('div', { style: styles.card },
169
324
  h('div', { style: styles.row },
170
325
  h('span', { style: styles.badge }, `${t('status')}: ${stateText}`),
@@ -48,6 +48,21 @@ export const zh = {
48
48
  status: '状态',
49
49
  version: '版本',
50
50
  loadFailed: '无法连接插件 RPC',
51
+ authorized: '已授权',
52
+ balance: '剩余时长',
53
+ monthUsed: '本月已用',
54
+ totalUsed: '累计已用',
55
+ authorizedAt: '授权于 ${date}',
56
+ authorizedDays: '已授权 ${days} 天',
57
+ profileLoading: '正在读取账号信息…',
58
+ profileFailed: '账号信息读取失败',
59
+ accountUnknown: '账号',
60
+ refresh: '刷新',
61
+ refreshing: '刷新中…',
62
+ durationHours: '${hours} 小时',
63
+ durationHoursMinutes: '${hours} 小时 ${minutes} 分',
64
+ durationMinutes: '${minutes} 分钟',
65
+ durationUnderMinute: '不足 1 分钟',
51
66
  };
52
67
 
53
68
  export const en = {
@@ -99,6 +114,21 @@ export const en = {
99
114
  status: 'Status',
100
115
  version: 'Version',
101
116
  loadFailed: 'Cannot reach plugin RPC',
117
+ authorized: 'Authorized',
118
+ balance: 'Time left',
119
+ monthUsed: 'Used this month',
120
+ totalUsed: 'Total used',
121
+ authorizedAt: 'Authorized ${date}',
122
+ authorizedDays: '${days} days since authorization',
123
+ profileLoading: 'Loading account…',
124
+ profileFailed: 'Failed to load account',
125
+ accountUnknown: 'Account',
126
+ refresh: 'Refresh',
127
+ refreshing: 'Refreshing…',
128
+ durationHours: '${hours} h',
129
+ durationHoursMinutes: '${hours} h ${minutes} min',
130
+ durationMinutes: '${minutes} min',
131
+ durationUnderMinute: '< 1 min',
102
132
  };
103
133
 
104
134
  export function detectLang(host = {}) {
package/lib/identity.js CHANGED
@@ -20,6 +20,7 @@ export const RPC_ENDPOINTS = Object.freeze({
20
20
  getAccessUrl: 'getAccessUrl',
21
21
  startLogin: 'startLogin',
22
22
  getEntryQr: 'getEntryQr',
23
+ getProfile: 'getProfile',
23
24
  setAccessPassword: 'setAccessPassword',
24
25
  clearAccessPassword: 'clearAccessPassword',
25
26
  });
package/lib/index.js CHANGED
@@ -21,6 +21,7 @@ import { SaasTunnelClient } from './saas/tunnel-client.mjs';
21
21
  import {
22
22
  claimDeviceCode,
23
23
  fetchDeviceCodeStatus,
24
+ fetchDeviceProfile,
24
25
  maskDeviceSecret,
25
26
  normalizeSaasBaseUrl,
26
27
  refreshDeviceToken,
@@ -68,6 +69,13 @@ function registerRpc(ctx, handler, logger) {
68
69
  }
69
70
  }
70
71
 
72
+ let lastApplyState = null;
73
+
74
+ /** 仅供单测读取 apply 产出的实例(宿主不允许 apply 返回值,见下方注释)。 */
75
+ export function getApplyState() {
76
+ return lastApplyState;
77
+ }
78
+
71
79
  export async function apply(ctx, pluginConfig = {}) {
72
80
  const logger = ctx?.logger?.(name) ?? console;
73
81
  const dshPort = Number(ctx?.webServer?.port) || Number(pluginConfig.targetPort) || 3080;
@@ -369,6 +377,21 @@ export async function apply(ctx, pluginConfig = {}) {
369
377
  }
370
378
  return ok({ url, token: config.access.token, qrDataUrl });
371
379
  }
380
+ case RPC_ENDPOINTS.getProfile: {
381
+ if (!config.deviceId || !config.deviceSecret) {
382
+ return fail('bad-request', '尚未绑定设备:请先登录授权');
383
+ }
384
+ // 账号资料是展示信息:失败只回给面板,不写 lastError 干扰隧道状态。
385
+ try {
386
+ return ok(await fetchDeviceProfile({
387
+ gatewayUrl: gatewayBase(),
388
+ deviceId: config.deviceId,
389
+ secret: config.deviceSecret,
390
+ }));
391
+ } catch (err) {
392
+ return errorPayload(err);
393
+ }
394
+ }
372
395
  case RPC_ENDPOINTS.setAccessPassword: {
373
396
  const password = String(payload?.password ?? '');
374
397
  if (password.length < 6) return fail('bad-request', '访问密码至少 6 位');
@@ -404,7 +427,10 @@ export async function apply(ctx, pluginConfig = {}) {
404
427
  };
405
428
  try { ctx.effect?.(() => dispose, `${PLUGIN_ID}: lifecycle`); } catch {}
406
429
 
407
- return { gate, dispose };
430
+ // 宿主(cordis)会把 async apply 的解析值当作 disposer 处理,返回对象会抛
431
+ // TypeError: Invalid effect,导致 "entry did not activate"。这里只暴露给单测,不返回值。
432
+ lastApplyState = { gate, dispose };
433
+ return undefined;
408
434
  }
409
435
 
410
436
  export { GateProxy } from './gate.js';
@@ -5,6 +5,7 @@
5
5
  // GET /api/saas/device/code/:code → { status: pending|approved|consumed|expired }
6
6
  // POST /api/saas/device/code/:code/claim → { deviceId, secret, tunnel:{publicId,name} }
7
7
  // POST /api/saas/device/token → { token, expiresAt, tunnel, gateway:{url,path} }
8
+ // POST /api/saas/device/profile → { user, usage, authorization, serverTime }
8
9
  //
9
10
  // 本模块只负责 HTTP 与状态判定,不做落盘:deviceSecret 由调用方写进 config.json
10
11
  // (lib/index.js 的 updateConfig → writeConfig 固定 mode=0600,与现有配置落盘方式一致)。
@@ -304,6 +305,65 @@ function normalizeTunnel(tunnel) {
304
305
  return { publicId, name };
305
306
  }
306
307
 
308
+ function nonNegativeInt(value) {
309
+ const parsed = Number(value);
310
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
311
+ }
312
+
313
+ /**
314
+ * 读取绑定账号资料(授权后展示):POST /api/saas/device/profile。
315
+ * 服务端已按 deviceId+secret 校验并要求 active;这里只做字段归一化,
316
+ * 头像只接受 http(s) / data:image(面板渲染失败会回退首字母)。
317
+ *
318
+ * @param {object} options
319
+ * @param {string} options.gatewayUrl
320
+ * @param {string} options.deviceId
321
+ * @param {string} options.secret
322
+ * @param {Function} [options.fetchImpl]
323
+ * @param {AbortSignal} [options.signal]
324
+ * @param {number} [options.timeoutMs]
325
+ * @returns {Promise<{user:{id:string,name:string,email:string,username:string|null,avatarUrl:string|null}, usage:{balanceSeconds:number,monthUsedSeconds:number,totalUsedSeconds:number}, authorization:{boundAt:string,days:number}, serverTime:string}>}
326
+ */
327
+ export async function fetchDeviceProfile({ gatewayUrl, deviceId, secret, fetchImpl, signal, timeoutMs } = {}) {
328
+ const base = normalizeSaasBaseUrl(gatewayUrl);
329
+ const id = String(deviceId ?? '').trim();
330
+ const value = String(secret ?? '');
331
+ if (!id || !value) {
332
+ throw new SaasAuthError('设备尚未绑定:缺少 deviceId/deviceSecret', { code: 'auth_failed', terminal: true });
333
+ }
334
+ const data = await requestJson({
335
+ url: `${base}/api/saas/device/profile`,
336
+ method: 'POST',
337
+ body: { deviceId: id, secret: value },
338
+ fetchImpl,
339
+ signal,
340
+ timeoutMs,
341
+ });
342
+ const user = data?.user && typeof data.user === 'object' ? data.user : {};
343
+ const usage = data?.usage && typeof data.usage === 'object' ? data.usage : {};
344
+ const authorization = data?.authorization && typeof data.authorization === 'object' ? data.authorization : {};
345
+ const avatar = typeof user.avatarUrl === 'string' ? user.avatarUrl : '';
346
+ return {
347
+ user: {
348
+ id: String(user.id ?? ''),
349
+ name: String(user.name ?? '').trim(),
350
+ email: String(user.email ?? '').trim(),
351
+ username: user.username ? String(user.username).trim() : null,
352
+ avatarUrl: /^(https?:\/\/|data:image\/)/i.test(avatar) ? avatar : null,
353
+ },
354
+ usage: {
355
+ balanceSeconds: nonNegativeInt(usage.balanceSeconds),
356
+ monthUsedSeconds: nonNegativeInt(usage.monthUsedSeconds),
357
+ totalUsedSeconds: nonNegativeInt(usage.totalUsedSeconds),
358
+ },
359
+ authorization: {
360
+ boundAt: typeof authorization.boundAt === 'string' ? authorization.boundAt : '',
361
+ days: nonNegativeInt(authorization.days),
362
+ },
363
+ serverTime: typeof data?.serverTime === 'string' ? data.serverTime : '',
364
+ };
365
+ }
366
+
307
367
  function defaultSleep(ms) {
308
368
  return new Promise((resolve) => {
309
369
  const timer = setTimeout(resolve, ms);
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@qzsy/dsh-bridge",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "DSH 远程隧道云官方客户端插件:一条命令把本机 DeepSeek Harness 接入云端隧道(设备码绑定、短期令牌、本地门禁代理、HTTP/SSE/WebSocket 反向隧道)。",
5
- "releaseNotes": "【v3.1.0】无感登录授权与扫码进入\\n• 面板新增「立即登录授权」:自动申请设备码、浏览器授权、绑定后自动开隧道,无需输入设备码或设置本地口令\\n• 新增扫码入口二维码:https://<控制面>/open/<publicId>,扫码登录 SaaS 账号即可进入隧道\\n• 网关信任头 x-qzsy-gate(HMAC-SHA256,±60 秒):验签通过即免本地口令,未验签隧道来源一律 401\\n• hello-ack 支持 gate.secret 每连接随机下发;改口令/断线自动清理信任密钥\\n• 本地口令降级为可选「高级」局域网能力",
5
+ "releaseNotes": "【v3.2.0】授权后显示账号与时长\\n• 面板新增账号卡片:授权成功后显示头像、昵称、邮箱/用户名\\n• 显示剩余时长、本月已用、累计已用与授权时间,每 60 秒自动刷新,也可手动刷新\\n• 控制面新增 POST /api/saas/device/profile(设备凭据鉴权,隔离边界仍为 device.userId)\\n• 头像加载失败回退首字母;相对路径头像由控制面补全为绝对地址",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
8
8
  "exports": {
@@ -68,7 +68,7 @@
68
68
  },
69
69
  "repository": {
70
70
  "type": "git",
71
- "url": "https://github.com/qzsy/dsh-remote.git",
71
+ "url": "git+https://github.com/qzsy/dsh-remote.git",
72
72
  "directory": "packages/dsh-bridge"
73
73
  },
74
74
  "homepage": "https://github.com/qzsy/dsh-remote#readme",