@csntgao/uni-base 0.1.2 → 0.1.4

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.
@@ -86,6 +86,11 @@ type QrViewState = 'idle' | 'loading' | UserLoginQrStatus | 'error'
86
86
 
87
87
  export const USER_LOGIN_QR_LOGIN_ENABLED = false
88
88
 
89
+ const USER_LOGIN_DEFAULT_USER_AGREEMENT_URL =
90
+ 'https://website.hrs100.com/agreement/user-service-agreement.html'
91
+ const USER_LOGIN_DEFAULT_PRIVACY_AGREEMENT_URL =
92
+ 'https://website.hrs100.com/agreement/qqxb-user-privacy.html'
93
+
89
94
  const EMPTY_ERRORS: FormErrors = {
90
95
  mobile: '',
91
96
  smsCode: '',
@@ -923,8 +928,14 @@ export class UniplatUserLogin extends LitElement {
923
928
  this.config = {
924
929
  transport: config.transport,
925
930
  applicationName: normalizeApplicationName(config.applicationName),
926
- userAgreementUrl: safeNavigationUrl(config.userAgreementUrl, 'Agreement URL'),
927
- privacyAgreementUrl: safeNavigationUrl(config.privacyAgreementUrl, 'Agreement URL'),
931
+ userAgreementUrl: safeNavigationUrl(
932
+ config.userAgreementUrl ?? USER_LOGIN_DEFAULT_USER_AGREEMENT_URL,
933
+ 'Agreement URL',
934
+ ),
935
+ privacyAgreementUrl: safeNavigationUrl(
936
+ config.privacyAgreementUrl ?? USER_LOGIN_DEFAULT_PRIVACY_AGREEMENT_URL,
937
+ 'Agreement URL',
938
+ ),
928
939
  forgotPasswordUrl: safeNavigationUrl(config.forgotPasswordUrl, 'Forgot-password URL'),
929
940
  countdownSeconds,
930
941
  qrPollIntervalMs,
@@ -725,6 +725,39 @@ describe('<uniplat-account-context>', () => {
725
725
  )
726
726
  })
727
727
 
728
+ it('falls back to the user avatar when the selected organization employee has no avatar', async () => {
729
+ const context = document.createElement(ACCOUNT_CONTEXT_TAG)
730
+ context.configure({
731
+ applicationName: APPLICATION_NAME,
732
+ displayName: '用户张伟',
733
+ avatarText: '用',
734
+ avatarUrl: 'https://static.example.com/user-avatar.png',
735
+ organization: {
736
+ id: 'enterprise-a',
737
+ name: '企业 A',
738
+ type: 'enterprise',
739
+ employeeDisplayName: '企业员工小张',
740
+ employeeAvatarText: '员',
741
+ verificationStatus: 'verified',
742
+ },
743
+ })
744
+ document.body.append(context)
745
+ await settle(context)
746
+
747
+ expect(context.shadowRoot?.querySelector('.display-name')?.textContent).toBe('企业员工小张')
748
+ expect(context.shadowRoot?.querySelector<HTMLImageElement>('.avatar-image')?.src).toBe(
749
+ 'https://static.example.com/user-avatar.png',
750
+ )
751
+
752
+ context.shadowRoot?.querySelector<HTMLButtonElement>('.account')?.click()
753
+ await settle(context)
754
+ const menu = context.shadowRoot?.querySelector<UniplatUserMenu>('uniplat-user-menu')
755
+ await menu?.updateComplete
756
+ expect(menu?.shadowRoot?.querySelector<HTMLImageElement>('.avatar-image')?.src).toBe(
757
+ 'https://static.example.com/user-avatar.png',
758
+ )
759
+ })
760
+
728
761
  it('falls back to initials for failed avatars and rejects unsafe identity URLs', async () => {
729
762
  const context = document.createElement(ACCOUNT_CONTEXT_TAG)
730
763
  expect(() =>
@@ -4,10 +4,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
4
4
  import {
5
5
  ORGANIZATION_SELECTOR_TAG,
6
6
  UniplatOrganizationSelector,
7
+ resolveOrganizationEntryDecision,
7
8
  type OrganizationSelectorConfig,
8
9
  } from '../src'
9
10
 
10
11
  const CONFIG: OrganizationSelectorConfig = {
12
+ displayName: '张伟',
13
+ maskedMobile: '138****8000',
11
14
  selectedOrganizationId: 'enterprise-beijing',
12
15
  rememberSelection: true,
13
16
  organizations: [
@@ -19,6 +22,7 @@ const CONFIG: OrganizationSelectorConfig = {
19
22
  role: '超管',
20
23
  memberCount: 128,
21
24
  lastEnteredLabel: '上次进入 2 小时前',
25
+ unifiedSocialCreditCode: '91110108MA01XY7788',
22
26
  },
23
27
  {
24
28
  id: 'enterprise-shanghai',
@@ -58,70 +62,278 @@ async function settle(selector: UniplatOrganizationSelector): Promise<void> {
58
62
  await Promise.resolve()
59
63
  }
60
64
 
65
+ async function mount(
66
+ config: OrganizationSelectorConfig = CONFIG,
67
+ ): Promise<UniplatOrganizationSelector> {
68
+ const selector = document.createElement(ORGANIZATION_SELECTOR_TAG)
69
+ selector.configure(config)
70
+ document.body.append(selector)
71
+ await settle(selector)
72
+ return selector
73
+ }
74
+
75
+ function rowNames(selector: UniplatOrganizationSelector): string[] {
76
+ return [...(selector.shadowRoot?.querySelectorAll('.organization .name') ?? [])].map(
77
+ (node) => node.textContent?.trim() ?? '',
78
+ )
79
+ }
80
+
81
+ async function typeSearch(selector: UniplatOrganizationSelector, value: string): Promise<void> {
82
+ const input = selector.shadowRoot?.querySelector<HTMLInputElement>('.search input')
83
+ if (!input) throw new Error('Missing search input')
84
+ input.value = value
85
+ input.dispatchEvent(new Event('input', { bubbles: true }))
86
+ await settle(selector)
87
+ }
88
+
61
89
  describe('<uniplat-organization-selector>', () => {
62
90
  afterEach(() => {
63
91
  document.body.replaceChildren()
64
92
  })
65
93
 
66
- it('registers and renders all enterprise and team memberships from the prototype', async () => {
94
+ it('registers and renders the S.1 default state from the prototype', async () => {
67
95
  expect(customElements.get(ORGANIZATION_SELECTOR_TAG)).toBe(UniplatOrganizationSelector)
68
- const selector = document.createElement(ORGANIZATION_SELECTOR_TAG)
69
- selector.configure(CONFIG)
70
- document.body.append(selector)
71
- await settle(selector)
96
+ const selector = await mount()
72
97
 
73
- expect(selector.shadowRoot?.textContent).toContain('你归属 5 个团队 / 企业')
98
+ const text = selector.shadowRoot?.textContent ?? ''
99
+ expect(text).toContain('选择要进入的团队 / 企业')
100
+ expect(text).toContain('张伟 · 138****8000')
101
+ expect(text).toContain('你共归属 5 个组织,进入后可在导航随时切换')
102
+ expect(selector.shadowRoot?.querySelector('.search input')?.getAttribute('placeholder')).toBe(
103
+ '搜索名称,或输入统一社会信用代码',
104
+ )
105
+ expect(text).toContain('最近进入')
106
+ expect(text).toContain('全部组织')
74
107
  expect(selector.shadowRoot?.querySelectorAll('.organization')).toHaveLength(5)
75
- expect(selector.shadowRoot?.textContent).toContain('北京星河科技有限公司')
76
- expect(selector.shadowRoot?.textContent).toContain('企业')
77
- expect(selector.shadowRoot?.textContent).toContain('未认证')
78
- expect(selector.shadowRoot?.textContent).toContain('已过期')
79
- expect(selector.shadowRoot?.textContent).toContain('团队')
80
- expect(selector.shadowRoot?.textContent).toContain('超管 · 128 人 · 上次进入 2 小时前')
108
+ expect(text).toContain('未认证')
109
+ expect(text).toContain('已过期')
110
+ expect(text).toContain('团队')
111
+ expect(text).toContain('超管 · 128 人 · 上次进入 2 小时前')
112
+ expect(text).toContain('记住选择,下次直接进入')
113
+ expect(text).toContain('+ 创建 / 加入')
114
+ expect(text).toContain('退出登录')
115
+ })
116
+
117
+ it('shows type filter counts and filters the list without collapsing the counts', async () => {
118
+ const selector = await mount()
119
+ const chips = [...(selector.shadowRoot?.querySelectorAll<HTMLButtonElement>('.chip') ?? [])]
120
+
121
+ expect(chips.map((chip) => chip.textContent?.trim())).toEqual(['全部 5', '企业 3', '团队 2'])
122
+ expect(chips[0]?.getAttribute('aria-pressed')).toBe('true')
123
+
124
+ chips[2]?.click()
125
+ await settle(selector)
126
+
127
+ expect(rowNames(selector)).toEqual(['星河增长项目组', '2026 校招筹备团队'])
128
+ expect(
129
+ [...(selector.shadowRoot?.querySelectorAll('.chip') ?? [])].map((chip) =>
130
+ chip.textContent?.trim(),
131
+ ),
132
+ ).toEqual(['全部 5', '企业 3', '团队 2'])
133
+ expect(selector.shadowRoot?.textContent).not.toContain('最近进入')
134
+ })
135
+
136
+ it('searches by name and by unified social credit code without rendering the code', async () => {
137
+ const selector = await mount()
138
+
139
+ await typeSearch(selector, '上海')
140
+ expect(rowNames(selector)).toEqual(['星河(上海)分公司'])
141
+
142
+ await typeSearch(selector, '91110108ma01xy7788')
143
+ expect(rowNames(selector)).toEqual(['北京星河科技有限公司'])
144
+ expect(selector.shadowRoot?.textContent).not.toContain('91110108MA01XY7788')
145
+
146
+ await typeSearch(selector, '不存在的组织')
147
+ expect(rowNames(selector)).toEqual([])
148
+ expect(selector.shadowRoot?.textContent).toContain('没有匹配的团队或企业')
149
+ })
150
+
151
+ it('groups the recently entered organization first and preselects it by default', async () => {
152
+ const selector = await mount({ organizations: CONFIG.organizations })
153
+ const groups = [...(selector.shadowRoot?.querySelectorAll('.group') ?? [])]
154
+
155
+ expect(
156
+ [...(groups[0]?.querySelectorAll('.name') ?? [])].map((node) => node.textContent?.trim()),
157
+ ).toEqual(['北京星河科技有限公司'])
158
+ expect(
159
+ [...(groups[1]?.querySelectorAll('.name') ?? [])].map((node) => node.textContent?.trim()),
160
+ ).toEqual(['星河(上海)分公司', '星河增长项目组', '已过期企业', '2026 校招筹备团队'])
161
+ expect(selector.shadowRoot?.querySelector('.organization.selected .name')?.textContent).toBe(
162
+ '北京星河科技有限公司',
163
+ )
164
+ expect(selector.shadowRoot?.querySelector('.enter')?.textContent?.trim()).toBe(
165
+ '进入「北京星河科技有限公司」',
166
+ )
81
167
  })
82
168
 
83
169
  it('keeps the choice local until enter and emits only ids and the remember preference', async () => {
84
170
  const selector = document.createElement(ORGANIZATION_SELECTOR_TAG)
85
171
  const enter = vi.fn()
86
172
  const create = vi.fn()
173
+ const logout = vi.fn()
87
174
  selector.addEventListener('organization-enter-requested', enter)
88
175
  selector.addEventListener('organization-create-requested', create)
176
+ selector.addEventListener('logout-requested', logout)
89
177
  selector.configure(CONFIG)
90
178
  document.body.append(selector)
91
179
  await settle(selector)
92
180
 
93
181
  selector.shadowRoot?.querySelectorAll<HTMLButtonElement>('.organization')[2]?.click()
182
+ await settle(selector)
183
+ expect(selector.shadowRoot?.querySelector('.enter')?.textContent?.trim()).toBe(
184
+ '进入「星河增长项目组」',
185
+ )
186
+
94
187
  const remember = selector.shadowRoot?.querySelector<HTMLInputElement>('.remember input')
95
188
  if (!remember) throw new Error('Missing remember checkbox')
96
189
  remember.checked = false
97
190
  remember.dispatchEvent(new Event('change', { bubbles: true }))
98
191
  selector.shadowRoot?.querySelector<HTMLButtonElement>('.enter')?.click()
99
192
  selector.shadowRoot?.querySelector<HTMLButtonElement>('.create')?.click()
193
+ selector.shadowRoot?.querySelector<HTMLButtonElement>('.logout')?.click()
100
194
 
101
195
  expect(enter.mock.calls[0]?.[0].detail).toEqual({
102
196
  organizationId: 'team-growth',
103
197
  rememberSelection: false,
104
198
  })
105
- expect(JSON.stringify(enter.mock.calls[0]?.[0].detail)).not.toContain('星河')
199
+ const serialized = JSON.stringify(enter.mock.calls[0]?.[0].detail)
200
+ expect(serialized).not.toContain('星河')
201
+ expect(serialized).not.toContain('张伟')
202
+ expect(serialized).not.toContain('8000')
106
203
  expect(create.mock.calls[0]?.[0].detail).toEqual({})
204
+ expect(logout.mock.calls[0]?.[0].detail).toEqual({})
205
+ expect(logout.mock.calls[0]?.[0].composed).toBe(true)
107
206
  expect(enter.mock.calls[0]?.[0].composed).toBe(true)
108
207
  })
109
208
 
110
- it('rejects duplicate ids and renders external labels as text', async () => {
209
+ it('renders a forced modal without a close button and ignores overlay clicks and Escape', async () => {
210
+ const selector = await mount({ ...CONFIG, presentation: 'modal' })
211
+ const hostEscape = vi.fn()
212
+ document.addEventListener('keydown', hostEscape)
213
+
214
+ const overlay = selector.shadowRoot?.querySelector<HTMLElement>('.overlay')
215
+ expect(overlay).not.toBeNull()
216
+ expect(selector.getAttribute('data-presentation')).toBe('modal')
217
+ expect(selector.shadowRoot?.querySelector('.card')?.getAttribute('aria-modal')).toBe('true')
218
+ expect(selector.shadowRoot?.querySelectorAll('button')).not.toHaveLength(0)
219
+ expect(
220
+ [...(selector.shadowRoot?.querySelectorAll('button') ?? [])].some((button) =>
221
+ /关闭|close|×/i.test(`${button.className} ${button.getAttribute('part') ?? ''}`),
222
+ ),
223
+ ).toBe(false)
224
+
225
+ overlay?.click()
226
+ await settle(selector)
227
+ expect(selector.shadowRoot?.querySelector('.overlay')).not.toBeNull()
228
+
229
+ const escape = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
230
+ document.dispatchEvent(escape)
231
+ expect(escape.defaultPrevented).toBe(true)
232
+ expect(hostEscape).not.toHaveBeenCalled()
233
+
234
+ document.removeEventListener('keydown', hostEscape)
235
+ })
236
+
237
+ it('stays inline by default and releases the Escape guard after removal', async () => {
238
+ const inline = await mount()
239
+ expect(inline.shadowRoot?.querySelector('.overlay')).toBeNull()
240
+ expect(inline.hasAttribute('data-presentation')).toBe(false)
241
+
242
+ const modal = await mount({ ...CONFIG, presentation: 'modal' })
243
+ modal.remove()
244
+ await settle(modal)
245
+
246
+ const hostEscape = vi.fn()
247
+ document.addEventListener('keydown', hostEscape)
248
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
249
+ expect(hostEscape).toHaveBeenCalledTimes(1)
250
+ document.removeEventListener('keydown', hostEscape)
251
+ })
252
+
253
+ it('rejects duplicate ids, full mobile numbers and unknown presentations', async () => {
111
254
  const selector = document.createElement(ORGANIZATION_SELECTOR_TAG)
112
255
  expect(() =>
113
256
  selector.configure({
114
257
  organizations: [CONFIG.organizations[0]!, CONFIG.organizations[0]!],
115
258
  }),
116
259
  ).toThrow(TypeError)
260
+ expect(() =>
261
+ selector.configure({ organizations: CONFIG.organizations, maskedMobile: '13800008000' }),
262
+ ).toThrow(TypeError)
263
+ expect(() =>
264
+ selector.configure({
265
+ organizations: CONFIG.organizations,
266
+ presentation: 'drawer' as never,
267
+ }),
268
+ ).toThrow(TypeError)
117
269
 
118
270
  selector.configure({
119
271
  organizations: [{ id: 'safe-id', name: '<img src=x onerror=unsafe()>', type: 'team' }],
272
+ displayName: '<b>张伟</b>',
120
273
  })
121
274
  document.body.append(selector)
122
275
  await settle(selector)
123
276
 
124
277
  expect(selector.shadowRoot?.querySelector('img')).toBeNull()
278
+ expect(selector.shadowRoot?.querySelector('b')).toBeNull()
125
279
  expect(selector.shadowRoot?.textContent).toContain('<img src=x onerror=unsafe()>')
280
+ expect(selector.shadowRoot?.textContent).toContain('<b>张伟</b>')
281
+ })
282
+
283
+ it('renders the empty state without search or filters', async () => {
284
+ const selector = await mount({ organizations: [] })
285
+
286
+ expect(selector.shadowRoot?.textContent).toContain('暂无可进入的团队或企业')
287
+ expect(selector.shadowRoot?.querySelector('.search')).toBeNull()
288
+ expect(selector.shadowRoot?.querySelector('.chip')).toBeNull()
289
+ expect(selector.shadowRoot?.querySelector<HTMLButtonElement>('.enter')?.disabled).toBe(true)
290
+ })
291
+ })
292
+
293
+ describe('resolveOrganizationEntryDecision()', () => {
294
+ it('sends users without memberships to the personal state', () => {
295
+ expect(resolveOrganizationEntryDecision({ organizations: [] })).toEqual({ kind: 'personal' })
296
+ })
297
+
298
+ it('enters directly with a single membership even when nothing is remembered', () => {
299
+ expect(resolveOrganizationEntryDecision({ organizations: [CONFIG.organizations[2]!] })).toEqual(
300
+ { kind: 'auto-enter', organizationId: 'team-growth' },
301
+ )
302
+ })
303
+
304
+ it('skips the prompt when the remembered organization is still available', () => {
305
+ expect(
306
+ resolveOrganizationEntryDecision({
307
+ organizations: CONFIG.organizations,
308
+ rememberedOrganizationId: 'team-campus',
309
+ }),
310
+ ).toEqual({ kind: 'auto-enter', organizationId: 'team-campus' })
311
+ })
312
+
313
+ it('prompts with the recently entered organization for two or more memberships', () => {
314
+ expect(resolveOrganizationEntryDecision({ organizations: CONFIG.organizations })).toEqual({
315
+ kind: 'prompt',
316
+ selectedOrganizationId: 'enterprise-beijing',
317
+ })
318
+ })
319
+
320
+ it('prompts again when the remembered organization is no longer available', () => {
321
+ expect(
322
+ resolveOrganizationEntryDecision({
323
+ organizations: CONFIG.organizations,
324
+ rememberedOrganizationId: 'enterprise-removed',
325
+ }),
326
+ ).toEqual({ kind: 'prompt', selectedOrganizationId: 'enterprise-beijing' })
327
+ })
328
+
329
+ it('rejects malformed input instead of guessing an entry', () => {
330
+ expect(() => resolveOrganizationEntryDecision({ organizations: undefined as never })).toThrow(
331
+ TypeError,
332
+ )
333
+ expect(() =>
334
+ resolveOrganizationEntryDecision({
335
+ organizations: [CONFIG.organizations[0]!, CONFIG.organizations[0]!],
336
+ }),
337
+ ).toThrow(TypeError)
126
338
  })
127
339
  })
@@ -48,6 +48,7 @@ describe('<uniplat-user-login>', () => {
48
48
  afterEach(() => {
49
49
  document.body.replaceChildren()
50
50
  vi.useRealTimers()
51
+ vi.restoreAllMocks()
51
52
  })
52
53
 
53
54
  it('registers, renders the verification login form and completes a safe login flow', async () => {
@@ -278,6 +279,32 @@ describe('<uniplat-user-login>', () => {
278
279
  ).toThrow(TypeError)
279
280
  })
280
281
 
282
+ it('opens the official user and privacy agreements by default', async () => {
283
+ const openWindow = vi.spyOn(window, 'open').mockReturnValue(null)
284
+ const login = document.createElement(USER_LOGIN_TAG)
285
+ login.configure({ transport: createTransport(), applicationName: 'HrSaas' })
286
+ document.body.append(login)
287
+ await settle(login)
288
+
289
+ const links = login.shadowRoot?.querySelectorAll<HTMLButtonElement>('.agreement-link')
290
+ expect(links).toHaveLength(2)
291
+ links?.[0]?.click()
292
+ links?.[1]?.click()
293
+
294
+ expect(openWindow).toHaveBeenNthCalledWith(
295
+ 1,
296
+ 'https://website.hrs100.com/agreement/user-service-agreement.html',
297
+ '_blank',
298
+ 'noopener,noreferrer',
299
+ )
300
+ expect(openWindow).toHaveBeenNthCalledWith(
301
+ 2,
302
+ 'https://website.hrs100.com/agreement/qqxb-user-privacy.html',
303
+ '_blank',
304
+ 'noopener,noreferrer',
305
+ )
306
+ })
307
+
281
308
  it('maps transport failures to stable errors without exposing the original exception', async () => {
282
309
  const sensitiveText = 'backend stack with jwt=should-never-render'
283
310
  const transport = createTransport({