@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,1066 @@
1
+ import {
2
+ NotificationCenterClient,
3
+ type NotificationCenterClientOptions,
4
+ type NotificationCenterErrorDetail,
5
+ type NotificationDto,
6
+ type NotificationTypeSummary,
7
+ } from '@uniplat/ai-employee-client'
8
+ import { LitElement, css, html, nothing, type TemplateResult } from 'lit'
9
+ import { unsafeSVG } from 'lit/directives/unsafe-svg.js'
10
+ import { renderSafeMarkdown } from './safe-markdown'
11
+
12
+ export type NotificationCenterTypeIconMap = Readonly<Record<string, string>>
13
+
14
+ export interface NotificationCenterConfig extends NotificationCenterClientOptions {
15
+ typeIcons?: NotificationCenterTypeIconMap
16
+ }
17
+
18
+ export interface NotificationResourceRequestedDetail {
19
+ resourceType: string
20
+ id: string
21
+ aeCode?: string
22
+ sessionId?: string
23
+ }
24
+
25
+ export interface NotificationCenterEventMap {
26
+ ready: CustomEvent<Record<string, never>>
27
+ 'unread-count-change': CustomEvent<{ unreadCount: number }>
28
+ 'notification-read': CustomEvent<{ notificationId: string }>
29
+ 'notification-resource-requested': CustomEvent<NotificationResourceRequestedDetail>
30
+ error: CustomEvent<NotificationCenterErrorDetail>
31
+ }
32
+
33
+ /*
34
+ * 只保留原型 .notif-icon-wrap 的图标与色底;分类展示名一律来自后端
35
+ * notification_type_name,SDK 不维护 code 到中文名的映射。
36
+ */
37
+ interface NotificationTypeMeta {
38
+ icon: string
39
+ tone: 'alert' | 'info' | 'report'
40
+ }
41
+
42
+ const TYPE_META: Readonly<Record<string, NotificationTypeMeta>> = {
43
+ alert: { icon: '⚠️', tone: 'alert' },
44
+ todo: { icon: '📑', tone: 'info' },
45
+ report: { icon: '📊', tone: 'report' },
46
+ care: { icon: '🎂', tone: 'info' },
47
+ }
48
+ const DEFAULT_TYPE_META: NotificationTypeMeta = { icon: '🔔', tone: 'info' }
49
+ const ALL_TAB_LABEL = '全部'
50
+
51
+ export class UniplatNotificationCenter extends LitElement {
52
+ static override properties = {
53
+ items: { state: true },
54
+ selectedId: { state: true },
55
+ activeTab: { state: true },
56
+ availableTypes: { state: true },
57
+ unreadCount: { state: true },
58
+ hasMore: { state: true },
59
+ loading: { state: true },
60
+ loadingMore: { state: true },
61
+ markingAllRead: { state: true },
62
+ visibleError: { state: true },
63
+ }
64
+
65
+ static override styles = css`
66
+ :host {
67
+ --uniplat-notification-primary: #3d7eff;
68
+ --uniplat-notification-background: #fff;
69
+ --uniplat-notification-surface: #f7f9fc;
70
+ --uniplat-notification-text: #1a202c;
71
+ --uniplat-notification-muted: #94a3b8;
72
+ --uniplat-notification-border: #e2e8f0;
73
+ --uniplat-notification-alert: #ef4444;
74
+ --uniplat-notification-warning: #f59e0b;
75
+ --uniplat-notification-success: #10b981;
76
+ --uniplat-notification-font-family:
77
+ 'Noto Sans SC', Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
78
+ 'Segoe UI', sans-serif;
79
+ display: block;
80
+ height: 100%;
81
+ color: var(--uniplat-notification-text);
82
+ font-family: var(--uniplat-notification-font-family);
83
+ font-size: 13px;
84
+ }
85
+ * {
86
+ box-sizing: border-box;
87
+ }
88
+ .nc-panel {
89
+ display: flex;
90
+ height: 100%;
91
+ min-height: 0;
92
+ flex-direction: column;
93
+ background: var(--uniplat-notification-background);
94
+ }
95
+ /* 原型 .page-bar:42px 高、13px 标题 */
96
+ .topbar {
97
+ display: flex;
98
+ flex: 0 0 auto;
99
+ align-items: center;
100
+ gap: 8px;
101
+ height: 42px;
102
+ padding: 0 20px;
103
+ border-bottom: 1px solid var(--uniplat-notification-border);
104
+ background: var(--uniplat-notification-background);
105
+ }
106
+ .topbar-title {
107
+ display: inline-flex;
108
+ flex: 1;
109
+ align-items: center;
110
+ gap: 6px;
111
+ font-size: 13px;
112
+ font-weight: 600;
113
+ }
114
+ button {
115
+ font: inherit;
116
+ }
117
+ /* 原型 .btn-outline(顶栏内覆写为 30px / 11px) */
118
+ .mark-all,
119
+ .load-more,
120
+ .mark-read {
121
+ display: inline-flex;
122
+ align-items: center;
123
+ justify-content: center;
124
+ gap: 5px;
125
+ border: 1px solid var(--uniplat-notification-border);
126
+ border-radius: 7px;
127
+ background: var(--uniplat-notification-background);
128
+ color: #4a5568;
129
+ cursor: pointer;
130
+ transition: all 0.15s;
131
+ }
132
+ .mark-all:hover:not(:disabled),
133
+ .load-more:hover:not(:disabled),
134
+ .mark-read:hover:not(:disabled) {
135
+ border-color: var(--uniplat-notification-primary);
136
+ color: var(--uniplat-notification-primary);
137
+ }
138
+ .mark-all {
139
+ flex: 0 0 auto;
140
+ height: 30px;
141
+ padding: 0 14px;
142
+ font-size: 11px;
143
+ }
144
+ .mark-all:disabled {
145
+ cursor: default;
146
+ opacity: 0.5;
147
+ }
148
+ /* 原型 .inbox-layout / .inbox-list / .inbox-detail */
149
+ .body {
150
+ display: flex;
151
+ min-height: 0;
152
+ flex: 1;
153
+ overflow: hidden;
154
+ }
155
+ .list-pane {
156
+ display: flex;
157
+ width: 340px;
158
+ min-width: 0;
159
+ flex: 0 0 340px;
160
+ flex-direction: column;
161
+ overflow: hidden;
162
+ border-right: 1px solid var(--uniplat-notification-border);
163
+ background: var(--uniplat-notification-background);
164
+ }
165
+ .list-head {
166
+ display: flex;
167
+ align-items: center;
168
+ justify-content: space-between;
169
+ padding: 16px;
170
+ border-bottom: 1px solid var(--uniplat-notification-border);
171
+ }
172
+ .list-title {
173
+ font-size: 14px;
174
+ font-weight: 600;
175
+ }
176
+ .list-count {
177
+ color: var(--uniplat-notification-primary);
178
+ font-size: 11px;
179
+ }
180
+ /*
181
+ * 原型 .inbox-filters 是隐藏滚动条的单行横向滚动,那是为固定 5 个页签设计的。
182
+ * 分类改为数据驱动后页签数量不再可控,单行滚动会把新分类裁到容器外且无滚动条
183
+ * 提示,故改为换行,其余尺寸与配色仍沿用原型。
184
+ */
185
+ .tabs {
186
+ display: flex;
187
+ gap: 6px;
188
+ padding: 10px 16px;
189
+ flex-wrap: wrap;
190
+ border-bottom: 1px solid var(--uniplat-notification-border);
191
+ }
192
+ .tab {
193
+ flex: 0 0 auto;
194
+ padding: 3px 8px;
195
+ border: 0;
196
+ border-radius: 5px;
197
+ background: transparent;
198
+ color: #4a5568;
199
+ font-size: 11px;
200
+ cursor: pointer;
201
+ transition: all 0.15s;
202
+ }
203
+ .tab:hover:not(.active) {
204
+ background: #eef2f7;
205
+ }
206
+ .tab.active {
207
+ background: var(--uniplat-notification-primary);
208
+ color: #fff;
209
+ }
210
+ .list {
211
+ min-height: 0;
212
+ flex: 1;
213
+ overflow-y: auto;
214
+ }
215
+ .list::-webkit-scrollbar {
216
+ width: 3px;
217
+ }
218
+ .list::-webkit-scrollbar-thumb {
219
+ border-radius: 2px;
220
+ background: var(--uniplat-notification-border);
221
+ }
222
+ .empty {
223
+ padding: 28px 16px;
224
+ color: var(--uniplat-notification-muted);
225
+ font-size: 11px;
226
+ text-align: center;
227
+ }
228
+ .notice {
229
+ margin: 10px 16px;
230
+ padding: 8px 10px;
231
+ border: 1px solid rgb(239 68 68 / 25%);
232
+ border-radius: 8px;
233
+ background: rgb(239 68 68 / 6%);
234
+ color: var(--uniplat-notification-alert);
235
+ font-size: 11px;
236
+ }
237
+ /* 原型 .inbox-item:整行分隔线,非浮动圆角卡片 */
238
+ .nc-item {
239
+ display: flex;
240
+ width: 100%;
241
+ flex-direction: column;
242
+ gap: 0;
243
+ padding: 12px 16px;
244
+ border: 0;
245
+ border-bottom: 1px solid #f0f4f9;
246
+ border-left: 2px solid transparent;
247
+ background: transparent;
248
+ color: inherit;
249
+ text-align: left;
250
+ cursor: pointer;
251
+ transition: background 0.15s;
252
+ }
253
+ .nc-item:hover {
254
+ background: var(--uniplat-notification-surface);
255
+ }
256
+ .nc-item.selected {
257
+ border-left-color: var(--uniplat-notification-primary);
258
+ background: rgb(61 126 255 / 12%);
259
+ }
260
+ .nc-item-head {
261
+ display: flex;
262
+ align-items: center;
263
+ gap: 6px;
264
+ margin-bottom: 4px;
265
+ }
266
+ .nc-item-title {
267
+ overflow: hidden;
268
+ flex: 1;
269
+ font-size: 12px;
270
+ font-weight: 600;
271
+ text-overflow: ellipsis;
272
+ white-space: nowrap;
273
+ }
274
+ .nc-item-time {
275
+ flex: 0 0 auto;
276
+ color: var(--uniplat-notification-muted);
277
+ font-size: 10px;
278
+ }
279
+ .unread-dot {
280
+ display: inline-block;
281
+ width: 6px;
282
+ height: 6px;
283
+ flex: 0 0 auto;
284
+ border-radius: 50%;
285
+ background: var(--uniplat-notification-primary);
286
+ }
287
+ .type-emoji {
288
+ flex: 0 0 auto;
289
+ font-size: 12px;
290
+ line-height: 1;
291
+ }
292
+ .type-glyph {
293
+ display: inline-flex;
294
+ flex: 0 0 auto;
295
+ }
296
+ .type-glyph svg,
297
+ .type-glyph img {
298
+ width: 14px;
299
+ height: 14px;
300
+ }
301
+ .nc-item-sub {
302
+ margin-bottom: 3px;
303
+ color: #4a5568;
304
+ font-size: 11px;
305
+ }
306
+ .nc-item-summary {
307
+ overflow: hidden;
308
+ color: var(--uniplat-notification-muted);
309
+ font-size: 11px;
310
+ text-overflow: ellipsis;
311
+ white-space: nowrap;
312
+ }
313
+ .load-more {
314
+ width: calc(100% - 32px);
315
+ height: 30px;
316
+ margin: 10px 16px;
317
+ font-size: 11px;
318
+ }
319
+ .detail-pane {
320
+ min-width: 0;
321
+ flex: 1;
322
+ overflow-y: auto;
323
+ padding: 24px;
324
+ background: var(--uniplat-notification-surface);
325
+ }
326
+ .detail-pane::-webkit-scrollbar {
327
+ width: 4px;
328
+ }
329
+ .detail-pane::-webkit-scrollbar-thumb {
330
+ border-radius: 2px;
331
+ background: var(--uniplat-notification-border);
332
+ }
333
+ .detail-empty {
334
+ display: grid;
335
+ height: 100%;
336
+ place-items: center;
337
+ color: var(--uniplat-notification-muted);
338
+ font-size: 12px;
339
+ }
340
+ /* 原型 .detail-card:详情整体是一张白卡 */
341
+ .detail-card {
342
+ margin-bottom: 14px;
343
+ padding: 20px;
344
+ border: 1px solid var(--uniplat-notification-border);
345
+ border-radius: 10px;
346
+ background: var(--uniplat-notification-background);
347
+ }
348
+ .detail-head {
349
+ display: flex;
350
+ align-items: flex-start;
351
+ justify-content: space-between;
352
+ margin-bottom: 14px;
353
+ gap: 12px;
354
+ }
355
+ .detail-head-main {
356
+ display: flex;
357
+ min-width: 0;
358
+ align-items: center;
359
+ gap: 12px;
360
+ }
361
+ /* 原型 .notif-icon-wrap:34px / 9px 圆角 / 类型色底 */
362
+ .detail-badge {
363
+ display: grid;
364
+ width: 34px;
365
+ height: 34px;
366
+ flex: 0 0 auto;
367
+ place-items: center;
368
+ border-radius: 9px;
369
+ font-size: 16px;
370
+ }
371
+ .detail-badge.alert {
372
+ background: rgb(245 158 11 / 10%);
373
+ }
374
+ .detail-badge.info {
375
+ background: rgb(61 126 255 / 10%);
376
+ }
377
+ .detail-badge.report {
378
+ background: rgb(99 102 241 / 10%);
379
+ }
380
+ .detail-badge .type-emoji {
381
+ font-size: 16px;
382
+ }
383
+ .detail-badge .type-glyph svg,
384
+ .detail-badge .type-glyph img {
385
+ width: 18px;
386
+ height: 18px;
387
+ }
388
+ .detail-heading {
389
+ min-width: 0;
390
+ flex: 1;
391
+ }
392
+ .detail-title {
393
+ font-size: 15px;
394
+ font-weight: 700;
395
+ }
396
+ .detail-meta {
397
+ margin-top: 3px;
398
+ color: var(--uniplat-notification-muted);
399
+ font-size: 12px;
400
+ }
401
+ /* 原型 .order-status.pending / .done */
402
+ .read-badge {
403
+ flex: 0 0 auto;
404
+ padding: 2px 7px;
405
+ border-radius: 10px;
406
+ font-size: 10px;
407
+ font-weight: 600;
408
+ white-space: nowrap;
409
+ }
410
+ .read-badge.unread {
411
+ background: rgb(245 158 11 / 10%);
412
+ color: var(--uniplat-notification-warning);
413
+ }
414
+ .read-badge.read {
415
+ background: rgb(16 185 129 / 10%);
416
+ color: var(--uniplat-notification-success);
417
+ }
418
+ /* 原型 .detail-block-title */
419
+ .section-title {
420
+ display: flex;
421
+ align-items: center;
422
+ gap: 6px;
423
+ margin: 18px 0 10px;
424
+ font-size: 12px;
425
+ font-weight: 600;
426
+ }
427
+ .info-card,
428
+ .content-card,
429
+ .resource-card,
430
+ .attachment-card {
431
+ border: 1px solid var(--uniplat-notification-border);
432
+ border-radius: 8px;
433
+ background: var(--uniplat-notification-background);
434
+ }
435
+ /* 原型 .msg-card:紧凑两列,无行分隔线 */
436
+ .info-card {
437
+ margin-top: 6px;
438
+ padding: 10px 12px;
439
+ font-size: 12px;
440
+ }
441
+ .info-row {
442
+ display: flex;
443
+ justify-content: space-between;
444
+ padding: 3px 0;
445
+ gap: 12px;
446
+ }
447
+ .info-key {
448
+ color: var(--uniplat-notification-muted);
449
+ }
450
+ .info-value {
451
+ font-weight: 500;
452
+ text-align: right;
453
+ }
454
+ /* 原型 .notif-fulltext */
455
+ .content-card {
456
+ padding: 14px 16px;
457
+ color: #4a5568;
458
+ font-size: 13px;
459
+ line-height: 1.85;
460
+ }
461
+ .content-card.plain {
462
+ white-space: pre-wrap;
463
+ overflow-wrap: anywhere;
464
+ }
465
+ .content-card .md-p {
466
+ margin: 0 0 10px;
467
+ }
468
+ .content-card .md-p:last-child {
469
+ margin-bottom: 0;
470
+ }
471
+ .content-card .md-code {
472
+ padding: 1px 5px;
473
+ border-radius: 4px;
474
+ background: #f1f5f9;
475
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
476
+ font-size: 12px;
477
+ }
478
+ .content-card .md-pre {
479
+ overflow: auto;
480
+ padding: 12px 14px;
481
+ border-radius: 8px;
482
+ background: #0f172a0d;
483
+ }
484
+ .content-card a {
485
+ color: var(--uniplat-notification-primary);
486
+ }
487
+ /* 原型 .order-item + .order-status.processing */
488
+ .resource-card {
489
+ display: flex;
490
+ align-items: center;
491
+ width: 100%;
492
+ margin-bottom: 8px;
493
+ padding: 12px 14px;
494
+ gap: 8px;
495
+ color: inherit;
496
+ text-align: left;
497
+ cursor: pointer;
498
+ transition: border-color 0.15s;
499
+ }
500
+ .resource-card:hover {
501
+ border-color: var(--uniplat-notification-primary);
502
+ }
503
+ .resource-main {
504
+ display: flex;
505
+ min-width: 0;
506
+ flex: 1;
507
+ align-items: center;
508
+ gap: 8px;
509
+ }
510
+ .resource-type {
511
+ flex: 0 0 auto;
512
+ color: var(--uniplat-notification-muted);
513
+ font-size: 11px;
514
+ }
515
+ .resource-title {
516
+ overflow: hidden;
517
+ font-size: 12px;
518
+ font-weight: 600;
519
+ text-overflow: ellipsis;
520
+ white-space: nowrap;
521
+ }
522
+ .resource-status {
523
+ flex: 0 0 auto;
524
+ padding: 2px 7px;
525
+ border-radius: 10px;
526
+ background: rgb(61 126 255 / 10%);
527
+ color: var(--uniplat-notification-primary);
528
+ font-size: 10px;
529
+ font-weight: 600;
530
+ }
531
+ /* 原型 .file-item / .file-icon / .file-dl */
532
+ .attachment-card {
533
+ display: flex;
534
+ align-items: center;
535
+ margin-bottom: 8px;
536
+ padding: 10px 12px;
537
+ gap: 10px;
538
+ transition: border-color 0.15s;
539
+ }
540
+ .attachment-card:hover {
541
+ border-color: var(--uniplat-notification-primary);
542
+ }
543
+ .attachment-icon {
544
+ display: grid;
545
+ width: 32px;
546
+ height: 32px;
547
+ flex: 0 0 auto;
548
+ place-items: center;
549
+ border-radius: 8px;
550
+ background: #eef2f7;
551
+ font-size: 15px;
552
+ }
553
+ .attachment-main {
554
+ display: flex;
555
+ min-width: 0;
556
+ flex: 1;
557
+ flex-direction: column;
558
+ }
559
+ .attachment-name {
560
+ overflow: hidden;
561
+ font-size: 12px;
562
+ font-weight: 500;
563
+ text-overflow: ellipsis;
564
+ white-space: nowrap;
565
+ }
566
+ .attachment-meta {
567
+ margin-top: 2px;
568
+ color: var(--uniplat-notification-muted);
569
+ font-size: 10px;
570
+ }
571
+ .attachment-download {
572
+ flex: 0 0 auto;
573
+ margin-left: auto;
574
+ border: 0;
575
+ background: transparent;
576
+ color: var(--uniplat-notification-primary);
577
+ font-size: 11px;
578
+ cursor: pointer;
579
+ }
580
+ .attachment-download:disabled {
581
+ cursor: default;
582
+ opacity: 0.5;
583
+ }
584
+ /* 原型 .action-row + .btn-outline */
585
+ .action-row {
586
+ display: flex;
587
+ margin-top: 14px;
588
+ gap: 8px;
589
+ }
590
+ .mark-read {
591
+ height: 34px;
592
+ padding: 0 14px;
593
+ font-size: 12px;
594
+ }
595
+ `
596
+
597
+ declare private items: NotificationDto[]
598
+ declare private selectedId: string | null
599
+ declare private activeTab: string
600
+ declare private availableTypes: NotificationTypeSummary[]
601
+ declare private unreadCount: number
602
+ declare private hasMore: boolean
603
+ declare private loading: boolean
604
+ declare private loadingMore: boolean
605
+ declare private markingAllRead: boolean
606
+ declare private visibleError: string
607
+
608
+ private client: NotificationCenterClient | null = null
609
+ private config: NotificationCenterConfig | null = null
610
+ private disposeListeners: Array<() => void> = []
611
+
612
+ constructor() {
613
+ super()
614
+ this.items = []
615
+ this.selectedId = null
616
+ this.activeTab = ''
617
+ this.availableTypes = []
618
+ this.unreadCount = 0
619
+ this.hasMore = false
620
+ this.loading = false
621
+ this.loadingMore = false
622
+ this.markingAllRead = false
623
+ this.visibleError = ''
624
+ }
625
+
626
+ configure(config: NotificationCenterConfig): void {
627
+ this.releaseClient()
628
+ this.config = config
629
+ this.items = []
630
+ this.selectedId = null
631
+ this.activeTab = ''
632
+ this.availableTypes = []
633
+ this.unreadCount = 0
634
+ this.visibleError = ''
635
+ if (this.isConnected) void this.startClient()
636
+ }
637
+
638
+ async refresh(): Promise<void> {
639
+ if (!this.client) return
640
+ await this.client.refresh()
641
+ }
642
+
643
+ /**
644
+ * Feed a notification realtime event captured by the host socket.
645
+ *
646
+ * The component owns no WebSocket: the host keeps its single
647
+ * `/api/user-realtime/ws` connection and forwards the `notification` channel
648
+ * event payload here. Calls before `configure()` or after removal are ignored;
649
+ * the next inbox load carries the server truth.
650
+ */
651
+ applyRealtimeEvent(event: unknown): void {
652
+ this.client?.applyRealtimeEvent(event)
653
+ }
654
+
655
+ getUnreadCount(): number {
656
+ return this.unreadCount
657
+ }
658
+
659
+ override connectedCallback(): void {
660
+ super.connectedCallback()
661
+ if (this.config && !this.client) void this.startClient()
662
+ }
663
+
664
+ override disconnectedCallback(): void {
665
+ this.releaseClient()
666
+ super.disconnectedCallback()
667
+ }
668
+
669
+ override render() {
670
+ const total = this.items.length
671
+ return html`
672
+ <section class="nc-panel" part="panel" aria-label="通知中心">
673
+ <header class="topbar" part="topbar">
674
+ <span class="topbar-title"><span aria-hidden="true">🔔</span> 通知中心</span>
675
+ <button
676
+ class="mark-all"
677
+ ?disabled=${this.unreadCount <= 0 || this.markingAllRead}
678
+ @click=${this.markAllRead}
679
+ >
680
+ 全部标已读
681
+ </button>
682
+ </header>
683
+ <div class="body">
684
+ <aside class="list-pane" part="list">
685
+ <div class="list-head">
686
+ <span class="list-title">通知</span>
687
+ <span class="list-count">${total} 条 · ${this.unreadCount} 未读</span>
688
+ </div>
689
+ <div class="tabs">
690
+ ${[{ type: '', name: ALL_TAB_LABEL }, ...this.availableTypes].map(
691
+ (entry) => html`
692
+ <button
693
+ class="tab ${entry.type === this.activeTab ? 'active' : ''}"
694
+ @click=${() => this.selectTab(entry.type)}
695
+ >
696
+ ${entry.name || entry.type}
697
+ </button>
698
+ `,
699
+ )}
700
+ </div>
701
+ <div class="list">
702
+ ${this.visibleError ? html`<div class="notice">${this.visibleError}</div>` : nothing}
703
+ ${
704
+ this.loading
705
+ ? html`<div class="empty">通知加载中…</div>`
706
+ : this.items.length
707
+ ? html`
708
+ ${this.items.map((item) => this.renderItem(item))}
709
+ ${
710
+ this.hasMore
711
+ ? html`<button class="load-more" @click=${this.loadMore}>
712
+ ${this.loadingMore ? '加载中…' : '加载更多'}
713
+ </button>`
714
+ : nothing
715
+ }
716
+ `
717
+ : html`<div class="empty">该分类暂无通知</div>`
718
+ }
719
+ </div>
720
+ </aside>
721
+ <section class="detail-pane" part="detail">${this.renderDetail()}</section>
722
+ </div>
723
+ </section>
724
+ `
725
+ }
726
+
727
+ private renderItem(item: NotificationDto) {
728
+ const label = typeLabel(item)
729
+ return html`
730
+ <button
731
+ class="nc-item ${item.id === this.selectedId ? 'selected' : ''}"
732
+ @click=${() => this.selectNotification(item)}
733
+ >
734
+ <span class="nc-item-head">
735
+ ${item.readStatus === 'unread' ? html`<span class="unread-dot" aria-label="未读"></span>` : nothing}
736
+ ${this.renderTypeIcon(item.type)}
737
+ <span class="nc-item-title">${item.title}</span>
738
+ <span class="nc-item-time">${formatTimestamp(item.createdAt)}</span>
739
+ </span>
740
+ <span class="nc-item-sub">${this.sourceName(item)} · ${label}</span>
741
+ <span class="nc-item-summary">${item.summary}</span>
742
+ </button>
743
+ `
744
+ }
745
+
746
+ private renderDetail() {
747
+ const selected = this.items.find((item) => item.id === this.selectedId)
748
+ if (!selected) {
749
+ return html`<div class="detail-empty">选择左侧一条通知查看详情</div>`
750
+ }
751
+ const meta = this.typeMeta(selected.type)
752
+ return html`
753
+ <article class="detail-card">
754
+ <header class="detail-head">
755
+ <div class="detail-head-main">
756
+ <span class="detail-badge ${meta.tone}">${this.renderTypeIcon(selected.type)}</span>
757
+ <div class="detail-heading">
758
+ <div class="detail-title">${selected.title}</div>
759
+ <div class="detail-meta">
760
+ ${this.sourceName(selected)} · ${formatTimestamp(selected.createdAt)}
761
+ </div>
762
+ </div>
763
+ </div>
764
+ <span class="read-badge ${selected.readStatus}">
765
+ ${selected.readStatus === 'read' ? '已读' : '未读'}
766
+ </span>
767
+ </header>
768
+ <div class="section-title"><span aria-hidden="true">📋</span> 基本信息</div>
769
+ <div class="info-card">
770
+ <div class="info-row">
771
+ <span class="info-key">通知类型</span
772
+ ><span class="info-value">${typeLabel(selected)}</span>
773
+ </div>
774
+ <div class="info-row">
775
+ <span class="info-key">通知来源</span
776
+ ><span class="info-value">${this.sourceName(selected)}</span>
777
+ </div>
778
+ <div class="info-row">
779
+ <span class="info-key">推送时间</span
780
+ ><span class="info-value">${formatTimestamp(selected.createdAt)}</span>
781
+ </div>
782
+ <div class="info-row">
783
+ <span class="info-key">状态</span
784
+ ><span class="info-value">${selected.readStatus === 'read' ? '已读' : '未读'}</span>
785
+ </div>
786
+ </div>
787
+ <div class="section-title"><span aria-hidden="true">📄</span> 通知内容</div>
788
+ <div class="content-card ${selected.contentType === 'markdown' ? '' : 'plain'}">
789
+ ${selected.contentType === 'markdown' ? renderSafeMarkdown(selected.content) : selected.content}
790
+ </div>
791
+ ${
792
+ selected.resources.length
793
+ ? html`
794
+ <div class="section-title"><span aria-hidden="true">🔗</span> 关联资源</div>
795
+ ${selected.resources.map(
796
+ (resource) => html`
797
+ <button class="resource-card" @click=${() => this.openResource(resource)}>
798
+ <span class="resource-main">
799
+ <span class="resource-type">${resourceTypeLabel(resource.type)}</span>
800
+ <span class="resource-title">${resource.title || resource.id}</span>
801
+ </span>
802
+ <span class="resource-status">查看 →</span>
803
+ </button>
804
+ `,
805
+ )}
806
+ `
807
+ : nothing
808
+ }
809
+ ${
810
+ selected.attachments.length
811
+ ? html`
812
+ <div class="section-title"><span aria-hidden="true">📎</span> 关联文件</div>
813
+ ${selected.attachments.map(
814
+ (attachment) => html`
815
+ <div class="attachment-card">
816
+ <span class="attachment-icon" aria-hidden="true"
817
+ >${attachmentIcon(attachment.mimeType, attachment.name)}</span
818
+ >
819
+ <span class="attachment-main">
820
+ <span class="attachment-name">${attachment.name || attachment.fileId}</span>
821
+ <span class="attachment-meta">
822
+ ${[attachment.mimeType, formatSize(attachment.sizeBytes)]
823
+ .filter(Boolean)
824
+ .join(' · ')}
825
+ </span>
826
+ </span>
827
+ <button
828
+ class="attachment-download"
829
+ ?disabled=${!attachment.fileId}
830
+ @click=${() => this.downloadAttachment(attachment)}
831
+ >
832
+ 下载
833
+ </button>
834
+ </div>
835
+ `,
836
+ )}
837
+ `
838
+ : nothing
839
+ }
840
+ ${
841
+ selected.readStatus === 'unread'
842
+ ? html`<div class="action-row">
843
+ <button class="mark-read" @click=${() => this.markSelectedRead(selected.id)}>
844
+ 标为已读
845
+ </button>
846
+ </div>`
847
+ : nothing
848
+ }
849
+ </article>
850
+ `
851
+ }
852
+
853
+ private renderTypeIcon(type: string): TemplateResult {
854
+ const custom = this.config?.typeIcons?.[type]
855
+ if (custom) {
856
+ const value = custom.trim()
857
+ if (isHttpUrl(value)) {
858
+ return html`<span class="type-glyph"
859
+ ><img src=${value} alt="" width="16" height="16"
860
+ /></span>`
861
+ }
862
+ if (isSafeInlineSvg(value)) {
863
+ return html`<span class="type-glyph">${unsafeSVG(value)}</span>`
864
+ }
865
+ return html`<span class="type-emoji">${value}</span>`
866
+ }
867
+ return html`<span class="type-emoji">${this.typeMeta(type).icon}</span>`
868
+ }
869
+
870
+ private typeMeta(type: string): NotificationTypeMeta {
871
+ return TYPE_META[type] ?? DEFAULT_TYPE_META
872
+ }
873
+
874
+ private sourceName(item: NotificationDto): string {
875
+ return item.source?.name || item.source?.code || '系统通知'
876
+ }
877
+
878
+ private async startClient(): Promise<void> {
879
+ if (!this.config) return
880
+ this.loading = true
881
+ this.visibleError = ''
882
+ try {
883
+ const client = new NotificationCenterClient(this.config)
884
+ this.client = client
885
+ this.disposeListeners = [
886
+ client.on('ready', () => this.dispatchSafe('ready', {})),
887
+ client.on('inbox', ({ items, hasMore }) => {
888
+ this.items = [...items]
889
+ this.hasMore = hasMore
890
+ // A successful projection clears any transient failure banner.
891
+ this.visibleError = ''
892
+ if (this.selectedId && !this.items.some((item) => item.id === this.selectedId)) {
893
+ this.selectedId = null
894
+ }
895
+ }),
896
+ client.on('types-change', ({ types }) => {
897
+ this.availableTypes = [...types]
898
+ // 当前筛选的分类若已不在目录中,回到「全部」,避免停在空列表上。
899
+ if (this.activeTab && !this.availableTypes.some((e) => e.type === this.activeTab)) {
900
+ this.selectTab('')
901
+ }
902
+ }),
903
+ client.on('unread-count-change', ({ unreadCount }) => {
904
+ this.unreadCount = unreadCount
905
+ this.dispatchSafe('unread-count-change', { unreadCount })
906
+ }),
907
+ client.on('notification-read', (detail) => this.dispatchSafe('notification-read', detail)),
908
+ client.on('error', (detail) => this.showError(detail)),
909
+ ]
910
+ await client.start()
911
+ } catch {
912
+ this.showError({
913
+ code: 'INVALID_CONFIGURATION',
914
+ message: '通知中心配置不可用。',
915
+ recoverable: false,
916
+ })
917
+ } finally {
918
+ this.loading = false
919
+ }
920
+ }
921
+
922
+ private selectTab(key: string): void {
923
+ if (key === this.activeTab || !this.client) return
924
+ this.activeTab = key
925
+ this.selectedId = null
926
+ void this.client.setTypeFilter(key ? [key] : [])
927
+ }
928
+
929
+ private selectNotification(item: NotificationDto): void {
930
+ this.selectedId = item.id
931
+ if (item.readStatus === 'unread') void this.client?.markRead(item.id)
932
+ }
933
+
934
+ private markSelectedRead(notificationId: string): void {
935
+ void this.client?.markRead(notificationId)
936
+ }
937
+
938
+ private loadMore = (): void => {
939
+ if (!this.client || this.loadingMore) return
940
+ this.loadingMore = true
941
+ void this.client.loadMore().finally(() => {
942
+ this.loadingMore = false
943
+ })
944
+ }
945
+
946
+ private markAllRead = async (): Promise<void> => {
947
+ if (!this.client || this.markingAllRead) return
948
+ this.markingAllRead = true
949
+ try {
950
+ await this.client.markAllRead()
951
+ } finally {
952
+ this.markingAllRead = false
953
+ }
954
+ }
955
+
956
+ private openResource(resource: NotificationDto['resources'][number]): void {
957
+ const detail: NotificationResourceRequestedDetail = {
958
+ resourceType: resource.type,
959
+ id: resource.id,
960
+ }
961
+ if (resource.aeCode) detail.aeCode = resource.aeCode
962
+ if (resource.type === 'session') detail.sessionId = resource.id
963
+ this.dispatchSafe('notification-resource-requested', detail)
964
+ }
965
+
966
+ private async downloadAttachment(
967
+ attachment: NotificationDto['attachments'][number],
968
+ ): Promise<void> {
969
+ if (!this.client || !attachment.fileId) return
970
+ try {
971
+ const blob = await this.client.downloadAttachment(attachment.fileId, attachment.name)
972
+ saveBlob(blob, attachment.name || 'download')
973
+ } catch {
974
+ this.visibleError = '附件下载失败,请稍后重试。'
975
+ }
976
+ }
977
+
978
+ private showError(detail: NotificationCenterErrorDetail): void {
979
+ this.visibleError = detail.message
980
+ this.dispatchSafe('error', detail)
981
+ }
982
+
983
+ private releaseClient(): void {
984
+ for (const dispose of this.disposeListeners) dispose()
985
+ this.disposeListeners = []
986
+ this.client?.destroy()
987
+ this.client = null
988
+ }
989
+
990
+ private dispatchSafe(type: string, detail: unknown): void {
991
+ this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }))
992
+ }
993
+ }
994
+
995
+ /** 分类展示名优先用后端 notification_type_name,缺省回退到原始 code。 */
996
+ function typeLabel(item: NotificationDto): string {
997
+ return item.typeName.trim() || item.type.trim()
998
+ }
999
+
1000
+ function resourceTypeLabel(type: string): string {
1001
+ if (type === 'service_order') return '关联服务单'
1002
+ if (type === 'ai_employee') return '关联数字员工'
1003
+ if (type === 'session') return '关联会话'
1004
+ if (type === 'file') return '关联文件'
1005
+ return '关联资源'
1006
+ }
1007
+
1008
+ function isHttpUrl(value: string): boolean {
1009
+ return /^https?:\/\//i.test(value)
1010
+ }
1011
+
1012
+ function isSafeInlineSvg(value: string): boolean {
1013
+ if (!/^<svg[\s>]/i.test(value)) return false
1014
+ if (/<script|<foreignobject/i.test(value)) return false
1015
+ if (/\son\w+\s*=/i.test(value)) return false
1016
+ if (/javascript:/i.test(value)) return false
1017
+ return true
1018
+ }
1019
+
1020
+ function saveBlob(blob: Blob, filename: string): void {
1021
+ if (typeof document === 'undefined' || typeof URL.createObjectURL !== 'function') return
1022
+ const url = URL.createObjectURL(blob)
1023
+ const anchor = document.createElement('a')
1024
+ anchor.href = url
1025
+ anchor.download = filename
1026
+ document.body.append(anchor)
1027
+ anchor.click()
1028
+ anchor.remove()
1029
+ setTimeout(() => URL.revokeObjectURL(url), 0)
1030
+ }
1031
+
1032
+ function formatTimestamp(value: string): string {
1033
+ const timestamp = Date.parse(value)
1034
+ if (!Number.isFinite(timestamp)) return ''
1035
+ const date = new Date(timestamp)
1036
+ const now = new Date()
1037
+ const hm = `${pad(date.getHours())}:${pad(date.getMinutes())}`
1038
+ if (date.toDateString() === now.toDateString()) return `今天 ${hm}`
1039
+ const yesterday = new Date(now)
1040
+ yesterday.setDate(now.getDate() - 1)
1041
+ if (date.toDateString() === yesterday.toDateString()) return `昨天 ${hm}`
1042
+ // 原型只使用「今天 / 昨天 / M月D日」三种相对形式,跨年时补上年份。
1043
+ const md = `${date.getMonth() + 1}月${date.getDate()}日`
1044
+ if (date.getFullYear() === now.getFullYear()) return `${md} ${hm}`
1045
+ return `${date.getFullYear()}年${md} ${hm}`
1046
+ }
1047
+
1048
+ // 原型 files[].icon 只用 📕(PDF/报告)与 📊(表格)两种,其余回退为回形针。
1049
+ function attachmentIcon(mimeType: string, name: string): string {
1050
+ const hint = `${mimeType} ${name}`.toLowerCase()
1051
+ if (hint.includes('pdf')) return '📕'
1052
+ if (/sheet|excel|csv|\.xlsx?\b|\.csv\b/.test(hint)) return '📊'
1053
+ return '📎'
1054
+ }
1055
+
1056
+ function formatSize(bytes: number): string {
1057
+ if (!bytes || bytes <= 0) return ''
1058
+ if (bytes < 1024) return `${bytes} B`
1059
+ const kb = bytes / 1024
1060
+ if (kb < 1024) return `${kb.toFixed(1)} KB`
1061
+ return `${(kb / 1024).toFixed(1)} MB`
1062
+ }
1063
+
1064
+ function pad(value: number): string {
1065
+ return value < 10 ? `0${value}` : String(value)
1066
+ }