@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.
- package/.prettierignore +4 -0
- package/.prettierrc.json +6 -0
- package/AGENTS.md +519 -0
- package/CLAUDE.md +512 -0
- package/README.md +734 -0
- 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
- 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
- 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
- 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
- package/eslint.config.js +30 -0
- package/examples/vanilla/index.html +33 -0
- package/examples/vanilla/main.js +247 -0
- package/examples/vanilla/package.json +15 -0
- package/examples/vanilla/public/runtime-config.js +10 -0
- package/examples/vanilla/style.css +99 -0
- package/package.json +31 -0
- package/packages/core/package.json +30 -0
- package/packages/core/src/client.ts +1132 -0
- package/packages/core/src/customer-support-client.ts +825 -0
- package/packages/core/src/customer-support-types.ts +123 -0
- package/packages/core/src/index.ts +79 -0
- package/packages/core/src/notification-center-client.ts +462 -0
- package/packages/core/src/notification-center-types.ts +154 -0
- package/packages/core/src/protocol.ts +237 -0
- package/packages/core/src/types.ts +170 -0
- package/packages/core/tests/client.test.ts +760 -0
- package/packages/core/tests/customer-support-client.test.ts +576 -0
- package/packages/core/tests/fake-websocket.ts +55 -0
- package/packages/core/tests/notification-center-client.test.ts +467 -0
- package/packages/core/tests/protocol.test.ts +124 -0
- package/packages/core/tsconfig.json +7 -0
- package/packages/core/tsup.config.ts +11 -0
- package/packages/web-components/package.json +35 -0
- package/packages/web-components/scripts/verify-runtime-build.mjs +111 -0
- package/packages/web-components/src/account-context.ts +1540 -0
- package/packages/web-components/src/application-switcher-transport.ts +243 -0
- package/packages/web-components/src/application-switcher.ts +1103 -0
- package/packages/web-components/src/chat.ts +1754 -0
- package/packages/web-components/src/customer-support-chat.ts +862 -0
- package/packages/web-components/src/customer-support-transport.ts +269 -0
- package/packages/web-components/src/index.ts +256 -0
- package/packages/web-components/src/notification-center.ts +1066 -0
- package/packages/web-components/src/notification-popover.ts +615 -0
- package/packages/web-components/src/organization-multi-employee-selector.ts +1564 -0
- package/packages/web-components/src/organization-onboarding-transport.ts +54 -0
- package/packages/web-components/src/organization-onboarding.ts +1216 -0
- package/packages/web-components/src/organization-selector.ts +534 -0
- package/packages/web-components/src/organization-single-employee-selector.ts +114 -0
- package/packages/web-components/src/prototype-icons.ts +242 -0
- package/packages/web-components/src/safe-image-url.ts +29 -0
- package/packages/web-components/src/safe-markdown.ts +192 -0
- package/packages/web-components/src/uniplat-base-account-context-transport.ts +625 -0
- package/packages/web-components/src/uniplat-base-application-switcher-transport.ts +418 -0
- package/packages/web-components/src/uniplat-base-application-ticket.ts +254 -0
- package/packages/web-components/src/uniplat-base-identity-ticket.ts +202 -0
- package/packages/web-components/src/uniplat-base-notification-center-transport.ts +378 -0
- package/packages/web-components/src/uniplat-base-organization-multi-employee-selector-transport.ts +394 -0
- package/packages/web-components/src/uniplat-base-organization-onboarding-transport.ts +183 -0
- package/packages/web-components/src/uniplat-base-organization-single-employee-selector-transport.ts +14 -0
- package/packages/web-components/src/user-login-transport.ts +563 -0
- package/packages/web-components/src/user-login.ts +2027 -0
- package/packages/web-components/src/user-menu.ts +880 -0
- package/packages/web-components/tests/account-context.test.ts +798 -0
- package/packages/web-components/tests/application-switcher.test.ts +562 -0
- package/packages/web-components/tests/chat.test.ts +1130 -0
- package/packages/web-components/tests/customer-support-chat.test.ts +428 -0
- package/packages/web-components/tests/customer-support-transport.test.ts +79 -0
- package/packages/web-components/tests/notification-center-transport.test.ts +271 -0
- package/packages/web-components/tests/notification-center.test.ts +429 -0
- package/packages/web-components/tests/notification-popover.test.ts +283 -0
- package/packages/web-components/tests/organization-multi-employee-selector-transport.test.ts +332 -0
- package/packages/web-components/tests/organization-multi-employee-selector.test.ts +225 -0
- package/packages/web-components/tests/organization-onboarding.test.ts +260 -0
- package/packages/web-components/tests/organization-selector.test.ts +127 -0
- package/packages/web-components/tests/organization-single-employee-selector.test.ts +145 -0
- package/packages/web-components/tests/uniplat-base-account-context-transport.test.ts +496 -0
- package/packages/web-components/tests/uniplat-base-application-switcher-transport.test.ts +598 -0
- package/packages/web-components/tests/uniplat-base-application-ticket.test.ts +233 -0
- package/packages/web-components/tests/uniplat-base-identity-ticket.test.ts +226 -0
- package/packages/web-components/tests/uniplat-base-organization-onboarding-transport.test.ts +227 -0
- package/packages/web-components/tests/user-login-transport.test.ts +443 -0
- package/packages/web-components/tests/user-login.test.ts +626 -0
- package/packages/web-components/tests/user-menu.test.ts +271 -0
- package/packages/web-components/tsconfig.json +8 -0
- package/packages/web-components/tsup.config.ts +12 -0
- package/packages/web-components/tsup.runtime.config.ts +37 -0
- package/pnpm-workspace.yaml +6 -0
- package/tsconfig.base.json +21 -0
- package/vitest.config.ts +20 -0
- 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
|
+
}
|