@wenbin_wb/dsh-bridge 2.4.0 → 2.5.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/client/index.js CHANGED
@@ -72,6 +72,9 @@ const Icons = {
72
72
  tunnel: (props) => React.createElement('svg', { viewBox: '0 0 24 24', width: 16, height: 16, fill: 'currentColor', ...props },
73
73
  React.createElement('path', { d: 'M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM19 18H6c-2.21 0-4-1.79-4-4 0-2.05 1.53-3.76 3.56-3.97l1.07-.11.5-.95C8.08 7.14 9.94 6 12 6c2.62 0 4.88 1.86 5.39 4.43l.3 1.5 1.53.11c1.56.1 2.78 1.41 2.78 2.96 0 1.65-1.35 3-3 3z' })
74
74
  ),
75
+ security: (props) => React.createElement('svg', { viewBox: '0 0 24 24', width: 16, height: 16, fill: 'currentColor', ...props },
76
+ React.createElement('path', { d: 'M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z' })
77
+ ),
75
78
  bot: (props) => React.createElement('svg', { viewBox: '0 0 24 24', width: 16, height: 16, fill: 'currentColor', ...props },
76
79
  React.createElement('path', { d: 'M20 9V7c0-1.1-.9-2-2-2h-3c0-1.66-1.34-3-3-3S9 3.34 9 5H6c-1.1 0-2 .9-2 2v2c-1.66 0-3 1.34-3 3s1.34 3 3 3v4c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-4c1.66 0 3-1.34 3-3s-1.34-3-3-3zm-2 10H6V7h12v12zm-9-6c-.83 0-1.5-.67-1.5-1.5S8.17 10 9 10s1.5.67 1.5 1.5S9.83 13 9 13zm6 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z' })
77
80
  ),
@@ -154,7 +157,7 @@ function StatusTag({ running, status }) {
154
157
  }, text);
155
158
  }
156
159
 
157
- function QrBlock({ url, qr, onReset }) {
160
+ function QrBlock({ url, qr, onReset, auth, onNavigateSecurity }) {
158
161
  const [copied, setCopied] = React.useState(false);
159
162
  const [showQr, setShowQr] = React.useState(true);
160
163
 
@@ -191,10 +194,31 @@ function QrBlock({ url, qr, onReset }) {
191
194
  const toggleQr = React.useCallback(() => setShowQr(v => !v), []);
192
195
 
193
196
  return React.createElement('div', { style: { marginTop: 10 } },
194
- React.createElement('div', { style: s.warn },
195
- '⚠️ 请勿将此链接或二维码分享给他人,任何人扫码后都可直接访问您的 DSH。'
196
- ),
197
- React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 10 } },
197
+ auth?.enabled
198
+ ? React.createElement('div', {
199
+ style: {
200
+ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '4px 10px',
201
+ background: 'var(--dsw-alias-state-success-bg,#ecfdf5)',
202
+ border: '1px solid var(--dsw-alias-state-success-primary,#10b981)',
203
+ borderRadius: 8, fontSize: 12, color: 'var(--dsw-alias-state-success-primary,#059669)',
204
+ marginBottom: 8, fontWeight: 500,
205
+ cursor: onNavigateSecurity ? 'pointer' : 'default',
206
+ },
207
+ onClick: onNavigateSecurity,
208
+ title: onNavigateSecurity ? '点击前往「安全认证」配置' : undefined,
209
+ },
210
+ '🛡️ 访问安全认证已生效 · 扫码设备自动完成免密授权',
211
+ onNavigateSecurity && React.createElement('span', { style: { textDecoration: 'underline', fontSize: 11, marginLeft: 4 } }, '设置 ➔')
212
+ )
213
+ : React.createElement('div', {
214
+ style: { ...s.warn, cursor: onNavigateSecurity ? 'pointer' : 'default', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 6 },
215
+ onClick: onNavigateSecurity,
216
+ title: onNavigateSecurity ? '点击前往「安全认证」开启访问保护' : undefined,
217
+ },
218
+ React.createElement('span', null, '⚠️ 当前未开启访问认证,建议在「安全认证」开启密码或扫码保护。'),
219
+ onNavigateSecurity && React.createElement('span', { style: { fontWeight: 600, textDecoration: 'underline', fontSize: 12, color: 'var(--dsw-alias-brand-primary,#4f6ef7)' } }, '去开启 ➔')
220
+ ),
221
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 6 } },
198
222
  React.createElement('code', { style: { ...s.code, flex: 1 } }, url),
199
223
  React.createElement('button', {
200
224
  style: { ...s.btnGhost, height: 26, padding: '0 10px', fontSize: 12, flexShrink: 0 },
@@ -260,7 +284,7 @@ const CustomTunnelConfigForm = React.memo(function CustomTunnelConfigForm({ serv
260
284
  const handleTokenChange = React.useCallback((e) => { setAccessToken(e.target.value); setSaveSuccess(false); }, []);
261
285
 
262
286
  return React.createElement('div', { style: s.block },
263
- React.createElement('div', { style: { ...s.muted, marginBottom: 8 } }, '服务器配置'),
287
+ React.createElement('div', { style: { ...s.muted, marginBottom: 8 } }, '隧道服务器配置'),
264
288
  React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
265
289
  React.createElement('input', {
266
290
  style: s.input,
@@ -273,12 +297,15 @@ const CustomTunnelConfigForm = React.memo(function CustomTunnelConfigForm({ serv
273
297
  React.createElement('input', {
274
298
  style: s.input,
275
299
  type: 'password',
276
- placeholder: '访问令牌(Access Token)',
300
+ placeholder: '隧道服务端连接令牌(Tunnel Access Token)',
277
301
  value: accessToken,
278
302
  onChange: handleTokenChange,
279
303
  onKeyDown: (e) => { if (e.key === 'Enter' && dirty && !saving) handleSave(); },
280
304
  disabled: saving,
281
305
  }),
306
+ React.createElement('div', { style: { ...s.muted, fontSize: 11 } },
307
+ '💡 用于与您的 VPS 隧道服务端建立反向通道(与 Web 网页访客访问密码互相独立)。'
308
+ ),
282
309
  React.createElement('button', {
283
310
  style: {
284
311
  ...s.btnPri,
@@ -293,7 +320,7 @@ const CustomTunnelConfigForm = React.memo(function CustomTunnelConfigForm({ serv
293
320
  );
294
321
  });
295
322
 
296
- const TunnelCard = React.memo(function TunnelCard({ title, desc, data, onStart, onStop, onReset, children }) {
323
+ const TunnelCard = React.memo(function TunnelCard({ title, desc, data, onStart, onStop, onReset, auth, onNavigateSecurity, children }) {
297
324
  const { running, configured, url, qr, state } = data ?? {};
298
325
  const phase = state?.phase ?? 'idle';
299
326
 
@@ -312,7 +339,7 @@ const TunnelCard = React.memo(function TunnelCard({ title, desc, data, onStart,
312
339
  color: phase === 'error' ? 'var(--dsw-alias-state-error-primary,#dc2626)' : 'var(--dsw-alias-label-secondary,#6b7280)',
313
340
  },
314
341
  }, state?.detail ?? phase),
315
- url && React.createElement(QrBlock, { url, qr, onReset }),
342
+ url && React.createElement(QrBlock, { url, qr, onReset, auth, onNavigateSecurity }),
316
343
  (onStart || onStop) && React.createElement('div', {
317
344
  style: { ...s.block, display: 'flex', gap: 8, flexWrap: 'wrap' },
318
345
  },
@@ -327,6 +354,421 @@ const TunnelCard = React.memo(function TunnelCard({ title, desc, data, onStart,
327
354
  );
328
355
  });
329
356
 
357
+ // ---- 访问安全认证卡片 ----
358
+
359
+ const AccessAuthCard = React.memo(function AccessAuthCard({ auth, rpcCall, onUpdate }) {
360
+ const [enabled, setEnabled] = React.useState(auth?.enabled ?? false);
361
+ const [mode, setMode] = React.useState(auth?.mode ?? 'token_and_password');
362
+ const [scope, setScope] = React.useState(auth?.scope ?? 'all');
363
+ const [adminPolicy, setAdminPolicy] = React.useState(auth?.adminPolicy ?? 'password_unlock');
364
+
365
+ // 1. 访客访问密码状态
366
+ const [accessPassword, setAccessPassword] = React.useState('');
367
+ const [showAccessPassword, setShowAccessPassword] = React.useState(false);
368
+ const [savingAccess, setSavingAccess] = React.useState(false);
369
+ const [saveAccessSuccess, setSaveAccessSuccess] = React.useState(false);
370
+ const [msgAccess, setMsgAccess] = React.useState(null);
371
+
372
+ // 2. 后台管理密码状态
373
+ const [adminPassword, setAdminPassword] = React.useState('');
374
+ const [showAdminPassword, setShowAdminPassword] = React.useState(false);
375
+ const [savingAdmin, setSavingAdmin] = React.useState(false);
376
+ const [saveAdminSuccess, setSaveAdminSuccess] = React.useState(false);
377
+ const [msgAdmin, setMsgAdmin] = React.useState(null);
378
+
379
+ const [topMsg, setTopMsg] = React.useState(null);
380
+
381
+ React.useEffect(() => {
382
+ if (auth) {
383
+ setEnabled(auth.enabled ?? false);
384
+ setMode(auth.mode ?? 'token_and_password');
385
+ setScope(auth.scope ?? 'all');
386
+ setAdminPolicy(auth.adminPolicy ?? 'password_unlock');
387
+ }
388
+ }, [auth]);
389
+
390
+ const handleToggleEnabled = async () => {
391
+ const next = !enabled;
392
+ setEnabled(next);
393
+ try {
394
+ await rpcCall(BRIDGE_ENDPOINTS.authUpdateConfig, { enabled: next });
395
+ setTopMsg({ ok: true, text: next ? '✓ 访问安全认证已开启(现有登录态已刷新)' : '✓ 访问安全认证已关闭' });
396
+ onUpdate?.();
397
+ } catch (e) {
398
+ setTopMsg({ ok: false, text: e.message || '更新失败' });
399
+ }
400
+ };
401
+
402
+ const handleChangeMode = async (m) => {
403
+ setMode(m);
404
+ try {
405
+ await rpcCall(BRIDGE_ENDPOINTS.authUpdateConfig, { mode: m });
406
+ setTopMsg({ ok: true, text: '✓ 外部验证模式已切换,已刷新全域登录态' });
407
+ onUpdate?.();
408
+ } catch (e) {
409
+ setTopMsg({ ok: false, text: e.message || '更新失败' });
410
+ }
411
+ };
412
+
413
+ const handleChangeScope = async (sc) => {
414
+ setScope(sc);
415
+ try {
416
+ await rpcCall(BRIDGE_ENDPOINTS.authUpdateConfig, { scope: sc });
417
+ setTopMsg({ ok: true, text: '✓ 防护生效范围已更新' });
418
+ onUpdate?.();
419
+ } catch (e) {
420
+ setTopMsg({ ok: false, text: e.message || '更新失败' });
421
+ }
422
+ };
423
+
424
+ const handleChangeAdminPolicy = async (pol) => {
425
+ setAdminPolicy(pol);
426
+ try {
427
+ await rpcCall(BRIDGE_ENDPOINTS.authUpdateConfig, { adminPolicy: pol });
428
+ setTopMsg({ ok: true, text: '✓ 远程管理防篡改策略已更新' });
429
+ onUpdate?.();
430
+ } catch (e) {
431
+ setTopMsg({ ok: false, text: e.message || '更新失败' });
432
+ }
433
+ };
434
+
435
+ // 保存访客访问密码
436
+ const handleSaveAccessPassword = async () => {
437
+ setSavingAccess(true);
438
+ setMsgAccess(null);
439
+ try {
440
+ const res = await rpcCall(BRIDGE_ENDPOINTS.authUpdateConfig, { password: accessPassword });
441
+ if (res?.ok) {
442
+ setSaveAccessSuccess(true);
443
+ setMsgAccess({ ok: true, text: '✓ 访客访问密码已成功保存!原有的历史访客会话已全部安全刷新。' });
444
+ setAccessPassword('');
445
+ setTimeout(() => setSaveAccessSuccess(false), 3500);
446
+ onUpdate?.();
447
+ } else {
448
+ setMsgAccess({ ok: false, text: res?.error?.message || '保存失败' });
449
+ }
450
+ } catch (e) {
451
+ setMsgAccess({ ok: false, text: e.message || '保存失败' });
452
+ } finally {
453
+ setSavingAccess(false);
454
+ }
455
+ };
456
+
457
+ // 保存后台管理密码
458
+ const handleSaveAdminPassword = async () => {
459
+ setSavingAdmin(true);
460
+ setMsgAdmin(null);
461
+ try {
462
+ const res = await rpcCall(BRIDGE_ENDPOINTS.authUpdateConfig, { adminPassword });
463
+ if (res?.ok) {
464
+ setSaveAdminSuccess(true);
465
+ setMsgAdmin({ ok: true, text: '✓ 后台管理密码已成功保存!远程管理解锁状态已重置生效。' });
466
+ setAdminPassword('');
467
+ setTimeout(() => setSaveAdminSuccess(false), 3500);
468
+ onUpdate?.();
469
+ } else {
470
+ setMsgAdmin({ ok: false, text: res?.error?.message || '保存失败' });
471
+ }
472
+ } catch (e) {
473
+ setMsgAdmin({ ok: false, text: e.message || '保存失败' });
474
+ } finally {
475
+ setSavingAdmin(false);
476
+ }
477
+ };
478
+
479
+ const handleRegenerateToken = async () => {
480
+ if (!confirm('重置后,之前包含旧 Token 的二维码和分享链接将立即失效。是否确认重置?')) return;
481
+ try {
482
+ await rpcCall(BRIDGE_ENDPOINTS.authRegenerateToken, {});
483
+ setTopMsg({ ok: true, text: '✓ 安全 Token 已重置,二维码与专属链接已刷新' });
484
+ onUpdate?.();
485
+ } catch (e) {
486
+ setTopMsg({ ok: false, text: e.message || '重置失败' });
487
+ }
488
+ };
489
+
490
+ const scopeLabel = scope === 'all' ? '全部通道 (局域网+公网)' : scope === 'public_only' ? '仅公网隧道' : '仅局域网';
491
+ const modeLabel = mode === 'token_and_password' ? '扫码免密 + 密码' : mode === 'password_only' ? '仅密码登录' : '仅安全 Token';
492
+ const adminLabel = adminPolicy === 'password_unlock' ? '需密码解锁' : adminPolicy === 'local_only' ? '仅限电脑本机管理' : '宽松模式';
493
+
494
+ return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 14 } },
495
+ // ---- 顶部总控与状态概览卡片 ----
496
+ React.createElement('div', { style: s.card },
497
+ React.createElement('div', {
498
+ style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 },
499
+ },
500
+ React.createElement('div', { style: { flex: '1 1 260px' } },
501
+ React.createElement('div', { style: { ...s.label, fontSize: 15, display: 'flex', alignItems: 'center', gap: 8 } },
502
+ '🔐 全局访问安全防护体系',
503
+ ),
504
+ React.createElement('div', { style: { ...s.muted, marginTop: 4 } },
505
+ '集成外部访问门禁拦截与管理后台防篡改控制,双重守护远程会话与网络配置安全',
506
+ ),
507
+ ),
508
+ React.createElement('button', {
509
+ style: { ...(enabled ? s.btnPri : s.btnGhost), whiteSpace: 'nowrap', flexShrink: 0 },
510
+ onClick: handleToggleEnabled,
511
+ }, enabled ? '✓ 已启用安全防护' : '未开启安全防护'),
512
+ ),
513
+
514
+ enabled && React.createElement('div', {
515
+ style: {
516
+ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 14, paddingTop: 12,
517
+ borderTop: '1px solid var(--dsw-alias-border-l2,#e5e7eb)',
518
+ },
519
+ },
520
+ React.createElement('div', {
521
+ style: {
522
+ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '4px 10px',
523
+ borderRadius: 16, fontSize: 11, background: 'var(--dsw-alias-bg-layer-2,#f3f4f6)',
524
+ color: 'var(--dsw-alias-label-secondary,#4b5563)',
525
+ },
526
+ }, '🌐 保护范围: ', React.createElement('strong', { style: { color: 'var(--dsw-alias-brand-primary,#4f6ef7)' } }, scopeLabel)),
527
+ React.createElement('div', {
528
+ style: {
529
+ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '4px 10px',
530
+ borderRadius: 16, fontSize: 11, background: 'var(--dsw-alias-bg-layer-2,#f3f4f6)',
531
+ color: 'var(--dsw-alias-label-secondary,#4b5563)',
532
+ },
533
+ }, '🔑 外部验证: ', React.createElement('strong', { style: { color: 'var(--dsw-alias-brand-primary,#4f6ef7)' } }, modeLabel)),
534
+ React.createElement('div', {
535
+ style: {
536
+ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '4px 10px',
537
+ borderRadius: 16, fontSize: 11, background: 'var(--dsw-alias-bg-layer-2,#f3f4f6)',
538
+ color: 'var(--dsw-alias-label-secondary,#4b5563)',
539
+ },
540
+ }, '🔒 后台防篡改: ', React.createElement('strong', { style: { color: 'var(--dsw-alias-state-success-primary,#059669)' } }, adminLabel)),
541
+ ),
542
+
543
+ topMsg && React.createElement('div', {
544
+ style: {
545
+ marginTop: 12, padding: '8px 12px', borderRadius: 6, fontSize: 12,
546
+ background: topMsg.ok ? 'var(--dsw-alias-state-success-bg,#ecfdf5)' : 'var(--dsw-alias-state-error-bg,#fef2f2)',
547
+ color: topMsg.ok ? 'var(--dsw-alias-state-success-primary,#059669)' : 'var(--dsw-alias-state-error-primary,#dc2626)',
548
+ },
549
+ }, topMsg.text),
550
+ ),
551
+
552
+ enabled && React.createElement(React.Fragment, null,
553
+ // =========================================================================
554
+ // ---- 第一道防线:外部访问门禁(控制谁能进入 Web 界面使用 AI) ----
555
+ // =========================================================================
556
+ React.createElement('div', { style: s.card },
557
+ React.createElement('div', { style: { marginBottom: 14 } },
558
+ React.createElement('div', { style: { ...s.label, fontSize: 14, display: 'flex', alignItems: 'center', gap: 6 } },
559
+ '🛡️ 第一道防线:外部访问门禁(控制谁能使用 AI)',
560
+ ),
561
+ React.createElement('div', { style: { ...s.muted, marginTop: 3 } },
562
+ '控制外部设备通过局域网 IP 或公网隧道(Cloudflare/自建隧道)进入 DSH 聊天界面时的身份验证方式',
563
+ ),
564
+ ),
565
+
566
+ // 验证模式选择
567
+ React.createElement('div', { style: { marginBottom: 16 } },
568
+ React.createElement('label', { style: { ...s.label, display: 'block', marginBottom: 8, fontSize: 12 } }, '验证模式选择'),
569
+ React.createElement('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap' } },
570
+ [
571
+ { id: 'token_and_password', title: '🟢 扫码免密 + 密码认证 (推荐)', desc: '二维码自带专属 Token 扫码秒进;直接输 IP/公网域名需输密码' },
572
+ { id: 'password_only', title: '🔑 仅密码 / PIN 码登录', desc: '所有外部访问必须手动输入访问密码方可进入' },
573
+ { id: 'token_only', title: '🎫 仅安全 Token 免密', desc: '仅持有带安全 Token 的二维码或专属分享链接方可进入' },
574
+ ].map(opt => {
575
+ const isSel = mode === opt.id;
576
+ return React.createElement('div', {
577
+ key: opt.id,
578
+ onClick: () => handleChangeMode(opt.id),
579
+ style: {
580
+ flex: '1 1 200px',
581
+ padding: '10px 12px',
582
+ borderRadius: 8,
583
+ border: `1px solid ${isSel ? 'var(--dsw-alias-brand-primary,#4f6ef7)' : 'var(--dsw-alias-border-l2,#e5e7eb)'}`,
584
+ background: isSel ? 'var(--dsw-alias-state-info-bg,#eff6ff)' : 'var(--dsw-alias-bg-layer-2,#f9fafb)',
585
+ cursor: 'pointer',
586
+ transition: 'all 0.15s ease',
587
+ },
588
+ },
589
+ React.createElement('div', { style: { fontSize: 13, fontWeight: isSel ? 600 : 500, color: isSel ? 'var(--dsw-alias-brand-primary,#4f6ef7)' : 'var(--dsw-alias-label-primary,currentColor)' } }, opt.title),
590
+ React.createElement('div', { style: { ...s.muted, fontSize: 11, marginTop: 4 } }, opt.desc),
591
+ );
592
+ })
593
+ ),
594
+ ),
595
+
596
+ // 防护生效范围
597
+ React.createElement('div', { style: { marginBottom: 16 } },
598
+ React.createElement('label', { style: { ...s.label, display: 'block', marginBottom: 8, fontSize: 12 } }, '防护生效通道'),
599
+ React.createElement('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap' } },
600
+ [
601
+ { id: 'all', title: '全部通道防护 (推荐)', desc: '局域网 IP 直连与公网隧道全部受安全保护' },
602
+ { id: 'public_only', title: '仅公网隧道开启防护', desc: '局域网内设备直接免密直连,公网隧道强制验证' },
603
+ { id: 'lan_only', title: '仅局域网开启防护', desc: '仅局域网直连需验证,公网隧道不开启' },
604
+ ].map(opt => {
605
+ const isSel = scope === opt.id;
606
+ return React.createElement('div', {
607
+ key: opt.id,
608
+ onClick: () => handleChangeScope(opt.id),
609
+ style: {
610
+ flex: '1 1 180px',
611
+ padding: '9px 12px',
612
+ borderRadius: 8,
613
+ border: `1px solid ${isSel ? 'var(--dsw-alias-brand-primary,#4f6ef7)' : 'var(--dsw-alias-border-l2,#e5e7eb)'}`,
614
+ background: isSel ? 'var(--dsw-alias-state-info-bg,#eff6ff)' : 'var(--dsw-alias-bg-layer-2,#f9fafb)',
615
+ cursor: 'pointer',
616
+ transition: 'all 0.15s ease',
617
+ },
618
+ },
619
+ React.createElement('div', { style: { fontSize: 13, fontWeight: isSel ? 600 : 500, color: isSel ? 'var(--dsw-alias-brand-primary,#4f6ef7)' : 'var(--dsw-alias-label-primary,currentColor)' } }, opt.title),
620
+ React.createElement('div', { style: { ...s.muted, fontSize: 11, marginTop: 3 } }, opt.desc),
621
+ );
622
+ })
623
+ ),
624
+ ),
625
+
626
+ // 访客访问密码输入框 (当非 token_only 时展示)
627
+ mode !== 'token_only' && React.createElement('div', { style: { marginBottom: 16 } },
628
+ React.createElement('label', { style: { ...s.label, display: 'block', marginBottom: 6, fontSize: 12 } },
629
+ `设置外部访客访问密码 ${auth?.hasPassword ? '(✓ 已设置访客密码)' : '(⚠️ 尚未设置密码,直接输入 IP 将免密)'}`,
630
+ ),
631
+ React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
632
+ React.createElement('input', {
633
+ type: showAccessPassword ? 'text' : 'password',
634
+ style: { ...s.input, flex: 1 },
635
+ placeholder: auth?.hasPassword ? '输入新密码以修改(留空保存可清除访客密码)' : '设置外部访客访问密码 / PIN 码',
636
+ value: accessPassword,
637
+ onChange: e => setAccessPassword(e.target.value),
638
+ }),
639
+ React.createElement('button', {
640
+ style: { ...s.btnGhost, height: 32, fontSize: 12, whiteSpace: 'nowrap', flexShrink: 0 },
641
+ onClick: () => setShowAccessPassword(v => !v),
642
+ }, showAccessPassword ? '隐藏' : '显示'),
643
+ React.createElement('button', {
644
+ style: {
645
+ ...s.btnPri, height: 32, fontSize: 12, whiteSpace: 'nowrap', flexShrink: 0,
646
+ background: saveAccessSuccess ? '#059669' : 'var(--dsw-alias-brand-primary, #4f6ef7)',
647
+ color: '#ffffff',
648
+ cursor: savingAccess ? 'wait' : 'pointer',
649
+ },
650
+ onClick: handleSaveAccessPassword,
651
+ disabled: savingAccess,
652
+ }, savingAccess ? '保存中…' : saveAccessSuccess ? '✓ 已成功保存!' : '保存访问密码'),
653
+ ),
654
+ React.createElement('div', { style: { ...s.muted, fontSize: 11, marginTop: 4 } },
655
+ '💡 当外部朋友或同事未通过二维码扫码,而是直接输入 IP 或公网域名访问时,需输入此密码登录。'
656
+ ),
657
+ msgAccess && React.createElement('div', {
658
+ style: {
659
+ marginTop: 8, padding: '6px 12px', borderRadius: 6, fontSize: 12,
660
+ background: msgAccess.ok ? 'var(--dsw-alias-state-success-bg,#ecfdf5)' : 'var(--dsw-alias-state-error-bg,#fef2f2)',
661
+ color: msgAccess.ok ? 'var(--dsw-alias-state-success-primary,#059669)' : 'var(--dsw-alias-state-error-primary,#dc2626)',
662
+ },
663
+ }, msgAccess.text),
664
+ ),
665
+
666
+ // 免密 Token 管理
667
+ mode !== 'password_only' && React.createElement('div', { style: s.block },
668
+ React.createElement('label', { style: { ...s.label, display: 'block', marginBottom: 6, fontSize: 12 } }, '免密扫码 Token (专属访问凭据)'),
669
+ React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
670
+ React.createElement('code', { style: { ...s.code, flex: 1, padding: '6px 10px', background: 'var(--dsw-alias-bg-layer-1,#fff)', borderRadius: 6, border: '1px solid var(--dsw-alias-border-l2,#e5e7eb)' } },
671
+ auth?.secretToken ? `${auth.secretToken.slice(0, 10)}****************` : '未生成'
672
+ ),
673
+ React.createElement('button', {
674
+ style: { ...s.btnGhost, height: 32, fontSize: 12, whiteSpace: 'nowrap', flexShrink: 0 },
675
+ onClick: handleRegenerateToken,
676
+ title: '重新生成 Token,使之前分享的旧二维码和链接立即失效',
677
+ }, '🔄 重置安全 Token'),
678
+ ),
679
+ React.createElement('div', { style: { ...s.muted, fontSize: 11, marginTop: 4 } },
680
+ '💡 控制台生成的局域网与公网二维码已自动嵌入此 Token,手机扫码即可免密进入聊天界面(但不赋予后台管理设置权限)。'
681
+ ),
682
+ ),
683
+ ),
684
+
685
+ // =========================================================================
686
+ // ---- 第二道防线:后台管理防篡改(控制谁能修改本插件所有设置) ----
687
+ // =========================================================================
688
+ React.createElement('div', { style: s.card },
689
+ React.createElement('div', { style: { marginBottom: 14 } },
690
+ React.createElement('div', { style: { ...s.label, fontSize: 14, display: 'flex', alignItems: 'center', gap: 6 } },
691
+ '🔒 第二道防线:管理后台防篡改(控制谁能修改本插件所有设置)',
692
+ ),
693
+ React.createElement('div', { style: { ...s.muted, marginTop: 3 } },
694
+ '锁定整个插件设置后台(包含局域网、公网隧道、IM 机器人密钥与安全设置),防止他人随意篡改配置',
695
+ ),
696
+ ),
697
+
698
+ // 管理员密码设置
699
+ React.createElement('div', { style: { marginBottom: 16 } },
700
+ React.createElement('label', { style: { ...s.label, display: 'block', marginBottom: 6, fontSize: 12 } },
701
+ `设置独立管理员密码 ${auth?.hasAdminPassword ? '(✓ 已设置独立管理密码)' : '(未单独设置,默认使用上述访客访问密码)'}`,
702
+ ),
703
+ React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
704
+ React.createElement('input', {
705
+ type: showAdminPassword ? 'text' : 'password',
706
+ style: { ...s.input, flex: 1 },
707
+ placeholder: auth?.hasAdminPassword ? '输入新密码以修改(留空保存可清除独立管理密码)' : '设置后台管理解锁密码(建议与访客密码不同)',
708
+ value: adminPassword,
709
+ onChange: e => setAdminPassword(e.target.value),
710
+ }),
711
+ React.createElement('button', {
712
+ style: { ...s.btnGhost, height: 32, fontSize: 12, whiteSpace: 'nowrap', flexShrink: 0 },
713
+ onClick: () => setShowAdminPassword(v => !v),
714
+ }, showAdminPassword ? '隐藏' : '显示'),
715
+ React.createElement('button', {
716
+ style: {
717
+ ...s.btnPri, height: 32, fontSize: 12, whiteSpace: 'nowrap', flexShrink: 0,
718
+ background: saveAdminSuccess ? '#059669' : 'var(--dsw-alias-brand-primary, #4f6ef7)',
719
+ color: '#ffffff',
720
+ cursor: savingAdmin ? 'wait' : 'pointer',
721
+ },
722
+ onClick: handleSaveAdminPassword,
723
+ disabled: savingAdmin,
724
+ }, savingAdmin ? '保存中…' : saveAdminSuccess ? '✓ 已成功保存!' : '保存管理密码'),
725
+ ),
726
+ React.createElement('div', { style: { ...s.muted, fontSize: 11, marginTop: 4, color: 'var(--dsw-alias-brand-primary,#4f6ef7)' } },
727
+ '🔑 核心作用:用于远程设备进入设置后台时的解锁验证。设置后,即便把访问密码告知他人,他人也无法进入设置后台改配置。'
728
+ ),
729
+ msgAdmin && React.createElement('div', {
730
+ style: {
731
+ marginTop: 8, padding: '6px 12px', borderRadius: 6, fontSize: 12,
732
+ background: msgAdmin.ok ? 'var(--dsw-alias-state-success-bg,#ecfdf5)' : 'var(--dsw-alias-state-error-bg,#fef2f2)',
733
+ color: msgAdmin.ok ? 'var(--dsw-alias-state-success-primary,#059669)' : 'var(--dsw-alias-state-error-primary,#dc2626)',
734
+ },
735
+ }, msgAdmin.text),
736
+ ),
737
+
738
+ // 远程管理权限控制策略
739
+ React.createElement('div', { style: s.block },
740
+ React.createElement('label', { style: { ...s.label, display: 'block', marginBottom: 8, fontSize: 12 } }, '远程设备管理权限策略'),
741
+ React.createElement('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap' } },
742
+ [
743
+ { id: 'password_unlock', title: '🔒 需密码解锁 (推荐)', desc: '远程手机/外网打开本插件设置时默认全局锁定,输入管理密码解锁后方可使用' },
744
+ { id: 'local_only', title: '🚫 仅限电脑本机管理 (最严格)', desc: '远程设备彻底锁定整个设置后台,仅允许在 127.0.0.1 电脑本机上操作' },
745
+ { id: 'open', title: '🔓 宽松模式', desc: '任何已通过第一道防线登录的设备均可直接修改所有配置' },
746
+ ].map(opt => {
747
+ const isSel = adminPolicy === opt.id;
748
+ return React.createElement('div', {
749
+ key: opt.id,
750
+ onClick: () => handleChangeAdminPolicy(opt.id),
751
+ style: {
752
+ flex: '1 1 180px',
753
+ padding: '10px 12px',
754
+ borderRadius: 8,
755
+ border: `1px solid ${isSel ? 'var(--dsw-alias-brand-primary,#4f6ef7)' : 'var(--dsw-alias-border-l2,#e5e7eb)'}`,
756
+ background: isSel ? 'var(--dsw-alias-state-info-bg,#eff6ff)' : 'var(--dsw-alias-bg-layer-2,#f9fafb)',
757
+ cursor: 'pointer',
758
+ transition: 'all 0.15s ease',
759
+ },
760
+ },
761
+ React.createElement('div', { style: { fontSize: 13, fontWeight: isSel ? 600 : 500, color: isSel ? 'var(--dsw-alias-brand-primary,#4f6ef7)' : 'var(--dsw-alias-label-primary,currentColor)' } }, opt.title),
762
+ React.createElement('div', { style: { ...s.muted, fontSize: 11, marginTop: 4 } }, opt.desc),
763
+ );
764
+ })
765
+ ),
766
+ ),
767
+ ),
768
+ ),
769
+ );
770
+ });
771
+
330
772
  // ---- 通用 IM 平台卡片 ----
331
773
 
332
774
  function PlatformCard({ platformId, platformName, platformDesc, rpcCall, onStatusChange }) {
@@ -1063,9 +1505,10 @@ function VersionBanner({ rpcCall }) {
1063
1505
  // ---- Tab Bar ----
1064
1506
 
1065
1507
  const TABS = [
1066
- { id: 'lan', label: '局域网', icon: Icons.lan },
1067
- { id: 'tunnel', label: '公网隧道', icon: Icons.tunnel },
1068
- { id: 'im', label: 'IM 机器人', icon: Icons.bot },
1508
+ { id: 'lan', label: '局域网', icon: Icons.lan },
1509
+ { id: 'tunnel', label: '公网隧道', icon: Icons.tunnel },
1510
+ { id: 'im', label: 'IM 机器人', icon: Icons.bot },
1511
+ { id: 'security', label: '安全认证', icon: Icons.security },
1069
1512
  ];
1070
1513
 
1071
1514
  function TabBar({ active, onChange, dots }) {
@@ -1125,6 +1568,43 @@ function BridgePanel({ rpcCall }) {
1125
1568
  const [platforms, setPlatforms] = React.useState(null);
1126
1569
  const [selectedPlatform, setSelectedPlatform] = React.useState('wechat');
1127
1570
 
1571
+ // 远程设备管理权限解锁状态
1572
+ const isLocalhost = typeof window === 'undefined' || (
1573
+ !window.location.hostname ||
1574
+ window.location.hostname === '127.0.0.1' ||
1575
+ window.location.hostname === 'localhost' ||
1576
+ window.location.hostname === '::1' ||
1577
+ window.location.hostname === '' ||
1578
+ window.location.protocol === 'file:' ||
1579
+ window.location.protocol === 'vscode-webview:' ||
1580
+ window.location.protocol === 'app:' ||
1581
+ window.location.hostname.endsWith('.local')
1582
+ );
1583
+ const [adminUnlocked, setAdminUnlocked] = React.useState(false);
1584
+ const [unlockPassword, setUnlockPassword] = React.useState('');
1585
+ const [unlockErr, setUnlockErr] = React.useState(null);
1586
+ const [unlocking, setUnlocking] = React.useState(false);
1587
+ const [showForgotGuide, setShowForgotGuide] = React.useState(false);
1588
+
1589
+ const handleUnlockAdmin = React.useCallback(async (e) => {
1590
+ e?.preventDefault?.();
1591
+ setUnlocking(true);
1592
+ setUnlockErr(null);
1593
+ try {
1594
+ const res = await rpcCall(BRIDGE_ENDPOINTS.authAdminUnlock, { password: unlockPassword });
1595
+ if (res?.ok) {
1596
+ setAdminUnlocked(true);
1597
+ setUnlockPassword('');
1598
+ } else {
1599
+ setUnlockErr(res?.error?.message || '管理员密码错误');
1600
+ }
1601
+ } catch (err) {
1602
+ setUnlockErr(err.message || '解锁请求失败');
1603
+ } finally {
1604
+ setUnlocking(false);
1605
+ }
1606
+ }, [rpcCall, unlockPassword]);
1607
+
1128
1608
  const load = React.useCallback(async (quiet = false) => {
1129
1609
  try {
1130
1610
  const r = await rpcCall(BRIDGE_ENDPOINTS.getStatus, {});
@@ -1180,6 +1660,8 @@ function BridgePanel({ rpcCall }) {
1180
1660
  act(BRIDGE_ENDPOINTS.saveCustomTunnelConfig, { serverUrl, accessToken })
1181
1661
  , [act]);
1182
1662
 
1663
+ const navSecurity = React.useCallback(() => setActiveTab('security'), []);
1664
+
1183
1665
  if (!status && !err) {
1184
1666
  return React.createElement('div', {
1185
1667
  style: { padding: 32, color: 'var(--dsw-alias-label-tertiary,#9ca3af)', fontSize: 13 },
@@ -1193,9 +1675,10 @@ function BridgePanel({ rpcCall }) {
1193
1675
  p.status === 'connected' || p.status === 'starting' || p.status === 'reconnecting'
1194
1676
  );
1195
1677
  const dots = {
1196
- lan: !!(status?.proxy?.running),
1197
- tunnel: !!(status?.cloudflared?.running || ct?.running),
1198
- im: !!imConnected,
1678
+ lan: !!(status?.proxy?.running),
1679
+ tunnel: !!(status?.cloudflared?.running || ct?.running),
1680
+ im: !!imConnected,
1681
+ security: !!(status?.auth?.enabled),
1199
1682
  };
1200
1683
 
1201
1684
  // Tab 内容
@@ -1205,6 +1688,8 @@ function BridgePanel({ rpcCall }) {
1205
1688
  title: '局域网访问',
1206
1689
  desc: '同一 Wi-Fi 下的设备可直接扫码访问',
1207
1690
  data: { running: status?.proxy?.running, url: status?.lan?.url, qr: status?.lan?.qr },
1691
+ auth: status?.auth,
1692
+ onNavigateSecurity: navSecurity,
1208
1693
  });
1209
1694
  } else if (activeTab === 'tunnel') {
1210
1695
  tabContent = React.createElement(React.Fragment, null,
@@ -1217,6 +1702,8 @@ function BridgePanel({ rpcCall }) {
1217
1702
  qr: status?.cloudflared?.qr,
1218
1703
  state: status?.cloudflared?.state,
1219
1704
  },
1705
+ auth: status?.auth,
1706
+ onNavigateSecurity: navSecurity,
1220
1707
  onStart: onStartCloudflared,
1221
1708
  onStop: onStopCloudflared,
1222
1709
  onReset: status?.cloudflared?.running ? onResetCloudflared : null,
@@ -1231,6 +1718,8 @@ function BridgePanel({ rpcCall }) {
1231
1718
  qr: ct?.qr,
1232
1719
  state: ct?.state,
1233
1720
  },
1721
+ auth: status?.auth,
1722
+ onNavigateSecurity: navSecurity,
1234
1723
  onStart: onStartCustom,
1235
1724
  onStop: onStopCustom,
1236
1725
  },
@@ -1242,6 +1731,12 @@ function BridgePanel({ rpcCall }) {
1242
1731
  }),
1243
1732
  ),
1244
1733
  );
1734
+ } else if (activeTab === 'security') {
1735
+ tabContent = React.createElement(AccessAuthCard, {
1736
+ auth: status?.auth,
1737
+ rpcCall,
1738
+ onUpdate: () => load(true),
1739
+ });
1245
1740
  } else if (activeTab === 'im') {
1246
1741
  // 从 listPlatforms 动态生成平台列表
1247
1742
  const IM_PLATFORMS = [
@@ -1306,11 +1801,116 @@ function BridgePanel({ rpcCall }) {
1306
1801
  );
1307
1802
  }
1308
1803
 
1804
+ const auth = status?.auth;
1805
+ const policy = auth?.adminPolicy ?? 'password_unlock';
1806
+ const isLocked = !isLocalhost && auth?.enabled && policy !== 'open' && !adminUnlocked;
1807
+
1808
+ // 远程设备被锁定:全局展示锁定页面,阻断所有 Tab 的查看与操作
1809
+ if (isLocked) {
1810
+ return React.createElement('div', { style: { maxWidth: 620 } },
1811
+ policy === 'local_only' ? (
1812
+ React.createElement('div', {
1813
+ style: { ...s.card, textAlign: 'center', padding: '36px 20px', marginTop: 10 },
1814
+ },
1815
+ React.createElement('div', { style: { fontSize: 40, marginBottom: 12 } }, '🛡️'),
1816
+ React.createElement('div', { style: { ...s.label, fontSize: 16, fontWeight: 600, marginBottom: 8 } }, '管理控制台已锁定(仅限电脑本机管理)'),
1817
+ React.createElement('div', { style: { ...s.muted, maxWidth: 420, margin: '0 auto', lineHeight: 1.6, fontSize: 13 } },
1818
+ '当前设备通过远程局域网或公网接入。已开启「仅限电脑本机管理」最高安全策略,远程设备禁止查看与修改任何网络与机器人配置。如需管理请在电脑本机(127.0.0.1)上操作。'
1819
+ ),
1820
+ React.createElement('div', { style: { marginTop: 20 } },
1821
+ React.createElement('button', {
1822
+ type: 'button',
1823
+ style: { ...s.btnLink, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)' },
1824
+ onClick: () => setShowForgotGuide(v => !v),
1825
+ }, '❓ 远程如何救急解除锁定?'),
1826
+ ),
1827
+ showForgotGuide && React.createElement('div', {
1828
+ style: {
1829
+ marginTop: 14, padding: '12px 14px', borderRadius: 8, fontSize: 12, lineHeight: 1.6,
1830
+ background: 'var(--dsw-alias-bg-layer-2,#f3f4f6)', border: '1px solid var(--dsw-alias-border-l2,#e5e7eb)',
1831
+ color: 'var(--dsw-alias-label-secondary,#4b5563)', textAlign: 'left', maxWidth: 420, margin: '14px auto 0',
1832
+ },
1833
+ },
1834
+ React.createElement('div', { style: { fontWeight: 600, color: 'var(--dsw-alias-label-primary,currentColor)', marginBottom: 4 } }, '🛟 救急解除锁定指引:'),
1835
+ React.createElement('div', null, '1. ', React.createElement('strong', null, '电脑本机直连修改'), ':直接在运行本程序的电脑本机打开本控制台(127.0.0.1 享有物理免锁特权),可随时修改策略或清除密码。'),
1836
+ React.createElement('div', { style: { marginTop: 4 } }, '2. ', React.createElement('strong', null, '服务器救急指令'), ':在宿主电脑/服务器终端执行 ', React.createElement('code', { style: s.code }, 'touch ~/.dsh/dsh-bridge/reset-auth'), ' 即可瞬间清空密码恢复初始状态。'),
1837
+ ),
1838
+ )
1839
+ ) : (
1840
+ React.createElement('div', {
1841
+ style: { ...s.card, maxWidth: 440, margin: '20px auto', padding: '32px 24px' },
1842
+ },
1843
+ React.createElement('div', { style: { textAlign: 'center', marginBottom: 20 } },
1844
+ React.createElement('div', { style: { fontSize: 40, marginBottom: 10 } }, '🔒'),
1845
+ React.createElement('div', { style: { ...s.label, fontSize: 16, fontWeight: 600 } }, '管理控制台已锁定'),
1846
+ React.createElement('div', { style: { ...s.muted, fontSize: 12, marginTop: 6, lineHeight: 1.5 } },
1847
+ '当前设备为远程访问。为保护您的网络与平台配置安全,请输入管理员密码解锁管理权限。'
1848
+ ),
1849
+ ),
1850
+ React.createElement('form', {
1851
+ onSubmit: handleUnlockAdmin,
1852
+ style: { display: 'flex', flexDirection: 'column', gap: 12 },
1853
+ },
1854
+ React.createElement('input', {
1855
+ type: 'password',
1856
+ style: s.input,
1857
+ placeholder: '输入后台管理密码',
1858
+ value: unlockPassword,
1859
+ onChange: (e) => setUnlockPassword(e.target.value),
1860
+ autoFocus: true,
1861
+ }),
1862
+ unlockErr && React.createElement('div', {
1863
+ style: { fontSize: 12, color: 'var(--dsw-alias-state-error-primary,#dc2626)' },
1864
+ }, unlockErr),
1865
+ React.createElement('button', {
1866
+ type: 'submit',
1867
+ style: { ...s.btnPri, width: '100%', justifyContent: 'center', height: 36, background: '#4f6ef7', color: '#ffffff' },
1868
+ disabled: unlocking,
1869
+ }, unlocking ? '验证中…' : '解锁管理权限'),
1870
+ ),
1871
+ React.createElement('div', { style: { marginTop: 16, textAlign: 'center' } },
1872
+ React.createElement('button', {
1873
+ type: 'button',
1874
+ style: { ...s.btnLink, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)' },
1875
+ onClick: () => setShowForgotGuide(v => !v),
1876
+ }, '❓ 忘记后台管理密码?'),
1877
+ ),
1878
+ showForgotGuide && React.createElement('div', {
1879
+ style: {
1880
+ marginTop: 12, padding: '12px 14px', borderRadius: 8, fontSize: 12, lineHeight: 1.6,
1881
+ background: 'var(--dsw-alias-bg-layer-2,#f3f4f6)', border: '1px solid var(--dsw-alias-border-l2,#e5e7eb)',
1882
+ color: 'var(--dsw-alias-label-secondary,#4b5563)', textAlign: 'left',
1883
+ },
1884
+ },
1885
+ React.createElement('div', { style: { fontWeight: 600, color: 'var(--dsw-alias-label-primary,currentColor)', marginBottom: 4 } }, '🛟 找回与重置密码指引:'),
1886
+ React.createElement('div', null, '1. ', React.createElement('strong', null, '电脑本机直连修改'), ':直接在运行本程序的电脑本机打开本控制台(127.0.0.1 享有物理免锁特权),可随时修改管理密码。'),
1887
+ React.createElement('div', { style: { marginTop: 4 } }, '2. ', React.createElement('strong', null, '服务器救急指令'), ':在宿主电脑终端执行 ', React.createElement('code', { style: s.code }, 'touch ~/.dsh/dsh-bridge/reset-auth'), ' 即可瞬间清空密码恢复初始状态。'),
1888
+ ),
1889
+ )
1890
+ )
1891
+ );
1892
+ }
1893
+
1309
1894
  return React.createElement('div', { style: { maxWidth: 620 } },
1310
1895
  err && React.createElement('div', {
1311
1896
  style: { ...s.card, background: 'var(--dsw-alias-state-error-bg,#fef2f2)', color: 'var(--dsw-alias-state-error-primary,#dc2626)', fontSize: 13, marginBottom: 16 },
1312
1897
  }, err),
1313
1898
 
1899
+ !isLocalhost && adminUnlocked && React.createElement('div', {
1900
+ style: {
1901
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
1902
+ padding: '8px 14px', background: 'var(--dsw-alias-state-info-bg,#eff6ff)',
1903
+ border: '1px solid var(--dsw-alias-brand-primary,#4f6ef7)', borderRadius: 8,
1904
+ marginBottom: 14, fontSize: 12, color: 'var(--dsw-alias-brand-primary,#4f6ef7)',
1905
+ },
1906
+ },
1907
+ React.createElement('span', null, '🔓 管理员权限已解锁(当前临时会话有效)'),
1908
+ React.createElement('button', {
1909
+ style: { ...s.btnGhost, height: 24, fontSize: 11, padding: '0 8px' },
1910
+ onClick: () => setAdminUnlocked(false),
1911
+ }, '🔒 重新锁定后台'),
1912
+ ),
1913
+
1314
1914
  React.createElement(VersionBanner, { rpcCall }),
1315
1915
 
1316
1916
  React.createElement(TabBar, { active: activeTab, onChange: setActiveTab, dots }),