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