@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,1540 @@
1
+ import { LitElement, css, html, nothing } from 'lit'
2
+ import type {
3
+ ApplicationLaunchResult,
4
+ ApplicationSwitcherConfig,
5
+ } from './application-switcher-transport'
6
+ import { UniplatApplicationSwitcher } from './application-switcher'
7
+ import {
8
+ INTERNAL_ORGANIZATION_ONBOARDING_TAG,
9
+ type InternalOrganizationOnboarding,
10
+ } from './organization-onboarding'
11
+ import type { OrganizationOnboardingTransport } from './organization-onboarding-transport'
12
+ import type { EnterpriseVerificationStatus } from './organization-selector'
13
+ import { prototypeIcon, type PrototypeIconName } from './prototype-icons'
14
+ import { normalizeSafeImageUrl } from './safe-image-url'
15
+ import { UNIPLAT_APPLICATION_TICKET_FRAGMENT_KEY } from './uniplat-base-application-ticket'
16
+ import type { UniplatUserMenu, UserMenuConfig } from './user-menu'
17
+
18
+ const INTERNAL_APPLICATION_SWITCHER_TAG = 'uniplat-internal-application-switcher'
19
+
20
+ if (
21
+ typeof customElements !== 'undefined' &&
22
+ !customElements.get(INTERNAL_APPLICATION_SWITCHER_TAG)
23
+ ) {
24
+ customElements.define(INTERNAL_APPLICATION_SWITCHER_TAG, UniplatApplicationSwitcher)
25
+ }
26
+
27
+ export type AccountContextOrganizationType = 'enterprise' | 'team'
28
+
29
+ export interface AccountContextOrganization {
30
+ id: string
31
+ name: string
32
+ type: AccountContextOrganizationType
33
+ /**
34
+ * @deprecated The Host must pass the server-projected organizations for applicationName.
35
+ * This compatibility field is ignored.
36
+ */
37
+ applicationNames?: readonly string[]
38
+ avatarText?: string
39
+ employeeDisplayName?: string
40
+ employeeAvatarText?: string
41
+ employeeAvatarUrl?: string
42
+ /** Enterprise name shown in the employee-information dialog. */
43
+ companyName?: string
44
+ /** Current employee's department within this organization. */
45
+ employeeDepartmentName?: string
46
+ /** Current employee's employment state, for example `active`. */
47
+ employeeEmploymentStatus?: string
48
+ verificationStatus?: EnterpriseVerificationStatus
49
+ role?: string
50
+ memberCount?: number
51
+ }
52
+
53
+ export interface AccountContextUser {
54
+ displayName: string
55
+ maskedMobile?: string
56
+ avatarText?: string
57
+ avatarUrl?: string
58
+ }
59
+
60
+ export interface AccountContextEmployeeInfo {
61
+ departmentName: string | null
62
+ }
63
+
64
+ export interface AccountContextProjection {
65
+ applicationName: string
66
+ user: AccountContextUser
67
+ currentOrganization: AccountContextOrganization | null
68
+ currentOrganizationId: string | null
69
+ organizations: readonly AccountContextOrganization[]
70
+ }
71
+
72
+ export interface AccountContextTransport {
73
+ load(applicationName: string): Promise<AccountContextProjection>
74
+ /**
75
+ * Loads the current JWT-bound employee's details only when the employee
76
+ * information dialog is opened.
77
+ */
78
+ loadCurrentEmployeeInfo?(): Promise<AccountContextEmployeeInfo>
79
+ switchOrganization?(
80
+ applicationName: string,
81
+ organizationId: string,
82
+ ): Promise<AccountContextProjection>
83
+ /**
84
+ * Opens the current application's personal-account route with a short-lived
85
+ * navigation ticket. The optional argument preserves existing custom
86
+ * transports that do not need the application identity.
87
+ */
88
+ launchAccountSettings?(applicationName?: string): Promise<ApplicationLaunchResult>
89
+ }
90
+
91
+ export interface AccountContextConfig {
92
+ applicationName: string
93
+ /**
94
+ * Loads the user profile and the application-projected organization context.
95
+ * The Host supplies authentication only through the transport's token provider.
96
+ */
97
+ transport?: AccountContextTransport
98
+ /** @deprecated Prefer transport so the component owns identity-data loading. */
99
+ displayName?: string
100
+ maskedMobile?: string
101
+ avatarText?: string
102
+ avatarUrl?: string
103
+ organization?: AccountContextOrganization
104
+ organizations?: readonly AccountContextOrganization[]
105
+ organizationCount?: number
106
+ showWorkbench?: boolean
107
+ showVerificationStatus?: boolean
108
+ /** Enables the integrated workbench application panel. */
109
+ workbench?: ApplicationSwitcherConfig
110
+ /**
111
+ * Creates enterprises and teams from the account menu's internal onboarding flow.
112
+ * The onboarding UI is intentionally not exposed as a standalone custom element.
113
+ */
114
+ organizationOnboarding?: {
115
+ transport: OrganizationOnboardingTransport
116
+ }
117
+ }
118
+
119
+ export interface AccountContextEventMap {
120
+ 'account-menu-requested': CustomEvent<{ organizationId: string | null }>
121
+ 'account-settings-requested': CustomEvent<Record<string, never>>
122
+ 'employee-info-requested': CustomEvent<{ organizationId: string | null }>
123
+ 'organization-selected': CustomEvent<{ organizationId: string }>
124
+ 'logout-requested': CustomEvent<Record<string, never>>
125
+ 'workbench-requested': CustomEvent<{ organizationId: string }>
126
+ 'enterprise-authentication-requested': CustomEvent<{
127
+ organizationId: string
128
+ reason: 'unverified' | 'rejected' | 'expired'
129
+ }>
130
+ error: CustomEvent<{
131
+ code: 'LOAD_FAILED' | 'SWITCH_FAILED' | 'ONBOARDING_UNAVAILABLE' | 'ACCOUNT_SETTINGS_FAILED'
132
+ message: string
133
+ }>
134
+ }
135
+
136
+ interface ResolvedAccountContext {
137
+ displayName: string
138
+ maskedMobile: string
139
+ avatarText: string
140
+ avatarUrl: string
141
+ organization: ResolvedAccountContextOrganization | null
142
+ organizations: readonly ResolvedAccountContextOrganization[]
143
+ organizationCount: number
144
+ showWorkbench: boolean
145
+ showVerificationStatus: boolean
146
+ }
147
+
148
+ interface ResolvedAccountContextOrganization {
149
+ id: string
150
+ name: string
151
+ type: AccountContextOrganizationType
152
+ avatarText: string
153
+ employeeDisplayName: string
154
+ employeeAvatarText: string
155
+ employeeAvatarUrl: string
156
+ companyName: string
157
+ employeeDepartmentName: string
158
+ employeeEmploymentStatus: string
159
+ verificationStatus: EnterpriseVerificationStatus | null
160
+ role: string
161
+ memberCount: number | null
162
+ }
163
+
164
+ type ActivePanel = 'workbench' | 'account' | 'onboarding' | 'employee-info' | null
165
+
166
+ export class UniplatAccountContext extends LitElement {
167
+ static override properties = {
168
+ viewModel: { state: true },
169
+ activePanel: { state: true },
170
+ employeeInfo: { state: true },
171
+ }
172
+
173
+ static override styles = css`
174
+ :host {
175
+ --uniplat-account-context-primary: #2e6bf5;
176
+ --uniplat-account-context-text: #25303f;
177
+ --uniplat-account-context-muted: #7a8794;
178
+ --uniplat-account-context-border: #dce5f0;
179
+ --uniplat-account-context-panel-offset: 10px;
180
+ --uniplat-account-context-font-family:
181
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
182
+ position: relative;
183
+ display: inline-block;
184
+ color: var(--uniplat-account-context-text);
185
+ font-family: var(--uniplat-account-context-font-family);
186
+ line-height: 1.25;
187
+ }
188
+
189
+ * {
190
+ box-sizing: border-box;
191
+ }
192
+
193
+ button {
194
+ appearance: none;
195
+ font: inherit;
196
+ cursor: pointer;
197
+ }
198
+
199
+ .context {
200
+ display: inline-flex;
201
+ align-items: center;
202
+ gap: 10px;
203
+ max-width: 100%;
204
+ }
205
+
206
+ .context.personal {
207
+ gap: 12px;
208
+ }
209
+
210
+ .create,
211
+ .workbench {
212
+ display: inline-flex;
213
+ align-items: center;
214
+ justify-content: center;
215
+ white-space: nowrap;
216
+ font-size: 13px;
217
+ font-weight: 600;
218
+ }
219
+
220
+ .create {
221
+ gap: 6px;
222
+ min-height: 30px;
223
+ padding: 0 13px;
224
+ border: 1px solid var(--uniplat-account-context-primary);
225
+ border-radius: 15px;
226
+ color: var(--uniplat-account-context-primary);
227
+ background: #ffffff;
228
+ }
229
+
230
+ .workbench {
231
+ gap: 6px;
232
+ padding: 9px 15px;
233
+ border: 0;
234
+ border-radius: 19px;
235
+ color: #ffffff;
236
+ background: var(--uniplat-account-context-primary);
237
+ box-shadow: 0 6px 16px rgb(46 107 245 / 25%);
238
+ }
239
+
240
+ .workbench:hover,
241
+ .workbench:focus-visible,
242
+ .workbench[aria-expanded='true'] {
243
+ outline: none;
244
+ background: #245edc;
245
+ }
246
+
247
+ .account {
248
+ position: relative;
249
+ display: inline-flex;
250
+ align-items: center;
251
+ gap: 9px;
252
+ min-width: 0;
253
+ height: 42px;
254
+ padding: 0 11px 0 4px;
255
+ border: 1px solid var(--uniplat-account-context-border);
256
+ border-radius: 21px;
257
+ color: inherit;
258
+ background: #f7faff;
259
+ text-align: left;
260
+ }
261
+
262
+ .account.personal {
263
+ height: 30px;
264
+ padding: 0;
265
+ border: 0;
266
+ border-radius: 0;
267
+ background: transparent;
268
+ }
269
+
270
+ .account:hover,
271
+ .account:focus-visible,
272
+ .account[aria-expanded='true'] {
273
+ border-color: #bfcfed;
274
+ outline: none;
275
+ background: #f1f5fd;
276
+ }
277
+
278
+ .account.personal:hover,
279
+ .account.personal:focus-visible {
280
+ border-color: transparent;
281
+ background: transparent;
282
+ }
283
+
284
+ .attention-dot {
285
+ position: absolute;
286
+ top: -2px;
287
+ right: 5px;
288
+ width: 7px;
289
+ height: 7px;
290
+ border: 2px solid #ffffff;
291
+ border-radius: 50%;
292
+ background: #e5484d;
293
+ }
294
+
295
+ .avatar {
296
+ position: relative;
297
+ display: grid;
298
+ place-items: center;
299
+ width: 32px;
300
+ height: 32px;
301
+ flex: none;
302
+ border-radius: 50%;
303
+ color: #ffffff;
304
+ background: #233043;
305
+ font-size: 12.5px;
306
+ font-weight: 700;
307
+ overflow: hidden;
308
+ }
309
+
310
+ .avatar-fallback {
311
+ display: grid;
312
+ width: 100%;
313
+ height: 100%;
314
+ place-items: center;
315
+ }
316
+
317
+ .avatar-image {
318
+ position: absolute;
319
+ inset: 0;
320
+ display: block;
321
+ width: 100%;
322
+ height: 100%;
323
+ border-radius: inherit;
324
+ object-fit: cover;
325
+ }
326
+
327
+ .avatar-image[hidden] {
328
+ display: none;
329
+ }
330
+
331
+ .copy {
332
+ display: flex;
333
+ min-width: 0;
334
+ flex-direction: column;
335
+ line-height: 1.3;
336
+ }
337
+
338
+ .display-name {
339
+ overflow: hidden;
340
+ max-width: 120px;
341
+ font-size: 12.5px;
342
+ font-weight: 700;
343
+ text-overflow: ellipsis;
344
+ white-space: nowrap;
345
+ }
346
+
347
+ .organization-line {
348
+ display: inline-flex;
349
+ align-items: center;
350
+ gap: 4px;
351
+ max-width: 212px;
352
+ margin-top: 1px;
353
+ }
354
+
355
+ .organization-name {
356
+ min-width: 0;
357
+ overflow: hidden;
358
+ color: var(--uniplat-account-context-muted);
359
+ font-size: 11px;
360
+ text-overflow: ellipsis;
361
+ white-space: nowrap;
362
+ }
363
+
364
+ .organization-name.normal {
365
+ color: var(--uniplat-account-context-text);
366
+ font-weight: 600;
367
+ }
368
+
369
+ .status-icon {
370
+ display: grid;
371
+ width: 17px;
372
+ height: 17px;
373
+ flex: none;
374
+ place-items: center;
375
+ border-radius: 5px;
376
+ font-size: 10px;
377
+ font-weight: 800;
378
+ }
379
+
380
+ .status-icon.verified,
381
+ .status-icon.team {
382
+ color: #0ca678;
383
+ background: #e3f7ef;
384
+ }
385
+
386
+ .status-icon.pending {
387
+ color: #1971c2;
388
+ background: #e2effb;
389
+ }
390
+
391
+ .status-icon.unverified,
392
+ .status-icon.expired {
393
+ color: #e8590c;
394
+ background: #fff4e2;
395
+ }
396
+
397
+ .status-icon.rejected {
398
+ color: #e5484d;
399
+ background: #fdeced;
400
+ }
401
+
402
+ .status-icon svg {
403
+ width: 12px;
404
+ height: 12px;
405
+ }
406
+
407
+ .count {
408
+ display: inline-flex;
409
+ align-items: center;
410
+ justify-content: center;
411
+ flex: none;
412
+ padding: 1px 6px;
413
+ border-radius: 9px;
414
+ color: var(--uniplat-account-context-primary);
415
+ background: #e6eefe;
416
+ font-size: 11px;
417
+ font-weight: 700;
418
+ }
419
+
420
+ .selector {
421
+ width: 14px;
422
+ height: 14px;
423
+ flex: none;
424
+ color: #8b98a6;
425
+ }
426
+
427
+ .floating-panel {
428
+ position: absolute;
429
+ z-index: 1000;
430
+ top: calc(100% + var(--uniplat-account-context-panel-offset));
431
+ right: 0;
432
+ }
433
+
434
+ .account-panel {
435
+ width: min(340px, calc(100vw - 24px));
436
+ }
437
+
438
+ .workbench-panel {
439
+ width: min(328px, calc(100vw - 24px));
440
+ }
441
+
442
+ .onboarding-panel {
443
+ width: min(400px, calc(100vw - 24px));
444
+ }
445
+
446
+ .employee-info-modal {
447
+ position: fixed;
448
+ z-index: 1100;
449
+ inset: 0;
450
+ display: grid;
451
+ width: 100vw;
452
+ max-width: none;
453
+ height: 100dvh;
454
+ max-height: none;
455
+ place-items: center;
456
+ margin: 0;
457
+ padding: 24px;
458
+ border: 0;
459
+ background: transparent;
460
+ }
461
+
462
+ .employee-info-modal::backdrop {
463
+ background: rgb(20 35 56 / 56%);
464
+ }
465
+
466
+ .employee-info-modal:not([open]) {
467
+ display: none;
468
+ }
469
+
470
+ .employee-info-dialog {
471
+ width: min(560px, calc(100vw - 32px));
472
+ overflow: hidden;
473
+ border-radius: 16px;
474
+ background: #ffffff;
475
+ box-shadow: 0 24px 60px rgb(13 31 56 / 26%);
476
+ }
477
+
478
+ .employee-info-header {
479
+ display: flex;
480
+ align-items: center;
481
+ gap: 12px;
482
+ padding: 22px 24px;
483
+ border-bottom: 1px solid #e6edf6;
484
+ }
485
+
486
+ .employee-info-icon {
487
+ display: grid;
488
+ width: 40px;
489
+ height: 40px;
490
+ flex: none;
491
+ place-items: center;
492
+ border-radius: 11px;
493
+ color: #2e6bf5;
494
+ background: #eaf1ff;
495
+ }
496
+
497
+ .employee-info-icon svg {
498
+ width: 21px;
499
+ height: 21px;
500
+ }
501
+
502
+ .employee-info-heading {
503
+ min-width: 0;
504
+ flex: 1;
505
+ }
506
+
507
+ .employee-info-title {
508
+ overflow: hidden;
509
+ color: #25303f;
510
+ font-size: 17px;
511
+ font-weight: 700;
512
+ text-overflow: ellipsis;
513
+ white-space: nowrap;
514
+ }
515
+
516
+ .employee-info-company {
517
+ overflow: hidden;
518
+ margin-top: 4px;
519
+ color: #8997a8;
520
+ font-size: 12px;
521
+ text-overflow: ellipsis;
522
+ white-space: nowrap;
523
+ }
524
+
525
+ .employment-status {
526
+ display: inline-flex;
527
+ align-items: center;
528
+ gap: 5px;
529
+ flex: none;
530
+ padding: 5px 9px;
531
+ border-radius: 5px;
532
+ color: #169b68;
533
+ background: #e5f8ef;
534
+ font-size: 12px;
535
+ font-weight: 600;
536
+ }
537
+
538
+ .employment-status::before {
539
+ width: 5px;
540
+ height: 5px;
541
+ border-radius: 50%;
542
+ background: currentColor;
543
+ content: '';
544
+ }
545
+
546
+ .employment-status.inactive {
547
+ color: #7a8794;
548
+ background: #eef2f6;
549
+ }
550
+
551
+ .employee-info-close {
552
+ display: grid;
553
+ width: 28px;
554
+ height: 28px;
555
+ flex: none;
556
+ place-items: center;
557
+ padding: 0;
558
+ border: 0;
559
+ color: #8b98a6;
560
+ background: transparent;
561
+ }
562
+
563
+ .employee-info-close:hover,
564
+ .employee-info-close:focus-visible {
565
+ border-radius: 6px;
566
+ outline: none;
567
+ color: #526173;
568
+ background: #f1f5f9;
569
+ }
570
+
571
+ .employee-info-close svg {
572
+ width: 20px;
573
+ height: 20px;
574
+ }
575
+
576
+ .employee-info-details {
577
+ display: grid;
578
+ grid-template-columns: repeat(2, minmax(0, 1fr));
579
+ column-gap: 24px;
580
+ padding: 18px 24px;
581
+ }
582
+
583
+ .employee-info-detail {
584
+ display: flex;
585
+ align-items: center;
586
+ justify-content: space-between;
587
+ gap: 12px;
588
+ min-width: 0;
589
+ min-height: 46px;
590
+ border-bottom: 1px solid #edf1f5;
591
+ }
592
+
593
+ .employee-info-detail:nth-last-child(-n + 2) {
594
+ border-bottom: 0;
595
+ }
596
+
597
+ .employee-info-label {
598
+ flex: none;
599
+ color: #95a2b3;
600
+ font-size: 12px;
601
+ }
602
+
603
+ .employee-info-value {
604
+ min-width: 0;
605
+ overflow: hidden;
606
+ color: #25303f;
607
+ font-size: 12px;
608
+ font-weight: 600;
609
+ text-align: right;
610
+ text-overflow: ellipsis;
611
+ white-space: nowrap;
612
+ }
613
+
614
+ .employee-info-footer {
615
+ padding: 12px 24px 22px;
616
+ color: #9aa7b6;
617
+ font-size: 11px;
618
+ }
619
+
620
+ @media (width <= 480px) {
621
+ .employee-info-modal {
622
+ padding: 16px;
623
+ }
624
+
625
+ .employee-info-header {
626
+ padding: 18px;
627
+ }
628
+
629
+ .employee-info-details {
630
+ grid-template-columns: 1fr;
631
+ padding: 12px 18px;
632
+ }
633
+
634
+ .employee-info-detail:nth-last-child(2) {
635
+ border-bottom: 1px solid #edf1f5;
636
+ }
637
+
638
+ .employee-info-footer {
639
+ padding: 12px 18px 18px;
640
+ }
641
+ }
642
+
643
+ uniplat-user-menu,
644
+ uniplat-internal-application-switcher,
645
+ uniplat-internal-organization-onboarding {
646
+ width: 100%;
647
+ }
648
+
649
+ svg {
650
+ display: block;
651
+ width: 100%;
652
+ height: 100%;
653
+ fill: none;
654
+ stroke: currentColor;
655
+ stroke-linecap: round;
656
+ stroke-linejoin: round;
657
+ stroke-width: 2;
658
+ }
659
+
660
+ .create svg,
661
+ .workbench svg {
662
+ width: 14px;
663
+ height: 14px;
664
+ }
665
+
666
+ .workbench svg:last-child {
667
+ width: 13px;
668
+ height: 13px;
669
+ opacity: 0.8;
670
+ }
671
+ `
672
+
673
+ declare private viewModel: ResolvedAccountContext | null
674
+ declare private activePanel: ActivePanel
675
+ declare private employeeInfo: AccountContextEmployeeInfo | null
676
+
677
+ private workbenchConfig: ApplicationSwitcherConfig | null = null
678
+ private organizationOnboardingTransport: OrganizationOnboardingTransport | null = null
679
+ private applicationName = ''
680
+ private accountTransport: AccountContextTransport | null = null
681
+ private showWorkbench = true
682
+ private showVerificationStatus = true
683
+ private loadGeneration = 0
684
+ private configuredApplicationSwitcher: UniplatApplicationSwitcher | null = null
685
+ private configuredUserMenu: UniplatUserMenu | null = null
686
+ private configuredOrganizationOnboarding: InternalOrganizationOnboarding | null = null
687
+ private accountSettingsNavigationInFlight = false
688
+ private employeeInfoGeneration = 0
689
+
690
+ constructor() {
691
+ super()
692
+ this.viewModel = null
693
+ this.activePanel = null
694
+ this.employeeInfo = null
695
+ }
696
+
697
+ configure(config: AccountContextConfig): void {
698
+ if (!config) throw new TypeError('account-context requires a config object')
699
+ this.applicationName = normalizeRequiredText(config.applicationName, 'Application name', 128)
700
+ this.accountTransport = normalizeAccountTransport(config.transport)
701
+ this.showWorkbench = config.showWorkbench ?? true
702
+ this.showVerificationStatus = config.showVerificationStatus ?? true
703
+ this.workbenchConfig = normalizeWorkbench(config.workbench)
704
+ this.organizationOnboardingTransport = normalizeOrganizationOnboardingTransport(
705
+ config.organizationOnboarding?.transport,
706
+ )
707
+ this.configuredApplicationSwitcher = null
708
+ this.configuredUserMenu = null
709
+ this.configuredOrganizationOnboarding = null
710
+ this.activePanel = null
711
+ this.employeeInfo = null
712
+ this.employeeInfoGeneration += 1
713
+ this.loadGeneration += 1
714
+ if (this.accountTransport) {
715
+ this.viewModel = null
716
+ if (this.isConnected) void this.refresh()
717
+ return
718
+ }
719
+ this.applyManualConfig(config)
720
+ }
721
+
722
+ async refresh(): Promise<void> {
723
+ const transport = this.accountTransport
724
+ if (!transport) return
725
+ const generation = ++this.loadGeneration
726
+ try {
727
+ const projection = await transport.load(this.applicationName)
728
+ if (generation !== this.loadGeneration) return
729
+ this.applyProjection(projection)
730
+ } catch {
731
+ if (generation !== this.loadGeneration) return
732
+ this.dispatchPublicEvent('error', {
733
+ code: 'LOAD_FAILED',
734
+ message: '账户信息加载失败,请稍后重试。',
735
+ })
736
+ }
737
+ }
738
+
739
+ async switchOrganization(organizationId: string): Promise<void> {
740
+ const normalizedOrganizationId = normalizeRequiredText(organizationId, 'Organization id', 128)
741
+ const transport = this.accountTransport
742
+ if (!transport?.switchOrganization) {
743
+ throw new Error('Account context organization switching is unavailable')
744
+ }
745
+ const generation = ++this.loadGeneration
746
+ try {
747
+ const projection = await transport.switchOrganization(
748
+ this.applicationName,
749
+ normalizedOrganizationId,
750
+ )
751
+ if (generation !== this.loadGeneration) return
752
+ this.applyProjection(projection)
753
+ this.activePanel = null
754
+ } catch {
755
+ if (generation === this.loadGeneration) {
756
+ this.dispatchPublicEvent('error', {
757
+ code: 'SWITCH_FAILED',
758
+ message: '组织切换失败,当前登录状态未改变。',
759
+ })
760
+ }
761
+ throw new Error('Account context organization switch failed')
762
+ }
763
+ }
764
+
765
+ private applyProjection(projection: AccountContextProjection): void {
766
+ if (!projection || typeof projection !== 'object') {
767
+ throw new TypeError('account-context projection must be an object')
768
+ }
769
+ if (projection.applicationName !== this.applicationName) {
770
+ throw new TypeError('account-context projection applicationName does not match')
771
+ }
772
+ const currentOrganizationId = projection.currentOrganizationId
773
+ if (
774
+ (currentOrganizationId === null) !== (projection.currentOrganization === null) ||
775
+ (currentOrganizationId !== null &&
776
+ projection.currentOrganization?.id !== currentOrganizationId)
777
+ ) {
778
+ throw new TypeError('account-context projection current organization is inconsistent')
779
+ }
780
+ this.applyManualConfig({
781
+ applicationName: this.applicationName,
782
+ displayName: projection.user.displayName,
783
+ maskedMobile: projection.user.maskedMobile,
784
+ avatarText: projection.user.avatarText,
785
+ avatarUrl: projection.user.avatarUrl,
786
+ organization: projection.currentOrganization ?? undefined,
787
+ organizations: projection.organizations,
788
+ showWorkbench: this.showWorkbench,
789
+ showVerificationStatus: this.showVerificationStatus,
790
+ workbench: this.workbenchConfig ?? undefined,
791
+ organizationOnboarding: this.organizationOnboardingTransport
792
+ ? { transport: this.organizationOnboardingTransport }
793
+ : undefined,
794
+ })
795
+ }
796
+
797
+ private applyManualConfig(config: AccountContextConfig): void {
798
+ const userDisplayName = normalizeRequiredText(config.displayName, 'Display name', 80)
799
+ const configuredOrganization = normalizeOrganization(config.organization)
800
+ const organizations = normalizeOrganizations(config.organizations, configuredOrganization)
801
+ const organization = configuredOrganization
802
+ ? (organizations.find((entry) => entry.id === configuredOrganization.id) ??
803
+ configuredOrganization)
804
+ : null
805
+ const organizationCount = normalizeOrganizationCount(
806
+ config.organizationCount,
807
+ organization,
808
+ organizations.length,
809
+ )
810
+ const userAvatarText =
811
+ normalizeOptionalText(config.avatarText, 4) || Array.from(userDisplayName.trim())[0] || '用'
812
+ const userAvatarUrl = normalizeSafeImageUrl(config.avatarUrl, 'account-context avatar URL')
813
+ const employeeDisplayName = organization?.employeeDisplayName ?? ''
814
+ const displayName = employeeDisplayName || userDisplayName
815
+ this.viewModel = {
816
+ displayName,
817
+ maskedMobile: normalizeMaskedMobile(config.maskedMobile),
818
+ avatarText: employeeDisplayName
819
+ ? organization?.employeeAvatarText || Array.from(employeeDisplayName)[0] || '员'
820
+ : userAvatarText,
821
+ avatarUrl: employeeDisplayName ? (organization?.employeeAvatarUrl ?? '') : userAvatarUrl,
822
+ organization,
823
+ organizations,
824
+ organizationCount,
825
+ // An open application can have oid=0 while still exposing a global workbench.
826
+ // Whether that workbench is available is determined by the configured capability,
827
+ // not by the presence of a selected organization.
828
+ showWorkbench: config.showWorkbench ?? true,
829
+ showVerificationStatus: organization ? (config.showVerificationStatus ?? true) : false,
830
+ }
831
+ }
832
+
833
+ override connectedCallback(): void {
834
+ super.connectedCallback()
835
+ document.addEventListener('pointerdown', this.handleDocumentPointerDown, true)
836
+ document.addEventListener('keydown', this.handleDocumentKeyDown)
837
+ if (this.accountTransport && !this.viewModel) void this.refresh()
838
+ }
839
+
840
+ override disconnectedCallback(): void {
841
+ document.removeEventListener('pointerdown', this.handleDocumentPointerDown, true)
842
+ document.removeEventListener('keydown', this.handleDocumentKeyDown)
843
+ this.activePanel = null
844
+ this.employeeInfo = null
845
+ this.loadGeneration += 1
846
+ this.employeeInfoGeneration += 1
847
+ this.configuredApplicationSwitcher = null
848
+ this.configuredUserMenu = null
849
+ this.configuredOrganizationOnboarding = null
850
+ super.disconnectedCallback()
851
+ }
852
+
853
+ override render() {
854
+ const viewModel = this.viewModel
855
+ if (!viewModel) return nothing
856
+ const organization = viewModel.organization
857
+ const hasAvailableOrganizations = viewModel.organizations.length > 0
858
+ return html`
859
+ <div
860
+ class="context ${organization ? 'organization' : 'personal'}"
861
+ part="context"
862
+ aria-label="账户、工作台与当前组织"
863
+ >
864
+ ${
865
+ viewModel.showWorkbench && (organization || this.workbenchConfig)
866
+ ? html`<button
867
+ class="workbench"
868
+ part="workbench-action"
869
+ type="button"
870
+ aria-haspopup="menu"
871
+ aria-expanded=${String(this.activePanel === 'workbench')}
872
+ @click=${this.toggleWorkbench}
873
+ >
874
+ ${prototypeIcon('layout-grid')}工作台${prototypeIcon('chevron-down')}
875
+ </button>`
876
+ : !hasAvailableOrganizations
877
+ ? html`<button
878
+ class="create"
879
+ part="create-action"
880
+ type="button"
881
+ @click=${this.openOrganizationOnboarding}
882
+ >
883
+ ${prototypeIcon('plus')}创建团队 / 企业
884
+ </button>`
885
+ : nothing
886
+ }
887
+ <button
888
+ class="account ${organization ? '' : 'personal'}"
889
+ part="account-trigger"
890
+ type="button"
891
+ aria-haspopup="menu"
892
+ aria-expanded=${String(this.activePanel === 'account')}
893
+ @click=${this.toggleAccountMenu}
894
+ >
895
+ ${this.needsAttention(organization) ? html`<span class="attention-dot"></span>` : nothing}
896
+ <span class="avatar" part="avatar" aria-hidden="true">
897
+ <span class="avatar-fallback">${viewModel.avatarText}</span>
898
+ ${
899
+ viewModel.avatarUrl
900
+ ? html`<img
901
+ class="avatar-image"
902
+ part="avatar-image"
903
+ src=${viewModel.avatarUrl}
904
+ alt=""
905
+ referrerpolicy="no-referrer"
906
+ @load=${this.showLoadedAvatar}
907
+ @error=${this.hideBrokenAvatar}
908
+ />`
909
+ : nothing
910
+ }
911
+ </span>
912
+ <span class="copy">
913
+ <span class="display-name" part="display-name">${viewModel.displayName}</span>
914
+ ${
915
+ organization
916
+ ? this.renderOrganizationLine(organization, viewModel.showVerificationStatus)
917
+ : nothing
918
+ }
919
+ </span>
920
+ ${
921
+ organization && viewModel.organizationCount > 1
922
+ ? html`<span class="count" part="organization-count"
923
+ >${viewModel.organizationCount}</span
924
+ >`
925
+ : nothing
926
+ }
927
+ ${
928
+ organization
929
+ ? html`<span class="selector">${prototypeIcon('selector')}</span>`
930
+ : nothing
931
+ }
932
+ </button>
933
+ </div>
934
+ ${this.renderActivePanel()}
935
+ `
936
+ }
937
+
938
+ protected override updated(): void {
939
+ const applicationSwitcher = this.renderRoot.querySelector<UniplatApplicationSwitcher>(
940
+ INTERNAL_APPLICATION_SWITCHER_TAG,
941
+ )
942
+ if (applicationSwitcher && applicationSwitcher !== this.configuredApplicationSwitcher) {
943
+ const workbench = this.workbenchConfig
944
+ if (workbench) {
945
+ applicationSwitcher.configure({
946
+ ...workbench,
947
+ presentation: 'embedded',
948
+ showOrganizationSelector: false,
949
+ })
950
+ this.configuredApplicationSwitcher = applicationSwitcher
951
+ }
952
+ }
953
+ if (!applicationSwitcher) this.configuredApplicationSwitcher = null
954
+
955
+ const userMenu = this.renderRoot.querySelector<UniplatUserMenu>('uniplat-user-menu')
956
+ if (userMenu && userMenu !== this.configuredUserMenu && this.viewModel) {
957
+ userMenu.configure(this.toUserMenuConfig(this.viewModel))
958
+ this.configuredUserMenu = userMenu
959
+ }
960
+ if (!userMenu) this.configuredUserMenu = null
961
+
962
+ const onboarding = this.renderRoot.querySelector<InternalOrganizationOnboarding>(
963
+ INTERNAL_ORGANIZATION_ONBOARDING_TAG,
964
+ )
965
+ if (onboarding && onboarding !== this.configuredOrganizationOnboarding) {
966
+ const transport = this.organizationOnboardingTransport
967
+ if (transport) {
968
+ onboarding.configure({ transport, initialStep: 'method-selection' })
969
+ this.configuredOrganizationOnboarding = onboarding
970
+ }
971
+ }
972
+ if (!onboarding) this.configuredOrganizationOnboarding = null
973
+
974
+ const employeeInfoModal = this.renderRoot.querySelector<HTMLDialogElement>(
975
+ 'dialog.employee-info-modal',
976
+ )
977
+ if (employeeInfoModal && !employeeInfoModal.open) {
978
+ try {
979
+ employeeInfoModal.showModal()
980
+ } catch {
981
+ employeeInfoModal.setAttribute('open', '')
982
+ }
983
+ }
984
+ }
985
+
986
+ private renderOrganizationLine(
987
+ organization: ResolvedAccountContextOrganization,
988
+ showVerificationStatus: boolean,
989
+ ) {
990
+ const status = showVerificationStatus ? organizationStatus(organization) : null
991
+ return html`
992
+ <span class="organization-line">
993
+ <span
994
+ class=${`organization-name${status === 'verified' || status === 'team' ? ' normal' : ''}`}
995
+ part="organization-name"
996
+ >${organization.name}</span
997
+ >
998
+ ${
999
+ status
1000
+ ? html`<span
1001
+ class=${`status-icon ${status}`}
1002
+ part="verification-status"
1003
+ title=${organizationStatusDescription(status)}
1004
+ aria-label=${organizationStatusDescription(status)}
1005
+ >${prototypeIcon(organizationStatusIcon(status))}</span
1006
+ >`
1007
+ : nothing
1008
+ }
1009
+ </span>
1010
+ `
1011
+ }
1012
+
1013
+ private renderActivePanel() {
1014
+ if (this.activePanel === 'employee-info') return this.renderEmployeeInfoDialog()
1015
+ if (this.activePanel === 'workbench' && this.workbenchConfig) {
1016
+ return html`<div
1017
+ class="floating-panel workbench-panel"
1018
+ part="workbench-panel"
1019
+ @application-selected=${this.closePanelAfterAction}
1020
+ >
1021
+ <uniplat-internal-application-switcher></uniplat-internal-application-switcher>
1022
+ </div>`
1023
+ }
1024
+ if (this.activePanel === 'onboarding' && this.organizationOnboardingTransport) {
1025
+ return html`<div
1026
+ class="floating-panel onboarding-panel"
1027
+ part="onboarding-panel"
1028
+ @onboarding-skipped=${this.closePanelAfterAction}
1029
+ @organization-created=${this.refreshAfterOrganizationCreated}
1030
+ >
1031
+ <uniplat-internal-organization-onboarding></uniplat-internal-organization-onboarding>
1032
+ </div>`
1033
+ }
1034
+ if (this.activePanel === 'account') {
1035
+ return html`<div
1036
+ class="floating-panel account-panel"
1037
+ part="account-panel"
1038
+ @account-settings-requested=${this.handleAccountSettingsRequest}
1039
+ @employee-info-requested=${this.openEmployeeInfo}
1040
+ @organization-selected=${this.closePanelAfterAction}
1041
+ @logout-requested=${this.closePanelAfterAction}
1042
+ >
1043
+ <uniplat-user-menu></uniplat-user-menu>
1044
+ </div>`
1045
+ }
1046
+ return nothing
1047
+ }
1048
+
1049
+ private renderEmployeeInfoDialog() {
1050
+ const viewModel = this.viewModel
1051
+ const organization = viewModel?.organization
1052
+ if (!viewModel || !organization) return nothing
1053
+ const employmentStatus = employeeEmploymentStatus(organization.employeeEmploymentStatus)
1054
+ const employeeName = organization.employeeDisplayName || viewModel.displayName
1055
+ return html`<dialog
1056
+ class="employee-info-modal"
1057
+ part="employee-info-modal"
1058
+ @click=${this.closeEmployeeInfo}
1059
+ @cancel=${this.closeEmployeeInfo}
1060
+ >
1061
+ <section
1062
+ class="employee-info-dialog"
1063
+ part="employee-info-dialog"
1064
+ role="dialog"
1065
+ aria-modal="true"
1066
+ aria-labelledby="uniplat-employee-info-title"
1067
+ @click=${stopEventPropagation}
1068
+ >
1069
+ <header class="employee-info-header">
1070
+ <span class="employee-info-icon" aria-hidden="true">${prototypeIcon('id-badge-2')}</span>
1071
+ <span class="employee-info-heading">
1072
+ <span class="employee-info-title" id="uniplat-employee-info-title">我的员工信息</span>
1073
+ <span class="employee-info-company"
1074
+ >${organization.companyName || organization.name}</span
1075
+ >
1076
+ </span>
1077
+ <span class=${`employment-status ${employmentStatus.className}`}
1078
+ >${employmentStatus.label}</span
1079
+ >
1080
+ <button
1081
+ class="employee-info-close"
1082
+ part="employee-info-close"
1083
+ type="button"
1084
+ aria-label="关闭员工信息"
1085
+ @click=${this.closeEmployeeInfo}
1086
+ >
1087
+ ${prototypeIcon('x')}
1088
+ </button>
1089
+ </header>
1090
+ <div class="employee-info-details" part="employee-info-details">
1091
+ ${this.renderEmployeeInfoDetail('所属企业', organization.companyName || organization.name)}
1092
+ ${this.renderEmployeeInfoDetail(
1093
+ '部门',
1094
+ this.employeeInfo === null ? '加载中…' : (this.employeeInfo.departmentName ?? '未设置'),
1095
+ )}
1096
+ ${this.renderEmployeeInfoDetail('姓名', employeeName)}
1097
+ ${this.renderEmployeeInfoDetail('我的角色', organization.role || '成员')}
1098
+ </div>
1099
+ <footer class="employee-info-footer">
1100
+ 员工信息由企业管理员维护,如有误请联系管理员修改。
1101
+ </footer>
1102
+ </section>
1103
+ </dialog>`
1104
+ }
1105
+
1106
+ private renderEmployeeInfoDetail(label: string, value: string) {
1107
+ return html`<div class="employee-info-detail">
1108
+ <span class="employee-info-label">${label}</span>
1109
+ <span class="employee-info-value">${value}</span>
1110
+ </div>`
1111
+ }
1112
+
1113
+ private toUserMenuConfig(viewModel: ResolvedAccountContext): UserMenuConfig {
1114
+ return {
1115
+ displayName: viewModel.displayName,
1116
+ maskedMobile: viewModel.maskedMobile,
1117
+ avatarText: viewModel.avatarText,
1118
+ avatarUrl: viewModel.avatarUrl,
1119
+ currentOrganizationId: viewModel.organization?.id,
1120
+ organizationCount: viewModel.organizationCount,
1121
+ organizations: viewModel.organizations.map((organization) => ({
1122
+ id: organization.id,
1123
+ name: organization.name,
1124
+ type: organization.type === 'enterprise' ? 'company' : 'team',
1125
+ avatarText: organization.avatarText,
1126
+ role: organization.role,
1127
+ memberCount: organization.memberCount ?? undefined,
1128
+ verificationStatus: organization.verificationStatus ?? undefined,
1129
+ })),
1130
+ onCreateOrganization: this.openOrganizationOnboarding,
1131
+ }
1132
+ }
1133
+
1134
+ private needsAttention(organization: ResolvedAccountContextOrganization | null): boolean {
1135
+ return (
1136
+ organization?.type === 'enterprise' &&
1137
+ ['rejected', 'expired'].includes(organization.verificationStatus ?? '')
1138
+ )
1139
+ }
1140
+
1141
+ private readonly toggleWorkbench = (): void => {
1142
+ const organizationId = this.viewModel?.organization?.id
1143
+ // Preserve the existing event contract for organization-bound workbenches.
1144
+ // An oid=0 open application has no organization id to expose, but can still
1145
+ // open its own global application panel.
1146
+ if (organizationId) this.dispatchPublicEvent('workbench-requested', { organizationId })
1147
+ if (this.workbenchConfig) {
1148
+ this.activePanel = this.activePanel === 'workbench' ? null : 'workbench'
1149
+ }
1150
+ }
1151
+
1152
+ private readonly toggleAccountMenu = (): void => {
1153
+ this.dispatchPublicEvent('account-menu-requested', {
1154
+ organizationId: this.viewModel?.organization?.id ?? null,
1155
+ })
1156
+ this.activePanel = this.activePanel === 'account' ? null : 'account'
1157
+ }
1158
+
1159
+ startOrganizationOnboarding(): void {
1160
+ if (!this.organizationOnboardingTransport) {
1161
+ this.dispatchPublicEvent('error', {
1162
+ code: 'ONBOARDING_UNAVAILABLE',
1163
+ message: '暂时无法启动创建团队或企业流程。',
1164
+ })
1165
+ return
1166
+ }
1167
+ this.activePanel = 'onboarding'
1168
+ }
1169
+
1170
+ private readonly openOrganizationOnboarding = (): void => {
1171
+ this.startOrganizationOnboarding()
1172
+ }
1173
+
1174
+ private readonly refreshAfterOrganizationCreated = (): void => {
1175
+ void this.refresh()
1176
+ }
1177
+
1178
+ private readonly closePanelAfterAction = (): void => {
1179
+ this.activePanel = null
1180
+ }
1181
+
1182
+ private readonly openEmployeeInfo = (): void => {
1183
+ if (!this.viewModel?.organization) {
1184
+ this.activePanel = null
1185
+ return
1186
+ }
1187
+ this.activePanel = 'employee-info'
1188
+ this.employeeInfo = null
1189
+ void this.loadCurrentEmployeeInfo()
1190
+ }
1191
+
1192
+ private readonly closeEmployeeInfo = (): void => {
1193
+ this.activePanel = null
1194
+ this.employeeInfoGeneration += 1
1195
+ }
1196
+
1197
+ private async loadCurrentEmployeeInfo(): Promise<void> {
1198
+ const loader = this.accountTransport?.loadCurrentEmployeeInfo
1199
+ if (!loader) {
1200
+ this.employeeInfo = { departmentName: null }
1201
+ return
1202
+ }
1203
+ const generation = ++this.employeeInfoGeneration
1204
+ try {
1205
+ const employeeInfo = normalizeEmployeeInfo(await loader())
1206
+ if (generation !== this.employeeInfoGeneration || this.activePanel !== 'employee-info') return
1207
+ this.employeeInfo = employeeInfo
1208
+ } catch {
1209
+ if (generation !== this.employeeInfoGeneration || this.activePanel !== 'employee-info') return
1210
+ this.employeeInfo = { departmentName: null }
1211
+ }
1212
+ }
1213
+
1214
+ private readonly handleAccountSettingsRequest = (event: Event): void => {
1215
+ const launcher = this.accountTransport?.launchAccountSettings
1216
+ if (!launcher || this.accountSettingsNavigationInFlight) {
1217
+ this.closePanelAfterAction()
1218
+ return
1219
+ }
1220
+ event.stopPropagation()
1221
+ this.activePanel = null
1222
+ this.accountSettingsNavigationInFlight = true
1223
+ const accountSettingsWindow = openPendingAccountSettingsWindow()
1224
+ void this.openAccountSettings(launcher, accountSettingsWindow)
1225
+ }
1226
+
1227
+ private async openAccountSettings(
1228
+ launcher: NonNullable<AccountContextTransport['launchAccountSettings']>,
1229
+ accountSettingsWindow: Window | null,
1230
+ ): Promise<void> {
1231
+ let pendingWindow = accountSettingsWindow
1232
+ try {
1233
+ const launch = await launcher(this.applicationName)
1234
+ navigateToAccountSettings(launch.url, pendingWindow)
1235
+ pendingWindow = null
1236
+ } catch {
1237
+ this.dispatchPublicEvent('error', {
1238
+ code: 'ACCOUNT_SETTINGS_FAILED',
1239
+ message: '个人账号设置暂时无法打开,请稍后重试。',
1240
+ })
1241
+ } finally {
1242
+ closePendingAccountSettingsWindow(pendingWindow)
1243
+ this.accountSettingsNavigationInFlight = false
1244
+ }
1245
+ }
1246
+
1247
+ private readonly hideBrokenAvatar = (event: Event): void => {
1248
+ if (event.currentTarget instanceof HTMLImageElement) event.currentTarget.hidden = true
1249
+ }
1250
+
1251
+ private readonly showLoadedAvatar = (event: Event): void => {
1252
+ if (event.currentTarget instanceof HTMLImageElement) event.currentTarget.hidden = false
1253
+ }
1254
+
1255
+ private readonly handleDocumentPointerDown = (event: PointerEvent): void => {
1256
+ if (this.activePanel && !event.composedPath().includes(this)) this.activePanel = null
1257
+ }
1258
+
1259
+ private readonly handleDocumentKeyDown = (event: KeyboardEvent): void => {
1260
+ if (event.key !== 'Escape' || !this.activePanel) return
1261
+ const activePanel = this.activePanel
1262
+ this.activePanel = null
1263
+ const selector = activePanel === 'workbench' ? '.workbench' : '.account'
1264
+ void this.updateComplete.then(() =>
1265
+ this.renderRoot.querySelector<HTMLButtonElement>(selector)?.focus(),
1266
+ )
1267
+ }
1268
+
1269
+ private dispatchPublicEvent<K extends keyof AccountContextEventMap>(
1270
+ type: K,
1271
+ detail: AccountContextEventMap[K]['detail'],
1272
+ ): void {
1273
+ this.dispatchEvent(
1274
+ new CustomEvent(type, {
1275
+ detail,
1276
+ bubbles: true,
1277
+ composed: true,
1278
+ }),
1279
+ )
1280
+ }
1281
+ }
1282
+
1283
+ function normalizeAccountTransport(
1284
+ transport: AccountContextTransport | undefined,
1285
+ ): AccountContextTransport | null {
1286
+ if (transport === undefined) return null
1287
+ if (!transport || typeof transport.load !== 'function') {
1288
+ throw new TypeError('account-context transport requires load()')
1289
+ }
1290
+ if (
1291
+ transport.switchOrganization !== undefined &&
1292
+ typeof transport.switchOrganization !== 'function'
1293
+ ) {
1294
+ throw new TypeError('account-context transport switchOrganization must be a function')
1295
+ }
1296
+ if (
1297
+ transport.loadCurrentEmployeeInfo !== undefined &&
1298
+ typeof transport.loadCurrentEmployeeInfo !== 'function'
1299
+ ) {
1300
+ throw new TypeError('account-context transport loadCurrentEmployeeInfo must be a function')
1301
+ }
1302
+ if (
1303
+ transport.launchAccountSettings !== undefined &&
1304
+ typeof transport.launchAccountSettings !== 'function'
1305
+ ) {
1306
+ throw new TypeError('account-context transport launchAccountSettings must be a function')
1307
+ }
1308
+ return transport
1309
+ }
1310
+
1311
+ function normalizeWorkbench(
1312
+ workbench: ApplicationSwitcherConfig | undefined,
1313
+ ): ApplicationSwitcherConfig | null {
1314
+ if (workbench === undefined) return null
1315
+ if (!workbench || typeof workbench.getAccessToken !== 'function') {
1316
+ throw new TypeError('account-context workbench requires getAccessToken()')
1317
+ }
1318
+ return workbench
1319
+ }
1320
+
1321
+ function normalizeOrganizationOnboardingTransport(
1322
+ transport: OrganizationOnboardingTransport | undefined,
1323
+ ): OrganizationOnboardingTransport | null {
1324
+ if (transport === undefined) return null
1325
+ if (
1326
+ !transport ||
1327
+ typeof transport.createEnterprise !== 'function' ||
1328
+ typeof transport.createTeam !== 'function'
1329
+ ) {
1330
+ throw new TypeError(
1331
+ 'account-context organizationOnboarding requires createEnterprise() and createTeam()',
1332
+ )
1333
+ }
1334
+ return transport
1335
+ }
1336
+
1337
+ function normalizeOrganization(
1338
+ organization: AccountContextOrganization | undefined,
1339
+ ): ResolvedAccountContextOrganization | null {
1340
+ if (organization === undefined) return null
1341
+ return normalizeOrganizationEntry(organization, 'account-context organization')
1342
+ }
1343
+
1344
+ function normalizeOrganizations(
1345
+ organizations: readonly AccountContextOrganization[] | undefined,
1346
+ current: ResolvedAccountContextOrganization | null,
1347
+ ): readonly ResolvedAccountContextOrganization[] {
1348
+ if (organizations === undefined) {
1349
+ return current ? Object.freeze([current]) : Object.freeze([])
1350
+ }
1351
+ if (!Array.isArray(organizations)) {
1352
+ throw new TypeError('account-context organizations must be an array')
1353
+ }
1354
+ const allIds = new Set<string>()
1355
+ const normalized = organizations.map((organization, index) => {
1356
+ const entry = normalizeOrganizationEntry(
1357
+ organization,
1358
+ `account-context organization ${index + 1}`,
1359
+ )
1360
+ if (allIds.has(entry.id)) throw new TypeError('account-context organization IDs must be unique')
1361
+ allIds.add(entry.id)
1362
+ return entry
1363
+ })
1364
+ if (current && !allIds.has(current.id)) {
1365
+ throw new TypeError('account-context organizations must include the current organization')
1366
+ }
1367
+ return Object.freeze(normalized)
1368
+ }
1369
+
1370
+ function normalizeOrganizationEntry(
1371
+ organization: AccountContextOrganization,
1372
+ label: string,
1373
+ ): ResolvedAccountContextOrganization {
1374
+ if (!organization || typeof organization !== 'object') {
1375
+ throw new TypeError(`${label} must be an object`)
1376
+ }
1377
+ if (!['enterprise', 'team'].includes(organization.type)) {
1378
+ throw new TypeError(`${label} type is invalid`)
1379
+ }
1380
+ if (organization.type === 'enterprise' && !organization.verificationStatus) {
1381
+ throw new TypeError(`${label} enterprise verificationStatus is required`)
1382
+ }
1383
+ if (
1384
+ organization.verificationStatus &&
1385
+ !['unverified', 'pending', 'rejected', 'verified', 'expired'].includes(
1386
+ organization.verificationStatus,
1387
+ )
1388
+ ) {
1389
+ throw new TypeError(`${label} verificationStatus is invalid`)
1390
+ }
1391
+ const name = normalizeRequiredText(organization.name, 'Organization name', 100)
1392
+ const employeeDisplayName = normalizeOptionalText(organization.employeeDisplayName, 80)
1393
+ const employeeAvatarText = normalizeOptionalText(organization.employeeAvatarText, 4)
1394
+ const employeeAvatarUrl = normalizeSafeImageUrl(
1395
+ organization.employeeAvatarUrl,
1396
+ 'account-context employee avatar URL',
1397
+ )
1398
+ if (!employeeDisplayName && (employeeAvatarText || employeeAvatarUrl)) {
1399
+ throw new TypeError(`${label} employeeDisplayName is required for an employee avatar`)
1400
+ }
1401
+ return Object.freeze({
1402
+ id: normalizeRequiredText(organization.id, 'Organization id', 128),
1403
+ name,
1404
+ type: organization.type,
1405
+ avatarText: normalizeOptionalText(organization.avatarText, 4) || Array.from(name)[0] || '组',
1406
+ employeeDisplayName,
1407
+ employeeAvatarText:
1408
+ employeeAvatarText || (employeeDisplayName ? Array.from(employeeDisplayName)[0] || '员' : ''),
1409
+ employeeAvatarUrl,
1410
+ companyName: normalizeOptionalText(organization.companyName, 160),
1411
+ employeeDepartmentName: normalizeOptionalText(organization.employeeDepartmentName, 100),
1412
+ employeeEmploymentStatus: normalizeOptionalText(organization.employeeEmploymentStatus, 32),
1413
+ verificationStatus:
1414
+ organization.type === 'enterprise' ? (organization.verificationStatus ?? null) : null,
1415
+ role: normalizeOptionalText(organization.role, 80),
1416
+ memberCount: normalizeMemberCount(organization.memberCount),
1417
+ })
1418
+ }
1419
+
1420
+ function normalizeOrganizationCount(
1421
+ value: unknown,
1422
+ organization: ResolvedAccountContextOrganization | null,
1423
+ providedCount: number,
1424
+ ): number {
1425
+ const fallback = providedCount || (organization ? 1 : 0)
1426
+ if (value === undefined) return fallback
1427
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value !== fallback) {
1428
+ throw new TypeError(
1429
+ 'account-context organizationCount must equal the projected organization count',
1430
+ )
1431
+ }
1432
+ return value
1433
+ }
1434
+
1435
+ function normalizeMemberCount(value: unknown): number | null {
1436
+ if (value === undefined || value === null) return null
1437
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
1438
+ throw new TypeError('account-context memberCount must be a non-negative integer')
1439
+ }
1440
+ return value
1441
+ }
1442
+
1443
+ function normalizeEmployeeInfo(value: unknown): AccountContextEmployeeInfo {
1444
+ if (!value || typeof value !== 'object') {
1445
+ throw new TypeError('account-context employee info must be an object')
1446
+ }
1447
+ const source = value as { departmentName?: unknown }
1448
+ return Object.freeze({
1449
+ departmentName: normalizeOptionalText(source.departmentName, 100) || null,
1450
+ })
1451
+ }
1452
+
1453
+ function normalizeMaskedMobile(value: unknown): string {
1454
+ const mobile = normalizeOptionalText(value, 40)
1455
+ if (/^1[3-9]\d{9}$/.test(mobile)) {
1456
+ throw new TypeError('account-context only accepts a masked mobile number')
1457
+ }
1458
+ return mobile
1459
+ }
1460
+
1461
+ function normalizeRequiredText(value: unknown, label: string, maxLength: number): string {
1462
+ const normalized = normalizeOptionalText(value, maxLength)
1463
+ if (!normalized) throw new TypeError(`${label} is required`)
1464
+ return normalized
1465
+ }
1466
+
1467
+ function normalizeOptionalText(value: unknown, maxLength: number): string {
1468
+ if (value === undefined || value === null) return ''
1469
+ if (typeof value !== 'string') throw new TypeError('account-context text values must be strings')
1470
+ const normalized = value.trim()
1471
+ if (normalized.length > maxLength) throw new TypeError('account-context text value is too long')
1472
+ return normalized
1473
+ }
1474
+
1475
+ function organizationStatus(
1476
+ organization: ResolvedAccountContextOrganization,
1477
+ ): EnterpriseVerificationStatus | 'team' {
1478
+ return organization.type === 'team' ? 'team' : (organization.verificationStatus ?? 'unverified')
1479
+ }
1480
+
1481
+ function organizationStatusDescription(status: EnterpriseVerificationStatus | 'team'): string {
1482
+ if (status === 'team') return '团队组织,无需认证'
1483
+ if (status === 'verified') return '企业已认证'
1484
+ if (status === 'pending') return '企业认证审核中'
1485
+ if (status === 'rejected') return '企业认证未通过'
1486
+ if (status === 'expired') return '企业认证已过期'
1487
+ return '企业未认证'
1488
+ }
1489
+
1490
+ function organizationStatusIcon(status: EnterpriseVerificationStatus | 'team'): PrototypeIconName {
1491
+ if (status === 'team') return 'users'
1492
+ if (status === 'verified') return 'discount-check-filled'
1493
+ if (status === 'pending') return 'clock-hour-4'
1494
+ if (status === 'rejected') return 'alert-triangle'
1495
+ if (status === 'expired') return 'clock-exclamation'
1496
+ return 'alert-circle'
1497
+ }
1498
+
1499
+ function employeeEmploymentStatus(value: string): { className: string; label: string } {
1500
+ if (value === 'inactive' || value === '离职') return { className: 'inactive', label: '非在职' }
1501
+ return { className: 'active', label: '在职' }
1502
+ }
1503
+
1504
+ function stopEventPropagation(event: Event): void {
1505
+ event.stopPropagation()
1506
+ }
1507
+
1508
+ function openPendingAccountSettingsWindow(): Window | null {
1509
+ if (typeof window === 'undefined') return null
1510
+ const accountSettingsWindow = window.open('about:blank', '_blank')
1511
+ if (accountSettingsWindow) accountSettingsWindow.opener = null
1512
+ return accountSettingsWindow
1513
+ }
1514
+
1515
+ function closePendingAccountSettingsWindow(accountSettingsWindow: Window | null): void {
1516
+ if (!accountSettingsWindow || accountSettingsWindow.closed) return
1517
+ accountSettingsWindow.close()
1518
+ }
1519
+
1520
+ function navigateToAccountSettings(url: string, accountSettingsWindow: Window | null): void {
1521
+ if (typeof window === 'undefined') throw new Error('Account settings navigation is unavailable')
1522
+ const target = new URL(url, window.location.href)
1523
+ const ticket = new URLSearchParams(target.hash.slice(1)).get(
1524
+ UNIPLAT_APPLICATION_TICKET_FRAGMENT_KEY,
1525
+ )
1526
+ if (
1527
+ !['http:', 'https:'].includes(target.protocol) ||
1528
+ target.username ||
1529
+ target.password ||
1530
+ !ticket
1531
+ ) {
1532
+ throw new Error('Account settings navigation is invalid')
1533
+ }
1534
+ if (accountSettingsWindow && !accountSettingsWindow.closed) {
1535
+ accountSettingsWindow.location.replace(target.href)
1536
+ return
1537
+ }
1538
+ const openedWindow = window.open(target.href, '_blank')
1539
+ if (openedWindow) openedWindow.opener = null
1540
+ }