@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,862 @@
1
+ import {
2
+ CustomerSupportClient,
3
+ CustomerSupportClientError,
4
+ type AiEmployeeConnectionState,
5
+ type CustomerSupportClientOptions,
6
+ type CustomerSupportErrorDetail,
7
+ type CustomerSupportMessage,
8
+ type CustomerSupportSession,
9
+ } from '@uniplat/ai-employee-client'
10
+ import { LitElement, css, html, nothing } from 'lit'
11
+ import { prototypeIcon } from './prototype-icons'
12
+
13
+ export type CustomerSupportChatConfig = CustomerSupportClientOptions
14
+
15
+ export interface CustomerSupportChatEventMap {
16
+ ready: CustomEvent<{ sessionId: string | null }>
17
+ 'message-sent': CustomEvent<{ sessionId: string; message: CustomerSupportMessage }>
18
+ 'message-received': CustomEvent<{ sessionId: string; message: CustomerSupportMessage }>
19
+ 'connection-state-change': CustomEvent<{ state: AiEmployeeConnectionState }>
20
+ 'ai-chat-requested': CustomEvent<Record<string, never>>
21
+ closed: CustomEvent<Record<string, never>>
22
+ error: CustomEvent<CustomerSupportErrorDetail>
23
+ }
24
+
25
+ export class UniplatCustomerSupportChat extends LitElement {
26
+ static override properties = {
27
+ drawerOpen: { state: true },
28
+ messages: { state: true },
29
+ draft: { state: true },
30
+ sending: { state: true },
31
+ loading: { state: true },
32
+ visibleError: { state: true },
33
+ connectionState: { state: true },
34
+ currentSession: { state: true },
35
+ sessionPanelOpen: { state: true },
36
+ sessions: { state: true },
37
+ loadingSessions: { state: true },
38
+ }
39
+
40
+ static override styles = css`
41
+ :host {
42
+ --uniplat-customer-support-primary: #2e6bf5;
43
+ --uniplat-customer-support-background: #fff;
44
+ --uniplat-customer-support-surface: #f7fafd;
45
+ --uniplat-customer-support-text: #25303f;
46
+ --uniplat-customer-support-muted: #8b98a6;
47
+ --uniplat-customer-support-border: #e4ebf3;
48
+ --uniplat-customer-support-width: 420px;
49
+ --uniplat-customer-support-height: 100dvh;
50
+ --uniplat-customer-support-z-index: 510;
51
+ display: contents;
52
+ color: var(--uniplat-customer-support-text);
53
+ font-family:
54
+ Inter,
55
+ ui-sans-serif,
56
+ system-ui,
57
+ -apple-system,
58
+ BlinkMacSystemFont,
59
+ 'Segoe UI',
60
+ sans-serif;
61
+ }
62
+
63
+ * {
64
+ box-sizing: border-box;
65
+ }
66
+
67
+ .drawer {
68
+ position: fixed;
69
+ z-index: var(--uniplat-customer-support-z-index);
70
+ inset: 0 0 0 auto;
71
+ display: flex;
72
+ width: var(--uniplat-customer-support-width);
73
+ max-width: 96vw;
74
+ height: var(--uniplat-customer-support-height);
75
+ flex-direction: column;
76
+ overflow: hidden;
77
+ border-left: 1px solid var(--uniplat-customer-support-border);
78
+ background: var(--uniplat-customer-support-background);
79
+ box-shadow: -14px 0 34px rgb(20 40 80 / 8%);
80
+ }
81
+
82
+ .header {
83
+ display: flex;
84
+ flex: 0 0 auto;
85
+ align-items: center;
86
+ gap: 10px;
87
+ padding: 16px 18px;
88
+ border-bottom: 1px solid #edf1f6;
89
+ }
90
+
91
+ button {
92
+ font: inherit;
93
+ }
94
+ .icon-button {
95
+ position: relative;
96
+ display: grid;
97
+ width: 34px;
98
+ height: 34px;
99
+ flex: 0 0 auto;
100
+ place-items: center;
101
+ padding: 0;
102
+ border: 0;
103
+ border-radius: 9px;
104
+ background: transparent;
105
+ color: #5a6673;
106
+ cursor: pointer;
107
+ }
108
+ .icon-button:hover,
109
+ .icon-button:focus-visible {
110
+ background: #f0f3f8;
111
+ }
112
+ .icon-button svg {
113
+ width: 19px;
114
+ height: 19px;
115
+ }
116
+ .conversation-unread,
117
+ .session-item-unread {
118
+ display: inline-grid;
119
+ min-width: 18px;
120
+ height: 18px;
121
+ place-items: center;
122
+ padding: 0 5px;
123
+ border-radius: 999px;
124
+ background: #d83a34;
125
+ color: #fff;
126
+ font-size: 10px;
127
+ font-weight: 700;
128
+ line-height: 1;
129
+ }
130
+ .conversation-unread {
131
+ position: absolute;
132
+ top: -3px;
133
+ right: -3px;
134
+ }
135
+ .advisor-mark {
136
+ display: grid;
137
+ width: 38px;
138
+ height: 38px;
139
+ flex: 0 0 auto;
140
+ place-items: center;
141
+ border-radius: 11px;
142
+ background: #eaf1fe;
143
+ color: var(--uniplat-customer-support-primary);
144
+ }
145
+ .advisor-mark svg {
146
+ width: 20px;
147
+ height: 20px;
148
+ }
149
+ .heading {
150
+ min-width: 0;
151
+ flex: 1;
152
+ }
153
+ .title {
154
+ overflow: hidden;
155
+ color: #182634;
156
+ font-size: 16px;
157
+ font-weight: 800;
158
+ text-overflow: ellipsis;
159
+ white-space: nowrap;
160
+ }
161
+ .subtitle {
162
+ margin-top: 2px;
163
+ color: var(--uniplat-customer-support-muted);
164
+ font-size: 12.5px;
165
+ }
166
+
167
+ .status {
168
+ display: flex;
169
+ flex: 0 0 auto;
170
+ align-items: center;
171
+ gap: 6px;
172
+ padding: 10px 18px;
173
+ border-bottom: 1px solid #edf1f6;
174
+ color: #667384;
175
+ font-size: 12.5px;
176
+ }
177
+ .status-icon {
178
+ position: relative;
179
+ display: grid;
180
+ width: 14px;
181
+ height: 14px;
182
+ flex: 0 0 auto;
183
+ place-items: center;
184
+ }
185
+ .status-icon svg {
186
+ width: 14px;
187
+ height: 14px;
188
+ }
189
+ .status.success {
190
+ color: #49ad75;
191
+ }
192
+ .status.pending {
193
+ color: #e99a2c;
194
+ }
195
+ .status.muted {
196
+ color: #98a5b5;
197
+ }
198
+ .status-spinner {
199
+ width: 13px;
200
+ height: 13px;
201
+ border: 2px solid currentColor;
202
+ border-right-color: transparent;
203
+ border-radius: 50%;
204
+ animation: status-spin 0.8s linear infinite;
205
+ }
206
+ .status-stop {
207
+ display: grid;
208
+ width: 13px;
209
+ height: 13px;
210
+ place-items: center;
211
+ border: 1.5px solid currentColor;
212
+ border-radius: 50%;
213
+ }
214
+ .status-stop::after {
215
+ width: 5px;
216
+ height: 5px;
217
+ border-radius: 1px;
218
+ background: currentColor;
219
+ content: '';
220
+ }
221
+ @keyframes status-spin {
222
+ to {
223
+ transform: rotate(360deg);
224
+ }
225
+ }
226
+ @media (prefers-reduced-motion: reduce) {
227
+ .status-spinner {
228
+ animation: none;
229
+ }
230
+ }
231
+
232
+ .messages {
233
+ display: flex;
234
+ min-height: 0;
235
+ flex: 1;
236
+ flex-direction: column;
237
+ gap: 14px;
238
+ overflow-y: auto;
239
+ padding: 18px 14px;
240
+ background: var(--uniplat-customer-support-surface);
241
+ }
242
+ .empty {
243
+ margin: auto;
244
+ color: #98a5b5;
245
+ font-size: 14px;
246
+ }
247
+ .notice {
248
+ padding: 11px 12px;
249
+ border: 1px solid #efc1bc;
250
+ border-radius: 10px;
251
+ background: #fff8f7;
252
+ color: #b42318;
253
+ font-size: 13px;
254
+ }
255
+ .message-row {
256
+ display: flex;
257
+ align-items: flex-start;
258
+ gap: 9px;
259
+ }
260
+ .message-row.customer {
261
+ justify-content: flex-end;
262
+ }
263
+ .message-row.system {
264
+ justify-content: center;
265
+ }
266
+ .sender-avatar {
267
+ display: grid;
268
+ width: 30px;
269
+ height: 30px;
270
+ flex: 0 0 auto;
271
+ place-items: center;
272
+ border-radius: 50%;
273
+ background: #eaf1fe;
274
+ color: var(--uniplat-customer-support-primary);
275
+ }
276
+ .sender-avatar svg {
277
+ width: 16px;
278
+ height: 16px;
279
+ }
280
+ .message-column {
281
+ max-width: 82%;
282
+ }
283
+ .sender-name {
284
+ margin: 0 0 4px;
285
+ color: #98a5b5;
286
+ font-size: 12px;
287
+ }
288
+ .bubble {
289
+ padding: 12px 14px;
290
+ border: 1px solid #e1e8f1;
291
+ border-radius: 2px 12px 12px 12px;
292
+ background: #fff;
293
+ color: #25303f;
294
+ font-size: 14px;
295
+ line-height: 1.75;
296
+ overflow-wrap: anywhere;
297
+ white-space: pre-wrap;
298
+ }
299
+ .customer .bubble {
300
+ border-color: var(--uniplat-customer-support-primary);
301
+ border-radius: 12px 2px 12px 12px;
302
+ background: var(--uniplat-customer-support-primary);
303
+ color: #fff;
304
+ }
305
+ .system .bubble {
306
+ padding: 6px 10px;
307
+ border: 0;
308
+ border-radius: 999px;
309
+ background: #edf1f6;
310
+ color: #778494;
311
+ font-size: 12px;
312
+ }
313
+
314
+ .composer {
315
+ flex: 0 0 auto;
316
+ padding: 12px 14px 16px;
317
+ border-top: 1px solid #edf1f6;
318
+ }
319
+ .composer-row {
320
+ display: flex;
321
+ align-items: center;
322
+ gap: 10px;
323
+ }
324
+ textarea {
325
+ min-width: 0;
326
+ height: 44px;
327
+ max-height: 116px;
328
+ flex: 1;
329
+ resize: none;
330
+ padding: 10px 12px;
331
+ border: 1px solid #e2e9f3;
332
+ border-radius: 10px;
333
+ outline: none;
334
+ color: #25303f;
335
+ font: inherit;
336
+ line-height: 22px;
337
+ }
338
+ textarea:focus {
339
+ border-color: var(--uniplat-customer-support-primary);
340
+ }
341
+ .send {
342
+ height: 44px;
343
+ flex: 0 0 auto;
344
+ padding: 0 18px;
345
+ border: 0;
346
+ border-radius: 10px;
347
+ background: var(--uniplat-customer-support-primary);
348
+ color: #fff;
349
+ font-weight: 700;
350
+ cursor: pointer;
351
+ }
352
+ .send:disabled {
353
+ cursor: default;
354
+ opacity: 0.45;
355
+ }
356
+ .composer-actions {
357
+ display: flex;
358
+ align-items: center;
359
+ margin-top: 10px;
360
+ }
361
+ .ai-service-button {
362
+ display: inline-flex;
363
+ align-items: center;
364
+ gap: 6px;
365
+ padding: 0;
366
+ border: 0;
367
+ background: transparent;
368
+ color: var(--uniplat-customer-support-primary);
369
+ font-weight: 700;
370
+ cursor: pointer;
371
+ }
372
+ .ai-service-button:hover,
373
+ .ai-service-button:focus-visible {
374
+ text-decoration: underline;
375
+ }
376
+ .ai-service-button svg {
377
+ width: 17px;
378
+ height: 17px;
379
+ }
380
+
381
+ .session-panel {
382
+ position: absolute;
383
+ z-index: 2;
384
+ inset: 0;
385
+ display: flex;
386
+ flex-direction: column;
387
+ background: #fff;
388
+ }
389
+ .session-list {
390
+ min-height: 0;
391
+ flex: 1;
392
+ overflow-y: auto;
393
+ padding: 8px 10px;
394
+ }
395
+ .session-item {
396
+ display: flex;
397
+ width: 100%;
398
+ align-items: flex-start;
399
+ gap: 11px;
400
+ padding: 12px;
401
+ border: 0;
402
+ border-radius: 12px;
403
+ background: transparent;
404
+ color: inherit;
405
+ text-align: left;
406
+ cursor: pointer;
407
+ }
408
+ .session-item.current,
409
+ .session-item:hover {
410
+ background: #f1f5ff;
411
+ }
412
+ .session-copy {
413
+ min-width: 0;
414
+ flex: 1;
415
+ }
416
+ .session-title,
417
+ .session-preview {
418
+ overflow: hidden;
419
+ text-overflow: ellipsis;
420
+ white-space: nowrap;
421
+ }
422
+ .session-title {
423
+ color: #182634;
424
+ font-size: 14px;
425
+ font-weight: 700;
426
+ }
427
+ .session-preview {
428
+ margin-top: 3px;
429
+ color: #8b98a6;
430
+ font-size: 12.5px;
431
+ }
432
+ .session-time {
433
+ flex: 0 0 auto;
434
+ color: #a6b1be;
435
+ font-size: 11.5px;
436
+ }
437
+ .session-meta {
438
+ display: flex;
439
+ flex: 0 0 auto;
440
+ flex-direction: column;
441
+ align-items: flex-end;
442
+ gap: 7px;
443
+ }
444
+
445
+ @media (max-width: 520px) {
446
+ .drawer {
447
+ width: 100vw;
448
+ max-width: none;
449
+ }
450
+ }
451
+ `
452
+
453
+ declare private drawerOpen: boolean
454
+ declare private messages: CustomerSupportMessage[]
455
+ declare private draft: string
456
+ declare private sending: boolean
457
+ declare private loading: boolean
458
+ declare private visibleError: string
459
+ declare private connectionState: AiEmployeeConnectionState
460
+ declare private currentSession: CustomerSupportSession | null
461
+ declare private sessionPanelOpen: boolean
462
+ declare private sessions: CustomerSupportSession[]
463
+ declare private loadingSessions: boolean
464
+
465
+ private client: CustomerSupportClient | null = null
466
+ private config: CustomerSupportChatConfig | null = null
467
+ private disposeListeners: Array<() => void> = []
468
+
469
+ constructor() {
470
+ super()
471
+ this.drawerOpen = false
472
+ this.messages = []
473
+ this.draft = ''
474
+ this.sending = false
475
+ this.loading = false
476
+ this.visibleError = ''
477
+ this.connectionState = 'idle'
478
+ this.currentSession = null
479
+ this.sessionPanelOpen = false
480
+ this.sessions = []
481
+ this.loadingSessions = false
482
+ }
483
+
484
+ configure(config: CustomerSupportChatConfig): void {
485
+ this.releaseClient()
486
+ this.config = config
487
+ this.messages = []
488
+ this.currentSession = null
489
+ this.sessions = []
490
+ this.visibleError = ''
491
+ }
492
+
493
+ async open(): Promise<void> {
494
+ this.drawerOpen = true
495
+ if (!this.client) await this.startClient()
496
+ }
497
+
498
+ close(): void {
499
+ this.drawerOpen = false
500
+ this.sessionPanelOpen = false
501
+ this.dispatchSafe('closed', {})
502
+ }
503
+
504
+ override disconnectedCallback(): void {
505
+ this.releaseClient()
506
+ super.disconnectedCallback()
507
+ }
508
+
509
+ override render() {
510
+ if (!this.drawerOpen) return nothing
511
+ return html`
512
+ <section class="drawer" part="drawer" aria-label="人工客服">
513
+ ${this.renderMain()} ${this.sessionPanelOpen ? this.renderSessionPanel() : nothing}
514
+ </section>
515
+ `
516
+ }
517
+
518
+ private renderMain() {
519
+ const status = this.statusPresentation()
520
+ const unreadCount = this.totalUnreadCount()
521
+ return html`
522
+ <header class="header" part="header">
523
+ <button class="icon-button" aria-label="我的客服会话" @click=${this.openSessionPanel}>
524
+ ${prototypeIcon('messages')}
525
+ ${
526
+ unreadCount > 0
527
+ ? html`<span class="conversation-unread" aria-label="${unreadCount} 条未读"
528
+ >${formatUnreadCount(unreadCount)}</span
529
+ >`
530
+ : nothing
531
+ }
532
+ </button>
533
+ <span class="advisor-mark">${prototypeIcon('headset')}</span>
534
+ <div class="heading">
535
+ <div class="title">企业客服人工顾问</div>
536
+ <div class="subtitle">${this.headerSubtitle()}</div>
537
+ </div>
538
+ <button class="icon-button" aria-label="关闭人工客服" @click=${this.close}>
539
+ ${prototypeIcon('x')}
540
+ </button>
541
+ </header>
542
+ <div class="status ${status.tone}" role="status" aria-live="polite">
543
+ <span class="status-icon" aria-hidden="true">${this.renderStatusIcon(status.icon)}</span>
544
+ ${status.label}
545
+ </div>
546
+ <main class="messages" part="messages">
547
+ ${this.visibleError ? html`<div class="notice">${this.visibleError}</div>` : nothing}
548
+ ${
549
+ this.loading
550
+ ? html`<div class="empty">正在加载客服会话…</div>`
551
+ : this.messages.length
552
+ ? this.messages.map((message) => this.renderMessage(message))
553
+ : html`<div class="empty">开始和人工客服对话吧</div>`
554
+ }
555
+ </main>
556
+ <footer class="composer" part="composer">
557
+ <div class="composer-row">
558
+ <textarea
559
+ .value=${this.draft}
560
+ placeholder="输入你的问题……"
561
+ aria-label="客服消息"
562
+ @input=${this.handleDraftInput}
563
+ @keydown=${this.handleDraftKeydown}
564
+ ></textarea>
565
+ <button class="send" ?disabled=${this.sending || !this.draft.trim()} @click=${this.send}>
566
+ 发送
567
+ </button>
568
+ </div>
569
+ <div class="composer-actions">
570
+ <button class="ai-service-button" type="button" @click=${this.requestAiChat}>
571
+ ${prototypeIcon('robot')}转AI客服
572
+ </button>
573
+ </div>
574
+ </footer>
575
+ `
576
+ }
577
+
578
+ private renderMessage(message: CustomerSupportMessage) {
579
+ if (message.senderType === 'system') {
580
+ return html`<div class="message-row system">
581
+ <div class="bubble">${message.content}</div>
582
+ </div>`
583
+ }
584
+ const customer = message.senderType === 'customer'
585
+ return html`
586
+ <div class="message-row ${customer ? 'customer' : 'agent'}">
587
+ ${customer ? nothing : html`<span class="sender-avatar">${prototypeIcon('headset')}</span>`}
588
+ <div class="message-column">
589
+ ${
590
+ customer
591
+ ? nothing
592
+ : html`<div class="sender-name">${message.senderName || '人工顾问'}</div>`
593
+ }
594
+ <div class="bubble">${message.content}</div>
595
+ </div>
596
+ </div>
597
+ `
598
+ }
599
+
600
+ private renderSessionPanel() {
601
+ return html`
602
+ <section class="session-panel" aria-label="我的客服会话">
603
+ <header class="header">
604
+ <button class="icon-button" aria-label="返回当前会话" @click=${this.closeSessionPanel}>
605
+ ${prototypeIcon('arrow-left')}
606
+ </button>
607
+ <div class="heading"><div class="title">我的会话</div></div>
608
+ <button class="icon-button" aria-label="开启新客服会话" @click=${this.createConversation}>
609
+ ${prototypeIcon('plus')}
610
+ </button>
611
+ <button class="icon-button" aria-label="关闭人工客服" @click=${this.close}>
612
+ ${prototypeIcon('x')}
613
+ </button>
614
+ </header>
615
+ <div class="session-list">
616
+ ${
617
+ this.loadingSessions
618
+ ? html`<div class="empty">正在加载会话…</div>`
619
+ : this.sessions.map(
620
+ (session) => html`
621
+ <button
622
+ class="session-item ${session.id === this.currentSession?.id ? 'current' : ''}"
623
+ @click=${() => this.selectSession(session.id)}
624
+ >
625
+ <span class="advisor-mark">${prototypeIcon('headset')}</span>
626
+ <span class="session-copy">
627
+ <span class="session-title">${session.title}</span>
628
+ <span class="session-preview">${session.lastMessage || '暂无消息'}</span>
629
+ </span>
630
+ <span class="session-meta">
631
+ <span class="session-time">${relativeTime(session.updatedAt)}</span>
632
+ ${
633
+ session.id !== this.currentSession?.id && session.unreadCount > 0
634
+ ? html`<span
635
+ class="session-item-unread"
636
+ aria-label="${session.unreadCount} 条未读"
637
+ >${formatUnreadCount(session.unreadCount)}</span
638
+ >`
639
+ : nothing
640
+ }
641
+ </span>
642
+ </button>
643
+ `,
644
+ )
645
+ }
646
+ </div>
647
+ </section>
648
+ `
649
+ }
650
+
651
+ private async startClient(): Promise<void> {
652
+ if (!this.config) {
653
+ this.showError({
654
+ code: 'INVALID_CONFIGURATION',
655
+ message: '人工客服暂未配置。',
656
+ recoverable: false,
657
+ })
658
+ return
659
+ }
660
+ this.loading = true
661
+ this.visibleError = ''
662
+ try {
663
+ const client = new CustomerSupportClient(this.config)
664
+ this.client = client
665
+ this.disposeListeners = [
666
+ client.on('ready', (detail) => this.dispatchSafe('ready', detail)),
667
+ client.on('session', ({ session }) => {
668
+ this.currentSession = session
669
+ }),
670
+ client.on('sessions', ({ sessions }) => {
671
+ this.sessions = [...sessions]
672
+ }),
673
+ client.on('history', ({ messages }) => {
674
+ this.messages = [...messages]
675
+ this.scrollToEnd()
676
+ }),
677
+ client.on('message', (detail) => {
678
+ this.messages = [...client.messages]
679
+ this.dispatchSafe(detail.source === 'local' ? 'message-sent' : 'message-received', {
680
+ sessionId: detail.sessionId,
681
+ message: detail.message,
682
+ })
683
+ this.scrollToEnd()
684
+ }),
685
+ client.on('connection-state-change', ({ current }) => {
686
+ this.connectionState = current
687
+ if (current === 'connected') this.visibleError = ''
688
+ this.dispatchSafe('connection-state-change', { state: current })
689
+ }),
690
+ client.on('error', (detail) => this.showError(detail)),
691
+ ]
692
+ await client.start()
693
+ } catch {
694
+ this.showError({
695
+ code: 'INVALID_CONFIGURATION',
696
+ message: '人工客服配置不可用。',
697
+ recoverable: false,
698
+ })
699
+ } finally {
700
+ this.loading = false
701
+ }
702
+ }
703
+
704
+ private handleDraftInput(event: Event): void {
705
+ this.draft = (event.currentTarget as HTMLTextAreaElement).value
706
+ }
707
+
708
+ private handleDraftKeydown(event: KeyboardEvent): void {
709
+ if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return
710
+ event.preventDefault()
711
+ void this.send()
712
+ }
713
+
714
+ private async send(): Promise<void> {
715
+ const content = this.draft.trim()
716
+ if (!content || !this.client || this.sending) return
717
+ this.sending = true
718
+ this.visibleError = ''
719
+ try {
720
+ await this.client.sendMessage(content)
721
+ this.draft = ''
722
+ } catch (error) {
723
+ if (!(error instanceof CustomerSupportClientError)) {
724
+ this.visibleError = '消息发送失败,请稍后重试。'
725
+ }
726
+ } finally {
727
+ this.sending = false
728
+ }
729
+ }
730
+
731
+ private openSessionPanel = async (): Promise<void> => {
732
+ if (!this.client) return
733
+ this.sessionPanelOpen = true
734
+ this.loadingSessions = true
735
+ try {
736
+ this.sessions = [...(await this.client.listSessions())]
737
+ } catch {
738
+ this.visibleError = '客服会话列表加载失败,请稍后重试。'
739
+ } finally {
740
+ this.loadingSessions = false
741
+ }
742
+ }
743
+
744
+ private closeSessionPanel = (): void => {
745
+ this.sessionPanelOpen = false
746
+ }
747
+
748
+ private createConversation = async (): Promise<void> => {
749
+ if (!this.client) return
750
+ try {
751
+ this.client.prepareNewConversation()
752
+ this.currentSession = null
753
+ this.messages = []
754
+ this.sessionPanelOpen = false
755
+ } catch {
756
+ this.visibleError = '暂时无法开启新的客服会话。'
757
+ }
758
+ }
759
+
760
+ private async selectSession(sessionId: string): Promise<void> {
761
+ if (!this.client || sessionId === this.currentSession?.id) {
762
+ this.sessionPanelOpen = false
763
+ return
764
+ }
765
+ this.loading = true
766
+ try {
767
+ await this.client.bindSession(sessionId)
768
+ this.sessionPanelOpen = false
769
+ } catch {
770
+ this.visibleError = '暂时无法打开该客服会话。'
771
+ } finally {
772
+ this.loading = false
773
+ }
774
+ }
775
+
776
+ private headerSubtitle(): string {
777
+ if (!this.currentSession) return '尚未建立客服会话'
778
+ if (this.currentSession.status === 'active') return '人工客服已接入,可直接发送消息'
779
+ if (this.currentSession.status === 'waiting') return '等待客服接入'
780
+ return '客服会话已结束'
781
+ }
782
+
783
+ private totalUnreadCount(): number {
784
+ return this.sessions.reduce(
785
+ (total, session) =>
786
+ session.id === this.currentSession?.id ? total : total + session.unreadCount,
787
+ 0,
788
+ )
789
+ }
790
+
791
+ private statusPresentation(): CustomerSupportStatusPresentation {
792
+ const realtimeConnected = this.connectionState === 'connected'
793
+ if (!this.currentSession) {
794
+ return realtimeConnected
795
+ ? { tone: 'success', icon: 'check', label: '客服通道已连接' }
796
+ : { tone: 'pending', icon: 'loading', label: '正在连接客服通道' }
797
+ }
798
+ if (this.currentSession.status === 'closed') {
799
+ return { tone: 'muted', icon: 'stop', label: '会话已结束' }
800
+ }
801
+ if (this.currentSession.status === 'waiting') {
802
+ return { tone: 'pending', icon: 'loading', label: '等待客服接入' }
803
+ }
804
+ return realtimeConnected
805
+ ? { tone: 'success', icon: 'check', label: '人工客服在线' }
806
+ : { tone: 'pending', icon: 'loading', label: '正在恢复实时连接' }
807
+ }
808
+
809
+ private renderStatusIcon(icon: CustomerSupportStatusIcon) {
810
+ if (icon === 'check') return prototypeIcon('circle-check-filled')
811
+ if (icon === 'stop') return html`<span class="status-stop"></span>`
812
+ return html`<span class="status-spinner"></span>`
813
+ }
814
+
815
+ private requestAiChat = (): void => this.dispatchSafe('ai-chat-requested', {})
816
+
817
+ private showError(detail: CustomerSupportErrorDetail): void {
818
+ this.visibleError = detail.message
819
+ this.dispatchSafe('error', detail)
820
+ }
821
+
822
+ private scrollToEnd(): void {
823
+ void this.updateComplete.then(() => {
824
+ const container = this.renderRoot.querySelector<HTMLElement>('.messages')
825
+ if (container) container.scrollTop = container.scrollHeight
826
+ })
827
+ }
828
+
829
+ private releaseClient(): void {
830
+ for (const dispose of this.disposeListeners) dispose()
831
+ this.disposeListeners = []
832
+ this.client?.destroy()
833
+ this.client = null
834
+ this.connectionState = 'idle'
835
+ }
836
+
837
+ private dispatchSafe(type: string, detail: unknown): void {
838
+ this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }))
839
+ }
840
+ }
841
+
842
+ type CustomerSupportStatusIcon = 'check' | 'loading' | 'stop'
843
+
844
+ interface CustomerSupportStatusPresentation {
845
+ tone: 'success' | 'pending' | 'muted'
846
+ icon: CustomerSupportStatusIcon
847
+ label: string
848
+ }
849
+
850
+ function formatUnreadCount(value: number): string {
851
+ return value > 99 ? '99+' : String(value)
852
+ }
853
+
854
+ function relativeTime(value: string): string {
855
+ const timestamp = Date.parse(value)
856
+ if (!Number.isFinite(timestamp)) return ''
857
+ const minutes = Math.max(0, Math.floor((Date.now() - timestamp) / 60_000))
858
+ if (minutes < 1) return '刚刚'
859
+ if (minutes < 60) return `${minutes} 分钟前`
860
+ if (minutes < 1_440) return `${Math.floor(minutes / 60)} 小时前`
861
+ return `${Math.floor(minutes / 1_440)} 天前`
862
+ }