@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,429 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import type {
5
+ NotificationCenterTransport,
6
+ NotificationDto,
7
+ NotificationRealtimeChange,
8
+ } from '@uniplat/ai-employee-client'
9
+ import '../src'
10
+ import { UniplatNotificationCenter } from '../src'
11
+
12
+ function notification(overrides: Partial<NotificationDto> = {}): NotificationDto {
13
+ return {
14
+ id: overrides.id ?? 'n1',
15
+ type: overrides.type ?? 'report',
16
+ typeName: overrides.typeName ?? '报告',
17
+ title: overrides.title ?? '5月人事月报已生成',
18
+ summary: overrides.summary ?? '含在职、入离职、合规、薪酬',
19
+ content: overrides.content ?? '正文内容',
20
+ contentType: overrides.contentType ?? 'text',
21
+ priority: overrides.priority ?? 'normal',
22
+ source: overrides.source ?? {
23
+ type: 'ai_employee',
24
+ code: 'analyst',
25
+ id: '',
26
+ name: '数据分析师',
27
+ avatarUrl: '',
28
+ },
29
+ resources: overrides.resources ?? [],
30
+ attachments: overrides.attachments ?? [],
31
+ readStatus: overrides.readStatus ?? 'unread',
32
+ readAt: overrides.readAt ?? null,
33
+ archivedAt: overrides.archivedAt ?? null,
34
+ createdAt: overrides.createdAt ?? '2026-05-30T16:35:00.000Z',
35
+ expiresAt: overrides.expiresAt ?? null,
36
+ }
37
+ }
38
+
39
+ function makeTransport(
40
+ items: NotificationDto[],
41
+ overrides: Partial<NotificationCenterTransport> = {},
42
+ ) {
43
+ return {
44
+ listInbox: vi.fn(async () => ({ items, hasMore: false, nextId: null })),
45
+ getUnreadCount: vi.fn(async () => ({
46
+ unreadCount: items.filter((i) => i.readStatus === 'unread').length,
47
+ })),
48
+ markRead: vi.fn(async () => ({ unreadCount: 0, readAt: '2026-05-30T17:00:00.000Z' })),
49
+ markAllRead: vi.fn(async () => ({ markedCount: 1, unreadCount: 0 })),
50
+ downloadAttachment: vi.fn(async () => new Blob(['file'])),
51
+ ...overrides,
52
+ } satisfies NotificationCenterTransport
53
+ }
54
+
55
+ function tabLabels(component: UniplatNotificationCenter): string[] {
56
+ return Array.from(
57
+ component.shadowRoot?.querySelectorAll('.tab') ?? [],
58
+ (node) => node.textContent?.trim() ?? '',
59
+ )
60
+ }
61
+
62
+ async function mount(
63
+ items: NotificationDto[],
64
+ overrides: Partial<NotificationCenterTransport> = {},
65
+ ) {
66
+ const transport = makeTransport(items, overrides)
67
+ const component = document.createElement('uniplat-notification-center')
68
+ component.configure({ applicationName: 'HrSaas', transport })
69
+ document.body.append(component)
70
+ await new Promise((resolve) => setTimeout(resolve, 0))
71
+ await component.updateComplete
72
+ return { component, transport }
73
+ }
74
+
75
+ describe('<uniplat-notification-center>', () => {
76
+ afterEach(() => document.body.replaceChildren())
77
+
78
+ it('registers, exports the class and renders the notification center panel', async () => {
79
+ const { component } = await mount([notification()])
80
+ expect(customElements.get('uniplat-notification-center')).toBeTruthy()
81
+ expect(component).toBeInstanceOf(UniplatNotificationCenter)
82
+ const root = component.shadowRoot
83
+ expect(root?.querySelector('.topbar-title')?.textContent).toContain('通知中心')
84
+ expect(root?.querySelector('.topbar-subtitle')).toBeNull()
85
+ expect(root?.textContent).not.toContain('数字员工推送的提醒与报告')
86
+ expect(root?.querySelector('.mark-all')?.textContent).toContain('全部标已读')
87
+ // 页签由数据里实际出现的分类决定,展示名来自后端 notification_type_name。
88
+ expect(tabLabels(component)).toEqual(['全部', '报告'])
89
+ expect(root?.querySelector('.list-count')?.textContent).toContain('条')
90
+ expect(root?.querySelector('.nc-item-title')?.textContent).toContain('5月人事月报已生成')
91
+ expect(root?.querySelector('.unread-dot')).toBeTruthy()
92
+ })
93
+
94
+ it('builds the category tabs from the backend facets with backend display names', async () => {
95
+ const listInbox = vi.fn(async (query: { notificationTypes?: readonly string[] }) => {
96
+ const types = query.notificationTypes ?? []
97
+ const all = [
98
+ notification({ id: 'n1', type: 'alert', typeName: '预警' }),
99
+ notification({ id: 'n2', type: 'sales_lead_assigned', typeName: '新线索' }),
100
+ // 后端字典没登记该码时 notification_type_name 为空,页签回退到原始 code。
101
+ notification({ id: 'n3', type: 'interview', typeName: '' }),
102
+ ]
103
+ const items = types.length ? all.filter((item) => types.includes(item.type)) : all
104
+ return {
105
+ items,
106
+ hasMore: false,
107
+ nextId: null,
108
+ types: [
109
+ { type: 'alert', name: '预警' },
110
+ { type: 'sales_lead_assigned', name: '新线索' },
111
+ { type: 'interview', name: '' },
112
+ ],
113
+ }
114
+ })
115
+ const { component, transport } = await mount([], {
116
+ listInbox: listInbox as unknown as NotificationCenterTransport['listInbox'],
117
+ })
118
+ expect(transport.listInbox).toHaveBeenCalledWith(
119
+ expect.objectContaining({ includeTypeFacets: true }),
120
+ )
121
+ expect(tabLabels(component)).toEqual(['全部', '预警', '新线索', 'interview'])
122
+
123
+ const interviewTab = component.shadowRoot?.querySelectorAll<HTMLButtonElement>('.tab')[3]
124
+ interviewTab?.click()
125
+ await vi.waitFor(() =>
126
+ expect(transport.listInbox).toHaveBeenLastCalledWith(
127
+ expect.objectContaining({ notificationTypes: ['interview'] }),
128
+ ),
129
+ )
130
+ await component.updateComplete
131
+ // 服务端只回该分类,页签集合不能因此塌缩。
132
+ expect(tabLabels(component)).toEqual(['全部', '预警', '新线索', 'interview'])
133
+ expect(component.shadowRoot?.querySelectorAll('.nc-item')).toHaveLength(1)
134
+ expect(component.shadowRoot?.querySelector('.nc-item-sub')?.textContent).toContain('interview')
135
+ })
136
+
137
+ it('falls back to page-local types when the backend sends no facets', async () => {
138
+ const { component } = await mount([notification({ id: 'n1', type: 'todo', typeName: '待办' })])
139
+ expect(tabLabels(component)).toEqual(['全部', '待办'])
140
+ expect(component.shadowRoot?.querySelector('.notice')).toBeNull()
141
+ })
142
+
143
+ it('keeps no built-in Chinese name for the documented codes', async () => {
144
+ // 展示名只能来自数据:typeName 为空时必须显示原始 code,不得回落到内置中文名。
145
+ const { component } = await mount([
146
+ notification({ id: 'n1', type: 'alert', typeName: '', readStatus: 'read' }),
147
+ ])
148
+ expect(tabLabels(component)).toEqual(['全部', 'alert'])
149
+ expect(component.shadowRoot?.querySelector('.nc-item-sub')?.textContent).toContain('alert')
150
+ expect(component.shadowRoot?.innerHTML ?? '').not.toContain('预警')
151
+ component.shadowRoot?.querySelector<HTMLButtonElement>('.nc-item')?.click()
152
+ await component.updateComplete
153
+ expect(component.shadowRoot?.querySelector('.info-card')?.textContent).toContain('alert')
154
+ })
155
+
156
+ it('keeps the prototype structure: row list, single detail card, typed tone and file tile', async () => {
157
+ const { component } = await mount([
158
+ notification({ id: 'n1', type: 'care', typeName: '关怀', readStatus: 'read' }),
159
+ notification({
160
+ id: 'n2',
161
+ type: 'report',
162
+ readStatus: 'read',
163
+ resources: [{ type: 'service_order', id: 'so-9', title: '服务单 A', aeCode: '' }],
164
+ attachments: [
165
+ {
166
+ fileId: 'f1',
167
+ versionId: '',
168
+ name: 'Q2资料完整度巡检报告.pdf',
169
+ sizeBytes: 1638,
170
+ mimeType: 'application/pdf',
171
+ },
172
+ ],
173
+ }),
174
+ ])
175
+ const root = component.shadowRoot
176
+ // 原型 .inbox-item 是整行(分隔线 + 左侧选中条),不是浮动圆角卡片。
177
+ const styles = UniplatNotificationCenter.styles?.toString() ?? ''
178
+ expect(styles).toContain('border-bottom: 1px solid #f0f4f9')
179
+ // 原型 .notif-icon-wrap 的 alert-i 是橙色、report-i 是靛蓝、关怀走 info-i。
180
+ expect(styles).toContain('.detail-badge.alert')
181
+ expect(styles).not.toContain('.detail-badge.success')
182
+
183
+ root?.querySelectorAll<HTMLButtonElement>('.nc-item')[0]?.click()
184
+ await component.updateComplete
185
+ // 关怀沿用原型 info-i 蓝底
186
+ expect(root?.querySelector('.detail-badge')?.className).toContain('info')
187
+
188
+ root?.querySelectorAll<HTMLButtonElement>('.nc-item')[1]?.click()
189
+ await component.updateComplete
190
+ // 原型 .detail-card:详情整体是一张白卡,各区块在卡内
191
+ const card = root?.querySelector('.detail-card')
192
+ expect(card).toBeTruthy()
193
+ expect(card?.querySelectorAll('.section-title')).toHaveLength(4)
194
+ expect(root?.querySelector('.detail-badge')?.className).toContain('report')
195
+ // 原型 .order-item 右侧「查看 →」胶囊与 .file-icon 图标块
196
+ expect(root?.querySelector('.resource-status')?.textContent).toContain('查看')
197
+ expect(root?.querySelector('.attachment-icon')?.textContent?.trim()).toBe('📕')
198
+ })
199
+
200
+ it('formats timestamps with the prototype 今天/昨天/月日 forms', async () => {
201
+ const now = new Date()
202
+ const yesterday = new Date(now.getTime() - 86_400_000)
203
+ // 60 天前一定不落在今天/昨天分支,且不依赖测试运行的具体日历日。
204
+ const older = new Date(now.getTime() - 60 * 86_400_000)
205
+ const { component } = await mount([
206
+ notification({ id: 'a', createdAt: now.toISOString() }),
207
+ notification({ id: 'b', createdAt: yesterday.toISOString() }),
208
+ notification({ id: 'c', createdAt: older.toISOString() }),
209
+ ])
210
+ const times = Array.from(
211
+ component.shadowRoot?.querySelectorAll('.nc-item-time') ?? [],
212
+ (node) => node.textContent?.trim() ?? '',
213
+ )
214
+ expect(times[0]).toMatch(/^今天 \d{2}:\d{2}$/)
215
+ expect(times[1]).toMatch(/^昨天 \d{2}:\d{2}$/)
216
+ // 原型第三种形式是「M月D日 HH:mm」,跨年才补年份;不使用星期或 ISO 日期。
217
+ const sameYear = older.getFullYear() === now.getFullYear()
218
+ expect(times[2]).toMatch(
219
+ sameYear ? /^\d{1,2}月\d{1,2}日 \d{2}:\d{2}$/ : /^\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}$/,
220
+ )
221
+ expect(times[2]).toContain(`${older.getMonth() + 1}月${older.getDate()}日`)
222
+ })
223
+
224
+ it('never opens a WebSocket and takes realtime updates from the host instead', async () => {
225
+ const openedSockets: string[] = []
226
+ const originalWebSocket = globalThis.WebSocket
227
+ class TrackingSocket {
228
+ constructor(url: string) {
229
+ openedSockets.push(url)
230
+ }
231
+ }
232
+ Object.defineProperty(globalThis, 'WebSocket', {
233
+ value: TrackingSocket,
234
+ configurable: true,
235
+ writable: true,
236
+ })
237
+ try {
238
+ const parseRealtimeEvent = vi.fn((): NotificationRealtimeChange => ({
239
+ kind: 'created',
240
+ notification: notification({ id: 'n2', type: 'alert', title: '社保缴纳异常' }),
241
+ unreadCount: 5,
242
+ }))
243
+ const unreadEvents: number[] = []
244
+ const { component, transport } = await mount([notification({ id: 'n1' })], {
245
+ parseRealtimeEvent,
246
+ })
247
+ component.addEventListener('unread-count-change', (event) =>
248
+ unreadEvents.push((event as CustomEvent<{ unreadCount: number }>).detail.unreadCount),
249
+ )
250
+ vi.mocked(transport.listInbox).mockClear()
251
+
252
+ component.applyRealtimeEvent({ event_type: 'notification_created', notification: {} })
253
+ await component.updateComplete
254
+
255
+ expect(openedSockets).toEqual([])
256
+ expect(parseRealtimeEvent).toHaveBeenCalledTimes(1)
257
+ expect(transport.listInbox).not.toHaveBeenCalled()
258
+ expect(component.shadowRoot?.querySelector('.nc-item-title')?.textContent).toContain(
259
+ '社保缴纳异常',
260
+ )
261
+ expect(unreadEvents).toContain(5)
262
+ expect(component.getUnreadCount()).toBe(5)
263
+ } finally {
264
+ Object.defineProperty(globalThis, 'WebSocket', {
265
+ value: originalWebSocket,
266
+ configurable: true,
267
+ writable: true,
268
+ })
269
+ }
270
+ })
271
+
272
+ it('refreshes the inbox when a host-fed event cannot be mapped', async () => {
273
+ const { component, transport } = await mount([notification()], {
274
+ parseRealtimeEvent: () => null,
275
+ })
276
+ vi.mocked(transport.listInbox).mockClear()
277
+ vi.mocked(transport.getUnreadCount).mockClear()
278
+ component.applyRealtimeEvent({ event_type: 'notification_unknown' })
279
+ await vi.waitFor(() => expect(transport.getUnreadCount).toHaveBeenCalledTimes(1))
280
+ expect(transport.listInbox).toHaveBeenCalledTimes(1)
281
+ })
282
+
283
+ it('ignores host-fed events before configure and after removal', async () => {
284
+ const bare = document.createElement('uniplat-notification-center')
285
+ expect(() => bare.applyRealtimeEvent({ event_type: 'notification_created' })).not.toThrow()
286
+
287
+ const { component, transport } = await mount([notification()], {
288
+ parseRealtimeEvent: () => null,
289
+ })
290
+ component.remove()
291
+ vi.mocked(transport.listInbox).mockClear()
292
+ expect(() => component.applyRealtimeEvent({ event_type: 'notification_created' })).not.toThrow()
293
+ await new Promise((resolve) => setTimeout(resolve, 0))
294
+ expect(transport.listInbox).not.toHaveBeenCalled()
295
+ })
296
+
297
+ it('marks an unread notification read on selection and emits a safe read event', async () => {
298
+ const readEvents: unknown[] = []
299
+ const { component, transport } = await mount([notification({ id: 'n1' })])
300
+ component.addEventListener('notification-read', (event) =>
301
+ readEvents.push((event as CustomEvent).detail),
302
+ )
303
+ component.shadowRoot?.querySelector<HTMLButtonElement>('.nc-item')?.click()
304
+ await vi.waitFor(() => expect(transport.markRead).toHaveBeenCalledTimes(1))
305
+ expect(transport.markRead).toHaveBeenCalledWith({
306
+ notificationId: 'n1',
307
+ applicationName: 'HrSaas',
308
+ })
309
+ await component.updateComplete
310
+ expect(readEvents).toEqual([{ notificationId: 'n1' }])
311
+ // Detail area is populated with the基本信息 section.
312
+ expect(component.shadowRoot?.querySelector('.detail-title')?.textContent).toContain(
313
+ '5月人事月报',
314
+ )
315
+ expect(component.shadowRoot?.querySelector('.info-card')?.textContent).toContain('报告')
316
+ })
317
+
318
+ it('emits unread-count-change and disables 全部标已读 after marking all read', async () => {
319
+ const unreadEvents: number[] = []
320
+ const { component, transport } = await mount([notification({ id: 'n1' })])
321
+ component.addEventListener('unread-count-change', (event) =>
322
+ unreadEvents.push((event as CustomEvent<{ unreadCount: number }>).detail.unreadCount),
323
+ )
324
+ expect(component.getUnreadCount()).toBe(1)
325
+ const button = component.shadowRoot?.querySelector<HTMLButtonElement>('.mark-all')
326
+ expect(button?.disabled).toBe(false)
327
+ button?.click()
328
+ await vi.waitFor(() => expect(transport.markAllRead).toHaveBeenCalledTimes(1))
329
+ await component.updateComplete
330
+ // The recomputed unread count is emitted to the host and zeroes the badge.
331
+ expect(unreadEvents).toContain(0)
332
+ expect(component.getUnreadCount()).toBe(0)
333
+ expect(component.shadowRoot?.querySelector<HTMLButtonElement>('.mark-all')?.disabled).toBe(true)
334
+ })
335
+
336
+ it('requests host navigation for related resources with only safe business fields', async () => {
337
+ const details: unknown[] = []
338
+ const { component } = await mount([
339
+ notification({
340
+ readStatus: 'read',
341
+ resources: [{ type: 'service_order', id: 'so-9', title: '服务单 A', aeCode: '' }],
342
+ }),
343
+ ])
344
+ component.addEventListener('notification-resource-requested', (event) =>
345
+ details.push((event as CustomEvent).detail),
346
+ )
347
+ component.shadowRoot?.querySelector<HTMLButtonElement>('.nc-item')?.click()
348
+ await component.updateComplete
349
+ component.shadowRoot?.querySelector<HTMLButtonElement>('.resource-card')?.click()
350
+ expect(details).toEqual([{ resourceType: 'service_order', id: 'so-9' }])
351
+ })
352
+
353
+ it('downloads attachments through the transport', async () => {
354
+ const { component, transport } = await mount([
355
+ notification({
356
+ readStatus: 'read',
357
+ attachments: [
358
+ {
359
+ fileId: 'f1',
360
+ versionId: '',
361
+ name: '2026年5月人事月报.pdf',
362
+ sizeBytes: 1600,
363
+ mimeType: 'application/pdf',
364
+ },
365
+ ],
366
+ }),
367
+ ])
368
+ component.shadowRoot?.querySelector<HTMLButtonElement>('.nc-item')?.click()
369
+ await component.updateComplete
370
+ expect(component.shadowRoot?.querySelector('.attachment-name')?.textContent).toContain(
371
+ '人事月报.pdf',
372
+ )
373
+ component.shadowRoot?.querySelector<HTMLButtonElement>('.attachment-download')?.click()
374
+ await vi.waitFor(() => expect(transport.downloadAttachment).toHaveBeenCalledTimes(1))
375
+ expect(transport.downloadAttachment).toHaveBeenCalledWith({
376
+ fileId: 'f1',
377
+ name: '2026年5月人事月报.pdf',
378
+ })
379
+ })
380
+
381
+ it('renders markdown safely without injecting raw HTML', async () => {
382
+ const { component } = await mount([
383
+ notification({
384
+ readStatus: 'read',
385
+ contentType: 'markdown',
386
+ content: '**加粗** 与 [x](javascript:alert(1)) 与 <script>alert(2)</script>',
387
+ }),
388
+ ])
389
+ component.shadowRoot?.querySelector<HTMLButtonElement>('.nc-item')?.click()
390
+ await component.updateComplete
391
+ const content = component.shadowRoot?.querySelector('.content-card')
392
+ expect(content?.querySelector('strong')?.textContent).toContain('加粗')
393
+ // No script element is created and no javascript: anchor survives; raw
394
+ // markup is escaped to inert text rather than injected as HTML.
395
+ expect(content?.querySelector('script')).toBeNull()
396
+ expect(content?.querySelector('a')).toBeNull()
397
+ expect(content?.innerHTML ?? '').toContain('&lt;script&gt;')
398
+ })
399
+
400
+ it('keeps host-fed realtime payloads out of the DOM and public events', async () => {
401
+ const errors: unknown[] = []
402
+ const { component } = await mount([notification()], { parseRealtimeEvent: () => null })
403
+ component.addEventListener('error', (event) =>
404
+ errors.push((event as unknown as CustomEvent).detail),
405
+ )
406
+ component.applyRealtimeEvent({ event_type: 'notification_created', access_token: 'notify.jwt' })
407
+ await component.updateComplete
408
+ expect(component.outerHTML).not.toContain('notify.jwt')
409
+ expect(component.shadowRoot?.innerHTML ?? '').not.toContain('notify.jwt')
410
+ expect(JSON.stringify(errors)).not.toContain('notify.jwt')
411
+ })
412
+
413
+ it('surfaces a sanitized banner when the inbox request fails', async () => {
414
+ const { component } = await mount([], {
415
+ listInbox: vi.fn(async () => {
416
+ throw new Error('backend stack trace 5xx')
417
+ }) as unknown as NotificationCenterTransport['listInbox'],
418
+ })
419
+ await component.updateComplete
420
+ const notice = component.shadowRoot?.querySelector('.notice')?.textContent ?? ''
421
+ expect(notice).toContain('通知加载失败')
422
+ expect(notice).not.toContain('backend stack trace')
423
+ })
424
+
425
+ it('releases the client on disconnect without throwing', async () => {
426
+ const { component } = await mount([notification()])
427
+ expect(() => component.remove()).not.toThrow()
428
+ })
429
+ })