@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,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
|
+
}
|