@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,798 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import {
5
+ ACCOUNT_CONTEXT_TAG,
6
+ createMockOrganizationOnboardingTransport,
7
+ UniplatAccountContext,
8
+ type AccountContextProjection,
9
+ type AccountContextTransport,
10
+ type UniplatUserMenu,
11
+ } from '../src'
12
+ import type { UniplatApplicationSwitcher } from '../src/application-switcher'
13
+
14
+ const APPLICATION_NAME = 'OfficialWebsite'
15
+
16
+ function serverProjection(
17
+ currentOrganizationId: string | null = 'enterprise-a',
18
+ ): AccountContextProjection {
19
+ const organizations = [
20
+ {
21
+ id: 'enterprise-a',
22
+ name: '企业 A',
23
+ type: 'enterprise' as const,
24
+ employeeDisplayName: '企业员工小张',
25
+ employeeAvatarText: '员',
26
+ companyName: '企业 A 有限公司',
27
+ employeeDepartmentName: '总裁办',
28
+ employeeEmploymentStatus: 'active',
29
+ verificationStatus: 'verified' as const,
30
+ role: '所有者',
31
+ memberCount: 187,
32
+ },
33
+ {
34
+ id: 'team-b',
35
+ name: '团队 B',
36
+ type: 'team' as const,
37
+ employeeDisplayName: '团队成员小李',
38
+ employeeAvatarText: '李',
39
+ role: '成员',
40
+ memberCount: 8,
41
+ },
42
+ ]
43
+ return {
44
+ applicationName: APPLICATION_NAME,
45
+ user: {
46
+ displayName: '用户张伟',
47
+ maskedMobile: '138****6688',
48
+ avatarText: '用',
49
+ },
50
+ currentOrganizationId,
51
+ currentOrganization:
52
+ organizations.find((organization) => organization.id === currentOrganizationId) ?? null,
53
+ organizations,
54
+ }
55
+ }
56
+
57
+ async function settle(context: UniplatAccountContext): Promise<void> {
58
+ await context.updateComplete
59
+ await Promise.resolve()
60
+ }
61
+
62
+ describe('<uniplat-account-context>', () => {
63
+ afterEach(() => {
64
+ document.body.replaceChildren()
65
+ vi.restoreAllMocks()
66
+ })
67
+
68
+ it('loads user, organizations and verification state from applicationName plus transport', async () => {
69
+ const load = vi.fn(async () => serverProjection())
70
+ const context = document.createElement(ACCOUNT_CONTEXT_TAG)
71
+ context.configure({
72
+ applicationName: APPLICATION_NAME,
73
+ transport: { load },
74
+ })
75
+ document.body.append(context)
76
+
77
+ await vi.waitFor(() =>
78
+ expect(context.shadowRoot?.querySelector('.display-name')?.textContent).toBe('企业员工小张'),
79
+ )
80
+ expect(load).toHaveBeenCalledWith(APPLICATION_NAME)
81
+ expect(context.shadowRoot?.textContent).toContain('企业 A')
82
+ expect(context.shadowRoot?.querySelector('.status-icon.verified')).not.toBeNull()
83
+ expect(context.shadowRoot?.querySelector('.count')?.textContent?.trim()).toBe('2')
84
+
85
+ context.shadowRoot?.querySelector<HTMLButtonElement>('.account')?.click()
86
+ await settle(context)
87
+ const menu = context.shadowRoot?.querySelector<UniplatUserMenu>('uniplat-user-menu')
88
+ await menu?.updateComplete
89
+ expect(menu?.shadowRoot?.textContent).toContain('企业 A')
90
+ expect(menu?.shadowRoot?.textContent).toContain('团队 B')
91
+ expect(menu?.shadowRoot?.querySelector('.profile-mobile')?.textContent).toBe('138****6688')
92
+ })
93
+
94
+ it('lets the Host respond to organization-selected through the public switch method', async () => {
95
+ const transport: AccountContextTransport = {
96
+ load: vi.fn(async () => serverProjection()),
97
+ switchOrganization: vi.fn(async (_applicationName, organizationId) =>
98
+ serverProjection(organizationId),
99
+ ),
100
+ }
101
+ const context = document.createElement(ACCOUNT_CONTEXT_TAG)
102
+ context.configure({ applicationName: APPLICATION_NAME, transport })
103
+ context.addEventListener('organization-selected', (event) => {
104
+ const selected = event as CustomEvent<{ organizationId: string }>
105
+ void context.switchOrganization(selected.detail.organizationId)
106
+ })
107
+ document.body.append(context)
108
+ await vi.waitFor(() => expect(context.shadowRoot?.textContent).toContain('企业 A'))
109
+
110
+ context.shadowRoot?.querySelector<HTMLButtonElement>('.account')?.click()
111
+ await settle(context)
112
+ const menu = context.shadowRoot?.querySelector<UniplatUserMenu>('uniplat-user-menu')
113
+ await menu?.updateComplete
114
+ menu?.shadowRoot?.querySelector<HTMLButtonElement>('[data-organization-id="team-b"]')?.click()
115
+
116
+ await vi.waitFor(() =>
117
+ expect(context.shadowRoot?.querySelector('.display-name')?.textContent).toBe('团队成员小李'),
118
+ )
119
+ expect(transport.switchOrganization).toHaveBeenCalledWith(APPLICATION_NAME, 'team-b')
120
+ expect(context.shadowRoot?.textContent).toContain('团队 B')
121
+ })
122
+
123
+ it('starts internal onboarding from the account-menu create action without a public create event', async () => {
124
+ const context = document.createElement(ACCOUNT_CONTEXT_TAG)
125
+ const create = vi.fn()
126
+ context.addEventListener('organization-create-requested', create)
127
+ context.configure({
128
+ applicationName: APPLICATION_NAME,
129
+ transport: { load: async () => serverProjection() },
130
+ organizationOnboarding: { transport: createMockOrganizationOnboardingTransport() },
131
+ })
132
+ document.body.append(context)
133
+
134
+ await vi.waitFor(() => expect(context.shadowRoot?.textContent).toContain('企业 A'))
135
+ context.shadowRoot?.querySelector<HTMLButtonElement>('.account')?.click()
136
+ await settle(context)
137
+ const menu = context.shadowRoot?.querySelector<UniplatUserMenu>('uniplat-user-menu')
138
+ await menu?.updateComplete
139
+ menu?.shadowRoot?.querySelector<HTMLButtonElement>('.create-action')?.click()
140
+ await settle(context)
141
+
142
+ expect(create).not.toHaveBeenCalled()
143
+ expect(context.shadowRoot?.querySelector('uniplat-user-menu')).toBeNull()
144
+ const onboarding = context.shadowRoot?.querySelector('uniplat-internal-organization-onboarding')
145
+ await onboarding?.updateComplete
146
+ expect(onboarding?.shadowRoot?.textContent).toContain('创建你的团队 / 企业')
147
+ })
148
+
149
+ it('refreshes its own organization projection after internal onboarding creates an organization', async () => {
150
+ const initialProjection = serverProjection()
151
+ const createdOrganization = {
152
+ id: 'team-created',
153
+ name: '新创建团队',
154
+ type: 'team' as const,
155
+ employeeDisplayName: '团队成员小张',
156
+ employeeAvatarText: '张',
157
+ role: '所有者',
158
+ memberCount: 1,
159
+ }
160
+ const updatedProjection: AccountContextProjection = {
161
+ ...initialProjection,
162
+ organizations: [...initialProjection.organizations, createdOrganization],
163
+ }
164
+ const load = vi
165
+ .fn<() => Promise<AccountContextProjection>>()
166
+ .mockResolvedValueOnce(initialProjection)
167
+ .mockResolvedValueOnce(updatedProjection)
168
+ const context = document.createElement(ACCOUNT_CONTEXT_TAG)
169
+ context.configure({
170
+ applicationName: APPLICATION_NAME,
171
+ transport: { load },
172
+ organizationOnboarding: { transport: createMockOrganizationOnboardingTransport() },
173
+ })
174
+ document.body.append(context)
175
+
176
+ await vi.waitFor(() => expect(load).toHaveBeenCalledTimes(1))
177
+ context.startOrganizationOnboarding()
178
+ await settle(context)
179
+ const onboarding = context.shadowRoot?.querySelector('uniplat-internal-organization-onboarding')
180
+ onboarding?.dispatchEvent(
181
+ new CustomEvent('organization-created', {
182
+ detail: { organizationId: createdOrganization.id, type: 'team' },
183
+ bubbles: true,
184
+ composed: true,
185
+ }),
186
+ )
187
+
188
+ await vi.waitFor(() => expect(load).toHaveBeenCalledTimes(2))
189
+ context.shadowRoot?.querySelector<HTMLButtonElement>('.account')?.click()
190
+ await settle(context)
191
+ const menu = context.shadowRoot?.querySelector<UniplatUserMenu>('uniplat-user-menu')
192
+ await menu?.updateComplete
193
+ expect(menu?.shadowRoot?.textContent).toContain('新创建团队')
194
+ })
195
+
196
+ it('registers and renders the personal account navigation state', async () => {
197
+ expect(customElements.get(ACCOUNT_CONTEXT_TAG)).toBe(UniplatAccountContext)
198
+ const context = document.createElement(ACCOUNT_CONTEXT_TAG)
199
+ const account = vi.fn()
200
+ context.addEventListener('account-menu-requested', account)
201
+ context.configure({
202
+ applicationName: APPLICATION_NAME,
203
+ displayName: '张伟',
204
+ avatarText: '张',
205
+ organizationOnboarding: { transport: createMockOrganizationOnboardingTransport() },
206
+ })
207
+ document.body.append(context)
208
+ await settle(context)
209
+
210
+ expect(context.shadowRoot?.textContent).toContain('创建团队 / 企业')
211
+ expect(context.shadowRoot?.textContent).toContain('张伟')
212
+ expect(context.shadowRoot?.querySelector('.context.personal')).not.toBeNull()
213
+ expect(context.shadowRoot?.querySelector('.workbench')).toBeNull()
214
+ context.shadowRoot?.querySelector<HTMLButtonElement>('.create')?.click()
215
+ context.shadowRoot?.querySelector<HTMLButtonElement>('.account')?.click()
216
+ await settle(context)
217
+ expect(account.mock.calls[0]?.[0].detail).toEqual({ organizationId: null })
218
+ expect(context.shadowRoot?.querySelector('uniplat-user-menu')).not.toBeNull()
219
+ })
220
+
221
+ it('shows the workbench for an open application with oid=0 and unselected organizations', async () => {
222
+ const context = document.createElement(ACCOUNT_CONTEXT_TAG)
223
+ context.configure({
224
+ applicationName: APPLICATION_NAME,
225
+ transport: { load: async () => serverProjection(null) },
226
+ workbench: { getAccessToken: async () => 'test.access.token' },
227
+ organizationOnboarding: { transport: createMockOrganizationOnboardingTransport() },
228
+ })
229
+ document.body.append(context)
230
+
231
+ await vi.waitFor(() =>
232
+ expect(context.shadowRoot?.querySelector('.display-name')?.textContent).toBe('用户张伟'),
233
+ )
234
+ expect(context.shadowRoot?.querySelector('.context.personal')).not.toBeNull()
235
+ expect(context.shadowRoot?.querySelector('.create')).toBeNull()
236
+ expect(context.shadowRoot?.querySelector('.workbench')).not.toBeNull()
237
+
238
+ context.shadowRoot?.querySelector<HTMLButtonElement>('.workbench')?.click()
239
+ await settle(context)
240
+ expect(context.shadowRoot?.querySelector('.workbench-panel')).not.toBeNull()
241
+
242
+ context.shadowRoot?.querySelector<HTMLButtonElement>('.account')?.click()
243
+ await settle(context)
244
+ const menu = context.shadowRoot?.querySelector<UniplatUserMenu>('uniplat-user-menu')
245
+ await menu?.updateComplete
246
+ expect(menu?.shadowRoot?.textContent).toContain('企业 A')
247
+ expect(menu?.shadowRoot?.textContent).toContain('团队 B')
248
+ })
249
+
250
+ it.each([
251
+ ['unverified', 'unverified', '企业未认证', 'alert-circle'],
252
+ ['pending', 'pending', '企业认证审核中', 'clock-hour-4'],
253
+ ['rejected', 'rejected', '企业认证未通过', 'alert-triangle'],
254
+ ['verified', 'verified', '企业已认证', 'discount-check-filled'],
255
+ ['expired', 'expired', '企业认证已过期', 'clock-exclamation'],
256
+ ] as const)(
257
+ 'renders the compact enterprise %s status from the prototype',
258
+ async (status, cssClass, label, iconName) => {
259
+ const context = document.createElement(ACCOUNT_CONTEXT_TAG)
260
+ context.configure({
261
+ applicationName: APPLICATION_NAME,
262
+ displayName: '张伟',
263
+ organization: {
264
+ id: 'enterprise-beijing',
265
+ name: '北京星河科技有限公司',
266
+ type: 'enterprise',
267
+ applicationNames: [APPLICATION_NAME],
268
+ avatarText: '星',
269
+ verificationStatus: status,
270
+ },
271
+ })
272
+ document.body.append(context)
273
+ await settle(context)
274
+
275
+ expect(context.shadowRoot?.textContent).toContain('工作台')
276
+ expect(context.shadowRoot?.textContent).toContain('北京星河科技有限公司')
277
+ const statusIcon = context.shadowRoot?.querySelector(`.status-icon.${cssClass}`)
278
+ expect(statusIcon?.getAttribute('aria-label')).toBe(label)
279
+ expect(statusIcon?.querySelector('svg')?.dataset.prototypeIcon).toBe(iconName)
280
+ expect(statusIcon?.querySelector('path')?.namespaceURI).toBe('http://www.w3.org/2000/svg')
281
+ expect(context.shadowRoot?.querySelector('.selector')).not.toBeNull()
282
+ expect(context.shadowRoot?.querySelector('.verification')).toBeNull()
283
+ expect(context.shadowRoot?.querySelector('.attention-dot') !== null).toBe(
284
+ status === 'rejected' || status === 'expired',
285
+ )
286
+ },
287
+ )
288
+
289
+ it('integrates mutually exclusive workbench and organization panels without changing layout', async () => {
290
+ const token = 'integrated.private.jwt'
291
+ const getAccessToken = vi.fn(async () => token)
292
+ const context = document.createElement(ACCOUNT_CONTEXT_TAG)
293
+ context.configure({
294
+ applicationName: APPLICATION_NAME,
295
+ displayName: '张伟',
296
+ maskedMobile: '138****6688',
297
+ organization: {
298
+ id: 'enterprise-beijing',
299
+ name: '北京星河科技有限公司',
300
+ type: 'enterprise',
301
+ applicationNames: [APPLICATION_NAME],
302
+ verificationStatus: 'verified',
303
+ },
304
+ organizations: [
305
+ {
306
+ id: 'enterprise-beijing',
307
+ name: '北京星河科技有限公司',
308
+ type: 'enterprise',
309
+ applicationNames: [APPLICATION_NAME],
310
+ avatarText: '星',
311
+ verificationStatus: 'verified',
312
+ role: '超管',
313
+ memberCount: 128,
314
+ },
315
+ {
316
+ id: 'team-growth',
317
+ name: '星河增长项目组',
318
+ type: 'team',
319
+ applicationNames: [APPLICATION_NAME],
320
+ avatarText: '增',
321
+ role: '团队负责人',
322
+ memberCount: 9,
323
+ },
324
+ ],
325
+ workbench: { getAccessToken },
326
+ })
327
+ document.body.append(context)
328
+ await settle(context)
329
+
330
+ const contextBox = context.shadowRoot?.querySelector<HTMLElement>('.context')
331
+ const workbenchTrigger = context.shadowRoot?.querySelector<HTMLElement>('.workbench')
332
+ const accountTrigger = context.shadowRoot?.querySelector<HTMLElement>('.account')
333
+ const count = context.shadowRoot?.querySelector<HTMLElement>('.count')
334
+ const initialWidth = getComputedStyle(contextBox!).width
335
+ expect(getComputedStyle(workbenchTrigger!).paddingTop).toBe('9px')
336
+ expect(getComputedStyle(accountTrigger!).height).toBe('42px')
337
+ expect(getComputedStyle(count!).paddingTop).toBe('1px')
338
+ expect(
339
+ context.shadowRoot?.querySelector('.workbench svg')?.getAttribute('data-prototype-icon'),
340
+ ).toBe('layout-grid')
341
+ expect(context.shadowRoot?.querySelector('.workbench svg path')?.getAttribute('d')).toBe(
342
+ 'M4 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z',
343
+ )
344
+ expect(
345
+ context.shadowRoot?.querySelector('.selector svg')?.getAttribute('data-prototype-icon'),
346
+ ).toBe('selector')
347
+ context.shadowRoot?.querySelector<HTMLButtonElement>('.workbench')?.click()
348
+ await settle(context)
349
+
350
+ const workbenchPanel = context.shadowRoot?.querySelector<HTMLElement>('.workbench-panel')
351
+ const switcher = context.shadowRoot?.querySelector<UniplatApplicationSwitcher>(
352
+ 'uniplat-internal-application-switcher',
353
+ )
354
+ expect(getComputedStyle(workbenchPanel!).position).toBe('absolute')
355
+ expect(context.shadowRoot?.querySelector('uniplat-user-menu')).toBeNull()
356
+ await vi.waitFor(() =>
357
+ expect(switcher?.shadowRoot?.querySelectorAll('.application')).toHaveLength(9),
358
+ )
359
+ expect(switcher?.shadowRoot?.querySelector('.launcher')).toBeNull()
360
+ expect(switcher?.shadowRoot?.querySelector('.organization-selector')).toBeNull()
361
+ expect(switcher?.shadowRoot?.querySelector('.panel.embedded')).not.toBeNull()
362
+ expect(context.shadowRoot?.innerHTML).not.toContain(token)
363
+
364
+ context.shadowRoot?.querySelector<HTMLButtonElement>('.account')?.click()
365
+ await settle(context)
366
+ const accountPanel = context.shadowRoot?.querySelector<HTMLElement>('.account-panel')
367
+ const menu = context.shadowRoot?.querySelector<UniplatUserMenu>('uniplat-user-menu')
368
+ await menu?.updateComplete
369
+ expect(getComputedStyle(accountPanel!).position).toBe('absolute')
370
+ expect(context.shadowRoot?.querySelector('uniplat-internal-application-switcher')).toBeNull()
371
+ expect(menu?.shadowRoot?.textContent).toContain('个人账号设置')
372
+ expect(menu?.shadowRoot?.textContent).toContain('我的员工信息')
373
+ expect(menu?.shadowRoot?.textContent).toContain('在 北京星河科技有限公司 的员工信息')
374
+ expect(menu?.shadowRoot?.textContent).toContain('切换企业 / 团队(2)')
375
+ expect(menu?.shadowRoot?.textContent).toContain('退出登录')
376
+ expect(menu?.shadowRoot?.querySelector('.profile-mobile')?.textContent).toBe('138****6688')
377
+ expect(
378
+ menu?.shadowRoot?.querySelector(
379
+ '[data-organization-id="enterprise-beijing"] .organization-icon svg[data-prototype-icon="building"]',
380
+ ),
381
+ ).not.toBeNull()
382
+ expect(getComputedStyle(contextBox!).width).toBe(initialWidth)
383
+ })
384
+
385
+ it('keeps organization selection Host-controlled and closes the integrated menu', async () => {
386
+ const context = document.createElement(ACCOUNT_CONTEXT_TAG)
387
+ const selected = vi.fn()
388
+ const logout = vi.fn()
389
+ context.addEventListener('organization-selected', selected)
390
+ context.addEventListener('logout-requested', logout)
391
+ context.configure({
392
+ applicationName: APPLICATION_NAME,
393
+ displayName: '张伟',
394
+ organization: {
395
+ id: 'enterprise-a',
396
+ name: '企业 A',
397
+ type: 'enterprise',
398
+ applicationNames: [APPLICATION_NAME],
399
+ verificationStatus: 'verified',
400
+ },
401
+ organizations: [
402
+ {
403
+ id: 'enterprise-a',
404
+ name: '企业 A',
405
+ type: 'enterprise',
406
+ applicationNames: [APPLICATION_NAME],
407
+ verificationStatus: 'verified',
408
+ },
409
+ {
410
+ id: 'team-b',
411
+ name: '团队 B',
412
+ type: 'team',
413
+ applicationNames: [APPLICATION_NAME],
414
+ },
415
+ ],
416
+ })
417
+ document.body.append(context)
418
+ await settle(context)
419
+ context.shadowRoot?.querySelector<HTMLButtonElement>('.account')?.click()
420
+ await settle(context)
421
+ const menu = context.shadowRoot?.querySelector<UniplatUserMenu>('uniplat-user-menu')
422
+ await menu?.updateComplete
423
+ menu?.shadowRoot?.querySelector<HTMLButtonElement>('[data-organization-id="team-b"]')?.click()
424
+ await settle(context)
425
+
426
+ expect(selected.mock.calls[0]?.[0].detail).toEqual({ organizationId: 'team-b' })
427
+ expect(context.shadowRoot?.querySelector('uniplat-user-menu')).toBeNull()
428
+ expect(context.shadowRoot?.textContent).toContain('企业 A')
429
+
430
+ context.shadowRoot?.querySelector<HTMLButtonElement>('.account')?.click()
431
+ await settle(context)
432
+ const reopened = context.shadowRoot?.querySelector<UniplatUserMenu>('uniplat-user-menu')
433
+ await reopened?.updateComplete
434
+ reopened?.shadowRoot?.querySelector<HTMLButtonElement>('.logout')?.click()
435
+ expect(logout.mock.calls[0]?.[0].detail).toEqual({})
436
+ })
437
+
438
+ it('forwards the new account actions and closes the integrated menu', async () => {
439
+ const context = document.createElement(ACCOUNT_CONTEXT_TAG)
440
+ const accountSettings = vi.fn()
441
+ const employeeInfo = vi.fn()
442
+ context.addEventListener('account-settings-requested', accountSettings)
443
+ context.addEventListener('employee-info-requested', employeeInfo)
444
+ context.configure({
445
+ applicationName: APPLICATION_NAME,
446
+ displayName: '张伟',
447
+ organization: {
448
+ id: 'enterprise-a',
449
+ name: '企业 A',
450
+ type: 'enterprise',
451
+ applicationNames: [APPLICATION_NAME],
452
+ companyName: '企业 A 有限公司',
453
+ employeeDepartmentName: '总裁办',
454
+ employeeEmploymentStatus: 'active',
455
+ employeeDisplayName: '张伟',
456
+ role: '所有者',
457
+ verificationStatus: 'verified',
458
+ },
459
+ })
460
+ document.body.append(context)
461
+ await settle(context)
462
+
463
+ context.shadowRoot?.querySelector<HTMLButtonElement>('.account')?.click()
464
+ await settle(context)
465
+ let menu = context.shadowRoot?.querySelector<UniplatUserMenu>('uniplat-user-menu')
466
+ await menu?.updateComplete
467
+ menu?.shadowRoot?.querySelector<HTMLButtonElement>('.account-settings-action')?.click()
468
+ await settle(context)
469
+ expect(accountSettings.mock.calls[0]?.[0].detail).toEqual({})
470
+ expect(context.shadowRoot?.querySelector('uniplat-user-menu')).toBeNull()
471
+
472
+ context.shadowRoot?.querySelector<HTMLButtonElement>('.account')?.click()
473
+ await settle(context)
474
+ menu = context.shadowRoot?.querySelector<UniplatUserMenu>('uniplat-user-menu')
475
+ await menu?.updateComplete
476
+ menu?.shadowRoot?.querySelector<HTMLButtonElement>('.employee-info-action')?.click()
477
+ await settle(context)
478
+ expect(employeeInfo.mock.calls[0]?.[0].detail).toEqual({ organizationId: 'enterprise-a' })
479
+ expect(context.shadowRoot?.querySelector('uniplat-user-menu')).toBeNull()
480
+ const employeeInfoModal = context.shadowRoot?.querySelector<HTMLDialogElement>(
481
+ '[part="employee-info-modal"]',
482
+ )
483
+ expect(employeeInfoModal).toBeInstanceOf(HTMLDialogElement)
484
+ expect(employeeInfoModal?.open).toBe(true)
485
+ expect(context.shadowRoot?.querySelector('[part="employee-info-dialog"]')).not.toBeNull()
486
+ expect(context.shadowRoot?.textContent).toContain('所属企业')
487
+ expect(context.shadowRoot?.textContent).toContain('企业 A 有限公司')
488
+ expect(context.shadowRoot?.textContent).toContain('部门')
489
+ expect(context.shadowRoot?.textContent).toContain('未设置')
490
+ expect(context.shadowRoot?.textContent).toContain('姓名')
491
+ expect(context.shadowRoot?.textContent).toContain('张伟')
492
+ expect(context.shadowRoot?.textContent).toContain('我的角色')
493
+ expect(context.shadowRoot?.textContent).toContain('所有者')
494
+ expect(context.shadowRoot?.textContent).toContain('在职')
495
+ })
496
+
497
+ it('loads the current employee department only after opening the employee information dialog', async () => {
498
+ const loadCurrentEmployeeInfo = vi.fn(async () => ({ departmentName: '总裁办' }))
499
+ const context = document.createElement(ACCOUNT_CONTEXT_TAG)
500
+ context.configure({
501
+ applicationName: APPLICATION_NAME,
502
+ transport: {
503
+ load: vi.fn(async () => serverProjection()),
504
+ loadCurrentEmployeeInfo,
505
+ },
506
+ })
507
+ document.body.append(context)
508
+ await vi.waitFor(() => expect(context.shadowRoot?.textContent).toContain('企业 A'))
509
+
510
+ expect(loadCurrentEmployeeInfo).not.toHaveBeenCalled()
511
+ context.shadowRoot?.querySelector<HTMLButtonElement>('.account')?.click()
512
+ await settle(context)
513
+ const menu = context.shadowRoot?.querySelector<UniplatUserMenu>('uniplat-user-menu')
514
+ await menu?.updateComplete
515
+ menu?.shadowRoot?.querySelector<HTMLButtonElement>('.employee-info-action')?.click()
516
+
517
+ await vi.waitFor(() =>
518
+ expect(context.shadowRoot?.querySelector('.employee-info-details')?.textContent).toContain(
519
+ '总裁办',
520
+ ),
521
+ )
522
+ expect(loadCurrentEmployeeInfo).toHaveBeenCalledTimes(1)
523
+ })
524
+
525
+ it('opens personal account settings in a pre-opened window when the transport provides ticket navigation', async () => {
526
+ const replace = vi.fn()
527
+ const pendingWindow = {
528
+ closed: false,
529
+ opener: window,
530
+ location: { replace },
531
+ close: vi.fn(),
532
+ } as unknown as Window
533
+ const open = vi.spyOn(window, 'open').mockReturnValue(pendingWindow)
534
+ const launchAccountSettings = vi.fn(async () => ({
535
+ url: 'https://accounts.example.com/personal#uniplat_application_ticket=account_settings_ticket_1234567890',
536
+ }))
537
+ const requested = vi.fn()
538
+ const context = document.createElement(ACCOUNT_CONTEXT_TAG)
539
+ context.addEventListener('account-settings-requested', requested)
540
+ context.configure({
541
+ applicationName: APPLICATION_NAME,
542
+ transport: {
543
+ load: async () => serverProjection(),
544
+ launchAccountSettings,
545
+ },
546
+ })
547
+ document.body.append(context)
548
+ await vi.waitFor(() =>
549
+ expect(context.shadowRoot?.querySelector<HTMLButtonElement>('.account')).not.toBeNull(),
550
+ )
551
+
552
+ context.shadowRoot?.querySelector<HTMLButtonElement>('.account')?.click()
553
+ await settle(context)
554
+ const menu = context.shadowRoot?.querySelector<UniplatUserMenu>('uniplat-user-menu')
555
+ await menu?.updateComplete
556
+ menu?.shadowRoot?.querySelector<HTMLButtonElement>('.account-settings-action')?.click()
557
+ await vi.waitFor(() => expect(launchAccountSettings).toHaveBeenCalledTimes(1))
558
+
559
+ expect(launchAccountSettings).toHaveBeenCalledWith(APPLICATION_NAME)
560
+ expect(open).toHaveBeenCalledWith('about:blank', '_blank')
561
+ expect(replace).toHaveBeenCalledWith(
562
+ 'https://accounts.example.com/personal#uniplat_application_ticket=account_settings_ticket_1234567890',
563
+ )
564
+ expect(requested).not.toHaveBeenCalled()
565
+ })
566
+
567
+ it('requires applicationName and renders the complete server-projected organization list', async () => {
568
+ const context = document.createElement(ACCOUNT_CONTEXT_TAG)
569
+ expect(() => context.configure({ applicationName: ' ', displayName: '张伟' })).toThrow(
570
+ TypeError,
571
+ )
572
+ context.configure({
573
+ applicationName: APPLICATION_NAME,
574
+ displayName: '张伟',
575
+ organization: {
576
+ id: 'enterprise-a',
577
+ name: '企业 A',
578
+ type: 'enterprise',
579
+ verificationStatus: 'verified',
580
+ },
581
+ organizations: [
582
+ {
583
+ id: 'enterprise-a',
584
+ name: '企业 A',
585
+ type: 'enterprise',
586
+ verificationStatus: 'verified',
587
+ },
588
+ {
589
+ id: 'team-b',
590
+ name: '服务端投影团队 B',
591
+ type: 'team',
592
+ },
593
+ {
594
+ id: 'team-c',
595
+ name: '服务端投影团队 C',
596
+ type: 'team',
597
+ },
598
+ ],
599
+ organizationCount: 3,
600
+ })
601
+ document.body.append(context)
602
+ await settle(context)
603
+
604
+ expect(context.shadowRoot?.querySelector('.count')?.textContent?.trim()).toBe('3')
605
+ context.shadowRoot?.querySelector<HTMLButtonElement>('.account')?.click()
606
+ await settle(context)
607
+ const menu = context.shadowRoot?.querySelector<UniplatUserMenu>('uniplat-user-menu')
608
+ await menu?.updateComplete
609
+ expect(menu?.shadowRoot?.querySelector('[data-organization-id="enterprise-a"]')).not.toBeNull()
610
+ expect(menu?.shadowRoot?.querySelector('[data-organization-id="team-c"]')).not.toBeNull()
611
+ expect(menu?.shadowRoot?.querySelector('[data-organization-id="team-b"]')).not.toBeNull()
612
+ expect(menu?.shadowRoot?.textContent).toContain('服务端投影团队 B')
613
+ })
614
+
615
+ it('uses the selected organization employee identity and falls back to the user identity', async () => {
616
+ const context = document.createElement(ACCOUNT_CONTEXT_TAG)
617
+ context.configure({
618
+ applicationName: APPLICATION_NAME,
619
+ displayName: '用户张伟',
620
+ maskedMobile: '138****6688',
621
+ avatarText: '用',
622
+ avatarUrl: 'https://static.example.com/user-avatar.png',
623
+ organization: {
624
+ id: 'enterprise-a',
625
+ name: '企业 A',
626
+ type: 'enterprise',
627
+ applicationNames: [APPLICATION_NAME],
628
+ verificationStatus: 'verified',
629
+ },
630
+ organizations: [
631
+ {
632
+ id: 'enterprise-a',
633
+ name: '企业 A',
634
+ type: 'enterprise',
635
+ applicationNames: [APPLICATION_NAME],
636
+ avatarText: '企',
637
+ employeeDisplayName: '企业员工小张',
638
+ employeeAvatarText: '员',
639
+ employeeAvatarUrl: 'https://static.example.com/employee-a.png',
640
+ verificationStatus: 'verified',
641
+ },
642
+ {
643
+ id: 'team-b',
644
+ name: '团队 B',
645
+ type: 'team',
646
+ applicationNames: [APPLICATION_NAME],
647
+ avatarText: '团',
648
+ employeeDisplayName: '团队成员小李',
649
+ employeeAvatarText: '李',
650
+ employeeAvatarUrl: 'https://static.example.com/employee-b.png',
651
+ },
652
+ ],
653
+ })
654
+ document.body.append(context)
655
+ await settle(context)
656
+
657
+ expect(context.shadowRoot?.querySelector('.display-name')?.textContent).toBe('企业员工小张')
658
+ expect(context.shadowRoot?.querySelector('.avatar-fallback')?.textContent).toBe('员')
659
+ expect(context.shadowRoot?.querySelector<HTMLImageElement>('.avatar-image')?.src).toBe(
660
+ 'https://static.example.com/employee-a.png',
661
+ )
662
+
663
+ context.shadowRoot?.querySelector<HTMLButtonElement>('.account')?.click()
664
+ await settle(context)
665
+ const menu = context.shadowRoot?.querySelector<UniplatUserMenu>('uniplat-user-menu')
666
+ await menu?.updateComplete
667
+ expect(menu?.shadowRoot?.querySelector('.profile-name')?.textContent).toBe('企业员工小张')
668
+ expect(menu?.shadowRoot?.querySelector<HTMLImageElement>('.avatar-image')?.src).toBe(
669
+ 'https://static.example.com/employee-a.png',
670
+ )
671
+ expect(
672
+ menu?.shadowRoot
673
+ ?.querySelector('[data-organization-id="enterprise-a"] .organization-icon')
674
+ ?.querySelector('svg')?.dataset.prototypeIcon,
675
+ ).toBe('building')
676
+
677
+ context.configure({
678
+ applicationName: APPLICATION_NAME,
679
+ displayName: '用户张伟',
680
+ avatarText: '用',
681
+ avatarUrl: 'https://static.example.com/user-avatar.png',
682
+ organization: {
683
+ id: 'team-b',
684
+ name: '团队 B',
685
+ type: 'team',
686
+ applicationNames: [APPLICATION_NAME],
687
+ },
688
+ organizations: [
689
+ {
690
+ id: 'enterprise-a',
691
+ name: '企业 A',
692
+ type: 'enterprise',
693
+ applicationNames: [APPLICATION_NAME],
694
+ employeeDisplayName: '企业员工小张',
695
+ verificationStatus: 'verified',
696
+ },
697
+ {
698
+ id: 'team-b',
699
+ name: '团队 B',
700
+ type: 'team',
701
+ applicationNames: [APPLICATION_NAME],
702
+ employeeDisplayName: '团队成员小李',
703
+ employeeAvatarText: '李',
704
+ employeeAvatarUrl: 'https://static.example.com/employee-b.png',
705
+ },
706
+ ],
707
+ })
708
+ await settle(context)
709
+ expect(context.shadowRoot?.querySelector('.display-name')?.textContent).toBe('团队成员小李')
710
+ expect(context.shadowRoot?.querySelector<HTMLImageElement>('.avatar-image')?.src).toBe(
711
+ 'https://static.example.com/employee-b.png',
712
+ )
713
+
714
+ context.configure({
715
+ applicationName: APPLICATION_NAME,
716
+ displayName: '用户张伟',
717
+ avatarText: '用',
718
+ avatarUrl: 'https://static.example.com/user-avatar.png',
719
+ })
720
+ await settle(context)
721
+ expect(context.shadowRoot?.querySelector('.display-name')?.textContent).toBe('用户张伟')
722
+ expect(context.shadowRoot?.querySelector('.avatar-fallback')?.textContent).toBe('用')
723
+ expect(context.shadowRoot?.querySelector<HTMLImageElement>('.avatar-image')?.src).toBe(
724
+ 'https://static.example.com/user-avatar.png',
725
+ )
726
+ })
727
+
728
+ it('falls back to initials for failed avatars and rejects unsafe identity URLs', async () => {
729
+ const context = document.createElement(ACCOUNT_CONTEXT_TAG)
730
+ expect(() =>
731
+ context.configure({
732
+ applicationName: APPLICATION_NAME,
733
+ displayName: '用户张伟',
734
+ avatarUrl: 'https://static.example.com/avatar.png?accessToken=private',
735
+ }),
736
+ ).toThrow(TypeError)
737
+ expect(() =>
738
+ context.configure({
739
+ applicationName: APPLICATION_NAME,
740
+ displayName: '用户张伟',
741
+ organization: {
742
+ id: 'team-a',
743
+ name: '团队 A',
744
+ type: 'team',
745
+ applicationNames: [APPLICATION_NAME],
746
+ employeeAvatarUrl: 'https://static.example.com/employee.png',
747
+ },
748
+ }),
749
+ ).toThrow(TypeError)
750
+
751
+ context.configure({
752
+ applicationName: APPLICATION_NAME,
753
+ displayName: '用户张伟',
754
+ avatarText: '用',
755
+ avatarUrl: 'https://static.example.com/avatar.png',
756
+ })
757
+ document.body.append(context)
758
+ await settle(context)
759
+ const image = context.shadowRoot?.querySelector<HTMLImageElement>('.avatar-image')
760
+ image?.dispatchEvent(new Event('error'))
761
+ expect(image?.hidden).toBe(true)
762
+ expect(context.shadowRoot?.querySelector('.avatar-fallback')?.textContent).toBe('用')
763
+ image?.dispatchEvent(new Event('load'))
764
+ expect(image?.hidden).toBe(false)
765
+ })
766
+
767
+ it('requires enterprise status, rejects raw mobile numbers and renders labels as text', async () => {
768
+ const context = document.createElement(ACCOUNT_CONTEXT_TAG)
769
+ expect(() =>
770
+ context.configure({
771
+ applicationName: APPLICATION_NAME,
772
+ displayName: '张伟',
773
+ organization: {
774
+ id: 'enterprise-beijing',
775
+ name: '北京星河科技有限公司',
776
+ type: 'enterprise',
777
+ applicationNames: [APPLICATION_NAME],
778
+ },
779
+ }),
780
+ ).toThrow(TypeError)
781
+ expect(() =>
782
+ context.configure({
783
+ applicationName: APPLICATION_NAME,
784
+ displayName: '张伟',
785
+ maskedMobile: '13800138000',
786
+ }),
787
+ ).toThrow(TypeError)
788
+
789
+ context.configure({
790
+ applicationName: APPLICATION_NAME,
791
+ displayName: '<script>unsafe()</script>',
792
+ })
793
+ document.body.append(context)
794
+ await settle(context)
795
+ expect(context.shadowRoot?.querySelector('script')).toBeNull()
796
+ expect(context.shadowRoot?.textContent).toContain('<script>unsafe()</script>')
797
+ })
798
+ })