@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,1564 @@
1
+ import { LitElement, css, html, nothing, type TemplateResult } from 'lit'
2
+
3
+ export type OrganizationEmployeeEmploymentStatus =
4
+ 'active' | 'pending' | 'rejected' | 'left' | 'unknown'
5
+
6
+ export interface OrganizationEmployeeAvatar {
7
+ kind: 'file' | 'color' | 'none'
8
+ fileId?: string
9
+ color?: string
10
+ }
11
+
12
+ export interface OrganizationEmployeeSelectionItem {
13
+ employeeId: string
14
+ displayName: string
15
+ avatar?: OrganizationEmployeeAvatar
16
+ maskedMobile?: string
17
+ primaryDepartmentId?: string
18
+ primaryDepartmentName?: string
19
+ departmentIds: string[]
20
+ departmentNames: string[]
21
+ employmentStatus: OrganizationEmployeeEmploymentStatus
22
+ }
23
+
24
+ export interface OrganizationDepartmentNode {
25
+ id: string
26
+ name: string
27
+ parentId?: string
28
+ depth: number
29
+ memberCount?: number
30
+ hasChildren?: boolean
31
+ }
32
+
33
+ export interface OrganizationEmployeePage {
34
+ items: OrganizationEmployeeSelectionItem[]
35
+ page: number
36
+ pageSize: number
37
+ total: number
38
+ hasMore: boolean
39
+ }
40
+
41
+ export interface UniplatOrganizationMultiEmployeeSelectorTransport {
42
+ loadDepartmentTree(): Promise<OrganizationDepartmentNode[]>
43
+ listDepartmentEmployees(input: {
44
+ departmentId: string
45
+ page: number
46
+ pageSize: number
47
+ employmentStatuses: readonly string[]
48
+ }): Promise<OrganizationEmployeePage>
49
+ searchEmployees(input: {
50
+ keyword: string
51
+ page: number
52
+ pageSize: number
53
+ employmentStatuses: readonly string[]
54
+ }): Promise<OrganizationEmployeePage>
55
+ resolveEmployees(input: {
56
+ employeeIds: readonly string[]
57
+ }): Promise<OrganizationEmployeeSelectionItem[]>
58
+ }
59
+
60
+ export interface OrganizationMultiEmployeeSelectorConfig {
61
+ applicationName: string
62
+ transport: UniplatOrganizationMultiEmployeeSelectorTransport
63
+ }
64
+
65
+ export interface OrganizationMultiEmployeeSelectorDisabledSelection {
66
+ employeeId: string
67
+ reason?: string
68
+ }
69
+
70
+ export interface OrganizationMultiEmployeeSelectorOpenOptions {
71
+ title?: string
72
+ initialSelectedEmployeeIds?: readonly string[]
73
+ disabledSelections?: readonly OrganizationMultiEmployeeSelectorDisabledSelection[]
74
+ maxSelection?: number
75
+ selectableEmploymentStatuses?: readonly 'active'[]
76
+ enableDepartmentBulkSelection?: boolean
77
+ }
78
+
79
+ export interface OrganizationMultiEmployeeSelectionConfirmedDetail {
80
+ employeeIds: string[]
81
+ employees: OrganizationEmployeeSelectionItem[]
82
+ }
83
+
84
+ export interface OrganizationMultiEmployeeSelectorErrorDetail {
85
+ code: string
86
+ message: string
87
+ retryable: boolean
88
+ }
89
+
90
+ export interface OrganizationMultiEmployeeSelectorEventMap {
91
+ 'selection-confirmed': CustomEvent<OrganizationMultiEmployeeSelectionConfirmedDetail>
92
+ 'selection-cancelled': CustomEvent<Record<string, never>>
93
+ 'selector-error': CustomEvent<OrganizationMultiEmployeeSelectorErrorDetail>
94
+ }
95
+
96
+ export class OrganizationMultiEmployeeSelectorTransportError extends Error {
97
+ readonly code: string
98
+ readonly retryable: boolean
99
+
100
+ constructor(code: string, retryable: boolean) {
101
+ super('Organization employee selector request failed')
102
+ this.name = 'OrganizationMultiEmployeeSelectorTransportError'
103
+ this.code = code
104
+ this.retryable = retryable
105
+ }
106
+ }
107
+
108
+ const PAGE_SIZE = 50
109
+ const ALL_EMPLOYEES_ID = '__all__'
110
+
111
+ export class UniplatOrganizationMultiEmployeeSelectorElement extends LitElement {
112
+ static override properties = {
113
+ visible: { state: true },
114
+ dialogTitle: { state: true },
115
+ departments: { state: true },
116
+ activeDepartmentId: { state: true },
117
+ expandedDepartmentIds: { state: true },
118
+ candidates: { state: true },
119
+ selectedEmployees: { state: true },
120
+ keyword: { state: true },
121
+ currentPage: { state: true },
122
+ hasMore: { state: true },
123
+ loadingTree: { state: true },
124
+ loadingCandidates: { state: true },
125
+ loadingMore: { state: true },
126
+ candidateError: { state: true },
127
+ initializationError: { state: true },
128
+ selectionMessage: { state: true },
129
+ mobileView: { state: true },
130
+ }
131
+
132
+ static override styles = css`
133
+ :host {
134
+ --uniplat-employee-selector-primary: #3f6ff5;
135
+ --uniplat-employee-selector-text: #1d2939;
136
+ --uniplat-employee-selector-muted: #8a98ab;
137
+ --uniplat-employee-selector-border: #e3eaf3;
138
+ --uniplat-employee-selector-surface: #f6f8fc;
139
+ --uniplat-employee-selector-font-family:
140
+ 'Noto Sans SC', Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
141
+ 'Segoe UI', sans-serif;
142
+ color: var(--uniplat-employee-selector-text);
143
+ font-family: var(--uniplat-employee-selector-font-family);
144
+ }
145
+ * {
146
+ box-sizing: border-box;
147
+ }
148
+ button,
149
+ input {
150
+ font: inherit;
151
+ }
152
+ button {
153
+ cursor: pointer;
154
+ }
155
+ .backdrop {
156
+ position: fixed;
157
+ z-index: 2147483000;
158
+ inset: 0;
159
+ display: grid;
160
+ padding: 24px;
161
+ place-items: center;
162
+ background: rgba(19, 31, 51, 0.48);
163
+ }
164
+ .dialog {
165
+ display: flex;
166
+ width: min(960px, calc(100vw - 48px));
167
+ height: min(680px, calc(100vh - 48px));
168
+ min-height: 480px;
169
+ flex-direction: column;
170
+ overflow: hidden;
171
+ border-radius: 18px;
172
+ background: #fff;
173
+ box-shadow: 0 24px 72px rgba(20, 40, 80, 0.2);
174
+ }
175
+ .header,
176
+ .footer {
177
+ display: flex;
178
+ flex: 0 0 auto;
179
+ align-items: center;
180
+ border-color: var(--uniplat-employee-selector-border);
181
+ }
182
+ .header {
183
+ justify-content: space-between;
184
+ min-height: 68px;
185
+ padding: 0 24px;
186
+ border-bottom: 1px solid var(--uniplat-employee-selector-border);
187
+ }
188
+ h2,
189
+ h3,
190
+ p {
191
+ margin: 0;
192
+ }
193
+ h2 {
194
+ font-size: 20px;
195
+ font-weight: 750;
196
+ }
197
+ h3 {
198
+ font-size: 14px;
199
+ font-weight: 700;
200
+ }
201
+ .icon-button {
202
+ display: grid;
203
+ width: 36px;
204
+ height: 36px;
205
+ padding: 0;
206
+ place-items: center;
207
+ border: 0;
208
+ border-radius: 8px;
209
+ color: #697586;
210
+ background: transparent;
211
+ }
212
+ .icon-button:hover {
213
+ background: #f1f4f8;
214
+ }
215
+ .mobile-tabs {
216
+ display: none;
217
+ }
218
+ .body {
219
+ display: grid;
220
+ min-height: 0;
221
+ flex: 1;
222
+ grid-template-columns: minmax(0, 1fr) 320px;
223
+ overflow: hidden;
224
+ }
225
+ .body.single-selection {
226
+ grid-template-columns: minmax(0, 1fr);
227
+ }
228
+ .candidate-pane,
229
+ .selected-pane {
230
+ display: flex;
231
+ min-width: 0;
232
+ min-height: 0;
233
+ flex-direction: column;
234
+ overflow: hidden;
235
+ }
236
+ .candidate-pane {
237
+ border-right: 1px solid var(--uniplat-employee-selector-border);
238
+ }
239
+ .candidate-pane.single-selection {
240
+ border-right: 0;
241
+ }
242
+ .search-row {
243
+ display: flex;
244
+ gap: 8px;
245
+ padding: 16px 20px;
246
+ border-bottom: 1px solid var(--uniplat-employee-selector-border);
247
+ }
248
+ .search-row input {
249
+ min-width: 0;
250
+ flex: 1;
251
+ height: 38px;
252
+ padding: 0 12px;
253
+ border: 1px solid #d6deea;
254
+ border-radius: 8px;
255
+ outline: none;
256
+ }
257
+ .search-row input:focus {
258
+ border-color: var(--uniplat-employee-selector-primary);
259
+ box-shadow: 0 0 0 3px rgba(63, 111, 245, 0.12);
260
+ }
261
+ .search-button,
262
+ .primary,
263
+ .secondary,
264
+ .retry,
265
+ .load-more {
266
+ border-radius: 8px;
267
+ font-weight: 650;
268
+ }
269
+ .search-button,
270
+ .primary {
271
+ border: 1px solid var(--uniplat-employee-selector-primary);
272
+ color: #fff;
273
+ background: var(--uniplat-employee-selector-primary);
274
+ }
275
+ .search-button {
276
+ min-width: 66px;
277
+ padding: 0 14px;
278
+ }
279
+ .directory-layout {
280
+ display: grid;
281
+ min-height: 0;
282
+ flex: 1;
283
+ grid-template-columns: 220px minmax(0, 1fr);
284
+ overflow: hidden;
285
+ }
286
+ .tree {
287
+ min-height: 0;
288
+ padding: 12px 8px;
289
+ overflow-y: auto;
290
+ border-right: 1px solid var(--uniplat-employee-selector-border);
291
+ background: #fbfcfe;
292
+ scrollbar-gutter: stable;
293
+ }
294
+ .tree-label {
295
+ padding: 0 10px 8px;
296
+ color: var(--uniplat-employee-selector-muted);
297
+ font-size: 12px;
298
+ }
299
+ .department {
300
+ display: flex;
301
+ width: 100%;
302
+ min-height: 36px;
303
+ align-items: center;
304
+ gap: 5px;
305
+ padding: 5px 8px;
306
+ border: 0;
307
+ border-radius: 7px;
308
+ color: inherit;
309
+ background: transparent;
310
+ text-align: left;
311
+ }
312
+ .department:hover,
313
+ .department.active {
314
+ background: #edf3ff;
315
+ color: var(--uniplat-employee-selector-primary);
316
+ }
317
+ .department-name {
318
+ min-width: 0;
319
+ flex: 1;
320
+ overflow: hidden;
321
+ text-overflow: ellipsis;
322
+ white-space: nowrap;
323
+ }
324
+ .department-count {
325
+ color: var(--uniplat-employee-selector-muted);
326
+ font-size: 11px;
327
+ }
328
+ .expand {
329
+ display: grid;
330
+ width: 20px;
331
+ height: 20px;
332
+ padding: 0;
333
+ place-items: center;
334
+ border: 0;
335
+ color: #7f8b9d;
336
+ background: transparent;
337
+ }
338
+ .employees,
339
+ .selected-list {
340
+ min-height: 0;
341
+ overflow-y: auto;
342
+ scrollbar-gutter: stable;
343
+ }
344
+ .employees {
345
+ padding: 10px 14px 16px;
346
+ }
347
+ .employee,
348
+ .selected-employee {
349
+ display: grid;
350
+ align-items: center;
351
+ gap: 10px;
352
+ border-radius: 9px;
353
+ }
354
+ .employee {
355
+ width: 100%;
356
+ min-height: 58px;
357
+ grid-template-columns: 18px 38px minmax(0, 1fr);
358
+ padding: 8px;
359
+ border: 1px solid transparent;
360
+ color: inherit;
361
+ background: transparent;
362
+ text-align: left;
363
+ }
364
+ .employee:hover,
365
+ .employee.selected {
366
+ border-color: #dce6ff;
367
+ background: #f4f7ff;
368
+ }
369
+ .employee.disabled {
370
+ cursor: not-allowed;
371
+ opacity: 0.62;
372
+ }
373
+ .selection-indicator {
374
+ display: grid;
375
+ width: 18px;
376
+ height: 18px;
377
+ place-items: center;
378
+ border: 1.5px solid #b8c3d3;
379
+ color: transparent;
380
+ }
381
+ .checkbox {
382
+ border-radius: 5px;
383
+ }
384
+ .selected .checkbox {
385
+ border-color: var(--uniplat-employee-selector-primary);
386
+ color: #fff;
387
+ background: var(--uniplat-employee-selector-primary);
388
+ }
389
+ .radio {
390
+ border-radius: 50%;
391
+ }
392
+ .selected .radio {
393
+ border-color: var(--uniplat-employee-selector-primary);
394
+ }
395
+ .selected .radio::after {
396
+ width: 8px;
397
+ height: 8px;
398
+ border-radius: 50%;
399
+ background: var(--uniplat-employee-selector-primary);
400
+ content: '';
401
+ }
402
+ .avatar {
403
+ display: grid;
404
+ width: 38px;
405
+ height: 38px;
406
+ flex: none;
407
+ place-items: center;
408
+ border-radius: 50%;
409
+ color: #315dbd;
410
+ background: #e8efff;
411
+ font-size: 13px;
412
+ font-weight: 750;
413
+ }
414
+ .employee-copy {
415
+ min-width: 0;
416
+ }
417
+ .employee-name,
418
+ .employee-meta {
419
+ display: block;
420
+ overflow: hidden;
421
+ text-overflow: ellipsis;
422
+ white-space: nowrap;
423
+ }
424
+ .employee-name {
425
+ font-size: 13px;
426
+ font-weight: 700;
427
+ }
428
+ .employee-meta {
429
+ margin-top: 3px;
430
+ color: var(--uniplat-employee-selector-muted);
431
+ font-size: 11.5px;
432
+ }
433
+ .selected-head {
434
+ display: flex;
435
+ min-height: 54px;
436
+ align-items: center;
437
+ justify-content: space-between;
438
+ padding: 0 18px;
439
+ border-bottom: 1px solid var(--uniplat-employee-selector-border);
440
+ }
441
+ .selected-count {
442
+ color: var(--uniplat-employee-selector-primary);
443
+ font-size: 12px;
444
+ }
445
+ .selected-list {
446
+ flex: 1;
447
+ padding: 10px 12px;
448
+ }
449
+ .selected-employee {
450
+ min-height: 58px;
451
+ grid-template-columns: 38px minmax(0, 1fr) 32px;
452
+ padding: 8px;
453
+ }
454
+ .remove {
455
+ display: grid;
456
+ width: 30px;
457
+ height: 30px;
458
+ padding: 0;
459
+ place-items: center;
460
+ border: 0;
461
+ border-radius: 7px;
462
+ color: #8491a3;
463
+ background: transparent;
464
+ }
465
+ .remove:hover:not(:disabled) {
466
+ color: #d14343;
467
+ background: #fff0f0;
468
+ }
469
+ .remove:disabled {
470
+ cursor: not-allowed;
471
+ opacity: 0.55;
472
+ }
473
+ .empty,
474
+ .loading,
475
+ .error {
476
+ display: grid;
477
+ min-height: 132px;
478
+ padding: 24px;
479
+ place-items: center;
480
+ color: var(--uniplat-employee-selector-muted);
481
+ font-size: 13px;
482
+ text-align: center;
483
+ }
484
+ .error {
485
+ gap: 10px;
486
+ color: #b42318;
487
+ }
488
+ .retry,
489
+ .load-more,
490
+ .secondary {
491
+ border: 1px solid #d5deea;
492
+ color: #526173;
493
+ background: #fff;
494
+ }
495
+ .retry,
496
+ .load-more {
497
+ min-height: 34px;
498
+ padding: 0 13px;
499
+ }
500
+ .load-more {
501
+ display: block;
502
+ margin: 8px auto 0;
503
+ }
504
+ .selection-message {
505
+ padding: 8px 20px;
506
+ border-top: 1px solid #fde4b8;
507
+ color: #b54708;
508
+ background: #fff8eb;
509
+ font-size: 12px;
510
+ }
511
+ .footer {
512
+ justify-content: flex-end;
513
+ gap: 10px;
514
+ min-height: 68px;
515
+ padding: 0 24px;
516
+ border-top: 1px solid var(--uniplat-employee-selector-border);
517
+ }
518
+ .primary,
519
+ .secondary {
520
+ min-width: 92px;
521
+ min-height: 40px;
522
+ padding: 0 18px;
523
+ }
524
+ .primary:disabled {
525
+ cursor: not-allowed;
526
+ opacity: 0.5;
527
+ }
528
+ svg {
529
+ display: block;
530
+ width: 18px;
531
+ height: 18px;
532
+ fill: none;
533
+ stroke: currentColor;
534
+ stroke-linecap: round;
535
+ stroke-linejoin: round;
536
+ stroke-width: 1.8;
537
+ }
538
+ @media (max-width: 760px) {
539
+ .backdrop {
540
+ padding: 0;
541
+ place-items: stretch;
542
+ }
543
+ .dialog {
544
+ width: 100vw;
545
+ height: 100vh;
546
+ min-height: 0;
547
+ border-radius: 0;
548
+ }
549
+ .mobile-tabs {
550
+ display: grid;
551
+ grid-template-columns: 1fr 1fr;
552
+ border-bottom: 1px solid var(--uniplat-employee-selector-border);
553
+ }
554
+ .mobile-tab {
555
+ min-height: 44px;
556
+ border: 0;
557
+ border-bottom: 2px solid transparent;
558
+ color: #697586;
559
+ background: #fff;
560
+ }
561
+ .mobile-tab.active {
562
+ border-bottom-color: var(--uniplat-employee-selector-primary);
563
+ color: var(--uniplat-employee-selector-primary);
564
+ }
565
+ .body {
566
+ display: block;
567
+ }
568
+ .candidate-pane,
569
+ .selected-pane {
570
+ height: 100%;
571
+ border: 0;
572
+ }
573
+ .candidate-pane.mobile-hidden,
574
+ .selected-pane.mobile-hidden {
575
+ display: none;
576
+ }
577
+ .directory-layout {
578
+ grid-template-columns: 150px minmax(0, 1fr);
579
+ }
580
+ }
581
+ `
582
+
583
+ declare private visible: boolean
584
+ declare private dialogTitle: string
585
+ declare private departments: readonly OrganizationDepartmentNode[]
586
+ declare private activeDepartmentId: string
587
+ declare private expandedDepartmentIds: ReadonlySet<string>
588
+ declare private candidates: readonly OrganizationEmployeeSelectionItem[]
589
+ declare private selectedEmployees: ReadonlyMap<string, OrganizationEmployeeSelectionItem>
590
+ declare private keyword: string
591
+ declare private currentPage: number
592
+ declare private hasMore: boolean
593
+ declare private loadingTree: boolean
594
+ declare private loadingCandidates: boolean
595
+ declare private loadingMore: boolean
596
+ declare private candidateError: string
597
+ declare private initializationError: string
598
+ declare private selectionMessage: string
599
+ declare private mobileView: 'candidates' | 'selected'
600
+
601
+ private config: OrganizationMultiEmployeeSelectorConfig | null = null
602
+ private disabledSelections = new Map<string, string>()
603
+ private maxSelection: number | null = null
604
+ private selectableEmploymentStatuses: readonly string[] = ['active']
605
+ private openGeneration = 0
606
+ private loadGeneration = 0
607
+ private openingPromise: Promise<void> | null = null
608
+ private previousFocus: HTMLElement | null = null
609
+ private initialSelectedEmployeeIds: readonly string[] = []
610
+
611
+ constructor() {
612
+ super()
613
+ this.visible = false
614
+ this.dialogTitle = '选择组织成员'
615
+ this.departments = []
616
+ this.activeDepartmentId = ALL_EMPLOYEES_ID
617
+ this.expandedDepartmentIds = new Set()
618
+ this.candidates = []
619
+ this.selectedEmployees = new Map()
620
+ this.keyword = ''
621
+ this.currentPage = 1
622
+ this.hasMore = false
623
+ this.loadingTree = false
624
+ this.loadingCandidates = false
625
+ this.loadingMore = false
626
+ this.candidateError = ''
627
+ this.initializationError = ''
628
+ this.selectionMessage = ''
629
+ this.mobileView = 'candidates'
630
+ }
631
+
632
+ /** @internal Shared by the semantically independent single employee selector. */
633
+ protected get singleSelection(): boolean {
634
+ return false
635
+ }
636
+
637
+ configure(config: OrganizationMultiEmployeeSelectorConfig): void {
638
+ validateConfig(config)
639
+ this.openGeneration += 1
640
+ this.loadGeneration += 1
641
+ this.openingPromise = null
642
+ this.config = { applicationName: config.applicationName.trim(), transport: config.transport }
643
+ this.resetState()
644
+ }
645
+
646
+ override addEventListener<K extends keyof OrganizationMultiEmployeeSelectorEventMap>(
647
+ type: K,
648
+ listener: (
649
+ this: UniplatOrganizationMultiEmployeeSelectorElement,
650
+ event: OrganizationMultiEmployeeSelectorEventMap[K],
651
+ ) => unknown,
652
+ options?: boolean | AddEventListenerOptions,
653
+ ): void
654
+ override addEventListener(
655
+ type: string,
656
+ listener: EventListenerOrEventListenerObject,
657
+ options?: boolean | AddEventListenerOptions,
658
+ ): void
659
+ override addEventListener(
660
+ type: string,
661
+ listener: EventListenerOrEventListenerObject,
662
+ options?: boolean | AddEventListenerOptions,
663
+ ): void {
664
+ super.addEventListener(type, listener, options)
665
+ }
666
+
667
+ open(options: OrganizationMultiEmployeeSelectorOpenOptions = {}): Promise<void> {
668
+ if (!this.config) throw new TypeError('Organization employee selector is not configured')
669
+ if (this.openingPromise) return this.openingPromise
670
+ const normalized = normalizeOpenOptions(options)
671
+ this.previousFocus =
672
+ document.activeElement instanceof HTMLElement ? document.activeElement : null
673
+ this.visible = true
674
+ this.dialogTitle = normalized.title
675
+ this.disabledSelections = normalized.disabledSelections
676
+ this.maxSelection = normalized.maxSelection
677
+ this.selectableEmploymentStatuses = normalized.selectableEmploymentStatuses
678
+ this.initialSelectedEmployeeIds = normalized.initialSelectedEmployeeIds
679
+ this.mobileView = 'candidates'
680
+ this.resetLoadedData()
681
+ const generation = ++this.openGeneration
682
+ window.addEventListener('keydown', this.handleWindowKeydown)
683
+ this.openingPromise = this.initialize(
684
+ generation,
685
+ normalized.initialSelectedEmployeeIds,
686
+ ).finally(() => {
687
+ if (generation === this.openGeneration) this.openingPromise = null
688
+ })
689
+ void this.updateComplete.then(() => this.focusDialog())
690
+ return this.openingPromise
691
+ }
692
+
693
+ close(): void {
694
+ if (!this.visible) return
695
+ this.finishClose(true)
696
+ }
697
+
698
+ override disconnectedCallback(): void {
699
+ this.finishClose(false)
700
+ this.config = null
701
+ this.resetState()
702
+ super.disconnectedCallback()
703
+ }
704
+
705
+ override render() {
706
+ if (!this.visible) return nothing
707
+ return html`
708
+ <div class="backdrop" part="backdrop" @mousedown=${this.handleBackdropMouseDown}>
709
+ <section
710
+ class="dialog"
711
+ part="dialog"
712
+ role="dialog"
713
+ aria-modal="true"
714
+ aria-labelledby="organization-employee-selector-title"
715
+ @mousedown=${(event: MouseEvent) => event.stopPropagation()}
716
+ >
717
+ <header class="header" part="header">
718
+ <h2 id="organization-employee-selector-title" part="title">${this.dialogTitle}</h2>
719
+ <button
720
+ class="icon-button"
721
+ part="close-action"
722
+ type="button"
723
+ aria-label="关闭"
724
+ @click=${this.close}
725
+ >
726
+ ${icon('close')}
727
+ </button>
728
+ </header>
729
+ ${
730
+ this.singleSelection
731
+ ? nothing
732
+ : html`<nav class="mobile-tabs" aria-label="选择器视图">
733
+ <button
734
+ class="mobile-tab ${this.mobileView === 'candidates' ? 'active' : ''}"
735
+ type="button"
736
+ @click=${() => (this.mobileView = 'candidates')}
737
+ >
738
+ 候选成员
739
+ </button>
740
+ <button
741
+ class="mobile-tab ${this.mobileView === 'selected' ? 'active' : ''}"
742
+ type="button"
743
+ @click=${() => (this.mobileView = 'selected')}
744
+ >
745
+ 已选成员(${this.selectedEmployees.size})
746
+ </button>
747
+ </nav>`
748
+ }
749
+ <div class="body ${this.singleSelection ? 'single-selection' : ''}">
750
+ ${this.renderCandidatePane()}
751
+ ${this.singleSelection ? nothing : this.renderSelectedPane()}
752
+ </div>
753
+ ${
754
+ this.selectionMessage
755
+ ? html`<div class="selection-message" role="status">${this.selectionMessage}</div>`
756
+ : nothing
757
+ }
758
+ <footer class="footer" part="footer">
759
+ <button class="secondary" part="cancel-action" type="button" @click=${this.close}>
760
+ 取消
761
+ </button>
762
+ <button
763
+ class="primary"
764
+ part="confirm-action"
765
+ type="button"
766
+ ?disabled=${
767
+ this.loadingTree ||
768
+ Boolean(this.initializationError) ||
769
+ (this.singleSelection && this.selectedEmployees.size !== 1)
770
+ }
771
+ @click=${this.confirmSelection}
772
+ >
773
+ 确认${
774
+ !this.singleSelection && this.selectedEmployees.size
775
+ ? `(${this.selectedEmployees.size})`
776
+ : ''
777
+ }
778
+ </button>
779
+ </footer>
780
+ </section>
781
+ </div>
782
+ `
783
+ }
784
+
785
+ private renderCandidatePane() {
786
+ return html`
787
+ <section
788
+ class="candidate-pane ${this.singleSelection ? 'single-selection' : ''} ${
789
+ !this.singleSelection && this.mobileView === 'selected' ? 'mobile-hidden' : ''
790
+ }"
791
+ part="candidate-pane"
792
+ aria-label="候选成员"
793
+ >
794
+ ${this.renderSearch()}
795
+ <div class="directory-layout">
796
+ <aside class="tree" part="department-tree" aria-label="组织架构">
797
+ <div class="tree-label">组织架构</div>
798
+ <button
799
+ class="department ${this.activeDepartmentId === ALL_EMPLOYEES_ID ? 'active' : ''}"
800
+ type="button"
801
+ @click=${() => this.selectDepartment(ALL_EMPLOYEES_ID)}
802
+ >
803
+ <span class="expand" aria-hidden="true">${icon('organization')}</span>
804
+ <span class="department-name">全部成员</span>
805
+ </button>
806
+ ${
807
+ this.loadingTree
808
+ ? html`<div class="loading">正在加载组织架构…</div>`
809
+ : this.visibleDepartments.map((department) => this.renderDepartment(department))
810
+ }
811
+ </aside>
812
+ <div
813
+ class="employees"
814
+ part="employee-list"
815
+ role=${this.singleSelection ? 'radiogroup' : nothing}
816
+ aria-label=${this.singleSelection ? '选择一名成员' : nothing}
817
+ >
818
+ ${this.renderCandidateContent()}
819
+ </div>
820
+ </div>
821
+ </section>
822
+ `
823
+ }
824
+
825
+ private renderSelectedPane() {
826
+ const selected = [...this.selectedEmployees.values()]
827
+ return html`
828
+ <aside
829
+ class="selected-pane ${this.mobileView === 'candidates' ? 'mobile-hidden' : ''}"
830
+ part="selected-pane"
831
+ aria-label="已选择的成员"
832
+ >
833
+ <div class="selected-head">
834
+ <h3>已选择的成员</h3>
835
+ <span class="selected-count">${selected.length} 人</span>
836
+ </div>
837
+ <div class="selected-list" part="selected-list">
838
+ ${
839
+ selected.length
840
+ ? selected.map((employee) => this.renderSelectedEmployee(employee))
841
+ : html`<div class="empty">暂未选择成员</div>`
842
+ }
843
+ </div>
844
+ </aside>
845
+ `
846
+ }
847
+
848
+ private renderSearch() {
849
+ return html`<form class="search-row" role="search" @submit=${this.submitSearch}>
850
+ <input
851
+ type="search"
852
+ autocomplete="off"
853
+ maxlength="80"
854
+ aria-label="搜索成员或部门"
855
+ placeholder="搜索成员或部门"
856
+ .value=${this.keyword}
857
+ @input=${this.updateKeyword}
858
+ />
859
+ <button class="search-button" type="submit">搜索</button>
860
+ </form>`
861
+ }
862
+
863
+ private renderCandidateContent() {
864
+ if (this.initializationError) {
865
+ return html`<div class="error" role="alert">
866
+ <span>${this.initializationError}</span>
867
+ <button class="retry" type="button" @click=${this.retryInitialization}>重试</button>
868
+ </div>`
869
+ }
870
+ if (this.loadingCandidates) return html`<div class="loading">正在加载成员…</div>`
871
+ if (this.candidateError) {
872
+ return html`<div class="error" role="alert">
873
+ <span>${this.candidateError}</span>
874
+ <button class="retry" type="button" @click=${this.retryCandidates}>重试</button>
875
+ </div>`
876
+ }
877
+ if (!this.candidates.length) return html`<div class="empty">暂无符合条件的在职成员</div>`
878
+ return html`
879
+ ${this.candidates.map((employee) => this.renderCandidate(employee))}
880
+ ${
881
+ this.hasMore
882
+ ? html`<button
883
+ class="load-more"
884
+ type="button"
885
+ ?disabled=${this.loadingMore}
886
+ @click=${this.loadMore}
887
+ >
888
+ ${this.loadingMore ? '正在加载…' : '加载更多'}
889
+ </button>`
890
+ : nothing
891
+ }
892
+ `
893
+ }
894
+
895
+ private renderDepartment(department: OrganizationDepartmentNode) {
896
+ const hasChildren = this.departments.some((item) => item.parentId === department.id)
897
+ const expanded = this.expandedDepartmentIds.has(department.id)
898
+ const active = this.activeDepartmentId === department.id
899
+ return html`
900
+ <div style=${`padding-left:${Math.min(department.depth, 8) * 12}px`}>
901
+ <button
902
+ class="department ${active ? 'active' : ''}"
903
+ type="button"
904
+ aria-pressed=${String(active)}
905
+ @click=${() => {
906
+ if (hasChildren) this.toggleDepartment(department.id)
907
+ this.selectDepartment(department.id)
908
+ }}
909
+ >
910
+ <span class="expand" aria-hidden="true">
911
+ ${hasChildren ? icon(expanded ? 'chevron-down' : 'chevron-right') : icon('department')}
912
+ </span>
913
+ <span class="department-name">${department.name}</span>
914
+ ${
915
+ department.memberCount === undefined
916
+ ? nothing
917
+ : html`<span class="department-count">${department.memberCount}</span>`
918
+ }
919
+ </button>
920
+ </div>
921
+ `
922
+ }
923
+
924
+ private renderCandidate(employee: OrganizationEmployeeSelectionItem) {
925
+ const selected = this.selectedEmployees.has(employee.employeeId)
926
+ const disabledReason = this.disabledSelections.get(employee.employeeId)
927
+ const disabled = Boolean(disabledReason) && !selected
928
+ const statusDisabled = !this.selectableEmploymentStatuses.includes(employee.employmentStatus)
929
+ const title = disabledReason || (statusDisabled ? '该员工当前不可选择' : '')
930
+ return html`
931
+ <button
932
+ class="employee ${selected ? 'selected' : ''} ${disabled || statusDisabled ? 'disabled' : ''}"
933
+ part="employee"
934
+ type="button"
935
+ role=${this.singleSelection ? 'radio' : nothing}
936
+ aria-checked=${this.singleSelection ? String(selected) : nothing}
937
+ aria-pressed=${this.singleSelection ? nothing : String(selected)}
938
+ aria-disabled=${String(disabled || statusDisabled)}
939
+ title=${title}
940
+ @click=${() => this.toggleEmployee(employee)}
941
+ >
942
+ <span
943
+ class="selection-indicator ${this.singleSelection ? 'radio' : 'checkbox'}"
944
+ aria-hidden="true"
945
+ >${this.singleSelection ? nothing : icon('check')}</span
946
+ >
947
+ ${this.renderAvatar(employee)}
948
+ <span class="employee-copy">
949
+ <span class="employee-name">${employee.displayName}</span>
950
+ <span class="employee-meta">${employeeMeta(employee, title)}</span>
951
+ </span>
952
+ </button>
953
+ `
954
+ }
955
+
956
+ private renderSelectedEmployee(employee: OrganizationEmployeeSelectionItem) {
957
+ const reason = this.disabledSelections.get(employee.employeeId)
958
+ return html`
959
+ <div class="selected-employee" part="selected-employee">
960
+ ${this.renderAvatar(employee)}
961
+ <span class="employee-copy">
962
+ <span class="employee-name">${employee.displayName}</span>
963
+ <span class="employee-meta">${employeeMeta(employee, reason)}</span>
964
+ </span>
965
+ <button
966
+ class="remove"
967
+ type="button"
968
+ aria-label=${reason ? `${employee.displayName}不可移除:${reason}` : `移除${employee.displayName}`}
969
+ title=${reason || '移除'}
970
+ ?disabled=${Boolean(reason)}
971
+ @click=${() => this.removeEmployee(employee.employeeId)}
972
+ >
973
+ ${icon('close')}
974
+ </button>
975
+ </div>
976
+ `
977
+ }
978
+
979
+ private renderAvatar(employee: OrganizationEmployeeSelectionItem) {
980
+ const style =
981
+ employee.avatar?.kind === 'color' && employee.avatar.color
982
+ ? `background:${employee.avatar.color};color:#fff`
983
+ : ''
984
+ return html`<span class="avatar" style=${style} aria-hidden="true">
985
+ ${employee.displayName.slice(0, 1) || '员'}
986
+ </span>`
987
+ }
988
+
989
+ private get visibleDepartments(): readonly OrganizationDepartmentNode[] {
990
+ const visible: OrganizationDepartmentNode[] = []
991
+ const keyword = this.keyword.trim().toLocaleLowerCase()
992
+ const includedIds = new Set(this.departments.map(({ id }) => id))
993
+ if (keyword) {
994
+ includedIds.clear()
995
+ const byId = new Map(this.departments.map((department) => [department.id, department]))
996
+ for (const department of this.departments) {
997
+ if (!department.name.toLocaleLowerCase().includes(keyword)) continue
998
+ let cursor: OrganizationDepartmentNode | undefined = department
999
+ while (cursor) {
1000
+ includedIds.add(cursor.id)
1001
+ cursor = cursor.parentId ? byId.get(cursor.parentId) : undefined
1002
+ }
1003
+ }
1004
+ }
1005
+ const byParent = new Map<string, OrganizationDepartmentNode[]>()
1006
+ for (const department of this.departments) {
1007
+ const key = department.parentId ?? ''
1008
+ const siblings = byParent.get(key) ?? []
1009
+ siblings.push(department)
1010
+ byParent.set(key, siblings)
1011
+ }
1012
+ const append = (parentId: string): void => {
1013
+ for (const department of byParent.get(parentId) ?? []) {
1014
+ if (!includedIds.has(department.id)) continue
1015
+ visible.push(department)
1016
+ if (keyword || this.expandedDepartmentIds.has(department.id)) append(department.id)
1017
+ }
1018
+ }
1019
+ append('')
1020
+ return visible
1021
+ }
1022
+
1023
+ private async initialize(generation: number, initialIds: readonly string[]): Promise<void> {
1024
+ if (!this.config) return
1025
+ this.loadingTree = true
1026
+ this.loadingCandidates = true
1027
+ this.initializationError = ''
1028
+ try {
1029
+ const [departments, initialEmployees] = await Promise.all([
1030
+ this.config.transport.loadDepartmentTree(),
1031
+ initialIds.length
1032
+ ? this.config.transport.resolveEmployees({ employeeIds: initialIds })
1033
+ : Promise.resolve([]),
1034
+ ])
1035
+ if (!this.isCurrentOpen(generation)) return
1036
+ this.departments = normalizeDepartments(departments)
1037
+ this.expandedDepartmentIds = initialExpandedDepartments(this.departments)
1038
+ this.selectedEmployees = mapEmployees(initialEmployees, initialIds)
1039
+ await this.loadCandidates(1, false, generation)
1040
+ } catch (error) {
1041
+ if (!this.isCurrentOpen(generation)) return
1042
+ const detail = selectorErrorDetail(error)
1043
+ this.initializationError = detail.message
1044
+ this.dispatchSelectorError(detail)
1045
+ } finally {
1046
+ if (this.isCurrentOpen(generation)) {
1047
+ this.loadingTree = false
1048
+ this.loadingCandidates = false
1049
+ }
1050
+ }
1051
+ }
1052
+
1053
+ private async loadCandidates(
1054
+ page: number,
1055
+ append: boolean,
1056
+ openGeneration = this.openGeneration,
1057
+ ): Promise<void> {
1058
+ if (!this.config || !this.isCurrentOpen(openGeneration)) return
1059
+ const generation = ++this.loadGeneration
1060
+ if (append) this.loadingMore = true
1061
+ else this.loadingCandidates = true
1062
+ this.candidateError = ''
1063
+ try {
1064
+ const keyword = this.keyword.trim()
1065
+ const input = {
1066
+ page,
1067
+ pageSize: PAGE_SIZE,
1068
+ employmentStatuses: this.selectableEmploymentStatuses,
1069
+ }
1070
+ const result =
1071
+ keyword || this.activeDepartmentId === ALL_EMPLOYEES_ID
1072
+ ? await this.config.transport.searchEmployees({ ...input, keyword })
1073
+ : await this.config.transport.listDepartmentEmployees({
1074
+ ...input,
1075
+ departmentId: this.activeDepartmentId,
1076
+ })
1077
+ if (!this.isCurrentLoad(openGeneration, generation)) return
1078
+ const items = normalizeEmployees(result.items)
1079
+ this.candidates = append ? dedupeEmployees([...this.candidates, ...items]) : items
1080
+ this.currentPage = result.page
1081
+ this.hasMore = result.hasMore
1082
+ } catch (error) {
1083
+ if (!this.isCurrentLoad(openGeneration, generation)) return
1084
+ const detail = selectorErrorDetail(error)
1085
+ this.candidateError = detail.message
1086
+ this.dispatchSelectorError(detail)
1087
+ } finally {
1088
+ if (this.isCurrentLoad(openGeneration, generation)) {
1089
+ this.loadingCandidates = false
1090
+ this.loadingMore = false
1091
+ }
1092
+ }
1093
+ }
1094
+
1095
+ private readonly submitSearch = (event: SubmitEvent) => {
1096
+ event.preventDefault()
1097
+ void this.loadCandidates(1, false)
1098
+ }
1099
+
1100
+ private readonly updateKeyword = (event: InputEvent) => {
1101
+ this.keyword = (event.currentTarget as HTMLInputElement).value
1102
+ }
1103
+
1104
+ private selectDepartment(departmentId: string): void {
1105
+ if (this.activeDepartmentId === departmentId && !this.keyword.trim()) return
1106
+ this.activeDepartmentId = departmentId
1107
+ this.keyword = ''
1108
+ void this.loadCandidates(1, false)
1109
+ }
1110
+
1111
+ private toggleDepartment(departmentId: string): void {
1112
+ const expanded = new Set(this.expandedDepartmentIds)
1113
+ if (expanded.has(departmentId)) expanded.delete(departmentId)
1114
+ else expanded.add(departmentId)
1115
+ this.expandedDepartmentIds = expanded
1116
+ }
1117
+
1118
+ private toggleEmployee(employee: OrganizationEmployeeSelectionItem): void {
1119
+ this.selectionMessage = ''
1120
+ if (!this.selectableEmploymentStatuses.includes(employee.employmentStatus)) {
1121
+ this.selectionMessage = '该员工当前不可选择'
1122
+ return
1123
+ }
1124
+ if (this.selectedEmployees.has(employee.employeeId)) {
1125
+ this.removeEmployee(employee.employeeId)
1126
+ return
1127
+ }
1128
+ const reason = this.disabledSelections.get(employee.employeeId)
1129
+ if (reason) {
1130
+ this.selectionMessage = reason
1131
+ return
1132
+ }
1133
+ if (this.singleSelection) {
1134
+ const protectedSelection = [...this.selectedEmployees.keys()].find((employeeId) =>
1135
+ this.disabledSelections.has(employeeId),
1136
+ )
1137
+ if (protectedSelection) {
1138
+ this.selectionMessage =
1139
+ this.disabledSelections.get(protectedSelection) ?? '当前成员不可替换'
1140
+ return
1141
+ }
1142
+ this.selectedEmployees = new Map([[employee.employeeId, employee]])
1143
+ return
1144
+ }
1145
+ if (this.maxSelection !== null && this.selectedEmployees.size >= this.maxSelection) {
1146
+ this.selectionMessage = `最多可选择 ${this.maxSelection} 名成员`
1147
+ return
1148
+ }
1149
+ const selected = new Map(this.selectedEmployees)
1150
+ selected.set(employee.employeeId, employee)
1151
+ this.selectedEmployees = selected
1152
+ }
1153
+
1154
+ private removeEmployee(employeeId: string): void {
1155
+ const reason = this.disabledSelections.get(employeeId)
1156
+ if (reason) {
1157
+ this.selectionMessage = reason
1158
+ return
1159
+ }
1160
+ const selected = new Map(this.selectedEmployees)
1161
+ selected.delete(employeeId)
1162
+ this.selectedEmployees = selected
1163
+ this.selectionMessage = ''
1164
+ }
1165
+
1166
+ private readonly loadMore = () => {
1167
+ if (!this.hasMore || this.loadingMore) return
1168
+ void this.loadCandidates(this.currentPage + 1, true)
1169
+ }
1170
+
1171
+ private readonly retryCandidates = () => {
1172
+ void this.loadCandidates(this.currentPage || 1, false)
1173
+ }
1174
+
1175
+ private readonly retryInitialization = () => {
1176
+ this.initializationError = ''
1177
+ void this.initialize(this.openGeneration, this.initialSelectedEmployeeIds)
1178
+ }
1179
+
1180
+ private readonly confirmSelection = () => {
1181
+ const employees = [...this.selectedEmployees.values()].map(cloneEmployee)
1182
+ if (this.singleSelection && employees.length !== 1) {
1183
+ this.selectionMessage = '请选择一名成员'
1184
+ return
1185
+ }
1186
+ this.dispatchSelectionConfirmed(employees)
1187
+ this.finishClose(false)
1188
+ }
1189
+
1190
+ /** @internal Overridden by the single employee selector to emit a scalar result. */
1191
+ protected dispatchSelectionConfirmed(employees: OrganizationEmployeeSelectionItem[]): void {
1192
+ this.dispatchEvent(
1193
+ new CustomEvent<OrganizationMultiEmployeeSelectionConfirmedDetail>('selection-confirmed', {
1194
+ detail: {
1195
+ employeeIds: employees.map(({ employeeId }) => employeeId),
1196
+ employees,
1197
+ },
1198
+ bubbles: true,
1199
+ composed: true,
1200
+ }),
1201
+ )
1202
+ }
1203
+
1204
+ private readonly handleBackdropMouseDown = () => {
1205
+ this.close()
1206
+ }
1207
+
1208
+ private readonly handleWindowKeydown = (event: KeyboardEvent) => {
1209
+ if (!this.visible) return
1210
+ if (event.key === 'Escape') {
1211
+ event.preventDefault()
1212
+ this.close()
1213
+ return
1214
+ }
1215
+ if (event.key !== 'Tab') return
1216
+ const focusable = this.focusableElements
1217
+ if (!focusable.length) return
1218
+ const first = focusable[0]
1219
+ const last = focusable.at(-1)
1220
+ if (event.shiftKey && this.shadowRoot?.activeElement === first) {
1221
+ event.preventDefault()
1222
+ last?.focus()
1223
+ } else if (!event.shiftKey && this.shadowRoot?.activeElement === last) {
1224
+ event.preventDefault()
1225
+ first?.focus()
1226
+ }
1227
+ }
1228
+
1229
+ private get focusableElements(): HTMLElement[] {
1230
+ return [
1231
+ ...(this.shadowRoot?.querySelectorAll<HTMLElement>('button:not(:disabled), input') ?? []),
1232
+ ].filter((element) => element.offsetParent !== null || element.getClientRects().length > 0)
1233
+ }
1234
+
1235
+ private focusDialog(): void {
1236
+ this.shadowRoot?.querySelector<HTMLInputElement>('.search-row input')?.focus()
1237
+ }
1238
+
1239
+ private finishClose(cancelled: boolean): void {
1240
+ if (!this.visible && !this.openingPromise) return
1241
+ this.visible = false
1242
+ this.openGeneration += 1
1243
+ this.loadGeneration += 1
1244
+ this.openingPromise = null
1245
+ window.removeEventListener('keydown', this.handleWindowKeydown)
1246
+ if (cancelled) {
1247
+ this.dispatchEvent(
1248
+ new CustomEvent('selection-cancelled', {
1249
+ detail: {},
1250
+ bubbles: true,
1251
+ composed: true,
1252
+ }),
1253
+ )
1254
+ }
1255
+ const focus = this.previousFocus
1256
+ this.previousFocus = null
1257
+ this.resetLoadedData()
1258
+ queueMicrotask(() => focus?.focus())
1259
+ }
1260
+
1261
+ private resetState(): void {
1262
+ this.visible = false
1263
+ this.dialogTitle = '选择组织成员'
1264
+ this.disabledSelections = new Map()
1265
+ this.maxSelection = null
1266
+ this.selectableEmploymentStatuses = ['active']
1267
+ this.initialSelectedEmployeeIds = []
1268
+ window.removeEventListener('keydown', this.handleWindowKeydown)
1269
+ this.resetLoadedData()
1270
+ }
1271
+
1272
+ private resetLoadedData(): void {
1273
+ this.departments = []
1274
+ this.activeDepartmentId = ALL_EMPLOYEES_ID
1275
+ this.expandedDepartmentIds = new Set()
1276
+ this.candidates = []
1277
+ this.selectedEmployees = new Map()
1278
+ this.keyword = ''
1279
+ this.currentPage = 1
1280
+ this.hasMore = false
1281
+ this.loadingTree = false
1282
+ this.loadingCandidates = false
1283
+ this.loadingMore = false
1284
+ this.candidateError = ''
1285
+ this.initializationError = ''
1286
+ this.selectionMessage = ''
1287
+ }
1288
+
1289
+ private isCurrentOpen(generation: number): boolean {
1290
+ return this.visible && generation === this.openGeneration
1291
+ }
1292
+
1293
+ private isCurrentLoad(openGeneration: number, loadGeneration: number): boolean {
1294
+ return this.isCurrentOpen(openGeneration) && loadGeneration === this.loadGeneration
1295
+ }
1296
+
1297
+ private dispatchSelectorError(detail: OrganizationMultiEmployeeSelectorErrorDetail): void {
1298
+ this.dispatchEvent(
1299
+ new CustomEvent<OrganizationMultiEmployeeSelectorErrorDetail>('selector-error', {
1300
+ detail,
1301
+ bubbles: true,
1302
+ composed: true,
1303
+ }),
1304
+ )
1305
+ }
1306
+ }
1307
+
1308
+ function validateConfig(config: OrganizationMultiEmployeeSelectorConfig): void {
1309
+ if (!config || typeof config !== 'object') {
1310
+ throw new TypeError('Organization employee selector config is required')
1311
+ }
1312
+ if (typeof config.applicationName !== 'string' || !config.applicationName.trim()) {
1313
+ throw new TypeError('Organization employee selector applicationName is required')
1314
+ }
1315
+ if (config.applicationName.trim().length > 255) {
1316
+ throw new TypeError('Organization employee selector applicationName is too long')
1317
+ }
1318
+ const transport = config.transport
1319
+ if (
1320
+ !transport ||
1321
+ typeof transport.loadDepartmentTree !== 'function' ||
1322
+ typeof transport.listDepartmentEmployees !== 'function' ||
1323
+ typeof transport.searchEmployees !== 'function' ||
1324
+ typeof transport.resolveEmployees !== 'function'
1325
+ ) {
1326
+ throw new TypeError('Organization employee selector transport is invalid')
1327
+ }
1328
+ }
1329
+
1330
+ function normalizeOpenOptions(options: OrganizationMultiEmployeeSelectorOpenOptions) {
1331
+ if (!options || typeof options !== 'object') {
1332
+ throw new TypeError('Organization employee selector open options are invalid')
1333
+ }
1334
+ if (options.enableDepartmentBulkSelection === true) {
1335
+ throw new TypeError('Department bulk selection is not supported in this release')
1336
+ }
1337
+ const title = optionalText(options.title, 80) || '选择组织成员'
1338
+ const initialSelectedEmployeeIds = uniqueIdentifiers(options.initialSelectedEmployeeIds ?? [])
1339
+ const disabledSelections = new Map<string, string>()
1340
+ for (const item of options.disabledSelections ?? []) {
1341
+ if (!item || typeof item !== 'object') {
1342
+ throw new TypeError('Disabled employee selection is invalid')
1343
+ }
1344
+ const employeeId = identifier(item.employeeId)
1345
+ if (!employeeId) throw new TypeError('Disabled employee id is invalid')
1346
+ disabledSelections.set(employeeId, optionalText(item.reason, 120))
1347
+ }
1348
+ const maxSelection = options.maxSelection
1349
+ if (maxSelection !== undefined && (!Number.isSafeInteger(maxSelection) || maxSelection < 2)) {
1350
+ throw new TypeError('maxSelection must be an integer greater than or equal to 2')
1351
+ }
1352
+ const statuses = options.selectableEmploymentStatuses ?? ['active']
1353
+ if (!Array.isArray(statuses) || statuses.length !== 1 || statuses[0] !== 'active') {
1354
+ throw new TypeError('Only active employees are selectable in this release')
1355
+ }
1356
+ return {
1357
+ title,
1358
+ initialSelectedEmployeeIds,
1359
+ disabledSelections,
1360
+ maxSelection: maxSelection ?? null,
1361
+ selectableEmploymentStatuses: ['active'] as readonly string[],
1362
+ }
1363
+ }
1364
+
1365
+ function normalizeDepartments(
1366
+ departments: readonly OrganizationDepartmentNode[],
1367
+ ): readonly OrganizationDepartmentNode[] {
1368
+ if (!Array.isArray(departments)) throw new TypeError('Department tree response is invalid')
1369
+ const seen = new Set<string>()
1370
+ return departments.map((department) => {
1371
+ if (!department || typeof department !== 'object') {
1372
+ throw new TypeError('Department tree response is invalid')
1373
+ }
1374
+ const id = identifier(department.id)
1375
+ const name = optionalText(department.name, 100)
1376
+ if (!id || !name || seen.has(id)) throw new TypeError('Department tree response is invalid')
1377
+ seen.add(id)
1378
+ return Object.freeze({
1379
+ id,
1380
+ name,
1381
+ ...(identifier(department.parentId) ? { parentId: identifier(department.parentId) } : {}),
1382
+ depth: nonNegativeInteger(department.depth),
1383
+ ...(department.memberCount === undefined
1384
+ ? {}
1385
+ : { memberCount: nonNegativeInteger(department.memberCount) }),
1386
+ ...(department.hasChildren === undefined
1387
+ ? {}
1388
+ : { hasChildren: department.hasChildren === true }),
1389
+ })
1390
+ })
1391
+ }
1392
+
1393
+ function normalizeEmployees(
1394
+ employees: readonly OrganizationEmployeeSelectionItem[],
1395
+ ): readonly OrganizationEmployeeSelectionItem[] {
1396
+ if (!Array.isArray(employees)) throw new TypeError('Employee response is invalid')
1397
+ return dedupeEmployees(employees.map(normalizeEmployee))
1398
+ }
1399
+
1400
+ function normalizeEmployee(employee: OrganizationEmployeeSelectionItem) {
1401
+ if (!employee || typeof employee !== 'object') throw new TypeError('Employee response is invalid')
1402
+ const employeeId = identifier(employee.employeeId)
1403
+ const displayName = optionalText(employee.displayName, 80)
1404
+ if (!employeeId || !displayName) throw new TypeError('Employee response is invalid')
1405
+ const status = employee.employmentStatus
1406
+ if (!['active', 'pending', 'rejected', 'left', 'unknown'].includes(status)) {
1407
+ throw new TypeError('Employee response is invalid')
1408
+ }
1409
+ const departmentIds = uniqueIdentifiers(employee.departmentIds ?? [])
1410
+ const departmentNames = uniqueText(employee.departmentNames ?? [], 100)
1411
+ const maskedMobile = optionalText(employee.maskedMobile, 32)
1412
+ const result: OrganizationEmployeeSelectionItem = {
1413
+ employeeId,
1414
+ displayName,
1415
+ departmentIds,
1416
+ departmentNames,
1417
+ employmentStatus: status,
1418
+ }
1419
+ if (maskedMobile && !/^1[3-9]\d{9}$/.test(maskedMobile)) result.maskedMobile = maskedMobile
1420
+ const primaryDepartmentId = identifier(employee.primaryDepartmentId)
1421
+ const primaryDepartmentName = optionalText(employee.primaryDepartmentName, 100)
1422
+ if (primaryDepartmentId) result.primaryDepartmentId = primaryDepartmentId
1423
+ if (primaryDepartmentName) result.primaryDepartmentName = primaryDepartmentName
1424
+ const avatar = normalizeAvatar(employee.avatar)
1425
+ if (avatar) result.avatar = avatar
1426
+ return Object.freeze(result)
1427
+ }
1428
+
1429
+ function normalizeAvatar(
1430
+ value: OrganizationEmployeeAvatar | undefined,
1431
+ ): OrganizationEmployeeAvatar | undefined {
1432
+ if (!value) return undefined
1433
+ if (value.kind === 'file') {
1434
+ const fileId = optionalText(value.fileId, 128)
1435
+ return fileId
1436
+ ? Object.freeze({ kind: 'file' as const, fileId })
1437
+ : Object.freeze({ kind: 'none' as const })
1438
+ }
1439
+ if (value.kind === 'color') {
1440
+ const color = optionalText(value.color, 32)
1441
+ return /^#[0-9a-fA-F]{6}$/.test(color)
1442
+ ? Object.freeze({ kind: 'color' as const, color })
1443
+ : Object.freeze({ kind: 'none' as const })
1444
+ }
1445
+ return Object.freeze({ kind: 'none' as const })
1446
+ }
1447
+
1448
+ function mapEmployees(
1449
+ employees: readonly OrganizationEmployeeSelectionItem[],
1450
+ requestedIds: readonly string[],
1451
+ ): ReadonlyMap<string, OrganizationEmployeeSelectionItem> {
1452
+ const normalized = normalizeEmployees(employees)
1453
+ const byId = new Map(normalized.map((employee) => [employee.employeeId, employee]))
1454
+ const result = new Map<string, OrganizationEmployeeSelectionItem>()
1455
+ for (const id of requestedIds) {
1456
+ const employee = byId.get(id)
1457
+ if (employee) result.set(id, employee)
1458
+ }
1459
+ return result
1460
+ }
1461
+
1462
+ function dedupeEmployees(
1463
+ employees: readonly OrganizationEmployeeSelectionItem[],
1464
+ ): OrganizationEmployeeSelectionItem[] {
1465
+ return [...new Map(employees.map((employee) => [employee.employeeId, employee])).values()]
1466
+ }
1467
+
1468
+ function initialExpandedDepartments(
1469
+ departments: readonly OrganizationDepartmentNode[],
1470
+ ): ReadonlySet<string> {
1471
+ return new Set(departments.filter(({ depth }) => depth === 0).map(({ id }) => id))
1472
+ }
1473
+
1474
+ function cloneEmployee(
1475
+ employee: OrganizationEmployeeSelectionItem,
1476
+ ): OrganizationEmployeeSelectionItem {
1477
+ return {
1478
+ ...employee,
1479
+ ...(employee.avatar ? { avatar: { ...employee.avatar } } : {}),
1480
+ departmentIds: [...employee.departmentIds],
1481
+ departmentNames: [...employee.departmentNames],
1482
+ }
1483
+ }
1484
+
1485
+ function selectorErrorDetail(error: unknown): OrganizationMultiEmployeeSelectorErrorDetail {
1486
+ if (error instanceof OrganizationMultiEmployeeSelectorTransportError) {
1487
+ if (error.code === 'AUTH_REQUIRED') {
1488
+ return { code: error.code, message: '登录状态已失效,请重新登录后再试。', retryable: false }
1489
+ }
1490
+ if (error.code === 'FORBIDDEN') {
1491
+ return { code: error.code, message: '当前账号无权读取组织通讯录。', retryable: false }
1492
+ }
1493
+ return {
1494
+ code: error.code,
1495
+ message: '组织成员加载失败,请稍后重试。',
1496
+ retryable: error.retryable,
1497
+ }
1498
+ }
1499
+ return { code: 'UNKNOWN', message: '组织成员加载失败,请稍后重试。', retryable: true }
1500
+ }
1501
+
1502
+ function employeeMeta(employee: OrganizationEmployeeSelectionItem, trailing = ''): string {
1503
+ return [
1504
+ employee.primaryDepartmentName || employee.departmentNames[0] || '',
1505
+ employee.maskedMobile || '',
1506
+ trailing,
1507
+ ]
1508
+ .filter(Boolean)
1509
+ .join(' · ')
1510
+ }
1511
+
1512
+ function uniqueIdentifiers(values: readonly unknown[]): string[] {
1513
+ if (!Array.isArray(values)) throw new TypeError('Employee ids must be an array')
1514
+ const result: string[] = []
1515
+ for (const value of values) {
1516
+ const normalized = identifier(value)
1517
+ if (!normalized) throw new TypeError('Employee id is invalid')
1518
+ if (!result.includes(normalized)) result.push(normalized)
1519
+ }
1520
+ return result
1521
+ }
1522
+
1523
+ function uniqueText(values: readonly unknown[], maxLength: number): string[] {
1524
+ if (!Array.isArray(values)) return []
1525
+ return [...new Set(values.map((value) => optionalText(value, maxLength)).filter(Boolean))]
1526
+ }
1527
+
1528
+ function identifier(value: unknown): string {
1529
+ const result = typeof value === 'string' || typeof value === 'number' ? String(value).trim() : ''
1530
+ return /^[1-9]\d{0,19}$/.test(result) ? result : ''
1531
+ }
1532
+
1533
+ function optionalText(value: unknown, maxLength: number): string {
1534
+ if (value === undefined || value === null) return ''
1535
+ if (typeof value !== 'string') throw new TypeError('Text value must be a string')
1536
+ const normalized = value.trim()
1537
+ if (normalized.length > maxLength) throw new TypeError('Text value is too long')
1538
+ return normalized
1539
+ }
1540
+
1541
+ function nonNegativeInteger(value: unknown): number {
1542
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0
1543
+ }
1544
+
1545
+ function icon(
1546
+ name: 'close' | 'check' | 'organization' | 'department' | 'chevron-right' | 'chevron-down',
1547
+ ): TemplateResult {
1548
+ if (name === 'close') {
1549
+ return html`<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18" /></svg>`
1550
+ }
1551
+ if (name === 'check') {
1552
+ return html`<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12 4 4 10-10" /></svg>`
1553
+ }
1554
+ if (name === 'organization') {
1555
+ return html`<svg viewBox="0 0 24 24" aria-hidden="true">
1556
+ <path d="M4 21V5l8-3 8 3v16M2 21h20M8 8h1M15 8h1M8 12h1M15 12h1M8 16h1M15 16h1" />
1557
+ </svg>`
1558
+ }
1559
+ if (name === 'department') {
1560
+ return html`<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7h7l2 2h9v10H3z" /></svg>`
1561
+ }
1562
+ const path = name === 'chevron-down' ? 'm7 10 5 5 5-5' : 'm10 7 5 5-5 5'
1563
+ return html`<svg viewBox="0 0 24 24" aria-hidden="true"><path d=${path} /></svg>`
1564
+ }