@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,1103 @@
1
+ import { LitElement, css, html, nothing, type PropertyValues } from 'lit'
2
+ import { createMockApplicationSwitcherTransport } from './application-switcher-transport'
3
+ import type {
4
+ ApplicationEntry,
5
+ ApplicationSwitcherConfig,
6
+ ApplicationSwitcherTransport,
7
+ OrganizationEntry,
8
+ } from './application-switcher-transport'
9
+
10
+ export type ApplicationSwitcherErrorCode =
11
+ | 'INVALID_CONFIGURATION'
12
+ | 'TOKEN_UNAVAILABLE'
13
+ | 'LOAD_FAILED'
14
+ | 'ORGANIZATION_SWITCH_FAILED'
15
+ | 'APPLICATION_SWITCH_FAILED'
16
+
17
+ export interface ApplicationSwitcherErrorDetail {
18
+ code: ApplicationSwitcherErrorCode
19
+ message: string
20
+ recoverable: boolean
21
+ }
22
+
23
+ export interface ApplicationSwitcherEventMap {
24
+ 'entries-loaded': CustomEvent<{ entries: readonly ApplicationEntry[] }>
25
+ 'application-selected': CustomEvent<{ entry: ApplicationEntry }>
26
+ 'organization-changed': CustomEvent<{ organization: OrganizationEntry }>
27
+ error: CustomEvent<ApplicationSwitcherErrorDetail>
28
+ }
29
+
30
+ const ACCENT_COLORS = ['#1a73e8', '#0f9d58', '#f9ab00', '#d93025', '#9334e6', '#12b5cb'] as const
31
+ const APPLICATION_SLOT_COUNT = 9
32
+
33
+ interface ResolvedApplicationSwitcherConfig {
34
+ getAccessToken(): Promise<string>
35
+ transport: ApplicationSwitcherTransport
36
+ }
37
+
38
+ export class UniplatApplicationSwitcher extends LitElement {
39
+ static override properties = {
40
+ entries: { state: true },
41
+ open: { state: true },
42
+ loading: { state: true },
43
+ loaded: { state: true },
44
+ visibleError: { state: true },
45
+ organizations: { state: true },
46
+ currentOrganizationId: { state: true },
47
+ organizationMenuOpen: { state: true },
48
+ switchingOrganization: { state: true },
49
+ switchingApplication: { state: true },
50
+ presentation: { state: true },
51
+ showOrganizationSelector: { state: true },
52
+ }
53
+
54
+ static override styles = css`
55
+ :host {
56
+ --uniplat-app-switcher-trigger-color: #3c4043;
57
+ --uniplat-app-switcher-trigger-hover: #f1f3f4;
58
+ --uniplat-app-switcher-panel-background: #f8fafd;
59
+ --uniplat-app-switcher-item-hover: #e9eef6;
60
+ --uniplat-app-switcher-text: #1f1f1f;
61
+ --uniplat-app-switcher-muted: #5f6368;
62
+ --uniplat-app-switcher-error: #b3261e;
63
+ --uniplat-app-switcher-font-family:
64
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
65
+ position: relative;
66
+ display: inline-block;
67
+ color: var(--uniplat-app-switcher-text);
68
+ font-family: var(--uniplat-app-switcher-font-family);
69
+ line-height: 1.4;
70
+ }
71
+
72
+ * {
73
+ box-sizing: border-box;
74
+ }
75
+
76
+ .launcher {
77
+ display: grid;
78
+ width: 40px;
79
+ height: 40px;
80
+ padding: 10px;
81
+ border: 0;
82
+ border-radius: 50%;
83
+ background: transparent;
84
+ color: var(--uniplat-app-switcher-trigger-color);
85
+ cursor: pointer;
86
+ place-items: center;
87
+ }
88
+
89
+ .launcher:hover,
90
+ .launcher:focus-visible,
91
+ .launcher[aria-expanded='true'] {
92
+ background: var(--uniplat-app-switcher-trigger-hover);
93
+ }
94
+
95
+ .launcher:focus-visible,
96
+ .application:focus-visible,
97
+ .retry:focus-visible,
98
+ .organization-trigger:focus-visible,
99
+ .organization-option:focus-visible {
100
+ outline: 2px solid #1a73e8;
101
+ outline-offset: 2px;
102
+ }
103
+
104
+ .waffle {
105
+ display: grid;
106
+ grid-template-columns: repeat(3, 4px);
107
+ gap: 3px;
108
+ width: 18px;
109
+ height: 18px;
110
+ place-content: center;
111
+ }
112
+
113
+ .dot {
114
+ width: 4px;
115
+ height: 4px;
116
+ border-radius: 50%;
117
+ background: currentcolor;
118
+ }
119
+
120
+ .panel {
121
+ position: absolute;
122
+ z-index: 1000;
123
+ top: calc(100% + 10px);
124
+ right: 0;
125
+ width: min(328px, calc(100vw - 24px));
126
+ max-height: min(448px, calc(100vh - 88px));
127
+ padding: 18px 12px;
128
+ overflow-y: auto;
129
+ border: 1px solid rgb(60 64 67 / 12%);
130
+ border-radius: 24px;
131
+ background: var(--uniplat-app-switcher-panel-background);
132
+ box-shadow:
133
+ 0 8px 24px rgb(60 64 67 / 20%),
134
+ 0 1px 3px rgb(60 64 67 / 16%);
135
+ overscroll-behavior: contain;
136
+ }
137
+
138
+ .panel.embedded {
139
+ position: static;
140
+ top: auto;
141
+ right: auto;
142
+ }
143
+
144
+ .panel-content {
145
+ min-height: 292px;
146
+ }
147
+
148
+ .panel-content.has-organizations {
149
+ min-height: 373px;
150
+ }
151
+
152
+ .grid {
153
+ display: grid;
154
+ grid-template-columns: repeat(3, minmax(0, 1fr));
155
+ grid-template-rows: repeat(3, 92px);
156
+ grid-auto-rows: 92px;
157
+ gap: 8px 4px;
158
+ min-height: 292px;
159
+ }
160
+
161
+ .application-grid-shell {
162
+ position: relative;
163
+ height: 292px;
164
+ overflow-y: auto;
165
+ overscroll-behavior: contain;
166
+ }
167
+
168
+ .application-empty-message {
169
+ position: absolute;
170
+ inset: 0;
171
+ display: grid;
172
+ color: var(--uniplat-app-switcher-muted);
173
+ font-size: 13px;
174
+ pointer-events: none;
175
+ place-items: center;
176
+ }
177
+
178
+ .organization-selector {
179
+ position: relative;
180
+ margin: 0 4px 14px;
181
+ padding-bottom: 14px;
182
+ border-bottom: 1px solid rgb(60 64 67 / 12%);
183
+ }
184
+
185
+ .organization-trigger,
186
+ .organization-option {
187
+ width: 100%;
188
+ border: 0;
189
+ color: var(--uniplat-app-switcher-text);
190
+ cursor: pointer;
191
+ font: inherit;
192
+ text-align: left;
193
+ }
194
+
195
+ .organization-trigger {
196
+ display: grid;
197
+ grid-template-columns: 34px minmax(0, 1fr) 20px;
198
+ gap: 10px;
199
+ align-items: center;
200
+ min-height: 52px;
201
+ padding: 8px 10px;
202
+ border-radius: 16px;
203
+ background: #ffffff;
204
+ box-shadow: 0 1px 2px rgb(60 64 67 / 12%);
205
+ }
206
+
207
+ .organization-trigger:hover,
208
+ .organization-trigger[aria-expanded='true'],
209
+ .organization-option:hover,
210
+ .organization-option[aria-selected='true'] {
211
+ background: var(--uniplat-app-switcher-item-hover);
212
+ }
213
+
214
+ .organization-trigger:disabled,
215
+ .organization-option:disabled {
216
+ cursor: wait;
217
+ opacity: 0.65;
218
+ }
219
+
220
+ .organization-avatar {
221
+ display: grid;
222
+ width: 34px;
223
+ height: 34px;
224
+ border-radius: 10px;
225
+ background: #d2e3fc;
226
+ color: #174ea6;
227
+ font-size: 14px;
228
+ font-weight: 700;
229
+ place-items: center;
230
+ }
231
+
232
+ .organization-copy {
233
+ min-width: 0;
234
+ }
235
+
236
+ .organization-label {
237
+ display: block;
238
+ color: var(--uniplat-app-switcher-muted);
239
+ font-size: 11px;
240
+ line-height: 15px;
241
+ }
242
+
243
+ .organization-name {
244
+ display: block;
245
+ overflow: hidden;
246
+ font-size: 14px;
247
+ font-weight: 600;
248
+ line-height: 20px;
249
+ text-overflow: ellipsis;
250
+ white-space: nowrap;
251
+ }
252
+
253
+ .organization-chevron {
254
+ color: var(--uniplat-app-switcher-muted);
255
+ font-size: 18px;
256
+ text-align: center;
257
+ transition: transform 0.15s ease;
258
+ }
259
+
260
+ .organization-trigger[aria-expanded='true'] .organization-chevron {
261
+ transform: rotate(180deg);
262
+ }
263
+
264
+ .organization-menu {
265
+ position: absolute;
266
+ z-index: 2;
267
+ top: 58px;
268
+ right: 0;
269
+ left: 0;
270
+ display: grid;
271
+ gap: 2px;
272
+ padding: 4px;
273
+ border-radius: 14px;
274
+ background: #ffffff;
275
+ box-shadow: 0 2px 8px rgb(60 64 67 / 14%);
276
+ }
277
+
278
+ .organization-option {
279
+ display: grid;
280
+ grid-template-columns: minmax(0, 1fr) 20px;
281
+ gap: 8px;
282
+ align-items: center;
283
+ min-height: 38px;
284
+ padding: 7px 10px;
285
+ border-radius: 10px;
286
+ background: transparent;
287
+ font-size: 13px;
288
+ }
289
+
290
+ .organization-option-name {
291
+ overflow: hidden;
292
+ text-overflow: ellipsis;
293
+ white-space: nowrap;
294
+ }
295
+
296
+ .organization-check {
297
+ color: #1a73e8;
298
+ font-weight: 700;
299
+ text-align: center;
300
+ }
301
+
302
+ .application {
303
+ display: grid;
304
+ height: 92px;
305
+ min-width: 0;
306
+ padding: 10px 6px 8px;
307
+ border-radius: 16px;
308
+ color: var(--uniplat-app-switcher-text);
309
+ text-align: center;
310
+ text-decoration: none;
311
+ place-items: center;
312
+ }
313
+
314
+ .application:hover {
315
+ background: var(--uniplat-app-switcher-item-hover);
316
+ }
317
+
318
+ .application[aria-disabled='true'] {
319
+ cursor: wait;
320
+ opacity: 0.65;
321
+ pointer-events: none;
322
+ }
323
+
324
+ .application-empty-slot {
325
+ height: 92px;
326
+ min-width: 0;
327
+ border-radius: 16px;
328
+ pointer-events: none;
329
+ }
330
+
331
+ .icon {
332
+ position: relative;
333
+ display: grid;
334
+ width: 48px;
335
+ height: 48px;
336
+ overflow: hidden;
337
+ border-radius: 14px;
338
+ background: var(--application-accent);
339
+ box-shadow: inset 0 0 0 1px rgb(255 255 255 / 24%);
340
+ color: #ffffff;
341
+ font-size: 16px;
342
+ font-weight: 700;
343
+ letter-spacing: -0.02em;
344
+ place-items: center;
345
+ }
346
+
347
+ .icon img {
348
+ position: absolute;
349
+ width: 100%;
350
+ height: 100%;
351
+ background: #ffffff;
352
+ object-fit: contain;
353
+ }
354
+
355
+ .name {
356
+ display: -webkit-box;
357
+ max-width: 100%;
358
+ margin-top: 8px;
359
+ overflow: hidden;
360
+ font-size: 13px;
361
+ line-height: 18px;
362
+ text-overflow: ellipsis;
363
+ -webkit-box-orient: vertical;
364
+ -webkit-line-clamp: 2;
365
+ }
366
+
367
+ .state {
368
+ display: grid;
369
+ min-height: 292px;
370
+ padding: 24px;
371
+ color: var(--uniplat-app-switcher-muted);
372
+ font-size: 13px;
373
+ text-align: center;
374
+ place-items: center;
375
+ }
376
+
377
+ .state.error {
378
+ gap: 12px;
379
+ color: var(--uniplat-app-switcher-error);
380
+ }
381
+
382
+ .spinner {
383
+ width: 24px;
384
+ height: 24px;
385
+ border: 3px solid #d2e3fc;
386
+ border-top-color: #1a73e8;
387
+ border-radius: 50%;
388
+ animation: rotate 0.8s linear infinite;
389
+ }
390
+
391
+ .retry {
392
+ min-height: 34px;
393
+ padding: 6px 14px;
394
+ border: 1px solid #dadce0;
395
+ border-radius: 17px;
396
+ background: #ffffff;
397
+ color: #1a73e8;
398
+ cursor: pointer;
399
+ font: inherit;
400
+ font-weight: 600;
401
+ }
402
+
403
+ .visually-hidden {
404
+ position: absolute;
405
+ width: 1px;
406
+ height: 1px;
407
+ padding: 0;
408
+ overflow: hidden;
409
+ clip: rect(0, 0, 0, 0);
410
+ white-space: nowrap;
411
+ border: 0;
412
+ }
413
+
414
+ @keyframes rotate {
415
+ to {
416
+ transform: rotate(360deg);
417
+ }
418
+ }
419
+
420
+ @media (prefers-reduced-motion: reduce) {
421
+ .spinner,
422
+ .organization-chevron {
423
+ animation: none;
424
+ transition: none;
425
+ }
426
+ }
427
+ `
428
+
429
+ declare private entries: readonly ApplicationEntry[]
430
+ declare private open: boolean
431
+ declare private loading: boolean
432
+ declare private loaded: boolean
433
+ declare private visibleError: string
434
+ declare private organizations: readonly OrganizationEntry[]
435
+ declare private currentOrganizationId: string
436
+ declare private organizationMenuOpen: boolean
437
+ declare private switchingOrganization: boolean
438
+ declare private switchingApplication: boolean
439
+ declare private presentation: 'standalone' | 'embedded'
440
+ declare private showOrganizationSelector: boolean
441
+
442
+ private config: ResolvedApplicationSwitcherConfig | null = null
443
+ private loadGeneration = 0
444
+
445
+ constructor() {
446
+ super()
447
+ this.entries = []
448
+ this.open = false
449
+ this.loading = false
450
+ this.loaded = false
451
+ this.visibleError = ''
452
+ this.organizations = []
453
+ this.currentOrganizationId = ''
454
+ this.organizationMenuOpen = false
455
+ this.switchingOrganization = false
456
+ this.switchingApplication = false
457
+ this.presentation = 'standalone'
458
+ this.showOrganizationSelector = true
459
+ }
460
+
461
+ configure(config: ApplicationSwitcherConfig): void {
462
+ if (!config || typeof config.getAccessToken !== 'function') {
463
+ throw new TypeError('application-switcher requires getAccessToken()')
464
+ }
465
+ this.config = {
466
+ getAccessToken: config.getAccessToken,
467
+ transport: config.transport ?? createMockApplicationSwitcherTransport(),
468
+ }
469
+ this.presentation = config.presentation === 'embedded' ? 'embedded' : 'standalone'
470
+ this.showOrganizationSelector =
471
+ config.showOrganizationSelector ?? this.presentation === 'standalone'
472
+ this.loadGeneration += 1
473
+ this.entries = []
474
+ this.loading = false
475
+ this.loaded = false
476
+ this.visibleError = ''
477
+ this.organizations = []
478
+ this.currentOrganizationId = ''
479
+ this.organizationMenuOpen = false
480
+ this.switchingOrganization = false
481
+ this.switchingApplication = false
482
+ if (this.isConnected) void this.refresh()
483
+ }
484
+
485
+ async refresh(): Promise<void> {
486
+ const config = this.config
487
+ if (!config || this.loading || this.switchingOrganization || this.switchingApplication) return
488
+ const generation = ++this.loadGeneration
489
+ this.loading = true
490
+ this.visibleError = ''
491
+ let accessToken = ''
492
+
493
+ try {
494
+ accessToken = (await config.getAccessToken()).trim()
495
+ if (!accessToken) {
496
+ this.handleError('TOKEN_UNAVAILABLE', '无法获取应用入口凭证。', true)
497
+ return
498
+ }
499
+ if (!this.isCurrentLoad(generation)) return
500
+ const organizationRequest =
501
+ this.showOrganizationSelector && config.transport.listOrganizations
502
+ ? config.transport.listOrganizations(accessToken)
503
+ : Promise.resolve(null)
504
+ const [result, organizationResult] = await Promise.all([
505
+ config.transport.listApplications(accessToken),
506
+ organizationRequest,
507
+ ])
508
+ if (!this.isCurrentLoad(generation)) return
509
+ const entries = sanitizeEntries(result, accessToken)
510
+ const organizationState = sanitizeOrganizations(organizationResult, accessToken)
511
+ accessToken = ''
512
+ this.entries = entries
513
+ this.organizations = organizationState.organizations
514
+ this.currentOrganizationId = organizationState.currentOrganizationId
515
+ this.organizationMenuOpen = false
516
+ this.loaded = true
517
+ this.dispatchPublicEvent('entries-loaded', { entries: [...entries] })
518
+ } catch {
519
+ accessToken = ''
520
+ if (this.isCurrentLoad(generation)) {
521
+ this.handleError('LOAD_FAILED', '应用入口加载失败,请稍后重试。', true)
522
+ }
523
+ } finally {
524
+ accessToken = ''
525
+ if (this.isCurrentLoad(generation)) this.loading = false
526
+ }
527
+ }
528
+
529
+ override connectedCallback(): void {
530
+ super.connectedCallback()
531
+ document.addEventListener('pointerdown', this.handleDocumentPointerDown, true)
532
+ document.addEventListener('keydown', this.handleDocumentKeyDown)
533
+ if (this.config && !this.loaded) void this.refresh()
534
+ }
535
+
536
+ override disconnectedCallback(): void {
537
+ this.loadGeneration += 1
538
+ this.open = false
539
+ this.loading = false
540
+ this.organizationMenuOpen = false
541
+ this.switchingOrganization = false
542
+ this.switchingApplication = false
543
+ document.removeEventListener('pointerdown', this.handleDocumentPointerDown, true)
544
+ document.removeEventListener('keydown', this.handleDocumentKeyDown)
545
+ super.disconnectedCallback()
546
+ }
547
+
548
+ override render() {
549
+ const embedded = this.presentation === 'embedded'
550
+ const panelVisible = embedded || this.open
551
+ return html`
552
+ ${
553
+ embedded
554
+ ? nothing
555
+ : html`<button
556
+ class="launcher"
557
+ part="trigger"
558
+ type="button"
559
+ aria-label="打开应用菜单"
560
+ aria-haspopup="menu"
561
+ aria-expanded=${String(this.open)}
562
+ @click=${this.togglePanel}
563
+ >
564
+ <span class="waffle" part="trigger-icon" aria-hidden="true">
565
+ ${Array.from({ length: 9 }, () => html`<span class="dot"></span>`)}
566
+ </span>
567
+ </button>`
568
+ }
569
+ ${
570
+ panelVisible
571
+ ? html`
572
+ <section
573
+ class=${`panel${embedded ? ' embedded' : ''}`}
574
+ part="panel"
575
+ aria-label="应用入口"
576
+ >
577
+ <span class="visually-hidden">应用入口</span>
578
+ <div
579
+ class=${`panel-content${
580
+ this.showOrganizationSelector && this.organizations.length > 0
581
+ ? ' has-organizations'
582
+ : ''
583
+ }`}
584
+ >
585
+ ${this.renderPanelContent()}
586
+ </div>
587
+ </section>
588
+ `
589
+ : nothing
590
+ }
591
+ `
592
+ }
593
+
594
+ protected override updated(changed: PropertyValues): void {
595
+ if (changed.has('open') && this.open) {
596
+ this.renderRoot
597
+ .querySelector<HTMLElement>('.organization-trigger, .application, .retry')
598
+ ?.focus()
599
+ }
600
+ if (changed.has('organizationMenuOpen') && this.organizationMenuOpen) {
601
+ this.renderRoot
602
+ .querySelector<HTMLElement>(
603
+ '.organization-option[aria-selected="true"], .organization-option',
604
+ )
605
+ ?.focus()
606
+ }
607
+ }
608
+
609
+ private renderPanelContent() {
610
+ if (this.loading) {
611
+ return html`
612
+ <div class="state" part="loading" aria-live="polite">
613
+ <span class="spinner" aria-hidden="true"></span>
614
+ <span class="visually-hidden">正在加载应用入口</span>
615
+ </div>
616
+ `
617
+ }
618
+ if (this.visibleError) {
619
+ return html`
620
+ <div class="state error" part="error" role="alert">
621
+ <span>${this.visibleError}</span>
622
+ <button class="retry" part="retry-button" type="button" @click=${this.refresh}>
623
+ 重试
624
+ </button>
625
+ </div>
626
+ `
627
+ }
628
+ if (!this.config) {
629
+ return html`<div class="state" part="empty">请先配置应用入口</div>`
630
+ }
631
+ return html`
632
+ ${this.showOrganizationSelector ? this.renderOrganizationSelector() : nothing}
633
+ ${this.renderApplicationGrid()}
634
+ `
635
+ }
636
+
637
+ private renderApplicationGrid() {
638
+ const slotCount = Math.max(APPLICATION_SLOT_COUNT, Math.ceil(this.entries.length / 3) * 3)
639
+ const rowCount = slotCount / 3
640
+ const gridHeight = rowCount * 92 + (rowCount - 1) * 8
641
+ return html`
642
+ <div class="application-grid-shell">
643
+ <div
644
+ class="grid"
645
+ part="application-grid"
646
+ role="menu"
647
+ style=${`height: ${gridHeight}px`}
648
+ aria-label=${this.entries.length === 0 ? '应用入口,暂无可用应用' : '应用入口'}
649
+ >
650
+ ${Array.from({ length: slotCount }, (_, index) => {
651
+ const entry = this.entries[index]
652
+ return entry
653
+ ? this.renderEntry(entry)
654
+ : html`<span
655
+ class="application-empty-slot"
656
+ part="application-empty-slot"
657
+ data-slot-index=${String(index)}
658
+ aria-hidden="true"
659
+ ></span>`
660
+ })}
661
+ </div>
662
+ ${
663
+ this.entries.length === 0
664
+ ? html`<div class="application-empty-message" part="empty" role="status">
665
+ 暂无可用应用
666
+ </div>`
667
+ : nothing
668
+ }
669
+ </div>
670
+ `
671
+ }
672
+
673
+ private renderOrganizationSelector() {
674
+ if (this.organizations.length === 0) return nothing
675
+ const currentOrganization = this.currentOrganization
676
+
677
+ return html`
678
+ <div class="organization-selector" part="organization-selector">
679
+ <button
680
+ class="organization-trigger"
681
+ part="organization-trigger"
682
+ type="button"
683
+ aria-haspopup="listbox"
684
+ aria-expanded=${String(this.organizationMenuOpen)}
685
+ aria-label=${`切换组织,当前组织:${currentOrganization?.name ?? '未选择'}`}
686
+ ?disabled=${this.switchingOrganization}
687
+ @click=${this.toggleOrganizationMenu}
688
+ >
689
+ <span class="organization-avatar" part="organization-icon" aria-hidden="true">
690
+ ${organizationInitial(currentOrganization?.name ?? '')}
691
+ </span>
692
+ <span class="organization-copy">
693
+ <span class="organization-label">当前组织</span>
694
+ <span class="organization-name" part="organization-name">
695
+ ${currentOrganization?.name ?? '请选择组织'}
696
+ </span>
697
+ </span>
698
+ <span class="organization-chevron" aria-hidden="true">⌄</span>
699
+ </button>
700
+ ${
701
+ this.organizationMenuOpen
702
+ ? html`
703
+ <div
704
+ class="organization-menu"
705
+ part="organization-list"
706
+ role="listbox"
707
+ aria-label="选择组织"
708
+ >
709
+ ${this.organizations.map((organization) =>
710
+ this.renderOrganizationOption(organization),
711
+ )}
712
+ </div>
713
+ `
714
+ : nothing
715
+ }
716
+ </div>
717
+ `
718
+ }
719
+
720
+ private renderOrganizationOption(organization: OrganizationEntry) {
721
+ const selected = organization.id === this.currentOrganizationId
722
+ return html`
723
+ <button
724
+ class="organization-option"
725
+ part="organization-option"
726
+ type="button"
727
+ role="option"
728
+ aria-selected=${String(selected)}
729
+ data-organization-id=${organization.id}
730
+ ?disabled=${this.switchingOrganization}
731
+ @click=${() => this.selectOrganization(organization)}
732
+ >
733
+ <span class="organization-option-name">${organization.name}</span>
734
+ <span class="organization-check" aria-hidden="true">${selected ? '✓' : nothing}</span>
735
+ </button>
736
+ `
737
+ }
738
+
739
+ private renderEntry(entry: ApplicationEntry) {
740
+ const requiresIdentitySwitch = Boolean(this.config?.transport.switchApplication)
741
+ return html`
742
+ <a
743
+ class="application"
744
+ part="application"
745
+ role="menuitem"
746
+ href=${requiresIdentitySwitch ? nothing : entry.url}
747
+ target="_blank"
748
+ rel="noopener noreferrer"
749
+ tabindex=${requiresIdentitySwitch ? '0' : nothing}
750
+ aria-disabled=${String(this.switchingApplication)}
751
+ title=${entry.description || entry.name}
752
+ data-application-id=${entry.id}
753
+ @click=${(event: MouseEvent) => this.selectEntry(event, entry)}
754
+ @auxclick=${(event: MouseEvent) => this.selectEntry(event, entry)}
755
+ @keydown=${(event: KeyboardEvent) => this.handleEntryKeyDown(event)}
756
+ >
757
+ <span
758
+ class="icon"
759
+ part="application-icon"
760
+ style=${`--application-accent: ${applicationAccent(entry.id)}`}
761
+ aria-hidden="true"
762
+ >
763
+ ${applicationInitials(entry.name)}
764
+ ${
765
+ entry.iconUrl
766
+ ? html`<img src=${entry.iconUrl} alt="" @error=${hideBrokenImage} />`
767
+ : nothing
768
+ }
769
+ </span>
770
+ <span class="name" part="application-name">${entry.name}</span>
771
+ </a>
772
+ `
773
+ }
774
+
775
+ private readonly togglePanel = (): void => {
776
+ this.open = !this.open
777
+ if (!this.open) this.organizationMenuOpen = false
778
+ }
779
+
780
+ private readonly toggleOrganizationMenu = (): void => {
781
+ if (!this.switchingOrganization) this.organizationMenuOpen = !this.organizationMenuOpen
782
+ }
783
+
784
+ private readonly selectEntry = (event: MouseEvent, entry: ApplicationEntry): void => {
785
+ const switchApplication = this.config?.transport.switchApplication
786
+ if (!switchApplication) {
787
+ const navigate = this.dispatchPublicEvent(
788
+ 'application-selected',
789
+ { entry: { ...entry } },
790
+ true,
791
+ )
792
+ if (!navigate) event.preventDefault()
793
+ this.organizationMenuOpen = false
794
+ this.open = false
795
+ return
796
+ }
797
+
798
+ event.preventDefault()
799
+ if (event.type === 'auxclick' && event.button !== 1) return
800
+ if (this.switchingApplication) return
801
+ const applicationWindow = openPendingApplicationWindow()
802
+ void this.switchAndOpenApplication(entry, applicationWindow)
803
+ }
804
+
805
+ private readonly handleEntryKeyDown = (event: KeyboardEvent): void => {
806
+ if (!this.config?.transport.switchApplication || !['Enter', ' '].includes(event.key)) return
807
+ event.preventDefault()
808
+ ;(event.currentTarget as HTMLElement).click()
809
+ }
810
+
811
+ private async switchAndOpenApplication(
812
+ entry: ApplicationEntry,
813
+ pendingWindow: Window | null,
814
+ ): Promise<void> {
815
+ const config = this.config
816
+ if (!config?.transport.switchApplication || this.switchingApplication) {
817
+ closePendingApplicationWindow(pendingWindow)
818
+ return
819
+ }
820
+
821
+ const generation = ++this.loadGeneration
822
+ this.loading = false
823
+ this.switchingApplication = true
824
+ this.visibleError = ''
825
+ let accessToken = ''
826
+ let applicationWindow = pendingWindow
827
+
828
+ try {
829
+ accessToken = (await config.getAccessToken()).trim()
830
+ if (!accessToken) {
831
+ this.handleError('TOKEN_UNAVAILABLE', '无法获取应用切换凭证。', true)
832
+ return
833
+ }
834
+ if (!this.isCurrentLoad(generation)) return
835
+ const launchResult = await config.transport.switchApplication(accessToken, entry.id)
836
+ if (!this.isCurrentLoad(generation)) return
837
+ const navigationUrl = resolveApplicationNavigationUrl(launchResult, entry.url, accessToken)
838
+ accessToken = ''
839
+ this.organizationMenuOpen = false
840
+ this.open = false
841
+ const navigate = this.dispatchPublicEvent(
842
+ 'application-selected',
843
+ { entry: { ...entry } },
844
+ true,
845
+ )
846
+ if (navigate) {
847
+ navigateToApplication(navigationUrl, applicationWindow)
848
+ applicationWindow = null
849
+ }
850
+ } catch {
851
+ accessToken = ''
852
+ if (this.isCurrentLoad(generation)) {
853
+ this.handleError('APPLICATION_SWITCH_FAILED', '应用切换失败,请稍后重试。', true)
854
+ }
855
+ } finally {
856
+ accessToken = ''
857
+ closePendingApplicationWindow(applicationWindow)
858
+ if (this.isCurrentLoad(generation)) this.switchingApplication = false
859
+ }
860
+ }
861
+
862
+ private async selectOrganization(organization: OrganizationEntry): Promise<void> {
863
+ if (organization.id === this.currentOrganizationId) {
864
+ this.organizationMenuOpen = false
865
+ return
866
+ }
867
+
868
+ const config = this.config
869
+ if (!config || this.switchingOrganization || this.switchingApplication) return
870
+ if (!config.transport.switchOrganization) {
871
+ this.handleError('ORGANIZATION_SWITCH_FAILED', '当前接入未提供组织切换能力。', false)
872
+ return
873
+ }
874
+
875
+ const generation = ++this.loadGeneration
876
+ this.loading = false
877
+ this.switchingOrganization = true
878
+ this.visibleError = ''
879
+ let accessToken = ''
880
+ let switched = false
881
+
882
+ try {
883
+ accessToken = (await config.getAccessToken()).trim()
884
+ if (!accessToken) {
885
+ this.handleError('TOKEN_UNAVAILABLE', '无法获取组织切换凭证。', true)
886
+ return
887
+ }
888
+ if (!this.isCurrentLoad(generation)) return
889
+ await config.transport.switchOrganization(accessToken, organization.id)
890
+ if (!this.isCurrentLoad(generation)) return
891
+ accessToken = ''
892
+ this.currentOrganizationId = organization.id
893
+ this.organizationMenuOpen = false
894
+ this.dispatchPublicEvent('organization-changed', { organization: { ...organization } })
895
+ this.loaded = false
896
+ switched = true
897
+ } catch {
898
+ accessToken = ''
899
+ if (this.isCurrentLoad(generation)) {
900
+ this.handleError('ORGANIZATION_SWITCH_FAILED', '组织切换失败,请稍后重试。', true)
901
+ }
902
+ } finally {
903
+ accessToken = ''
904
+ if (this.isCurrentLoad(generation)) this.switchingOrganization = false
905
+ }
906
+
907
+ if (switched && this.isCurrentLoad(generation)) await this.refresh()
908
+ }
909
+
910
+ private readonly handleDocumentPointerDown = (event: PointerEvent): void => {
911
+ if (this.open && !event.composedPath().includes(this)) {
912
+ this.organizationMenuOpen = false
913
+ this.open = false
914
+ }
915
+ }
916
+
917
+ private readonly handleDocumentKeyDown = (event: KeyboardEvent): void => {
918
+ if (event.key !== 'Escape' || !this.open) return
919
+ if (this.organizationMenuOpen) {
920
+ this.organizationMenuOpen = false
921
+ this.renderRoot.querySelector<HTMLButtonElement>('.organization-trigger')?.focus()
922
+ return
923
+ }
924
+ this.open = false
925
+ this.renderRoot.querySelector<HTMLButtonElement>('.launcher')?.focus()
926
+ }
927
+
928
+ private handleError(
929
+ code: ApplicationSwitcherErrorCode,
930
+ message: string,
931
+ recoverable: boolean,
932
+ ): void {
933
+ this.entries = []
934
+ this.organizations = []
935
+ this.currentOrganizationId = ''
936
+ this.organizationMenuOpen = false
937
+ this.loaded = false
938
+ this.visibleError = message
939
+ this.dispatchPublicEvent('error', { code, message, recoverable })
940
+ }
941
+
942
+ private dispatchPublicEvent<K extends keyof ApplicationSwitcherEventMap>(
943
+ type: K,
944
+ detail: ApplicationSwitcherEventMap[K]['detail'],
945
+ cancelable = false,
946
+ ): boolean {
947
+ return this.dispatchEvent(
948
+ new CustomEvent(type, { detail, bubbles: true, composed: true, cancelable }),
949
+ )
950
+ }
951
+
952
+ private isCurrentLoad(generation: number): boolean {
953
+ return this.isConnected && generation === this.loadGeneration
954
+ }
955
+
956
+ private get currentOrganization(): OrganizationEntry | undefined {
957
+ return this.organizations.find((organization) => organization.id === this.currentOrganizationId)
958
+ }
959
+ }
960
+
961
+ function sanitizeEntries(rawEntries: unknown, accessToken: string): ApplicationEntry[] {
962
+ if (!Array.isArray(rawEntries)) return []
963
+ const entries: ApplicationEntry[] = []
964
+ const ids = new Set<string>()
965
+ for (const candidate of rawEntries) {
966
+ const record = asRecord(candidate)
967
+ const id = text(record.id).trim()
968
+ const name = text(record.name).trim()
969
+ const url = safeUrl(text(record.url), accessToken)
970
+ if (!id || ids.has(id) || !name || !url || containsToken(id, accessToken)) continue
971
+ if (containsToken(name, accessToken)) continue
972
+
973
+ const entry: ApplicationEntry = { id, name, url }
974
+ const iconUrl = safeUrl(text(record.iconUrl), accessToken)
975
+ const description = text(record.description).trim()
976
+ if (iconUrl) entry.iconUrl = iconUrl
977
+ if (description && !containsToken(description, accessToken)) entry.description = description
978
+ if (record.openMode === 'blank') entry.openMode = 'blank'
979
+ ids.add(id)
980
+ entries.push(entry)
981
+ }
982
+ return entries
983
+ }
984
+
985
+ function sanitizeOrganizations(
986
+ rawResult: unknown,
987
+ accessToken: string,
988
+ ): { organizations: OrganizationEntry[]; currentOrganizationId: string } {
989
+ const result = asRecord(rawResult)
990
+ if (!Array.isArray(result.organizations)) {
991
+ return { organizations: [], currentOrganizationId: '' }
992
+ }
993
+
994
+ const organizations: OrganizationEntry[] = []
995
+ const ids = new Set<string>()
996
+ for (const candidate of result.organizations) {
997
+ const record = asRecord(candidate)
998
+ const id = text(record.id).trim()
999
+ const name = text(record.name).trim()
1000
+ if (
1001
+ !id ||
1002
+ !name ||
1003
+ ids.has(id) ||
1004
+ containsToken(id, accessToken) ||
1005
+ containsToken(name, accessToken)
1006
+ ) {
1007
+ continue
1008
+ }
1009
+ organizations.push({ id, name })
1010
+ ids.add(id)
1011
+ }
1012
+
1013
+ const requestedCurrentId = text(result.currentOrganizationId).trim()
1014
+ const currentOrganizationId = organizations.some(
1015
+ (organization) => organization.id === requestedCurrentId,
1016
+ )
1017
+ ? requestedCurrentId
1018
+ : ''
1019
+
1020
+ return { organizations, currentOrganizationId }
1021
+ }
1022
+
1023
+ function safeUrl(rawValue: string, accessToken: string): string {
1024
+ const value = rawValue.trim()
1025
+ if (!value || containsToken(value, accessToken)) return ''
1026
+ const baseUrl = typeof document === 'undefined' ? 'https://localhost/' : document.baseURI
1027
+ try {
1028
+ const url = new URL(value, baseUrl)
1029
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return ''
1030
+ return url.href
1031
+ } catch {
1032
+ return ''
1033
+ }
1034
+ }
1035
+
1036
+ function containsToken(value: string, accessToken: string): boolean {
1037
+ return Boolean(accessToken && value.includes(accessToken))
1038
+ }
1039
+
1040
+ function applicationInitials(name: string): string {
1041
+ const characters = Array.from(name.replace(/\s+/g, ''))
1042
+ return characters.slice(0, 2).join('').toUpperCase()
1043
+ }
1044
+
1045
+ function organizationInitial(name: string): string {
1046
+ return Array.from(name.trim())[0]?.toUpperCase() ?? '组'
1047
+ }
1048
+
1049
+ function applicationAccent(id: string): string {
1050
+ let hash = 0
1051
+ for (const character of id) hash = (hash * 31 + character.charCodeAt(0)) >>> 0
1052
+ return ACCENT_COLORS[hash % ACCENT_COLORS.length] ?? ACCENT_COLORS[0]
1053
+ }
1054
+
1055
+ function hideBrokenImage(event: Event): void {
1056
+ ;(event.currentTarget as HTMLImageElement).hidden = true
1057
+ }
1058
+
1059
+ function openPendingApplicationWindow(): Window | null {
1060
+ if (typeof window === 'undefined') return null
1061
+ const applicationWindow = window.open('about:blank', '_blank')
1062
+ if (applicationWindow) applicationWindow.opener = null
1063
+ return applicationWindow
1064
+ }
1065
+
1066
+ function closePendingApplicationWindow(applicationWindow: Window | null): void {
1067
+ if (!applicationWindow || applicationWindow.closed) return
1068
+ applicationWindow.close()
1069
+ }
1070
+
1071
+ function resolveApplicationNavigationUrl(
1072
+ result: unknown,
1073
+ fallbackUrl: string,
1074
+ accessToken: string,
1075
+ ): string {
1076
+ if (result === undefined) return fallbackUrl
1077
+ const url = safeUrl(text(asRecord(result).url), accessToken)
1078
+ if (!url) throw new Error('Application navigation URL is invalid')
1079
+ return url
1080
+ }
1081
+
1082
+ function navigateToApplication(url: string, applicationWindow: Window | null): void {
1083
+ if (typeof window === 'undefined') return
1084
+ if (applicationWindow && !applicationWindow.closed) {
1085
+ try {
1086
+ applicationWindow.location.replace(url)
1087
+ return
1088
+ } catch {
1089
+ applicationWindow.close()
1090
+ }
1091
+ }
1092
+ window.open(url, '_blank', 'noopener,noreferrer')
1093
+ }
1094
+
1095
+ function asRecord(value: unknown): Record<string, unknown> {
1096
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
1097
+ ? (value as Record<string, unknown>)
1098
+ : {}
1099
+ }
1100
+
1101
+ function text(value: unknown): string {
1102
+ return typeof value === 'string' ? value : ''
1103
+ }