@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,123 @@
1
+ import type { AiEmployeeConnectionState, AiEmployeeReconnectOptions } from './types'
2
+
3
+ export type CustomerSupportSessionStatus = 'waiting' | 'active' | 'closed'
4
+ export type CustomerSupportSenderType = 'customer' | 'agent' | 'system'
5
+
6
+ export interface CustomerSupportAgent {
7
+ id: string
8
+ name: string
9
+ avatarUrl: string
10
+ roleName: string
11
+ }
12
+
13
+ export interface CustomerSupportMessage {
14
+ id: string
15
+ sessionId: string
16
+ sequence: string
17
+ clientMessageId: string
18
+ senderType: CustomerSupportSenderType
19
+ senderName: string
20
+ content: string
21
+ createdAt: string
22
+ }
23
+
24
+ export interface CustomerSupportSession {
25
+ id: string
26
+ sessionNo: string
27
+ title: string
28
+ status: CustomerSupportSessionStatus
29
+ statusText: string
30
+ primaryAgent: CustomerSupportAgent | null
31
+ lastMessage: string
32
+ unreadCount: number
33
+ openedAt: string
34
+ updatedAt: string
35
+ version: string
36
+ }
37
+
38
+ export interface CustomerSupportMessagePage {
39
+ messages: readonly CustomerSupportMessage[]
40
+ latestSequence: string
41
+ sessionVersion: string
42
+ }
43
+
44
+ export interface CustomerSupportCreateSessionRequest {
45
+ clientRequestId: string
46
+ title?: string
47
+ initialMessage?: string
48
+ }
49
+
50
+ export interface CustomerSupportCreateSessionResult {
51
+ session: CustomerSupportSession
52
+ initialMessage?: CustomerSupportMessage
53
+ }
54
+
55
+ export interface CustomerSupportTransport {
56
+ getAccessToken(): Promise<string>
57
+ listSessions(): Promise<readonly CustomerSupportSession[]>
58
+ getSession?(sessionId: string): Promise<CustomerSupportSession>
59
+ createSession(
60
+ request: CustomerSupportCreateSessionRequest,
61
+ ): Promise<CustomerSupportCreateSessionResult>
62
+ listMessages(sessionId: string): Promise<CustomerSupportMessagePage>
63
+ sendMessage(
64
+ sessionId: string,
65
+ clientMessageId: string,
66
+ content: string,
67
+ ): Promise<CustomerSupportMessage>
68
+ markRead?(sessionId: string, lastReadSequence: string): Promise<void>
69
+ }
70
+
71
+ export interface CustomerSupportClientOptions {
72
+ realtimeUrl: string
73
+ transport: CustomerSupportTransport
74
+ sessionId?: string
75
+ reconnect?: Partial<AiEmployeeReconnectOptions>
76
+ webSocketFactory?: (url: string) => WebSocket
77
+ requestIdFactory?: () => string
78
+ minimumHeartbeatMs?: number
79
+ }
80
+
81
+ export type CustomerSupportErrorCode =
82
+ | 'INVALID_CONFIGURATION'
83
+ | 'SESSION_UNAVAILABLE'
84
+ | 'HISTORY_LOAD_FAILED'
85
+ | 'TOKEN_UNAVAILABLE'
86
+ | 'WEBSOCKET_UNAVAILABLE'
87
+ | 'WEBSOCKET_ERROR'
88
+ | 'AUTHENTICATION_FAILED'
89
+ | 'ORGANIZATION_CONTEXT_REQUIRED'
90
+ | 'REALTIME_UNAVAILABLE'
91
+ | 'CHANNEL_UNAVAILABLE'
92
+ | 'PROTOCOL_ERROR'
93
+ | 'SEND_FAILED'
94
+ | 'SEND_IN_PROGRESS'
95
+ | 'EMPTY_MESSAGE'
96
+ | 'CLIENT_DESTROYED'
97
+
98
+ export interface CustomerSupportErrorDetail {
99
+ code: CustomerSupportErrorCode
100
+ message: string
101
+ recoverable: boolean
102
+ }
103
+
104
+ export interface CustomerSupportClientEventMap {
105
+ ready: { sessionId: string | null }
106
+ sessions: { sessions: readonly CustomerSupportSession[] }
107
+ session: { session: CustomerSupportSession }
108
+ history: { sessionId: string; messages: readonly CustomerSupportMessage[] }
109
+ message: {
110
+ sessionId: string
111
+ message: CustomerSupportMessage
112
+ source: 'local' | 'realtime'
113
+ }
114
+ 'connection-state-change': {
115
+ previous: AiEmployeeConnectionState
116
+ current: AiEmployeeConnectionState
117
+ }
118
+ error: CustomerSupportErrorDetail
119
+ }
120
+
121
+ export type CustomerSupportClientListener<K extends keyof CustomerSupportClientEventMap> = (
122
+ detail: CustomerSupportClientEventMap[K],
123
+ ) => void
@@ -0,0 +1,79 @@
1
+ export { AiEmployeeClient, AiEmployeeClientError, AiEmployeeTransportError } from './client'
2
+ export { CustomerSupportClient, CustomerSupportClientError } from './customer-support-client'
3
+ export {
4
+ NotificationCenterClient,
5
+ NotificationCenterClientError,
6
+ } from './notification-center-client'
7
+ export {
8
+ createAuthenticateFrame,
9
+ createPingFrame,
10
+ createSubscriptionFrame,
11
+ hashText,
12
+ parseAiEmployeeEvent,
13
+ parseJsonRecord,
14
+ } from './protocol'
15
+ export type {
16
+ AuthenticateFrame,
17
+ ParsedAiEmployeeEvent,
18
+ ParsedSessionMessage,
19
+ PingFrame,
20
+ SessionSubscriptionFrame,
21
+ } from './protocol'
22
+ export type {
23
+ AiEmployeeClientErrorDetail,
24
+ AiEmployeeClientEventMap,
25
+ AiEmployeeClientListener,
26
+ AiEmployeeClientOptions,
27
+ AiEmployeeClientStartResult,
28
+ AiEmployeeAttachmentReference,
29
+ AiEmployeeConnectionState,
30
+ AiEmployeeErrorCode,
31
+ AiEmployeeFirstMessageOptions,
32
+ AiEmployeeFirstMessageRequest,
33
+ AiEmployeeFirstMessageRequestState,
34
+ AiEmployeeFirstMessageRequestStore,
35
+ AiEmployeeFirstMessageResult,
36
+ AiEmployeeMessage,
37
+ AiEmployeeMessageRole,
38
+ AiEmployeeReconnectOptions,
39
+ AiEmployeeSessionSummary,
40
+ AiEmployeeTransport,
41
+ AiEmployeeTransportErrorCode,
42
+ AiEmployeeWebSocketFactory,
43
+ } from './types'
44
+ export type {
45
+ CustomerSupportAgent,
46
+ CustomerSupportClientEventMap,
47
+ CustomerSupportClientListener,
48
+ CustomerSupportClientOptions,
49
+ CustomerSupportCreateSessionRequest,
50
+ CustomerSupportCreateSessionResult,
51
+ CustomerSupportErrorCode,
52
+ CustomerSupportErrorDetail,
53
+ CustomerSupportMessage,
54
+ CustomerSupportMessagePage,
55
+ CustomerSupportSenderType,
56
+ CustomerSupportSession,
57
+ CustomerSupportSessionStatus,
58
+ CustomerSupportTransport,
59
+ } from './customer-support-types'
60
+ export type {
61
+ NotificationAttachment,
62
+ NotificationCenterClientEventMap,
63
+ NotificationCenterClientListener,
64
+ NotificationCenterClientOptions,
65
+ NotificationCenterErrorCode,
66
+ NotificationCenterErrorDetail,
67
+ NotificationCenterTransport,
68
+ NotificationContentType,
69
+ NotificationDto,
70
+ NotificationInboxQuery,
71
+ NotificationInboxResult,
72
+ NotificationMarkAllReadResult,
73
+ NotificationMarkReadResult,
74
+ NotificationReadStatus,
75
+ NotificationRealtimeChange,
76
+ NotificationResource,
77
+ NotificationSource,
78
+ NotificationTypeSummary,
79
+ } from './notification-center-types'
@@ -0,0 +1,462 @@
1
+ import type {
2
+ NotificationCenterClientEventMap,
3
+ NotificationCenterClientListener,
4
+ NotificationCenterClientOptions,
5
+ NotificationCenterErrorCode,
6
+ NotificationCenterErrorDetail,
7
+ NotificationCenterTransport,
8
+ NotificationDto,
9
+ NotificationRealtimeChange,
10
+ NotificationTypeSummary,
11
+ } from './notification-center-types'
12
+
13
+ type UntypedListener = (detail: unknown) => void
14
+
15
+ const DEFAULT_PAGE_SIZE = 20
16
+
17
+ export class NotificationCenterClientError extends Error {
18
+ readonly code: NotificationCenterErrorCode
19
+
20
+ constructor(code: NotificationCenterErrorCode, message: string) {
21
+ super(message)
22
+ this.name = 'NotificationCenterClientError'
23
+ this.code = code
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Inbox store and HTTP orchestration for the notification center.
29
+ *
30
+ * The client owns no realtime connection. The host keeps the single
31
+ * `/api/user-realtime/ws` socket it already maintains for its other channels and
32
+ * hands notification events in through {@link applyRealtimeEvent}; cursors,
33
+ * heartbeats, authentication and reconnect stay on the host side.
34
+ */
35
+ export class NotificationCenterClient {
36
+ private readonly transport: NotificationCenterTransport
37
+ private readonly applicationName: string
38
+ private readonly pageSize: number
39
+ private readonly readStatus: 'all' | 'unread' | 'read'
40
+ private readonly preferredTypeOrder: readonly string[]
41
+ private readonly listeners = new Map<
42
+ keyof NotificationCenterClientEventMap,
43
+ Set<UntypedListener>
44
+ >()
45
+
46
+ private items: NotificationDto[] = []
47
+ private hasMore = false
48
+ private nextId: string | null = null
49
+ private unreadCount = 0
50
+ private notificationTypes: string[] = []
51
+ private availableTypes: NotificationTypeSummary[] = []
52
+ private typeCatalogFromBackend = false
53
+ private inboxGeneration = 0
54
+ private destroyed = false
55
+ private loadingInbox = false
56
+ private pendingResync = false
57
+
58
+ constructor(options: NotificationCenterClientOptions) {
59
+ if (!options?.transport) {
60
+ throw new NotificationCenterClientError(
61
+ 'INVALID_CONFIGURATION',
62
+ '缺少通知中心 transport 配置。',
63
+ )
64
+ }
65
+ this.transport = options.transport
66
+ this.applicationName = normalizedText(options.applicationName)
67
+ this.pageSize = Math.max(1, Math.floor(options.pageSize ?? DEFAULT_PAGE_SIZE))
68
+ this.readStatus = ['all', 'unread', 'read'].includes(options.readStatus ?? '')
69
+ ? (options.readStatus as 'all' | 'unread' | 'read')
70
+ : 'all'
71
+ this.preferredTypeOrder = (options.preferredTypeOrder ?? [])
72
+ .map((value) => normalizedText(value))
73
+ .filter(Boolean)
74
+ }
75
+
76
+ get notifications(): readonly NotificationDto[] {
77
+ return [...this.items]
78
+ }
79
+
80
+ get unread(): number {
81
+ return this.unreadCount
82
+ }
83
+
84
+ /** 数据中实际存在的通知分类,供 UI 渲染页签;不是写死的枚举。 */
85
+ get types(): readonly NotificationTypeSummary[] {
86
+ return this.availableTypes.map((entry) => ({ ...entry }))
87
+ }
88
+
89
+ on<K extends keyof NotificationCenterClientEventMap>(
90
+ type: K,
91
+ listener: NotificationCenterClientListener<K>,
92
+ ): () => void {
93
+ this.assertUsable()
94
+ const listeners = this.listeners.get(type) ?? new Set<UntypedListener>()
95
+ const untyped = listener as UntypedListener
96
+ listeners.add(untyped)
97
+ this.listeners.set(type, listeners)
98
+ return () => listeners.delete(untyped)
99
+ }
100
+
101
+ async start(): Promise<void> {
102
+ this.assertUsable()
103
+ await Promise.all([this.reloadInbox(), this.refreshUnreadCount()])
104
+ this.emit('ready', {})
105
+ }
106
+
107
+ async setTypeFilter(types: readonly string[]): Promise<void> {
108
+ this.assertUsable()
109
+ this.notificationTypes = [...types].map((value) => normalizedText(value)).filter(Boolean)
110
+ await this.reloadInbox()
111
+ }
112
+
113
+ getTypeFilter(): readonly string[] {
114
+ return [...this.notificationTypes]
115
+ }
116
+
117
+ async loadMore(): Promise<void> {
118
+ this.assertUsable()
119
+ if (!this.hasMore || this.loadingInbox || !this.nextId) return
120
+ const generation = this.inboxGeneration
121
+ this.loadingInbox = true
122
+ try {
123
+ const result = await this.transport.listInbox({
124
+ pageSize: this.pageSize,
125
+ beforeId: this.nextId,
126
+ notificationTypes: this.notificationTypes,
127
+ readStatus: this.readStatus,
128
+ applicationName: this.applicationName,
129
+ })
130
+ if (this.destroyed || generation !== this.inboxGeneration) return
131
+ const existing = new Set(this.items.map((item) => item.id))
132
+ const merged = [...this.items]
133
+ for (const item of result.items) {
134
+ if (!existing.has(item.id)) {
135
+ existing.add(item.id)
136
+ merged.push(item)
137
+ }
138
+ }
139
+ this.items = merged
140
+ this.hasMore = result.hasMore
141
+ this.nextId = result.nextId
142
+ this.rememberTypes(result.items)
143
+ this.emitInbox()
144
+ } catch {
145
+ this.emitError('INBOX_LOAD_FAILED', '通知加载失败,请稍后重试。', true)
146
+ } finally {
147
+ if (generation === this.inboxGeneration) this.finishInboxLoad()
148
+ }
149
+ }
150
+
151
+ async refresh(): Promise<void> {
152
+ this.assertUsable()
153
+ await Promise.all([this.reloadInbox(), this.refreshUnreadCount()])
154
+ }
155
+
156
+ /**
157
+ * Consume a notification realtime event captured by the host socket.
158
+ *
159
+ * The raw event is mapped through the transport so the DTO contract stays in
160
+ * this SDK; anything that cannot be mapped degrades to a full refresh. Events
161
+ * that arrive while an inbox request is in flight are replaced by a resync so a
162
+ * late server page cannot drop them.
163
+ */
164
+ applyRealtimeEvent(event: unknown): void {
165
+ if (this.destroyed) return
166
+ if (this.loadingInbox) {
167
+ this.pendingResync = true
168
+ return
169
+ }
170
+ const parse = this.transport.parseRealtimeEvent
171
+ let change: NotificationRealtimeChange | null = null
172
+ if (parse) {
173
+ try {
174
+ change = parse.call(this.transport, event)
175
+ } catch {
176
+ change = null
177
+ }
178
+ }
179
+ if (!change) {
180
+ void this.refreshFromRealtime()
181
+ return
182
+ }
183
+ this.applyRealtimeChange(change)
184
+ }
185
+
186
+ async markRead(notificationId: string): Promise<void> {
187
+ this.assertUsable()
188
+ const id = requiredText(notificationId, 'notificationId')
189
+ const target = this.items.find((item) => item.id === id)
190
+ if (target && target.readStatus === 'read') return
191
+ try {
192
+ const result = await this.transport.markRead({
193
+ notificationId: id,
194
+ applicationName: this.applicationName,
195
+ })
196
+ if (this.destroyed) return
197
+ this.applyRead([id], result.readAt)
198
+ this.setUnreadCount(result.unreadCount)
199
+ this.emit('notification-read', { notificationId: id })
200
+ } catch {
201
+ this.emitError('MARK_READ_FAILED', '通知标记已读失败,请稍后重试。', true)
202
+ }
203
+ }
204
+
205
+ async markAllRead(): Promise<void> {
206
+ this.assertUsable()
207
+ try {
208
+ const result = await this.transport.markAllRead({
209
+ applicationName: this.applicationName,
210
+ })
211
+ if (this.destroyed) return
212
+ const readAt = new Date().toISOString()
213
+ this.items = this.items.map((item) =>
214
+ item.readStatus === 'read' ? item : { ...item, readStatus: 'read', readAt },
215
+ )
216
+ this.emitInbox()
217
+ this.setUnreadCount(result.unreadCount)
218
+ } catch {
219
+ this.emitError('MARK_READ_FAILED', '通知标记已读失败,请稍后重试。', true)
220
+ }
221
+ }
222
+
223
+ async downloadAttachment(fileId: string, name?: string): Promise<Blob> {
224
+ this.assertUsable()
225
+ const id = requiredText(fileId, 'fileId')
226
+ try {
227
+ return await this.transport.downloadAttachment({ fileId: id, name })
228
+ } catch {
229
+ const detail = this.emitError('DOWNLOAD_FAILED', '附件下载失败,请稍后重试。', true)
230
+ throw new NotificationCenterClientError(detail.code, detail.message)
231
+ }
232
+ }
233
+
234
+ destroy(): void {
235
+ if (this.destroyed) return
236
+ this.destroyed = true
237
+ this.pendingResync = false
238
+ this.inboxGeneration += 1
239
+ this.listeners.clear()
240
+ this.items = []
241
+ }
242
+
243
+ private async reloadInbox(): Promise<void> {
244
+ const generation = ++this.inboxGeneration
245
+ this.loadingInbox = true
246
+ try {
247
+ const result = await this.transport.listInbox({
248
+ pageSize: this.pageSize,
249
+ beforeId: null,
250
+ // 分类目录随首屏/刷新/切换页签的请求一并取回,不额外发请求。
251
+ includeTypeFacets: true,
252
+ notificationTypes: this.notificationTypes,
253
+ readStatus: this.readStatus,
254
+ applicationName: this.applicationName,
255
+ })
256
+ if (this.destroyed || generation !== this.inboxGeneration) return
257
+ const unique = new Map<string, NotificationDto>()
258
+ for (const item of result.items) {
259
+ if (item.id) unique.set(item.id, item)
260
+ }
261
+ this.items = [...unique.values()]
262
+ this.hasMore = result.hasMore
263
+ this.nextId = result.nextId
264
+ this.applyTypeCatalog(result.types)
265
+ this.rememberTypes(result.items)
266
+ this.emitInbox()
267
+ } catch {
268
+ if (generation === this.inboxGeneration) {
269
+ this.emitError('INBOX_LOAD_FAILED', '通知加载失败,请稍后重试。', true)
270
+ }
271
+ } finally {
272
+ if (generation === this.inboxGeneration) this.finishInboxLoad()
273
+ }
274
+ }
275
+
276
+ private finishInboxLoad(): void {
277
+ this.loadingInbox = false
278
+ if (!this.pendingResync || this.destroyed) return
279
+ this.pendingResync = false
280
+ void this.refreshFromRealtime()
281
+ }
282
+
283
+ /**
284
+ * 分类页签的数据来源。
285
+ *
286
+ * `notification_type` 是后端自由文本列,展示名来自后端字典(`notification_type_name`),
287
+ * 前端不维护 code 到中文名的映射。权威目录是 `inbox` 在 `includeTypeFacets` 下返回的
288
+ * `types`(后端按收件人可见范围统计,忽略类型筛选与分页);后端未提供时退回累积已加载
289
+ * 页面里出现过的分类——这在分页深处才出现的分类上会暂时缺页签,属已知取舍。
290
+ */
291
+ private applyTypeCatalog(types: readonly NotificationTypeSummary[] | undefined): void {
292
+ if (!Array.isArray(types)) return
293
+ const normalized = types
294
+ .map((entry) => ({
295
+ type: normalizedText(entry?.type),
296
+ name: normalizedText(entry?.name),
297
+ }))
298
+ .filter((entry) => entry.type)
299
+ this.typeCatalogFromBackend = true
300
+ this.setAvailableTypes(normalized, true)
301
+ }
302
+
303
+ private rememberTypes(items: readonly NotificationDto[]): void {
304
+ const discovered = items
305
+ .map((item) => ({ type: normalizedText(item.type), name: normalizedText(item.typeName) }))
306
+ .filter((entry) => entry.type)
307
+ if (!discovered.length) return
308
+ // 后端目录已给出分类集合时只补展示名,不新增或重排分类。
309
+ this.setAvailableTypes(discovered, false, this.typeCatalogFromBackend)
310
+ }
311
+
312
+ private setAvailableTypes(
313
+ candidates: readonly NotificationTypeSummary[],
314
+ replace: boolean,
315
+ namesOnly = false,
316
+ ): void {
317
+ const merged = new Map<string, string>()
318
+ if (!replace) {
319
+ for (const entry of this.availableTypes) merged.set(entry.type, entry.name)
320
+ }
321
+ for (const entry of candidates) {
322
+ if (namesOnly && !merged.has(entry.type)) continue
323
+ // 已有展示名优先保留:后端字典是权威,条目里的名字只用于填空。
324
+ const existing = merged.get(entry.type)
325
+ merged.set(entry.type, existing || entry.name)
326
+ }
327
+ const codes = [...merged.keys()]
328
+ const preferred = this.preferredTypeOrder.filter((value) => merged.has(value))
329
+ const ordered = [...preferred, ...codes.filter((value) => !preferred.includes(value))].map(
330
+ (type) => ({ type, name: merged.get(type) ?? '' }),
331
+ )
332
+ if (
333
+ ordered.length === this.availableTypes.length &&
334
+ ordered.every(
335
+ (entry, index) =>
336
+ entry.type === this.availableTypes[index]?.type &&
337
+ entry.name === this.availableTypes[index]?.name,
338
+ )
339
+ ) {
340
+ return
341
+ }
342
+ this.availableTypes = ordered
343
+ this.emit('types-change', { types: this.types })
344
+ }
345
+
346
+ private async refreshUnreadCount(): Promise<void> {
347
+ try {
348
+ const result = await this.transport.getUnreadCount({ applicationName: this.applicationName })
349
+ if (this.destroyed) return
350
+ this.setUnreadCount(result.unreadCount)
351
+ } catch {
352
+ // A later inbox reload or realtime event can recover the unread projection.
353
+ }
354
+ }
355
+
356
+ private applyRealtimeChange(change: NotificationRealtimeChange): void {
357
+ if (change.kind === 'created') {
358
+ const incoming = change.notification
359
+ if (!incoming.id) return
360
+ if (!this.items.some((item) => item.id === incoming.id)) {
361
+ this.items = [incoming, ...this.items]
362
+ this.rememberTypes([incoming])
363
+ this.emitInbox()
364
+ }
365
+ this.syncRealtimeUnreadCount(change.unreadCount)
366
+ return
367
+ }
368
+ if (change.kind === 'read') {
369
+ this.applyRead(change.notificationIds, change.readAt ?? null)
370
+ this.syncRealtimeUnreadCount(change.unreadCount)
371
+ return
372
+ }
373
+ const removed = new Set(change.notificationIds)
374
+ if (removed.size) {
375
+ const next = this.items.filter((item) => !removed.has(item.id))
376
+ if (next.length !== this.items.length) {
377
+ this.items = next
378
+ this.emitInbox()
379
+ }
380
+ }
381
+ this.syncRealtimeUnreadCount(change.unreadCount)
382
+ }
383
+
384
+ private syncRealtimeUnreadCount(value: number | undefined): void {
385
+ // Existing realtime events carry an application-scoped unread count. In the
386
+ // all-applications projection only the aggregate HTTP count is authoritative.
387
+ if (this.applicationName && typeof value === 'number') this.setUnreadCount(value)
388
+ else void this.refreshUnreadCount()
389
+ }
390
+
391
+ private applyRead(ids: readonly string[], readAt: string | null): void {
392
+ const target = new Set(ids)
393
+ if (!target.size) return
394
+ let changed = false
395
+ const next = this.items.map((item) => {
396
+ if (target.has(item.id) && item.readStatus !== 'read') {
397
+ changed = true
398
+ return { ...item, readStatus: 'read' as const, readAt: readAt ?? item.readAt }
399
+ }
400
+ return item
401
+ })
402
+ if (changed) {
403
+ this.items = next
404
+ this.emitInbox()
405
+ }
406
+ }
407
+
408
+ private async refreshFromRealtime(): Promise<void> {
409
+ await this.reloadInbox()
410
+ await this.refreshUnreadCount()
411
+ }
412
+
413
+ private emitInbox(): void {
414
+ this.emit('inbox', {
415
+ items: [...this.items],
416
+ hasMore: this.hasMore,
417
+ nextId: this.nextId,
418
+ })
419
+ }
420
+
421
+ private setUnreadCount(value: number): void {
422
+ const normalized = Number.isFinite(value) && value > 0 ? Math.floor(value) : 0
423
+ if (normalized === this.unreadCount) return
424
+ this.unreadCount = normalized
425
+ this.emit('unread-count-change', { unreadCount: normalized })
426
+ }
427
+
428
+ private emitError(
429
+ code: NotificationCenterErrorCode,
430
+ message: string,
431
+ recoverable: boolean,
432
+ ): NotificationCenterErrorDetail {
433
+ const detail = { code, message, recoverable }
434
+ this.emit('error', detail)
435
+ return detail
436
+ }
437
+
438
+ private emit<K extends keyof NotificationCenterClientEventMap>(
439
+ type: K,
440
+ detail: NotificationCenterClientEventMap[K],
441
+ ): void {
442
+ for (const listener of this.listeners.get(type) ?? []) listener(detail)
443
+ }
444
+
445
+ private assertUsable(): void {
446
+ if (this.destroyed) {
447
+ throw new NotificationCenterClientError('CLIENT_DESTROYED', '通知中心客户端已经销毁。')
448
+ }
449
+ }
450
+ }
451
+
452
+ function requiredText(value: unknown, field: string): string {
453
+ const normalized = normalizedText(value)
454
+ if (!normalized) {
455
+ throw new NotificationCenterClientError('INVALID_CONFIGURATION', `${field} 不能为空。`)
456
+ }
457
+ return normalized
458
+ }
459
+
460
+ function normalizedText(value: unknown): string {
461
+ return typeof value === 'string' ? value.trim() : ''
462
+ }