@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,880 @@
1
+ import { LitElement, css, html, nothing } from 'lit'
2
+ import { prototypeIcon, type PrototypeIconName } from './prototype-icons'
3
+ import { normalizeSafeImageUrl } from './safe-image-url'
4
+
5
+ export type UserMenuOrganizationType = 'company' | 'team'
6
+
7
+ export interface UserMenuOrganization {
8
+ id: string
9
+ name: string
10
+ type: UserMenuOrganizationType
11
+ avatarText?: string
12
+ role?: string
13
+ memberCount?: number
14
+ verificationStatus?: 'unverified' | 'pending' | 'rejected' | 'verified' | 'expired'
15
+ }
16
+
17
+ export interface UserMenuConfig {
18
+ displayName: string
19
+ maskedMobile?: string
20
+ avatarText?: string
21
+ avatarUrl?: string
22
+ organizations: readonly UserMenuOrganization[]
23
+ currentOrganizationId?: string
24
+ organizationCount?: number
25
+ accountSettingsLabel?: string
26
+ employeeInfoLabel?: string
27
+ organizationSectionLabel?: string
28
+ createOrganizationLabel?: string
29
+ logoutLabel?: string
30
+ /** Internal account-context hook. Public standalone usage remains event-based. */
31
+ onCreateOrganization?: () => void
32
+ }
33
+
34
+ export interface UserMenuEventMap {
35
+ 'account-settings-requested': CustomEvent<Record<string, never>>
36
+ 'employee-info-requested': CustomEvent<{ organizationId: string | null }>
37
+ 'organization-selected': CustomEvent<{ organizationId: string }>
38
+ 'organization-create-requested': CustomEvent<Record<string, never>>
39
+ 'logout-requested': CustomEvent<Record<string, never>>
40
+ }
41
+
42
+ interface ResolvedUserMenuOrganization {
43
+ id: string
44
+ name: string
45
+ type: UserMenuOrganizationType
46
+ avatarText: string
47
+ role: string
48
+ memberCount: number | null
49
+ verificationStatus: 'unverified' | 'pending' | 'rejected' | 'verified' | 'expired' | null
50
+ }
51
+
52
+ interface UserMenuViewModel {
53
+ displayName: string
54
+ maskedMobile: string
55
+ avatarText: string
56
+ avatarUrl: string
57
+ organizations: readonly ResolvedUserMenuOrganization[]
58
+ currentOrganizationId: string
59
+ organizationCount: number
60
+ accountSettingsLabel: string
61
+ employeeInfoLabel: string
62
+ organizationSectionLabel: string
63
+ createOrganizationLabel: string
64
+ logoutLabel: string
65
+ }
66
+
67
+ const DEFAULT_LABELS = {
68
+ accountSettings: '个人账号设置',
69
+ employeeInfo: '我的员工信息',
70
+ organizationSection: '切换企业 / 团队',
71
+ createOrganization: '创建企业 / 团队',
72
+ logout: '退出登录',
73
+ } as const
74
+
75
+ export class UniplatUserMenu extends LitElement {
76
+ static override properties = {
77
+ viewModel: { state: true },
78
+ }
79
+
80
+ static override styles = css`
81
+ :host {
82
+ --uniplat-user-menu-width: 330px;
83
+ --uniplat-user-menu-primary: #2e6bf5;
84
+ --uniplat-user-menu-text: #25303f;
85
+ --uniplat-user-menu-muted: #8b98a6;
86
+ --uniplat-user-menu-border: #e4ebf3;
87
+ --uniplat-user-menu-surface: #ffffff;
88
+ --uniplat-user-menu-hover: #f4f7fc;
89
+ --uniplat-user-menu-selected: #f0f5ff;
90
+ --uniplat-user-menu-list-max-height: 288px;
91
+ --uniplat-user-menu-font-family:
92
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
93
+ display: block;
94
+ width: var(--uniplat-user-menu-width);
95
+ max-width: 100%;
96
+ color: var(--uniplat-user-menu-text);
97
+ font-family: var(--uniplat-user-menu-font-family);
98
+ line-height: 1.4;
99
+ }
100
+
101
+ * {
102
+ box-sizing: border-box;
103
+ }
104
+
105
+ button {
106
+ appearance: none;
107
+ font: inherit;
108
+ }
109
+
110
+ .panel {
111
+ width: 100%;
112
+ padding: 8px;
113
+ border: 1px solid var(--uniplat-user-menu-border);
114
+ border-radius: 14px;
115
+ background: var(--uniplat-user-menu-surface);
116
+ box-shadow: 0 18px 44px rgba(20, 40, 80, 0.16);
117
+ }
118
+
119
+ .profile {
120
+ display: flex;
121
+ align-items: center;
122
+ gap: 10px;
123
+ padding: 10px 10px 12px;
124
+ border-bottom: 1px solid #edf1f6;
125
+ }
126
+
127
+ .avatar {
128
+ position: relative;
129
+ display: grid;
130
+ place-items: center;
131
+ flex: 0 0 auto;
132
+ width: 40px;
133
+ height: 40px;
134
+ border-radius: 50%;
135
+ color: #ffffff;
136
+ background: #233043;
137
+ font-size: 15px;
138
+ font-weight: 700;
139
+ overflow: hidden;
140
+ }
141
+
142
+ .avatar-fallback {
143
+ display: grid;
144
+ width: 100%;
145
+ height: 100%;
146
+ place-items: center;
147
+ }
148
+
149
+ .avatar-image {
150
+ position: absolute;
151
+ inset: 0;
152
+ display: block;
153
+ width: 100%;
154
+ height: 100%;
155
+ border-radius: inherit;
156
+ object-fit: cover;
157
+ }
158
+
159
+ .avatar-image[hidden] {
160
+ display: none;
161
+ }
162
+
163
+ .profile-copy,
164
+ .action-copy,
165
+ .organization-copy {
166
+ min-width: 0;
167
+ flex: 1;
168
+ }
169
+
170
+ .profile-name {
171
+ display: block;
172
+ overflow: hidden;
173
+ color: #182634;
174
+ font-size: 14.5px;
175
+ font-weight: 700;
176
+ text-overflow: ellipsis;
177
+ white-space: nowrap;
178
+ }
179
+
180
+ .profile-mobile {
181
+ display: block;
182
+ margin-top: 2px;
183
+ color: var(--uniplat-user-menu-muted);
184
+ font-size: 11.5px;
185
+ }
186
+
187
+ .menu-action,
188
+ .organization,
189
+ .footer-action {
190
+ width: 100%;
191
+ border: 0;
192
+ color: inherit;
193
+ background: transparent;
194
+ cursor: pointer;
195
+ text-align: left;
196
+ }
197
+
198
+ .menu-action:hover,
199
+ .organization:hover,
200
+ .footer-action:hover {
201
+ background: var(--uniplat-user-menu-hover);
202
+ }
203
+
204
+ .menu-action:focus-visible,
205
+ .organization:focus-visible,
206
+ .footer-action:focus-visible {
207
+ outline: 2px solid color-mix(in srgb, var(--uniplat-user-menu-primary) 55%, transparent);
208
+ outline-offset: -2px;
209
+ }
210
+
211
+ .menu-action-icon,
212
+ .menu-action-chevron,
213
+ .check,
214
+ .footer-icon {
215
+ display: grid;
216
+ place-items: center;
217
+ flex: 0 0 auto;
218
+ }
219
+
220
+ .menu-actions {
221
+ display: flex;
222
+ flex-direction: column;
223
+ padding: 6px 0 8px;
224
+ border-bottom: 1px solid #edf1f6;
225
+ }
226
+
227
+ .menu-action {
228
+ display: flex;
229
+ align-items: center;
230
+ gap: 10px;
231
+ width: 100%;
232
+ padding: 9px 10px;
233
+ border: 0;
234
+ border-radius: 9px;
235
+ color: inherit;
236
+ background: transparent;
237
+ cursor: pointer;
238
+ text-align: left;
239
+ }
240
+
241
+ .menu-action-icon {
242
+ width: 17px;
243
+ height: 17px;
244
+ color: var(--uniplat-user-menu-primary);
245
+ }
246
+
247
+ .menu-action-title {
248
+ display: block;
249
+ overflow: hidden;
250
+ color: #25303f;
251
+ font-size: 13.5px;
252
+ font-weight: 600;
253
+ text-overflow: ellipsis;
254
+ white-space: nowrap;
255
+ }
256
+
257
+ .menu-action-subtitle {
258
+ display: block;
259
+ margin-top: 1px;
260
+ overflow: hidden;
261
+ color: #98a4b2;
262
+ font-size: 11.5px;
263
+ text-overflow: ellipsis;
264
+ white-space: nowrap;
265
+ }
266
+
267
+ .menu-action-chevron {
268
+ width: 15px;
269
+ height: 15px;
270
+ color: #c7d1dd;
271
+ }
272
+
273
+ .section-title {
274
+ padding: 10px 10px 6px;
275
+ color: #98a4b2;
276
+ font-size: 11.5px;
277
+ font-weight: 700;
278
+ letter-spacing: 1px;
279
+ }
280
+
281
+ .organization-list {
282
+ display: flex;
283
+ flex-direction: column;
284
+ gap: 6px;
285
+ max-height: var(--uniplat-user-menu-list-max-height);
286
+ padding: 0 2px 4px;
287
+ overflow-x: hidden;
288
+ overflow-y: auto;
289
+ }
290
+
291
+ .organization {
292
+ display: flex;
293
+ align-items: center;
294
+ gap: 10px;
295
+ padding: 9px 10px;
296
+ border: 1.5px solid var(--uniplat-user-menu-border);
297
+ border-radius: 10px;
298
+ background: #ffffff;
299
+ }
300
+
301
+ .organization.selected {
302
+ border-color: var(--uniplat-user-menu-primary);
303
+ background: #f7faff;
304
+ }
305
+
306
+ .organization.selected:hover {
307
+ background: #f7faff;
308
+ }
309
+
310
+ .organization-icon {
311
+ display: grid;
312
+ place-items: center;
313
+ width: 30px;
314
+ height: 30px;
315
+ flex: none;
316
+ border-radius: 9px;
317
+ color: #5a6673;
318
+ background: #edf1f6;
319
+ font-size: 12.5px;
320
+ font-weight: 800;
321
+ }
322
+
323
+ .organization-icon svg {
324
+ width: 15px;
325
+ height: 15px;
326
+ }
327
+
328
+ .organization-type.company {
329
+ color: var(--uniplat-user-menu-primary);
330
+ background: #e6eefe;
331
+ }
332
+
333
+ .organization-type.team {
334
+ color: #0ca678;
335
+ background: #e3f7ef;
336
+ }
337
+
338
+ .selected .organization-icon.company {
339
+ color: var(--uniplat-user-menu-primary);
340
+ background: #e6eefe;
341
+ }
342
+
343
+ .selected .organization-icon.team {
344
+ color: #0ca678;
345
+ background: #e3f7ef;
346
+ }
347
+
348
+ .organization-heading {
349
+ display: flex;
350
+ align-items: center;
351
+ gap: 5px;
352
+ min-width: 0;
353
+ }
354
+
355
+ .verification-status {
356
+ display: inline-flex;
357
+ align-items: center;
358
+ gap: 3px;
359
+ flex: 0 0 auto;
360
+ padding: 1px 5px;
361
+ border-radius: 4px;
362
+ font-size: 9.5px;
363
+ font-weight: 700;
364
+ white-space: nowrap;
365
+ }
366
+
367
+ .verification-status svg {
368
+ width: 11px;
369
+ height: 11px;
370
+ }
371
+
372
+ .verification-status.verified {
373
+ color: #0ca678;
374
+ background: #e3f7ef;
375
+ }
376
+
377
+ .verification-status.pending {
378
+ color: #1971c2;
379
+ background: #e2effb;
380
+ }
381
+
382
+ .verification-status.unverified,
383
+ .verification-status.expired {
384
+ color: #e8590c;
385
+ background: #fff4e2;
386
+ }
387
+
388
+ .verification-status.rejected {
389
+ color: #e5484d;
390
+ background: #fdeced;
391
+ }
392
+
393
+ .verification-status.team {
394
+ color: #5a6673;
395
+ background: #edf1f6;
396
+ }
397
+
398
+ .organization-name {
399
+ min-width: 0;
400
+ flex: 1;
401
+ overflow: hidden;
402
+ font-size: 13px;
403
+ font-weight: 700;
404
+ text-overflow: ellipsis;
405
+ white-space: nowrap;
406
+ }
407
+
408
+ .organization-type {
409
+ flex: 0 0 auto;
410
+ padding: 0 5px;
411
+ border-radius: 4px;
412
+ font-size: 9.5px;
413
+ font-weight: 700;
414
+ }
415
+
416
+ .organization-meta {
417
+ display: block;
418
+ margin-top: 2px;
419
+ overflow: hidden;
420
+ color: var(--uniplat-user-menu-muted);
421
+ font-size: 11px;
422
+ text-overflow: ellipsis;
423
+ white-space: nowrap;
424
+ }
425
+
426
+ .organization-meta.attention {
427
+ color: #e8590c;
428
+ }
429
+
430
+ .check {
431
+ width: 17px;
432
+ height: 17px;
433
+ color: var(--uniplat-user-menu-primary);
434
+ visibility: hidden;
435
+ }
436
+
437
+ .selected .check {
438
+ visibility: visible;
439
+ }
440
+
441
+ .empty {
442
+ padding: 14px 10px 16px;
443
+ color: var(--uniplat-user-menu-muted);
444
+ font-size: 12px;
445
+ text-align: center;
446
+ }
447
+
448
+ .footer {
449
+ display: flex;
450
+ flex-direction: column;
451
+ margin: 6px 4px 0;
452
+ padding-top: 6px;
453
+ border-top: 1px solid #edf1f6;
454
+ }
455
+
456
+ .footer-action {
457
+ display: flex;
458
+ align-items: center;
459
+ gap: 9px;
460
+ padding: 9px 6px;
461
+ border-radius: 9px;
462
+ color: var(--uniplat-user-menu-primary);
463
+ font-size: 13px;
464
+ font-weight: 600;
465
+ }
466
+
467
+ .footer-action.logout {
468
+ color: #5a6673;
469
+ font-weight: 500;
470
+ }
471
+
472
+ .footer-action.logout .footer-icon {
473
+ color: #98a4b2;
474
+ }
475
+
476
+ .footer-icon {
477
+ width: 16px;
478
+ height: 16px;
479
+ }
480
+
481
+ svg {
482
+ display: block;
483
+ width: 100%;
484
+ height: 100%;
485
+ fill: none;
486
+ stroke: currentColor;
487
+ stroke-linecap: round;
488
+ stroke-linejoin: round;
489
+ stroke-width: 2;
490
+ }
491
+ `
492
+
493
+ declare private viewModel: UserMenuViewModel | null
494
+ private onCreateOrganization: (() => void) | null = null
495
+
496
+ constructor() {
497
+ super()
498
+ this.viewModel = null
499
+ }
500
+
501
+ configure(config: UserMenuConfig): void {
502
+ if (!config || !Array.isArray(config.organizations)) {
503
+ throw new TypeError('user-menu requires an organizations array')
504
+ }
505
+ if (
506
+ config.onCreateOrganization !== undefined &&
507
+ typeof config.onCreateOrganization !== 'function'
508
+ ) {
509
+ throw new TypeError('user-menu onCreateOrganization must be a function')
510
+ }
511
+
512
+ const displayName = normalizeRequiredText(config.displayName, 'Display name', 80)
513
+ const organizations = normalizeOrganizations(config.organizations)
514
+ const currentOrganizationId = normalizeOptionalText(config.currentOrganizationId, 128)
515
+ const avatarText =
516
+ normalizeOptionalText(config.avatarText, 4) || Array.from(displayName.trim())[0] || '用'
517
+
518
+ this.viewModel = {
519
+ displayName,
520
+ maskedMobile: normalizeMaskedMobile(config.maskedMobile),
521
+ avatarText,
522
+ avatarUrl: normalizeSafeImageUrl(config.avatarUrl, 'user-menu avatar URL'),
523
+ organizations,
524
+ currentOrganizationId,
525
+ organizationCount: normalizeOrganizationCount(config.organizationCount, organizations.length),
526
+ accountSettingsLabel:
527
+ normalizeOptionalText(config.accountSettingsLabel, 40) || DEFAULT_LABELS.accountSettings,
528
+ employeeInfoLabel:
529
+ normalizeOptionalText(config.employeeInfoLabel, 40) || DEFAULT_LABELS.employeeInfo,
530
+ organizationSectionLabel:
531
+ normalizeOptionalText(config.organizationSectionLabel, 40) ||
532
+ DEFAULT_LABELS.organizationSection,
533
+ createOrganizationLabel:
534
+ normalizeOptionalText(config.createOrganizationLabel, 40) ||
535
+ DEFAULT_LABELS.createOrganization,
536
+ logoutLabel: normalizeOptionalText(config.logoutLabel, 40) || DEFAULT_LABELS.logout,
537
+ }
538
+ this.onCreateOrganization = config.onCreateOrganization ?? null
539
+ }
540
+
541
+ override render() {
542
+ const viewModel = this.viewModel
543
+ if (!viewModel) return nothing
544
+
545
+ return html`
546
+ <section class="panel" part="panel" aria-label="账户与组织">
547
+ <header class="profile" part="profile">
548
+ <span class="avatar" part="avatar" aria-hidden="true">
549
+ <span class="avatar-fallback">${viewModel.avatarText}</span>
550
+ ${
551
+ viewModel.avatarUrl
552
+ ? html`<img
553
+ class="avatar-image"
554
+ part="avatar-image"
555
+ src=${viewModel.avatarUrl}
556
+ alt=""
557
+ referrerpolicy="no-referrer"
558
+ @load=${this.showLoadedAvatar}
559
+ @error=${this.hideBrokenAvatar}
560
+ />`
561
+ : nothing
562
+ }
563
+ </span>
564
+ <span class="profile-copy">
565
+ <span class="profile-name" part="profile-name">${viewModel.displayName}</span>
566
+ ${
567
+ viewModel.maskedMobile
568
+ ? html`<span class="profile-mobile" part="profile-mobile"
569
+ >${viewModel.maskedMobile}</span
570
+ >`
571
+ : nothing
572
+ }
573
+ </span>
574
+ </header>
575
+
576
+ <div class="menu-actions" part="menu-actions">
577
+ <button
578
+ class="menu-action account-settings-action"
579
+ part="account-settings-action"
580
+ type="button"
581
+ @click=${this.requestAccountSettings}
582
+ >
583
+ <span class="menu-action-icon" aria-hidden="true">${prototypeIcon('user-cog')}</span>
584
+ <span class="action-copy">
585
+ <span class="menu-action-title">${viewModel.accountSettingsLabel}</span>
586
+ </span>
587
+ <span class="menu-action-chevron" aria-hidden="true"
588
+ >${prototypeIcon('chevron-right')}</span
589
+ >
590
+ </button>
591
+ <button
592
+ class="menu-action employee-info-action"
593
+ part="employee-info-action"
594
+ type="button"
595
+ @click=${this.requestEmployeeInfo}
596
+ >
597
+ <span class="menu-action-icon" aria-hidden="true">${prototypeIcon('id-badge-2')}</span>
598
+ <span class="action-copy">
599
+ <span class="menu-action-title">${viewModel.employeeInfoLabel}</span>
600
+ ${this.renderEmployeeInfoSubtitle(viewModel)}
601
+ </span>
602
+ <span class="menu-action-chevron" aria-hidden="true"
603
+ >${prototypeIcon('chevron-right')}</span
604
+ >
605
+ </button>
606
+ </div>
607
+
608
+ <div class="section-title" part="section-title">
609
+ ${viewModel.organizationSectionLabel}(${viewModel.organizationCount})
610
+ </div>
611
+ <div class="organization-list" part="organization-list">
612
+ ${
613
+ viewModel.organizations.length
614
+ ? viewModel.organizations.map((organization) =>
615
+ this.renderOrganization(organization, viewModel.currentOrganizationId),
616
+ )
617
+ : html`<div class="empty" part="empty">暂无可切换的企业或团队</div>`
618
+ }
619
+ </div>
620
+
621
+ <div class="footer create-footer" part="footer create-footer">
622
+ <button
623
+ class="footer-action create-action"
624
+ part="create-action"
625
+ type="button"
626
+ @click=${this.requestOrganizationCreation}
627
+ >
628
+ <span class="footer-icon" aria-hidden="true">${prototypeIcon('plus')}</span>
629
+ <span>${viewModel.createOrganizationLabel}</span>
630
+ </button>
631
+ </div>
632
+ <div class="footer logout-footer" part="logout-footer">
633
+ <button
634
+ class="footer-action logout"
635
+ part="logout-action"
636
+ type="button"
637
+ @click=${this.requestLogout}
638
+ >
639
+ <span class="footer-icon" aria-hidden="true">${prototypeIcon('logout')}</span>
640
+ <span>${viewModel.logoutLabel}</span>
641
+ </button>
642
+ </div>
643
+ </section>
644
+ `
645
+ }
646
+
647
+ private renderOrganization(
648
+ organization: ResolvedUserMenuOrganization,
649
+ currentOrganizationId: string,
650
+ ) {
651
+ const selected = organization.id === currentOrganizationId
652
+ const typeLabel = organization.type === 'company' ? '企业' : '团队'
653
+ const meta = organizationMeta(organization)
654
+ return html`
655
+ <button
656
+ class=${`organization${selected ? ' selected' : ''}`}
657
+ part="organization"
658
+ type="button"
659
+ aria-pressed=${String(selected)}
660
+ data-organization-id=${organization.id}
661
+ @click=${() => this.selectOrganization(organization.id)}
662
+ >
663
+ <span
664
+ class=${`organization-icon ${organization.type}`}
665
+ part="organization-icon"
666
+ aria-hidden="true"
667
+ >${prototypeIcon(organization.type === 'company' ? 'building' : 'users')}</span
668
+ >
669
+ <span class="organization-copy">
670
+ <span class="organization-heading">
671
+ <span class="organization-name" part="organization-name">${organization.name}</span>
672
+ <span class=${`organization-type ${organization.type}`} part="organization-type"
673
+ >${typeLabel}</span
674
+ >
675
+ ${this.renderVerificationStatus(organization)}
676
+ </span>
677
+ ${
678
+ meta
679
+ ? html`<span
680
+ class=${`organization-meta${organizationNeedsAttention(organization) ? ' attention' : ''}`}
681
+ part="organization-meta"
682
+ >${meta}</span
683
+ >`
684
+ : nothing
685
+ }
686
+ </span>
687
+ <span class="check" part="organization-check" aria-hidden="true"
688
+ >${prototypeIcon('circle-check-filled')}</span
689
+ >
690
+ </button>
691
+ `
692
+ }
693
+
694
+ private renderVerificationStatus(organization: ResolvedUserMenuOrganization) {
695
+ if (organization.type === 'team') {
696
+ return html`<span class="verification-status team" part="verification-status"
697
+ >${prototypeIcon('users')}无需认证</span
698
+ >`
699
+ }
700
+ const status = organization.verificationStatus ?? 'unverified'
701
+ return html`<span class=${`verification-status ${status}`} part="verification-status"
702
+ >${prototypeIcon(verificationStatusIcon(status))}${verificationStatusLabel(status)}</span
703
+ >`
704
+ }
705
+
706
+ private readonly selectOrganization = (organizationId: string): void => {
707
+ this.dispatchPublicEvent('organization-selected', { organizationId })
708
+ }
709
+
710
+ private renderEmployeeInfoSubtitle(viewModel: UserMenuViewModel) {
711
+ const organization = viewModel.organizations.find(
712
+ (candidate) => candidate.id === viewModel.currentOrganizationId,
713
+ )
714
+ return organization
715
+ ? html`<span class="menu-action-subtitle">在 ${organization.name} 的员工信息</span>`
716
+ : nothing
717
+ }
718
+
719
+ private readonly requestAccountSettings = (): void => {
720
+ this.dispatchPublicEvent('account-settings-requested', {})
721
+ }
722
+
723
+ private readonly requestEmployeeInfo = (): void => {
724
+ this.dispatchPublicEvent('employee-info-requested', {
725
+ organizationId: this.viewModel?.currentOrganizationId || null,
726
+ })
727
+ }
728
+
729
+ private readonly requestOrganizationCreation = (): void => {
730
+ if (this.onCreateOrganization) {
731
+ this.onCreateOrganization()
732
+ return
733
+ }
734
+ this.dispatchPublicEvent('organization-create-requested', {})
735
+ }
736
+
737
+ private readonly requestLogout = (): void => {
738
+ this.dispatchPublicEvent('logout-requested', {})
739
+ }
740
+
741
+ private readonly hideBrokenAvatar = (event: Event): void => {
742
+ if (event.currentTarget instanceof HTMLImageElement) event.currentTarget.hidden = true
743
+ }
744
+
745
+ private readonly showLoadedAvatar = (event: Event): void => {
746
+ if (event.currentTarget instanceof HTMLImageElement) event.currentTarget.hidden = false
747
+ }
748
+
749
+ private dispatchPublicEvent<K extends keyof UserMenuEventMap>(
750
+ type: K,
751
+ detail: UserMenuEventMap[K]['detail'],
752
+ ): void {
753
+ this.dispatchEvent(
754
+ new CustomEvent(type, {
755
+ detail,
756
+ bubbles: true,
757
+ composed: true,
758
+ }),
759
+ )
760
+ }
761
+ }
762
+
763
+ function normalizeOrganizations(
764
+ organizations: readonly UserMenuOrganization[],
765
+ ): readonly ResolvedUserMenuOrganization[] {
766
+ const ids = new Set<string>()
767
+ return Object.freeze(
768
+ organizations.map((organization, index) => {
769
+ if (!organization || typeof organization !== 'object') {
770
+ throw new TypeError(`Organization ${index + 1} is invalid`)
771
+ }
772
+ const id = normalizeRequiredText(organization.id, 'Organization ID', 128)
773
+ if (ids.has(id)) throw new TypeError('Organization IDs must be unique')
774
+ ids.add(id)
775
+ if (organization.type !== 'company' && organization.type !== 'team') {
776
+ throw new TypeError('Organization type must be company or team')
777
+ }
778
+ const memberCount = normalizeMemberCount(organization.memberCount)
779
+ const name = normalizeRequiredText(organization.name, 'Organization name', 120)
780
+ return Object.freeze({
781
+ id,
782
+ name,
783
+ type: organization.type,
784
+ avatarText:
785
+ normalizeOptionalText(organization.avatarText, 4) || Array.from(name)[0] || '组',
786
+ role: normalizeOptionalText(organization.role, 80),
787
+ memberCount,
788
+ verificationStatus:
789
+ organization.type === 'company'
790
+ ? normalizeVerificationStatus(organization.verificationStatus)
791
+ : null,
792
+ })
793
+ }),
794
+ )
795
+ }
796
+
797
+ function normalizeOrganizationCount(value: unknown, fallback: number): number {
798
+ if (value === undefined) return fallback
799
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < fallback) {
800
+ throw new TypeError('user-menu organizationCount must include every provided organization')
801
+ }
802
+ return value
803
+ }
804
+
805
+ function normalizeVerificationStatus(
806
+ value: unknown,
807
+ ): 'unverified' | 'pending' | 'rejected' | 'verified' | 'expired' | null {
808
+ if (value === undefined || value === null) return null
809
+ if (!['unverified', 'pending', 'rejected', 'verified', 'expired'].includes(String(value))) {
810
+ throw new TypeError('Organization verification status is invalid')
811
+ }
812
+ return value as 'unverified' | 'pending' | 'rejected' | 'verified' | 'expired'
813
+ }
814
+
815
+ function verificationStatusLabel(
816
+ status: 'unverified' | 'pending' | 'rejected' | 'verified' | 'expired',
817
+ ): string {
818
+ if (status === 'verified') return '已认证'
819
+ if (status === 'pending') return '审核中'
820
+ if (status === 'rejected') return '未通过'
821
+ if (status === 'expired') return '已过期'
822
+ return '未认证'
823
+ }
824
+
825
+ function normalizeMemberCount(value: unknown): number | null {
826
+ if (value === undefined || value === null) return null
827
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
828
+ throw new TypeError('Organization member count must be a non-negative integer')
829
+ }
830
+ return value
831
+ }
832
+
833
+ function normalizeMaskedMobile(value: unknown): string {
834
+ const mobile = normalizeOptionalText(value, 40)
835
+ if (/^1[3-9]\d{9}$/.test(mobile)) {
836
+ throw new TypeError('user-menu only accepts a masked mobile number')
837
+ }
838
+ return mobile
839
+ }
840
+
841
+ function normalizeRequiredText(value: unknown, label: string, maxLength: number): string {
842
+ const normalized = normalizeOptionalText(value, maxLength)
843
+ if (!normalized) throw new TypeError(`${label} is required`)
844
+ return normalized
845
+ }
846
+
847
+ function normalizeOptionalText(value: unknown, maxLength: number): string {
848
+ if (value === undefined || value === null) return ''
849
+ if (typeof value !== 'string') throw new TypeError('user-menu text values must be strings')
850
+ const normalized = value.trim()
851
+ if (normalized.length > maxLength) throw new TypeError('user-menu text value is too long')
852
+ return normalized
853
+ }
854
+
855
+ function organizationMeta(organization: ResolvedUserMenuOrganization): string {
856
+ const values: string[] = []
857
+ if (organization.role) values.push(organization.role)
858
+ if (organization.memberCount !== null) values.push(`${organization.memberCount} 人`)
859
+ if (organization.type === 'company' && organization.verificationStatus === 'unverified') {
860
+ values.push('认证后可办理业务')
861
+ }
862
+ return values.join(' · ')
863
+ }
864
+
865
+ function organizationNeedsAttention(organization: ResolvedUserMenuOrganization): boolean {
866
+ return (
867
+ organization.type === 'company' &&
868
+ ['unverified', 'rejected', 'expired'].includes(organization.verificationStatus ?? '')
869
+ )
870
+ }
871
+
872
+ function verificationStatusIcon(
873
+ status: 'unverified' | 'pending' | 'rejected' | 'verified' | 'expired',
874
+ ): PrototypeIconName {
875
+ if (status === 'verified') return 'discount-check-filled'
876
+ if (status === 'pending') return 'clock-hour-4'
877
+ if (status === 'rejected') return 'alert-triangle'
878
+ if (status === 'expired') return 'clock-exclamation'
879
+ return 'alert-circle'
880
+ }