@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,615 @@
1
+ import {
2
+ NotificationCenterClient,
3
+ type NotificationCenterErrorDetail,
4
+ type NotificationCenterTransport,
5
+ type NotificationDto,
6
+ } from '@uniplat/ai-employee-client'
7
+ import { LitElement, css, html, nothing, type TemplateResult } from 'lit'
8
+ import { unsafeSVG } from 'lit/directives/unsafe-svg.js'
9
+ import { prototypeIcon, type PrototypeIconName } from './prototype-icons'
10
+ import type { NotificationCenterTypeIconMap } from './notification-center'
11
+
12
+ export interface NotificationPopoverConfig {
13
+ /** Empty means the current principal's notifications across all applications. */
14
+ applicationName: string
15
+ transport: NotificationCenterTransport
16
+ typeIcons?: NotificationCenterTypeIconMap
17
+ }
18
+
19
+ export interface NotificationCenterRequestedDetail {
20
+ notificationId?: string
21
+ }
22
+
23
+ export interface NotificationPopoverEventMap {
24
+ ready: CustomEvent<Record<string, never>>
25
+ 'unread-count-change': CustomEvent<{ unreadCount: number }>
26
+ 'notification-read': CustomEvent<{ notificationId: string }>
27
+ 'notification-center-requested': CustomEvent<NotificationCenterRequestedDetail>
28
+ closed: CustomEvent<Record<string, never>>
29
+ error: CustomEvent<NotificationCenterErrorDetail>
30
+ }
31
+
32
+ interface NotificationVisual {
33
+ icon: PrototypeIconName
34
+ tint: string
35
+ accent: string
36
+ }
37
+
38
+ const MAX_VISIBLE_ITEMS = 4
39
+ const DEFAULT_VISUAL: NotificationVisual = {
40
+ icon: 'bell',
41
+ tint: '#e6eefe',
42
+ accent: '#2e6bf5',
43
+ }
44
+ const TYPE_VISUALS: Readonly<Record<string, NotificationVisual>> = {
45
+ alert: { icon: 'speakerphone', tint: '#fff4e2', accent: '#e8590c' },
46
+ todo: { icon: 'clipboard-check', tint: '#fff4e2', accent: '#e8590c' },
47
+ report: { icon: 'coin', tint: '#e6eefe', accent: '#2e6bf5' },
48
+ care: { icon: 'discount-check', tint: '#e3f7ef', accent: '#0ca678' },
49
+ certification: { icon: 'discount-check', tint: '#e3f7ef', accent: '#0ca678' },
50
+ message: { icon: 'message-2', tint: '#efe9fd', accent: '#6741d9' },
51
+ policy: { icon: 'speakerphone', tint: '#fff4e2', accent: '#e8590c' },
52
+ service: { icon: 'coin', tint: '#e6eefe', accent: '#2e6bf5' },
53
+ }
54
+
55
+ /**
56
+ * Compact 40 prototype notification popover.
57
+ *
58
+ * It reuses the notification-center HTTP projection and consumes host-forwarded
59
+ * realtime events. The host owns the bell trigger, positioning and the single
60
+ * realtime socket; this component owns the popover panel and close behaviour.
61
+ */
62
+ export class UniplatNotificationPopover extends LitElement {
63
+ static override properties = {
64
+ opened: { type: Boolean, reflect: true, attribute: 'open' },
65
+ items: { state: true },
66
+ unreadCount: { state: true },
67
+ loading: { state: true },
68
+ markingAllRead: { state: true },
69
+ visibleError: { state: true },
70
+ }
71
+
72
+ static override styles = css`
73
+ :host {
74
+ --uniplat-notification-popover-width: 376px;
75
+ --uniplat-notification-popover-primary: #2e6bf5;
76
+ --uniplat-notification-popover-text: #182634;
77
+ --uniplat-notification-popover-muted: #7a8794;
78
+ --uniplat-notification-popover-border: #e4ebf3;
79
+ --uniplat-notification-popover-background: #fff;
80
+ --uniplat-notification-popover-font-family:
81
+ 'Noto Sans SC', Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
82
+ 'Segoe UI', sans-serif;
83
+ position: relative;
84
+ z-index: 200;
85
+ display: block;
86
+ width: min(var(--uniplat-notification-popover-width), calc(100vw - 24px));
87
+ color: var(--uniplat-notification-popover-text);
88
+ font-family: var(--uniplat-notification-popover-font-family);
89
+ }
90
+ :host(:not([open])) {
91
+ display: none;
92
+ }
93
+ * {
94
+ box-sizing: border-box;
95
+ }
96
+ button {
97
+ font: inherit;
98
+ }
99
+ .backdrop {
100
+ position: fixed;
101
+ z-index: 0;
102
+ inset: 0;
103
+ padding: 0;
104
+ border: 0;
105
+ background: transparent;
106
+ cursor: default;
107
+ }
108
+ .panel {
109
+ position: relative;
110
+ z-index: 1;
111
+ width: 100%;
112
+ overflow: hidden;
113
+ border: 1px solid var(--uniplat-notification-popover-border);
114
+ border-radius: 14px;
115
+ background: var(--uniplat-notification-popover-background);
116
+ box-shadow: 0 18px 44px rgb(20 40 80 / 16%);
117
+ }
118
+ .header {
119
+ display: flex;
120
+ align-items: center;
121
+ gap: 9px;
122
+ min-height: 49px;
123
+ padding: 14px 16px;
124
+ border-bottom: 1px solid #edf1f6;
125
+ }
126
+ .title {
127
+ min-width: 0;
128
+ flex: 1;
129
+ font-size: 14.5px;
130
+ font-weight: 800;
131
+ }
132
+ .unread-badge {
133
+ flex: 0 0 auto;
134
+ padding: 2px 8px;
135
+ border-radius: 5px;
136
+ background: #fdeced;
137
+ color: #e5484d;
138
+ font-size: 11.5px;
139
+ font-weight: 700;
140
+ }
141
+ .mark-all {
142
+ flex: 0 0 auto;
143
+ padding: 2px 0;
144
+ border: 0;
145
+ background: transparent;
146
+ color: var(--uniplat-notification-popover-primary);
147
+ font-size: 12.5px;
148
+ font-weight: 600;
149
+ cursor: pointer;
150
+ }
151
+ .mark-all:hover:not(:disabled) {
152
+ text-decoration: underline;
153
+ }
154
+ .mark-all:disabled {
155
+ cursor: default;
156
+ opacity: 0.55;
157
+ }
158
+ .list {
159
+ display: flex;
160
+ max-height: 352px;
161
+ flex-direction: column;
162
+ overflow-y: auto;
163
+ overscroll-behavior: contain;
164
+ }
165
+ .list::-webkit-scrollbar {
166
+ width: 5px;
167
+ }
168
+ .list::-webkit-scrollbar-thumb {
169
+ border-radius: 3px;
170
+ background: #b9c4d0;
171
+ }
172
+ .item {
173
+ display: flex;
174
+ width: 100%;
175
+ gap: 11px;
176
+ padding: 13px 16px;
177
+ border: 0;
178
+ border-bottom: 1px solid #f3f6fa;
179
+ background: transparent;
180
+ color: inherit;
181
+ text-align: left;
182
+ cursor: pointer;
183
+ }
184
+ .item:hover {
185
+ background: #f7faff;
186
+ }
187
+ .icon-wrap {
188
+ display: flex;
189
+ width: 34px;
190
+ height: 34px;
191
+ flex: 0 0 auto;
192
+ align-items: center;
193
+ justify-content: center;
194
+ border-radius: 10px;
195
+ background: var(--item-tint);
196
+ color: var(--item-accent);
197
+ }
198
+ .icon-wrap svg,
199
+ .icon-wrap img {
200
+ width: 17px;
201
+ height: 17px;
202
+ }
203
+ .custom-text-icon {
204
+ font-size: 17px;
205
+ line-height: 1;
206
+ }
207
+ .item-body {
208
+ min-width: 0;
209
+ flex: 1;
210
+ }
211
+ .item-head {
212
+ display: flex;
213
+ align-items: center;
214
+ gap: 7px;
215
+ }
216
+ .item-title {
217
+ overflow: hidden;
218
+ min-width: 0;
219
+ color: var(--uniplat-notification-popover-text);
220
+ font-size: 13.5px;
221
+ font-weight: 700;
222
+ text-overflow: ellipsis;
223
+ white-space: nowrap;
224
+ }
225
+ .unread-dot {
226
+ width: 7px;
227
+ height: 7px;
228
+ flex: 0 0 auto;
229
+ border-radius: 50%;
230
+ background: #e5484d;
231
+ }
232
+ .summary {
233
+ display: -webkit-box;
234
+ overflow: hidden;
235
+ margin-top: 3px;
236
+ color: var(--uniplat-notification-popover-muted);
237
+ font-size: 12px;
238
+ line-height: 1.65;
239
+ -webkit-box-orient: vertical;
240
+ -webkit-line-clamp: 2;
241
+ }
242
+ .meta {
243
+ display: block;
244
+ margin-top: 5px;
245
+ color: #a6b1be;
246
+ font-size: 11px;
247
+ }
248
+ .empty,
249
+ .loading {
250
+ display: flex;
251
+ min-height: 112px;
252
+ flex-direction: column;
253
+ align-items: center;
254
+ justify-content: center;
255
+ gap: 7px;
256
+ padding: 28px 16px;
257
+ color: #98a4b2;
258
+ font-size: 13px;
259
+ text-align: center;
260
+ }
261
+ .empty svg {
262
+ width: 26px;
263
+ height: 26px;
264
+ color: #c7d1dd;
265
+ }
266
+ .notice {
267
+ margin: 12px 16px;
268
+ padding: 10px;
269
+ border: 1px solid rgb(229 72 77 / 25%);
270
+ border-radius: 8px;
271
+ background: rgb(229 72 77 / 6%);
272
+ color: #c43c40;
273
+ font-size: 12px;
274
+ line-height: 1.5;
275
+ }
276
+ .footer {
277
+ display: flex;
278
+ width: 100%;
279
+ height: 46px;
280
+ align-items: center;
281
+ justify-content: center;
282
+ gap: 6px;
283
+ border: 0;
284
+ border-top: 1px solid #edf1f6;
285
+ background: var(--uniplat-notification-popover-background);
286
+ color: var(--uniplat-notification-popover-primary);
287
+ font-size: 13.5px;
288
+ font-weight: 700;
289
+ cursor: pointer;
290
+ }
291
+ .footer:hover {
292
+ background: #f7faff;
293
+ }
294
+ .footer svg {
295
+ width: 15px;
296
+ height: 15px;
297
+ }
298
+ `
299
+
300
+ declare opened: boolean
301
+ declare private items: NotificationDto[]
302
+ declare private unreadCount: number
303
+ declare private loading: boolean
304
+ declare private markingAllRead: boolean
305
+ declare private visibleError: string
306
+
307
+ private client: NotificationCenterClient | null = null
308
+ private config: NotificationPopoverConfig | null = null
309
+ private disposeListeners: Array<() => void> = []
310
+ private outsideClickListening = false
311
+ private outsideClickGeneration = 0
312
+ private outsideClickTimer: ReturnType<typeof setTimeout> | null = null
313
+
314
+ constructor() {
315
+ super()
316
+ this.opened = false
317
+ this.items = []
318
+ this.unreadCount = 0
319
+ this.loading = false
320
+ this.markingAllRead = false
321
+ this.visibleError = ''
322
+ }
323
+
324
+ configure(config: NotificationPopoverConfig): void {
325
+ this.releaseClient()
326
+ this.config = config
327
+ this.items = []
328
+ this.unreadCount = 0
329
+ this.visibleError = ''
330
+ if (this.isConnected) void this.startClient()
331
+ }
332
+
333
+ open(): void {
334
+ this.opened = true
335
+ this.scheduleOutsideClickListener()
336
+ }
337
+
338
+ close(): void {
339
+ if (!this.opened) return
340
+ this.opened = false
341
+ this.removeOutsideClickListener()
342
+ this.dispatchSafe('closed', {})
343
+ }
344
+
345
+ toggle(): void {
346
+ if (this.opened) this.close()
347
+ else this.open()
348
+ }
349
+
350
+ async refresh(): Promise<void> {
351
+ await this.client?.refresh()
352
+ }
353
+
354
+ applyRealtimeEvent(event: unknown): void {
355
+ this.client?.applyRealtimeEvent(event)
356
+ }
357
+
358
+ getUnreadCount(): number {
359
+ return this.unreadCount
360
+ }
361
+
362
+ override connectedCallback(): void {
363
+ super.connectedCallback()
364
+ if (this.config && !this.client) void this.startClient()
365
+ if (this.opened) this.scheduleOutsideClickListener()
366
+ }
367
+
368
+ override disconnectedCallback(): void {
369
+ this.removeOutsideClickListener()
370
+ this.releaseClient()
371
+ super.disconnectedCallback()
372
+ }
373
+
374
+ override render() {
375
+ const unreadItems = this.items
376
+ .filter((item) => item.readStatus === 'unread')
377
+ .slice(0, MAX_VISIBLE_ITEMS)
378
+ return html`
379
+ <button
380
+ class="backdrop"
381
+ type="button"
382
+ aria-label="关闭消息通知"
383
+ @click=${this.close}
384
+ ></button>
385
+ <section class="panel" part="panel" aria-label="消息通知">
386
+ <header class="header" part="header">
387
+ <span class="title">消息通知</span>
388
+ ${
389
+ this.unreadCount > 0
390
+ ? html`
391
+ <span class="unread-badge">${this.unreadCount} 条未读</span>
392
+ <button
393
+ class="mark-all"
394
+ type="button"
395
+ ?disabled=${this.markingAllRead}
396
+ @click=${this.markAllRead}
397
+ >
398
+ 全部已读
399
+ </button>
400
+ `
401
+ : nothing
402
+ }
403
+ </header>
404
+ <div class="list" part="list">
405
+ ${this.visibleError ? html`<div class="notice">${this.visibleError}</div>` : nothing}
406
+ ${
407
+ this.loading
408
+ ? html`<div class="loading">消息加载中…</div>`
409
+ : unreadItems.length
410
+ ? unreadItems.map((item) => this.renderItem(item))
411
+ : html`<div class="empty">
412
+ ${prototypeIcon('mail-check')}<span>暂无未读消息</span>
413
+ </div>`
414
+ }
415
+ </div>
416
+ <button class="footer" part="footer" type="button" @click=${this.requestAllNotifications}>
417
+ 查看全部消息 ${prototypeIcon('arrow-right')}
418
+ </button>
419
+ </section>
420
+ `
421
+ }
422
+
423
+ private renderItem(item: NotificationDto) {
424
+ const visual = this.typeVisual(item.type)
425
+ return html`
426
+ <button
427
+ class="item"
428
+ part="item"
429
+ type="button"
430
+ style=${`--item-tint:${visual.tint};--item-accent:${visual.accent}`}
431
+ @click=${() => this.requestNotification(item)}
432
+ >
433
+ <span class="icon-wrap">${this.renderTypeIcon(item.type, visual)}</span>
434
+ <span class="item-body">
435
+ <span class="item-head">
436
+ <span class="item-title">${item.title}</span>
437
+ <span class="unread-dot" aria-label="未读"></span>
438
+ </span>
439
+ <span class="summary">${item.summary}</span>
440
+ <span class="meta">${typeLabel(item)} · ${formatCompactTimestamp(item.createdAt)}</span>
441
+ </span>
442
+ </button>
443
+ `
444
+ }
445
+
446
+ private renderTypeIcon(type: string, visual: NotificationVisual): TemplateResult {
447
+ const custom = this.config?.typeIcons?.[type]
448
+ if (custom) {
449
+ const value = custom.trim()
450
+ const iconUrl = safeIconUrl(value)
451
+ if (iconUrl) return html`<img src=${iconUrl} alt="" width="17" height="17" />`
452
+ if (isSafeInlineSvg(value)) return html`${unsafeSVG(value)}`
453
+ if (/^(?:https?:\/\/|<svg[\s>])/i.test(value)) return prototypeIcon(visual.icon)
454
+ return html`<span class="custom-text-icon">${value}</span>`
455
+ }
456
+ return prototypeIcon(visual.icon)
457
+ }
458
+
459
+ private typeVisual(type: string): NotificationVisual {
460
+ return TYPE_VISUALS[type.trim()] ?? DEFAULT_VISUAL
461
+ }
462
+
463
+ private async startClient(): Promise<void> {
464
+ if (!this.config) return
465
+ this.loading = true
466
+ this.visibleError = ''
467
+ try {
468
+ const client = new NotificationCenterClient({
469
+ transport: this.config.transport,
470
+ applicationName: this.config.applicationName,
471
+ pageSize: MAX_VISIBLE_ITEMS,
472
+ readStatus: 'unread',
473
+ })
474
+ this.client = client
475
+ this.disposeListeners = [
476
+ client.on('ready', () => this.dispatchSafe('ready', {})),
477
+ client.on('inbox', ({ items }) => {
478
+ this.items = [...items]
479
+ this.visibleError = ''
480
+ }),
481
+ client.on('unread-count-change', ({ unreadCount }) => {
482
+ this.unreadCount = unreadCount
483
+ this.dispatchSafe('unread-count-change', { unreadCount })
484
+ }),
485
+ client.on('notification-read', (detail) => this.dispatchSafe('notification-read', detail)),
486
+ client.on('error', (detail) => this.showError(detail)),
487
+ ]
488
+ await client.start()
489
+ } catch {
490
+ this.showError({
491
+ code: 'INVALID_CONFIGURATION',
492
+ message: '消息通知配置不可用。',
493
+ recoverable: false,
494
+ })
495
+ } finally {
496
+ this.loading = false
497
+ }
498
+ }
499
+
500
+ private markAllRead = async (): Promise<void> => {
501
+ if (!this.client || this.markingAllRead) return
502
+ this.markingAllRead = true
503
+ try {
504
+ await this.client.markAllRead()
505
+ } finally {
506
+ this.markingAllRead = false
507
+ }
508
+ }
509
+
510
+ private requestNotification(item: NotificationDto): void {
511
+ if (item.readStatus === 'unread') void this.client?.markRead(item.id)
512
+ this.close()
513
+ this.dispatchSafe('notification-center-requested', { notificationId: item.id })
514
+ }
515
+
516
+ private requestAllNotifications = (): void => {
517
+ this.close()
518
+ this.dispatchSafe('notification-center-requested', {})
519
+ }
520
+
521
+ private scheduleOutsideClickListener(): void {
522
+ const generation = ++this.outsideClickGeneration
523
+ if (this.outsideClickTimer !== null) clearTimeout(this.outsideClickTimer)
524
+ this.outsideClickTimer = setTimeout(() => {
525
+ this.outsideClickTimer = null
526
+ if (generation !== this.outsideClickGeneration || !this.opened || !this.isConnected) return
527
+ if (this.outsideClickListening) return
528
+ this.ownerDocument.addEventListener('click', this.handleDocumentClick)
529
+ this.outsideClickListening = true
530
+ }, 0)
531
+ }
532
+
533
+ private removeOutsideClickListener(): void {
534
+ this.outsideClickGeneration += 1
535
+ if (this.outsideClickTimer !== null) {
536
+ clearTimeout(this.outsideClickTimer)
537
+ this.outsideClickTimer = null
538
+ }
539
+ if (!this.outsideClickListening) return
540
+ this.ownerDocument.removeEventListener('click', this.handleDocumentClick)
541
+ this.outsideClickListening = false
542
+ }
543
+
544
+ private handleDocumentClick = (event: MouseEvent): void => {
545
+ if (!this.opened || event.composedPath().includes(this)) return
546
+ this.close()
547
+ }
548
+
549
+ private showError(detail: NotificationCenterErrorDetail): void {
550
+ this.visibleError = detail.message
551
+ this.dispatchSafe('error', detail)
552
+ }
553
+
554
+ private releaseClient(): void {
555
+ for (const dispose of this.disposeListeners) dispose()
556
+ this.disposeListeners = []
557
+ this.client?.destroy()
558
+ this.client = null
559
+ }
560
+
561
+ private dispatchSafe(type: string, detail: unknown): void {
562
+ this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }))
563
+ }
564
+ }
565
+
566
+ function typeLabel(item: NotificationDto): string {
567
+ return item.typeName.trim() || item.type.trim()
568
+ }
569
+
570
+ function formatCompactTimestamp(value: string): string {
571
+ const timestamp = Date.parse(value)
572
+ if (!Number.isFinite(timestamp)) return ''
573
+ const date = new Date(timestamp)
574
+ const now = new Date()
575
+ const elapsed = Math.max(0, now.getTime() - timestamp)
576
+ if (elapsed < 60_000) return '刚刚'
577
+ if (elapsed < 60 * 60_000) return `${Math.floor(elapsed / 60_000)} 分钟前`
578
+ if (elapsed < 6 * 60 * 60_000 && date.toDateString() === now.toDateString()) {
579
+ return `${Math.floor(elapsed / (60 * 60_000))} 小时前`
580
+ }
581
+ const hm = `${pad(date.getHours())}:${pad(date.getMinutes())}`
582
+ if (date.toDateString() === now.toDateString()) return `今天 ${hm}`
583
+ const yesterday = new Date(now)
584
+ yesterday.setDate(now.getDate() - 1)
585
+ if (date.toDateString() === yesterday.toDateString()) return `昨天 ${hm}`
586
+ const md = `${date.getMonth() + 1}月${date.getDate()}日`
587
+ return date.getFullYear() === now.getFullYear() ? md : `${date.getFullYear()}年${md}`
588
+ }
589
+
590
+ function safeIconUrl(value: string): string | null {
591
+ try {
592
+ const url = new URL(value)
593
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) {
594
+ return null
595
+ }
596
+ for (const key of url.searchParams.keys()) {
597
+ if (/token|ticket|jwt|auth|signature|credential|secret/i.test(key)) return null
598
+ }
599
+ return url.href
600
+ } catch {
601
+ return null
602
+ }
603
+ }
604
+
605
+ function isSafeInlineSvg(value: string): boolean {
606
+ if (!/^<svg[\s>]/i.test(value)) return false
607
+ if (/<script|<foreignobject|<image|<use/i.test(value)) return false
608
+ if (/\son\w+\s*=/i.test(value)) return false
609
+ if (/javascript:|\b(?:xlink:)?href\s*=|\bstyle\s*=|url\s*\(/i.test(value)) return false
610
+ return true
611
+ }
612
+
613
+ function pad(value: number): string {
614
+ return value < 10 ? `0${value}` : String(value)
615
+ }