@csntgao/uni-base 0.1.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.
Files changed (90) hide show
  1. package/.prettierignore +4 -0
  2. package/.prettierrc.json +6 -0
  3. package/AGENTS.md +519 -0
  4. package/CLAUDE.md +512 -0
  5. package/README.md +734 -0
  6. package/docs//346/225/260/345/255/227/345/221/230/345/267/245/345/256/214/346/225/264/346/212/275/345/261/211/346/216/245/345/205/245/350/257/264/346/230/216.md +110 -0
  7. package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/215/225/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +79 -0
  8. package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/244/232/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +93 -0
  9. package/docs//350/264/246/346/210/267/344/270/216/347/231/273/345/275/225/347/273/204/344/273/266/345/212/250/346/200/201/346/216/245/345/205/245/350/257/264/346/230/216.md +201 -0
  10. package/eslint.config.js +30 -0
  11. package/examples/vanilla/index.html +33 -0
  12. package/examples/vanilla/main.js +247 -0
  13. package/examples/vanilla/package.json +15 -0
  14. package/examples/vanilla/public/runtime-config.js +10 -0
  15. package/examples/vanilla/style.css +99 -0
  16. package/package.json +31 -0
  17. package/packages/core/package.json +30 -0
  18. package/packages/core/src/client.ts +1132 -0
  19. package/packages/core/src/customer-support-client.ts +825 -0
  20. package/packages/core/src/customer-support-types.ts +123 -0
  21. package/packages/core/src/index.ts +79 -0
  22. package/packages/core/src/notification-center-client.ts +462 -0
  23. package/packages/core/src/notification-center-types.ts +154 -0
  24. package/packages/core/src/protocol.ts +237 -0
  25. package/packages/core/src/types.ts +170 -0
  26. package/packages/core/tests/client.test.ts +760 -0
  27. package/packages/core/tests/customer-support-client.test.ts +576 -0
  28. package/packages/core/tests/fake-websocket.ts +55 -0
  29. package/packages/core/tests/notification-center-client.test.ts +467 -0
  30. package/packages/core/tests/protocol.test.ts +124 -0
  31. package/packages/core/tsconfig.json +7 -0
  32. package/packages/core/tsup.config.ts +11 -0
  33. package/packages/web-components/package.json +35 -0
  34. package/packages/web-components/scripts/verify-runtime-build.mjs +111 -0
  35. package/packages/web-components/src/account-context.ts +1540 -0
  36. package/packages/web-components/src/application-switcher-transport.ts +243 -0
  37. package/packages/web-components/src/application-switcher.ts +1103 -0
  38. package/packages/web-components/src/chat.ts +1754 -0
  39. package/packages/web-components/src/customer-support-chat.ts +862 -0
  40. package/packages/web-components/src/customer-support-transport.ts +269 -0
  41. package/packages/web-components/src/index.ts +256 -0
  42. package/packages/web-components/src/notification-center.ts +1066 -0
  43. package/packages/web-components/src/notification-popover.ts +615 -0
  44. package/packages/web-components/src/organization-multi-employee-selector.ts +1564 -0
  45. package/packages/web-components/src/organization-onboarding-transport.ts +54 -0
  46. package/packages/web-components/src/organization-onboarding.ts +1216 -0
  47. package/packages/web-components/src/organization-selector.ts +534 -0
  48. package/packages/web-components/src/organization-single-employee-selector.ts +114 -0
  49. package/packages/web-components/src/prototype-icons.ts +242 -0
  50. package/packages/web-components/src/safe-image-url.ts +29 -0
  51. package/packages/web-components/src/safe-markdown.ts +192 -0
  52. package/packages/web-components/src/uniplat-base-account-context-transport.ts +625 -0
  53. package/packages/web-components/src/uniplat-base-application-switcher-transport.ts +418 -0
  54. package/packages/web-components/src/uniplat-base-application-ticket.ts +254 -0
  55. package/packages/web-components/src/uniplat-base-identity-ticket.ts +202 -0
  56. package/packages/web-components/src/uniplat-base-notification-center-transport.ts +378 -0
  57. package/packages/web-components/src/uniplat-base-organization-multi-employee-selector-transport.ts +394 -0
  58. package/packages/web-components/src/uniplat-base-organization-onboarding-transport.ts +183 -0
  59. package/packages/web-components/src/uniplat-base-organization-single-employee-selector-transport.ts +14 -0
  60. package/packages/web-components/src/user-login-transport.ts +563 -0
  61. package/packages/web-components/src/user-login.ts +2027 -0
  62. package/packages/web-components/src/user-menu.ts +880 -0
  63. package/packages/web-components/tests/account-context.test.ts +798 -0
  64. package/packages/web-components/tests/application-switcher.test.ts +562 -0
  65. package/packages/web-components/tests/chat.test.ts +1130 -0
  66. package/packages/web-components/tests/customer-support-chat.test.ts +428 -0
  67. package/packages/web-components/tests/customer-support-transport.test.ts +79 -0
  68. package/packages/web-components/tests/notification-center-transport.test.ts +271 -0
  69. package/packages/web-components/tests/notification-center.test.ts +429 -0
  70. package/packages/web-components/tests/notification-popover.test.ts +283 -0
  71. package/packages/web-components/tests/organization-multi-employee-selector-transport.test.ts +332 -0
  72. package/packages/web-components/tests/organization-multi-employee-selector.test.ts +225 -0
  73. package/packages/web-components/tests/organization-onboarding.test.ts +260 -0
  74. package/packages/web-components/tests/organization-selector.test.ts +127 -0
  75. package/packages/web-components/tests/organization-single-employee-selector.test.ts +145 -0
  76. package/packages/web-components/tests/uniplat-base-account-context-transport.test.ts +496 -0
  77. package/packages/web-components/tests/uniplat-base-application-switcher-transport.test.ts +598 -0
  78. package/packages/web-components/tests/uniplat-base-application-ticket.test.ts +233 -0
  79. package/packages/web-components/tests/uniplat-base-identity-ticket.test.ts +226 -0
  80. package/packages/web-components/tests/uniplat-base-organization-onboarding-transport.test.ts +227 -0
  81. package/packages/web-components/tests/user-login-transport.test.ts +443 -0
  82. package/packages/web-components/tests/user-login.test.ts +626 -0
  83. package/packages/web-components/tests/user-menu.test.ts +271 -0
  84. package/packages/web-components/tsconfig.json +8 -0
  85. package/packages/web-components/tsup.config.ts +12 -0
  86. package/packages/web-components/tsup.runtime.config.ts +37 -0
  87. package/pnpm-workspace.yaml +6 -0
  88. package/tsconfig.base.json +21 -0
  89. package/vitest.config.ts +20 -0
  90. package//344/272/244/346/216/245/346/226/207/346/241/243.md +254 -0
@@ -0,0 +1,2027 @@
1
+ import { LitElement, css, html, nothing } from 'lit'
2
+ import {
3
+ UserLoginTransportError,
4
+ type UserLoginQrStatus,
5
+ type UserLoginSuccess,
6
+ type UserLoginTransport,
7
+ } from './user-login-transport'
8
+
9
+ export type UserLoginMethod = 'password' | 'verification-code' | 'qr-code'
10
+
11
+ export type UserLoginErrorCode =
12
+ | 'CAPTCHA_LOAD_FAILED'
13
+ | 'SMS_SEND_FAILED'
14
+ | 'SMS_CODE_INVALID_OR_EXPIRED'
15
+ | 'LOGIN_FAILED'
16
+ | 'QR_LOAD_FAILED'
17
+ | 'QR_POLL_FAILED'
18
+ | 'PASSWORD_INVALID'
19
+ | 'ACCOUNT_LOCKED'
20
+ | 'ACCOUNT_DISABLED'
21
+ | 'ORG_ACCESS_DENIED'
22
+ | 'QR_LOGIN_REJECTED'
23
+ | 'QR_LOGIN_UNAVAILABLE'
24
+
25
+ export interface UserLoginErrorDetail {
26
+ code: UserLoginErrorCode
27
+ message: string
28
+ recoverable: boolean
29
+ }
30
+
31
+ export interface UserLoginEventMap {
32
+ 'sms-sent': CustomEvent<Record<string, never>>
33
+ 'login-success': CustomEvent<UserLoginSuccess>
34
+ 'onboarding-requested': CustomEvent<{
35
+ applicationName: string
36
+ trigger: 'automatic' | 'manual'
37
+ }>
38
+ 'agreement-requested': CustomEvent<{ type: 'user' | 'privacy' }>
39
+ 'forgot-password-requested': CustomEvent<Record<string, never>>
40
+ error: CustomEvent<UserLoginErrorDetail>
41
+ }
42
+
43
+ export interface UserLoginConfig {
44
+ transport: UserLoginTransport
45
+ applicationName: string
46
+ headerTitle?: string
47
+ headerSubtitle?: string
48
+ headerIconUrl?: string
49
+ securityHint?: string
50
+ userAgreementUrl?: string
51
+ privacyAgreementUrl?: string
52
+ forgotPasswordUrl?: string
53
+ countdownSeconds?: number
54
+ qrPollIntervalMs?: number
55
+ initialMethod?: UserLoginMethod
56
+ initiallyAgreed?: boolean
57
+ initiallyRememberSession?: boolean
58
+ }
59
+
60
+ interface ResolvedUserLoginConfig {
61
+ transport: UserLoginTransport
62
+ applicationName: string
63
+ userAgreementUrl: string
64
+ privacyAgreementUrl: string
65
+ forgotPasswordUrl: string
66
+ countdownSeconds: number
67
+ qrPollIntervalMs: number
68
+ }
69
+
70
+ interface FormErrors {
71
+ mobile: string
72
+ smsCode: string
73
+ password: string
74
+ agreement: string
75
+ global: string
76
+ }
77
+
78
+ interface UserLoginBranding {
79
+ headerTitle: string
80
+ headerSubtitle: string
81
+ headerIconUrl: string
82
+ securityHint: string
83
+ }
84
+
85
+ type QrViewState = 'idle' | 'loading' | UserLoginQrStatus | 'error'
86
+
87
+ const EMPTY_ERRORS: FormErrors = {
88
+ mobile: '',
89
+ smsCode: '',
90
+ password: '',
91
+ agreement: '',
92
+ global: '',
93
+ }
94
+
95
+ const LOGIN_METHODS: ReadonlyArray<{ method: UserLoginMethod; label: string }> = [
96
+ { method: 'password', label: '密码登录' },
97
+ { method: 'verification-code', label: '验证码登录' },
98
+ { method: 'qr-code', label: '扫码登录' },
99
+ ]
100
+
101
+ export class UniplatUserLogin extends LitElement {
102
+ static override properties = {
103
+ activeMethod: { state: true },
104
+ mobile: { state: true },
105
+ smsCode: { state: true },
106
+ password: { state: true },
107
+ passwordVisible: { state: true },
108
+ agreed: { state: true },
109
+ rememberSession: { state: true },
110
+ sendingSms: { state: true },
111
+ loggingIn: { state: true },
112
+ countdown: { state: true },
113
+ errors: { state: true },
114
+ statusMessage: { state: true },
115
+ branding: { state: true },
116
+ qrState: { state: true },
117
+ qrImageUrl: { state: true },
118
+ registrationSucceeded: { state: true },
119
+ }
120
+
121
+ static override styles = css`
122
+ :host {
123
+ --uniplat-user-login-width: 400px;
124
+ --uniplat-user-login-primary: #2e6bf5;
125
+ --uniplat-user-login-text: #182634;
126
+ --uniplat-user-login-muted: #7a8794;
127
+ --uniplat-user-login-placeholder: #a9b3bf;
128
+ --uniplat-user-login-background: #ffffff;
129
+ --uniplat-user-login-input-background: #ffffff;
130
+ --uniplat-user-login-error: #e5484d;
131
+ --uniplat-user-login-font-family:
132
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
133
+ display: block;
134
+ width: min(var(--uniplat-user-login-width), 100%);
135
+ color: var(--uniplat-user-login-text);
136
+ font-family: var(--uniplat-user-login-font-family);
137
+ line-height: 1.4;
138
+ }
139
+
140
+ * {
141
+ box-sizing: border-box;
142
+ }
143
+
144
+ button,
145
+ input {
146
+ font: inherit;
147
+ }
148
+
149
+ .shell {
150
+ display: flex;
151
+ width: 100%;
152
+ overflow: hidden;
153
+ border: 1px solid #e4ebf3;
154
+ border-radius: 16px;
155
+ background: var(--uniplat-user-login-background);
156
+ box-shadow: 0 8px 24px rgb(20 40 80 / 6%);
157
+ flex-direction: column;
158
+ }
159
+
160
+ .header {
161
+ display: grid;
162
+ min-height: 72px;
163
+ grid-template-columns: 42px minmax(0, 1fr);
164
+ gap: 12px;
165
+ align-items: center;
166
+ padding: 14px 18px;
167
+ border-bottom: 1px solid #edf1f6;
168
+ background: #fafcfe;
169
+ order: 1;
170
+ }
171
+
172
+ .header.no-icon {
173
+ grid-template-columns: minmax(0, 1fr);
174
+ }
175
+
176
+ .header-icon {
177
+ width: 42px;
178
+ height: 42px;
179
+ border-radius: 12px;
180
+ object-fit: contain;
181
+ }
182
+
183
+ .header-copy {
184
+ min-width: 0;
185
+ }
186
+
187
+ .header-title,
188
+ .header-subtitle {
189
+ overflow: hidden;
190
+ text-overflow: ellipsis;
191
+ white-space: nowrap;
192
+ }
193
+
194
+ .header-title {
195
+ color: var(--uniplat-user-login-text);
196
+ font-size: 16px;
197
+ font-weight: 800;
198
+ line-height: 22px;
199
+ }
200
+
201
+ .header-subtitle {
202
+ margin-top: 2px;
203
+ color: var(--uniplat-user-login-muted);
204
+ font-size: 12.5px;
205
+ line-height: 18px;
206
+ }
207
+
208
+ .form {
209
+ display: flex;
210
+ width: 100%;
211
+ padding: 22px 24px 24px;
212
+ flex-direction: column;
213
+ order: 2;
214
+ }
215
+
216
+ .tabs {
217
+ display: flex;
218
+ gap: 18px;
219
+ border-bottom: 1px solid #edf1f6;
220
+ }
221
+
222
+ .tab {
223
+ position: relative;
224
+ min-width: 0;
225
+ padding: 0 0 10px;
226
+ border: 0;
227
+ background: transparent;
228
+ color: #98a4b2;
229
+ cursor: pointer;
230
+ font-size: 14.5px;
231
+ line-height: 20px;
232
+ white-space: nowrap;
233
+ }
234
+
235
+ .tab.selected {
236
+ color: var(--uniplat-user-login-text);
237
+ font-weight: 800;
238
+ }
239
+
240
+ .tab.selected::after {
241
+ position: absolute;
242
+ right: 0;
243
+ bottom: -1px;
244
+ left: 0;
245
+ height: 2px;
246
+ border-radius: 2px;
247
+ background: var(--uniplat-user-login-primary);
248
+ content: '';
249
+ }
250
+
251
+ .tab:disabled {
252
+ color: #98a4b2;
253
+ cursor: not-allowed;
254
+ }
255
+
256
+ .method-panel {
257
+ display: flex;
258
+ gap: 12px;
259
+ padding-top: 18px;
260
+ flex-direction: column;
261
+ }
262
+
263
+ .field {
264
+ min-height: 0;
265
+ }
266
+
267
+ .input-row {
268
+ display: flex;
269
+ width: 100%;
270
+ height: 44px;
271
+ overflow: hidden;
272
+ align-items: center;
273
+ border: 1px solid #e2e9f3;
274
+ border-radius: 9px;
275
+ background: var(--uniplat-user-login-input-background);
276
+ transition:
277
+ border-color 0.16s ease,
278
+ box-shadow 0.16s ease;
279
+ }
280
+
281
+ .input-row:hover,
282
+ .input-row:focus-within {
283
+ border-color: #8eaff9;
284
+ box-shadow: 0 0 0 3px rgb(46 107 245 / 10%);
285
+ }
286
+
287
+ .input-row.error {
288
+ border-color: var(--uniplat-user-login-error);
289
+ box-shadow: none;
290
+ }
291
+
292
+ .country-code {
293
+ padding-left: 12px;
294
+ color: #5a6673;
295
+ font-size: 13.5px;
296
+ font-weight: 600;
297
+ white-space: nowrap;
298
+ }
299
+
300
+ .country-divider {
301
+ width: 1px;
302
+ height: 16px;
303
+ margin: 0 8px;
304
+ flex: none;
305
+ background: #e2e9f3;
306
+ }
307
+
308
+ .input {
309
+ width: 100%;
310
+ min-width: 0;
311
+ height: 100%;
312
+ padding: 0 12px;
313
+ border: 0;
314
+ outline: 0;
315
+ background: transparent;
316
+ color: #25303f;
317
+ font-size: 13.5px;
318
+ }
319
+
320
+ .phone-input {
321
+ padding-left: 0;
322
+ }
323
+
324
+ .input::placeholder {
325
+ color: var(--uniplat-user-login-placeholder);
326
+ }
327
+
328
+ .error-text {
329
+ display: flex;
330
+ gap: 5px;
331
+ align-items: center;
332
+ margin-top: 6px;
333
+ color: var(--uniplat-user-login-error);
334
+ font-size: 12px;
335
+ line-height: 16px;
336
+ }
337
+
338
+ .error-dot {
339
+ display: grid;
340
+ width: 13px;
341
+ height: 13px;
342
+ flex: none;
343
+ border-radius: 50%;
344
+ background: currentcolor;
345
+ color: #fff;
346
+ font-size: 9px;
347
+ font-weight: 800;
348
+ place-items: center;
349
+ }
350
+
351
+ .send-button,
352
+ .countdown {
353
+ display: grid;
354
+ width: 100%;
355
+ height: 44px;
356
+ padding: 0 8px;
357
+ flex: none;
358
+ border: 0;
359
+ border-radius: 9px;
360
+ font-size: 13px;
361
+ font-weight: 700;
362
+ text-align: center;
363
+ white-space: nowrap;
364
+ place-items: center;
365
+ }
366
+
367
+ .sms-row {
368
+ display: flex;
369
+ gap: 10px;
370
+ }
371
+
372
+ .sms-row .input-row {
373
+ min-width: 0;
374
+ flex: 1;
375
+ }
376
+
377
+ .sms-action {
378
+ width: 96px;
379
+ height: 44px;
380
+ flex: none;
381
+ }
382
+
383
+ .send-button {
384
+ background: #f4f7fc;
385
+ color: var(--uniplat-user-login-primary);
386
+ cursor: pointer;
387
+ }
388
+
389
+ .send-button:disabled,
390
+ .countdown {
391
+ background: #f4f7fc;
392
+ color: #98a4b2;
393
+ cursor: not-allowed;
394
+ }
395
+
396
+ .password-toggle {
397
+ display: grid;
398
+ width: 42px;
399
+ height: 42px;
400
+ padding: 0;
401
+ flex: none;
402
+ border: 0;
403
+ background: transparent;
404
+ color: #98a4b2;
405
+ cursor: pointer;
406
+ font-size: 16px;
407
+ place-items: center;
408
+ }
409
+
410
+ .options,
411
+ .agreement {
412
+ display: flex;
413
+ color: var(--uniplat-user-login-muted);
414
+ font-size: 12.5px;
415
+ line-height: 20px;
416
+ }
417
+
418
+ .options {
419
+ align-items: center;
420
+ justify-content: space-between;
421
+ margin-top: 1px;
422
+ }
423
+
424
+ .check-label {
425
+ display: inline-flex;
426
+ gap: 6px;
427
+ align-items: center;
428
+ cursor: pointer;
429
+ }
430
+
431
+ .checkbox {
432
+ position: relative;
433
+ width: 15px;
434
+ height: 15px;
435
+ margin: 0;
436
+ appearance: none;
437
+ border: 1.5px solid #c4ceda;
438
+ border-radius: 4px;
439
+ background: #fff;
440
+ cursor: pointer;
441
+ }
442
+
443
+ .checkbox:checked {
444
+ border-color: var(--uniplat-user-login-primary);
445
+ background: var(--uniplat-user-login-primary);
446
+ }
447
+
448
+ .checkbox.error {
449
+ border-color: var(--uniplat-user-login-error);
450
+ }
451
+
452
+ .checkbox:checked::after {
453
+ position: absolute;
454
+ top: 1px;
455
+ left: 4px;
456
+ width: 4px;
457
+ height: 8px;
458
+ border: solid #fff;
459
+ border-width: 0 1.5px 1.5px 0;
460
+ content: '';
461
+ transform: rotate(45deg);
462
+ }
463
+
464
+ .options .checkbox {
465
+ width: 14px;
466
+ height: 14px;
467
+ }
468
+
469
+ .text-button,
470
+ .agreement-link {
471
+ padding: 0;
472
+ border: 0;
473
+ background: transparent;
474
+ color: var(--uniplat-user-login-primary);
475
+ cursor: pointer;
476
+ text-decoration: none;
477
+ }
478
+
479
+ .agreement {
480
+ gap: 8px;
481
+ align-items: flex-start;
482
+ margin-top: 2px;
483
+ }
484
+
485
+ .agreement .checkbox {
486
+ margin-top: 3px;
487
+ }
488
+
489
+ .agreement-copy {
490
+ display: inline;
491
+ }
492
+
493
+ .agreement-link {
494
+ margin: 0 2px;
495
+ white-space: nowrap;
496
+ }
497
+
498
+ .submit {
499
+ display: flex;
500
+ width: 100%;
501
+ height: 46px;
502
+ align-items: center;
503
+ justify-content: center;
504
+ margin-top: 4px;
505
+ border: 0;
506
+ border-radius: 10px;
507
+ background: var(--uniplat-user-login-primary);
508
+ color: #fff;
509
+ cursor: pointer;
510
+ font-size: 15px;
511
+ font-weight: 700;
512
+ transition: opacity 0.16s ease;
513
+ }
514
+
515
+ .submit:hover:not(:disabled) {
516
+ opacity: 0.92;
517
+ }
518
+
519
+ .submit:disabled {
520
+ background: #a9c2f6;
521
+ cursor: not-allowed;
522
+ }
523
+
524
+ .helper {
525
+ margin-top: 0;
526
+ color: #98a4b2;
527
+ font-size: 12.5px;
528
+ line-height: 18px;
529
+ text-align: center;
530
+ }
531
+
532
+ .verification-panel .submit {
533
+ margin-top: 6px;
534
+ }
535
+
536
+ .verification-panel.has-errors {
537
+ gap: 16px;
538
+ padding-top: 16px;
539
+ }
540
+
541
+ .verification-panel.has-errors .submit {
542
+ margin-top: 0;
543
+ }
544
+
545
+ .global-message {
546
+ min-height: 18px;
547
+ margin-top: 10px;
548
+ color: var(--uniplat-user-login-muted);
549
+ font-size: 12px;
550
+ line-height: 18px;
551
+ text-align: center;
552
+ }
553
+
554
+ .global-message.error {
555
+ color: var(--uniplat-user-login-error);
556
+ }
557
+
558
+ .qr-panel {
559
+ gap: 0;
560
+ padding-top: 16px;
561
+ align-items: center;
562
+ }
563
+
564
+ .qr-frame {
565
+ position: relative;
566
+ display: grid;
567
+ width: 172px;
568
+ height: 172px;
569
+ overflow: hidden;
570
+ border: 1px solid #e4ebf3;
571
+ border-radius: 12px;
572
+ background: #fafcfe;
573
+ place-items: center;
574
+ }
575
+
576
+ .qr-image {
577
+ width: 150px;
578
+ height: 150px;
579
+ object-fit: contain;
580
+ }
581
+
582
+ .qr-placeholder {
583
+ color: #98a4b2;
584
+ font-size: 12px;
585
+ }
586
+
587
+ .qr-overlay {
588
+ position: absolute;
589
+ display: flex;
590
+ inset: 0;
591
+ align-items: center;
592
+ justify-content: center;
593
+ padding: 18px;
594
+ border: 0;
595
+ background: rgb(255 255 255 / 92%);
596
+ color: var(--uniplat-user-login-text);
597
+ cursor: default;
598
+ font-size: 13px;
599
+ font-weight: 700;
600
+ line-height: 20px;
601
+ text-align: center;
602
+ }
603
+
604
+ button.qr-overlay.retry {
605
+ color: var(--uniplat-user-login-primary);
606
+ cursor: pointer;
607
+ }
608
+
609
+ .qr-heading {
610
+ margin-top: 14px;
611
+ color: var(--uniplat-user-login-text);
612
+ font-size: 13.5px;
613
+ font-weight: 700;
614
+ line-height: 20px;
615
+ text-align: center;
616
+ }
617
+
618
+ .qr-steps {
619
+ display: flex;
620
+ width: 100%;
621
+ gap: 7px;
622
+ margin: 10px 0 0;
623
+ padding: 0;
624
+ color: var(--uniplat-user-login-muted);
625
+ flex-direction: column;
626
+ font-size: 12.5px;
627
+ line-height: 1.6;
628
+ list-style: none;
629
+ }
630
+
631
+ .qr-step {
632
+ display: flex;
633
+ gap: 8px;
634
+ }
635
+
636
+ .step-number {
637
+ display: grid;
638
+ width: 17px;
639
+ height: 17px;
640
+ margin-top: 2px;
641
+ flex: none;
642
+ border-radius: 50%;
643
+ background: #edf1f6;
644
+ color: #5a6673;
645
+ font-size: 11px;
646
+ font-weight: 700;
647
+ place-items: center;
648
+ }
649
+
650
+ .qr-tip {
651
+ display: flex;
652
+ width: 100%;
653
+ gap: 6px;
654
+ align-items: center;
655
+ margin-top: 12px;
656
+ padding: 8px 12px;
657
+ border-radius: 8px;
658
+ background: #eaf1fe;
659
+ color: var(--uniplat-user-login-primary);
660
+ font-size: 12.5px;
661
+ line-height: 20px;
662
+ }
663
+
664
+ .qr-tip-icon {
665
+ position: relative;
666
+ width: 10px;
667
+ height: 15px;
668
+ flex: none;
669
+ border: 1.4px solid currentcolor;
670
+ border-radius: 2px;
671
+ }
672
+
673
+ .qr-tip-icon::after {
674
+ position: absolute;
675
+ bottom: 1px;
676
+ left: 3px;
677
+ width: 2px;
678
+ height: 1px;
679
+ border-radius: 1px;
680
+ background: currentcolor;
681
+ content: '';
682
+ }
683
+
684
+ .form.success-form {
685
+ padding: 34px 24px;
686
+ }
687
+
688
+ .success-panel {
689
+ display: flex;
690
+ width: 100%;
691
+ align-items: center;
692
+ flex-direction: column;
693
+ text-align: center;
694
+ }
695
+
696
+ .success-icon {
697
+ display: grid;
698
+ width: 62px;
699
+ height: 62px;
700
+ border-radius: 50%;
701
+ background: #e3f7ef;
702
+ color: #0ca678;
703
+ place-items: center;
704
+ }
705
+
706
+ .success-title {
707
+ margin-top: 16px;
708
+ color: var(--uniplat-user-login-text);
709
+ font-size: 18px;
710
+ font-weight: 800;
711
+ line-height: 25px;
712
+ }
713
+
714
+ .success-description {
715
+ margin-top: 8px;
716
+ color: #61708a;
717
+ font-size: 13.5px;
718
+ line-height: 1.75;
719
+ }
720
+
721
+ .success-countdown {
722
+ display: flex;
723
+ gap: 7px;
724
+ align-items: center;
725
+ margin-top: 18px;
726
+ color: #98a4b2;
727
+ font-size: 12.5px;
728
+ line-height: 18px;
729
+ }
730
+
731
+ .success-spinner {
732
+ width: 15px;
733
+ height: 15px;
734
+ border: 1.5px solid #d4dce6;
735
+ border-top-color: #98a4b2;
736
+ border-radius: 50%;
737
+ animation: onboarding-spin 0.8s linear infinite;
738
+ }
739
+
740
+ .onboarding-button {
741
+ display: flex;
742
+ width: 100%;
743
+ height: 44px;
744
+ align-items: center;
745
+ justify-content: center;
746
+ margin-top: 18px;
747
+ border: 0;
748
+ border-radius: 10px;
749
+ background: var(--uniplat-user-login-primary);
750
+ color: #fff;
751
+ cursor: pointer;
752
+ font-size: 14.5px;
753
+ font-weight: 700;
754
+ }
755
+
756
+ @keyframes onboarding-spin {
757
+ to {
758
+ transform: rotate(360deg);
759
+ }
760
+ }
761
+
762
+ .security-footer {
763
+ display: flex;
764
+ min-height: 38px;
765
+ gap: 8px;
766
+ align-items: center;
767
+ justify-content: center;
768
+ padding: 10px 18px;
769
+ border-top: 1px solid #edf1f6;
770
+ color: var(--uniplat-user-login-placeholder);
771
+ font-size: 12px;
772
+ line-height: 18px;
773
+ order: 3;
774
+ text-align: center;
775
+ }
776
+
777
+ .security-icon {
778
+ position: relative;
779
+ width: 14px;
780
+ height: 10px;
781
+ margin-top: 4px;
782
+ flex: none;
783
+ border: 1.3px solid currentcolor;
784
+ border-radius: 2px;
785
+ }
786
+
787
+ .security-icon::before {
788
+ position: absolute;
789
+ bottom: 8px;
790
+ left: 2px;
791
+ width: 8px;
792
+ height: 6px;
793
+ border: 1.3px solid currentcolor;
794
+ border-bottom: 0;
795
+ border-radius: 6px 6px 0 0;
796
+ content: '';
797
+ }
798
+
799
+ .tab:focus-visible,
800
+ .send-button:focus-visible,
801
+ .password-toggle:focus-visible,
802
+ .text-button:focus-visible,
803
+ .agreement-link:focus-visible,
804
+ .submit:focus-visible,
805
+ .qr-overlay:focus-visible,
806
+ .onboarding-button:focus-visible {
807
+ outline: 2px solid var(--uniplat-user-login-primary);
808
+ outline-offset: 2px;
809
+ }
810
+
811
+ @media (max-width: 420px) {
812
+ .form {
813
+ padding-right: 18px;
814
+ padding-left: 18px;
815
+ }
816
+
817
+ .tabs {
818
+ gap: 10px;
819
+ }
820
+
821
+ .tab {
822
+ font-size: 13.5px;
823
+ }
824
+ }
825
+
826
+ @media (prefers-reduced-motion: reduce) {
827
+ .input-row,
828
+ .submit {
829
+ transition: none;
830
+ }
831
+
832
+ .success-spinner {
833
+ animation: none;
834
+ }
835
+ }
836
+ `
837
+
838
+ declare private activeMethod: UserLoginMethod
839
+ declare private mobile: string
840
+ declare private smsCode: string
841
+ declare private password: string
842
+ declare private passwordVisible: boolean
843
+ declare private agreed: boolean
844
+ declare private rememberSession: boolean
845
+ declare private sendingSms: boolean
846
+ declare private loggingIn: boolean
847
+ declare private countdown: number
848
+ declare private errors: FormErrors
849
+ declare private statusMessage: string
850
+ declare private branding: UserLoginBranding
851
+ declare private qrState: QrViewState
852
+ declare private qrImageUrl: string
853
+ declare private registrationSucceeded: boolean
854
+
855
+ private config: ResolvedUserLoginConfig | null = null
856
+ private countdownTimer: number | undefined
857
+ private qrChallengeId = ''
858
+ private qrPollIntervalMs = 0
859
+ private qrGeneration = 0
860
+ private qrPollTimer: number | undefined
861
+ private onboardingTimer: number | undefined
862
+ private onboardingRequested = false
863
+
864
+ constructor() {
865
+ super()
866
+ this.activeMethod = 'verification-code'
867
+ this.mobile = ''
868
+ this.smsCode = ''
869
+ this.password = ''
870
+ this.passwordVisible = false
871
+ this.agreed = false
872
+ this.rememberSession = true
873
+ this.sendingSms = false
874
+ this.loggingIn = false
875
+ this.countdown = 0
876
+ this.errors = { ...EMPTY_ERRORS }
877
+ this.statusMessage = ''
878
+ this.branding = {
879
+ headerTitle: '',
880
+ headerSubtitle: '',
881
+ headerIconUrl: '',
882
+ securityHint: '',
883
+ }
884
+ this.qrState = 'idle'
885
+ this.qrImageUrl = ''
886
+ this.registrationSucceeded = false
887
+ }
888
+
889
+ configure(config: UserLoginConfig): void {
890
+ if (
891
+ !config ||
892
+ !config.transport ||
893
+ typeof config.transport.sendSmsCode !== 'function' ||
894
+ typeof config.transport.login !== 'function'
895
+ ) {
896
+ throw new TypeError('user-login requires a complete verification-code transport')
897
+ }
898
+ const countdownSeconds = config.countdownSeconds ?? 60
899
+ if (!Number.isInteger(countdownSeconds) || countdownSeconds < 1 || countdownSeconds > 300) {
900
+ throw new TypeError('user-login countdownSeconds must be an integer from 1 to 300')
901
+ }
902
+ const qrPollIntervalMs = config.qrPollIntervalMs ?? 2_000
903
+ if (
904
+ !Number.isInteger(qrPollIntervalMs) ||
905
+ qrPollIntervalMs < 500 ||
906
+ qrPollIntervalMs > 10_000
907
+ ) {
908
+ throw new TypeError('user-login qrPollIntervalMs must be an integer from 500 to 10000')
909
+ }
910
+ const nextBranding: UserLoginBranding = {
911
+ headerTitle: normalizeDisplayText(config.headerTitle, 80),
912
+ headerSubtitle: normalizeDisplayText(config.headerSubtitle, 160),
913
+ headerIconUrl: safeConfiguredImageUrl(config.headerIconUrl),
914
+ securityHint: normalizeDisplayText(config.securityHint, 200),
915
+ }
916
+ this.stopQrFlow()
917
+ this.clearCountdown()
918
+ this.clearOnboardingTimer()
919
+ this.config = {
920
+ transport: config.transport,
921
+ applicationName: normalizeApplicationName(config.applicationName),
922
+ userAgreementUrl: safeNavigationUrl(config.userAgreementUrl, 'Agreement URL'),
923
+ privacyAgreementUrl: safeNavigationUrl(config.privacyAgreementUrl, 'Agreement URL'),
924
+ forgotPasswordUrl: safeNavigationUrl(config.forgotPasswordUrl, 'Forgot-password URL'),
925
+ countdownSeconds,
926
+ qrPollIntervalMs,
927
+ }
928
+ this.branding = nextBranding
929
+ this.activeMethod = this.resolveInitialMethod(normalizeLoginMethod(config.initialMethod))
930
+ this.mobile = ''
931
+ this.smsCode = ''
932
+ this.password = ''
933
+ this.passwordVisible = false
934
+ this.agreed = config.initiallyAgreed ?? false
935
+ this.rememberSession = config.initiallyRememberSession ?? true
936
+ this.qrState = 'idle'
937
+ this.qrImageUrl = ''
938
+ this.registrationSucceeded = false
939
+ this.onboardingRequested = false
940
+ this.errors = { ...EMPTY_ERRORS }
941
+ this.statusMessage = ''
942
+ if (this.isConnected) this.startActiveMethod()
943
+ }
944
+
945
+ override connectedCallback(): void {
946
+ super.connectedCallback()
947
+ if (this.registrationSucceeded) this.startOnboardingTimer()
948
+ else if (this.config) this.startActiveMethod()
949
+ }
950
+
951
+ override disconnectedCallback(): void {
952
+ this.clearCountdown()
953
+ this.clearOnboardingTimer()
954
+ this.stopQrFlow()
955
+ const passwordInput =
956
+ this.shadowRoot?.querySelector<HTMLInputElement>('#uniplat-login-password')
957
+ if (passwordInput) passwordInput.value = ''
958
+ this.password = ''
959
+ this.passwordVisible = false
960
+ super.disconnectedCallback()
961
+ }
962
+
963
+ override render() {
964
+ const hasHeader = Boolean(
965
+ this.branding.headerTitle || this.branding.headerSubtitle || this.branding.headerIconUrl,
966
+ )
967
+ return html`
968
+ <section class="shell" part="shell">
969
+ ${hasHeader ? this.renderHeader() : nothing}
970
+ ${
971
+ this.branding.securityHint
972
+ ? html`<div class="security-footer" part="security-footer">
973
+ <span class="security-icon" part="security-icon" aria-hidden="true"></span>
974
+ <span>${this.branding.securityHint}</span>
975
+ </div>`
976
+ : nothing
977
+ }
978
+ <form
979
+ class=${`form${this.registrationSucceeded ? ' success-form' : ''}`}
980
+ part="form"
981
+ novalidate
982
+ @submit=${this.submitLogin}
983
+ >
984
+ ${
985
+ this.registrationSucceeded
986
+ ? this.renderRegistrationSuccess()
987
+ : html`${this.renderTabs()} ${this.renderActivePanel()}`
988
+ }
989
+ </form>
990
+ </section>
991
+ `
992
+ }
993
+
994
+ private renderHeader() {
995
+ return html`
996
+ <header class=${`header${this.branding.headerIconUrl ? '' : ' no-icon'}`} part="header">
997
+ ${
998
+ this.branding.headerIconUrl
999
+ ? html`<img
1000
+ class="header-icon"
1001
+ part="header-icon"
1002
+ src=${this.branding.headerIconUrl}
1003
+ alt=""
1004
+ />`
1005
+ : nothing
1006
+ }
1007
+ <div class="header-copy">
1008
+ ${
1009
+ this.branding.headerTitle
1010
+ ? html`<div class="header-title" part="header-title">
1011
+ ${this.branding.headerTitle}
1012
+ </div>`
1013
+ : nothing
1014
+ }
1015
+ ${
1016
+ this.branding.headerSubtitle
1017
+ ? html`<div class="header-subtitle" part="header-subtitle">
1018
+ ${this.branding.headerSubtitle}
1019
+ </div>`
1020
+ : nothing
1021
+ }
1022
+ </div>
1023
+ </header>
1024
+ `
1025
+ }
1026
+
1027
+ private renderRegistrationSuccess() {
1028
+ return html`
1029
+ <div class="success-panel" part="registration-success" role="status" aria-live="polite">
1030
+ <span class="success-icon" part="registration-success-icon" aria-hidden="true">
1031
+ <svg
1032
+ width="32"
1033
+ height="32"
1034
+ viewBox="0 0 24 24"
1035
+ fill="none"
1036
+ stroke="currentColor"
1037
+ stroke-width="1.8"
1038
+ stroke-linecap="round"
1039
+ stroke-linejoin="round"
1040
+ >
1041
+ <circle cx="12" cy="12" r="9"></circle>
1042
+ <path d="m8 12 2.6 2.6L16.5 9"></path>
1043
+ </svg>
1044
+ </span>
1045
+ <div class="success-title">注册成功,欢迎加入亲亲企服</div>
1046
+ <div class="success-description">接下来创建你的团队 / 企业,即可开始办理业务</div>
1047
+ <div class="success-countdown">
1048
+ <span class="success-spinner" aria-hidden="true"></span>
1049
+ <span>2 秒后自动进入入驻引导</span>
1050
+ </div>
1051
+ <button
1052
+ class="onboarding-button"
1053
+ part="onboarding-button"
1054
+ type="button"
1055
+ @click=${this.requestOnboardingManually}
1056
+ >
1057
+ 立即开始
1058
+ </button>
1059
+ </div>
1060
+ `
1061
+ }
1062
+
1063
+ private renderTabs() {
1064
+ return html`
1065
+ <div class="tabs" part="title tabs" role="tablist" aria-label="登录方式">
1066
+ ${LOGIN_METHODS.map(({ method, label }) => {
1067
+ const available = this.isMethodAvailable(method)
1068
+ return html`
1069
+ <button
1070
+ class=${`tab${this.activeMethod === method ? ' selected' : ''}`}
1071
+ part="tab"
1072
+ type="button"
1073
+ role="tab"
1074
+ aria-selected=${String(this.activeMethod === method)}
1075
+ title=${available ? '' : '当前 Host 未配置此登录方式'}
1076
+ ?disabled=${!available}
1077
+ @click=${() => this.selectMethod(method)}
1078
+ >
1079
+ ${label}
1080
+ </button>
1081
+ `
1082
+ })}
1083
+ </div>
1084
+ `
1085
+ }
1086
+
1087
+ private renderActivePanel() {
1088
+ if (this.activeMethod === 'password') return this.renderPasswordPanel()
1089
+ if (this.activeMethod === 'qr-code') return this.renderQrPanel()
1090
+ return this.renderVerificationPanel()
1091
+ }
1092
+
1093
+ private renderPasswordPanel() {
1094
+ const busy = this.loggingIn || this.sendingSms
1095
+ const canSubmit = Boolean(this.isMobileValid() && this.password.length >= 6 && !busy)
1096
+ return html`
1097
+ <div class="method-panel" part="method-panel" role="tabpanel">
1098
+ ${this.renderMobileField()}
1099
+ <div class="field" part="field">
1100
+ <div class=${`input-row${this.errors.password ? ' error' : ''}`}>
1101
+ <input
1102
+ id="uniplat-login-password"
1103
+ class="input"
1104
+ part="input"
1105
+ type=${this.passwordVisible ? 'text' : 'password'}
1106
+ autocomplete="current-password"
1107
+ maxlength="128"
1108
+ placeholder="请输入密码"
1109
+ aria-label="密码"
1110
+ aria-invalid=${String(Boolean(this.errors.password))}
1111
+ .value=${this.password}
1112
+ @input=${this.updatePassword}
1113
+ @blur=${this.validatePasswordField}
1114
+ />
1115
+ <button
1116
+ class="password-toggle"
1117
+ part="password-toggle"
1118
+ type="button"
1119
+ aria-label=${this.passwordVisible ? '隐藏密码' : '显示密码'}
1120
+ @click=${this.togglePasswordVisibility}
1121
+ >
1122
+ ${this.renderPasswordVisibilityIcon()}
1123
+ </button>
1124
+ </div>
1125
+ ${this.renderFieldError(this.errors.password)}
1126
+ </div>
1127
+ <div class="options" part="login-options">
1128
+ <label class="check-label">
1129
+ <input
1130
+ class="checkbox"
1131
+ type="checkbox"
1132
+ .checked=${this.rememberSession}
1133
+ @change=${this.updateRememberSession}
1134
+ />
1135
+ <span>7 天内免登录</span>
1136
+ </label>
1137
+ <button
1138
+ class="text-button"
1139
+ part="forgot-password"
1140
+ type="button"
1141
+ @click=${this.openForgotPassword}
1142
+ >
1143
+ 忘记密码
1144
+ </button>
1145
+ </div>
1146
+ <button class="submit" part="submit-button" type="submit" ?disabled=${!canSubmit}>
1147
+ ${this.loggingIn ? '登录中…' : '登录'}
1148
+ </button>
1149
+ ${this.renderGlobalMessage()}
1150
+ </div>
1151
+ `
1152
+ }
1153
+
1154
+ private renderVerificationPanel() {
1155
+ const busy = this.sendingSms || this.loggingIn
1156
+ const hasValidationErrors = Boolean(
1157
+ this.errors.mobile || this.errors.smsCode || this.errors.agreement,
1158
+ )
1159
+ const canSubmit = Boolean(this.agreed && this.isMobileValid() && this.isSmsCodeValid() && !busy)
1160
+ return html`
1161
+ <div
1162
+ class=${`method-panel verification-panel${hasValidationErrors ? ' has-errors' : ''}`}
1163
+ part="method-panel"
1164
+ role="tabpanel"
1165
+ >
1166
+ ${this.renderMobileField()}
1167
+ <div class="field" part="field">
1168
+ <div class="sms-row" part="sms-row">
1169
+ <div class=${`input-row${this.errors.smsCode ? ' error' : ''}`}>
1170
+ <input
1171
+ id="uniplat-login-sms-code"
1172
+ class="input"
1173
+ part="input"
1174
+ type="text"
1175
+ inputmode="numeric"
1176
+ autocomplete="one-time-code"
1177
+ maxlength="6"
1178
+ placeholder="请输入 6 位验证码"
1179
+ aria-label="手机验证码"
1180
+ aria-invalid=${String(Boolean(this.errors.smsCode))}
1181
+ .value=${this.smsCode}
1182
+ @input=${this.updateSmsCode}
1183
+ @blur=${this.validateSmsCodeField}
1184
+ />
1185
+ </div>
1186
+ <div class="sms-action">
1187
+ <button
1188
+ class="send-button"
1189
+ part="send-button"
1190
+ type="button"
1191
+ ?disabled=${busy || this.countdown > 0 || !this.config || !this.isMobileValid()}
1192
+ @click=${this.sendSmsCode}
1193
+ >
1194
+ ${
1195
+ this.sendingSms
1196
+ ? '发送中…'
1197
+ : this.countdown > 0
1198
+ ? `${this.countdown}s 后重发`
1199
+ : '获取验证码'
1200
+ }
1201
+ </button>
1202
+ </div>
1203
+ </div>
1204
+ ${this.renderFieldError(this.errors.smsCode)}
1205
+ </div>
1206
+ <div class="agreement-field">
1207
+ <div class="agreement" part="agreement">
1208
+ <input
1209
+ class=${`checkbox${this.errors.agreement ? ' error' : ''}`}
1210
+ type="checkbox"
1211
+ aria-label="同意用户服务协议和隐私政策"
1212
+ aria-invalid=${String(Boolean(this.errors.agreement))}
1213
+ .checked=${this.agreed}
1214
+ @change=${this.updateAgreement}
1215
+ @blur=${this.validateAgreementField}
1216
+ />
1217
+ <span class="agreement-copy">
1218
+ 我已阅读并同意 ${this.renderAgreementLink('user', '《用户服务协议》')} 与
1219
+ ${this.renderAgreementLink('privacy', '《隐私政策》')}
1220
+ </span>
1221
+ </div>
1222
+ ${this.renderFieldError(this.errors.agreement)}
1223
+ </div>
1224
+ <button class="submit" part="submit-button" type="submit" ?disabled=${!canSubmit}>
1225
+ ${this.loggingIn ? '登录中…' : '注册 / 登录'}
1226
+ </button>
1227
+ ${hasValidationErrors ? nothing : html`<div class="helper">未注册的手机号将自动创建账号</div>`}
1228
+ ${this.renderGlobalMessage()}
1229
+ </div>
1230
+ `
1231
+ }
1232
+
1233
+ private renderMobileField() {
1234
+ return html`
1235
+ <div class="field" part="field">
1236
+ <div class=${`input-row${this.errors.mobile ? ' error' : ''}`}>
1237
+ <span class="country-code">+86</span>
1238
+ <span class="country-divider" aria-hidden="true"></span>
1239
+ <input
1240
+ id="uniplat-login-mobile"
1241
+ class="input phone-input"
1242
+ part="input"
1243
+ type="tel"
1244
+ inputmode="numeric"
1245
+ autocomplete="tel"
1246
+ maxlength="11"
1247
+ placeholder="请输入手机号"
1248
+ aria-label="手机号"
1249
+ aria-invalid=${String(Boolean(this.errors.mobile))}
1250
+ .value=${this.mobile}
1251
+ @input=${this.updateMobile}
1252
+ @blur=${this.validateMobileField}
1253
+ />
1254
+ </div>
1255
+ ${this.renderFieldError(this.errors.mobile)}
1256
+ </div>
1257
+ `
1258
+ }
1259
+
1260
+ private renderQrPanel() {
1261
+ const overlay = this.renderQrOverlay()
1262
+ return html`
1263
+ <div class="method-panel qr-panel" part="method-panel qr-panel" role="tabpanel">
1264
+ <div class="qr-frame" part="qr-code">
1265
+ ${
1266
+ this.qrImageUrl
1267
+ ? html`<img class="qr-image" src=${this.qrImageUrl} alt="登录二维码" />`
1268
+ : html`<span class="qr-placeholder"
1269
+ >${this.qrState === 'loading' ? '加载中…' : ''}</span
1270
+ >`
1271
+ }
1272
+ ${overlay}
1273
+ </div>
1274
+ <div class="qr-heading">
1275
+ ${this.qrState === 'scanned' ? '扫码成功,请在手机上确认' : '请使用微信扫描小程序码登录'}
1276
+ </div>
1277
+ <ol class="qr-steps">
1278
+ <li class="qr-step">
1279
+ <span class="step-number">1</span><span>打开微信右上角「扫一扫」</span>
1280
+ </li>
1281
+ <li class="qr-step">
1282
+ <span class="step-number">2</span><span>扫描上方小程序码,未登录时先完成登录</span>
1283
+ </li>
1284
+ <li class="qr-step">
1285
+ <span class="step-number">3</span><span>在小程序确认后,电脑端同步登录</span>
1286
+ </li>
1287
+ </ol>
1288
+ <div class="qr-tip" part="qr-tip">
1289
+ <span class="qr-tip-icon" aria-hidden="true"></span>
1290
+ <span>扫码将直接打开「亲亲企服」小程序</span>
1291
+ </div>
1292
+ ${this.renderGlobalMessage()}
1293
+ </div>
1294
+ `
1295
+ }
1296
+
1297
+ private renderQrOverlay() {
1298
+ if (this.qrState === 'expired') {
1299
+ return html`<button
1300
+ class="qr-overlay retry"
1301
+ part="qr-overlay"
1302
+ type="button"
1303
+ @click=${this.startQrChallenge}
1304
+ >
1305
+ 二维码已失效<br />点击刷新
1306
+ </button>`
1307
+ }
1308
+ if (this.qrState === 'error') {
1309
+ return html`<button
1310
+ class="qr-overlay retry"
1311
+ part="qr-overlay"
1312
+ type="button"
1313
+ @click=${this.startQrChallenge}
1314
+ >
1315
+ 加载失败<br />点击重试
1316
+ </button>`
1317
+ }
1318
+ if (this.qrState === 'rejected') {
1319
+ return html`<button
1320
+ class="qr-overlay retry"
1321
+ part="qr-overlay"
1322
+ type="button"
1323
+ @click=${this.startQrChallenge}
1324
+ >
1325
+ 登录已取消<br />点击刷新
1326
+ </button>`
1327
+ }
1328
+ if (this.qrState === 'scanned') {
1329
+ return html`<div class="qr-overlay" part="qr-overlay">✓<br />等待手机确认</div>`
1330
+ }
1331
+ if (this.qrState === 'confirmed') {
1332
+ return html`<div class="qr-overlay" part="qr-overlay">✓<br />登录成功</div>`
1333
+ }
1334
+ return nothing
1335
+ }
1336
+
1337
+ private renderPasswordVisibilityIcon() {
1338
+ return this.passwordVisible
1339
+ ? html`<svg
1340
+ aria-hidden="true"
1341
+ width="16"
1342
+ height="16"
1343
+ viewBox="0 0 24 24"
1344
+ fill="none"
1345
+ stroke="currentColor"
1346
+ stroke-width="2"
1347
+ stroke-linecap="round"
1348
+ stroke-linejoin="round"
1349
+ >
1350
+ <path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z"></path>
1351
+ <circle cx="12" cy="12" r="3"></circle>
1352
+ </svg>`
1353
+ : html`<svg
1354
+ aria-hidden="true"
1355
+ width="16"
1356
+ height="16"
1357
+ viewBox="0 0 24 24"
1358
+ fill="none"
1359
+ stroke="currentColor"
1360
+ stroke-width="2"
1361
+ stroke-linecap="round"
1362
+ stroke-linejoin="round"
1363
+ >
1364
+ <path d="m3 3 18 18"></path>
1365
+ <path d="M10.6 10.7a2 2 0 0 0 2.7 2.7"></path>
1366
+ <path d="M9.9 4.2A10.5 10.5 0 0 1 12 4c6.5 0 10 8 10 8a18.8 18.8 0 0 1-2.1 3.2"></path>
1367
+ <path d="M6.6 6.6C3.6 8.6 2 12 2 12s3.5 8 10 8a9.8 9.8 0 0 0 4.1-.9"></path>
1368
+ </svg>`
1369
+ }
1370
+
1371
+ private renderFieldError(message: string) {
1372
+ return message
1373
+ ? html`<span class="error-text" part="field-error" role="alert">
1374
+ <span class="error-dot" aria-hidden="true">!</span>${message}
1375
+ </span>`
1376
+ : nothing
1377
+ }
1378
+
1379
+ private renderGlobalMessage() {
1380
+ if (!this.errors.global && !this.statusMessage) return nothing
1381
+ return html`<div
1382
+ class=${`global-message${this.errors.global ? ' error' : ''}`}
1383
+ part=${this.errors.global ? 'error' : 'status'}
1384
+ role=${this.errors.global ? 'alert' : 'status'}
1385
+ aria-live="polite"
1386
+ >
1387
+ ${this.errors.global || this.statusMessage}
1388
+ </div>`
1389
+ }
1390
+
1391
+ private renderAgreementLink(type: 'user' | 'privacy', label: string) {
1392
+ return html`
1393
+ <button
1394
+ class="agreement-link"
1395
+ part="agreement-link"
1396
+ type="button"
1397
+ @click=${() => this.openAgreement(type)}
1398
+ >
1399
+ ${label}
1400
+ </button>
1401
+ `
1402
+ }
1403
+
1404
+ private readonly selectMethod = (method: UserLoginMethod): void => {
1405
+ if (method === this.activeMethod || !this.isMethodAvailable(method)) return
1406
+ if (this.activeMethod === 'qr-code') this.stopQrFlow()
1407
+ if (this.activeMethod === 'password') {
1408
+ this.password = ''
1409
+ this.passwordVisible = false
1410
+ }
1411
+ this.activeMethod = method
1412
+ this.errors = { ...EMPTY_ERRORS }
1413
+ this.statusMessage = ''
1414
+ this.startActiveMethod()
1415
+ }
1416
+
1417
+ private startActiveMethod(): void {
1418
+ if (this.activeMethod === 'qr-code') {
1419
+ void this.startQrChallenge()
1420
+ }
1421
+ }
1422
+
1423
+ private isMethodAvailable(method: UserLoginMethod): boolean {
1424
+ const transport = this.config?.transport
1425
+ if (method === 'verification-code') return Boolean(transport)
1426
+ if (method === 'password') return typeof transport?.loginWithPassword === 'function'
1427
+ return (
1428
+ typeof transport?.createQrChallenge === 'function' &&
1429
+ typeof transport.pollQrChallenge === 'function'
1430
+ )
1431
+ }
1432
+
1433
+ private resolveInitialMethod(method: UserLoginMethod | undefined): UserLoginMethod {
1434
+ return method && this.isMethodAvailable(method) ? method : 'verification-code'
1435
+ }
1436
+
1437
+ private readonly updateMobile = (event: InputEvent): void => {
1438
+ const input = event.currentTarget as HTMLInputElement
1439
+ this.mobile = input.value.replace(/\D/g, '').slice(0, 11)
1440
+ input.value = this.mobile
1441
+ this.errors = { ...this.errors, mobile: '', global: '' }
1442
+ }
1443
+
1444
+ private readonly updateSmsCode = (event: InputEvent): void => {
1445
+ const input = event.currentTarget as HTMLInputElement
1446
+ this.smsCode = input.value.replace(/\D/g, '').slice(0, 6)
1447
+ input.value = this.smsCode
1448
+ this.errors = { ...this.errors, smsCode: '', global: '' }
1449
+ }
1450
+
1451
+ private readonly updatePassword = (event: InputEvent): void => {
1452
+ this.password = (event.currentTarget as HTMLInputElement).value.slice(0, 128)
1453
+ this.errors = { ...this.errors, password: '', global: '' }
1454
+ }
1455
+
1456
+ private readonly updateAgreement = (event: Event): void => {
1457
+ this.agreed = (event.currentTarget as HTMLInputElement).checked
1458
+ this.errors = { ...this.errors, agreement: '', global: '' }
1459
+ }
1460
+
1461
+ private readonly updateRememberSession = (event: Event): void => {
1462
+ this.rememberSession = (event.currentTarget as HTMLInputElement).checked
1463
+ }
1464
+
1465
+ private readonly togglePasswordVisibility = (): void => {
1466
+ this.passwordVisible = !this.passwordVisible
1467
+ }
1468
+
1469
+ private readonly validateMobileField = (): void => {
1470
+ const mobile = this.mobileError()
1471
+ this.errors = { ...this.errors, mobile }
1472
+ }
1473
+
1474
+ private readonly validateSmsCodeField = (): void => {
1475
+ const smsCode = this.smsCodeError()
1476
+ this.errors = { ...this.errors, smsCode }
1477
+ }
1478
+
1479
+ private readonly validatePasswordField = (): void => {
1480
+ const password = this.passwordError()
1481
+ this.errors = { ...this.errors, password }
1482
+ }
1483
+
1484
+ private readonly validateAgreementField = (): void => {
1485
+ const agreement = this.agreed ? '' : '请先阅读并同意服务协议'
1486
+ this.errors = { ...this.errors, agreement }
1487
+ }
1488
+
1489
+ private readonly sendSmsCode = async (): Promise<void> => {
1490
+ const config = this.config
1491
+ if (!config || this.sendingSms || this.loggingIn || this.countdown > 0) return
1492
+ if (!this.validateMobile()) return
1493
+
1494
+ this.sendingSms = true
1495
+ this.statusMessage = ''
1496
+ try {
1497
+ await config.transport.sendSmsCode({ mobile: this.mobile })
1498
+ this.startCountdown(config.countdownSeconds)
1499
+ this.statusMessage = '验证码已发送'
1500
+ this.dispatchPublicEvent('sms-sent', {})
1501
+ } catch (error) {
1502
+ const message =
1503
+ error instanceof UserLoginTransportError && error.code === 'SMS_SEND_TOO_FREQUENT'
1504
+ ? '验证码发送频繁,请稍后重试。'
1505
+ : '验证码发送失败,请检查后重试。'
1506
+ this.handleError('SMS_SEND_FAILED', message, true)
1507
+ } finally {
1508
+ this.sendingSms = false
1509
+ }
1510
+ }
1511
+
1512
+ private readonly submitLogin = async (event: SubmitEvent): Promise<void> => {
1513
+ event.preventDefault()
1514
+ if (this.activeMethod === 'password') await this.submitPasswordLogin()
1515
+ if (this.activeMethod === 'verification-code') await this.submitVerificationLogin()
1516
+ }
1517
+
1518
+ private async submitPasswordLogin(): Promise<void> {
1519
+ const config = this.config
1520
+ if (
1521
+ !config ||
1522
+ typeof config.transport.loginWithPassword !== 'function' ||
1523
+ this.loggingIn ||
1524
+ this.sendingSms
1525
+ ) {
1526
+ return
1527
+ }
1528
+ if (!this.validatePasswordForm()) return
1529
+
1530
+ this.loggingIn = true
1531
+ this.statusMessage = ''
1532
+ try {
1533
+ const result = await config.transport.loginWithPassword({
1534
+ mobile: this.mobile,
1535
+ password: this.password,
1536
+ applicationName: config.applicationName,
1537
+ rememberSession: this.rememberSession,
1538
+ })
1539
+ this.finishLogin(result)
1540
+ } catch (error) {
1541
+ const failure = passwordLoginFailure(error, config.applicationName)
1542
+ this.handleError(failure.code, failure.message, failure.recoverable)
1543
+ } finally {
1544
+ this.loggingIn = false
1545
+ }
1546
+ }
1547
+
1548
+ private async submitVerificationLogin(): Promise<void> {
1549
+ const config = this.config
1550
+ if (!config || this.loggingIn || this.sendingSms) return
1551
+ if (!this.validateVerificationForm()) return
1552
+
1553
+ this.loggingIn = true
1554
+ this.statusMessage = ''
1555
+ try {
1556
+ const result = await config.transport.login({
1557
+ mobile: this.mobile,
1558
+ smsCode: this.smsCode,
1559
+ applicationName: config.applicationName,
1560
+ })
1561
+ this.finishLogin(result)
1562
+ } catch (error) {
1563
+ if (
1564
+ error instanceof UserLoginTransportError &&
1565
+ error.code === 'SMS_CODE_INVALID_OR_EXPIRED'
1566
+ ) {
1567
+ this.handleSmsCodeError('验证码错误或已失效,请重新获取')
1568
+ } else if (error instanceof UserLoginTransportError && error.code === 'ORG_ACCESS_DENIED') {
1569
+ this.handleError(
1570
+ 'ORG_ACCESS_DENIED',
1571
+ applicationAccessDeniedMessage(error, config.applicationName),
1572
+ false,
1573
+ )
1574
+ } else {
1575
+ this.handleError('LOGIN_FAILED', '登录失败,请检查手机验证码后重试。', true)
1576
+ }
1577
+ } finally {
1578
+ this.loggingIn = false
1579
+ }
1580
+ }
1581
+
1582
+ private finishLogin(result: UserLoginSuccess): void {
1583
+ const applicationName =
1584
+ typeof result.applicationName === 'string' ? result.applicationName.trim() : ''
1585
+ if (!applicationName || applicationName !== this.config?.applicationName) {
1586
+ throw new Error('Invalid login result')
1587
+ }
1588
+ const isNewUser = result.isNewUser === true
1589
+ this.clearCountdown()
1590
+ this.clearOnboardingTimer()
1591
+ this.password = ''
1592
+ this.passwordVisible = false
1593
+ this.errors = { ...EMPTY_ERRORS }
1594
+ this.statusMessage = isNewUser ? '' : '登录成功'
1595
+ this.registrationSucceeded = isNewUser
1596
+ this.onboardingRequested = false
1597
+ const detail: UserLoginSuccess = {
1598
+ applicationName,
1599
+ ...(typeof result.isNewUser === 'boolean' ? { isNewUser } : {}),
1600
+ }
1601
+ this.dispatchPublicEvent('login-success', detail)
1602
+ if (isNewUser && this.isConnected) this.startOnboardingTimer()
1603
+ }
1604
+
1605
+ private validateMobile(): boolean {
1606
+ const mobile = this.mobileError()
1607
+ this.errors = { ...this.errors, mobile, global: '' }
1608
+ return !mobile
1609
+ }
1610
+
1611
+ private validateVerificationForm(): boolean {
1612
+ const validMobile = this.validateMobile()
1613
+ const smsCode = this.smsCodeError()
1614
+ const agreement = this.agreed ? '' : '请先阅读并同意服务协议'
1615
+ this.errors = { ...this.errors, smsCode, agreement, global: '' }
1616
+ return validMobile && !smsCode && !agreement
1617
+ }
1618
+
1619
+ private validatePasswordForm(): boolean {
1620
+ const mobile = this.mobileError()
1621
+ const password = this.passwordError()
1622
+ this.errors = { ...this.errors, mobile, password, global: '' }
1623
+ return !mobile && !password
1624
+ }
1625
+
1626
+ private mobileError(): string {
1627
+ return this.isMobileValid() ? '' : '请输入正确的 11 位手机号'
1628
+ }
1629
+
1630
+ private smsCodeError(): string {
1631
+ return this.isSmsCodeValid() ? '' : '请输入正确的手机验证码'
1632
+ }
1633
+
1634
+ private isMobileValid(): boolean {
1635
+ return /^1[3-9]\d{9}$/.test(this.mobile)
1636
+ }
1637
+
1638
+ private isSmsCodeValid(): boolean {
1639
+ return /^\d{6}$/.test(this.smsCode)
1640
+ }
1641
+
1642
+ private passwordError(): string {
1643
+ return this.password.length >= 6 ? '' : '请输入至少 6 位密码'
1644
+ }
1645
+
1646
+ private openAgreement(type: 'user' | 'privacy'): void {
1647
+ const config = this.config
1648
+ const proceed = this.dispatchPublicEvent('agreement-requested', { type }, true)
1649
+ if (!proceed || !config) return
1650
+ const url = type === 'user' ? config.userAgreementUrl : config.privacyAgreementUrl
1651
+ if (url && typeof window !== 'undefined') window.open(url, '_blank', 'noopener,noreferrer')
1652
+ }
1653
+
1654
+ private readonly openForgotPassword = (): void => {
1655
+ const proceed = this.dispatchPublicEvent('forgot-password-requested', {}, true)
1656
+ const url = this.config?.forgotPasswordUrl
1657
+ if (proceed && url && typeof window !== 'undefined') {
1658
+ window.open(url, '_blank', 'noopener,noreferrer')
1659
+ }
1660
+ }
1661
+
1662
+ private readonly requestOnboardingManually = (): void => {
1663
+ this.requestOnboarding('manual')
1664
+ }
1665
+
1666
+ private startOnboardingTimer(): void {
1667
+ if (!this.registrationSucceeded || this.onboardingRequested) return
1668
+ this.clearOnboardingTimer()
1669
+ this.onboardingTimer = window.setTimeout(() => {
1670
+ this.onboardingTimer = undefined
1671
+ this.requestOnboarding('automatic')
1672
+ }, 2_000)
1673
+ }
1674
+
1675
+ private requestOnboarding(trigger: 'automatic' | 'manual'): void {
1676
+ const applicationName = this.config?.applicationName
1677
+ if (!this.registrationSucceeded || this.onboardingRequested || !applicationName) return
1678
+ this.clearOnboardingTimer()
1679
+ this.onboardingRequested = true
1680
+ this.dispatchPublicEvent('onboarding-requested', { applicationName, trigger })
1681
+ }
1682
+
1683
+ private readonly startQrChallenge = async (): Promise<void> => {
1684
+ const config = this.config
1685
+ if (
1686
+ !config ||
1687
+ typeof config.transport.createQrChallenge !== 'function' ||
1688
+ typeof config.transport.pollQrChallenge !== 'function' ||
1689
+ this.activeMethod !== 'qr-code'
1690
+ ) {
1691
+ return
1692
+ }
1693
+ this.stopQrFlow()
1694
+ const generation = this.qrGeneration
1695
+ this.qrState = 'loading'
1696
+ this.qrImageUrl = ''
1697
+ this.errors = { ...this.errors, global: '' }
1698
+ this.statusMessage = ''
1699
+ try {
1700
+ const challenge = await config.transport.createQrChallenge({
1701
+ applicationName: config.applicationName,
1702
+ })
1703
+ if (!this.isCurrentQr(generation)) return
1704
+ const challengeId = normalizeQrChallengeId(challenge.challengeId)
1705
+ const imageUrl = safeQrImageUrl(challenge.imageUrl)
1706
+ const pollIntervalMs = normalizeQrPollInterval(
1707
+ challenge.pollIntervalMs,
1708
+ config.qrPollIntervalMs,
1709
+ )
1710
+ if (!challengeId || !imageUrl || imageUrl.includes(challengeId)) {
1711
+ throw new Error('Invalid QR challenge')
1712
+ }
1713
+ this.qrChallengeId = challengeId
1714
+ this.qrPollIntervalMs = pollIntervalMs
1715
+ this.qrImageUrl = imageUrl
1716
+ this.qrState = 'pending'
1717
+ this.scheduleQrPoll(generation)
1718
+ } catch (error) {
1719
+ if (this.isCurrentQr(generation)) {
1720
+ this.qrState = 'error'
1721
+ const unavailable =
1722
+ error instanceof UserLoginTransportError && error.code === 'QR_LOGIN_UNAVAILABLE'
1723
+ const accessDenied =
1724
+ error instanceof UserLoginTransportError && error.code === 'ORG_ACCESS_DENIED'
1725
+ this.handleError(
1726
+ unavailable
1727
+ ? 'QR_LOGIN_UNAVAILABLE'
1728
+ : accessDenied
1729
+ ? 'ORG_ACCESS_DENIED'
1730
+ : 'QR_LOAD_FAILED',
1731
+ unavailable
1732
+ ? '扫码登录服务暂不可用,请稍后重试。'
1733
+ : accessDenied
1734
+ ? applicationAccessDeniedMessage(error, config.applicationName)
1735
+ : '登录二维码加载失败,请点击重试。',
1736
+ !accessDenied,
1737
+ )
1738
+ }
1739
+ }
1740
+ }
1741
+
1742
+ private scheduleQrPoll(generation: number): void {
1743
+ const interval = this.qrPollIntervalMs
1744
+ if (!interval || !this.qrChallengeId || !this.isCurrentQr(generation)) return
1745
+ this.qrPollTimer = window.setTimeout(() => void this.pollQrChallenge(generation), interval)
1746
+ }
1747
+
1748
+ private async pollQrChallenge(generation: number): Promise<void> {
1749
+ const config = this.config
1750
+ const challengeId = this.qrChallengeId
1751
+ if (
1752
+ !config ||
1753
+ typeof config.transport.pollQrChallenge !== 'function' ||
1754
+ !challengeId ||
1755
+ !this.isCurrentQr(generation)
1756
+ ) {
1757
+ return
1758
+ }
1759
+ this.qrPollTimer = undefined
1760
+ try {
1761
+ const result = await config.transport.pollQrChallenge(challengeId, config.applicationName)
1762
+ if (!this.isCurrentQr(generation)) return
1763
+ if (!['pending', 'scanned', 'confirmed', 'expired', 'rejected'].includes(result.status)) {
1764
+ throw new Error('Invalid QR status')
1765
+ }
1766
+ this.qrState = result.status
1767
+ if (result.status === 'confirmed') {
1768
+ if (!result.result) throw new Error('Missing QR login result')
1769
+ this.finishLogin(result.result)
1770
+ this.finishQrFlow()
1771
+ return
1772
+ }
1773
+ if (result.status === 'expired') {
1774
+ this.finishQrFlow()
1775
+ return
1776
+ }
1777
+ if (result.status === 'rejected') {
1778
+ this.finishQrFlow()
1779
+ this.handleError('QR_LOGIN_REJECTED', '已在手机上取消登录,请点击刷新。', true)
1780
+ return
1781
+ }
1782
+ this.scheduleQrPoll(generation)
1783
+ } catch (error) {
1784
+ if (this.isCurrentQr(generation)) {
1785
+ this.qrState = 'error'
1786
+ this.finishQrFlow()
1787
+ const unavailable =
1788
+ error instanceof UserLoginTransportError && error.code === 'QR_LOGIN_UNAVAILABLE'
1789
+ const accessDenied =
1790
+ error instanceof UserLoginTransportError && error.code === 'ORG_ACCESS_DENIED'
1791
+ this.handleError(
1792
+ unavailable
1793
+ ? 'QR_LOGIN_UNAVAILABLE'
1794
+ : accessDenied
1795
+ ? 'ORG_ACCESS_DENIED'
1796
+ : 'QR_POLL_FAILED',
1797
+ unavailable
1798
+ ? '扫码登录服务暂不可用,请稍后重试。'
1799
+ : accessDenied
1800
+ ? applicationAccessDeniedMessage(error, config.applicationName)
1801
+ : '扫码登录状态确认失败,请点击重试。',
1802
+ !accessDenied,
1803
+ )
1804
+ }
1805
+ }
1806
+ }
1807
+
1808
+ private startCountdown(seconds: number): void {
1809
+ this.clearCountdown()
1810
+ this.countdown = seconds
1811
+ this.countdownTimer = window.setInterval(() => {
1812
+ this.countdown -= 1
1813
+ if (this.countdown <= 0) this.clearCountdown()
1814
+ }, 1_000)
1815
+ }
1816
+
1817
+ private clearCountdown(): void {
1818
+ if (this.countdownTimer !== undefined) {
1819
+ window.clearInterval(this.countdownTimer)
1820
+ this.countdownTimer = undefined
1821
+ }
1822
+ this.countdown = 0
1823
+ }
1824
+
1825
+ private clearOnboardingTimer(): void {
1826
+ if (this.onboardingTimer !== undefined) {
1827
+ window.clearTimeout(this.onboardingTimer)
1828
+ this.onboardingTimer = undefined
1829
+ }
1830
+ }
1831
+
1832
+ private stopQrFlow(): void {
1833
+ this.qrGeneration += 1
1834
+ this.finishQrFlow()
1835
+ }
1836
+
1837
+ private finishQrFlow(): void {
1838
+ if (this.qrPollTimer !== undefined) {
1839
+ window.clearTimeout(this.qrPollTimer)
1840
+ this.qrPollTimer = undefined
1841
+ }
1842
+ this.qrChallengeId = ''
1843
+ this.qrPollIntervalMs = 0
1844
+ }
1845
+
1846
+ private handleError(code: UserLoginErrorCode, message: string, recoverable: boolean): void {
1847
+ this.errors = { ...this.errors, global: message }
1848
+ this.statusMessage = ''
1849
+ this.dispatchPublicEvent('error', { code, message, recoverable })
1850
+ }
1851
+
1852
+ private handleSmsCodeError(message: string): void {
1853
+ this.errors = { ...this.errors, smsCode: message, global: '' }
1854
+ this.statusMessage = ''
1855
+ this.dispatchPublicEvent('error', {
1856
+ code: 'SMS_CODE_INVALID_OR_EXPIRED',
1857
+ message,
1858
+ recoverable: true,
1859
+ })
1860
+ }
1861
+
1862
+ private dispatchPublicEvent<K extends keyof UserLoginEventMap>(
1863
+ type: K,
1864
+ detail: UserLoginEventMap[K]['detail'],
1865
+ cancelable = false,
1866
+ ): boolean {
1867
+ return this.dispatchEvent(
1868
+ new CustomEvent(type, { detail, bubbles: true, composed: true, cancelable }),
1869
+ )
1870
+ }
1871
+
1872
+ private isCurrentQr(generation: number): boolean {
1873
+ return this.isConnected && this.activeMethod === 'qr-code' && generation === this.qrGeneration
1874
+ }
1875
+ }
1876
+
1877
+ function safeNavigationUrl(rawValue: string | undefined, label: string): string {
1878
+ if (rawValue === undefined) return ''
1879
+ const value = rawValue.trim()
1880
+ if (!value) return ''
1881
+ const documentBase = typeof document === 'undefined' ? 'https://localhost/' : document.baseURI
1882
+ let url: URL
1883
+ try {
1884
+ url = new URL(value, documentBase)
1885
+ } catch {
1886
+ throw new TypeError(`${label} is invalid`)
1887
+ }
1888
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
1889
+ throw new TypeError(`${label} must use HTTP(S) without user credentials`)
1890
+ }
1891
+ if (hasSensitiveUrlParameter(url)) {
1892
+ throw new TypeError(`${label} must not contain authentication parameters`)
1893
+ }
1894
+ return url.href
1895
+ }
1896
+
1897
+ function normalizeDisplayText(rawValue: string | undefined, maxLength: number): string {
1898
+ return typeof rawValue === 'string' ? rawValue.trim().slice(0, maxLength) : ''
1899
+ }
1900
+
1901
+ function normalizeLoginMethod(rawValue: UserLoginMethod | undefined): UserLoginMethod | undefined {
1902
+ if (rawValue === undefined) return undefined
1903
+ if (LOGIN_METHODS.some(({ method }) => method === rawValue)) return rawValue
1904
+ throw new TypeError('user-login initialMethod is invalid')
1905
+ }
1906
+
1907
+ function normalizeApplicationName(rawValue: string): string {
1908
+ const value = typeof rawValue === 'string' ? rawValue.trim() : ''
1909
+ if (!value || value.length > 128) {
1910
+ throw new TypeError('user-login applicationName must contain 1 to 128 characters')
1911
+ }
1912
+ return value
1913
+ }
1914
+
1915
+ function normalizeQrChallengeId(rawValue: unknown): string {
1916
+ const value = typeof rawValue === 'string' ? rawValue.trim() : ''
1917
+ return /^[A-Za-z0-9_-]{16,512}$/.test(value) ? value : ''
1918
+ }
1919
+
1920
+ function normalizeQrPollInterval(rawValue: unknown, fallback: number): number {
1921
+ return typeof rawValue === 'number' &&
1922
+ Number.isInteger(rawValue) &&
1923
+ rawValue >= 500 &&
1924
+ rawValue <= 10_000
1925
+ ? rawValue
1926
+ : fallback
1927
+ }
1928
+
1929
+ function passwordLoginFailure(
1930
+ error: unknown,
1931
+ applicationName: string,
1932
+ ): {
1933
+ code: UserLoginErrorCode
1934
+ message: string
1935
+ recoverable: boolean
1936
+ } {
1937
+ if (error instanceof UserLoginTransportError) {
1938
+ if (error.code === 'ACCOUNT_LOCKED') {
1939
+ return {
1940
+ code: 'ACCOUNT_LOCKED',
1941
+ message: '账号暂时锁定,请稍后重试。',
1942
+ recoverable: false,
1943
+ }
1944
+ }
1945
+ if (error.code === 'ACCOUNT_DISABLED') {
1946
+ return {
1947
+ code: 'ACCOUNT_DISABLED',
1948
+ message: '账号已停用,请联系管理员。',
1949
+ recoverable: false,
1950
+ }
1951
+ }
1952
+ if (error.code === 'ORG_ACCESS_DENIED') {
1953
+ return {
1954
+ code: 'ORG_ACCESS_DENIED',
1955
+ message: applicationAccessDeniedMessage(error, applicationName),
1956
+ recoverable: false,
1957
+ }
1958
+ }
1959
+ if (error.code === 'PASSWORD_INVALID') {
1960
+ return {
1961
+ code: 'PASSWORD_INVALID',
1962
+ message: '手机号或密码错误,请检查后重试。',
1963
+ recoverable: true,
1964
+ }
1965
+ }
1966
+ }
1967
+ return {
1968
+ code: 'LOGIN_FAILED',
1969
+ message: '登录失败,请稍后重试。',
1970
+ recoverable: true,
1971
+ }
1972
+ }
1973
+
1974
+ function applicationAccessDeniedMessage(
1975
+ error: UserLoginTransportError,
1976
+ applicationName: string,
1977
+ ): string {
1978
+ return error.displayMessage ?? `当前用户没有可用的${applicationName}应用实例`
1979
+ }
1980
+
1981
+ function safeConfiguredImageUrl(rawValue: string | undefined): string {
1982
+ if (rawValue === undefined || rawValue.trim() === '') return ''
1983
+ const url = safeImageUrl(rawValue)
1984
+ if (!url) throw new TypeError('Header icon URL is invalid')
1985
+ return url
1986
+ }
1987
+
1988
+ function safeQrImageUrl(rawValue: string): string {
1989
+ const value = safeImageUrl(rawValue)
1990
+ if (!value || value.startsWith('data:')) return value
1991
+ const url = new URL(value)
1992
+ return url.search || url.hash ? '' : value
1993
+ }
1994
+
1995
+ function safeImageUrl(rawValue: string): string {
1996
+ const value = typeof rawValue === 'string' ? rawValue.trim() : ''
1997
+ if (!value) return ''
1998
+ if (/^data:image\/(?:png|jpeg|gif|webp|svg\+xml)[;,]/i.test(value)) return value
1999
+ const documentBase = typeof document === 'undefined' ? 'https://localhost/' : document.baseURI
2000
+ try {
2001
+ const url = new URL(value, documentBase)
2002
+ return ['http:', 'https:'].includes(url.protocol) &&
2003
+ !url.username &&
2004
+ !url.password &&
2005
+ !hasSensitiveUrlParameter(url)
2006
+ ? url.href
2007
+ : ''
2008
+ } catch {
2009
+ return ''
2010
+ }
2011
+ }
2012
+
2013
+ function hasSensitiveUrlParameter(url: URL): boolean {
2014
+ const sensitiveNames = new Set([
2015
+ 'token',
2016
+ 'jwt',
2017
+ 'access_token',
2018
+ 'authorization',
2019
+ 'auth_code',
2020
+ 'ticket',
2021
+ 'challenge',
2022
+ 'challenge_id',
2023
+ 'login_token',
2024
+ 'qr_token',
2025
+ ])
2026
+ return Array.from(url.searchParams.keys()).some((name) => sensitiveNames.has(name.toLowerCase()))
2027
+ }