@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/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
|
|
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
|
-
|
|
170
|
-
h('
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
)
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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 },
|
package/client/locales.mjs
CHANGED
|
@@ -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
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 位');
|
package/lib/saas/device-auth.mjs
CHANGED
|
@@ -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);
|