@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/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('');
@@ -158,6 +305,19 @@ function BridgePanel() {
158
305
  const openUrl = status?.openUrl ?? '';
159
306
  const passwordSet = Boolean(status?.access?.passwordSet);
160
307
  const stateText = connected ? t('ready') : (phase === 'connecting' || phase === 'reconnecting' ? t('connecting') : t('offline'));
308
+ // 在线态只展示一行「网关:地址」:把 wss://host/_gw/connect 归一成可点开的 web 地址。
309
+ const gatewayWebUrl = React.useMemo(() => {
310
+ const raw = String(status?.gatewayUrl || gatewayDraft || '').trim();
311
+ if (!raw) return '';
312
+ try {
313
+ const url = new URL(raw);
314
+ if (url.protocol === 'wss:') url.protocol = 'https:';
315
+ else if (url.protocol === 'ws:') url.protocol = 'http:';
316
+ return url.origin;
317
+ } catch {
318
+ return raw.replace(/\/+$/, '');
319
+ }
320
+ }, [status?.gatewayUrl, gatewayDraft]);
161
321
 
162
322
  return h('div', { style: styles.wrap },
163
323
  h('div', { style: { marginBottom: 12 } },
@@ -165,27 +325,55 @@ function BridgePanel() {
165
325
  h('div', { style: styles.muted, marginTop: 4 }, `${PACKAGE_NAME} · v${status?.version ?? '—'}`),
166
326
  ),
167
327
 
328
+ bound && adminToken ? h(AccountCard, {
329
+ profile,
330
+ loading: profileLoading,
331
+ error: profileError,
332
+ onRefresh: () => setProfileTick((value) => value + 1),
333
+ t,
334
+ }) : null,
335
+
336
+ // 网关卡片:在线时只留一行「网关:地址 + 新窗口打开」;掉线/连接中才展开
337
+ // 状态徽标与可编辑地址,避免正常状态下一屏塞满诊断信息。
168
338
  h('div', { style: styles.card },
169
- h('div', { style: styles.row },
170
- h('span', { style: styles.badge }, `${t('status')}: ${stateText}`),
171
- h('span', { style: styles.badge }, bound ? `${t('device')}: ${status?.device?.idMasked ?? ''}` : t('notBound')),
172
- status?.protocol ? h('span', { style: styles.badge }, `protocol v${status.protocol}`) : null,
173
- ),
174
- h('div', { style: { marginTop: 10 } },
175
- h('span', { style: styles.label }, t('gateway')),
176
- h('div', { style: styles.row },
177
- h('input', {
178
- style: styles.input,
179
- value: gatewayDraft,
180
- placeholder: 'wss://dsh.b.nps.qzsyzn.com/_gw/connect',
181
- onChange: (e) => setGatewayDraft(e.target.value),
182
- }),
183
- h('button', { style: styles.btnPrimary, disabled: busy === 'gateway' || !gatewayDraft, onClick: saveGateway }, t('save')),
184
- ),
185
- ),
186
- !adminToken ? h('div', { style: { ...styles.muted, marginTop: 8 } }, t('adminLocalOnly')) : null,
187
- error ? h('div', { style: { ...styles.error, marginTop: 10 } }, `${t('lastError')}: ${error}`) : null,
188
- notice ? h('div', { style: { ...styles.muted, marginTop: 8 } }, notice) : null,
339
+ connected
340
+ ? h('div', { style: styles.row },
341
+ h('span', { style: { ...styles.muted, whiteSpace: 'nowrap' } }, `${t('gatewayLabel')}:`),
342
+ h('a', {
343
+ href: gatewayWebUrl,
344
+ target: '_blank',
345
+ rel: 'noopener noreferrer',
346
+ style: { flex: 1, minWidth: 180, fontSize: 13, lineHeight: 1.6, wordBreak: 'break-all', color: 'inherit' },
347
+ }, gatewayWebUrl),
348
+ h('a', {
349
+ href: gatewayWebUrl,
350
+ target: '_blank',
351
+ rel: 'noopener noreferrer',
352
+ style: { ...styles.btn, textDecoration: 'none', whiteSpace: 'nowrap' },
353
+ }, t('openInNewWindow')),
354
+ )
355
+ : [
356
+ h('div', { style: styles.row },
357
+ h('span', { style: styles.badge }, `${t('status')}: ${stateText}`),
358
+ h('span', { style: styles.badge }, bound ? `${t('device')}: ${status?.device?.idMasked ?? ''}` : t('notBound')),
359
+ status?.protocol ? h('span', { style: styles.badge }, `protocol v${status.protocol}`) : null,
360
+ ),
361
+ h('div', { style: { marginTop: 10 } },
362
+ h('span', { style: styles.label }, t('gateway')),
363
+ h('div', { style: styles.row },
364
+ h('input', {
365
+ style: styles.input,
366
+ value: gatewayDraft,
367
+ placeholder: 'wss://dsh.b.nps.qzsyzn.com/_gw/connect',
368
+ onChange: (e) => setGatewayDraft(e.target.value),
369
+ }),
370
+ h('button', { style: styles.btnPrimary, disabled: busy === 'gateway' || !gatewayDraft, onClick: saveGateway }, t('save')),
371
+ ),
372
+ ),
373
+ !adminToken ? h('div', { style: { ...styles.muted, marginTop: 8 } }, t('adminLocalOnly')) : null,
374
+ error ? h('div', { style: { ...styles.error, marginTop: 10 } }, `${t('lastError')}: ${error}`) : null,
375
+ notice ? h('div', { style: { ...styles.muted, marginTop: 8 } }, notice) : null,
376
+ ],
189
377
  ),
190
378
 
191
379
  h('div', { style: styles.card },
@@ -3,6 +3,8 @@ export const zh = {
3
3
  title: '远程访问',
4
4
  subtitle: 'DSH 远程隧道云 · 官方客户端',
5
5
  gateway: 'SaaS 网关地址',
6
+ gatewayLabel: '网关',
7
+ openInNewWindow: '新窗口打开',
6
8
  save: '保存网关',
7
9
  saved: '已保存',
8
10
  device: '设备',
@@ -48,12 +50,29 @@ export const zh = {
48
50
  status: '状态',
49
51
  version: '版本',
50
52
  loadFailed: '无法连接插件 RPC',
53
+ authorized: '已授权',
54
+ balance: '剩余时长',
55
+ monthUsed: '本月已用',
56
+ totalUsed: '累计已用',
57
+ authorizedAt: '授权于 ${date}',
58
+ authorizedDays: '已授权 ${days} 天',
59
+ profileLoading: '正在读取账号信息…',
60
+ profileFailed: '账号信息读取失败',
61
+ accountUnknown: '账号',
62
+ refresh: '刷新',
63
+ refreshing: '刷新中…',
64
+ durationHours: '${hours} 小时',
65
+ durationHoursMinutes: '${hours} 小时 ${minutes} 分',
66
+ durationMinutes: '${minutes} 分钟',
67
+ durationUnderMinute: '不足 1 分钟',
51
68
  };
52
69
 
53
70
  export const en = {
54
71
  title: 'Remote Access',
55
72
  subtitle: 'DSH Remote Tunnel Cloud · Official Client',
56
73
  gateway: 'SaaS gateway URL',
74
+ gatewayLabel: 'Gateway',
75
+ openInNewWindow: 'Open in new window',
57
76
  save: 'Save gateway',
58
77
  saved: 'Saved',
59
78
  device: 'Device',
@@ -99,6 +118,21 @@ export const en = {
99
118
  status: 'Status',
100
119
  version: 'Version',
101
120
  loadFailed: 'Cannot reach plugin RPC',
121
+ authorized: 'Authorized',
122
+ balance: 'Time left',
123
+ monthUsed: 'Used this month',
124
+ totalUsed: 'Total used',
125
+ authorizedAt: 'Authorized ${date}',
126
+ authorizedDays: '${days} days since authorization',
127
+ profileLoading: 'Loading account…',
128
+ profileFailed: 'Failed to load account',
129
+ accountUnknown: 'Account',
130
+ refresh: 'Refresh',
131
+ refreshing: 'Refreshing…',
132
+ durationHours: '${hours} h',
133
+ durationHoursMinutes: '${hours} h ${minutes} min',
134
+ durationMinutes: '${minutes} min',
135
+ durationUnderMinute: '< 1 min',
102
136
  };
103
137
 
104
138
  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,
@@ -376,6 +377,21 @@ export async function apply(ctx, pluginConfig = {}) {
376
377
  }
377
378
  return ok({ url, token: config.access.token, qrDataUrl });
378
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
+ }
379
395
  case RPC_ENDPOINTS.setAccessPassword: {
380
396
  const password = String(payload?.password ?? '');
381
397
  if (password.length < 6) return fail('bad-request', '访问密码至少 6 位');
@@ -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);