@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,428 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import type { CustomerSupportSession, CustomerSupportTransport } from '@uniplat/ai-employee-client'
|
|
5
|
+
import '../src'
|
|
6
|
+
import { UniplatCustomerSupportChat } from '../src'
|
|
7
|
+
|
|
8
|
+
const session: CustomerSupportSession = {
|
|
9
|
+
id: 'support-1',
|
|
10
|
+
sessionNo: 'CS-1',
|
|
11
|
+
title: '异地用工咨询',
|
|
12
|
+
status: 'active',
|
|
13
|
+
statusText: '服务中',
|
|
14
|
+
primaryAgent: { id: 'agent-1', name: '王悦', avatarUrl: '', roleName: '客服专员' },
|
|
15
|
+
lastMessage: '您好,我是人工顾问。',
|
|
16
|
+
unreadCount: 0,
|
|
17
|
+
openedAt: '2026-08-18T08:00:00.000Z',
|
|
18
|
+
updatedAt: '2026-08-18T08:00:00.000Z',
|
|
19
|
+
version: '1',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class SupportSocket {
|
|
23
|
+
readyState = 0
|
|
24
|
+
sent: string[] = []
|
|
25
|
+
onopen: ((event: Event) => void) | null = null
|
|
26
|
+
onmessage: ((event: MessageEvent) => void) | null = null
|
|
27
|
+
onerror: ((event: Event) => void) | null = null
|
|
28
|
+
onclose: ((event: CloseEvent) => void) | null = null
|
|
29
|
+
open() {
|
|
30
|
+
this.readyState = 1
|
|
31
|
+
this.onopen?.(new Event('open'))
|
|
32
|
+
}
|
|
33
|
+
receive(payload: unknown) {
|
|
34
|
+
this.onmessage?.(new MessageEvent('message', { data: JSON.stringify(payload) }))
|
|
35
|
+
}
|
|
36
|
+
send(value: string) {
|
|
37
|
+
this.sent.push(value)
|
|
38
|
+
}
|
|
39
|
+
close() {
|
|
40
|
+
this.readyState = 3
|
|
41
|
+
this.onclose?.(new CloseEvent('close', { code: 1000 }))
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('<uniplat-customer-support-chat>', () => {
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
vi.useRealTimers()
|
|
48
|
+
document.body.replaceChildren()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('is registered, renders backend history and sends through the customer support transport', async () => {
|
|
52
|
+
const socket = new SupportSocket()
|
|
53
|
+
const transport = {
|
|
54
|
+
getAccessToken: vi.fn(async () => 'support.jwt'),
|
|
55
|
+
listSessions: vi.fn(async () => [session]),
|
|
56
|
+
createSession: vi.fn(async () => ({ session })),
|
|
57
|
+
listMessages: vi.fn(async () => ({
|
|
58
|
+
messages: [
|
|
59
|
+
{
|
|
60
|
+
id: 'message-1',
|
|
61
|
+
sessionId: 'support-1',
|
|
62
|
+
sequence: '1',
|
|
63
|
+
clientMessageId: '',
|
|
64
|
+
senderType: 'agent' as const,
|
|
65
|
+
senderName: '王悦',
|
|
66
|
+
content: '您好,我是人工顾问。',
|
|
67
|
+
createdAt: '2026-08-18T08:00:00.000Z',
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
latestSequence: '1',
|
|
71
|
+
sessionVersion: '1',
|
|
72
|
+
})),
|
|
73
|
+
sendMessage: vi.fn(async (_sessionId, clientMessageId, content) => ({
|
|
74
|
+
id: 'message-2',
|
|
75
|
+
sessionId: 'support-1',
|
|
76
|
+
sequence: '2',
|
|
77
|
+
clientMessageId,
|
|
78
|
+
senderType: 'customer' as const,
|
|
79
|
+
senderName: '',
|
|
80
|
+
content,
|
|
81
|
+
createdAt: '2026-08-18T08:01:00.000Z',
|
|
82
|
+
})),
|
|
83
|
+
markRead: vi.fn(async () => undefined),
|
|
84
|
+
} satisfies CustomerSupportTransport
|
|
85
|
+
const component = document.createElement('uniplat-customer-support-chat')
|
|
86
|
+
component.configure({
|
|
87
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
88
|
+
transport,
|
|
89
|
+
requestIdFactory: () => 'a191d25b-815c-4768-8c87-b31941121cca',
|
|
90
|
+
webSocketFactory: () => socket as unknown as WebSocket,
|
|
91
|
+
})
|
|
92
|
+
document.body.append(component)
|
|
93
|
+
await component.open()
|
|
94
|
+
await component.updateComplete
|
|
95
|
+
|
|
96
|
+
expect(component).toBeInstanceOf(UniplatCustomerSupportChat)
|
|
97
|
+
expect(component.shadowRoot?.querySelector('.title')?.textContent).toBe('企业客服人工顾问')
|
|
98
|
+
expect(component.shadowRoot?.querySelector('.subtitle')?.textContent).toBe(
|
|
99
|
+
'人工客服已接入,可直接发送消息',
|
|
100
|
+
)
|
|
101
|
+
expect(component.shadowRoot?.querySelector('[role="status"]')?.textContent).toContain(
|
|
102
|
+
'正在恢复实时连接',
|
|
103
|
+
)
|
|
104
|
+
expect(component.shadowRoot?.textContent).toContain('王悦')
|
|
105
|
+
expect(component.shadowRoot?.textContent).toContain('您好,我是人工顾问。')
|
|
106
|
+
socket.open()
|
|
107
|
+
await Promise.resolve()
|
|
108
|
+
socket.receive({
|
|
109
|
+
type: 'realtime_ready',
|
|
110
|
+
channels: { customer_support: { available: true } },
|
|
111
|
+
heartbeat_interval_seconds: 25,
|
|
112
|
+
})
|
|
113
|
+
await component.updateComplete
|
|
114
|
+
expect(component.shadowRoot?.querySelector('[role="status"]')?.textContent).toContain(
|
|
115
|
+
'人工客服在线',
|
|
116
|
+
)
|
|
117
|
+
expect(
|
|
118
|
+
component.shadowRoot?.querySelector('[data-prototype-icon="circle-check-filled"]'),
|
|
119
|
+
).toBeTruthy()
|
|
120
|
+
const textarea = component.shadowRoot?.querySelector('textarea')
|
|
121
|
+
textarea!.value = '我需要人工协助'
|
|
122
|
+
textarea!.dispatchEvent(new InputEvent('input', { bubbles: true }))
|
|
123
|
+
await component.updateComplete
|
|
124
|
+
component.shadowRoot?.querySelector<HTMLButtonElement>('.send')?.click()
|
|
125
|
+
await vi.waitFor(() => expect(transport.sendMessage).toHaveBeenCalledTimes(1))
|
|
126
|
+
expect(transport.sendMessage).toHaveBeenCalledWith(
|
|
127
|
+
'support-1',
|
|
128
|
+
'a191d25b-815c-4768-8c87-b31941121cca',
|
|
129
|
+
'我需要人工协助',
|
|
130
|
+
)
|
|
131
|
+
expect(component.outerHTML).not.toContain('support.jwt')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it.each([
|
|
135
|
+
{
|
|
136
|
+
name: 'waiting session',
|
|
137
|
+
sessions: [{ ...session, status: 'waiting' as const }],
|
|
138
|
+
label: '等待客服接入',
|
|
139
|
+
tone: 'pending',
|
|
140
|
+
icon: '.status-spinner',
|
|
141
|
+
connect: false,
|
|
142
|
+
subtitle: '等待客服接入',
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'closed session',
|
|
146
|
+
sessions: [{ ...session, status: 'closed' as const }],
|
|
147
|
+
label: '会话已结束',
|
|
148
|
+
tone: 'muted',
|
|
149
|
+
icon: '.status-stop',
|
|
150
|
+
connect: false,
|
|
151
|
+
subtitle: '客服会话已结束',
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: 'session not loaded',
|
|
155
|
+
sessions: [],
|
|
156
|
+
label: '正在连接客服通道',
|
|
157
|
+
tone: 'pending',
|
|
158
|
+
icon: '.status-spinner',
|
|
159
|
+
connect: false,
|
|
160
|
+
subtitle: '尚未建立客服会话',
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: 'empty waiting session',
|
|
164
|
+
sessions: [{ ...session, status: 'waiting' as const, lastMessage: '' }],
|
|
165
|
+
label: '正在连接客服通道',
|
|
166
|
+
tone: 'pending',
|
|
167
|
+
icon: '.status-spinner',
|
|
168
|
+
connect: false,
|
|
169
|
+
subtitle: '尚未建立客服会话',
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: 'session not loaded with realtime connected',
|
|
173
|
+
sessions: [],
|
|
174
|
+
label: '客服通道已连接',
|
|
175
|
+
tone: 'success',
|
|
176
|
+
icon: '[data-prototype-icon="circle-check-filled"]',
|
|
177
|
+
connect: true,
|
|
178
|
+
subtitle: '尚未建立客服会话',
|
|
179
|
+
},
|
|
180
|
+
])(
|
|
181
|
+
'combines session and realtime state for $name',
|
|
182
|
+
async ({ sessions, label, tone, icon, connect, subtitle }) => {
|
|
183
|
+
const socket = new SupportSocket()
|
|
184
|
+
const transport = {
|
|
185
|
+
getAccessToken: vi.fn(async () => 'support.jwt'),
|
|
186
|
+
listSessions: vi.fn(async () => sessions),
|
|
187
|
+
createSession: vi.fn(async () => ({ session })),
|
|
188
|
+
listMessages: vi.fn(async () => ({
|
|
189
|
+
messages: [],
|
|
190
|
+
latestSequence: '',
|
|
191
|
+
sessionVersion: '1',
|
|
192
|
+
})),
|
|
193
|
+
sendMessage: vi.fn(),
|
|
194
|
+
} satisfies CustomerSupportTransport
|
|
195
|
+
const component = document.createElement('uniplat-customer-support-chat')
|
|
196
|
+
component.configure({
|
|
197
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
198
|
+
transport,
|
|
199
|
+
webSocketFactory: () => socket as unknown as WebSocket,
|
|
200
|
+
})
|
|
201
|
+
document.body.append(component)
|
|
202
|
+
await component.open()
|
|
203
|
+
if (connect) {
|
|
204
|
+
socket.open()
|
|
205
|
+
await Promise.resolve()
|
|
206
|
+
socket.receive({
|
|
207
|
+
type: 'realtime_ready',
|
|
208
|
+
channels: { customer_support: { available: true } },
|
|
209
|
+
heartbeat_interval_seconds: 25,
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
await component.updateComplete
|
|
213
|
+
|
|
214
|
+
const status = component.shadowRoot?.querySelector('[role="status"]')
|
|
215
|
+
expect(component.shadowRoot?.querySelector('.subtitle')?.textContent).toBe(subtitle)
|
|
216
|
+
expect(status?.textContent).toContain(label)
|
|
217
|
+
expect(status?.classList.contains(tone)).toBe(true)
|
|
218
|
+
expect(status?.querySelector(icon)).toBeTruthy()
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
it('prepares a local blank conversation and creates it with the first message', async () => {
|
|
223
|
+
const socket = new SupportSocket()
|
|
224
|
+
const waitingSession = {
|
|
225
|
+
...session,
|
|
226
|
+
status: 'waiting' as const,
|
|
227
|
+
statusText: '等待接入',
|
|
228
|
+
lastMessage: '首次咨询',
|
|
229
|
+
}
|
|
230
|
+
const initialMessage = {
|
|
231
|
+
id: 'message-first',
|
|
232
|
+
sessionId: 'support-1',
|
|
233
|
+
sequence: '1',
|
|
234
|
+
clientMessageId: 'a191d25b-815c-4768-8c87-b31941121cca',
|
|
235
|
+
senderType: 'customer' as const,
|
|
236
|
+
senderName: '',
|
|
237
|
+
content: '首次咨询',
|
|
238
|
+
createdAt: '2026-08-18T08:01:00.000Z',
|
|
239
|
+
}
|
|
240
|
+
const transport = {
|
|
241
|
+
getAccessToken: vi.fn(async () => 'support.jwt'),
|
|
242
|
+
listSessions: vi.fn(async () => [session]),
|
|
243
|
+
createSession: vi.fn(async () => ({ session: waitingSession, initialMessage })),
|
|
244
|
+
listMessages: vi.fn(async () => ({
|
|
245
|
+
messages: [],
|
|
246
|
+
latestSequence: '',
|
|
247
|
+
sessionVersion: '1',
|
|
248
|
+
})),
|
|
249
|
+
sendMessage: vi.fn(),
|
|
250
|
+
} satisfies CustomerSupportTransport
|
|
251
|
+
const component = document.createElement('uniplat-customer-support-chat')
|
|
252
|
+
component.configure({
|
|
253
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
254
|
+
transport,
|
|
255
|
+
requestIdFactory: () => 'a191d25b-815c-4768-8c87-b31941121cca',
|
|
256
|
+
webSocketFactory: () => socket as unknown as WebSocket,
|
|
257
|
+
})
|
|
258
|
+
document.body.append(component)
|
|
259
|
+
await component.open()
|
|
260
|
+
await component.updateComplete
|
|
261
|
+
|
|
262
|
+
component.shadowRoot?.querySelector<HTMLButtonElement>('[aria-label="我的客服会话"]')?.click()
|
|
263
|
+
await vi.waitFor(() => expect(transport.listSessions).toHaveBeenCalledTimes(2))
|
|
264
|
+
await component.updateComplete
|
|
265
|
+
component.shadowRoot?.querySelector<HTMLButtonElement>('[aria-label="开启新客服会话"]')?.click()
|
|
266
|
+
await component.updateComplete
|
|
267
|
+
|
|
268
|
+
expect(transport.createSession).not.toHaveBeenCalled()
|
|
269
|
+
expect(component.shadowRoot?.querySelector('.subtitle')?.textContent).toBe('尚未建立客服会话')
|
|
270
|
+
|
|
271
|
+
const textarea = component.shadowRoot?.querySelector('textarea')
|
|
272
|
+
textarea!.value = '首次咨询'
|
|
273
|
+
textarea!.dispatchEvent(new InputEvent('input', { bubbles: true }))
|
|
274
|
+
await component.updateComplete
|
|
275
|
+
component.shadowRoot?.querySelector<HTMLButtonElement>('.send')?.click()
|
|
276
|
+
|
|
277
|
+
await vi.waitFor(() => expect(transport.createSession).toHaveBeenCalledTimes(1))
|
|
278
|
+
expect(transport.createSession).toHaveBeenCalledWith({
|
|
279
|
+
clientRequestId: 'a191d25b-815c-4768-8c87-b31941121cca',
|
|
280
|
+
title: '首次咨询',
|
|
281
|
+
initialMessage: '首次咨询',
|
|
282
|
+
})
|
|
283
|
+
await component.updateComplete
|
|
284
|
+
expect(component.shadowRoot?.querySelector('.subtitle')?.textContent).toBe('等待客服接入')
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('clears the transient realtime error banner once the connection recovers', async () => {
|
|
288
|
+
vi.useFakeTimers()
|
|
289
|
+
const socket = new SupportSocket()
|
|
290
|
+
const transport = {
|
|
291
|
+
getAccessToken: vi.fn(async () => 'support.jwt'),
|
|
292
|
+
listSessions: vi.fn(async () => [session]),
|
|
293
|
+
createSession: vi.fn(async () => ({ session })),
|
|
294
|
+
listMessages: vi.fn(async () => ({
|
|
295
|
+
messages: [],
|
|
296
|
+
latestSequence: '',
|
|
297
|
+
sessionVersion: '1',
|
|
298
|
+
})),
|
|
299
|
+
sendMessage: vi.fn(),
|
|
300
|
+
markRead: vi.fn(async () => undefined),
|
|
301
|
+
} satisfies CustomerSupportTransport
|
|
302
|
+
const component = document.createElement('uniplat-customer-support-chat')
|
|
303
|
+
component.configure({
|
|
304
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
305
|
+
transport,
|
|
306
|
+
webSocketFactory: () => socket as unknown as WebSocket,
|
|
307
|
+
})
|
|
308
|
+
document.body.append(component)
|
|
309
|
+
await component.open()
|
|
310
|
+
socket.open()
|
|
311
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
312
|
+
socket.receive({
|
|
313
|
+
type: 'realtime_ready',
|
|
314
|
+
channels: { customer_support: { available: true } },
|
|
315
|
+
heartbeat_interval_seconds: 25,
|
|
316
|
+
})
|
|
317
|
+
await component.updateComplete
|
|
318
|
+
expect(component.shadowRoot?.querySelector('.notice')).toBeNull()
|
|
319
|
+
|
|
320
|
+
// Transient realtime unavailability (close code 1013) surfaces the retry banner.
|
|
321
|
+
// happy-dom drops CloseEvent.code, so pass it directly like a real socket would.
|
|
322
|
+
socket.onclose?.({ code: 1013 } as CloseEvent)
|
|
323
|
+
await component.updateComplete
|
|
324
|
+
expect(component.shadowRoot?.querySelector('.notice')?.textContent).toContain(
|
|
325
|
+
'客服实时鉴权服务暂不可用,正在重试。',
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
// The client reconnects and re-authenticates; the stale banner must clear.
|
|
329
|
+
await vi.advanceTimersByTimeAsync(60_000)
|
|
330
|
+
socket.open()
|
|
331
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
332
|
+
socket.receive({
|
|
333
|
+
type: 'realtime_ready',
|
|
334
|
+
channels: { customer_support: { available: true } },
|
|
335
|
+
heartbeat_interval_seconds: 25,
|
|
336
|
+
})
|
|
337
|
+
await component.updateComplete
|
|
338
|
+
expect(component.shadowRoot?.querySelector('.notice')).toBeNull()
|
|
339
|
+
expect(component.shadowRoot?.querySelector('[role="status"]')?.textContent).toContain(
|
|
340
|
+
'人工客服在线',
|
|
341
|
+
)
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('renders the AI customer service entry and emits only a safe switch request', async () => {
|
|
345
|
+
const component = document.createElement('uniplat-customer-support-chat')
|
|
346
|
+
const details: unknown[] = []
|
|
347
|
+
component.addEventListener('ai-chat-requested', (event) => {
|
|
348
|
+
details.push((event as CustomEvent).detail)
|
|
349
|
+
})
|
|
350
|
+
document.body.append(component)
|
|
351
|
+
await component.open()
|
|
352
|
+
await component.updateComplete
|
|
353
|
+
|
|
354
|
+
expect(component.shadowRoot?.textContent).toContain('开始和人工客服对话吧')
|
|
355
|
+
const button = component.shadowRoot?.querySelector<HTMLButtonElement>('.ai-service-button')
|
|
356
|
+
expect(button?.textContent).toContain('转AI客服')
|
|
357
|
+
expect(button?.querySelector('[data-prototype-icon="robot"]')).toBeTruthy()
|
|
358
|
+
button?.click()
|
|
359
|
+
expect(details).toEqual([{}])
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it('updates unread badges when another support conversation receives a realtime message', async () => {
|
|
363
|
+
const socket = new SupportSocket()
|
|
364
|
+
const otherSession: CustomerSupportSession = {
|
|
365
|
+
...session,
|
|
366
|
+
id: 'support-2',
|
|
367
|
+
sessionNo: 'CS-2',
|
|
368
|
+
title: '社保咨询',
|
|
369
|
+
lastMessage: '旧消息',
|
|
370
|
+
unreadCount: 0,
|
|
371
|
+
}
|
|
372
|
+
const updatedOtherSession = {
|
|
373
|
+
...otherSession,
|
|
374
|
+
lastMessage: '其他会话的新回复',
|
|
375
|
+
unreadCount: 3,
|
|
376
|
+
updatedAt: '2026-08-18T09:00:00.000Z',
|
|
377
|
+
version: '2',
|
|
378
|
+
}
|
|
379
|
+
const transport = {
|
|
380
|
+
getAccessToken: vi.fn(async () => 'support.jwt'),
|
|
381
|
+
listSessions: vi
|
|
382
|
+
.fn<() => Promise<readonly CustomerSupportSession[]>>()
|
|
383
|
+
.mockResolvedValueOnce([session, otherSession])
|
|
384
|
+
.mockResolvedValue([session, updatedOtherSession]),
|
|
385
|
+
createSession: vi.fn(async () => ({ session })),
|
|
386
|
+
listMessages: vi.fn(async () => ({
|
|
387
|
+
messages: [],
|
|
388
|
+
latestSequence: '1',
|
|
389
|
+
sessionVersion: '1',
|
|
390
|
+
})),
|
|
391
|
+
sendMessage: vi.fn(),
|
|
392
|
+
markRead: vi.fn(async () => undefined),
|
|
393
|
+
} satisfies CustomerSupportTransport
|
|
394
|
+
const component = document.createElement('uniplat-customer-support-chat')
|
|
395
|
+
component.configure({
|
|
396
|
+
realtimeUrl: 'wss://kanban.example.com/api/user-realtime/ws',
|
|
397
|
+
transport,
|
|
398
|
+
webSocketFactory: () => socket as unknown as WebSocket,
|
|
399
|
+
})
|
|
400
|
+
document.body.append(component)
|
|
401
|
+
await component.open()
|
|
402
|
+
|
|
403
|
+
socket.receive({
|
|
404
|
+
type: 'realtime_event',
|
|
405
|
+
channel: 'customer_support',
|
|
406
|
+
event: {
|
|
407
|
+
type: 'support_event',
|
|
408
|
+
event_type: 'support_message_created',
|
|
409
|
+
session_id: 'support-2',
|
|
410
|
+
payload: {},
|
|
411
|
+
},
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
await vi.waitFor(() => expect(transport.listSessions).toHaveBeenCalledTimes(2))
|
|
415
|
+
await component.updateComplete
|
|
416
|
+
expect(component.shadowRoot?.querySelector('.conversation-unread')?.textContent).toBe('3')
|
|
417
|
+
|
|
418
|
+
component.shadowRoot?.querySelector<HTMLButtonElement>('[aria-label="我的客服会话"]')?.click()
|
|
419
|
+
await vi.waitFor(() => expect(transport.listSessions).toHaveBeenCalledTimes(3))
|
|
420
|
+
await vi.waitFor(() =>
|
|
421
|
+
expect(component.shadowRoot?.querySelectorAll('.session-item')).toHaveLength(2),
|
|
422
|
+
)
|
|
423
|
+
const items = [...(component.shadowRoot?.querySelectorAll('.session-item') ?? [])]
|
|
424
|
+
expect(items[0]?.querySelector('.session-item-unread')).toBeNull()
|
|
425
|
+
expect(items[1]?.querySelector('.session-item-unread')?.textContent).toBe('3')
|
|
426
|
+
expect(items[1]?.textContent).toContain('其他会话的新回复')
|
|
427
|
+
})
|
|
428
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { createHrSaasCustomerSupportTransport } from '../src'
|
|
5
|
+
|
|
6
|
+
function response(data: unknown): Response {
|
|
7
|
+
return new Response(JSON.stringify({ rescode: 0, data }), {
|
|
8
|
+
status: 200,
|
|
9
|
+
headers: { 'content-type': 'application/json' },
|
|
10
|
+
})
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('createHrSaasCustomerSupportTransport', () => {
|
|
14
|
+
it('maps HR-SaaS endpoints and keeps JWT only in Authorization headers', async () => {
|
|
15
|
+
const fetchImplementation = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
|
16
|
+
const url = String(input)
|
|
17
|
+
if (url.endsWith('/user_session_page')) {
|
|
18
|
+
return response({
|
|
19
|
+
list: [
|
|
20
|
+
{
|
|
21
|
+
session_id: '81',
|
|
22
|
+
title: '服务咨询',
|
|
23
|
+
status: 'active',
|
|
24
|
+
unread_count: 2,
|
|
25
|
+
primary_agent: { agent_id: '7', name: '王悦', role_name: '客服专员' },
|
|
26
|
+
last_message: { content_preview: '您好' },
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
if (url.endsWith('/user_message_page')) {
|
|
32
|
+
return response({
|
|
33
|
+
messages: [
|
|
34
|
+
{
|
|
35
|
+
message_id: '91',
|
|
36
|
+
session_id: '81',
|
|
37
|
+
sequence: '1',
|
|
38
|
+
sender_type: 'agent',
|
|
39
|
+
sender_name: '王悦',
|
|
40
|
+
content: '您好',
|
|
41
|
+
created_at: '2026-08-18T08:00:00Z',
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
latest_sequence: '1',
|
|
45
|
+
session_version: '2',
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`Unexpected endpoint: ${url} ${String(init?.body)}`)
|
|
49
|
+
})
|
|
50
|
+
const transport = createHrSaasCustomerSupportTransport({
|
|
51
|
+
serviceBaseUrl: 'https://hr.example.com/general/project/hr_saas/service/customer_support_api',
|
|
52
|
+
getAccessToken: () => 'customer.support.jwt',
|
|
53
|
+
fetch: fetchImplementation as typeof fetch,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const sessions = await transport.listSessions()
|
|
57
|
+
const page = await transport.listMessages('81')
|
|
58
|
+
|
|
59
|
+
expect(sessions[0]).toMatchObject({
|
|
60
|
+
id: '81',
|
|
61
|
+
title: '服务咨询',
|
|
62
|
+
status: 'active',
|
|
63
|
+
unreadCount: 2,
|
|
64
|
+
primaryAgent: { id: '7', name: '王悦' },
|
|
65
|
+
lastMessage: '您好',
|
|
66
|
+
})
|
|
67
|
+
expect(page.messages[0]).toMatchObject({
|
|
68
|
+
id: '91',
|
|
69
|
+
sessionId: '81',
|
|
70
|
+
senderType: 'agent',
|
|
71
|
+
content: '您好',
|
|
72
|
+
})
|
|
73
|
+
for (const call of fetchImplementation.mock.calls) {
|
|
74
|
+
expect(String(call[0])).not.toContain('customer.support.jwt')
|
|
75
|
+
expect(JSON.stringify(call[1]?.body)).not.toContain('customer.support.jwt')
|
|
76
|
+
expect(new Headers(call[1]?.headers).get('authorization')).toBe('Bearer customer.support.jwt')
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
})
|