@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,202 @@
1
+ import type { ApplicationLaunchResult } from './application-switcher-transport'
2
+ import { resolveUniplatBaseApplicationEntryUrl } from './uniplat-base-application-ticket'
3
+
4
+ export const UNIPLAT_IDENTITY_TICKET_FRAGMENT_KEY = 'uniplat_identity_ticket'
5
+ export const UNIPLAT_IDENTITY_TICKET_SOURCE_SYSTEM_FRAGMENT_KEY = 'source_system'
6
+
7
+ export interface UniplatBaseIdentityTicketLoginOptions {
8
+ serviceBaseUrl: string
9
+ applicationName: string
10
+ setAccessToken(accessToken: string): void | Promise<void>
11
+ fetch?: typeof fetch
12
+ }
13
+
14
+ const LOGIN_PATH = 'anonymous/application.identity/login_with_ticket'
15
+ const MIN_TICKET_LENGTH = 32
16
+ const MAX_TICKET_LENGTH = 128
17
+ const TICKET_LIFETIME_SECONDS = 15
18
+ const MAX_SOURCE_SYSTEM_LENGTH = 128
19
+
20
+ export async function loginWithUniplatBaseIdentityTicket(
21
+ options: UniplatBaseIdentityTicketLoginOptions,
22
+ ): Promise<boolean> {
23
+ if (!options || typeof options.setAccessToken !== 'function') {
24
+ throw new TypeError('Identity ticket login requires setAccessToken()')
25
+ }
26
+ const serviceBaseUrl = validateServiceBaseUrl(options.serviceBaseUrl)
27
+ const applicationName = requireText(options.applicationName, 'Application name', 255)
28
+ if (typeof window === 'undefined') return false
29
+
30
+ const fragment = readIdentityTicketFragment(window.location.hash)
31
+ if (!fragment.hasTicket) return false
32
+ let ticket = fragment.ticket
33
+ clearIdentityTicketFragment()
34
+ const fetchImplementation = options.fetch ?? globalThis.fetch
35
+ const endpoint = `${serviceBaseUrl}/${LOGIN_PATH}`
36
+ let accessToken = ''
37
+ let publicErrorMessage = ''
38
+ try {
39
+ ticket = requireTicket(ticket)
40
+ const sourceSystem = requireOptionalText(fragment.sourceSystem, MAX_SOURCE_SYSTEM_LENGTH)
41
+ if (!fetchImplementation) throw new Error('Fetch is unavailable')
42
+ const body: Record<string, string> = { application: applicationName, ticket }
43
+ if (sourceSystem) body.source_system = sourceSystem
44
+ const response = await fetchImplementation(endpoint, {
45
+ method: 'POST',
46
+ credentials: 'omit',
47
+ headers: {
48
+ accept: 'application/json',
49
+ 'content-type': 'application/json',
50
+ },
51
+ body: JSON.stringify(body),
52
+ })
53
+ if (!response.ok) throw new Error('Identity ticket login failed')
54
+ const envelope = asRecord(await response.json())
55
+ if (number(envelope.rescode) !== 0) {
56
+ publicErrorMessage = identityTicketPublicErrorMessage(envelope)
57
+ throw new Error('Identity ticket login failed')
58
+ }
59
+ const data = asRecord(envelope.data)
60
+ accessToken = text(data.token).trim()
61
+ if (!accessToken || text(data.application_name).trim() !== applicationName) {
62
+ throw new Error('Identity ticket login failed')
63
+ }
64
+ await options.setAccessToken(accessToken)
65
+ return true
66
+ } catch {
67
+ throw new Error(publicErrorMessage || '身份票据登录失败')
68
+ } finally {
69
+ accessToken = ''
70
+ ticket = ''
71
+ }
72
+ }
73
+
74
+ export function createIdentityTicketLaunchResult(
75
+ entryUrl: string,
76
+ rawTicket: unknown,
77
+ rawExpiresIn: unknown,
78
+ rawSourceSystem: unknown,
79
+ ): ApplicationLaunchResult {
80
+ const ticket = requireTicket(rawTicket)
81
+ const expiresIn = number(rawExpiresIn)
82
+ if (expiresIn !== TICKET_LIFETIME_SECONDS) {
83
+ throw new Error('Identity ticket response is invalid')
84
+ }
85
+ const sourceSystem = text(rawSourceSystem).trim()
86
+ if (!sourceSystem || sourceSystem.length > MAX_SOURCE_SYSTEM_LENGTH) {
87
+ throw new Error('Identity ticket response is invalid')
88
+ }
89
+ const url = new URL(resolveUniplatBaseApplicationEntryUrl(entryUrl))
90
+ url.hash = new URLSearchParams([
91
+ [UNIPLAT_IDENTITY_TICKET_FRAGMENT_KEY, ticket],
92
+ [UNIPLAT_IDENTITY_TICKET_SOURCE_SYSTEM_FRAGMENT_KEY, sourceSystem],
93
+ ]).toString()
94
+ return { url: url.href }
95
+ }
96
+
97
+ function readIdentityTicketFragment(hash: string): {
98
+ hasTicket: boolean
99
+ ticket: string
100
+ sourceSystem: string
101
+ } {
102
+ const fragment = hash.startsWith('#') ? hash.slice(1) : hash
103
+ const params = new URLSearchParams(fragment)
104
+ return {
105
+ hasTicket: params.has(UNIPLAT_IDENTITY_TICKET_FRAGMENT_KEY),
106
+ ticket: params.get(UNIPLAT_IDENTITY_TICKET_FRAGMENT_KEY)?.trim() ?? '',
107
+ sourceSystem: params.get(UNIPLAT_IDENTITY_TICKET_SOURCE_SYSTEM_FRAGMENT_KEY) ?? '',
108
+ }
109
+ }
110
+
111
+ function clearIdentityTicketFragment(): void {
112
+ const fragment = window.location.hash.startsWith('#')
113
+ ? window.location.hash.slice(1)
114
+ : window.location.hash
115
+ const params = new URLSearchParams(fragment)
116
+ params.delete(UNIPLAT_IDENTITY_TICKET_FRAGMENT_KEY)
117
+ params.delete(UNIPLAT_IDENTITY_TICKET_SOURCE_SYSTEM_FRAGMENT_KEY)
118
+ const remainingFragment = params.toString()
119
+ const cleanUrl = `${window.location.pathname}${window.location.search}${remainingFragment ? `#${remainingFragment}` : ''}`
120
+ window.history.replaceState(window.history.state, '', cleanUrl)
121
+ }
122
+
123
+ function identityTicketPublicErrorMessage(envelope: Record<string, unknown>): string {
124
+ const allowedCodes = new Set([
125
+ 'IDENTITY_TICKET_INVALID',
126
+ 'IDENTITY_TICKET_SYSTEM_UNAVAILABLE',
127
+ 'IDENTITY_TICKET_SOURCE_UNAVAILABLE',
128
+ 'IDENTITY_TICKET_IDENTITY_NOT_READY',
129
+ 'IDENTITY_TICKET_APPLICATION_ACCESS_DENIED',
130
+ ])
131
+ const code = text(envelope.error_code)
132
+ const message = text(envelope.msg)
133
+ if (
134
+ !allowedCodes.has(code) ||
135
+ !message ||
136
+ message.length > 200 ||
137
+ [...message].some((character) => {
138
+ const codePoint = character.charCodeAt(0)
139
+ return codePoint <= 31 || codePoint === 127
140
+ })
141
+ ) {
142
+ return ''
143
+ }
144
+ return message
145
+ }
146
+
147
+ function requireTicket(rawValue: unknown): string {
148
+ const ticket = text(rawValue).trim()
149
+ if (
150
+ ticket.length < MIN_TICKET_LENGTH ||
151
+ ticket.length > MAX_TICKET_LENGTH ||
152
+ !/^[A-Za-z0-9_-]+$/.test(ticket)
153
+ ) {
154
+ throw new Error('Identity ticket response is invalid')
155
+ }
156
+ return ticket
157
+ }
158
+
159
+ function requireText(rawValue: unknown, label: string, maxLength: number): string {
160
+ const value = text(rawValue).trim()
161
+ if (!value || value.length > maxLength) throw new TypeError(`${label} is required`)
162
+ return value
163
+ }
164
+
165
+ function requireOptionalText(rawValue: unknown, maxLength: number): string {
166
+ const value = text(rawValue).trim()
167
+ if (value.length > maxLength) throw new Error('Identity ticket response is invalid')
168
+ return value
169
+ }
170
+
171
+ function validateServiceBaseUrl(rawValue: string): string {
172
+ const value = typeof rawValue === 'string' ? rawValue.trim() : ''
173
+ if (!value) throw new TypeError('UniplatBase serviceBaseUrl is required')
174
+ const documentBase = typeof document === 'undefined' ? 'https://localhost/' : document.baseURI
175
+ let url: URL
176
+ try {
177
+ url = new URL(value, documentBase)
178
+ } catch {
179
+ throw new TypeError('UniplatBase serviceBaseUrl is invalid')
180
+ }
181
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
182
+ throw new TypeError('UniplatBase serviceBaseUrl must use HTTP(S) without user credentials')
183
+ }
184
+ if (url.search || url.hash) {
185
+ throw new TypeError('UniplatBase serviceBaseUrl must not contain query or hash')
186
+ }
187
+ return url.href.replace(/\/$/, '')
188
+ }
189
+
190
+ function asRecord(value: unknown): Record<string, unknown> {
191
+ return value && typeof value === 'object' && !Array.isArray(value)
192
+ ? (value as Record<string, unknown>)
193
+ : {}
194
+ }
195
+
196
+ function text(value: unknown): string {
197
+ return typeof value === 'string' ? value : ''
198
+ }
199
+
200
+ function number(value: unknown): number {
201
+ return typeof value === 'number' ? value : Number.NaN
202
+ }
@@ -0,0 +1,378 @@
1
+ import type {
2
+ NotificationAttachment,
3
+ NotificationCenterTransport,
4
+ NotificationContentType,
5
+ NotificationDto,
6
+ NotificationInboxQuery,
7
+ NotificationInboxResult,
8
+ NotificationReadStatus,
9
+ NotificationRealtimeChange,
10
+ NotificationResource,
11
+ NotificationSource,
12
+ NotificationTypeSummary,
13
+ } from '@uniplat/ai-employee-client'
14
+
15
+ export interface UniplatBaseNotificationCenterTransportOptions {
16
+ serviceBaseUrl: string
17
+ getAccessToken(): string | Promise<string>
18
+ fetch?: typeof fetch
19
+ }
20
+
21
+ const NOTIFICATION_SERVICE = 'messaging.notification_api'
22
+ const FILESYSTEM_SERVICE = 'filesystem.api'
23
+ const INBOX_ACTION = 'inbox'
24
+ const UNREAD_COUNT_ACTION = 'unread_count'
25
+ const MARK_READ_ACTION = 'mark_read'
26
+ const MARK_ALL_READ_ACTION = 'mark_all_read'
27
+ const ARCHIVE_ACTION = 'archive'
28
+ const CREATE_DOWNLOAD_TICKET_ACTION = 'create_download_ticket'
29
+
30
+ export function createUniplatBaseNotificationCenterTransport(
31
+ options: UniplatBaseNotificationCenterTransportOptions,
32
+ ): NotificationCenterTransport {
33
+ if (!options || typeof options.getAccessToken !== 'function') {
34
+ throw new TypeError('Notification center transport requires getAccessToken()')
35
+ }
36
+ const serviceBaseUrl = validateServiceBaseUrl(options.serviceBaseUrl)
37
+ const fetchImplementation = options.fetch ?? globalThis.fetch
38
+ const request = async (
39
+ service: string,
40
+ action: string,
41
+ body: Record<string, unknown>,
42
+ ): Promise<unknown> => {
43
+ const token = await resolveAccessToken(options.getAccessToken)
44
+ return requestData(fetchImplementation, `${serviceBaseUrl}/${service}/${action}`, token, body)
45
+ }
46
+
47
+ return {
48
+ async listInbox(query: NotificationInboxQuery): Promise<NotificationInboxResult> {
49
+ const body: Record<string, unknown> = {
50
+ page_size: Math.max(1, Math.floor(query.pageSize)),
51
+ }
52
+ assignApplicationName(body, query.applicationName)
53
+ if (query.beforeId) body.before_id = requiredText(query.beforeId, 128)
54
+ if (query.includeTypeFacets) body.include_type_facets = true
55
+ const types = (query.notificationTypes ?? [])
56
+ .map((value) => optionalText(value, 64))
57
+ .filter(Boolean)
58
+ if (types.length) body.notification_types = types
59
+ if (query.readStatus) body.read_status = query.readStatus
60
+ if (query.includeArchived) body.include_archived = true
61
+ const data = record(await request(NOTIFICATION_SERVICE, INBOX_ACTION, body))
62
+ return {
63
+ items: Array.isArray(data.items)
64
+ ? data.items
65
+ .map(parseNotification)
66
+ .filter((value): value is NotificationDto => value !== null)
67
+ : [],
68
+ hasMore: data.has_more === true,
69
+ nextId: textOrNull(data.next_id),
70
+ ...(Array.isArray(data.type_facets)
71
+ ? { types: parseTypeFacets(data.type_facets) }
72
+ : undefined),
73
+ }
74
+ },
75
+ async getUnreadCount(params) {
76
+ const body: Record<string, unknown> = {}
77
+ assignApplicationName(body, params.applicationName)
78
+ const data = record(await request(NOTIFICATION_SERVICE, UNREAD_COUNT_ACTION, body))
79
+ return { unreadCount: nonNegativeInteger(data.unread_count) }
80
+ },
81
+ async markRead(params) {
82
+ const body: Record<string, unknown> = {
83
+ notification_id: requiredText(params.notificationId, 128),
84
+ }
85
+ assignApplicationName(body, params.applicationName)
86
+ const data = record(await request(NOTIFICATION_SERVICE, MARK_READ_ACTION, body))
87
+ return {
88
+ unreadCount: nonNegativeInteger(data.unread_count),
89
+ readAt: textOrNull(data.read_at),
90
+ }
91
+ },
92
+ async markAllRead(params) {
93
+ const body: Record<string, unknown> = {}
94
+ assignApplicationName(body, params.applicationName)
95
+ if (params.beforeId) body.before_id = requiredText(params.beforeId, 128)
96
+ const types = (params.notificationTypes ?? [])
97
+ .map((value) => optionalText(value, 64))
98
+ .filter(Boolean)
99
+ if (types.length) body.notification_types = types
100
+ const data = record(await request(NOTIFICATION_SERVICE, MARK_ALL_READ_ACTION, body))
101
+ return {
102
+ markedCount: nonNegativeInteger(data.marked_count),
103
+ unreadCount: nonNegativeInteger(data.unread_count),
104
+ }
105
+ },
106
+ async archive(params) {
107
+ const body: Record<string, unknown> = {
108
+ notification_id: requiredText(params.notificationId, 128),
109
+ }
110
+ assignApplicationName(body, params.applicationName)
111
+ const data = record(await request(NOTIFICATION_SERVICE, ARCHIVE_ACTION, body))
112
+ return { unreadCount: nonNegativeInteger(data.unread_count) }
113
+ },
114
+ async downloadAttachment(params) {
115
+ const fileId = requiredText(params.fileId, 128)
116
+ const ticketBody: Record<string, unknown> = { file_id: fileId, disposition: 'attachment' }
117
+ const ticketData = record(
118
+ await request(FILESYSTEM_SERVICE, CREATE_DOWNLOAD_TICKET_ACTION, ticketBody),
119
+ )
120
+ const downloadUrl = resolveDownloadUrl(text(ticketData.download_url), serviceBaseUrl)
121
+ if (!downloadUrl) throw new Error('Notification attachment download ticket is invalid')
122
+ if (!fetchImplementation) throw new Error('Fetch is unavailable')
123
+ const response = await fetchImplementation(downloadUrl, {
124
+ method: 'GET',
125
+ credentials: 'include',
126
+ })
127
+ if (!response.ok) throw new Error('Notification attachment download failed')
128
+ return response.blob()
129
+ },
130
+ parseRealtimeEvent(event): NotificationRealtimeChange | null {
131
+ const data = record(event)
132
+ const eventType = text(data.event_type)
133
+ const unreadRaw = data.unread_count
134
+ const unreadCount =
135
+ typeof unreadRaw === 'number' && Number.isFinite(unreadRaw)
136
+ ? Math.max(0, Math.floor(unreadRaw))
137
+ : undefined
138
+ if (eventType === 'notification_created') {
139
+ const notification = parseNotification(data.notification)
140
+ if (!notification) return null
141
+ return { kind: 'created', notification, unreadCount }
142
+ }
143
+ if (eventType === 'notification_read') {
144
+ const ids = collectNotificationIds(data)
145
+ if (!ids.length) return null
146
+ return { kind: 'read', notificationIds: ids, unreadCount, readAt: textOrNull(data.read_at) }
147
+ }
148
+ if (eventType === 'notification_archived') {
149
+ const ids = collectNotificationIds(data)
150
+ if (!ids.length) return null
151
+ return { kind: 'archived', notificationIds: ids, unreadCount }
152
+ }
153
+ return null
154
+ },
155
+ }
156
+ }
157
+
158
+ function collectNotificationIds(data: Record<string, unknown>): string[] {
159
+ const ids: string[] = []
160
+ if (Array.isArray(data.notification_ids)) {
161
+ for (const value of data.notification_ids) {
162
+ const id = text(value)
163
+ if (id) ids.push(id)
164
+ }
165
+ }
166
+ const single = text(data.notification_id)
167
+ if (single && !ids.includes(single)) ids.push(single)
168
+ return ids
169
+ }
170
+
171
+ /** 只取分类目录需要的 code 与展示名;total/unread 暂不进入 UI,不做无用投影。 */
172
+ function parseTypeFacets(value: readonly unknown[]): NotificationTypeSummary[] {
173
+ const summaries: NotificationTypeSummary[] = []
174
+ for (const entry of value) {
175
+ const data = record(entry)
176
+ const type = optionalText(data.notification_type, 64)
177
+ if (!type) continue
178
+ summaries.push({ type, name: optionalText(data.notification_type_name, 128) })
179
+ }
180
+ return summaries
181
+ }
182
+
183
+ function parseNotification(value: unknown): NotificationDto | null {
184
+ const data = record(value)
185
+ const id = text(data.notification_id ?? data.id)
186
+ if (!id) return null
187
+ return {
188
+ id,
189
+ type: text(data.notification_type) || 'other',
190
+ typeName: text(data.notification_type_name),
191
+ title: text(data.title),
192
+ summary: text(data.summary),
193
+ content: text(data.content),
194
+ contentType: contentType(data.content_type),
195
+ priority: text(data.priority) || 'normal',
196
+ source: parseSource(data.source),
197
+ resources: parseResources(data.resources),
198
+ attachments: parseAttachments(data.attachments),
199
+ readStatus: readStatus(data.read_status),
200
+ readAt: textOrNull(data.read_at),
201
+ archivedAt: textOrNull(data.archived_at),
202
+ createdAt: text(data.created_at),
203
+ expiresAt: textOrNull(data.expires_at),
204
+ }
205
+ }
206
+
207
+ function parseSource(value: unknown): NotificationSource | null {
208
+ const data = record(value)
209
+ const type = text(data.type)
210
+ const code = text(data.code)
211
+ const id = text(data.id)
212
+ const name = text(data.name)
213
+ const avatarUrl = safeImageUrl(data.avatar_url)
214
+ if (!type && !code && !id && !name && !avatarUrl) return null
215
+ return { type, code, id, name, avatarUrl }
216
+ }
217
+
218
+ function parseResources(value: unknown): NotificationResource[] {
219
+ if (!Array.isArray(value)) return []
220
+ const resources: NotificationResource[] = []
221
+ for (const entry of value) {
222
+ const data = record(entry)
223
+ const type = text(data.type)
224
+ const id = text(data.id)
225
+ const title = text(data.title)
226
+ const aeCode = text(data.ae_code)
227
+ if (!type && !id && !title && !aeCode) continue
228
+ resources.push({ type, id, title, aeCode })
229
+ }
230
+ return resources
231
+ }
232
+
233
+ function parseAttachments(value: unknown): NotificationAttachment[] {
234
+ if (!Array.isArray(value)) return []
235
+ const attachments: NotificationAttachment[] = []
236
+ for (const entry of value) {
237
+ const data = record(entry)
238
+ const fileId = text(data.file_id)
239
+ const name = text(data.name)
240
+ if (!fileId && !name) continue
241
+ attachments.push({
242
+ fileId,
243
+ versionId: text(data.version_id),
244
+ name,
245
+ sizeBytes: nonNegativeInteger(data.size_bytes),
246
+ mimeType: text(data.mime_type),
247
+ })
248
+ }
249
+ return attachments
250
+ }
251
+
252
+ async function requestData(
253
+ fetchImplementation: typeof fetch | undefined,
254
+ endpoint: string,
255
+ accessToken: string,
256
+ body: Record<string, unknown>,
257
+ ): Promise<unknown> {
258
+ if (!fetchImplementation) throw new Error('Fetch is unavailable')
259
+ const response = await fetchImplementation(endpoint, {
260
+ method: 'POST',
261
+ credentials: 'include',
262
+ headers: {
263
+ accept: 'application/json',
264
+ authorization: `Bearer ${accessToken}`,
265
+ 'content-type': 'application/json',
266
+ },
267
+ body: JSON.stringify(body),
268
+ })
269
+ if (!response.ok) throw new Error('Notification request failed')
270
+ const payload: unknown = await response.json()
271
+ const envelope = record(payload)
272
+ if ('rescode' in envelope) {
273
+ if (Number(envelope.rescode) !== 0) throw new Error('Notification request failed')
274
+ return envelope.data
275
+ }
276
+ return payload
277
+ }
278
+
279
+ function resolveDownloadUrl(rawUrl: string, serviceBaseUrl: string): string {
280
+ const value = rawUrl.trim()
281
+ if (!value) return ''
282
+ try {
283
+ const origin = new URL(serviceBaseUrl).origin
284
+ const url = new URL(value, origin)
285
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return ''
286
+ return url.href
287
+ } catch {
288
+ return ''
289
+ }
290
+ }
291
+
292
+ function validateServiceBaseUrl(value: string): string {
293
+ const raw = typeof value === 'string' ? value.trim() : ''
294
+ if (!raw) throw new TypeError('Notification center serviceBaseUrl is required')
295
+ const base = typeof document === 'undefined' ? 'https://localhost/' : document.baseURI
296
+ let parsed: URL
297
+ try {
298
+ parsed = new URL(raw, base)
299
+ } catch {
300
+ throw new TypeError('Notification center serviceBaseUrl is invalid')
301
+ }
302
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
303
+ throw new TypeError('Notification center serviceBaseUrl must use safe HTTP(S)')
304
+ }
305
+ if (parsed.search || parsed.hash) {
306
+ throw new TypeError('Notification center serviceBaseUrl must not contain query or hash')
307
+ }
308
+ return parsed.href.replace(/\/$/, '')
309
+ }
310
+
311
+ async function resolveAccessToken(provider: () => string | Promise<string>): Promise<string> {
312
+ let value = ''
313
+ try {
314
+ value = (await provider()).trim()
315
+ } catch {
316
+ // Provider details may contain credentials and are intentionally discarded.
317
+ }
318
+ if (!value) throw new Error('Access token is unavailable')
319
+ return value
320
+ }
321
+
322
+ function requiredText(value: unknown, maximumLength: number): string {
323
+ const normalized = optionalText(value, maximumLength)
324
+ if (!normalized) throw new Error('Required notification field is missing')
325
+ return normalized
326
+ }
327
+
328
+ function optionalText(value: unknown, maximumLength: number): string {
329
+ return typeof value === 'string' ? value.trim().slice(0, maximumLength) : ''
330
+ }
331
+
332
+ function assignApplicationName(body: Record<string, unknown>, value: unknown): void {
333
+ const applicationName = optionalText(value, 128)
334
+ if (applicationName) body.application_name = applicationName
335
+ }
336
+
337
+ function text(value: unknown): string {
338
+ return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : ''
339
+ }
340
+
341
+ function textOrNull(value: unknown): string | null {
342
+ const normalized = text(value)
343
+ return normalized ? normalized : null
344
+ }
345
+
346
+ function record(value: unknown): Record<string, unknown> {
347
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
348
+ ? (value as Record<string, unknown>)
349
+ : {}
350
+ }
351
+
352
+ function contentType(value: unknown): NotificationContentType {
353
+ return value === 'markdown' ? 'markdown' : 'text'
354
+ }
355
+
356
+ function readStatus(value: unknown): NotificationReadStatus {
357
+ return value === 'read' ? 'read' : 'unread'
358
+ }
359
+
360
+ function nonNegativeInteger(value: unknown): number {
361
+ const parsed = Number(value)
362
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0
363
+ }
364
+
365
+ function safeImageUrl(value: unknown): string {
366
+ const raw = text(value)
367
+ if (!raw) return ''
368
+ try {
369
+ const base = typeof document === 'undefined' ? 'https://localhost/' : document.baseURI
370
+ const url = new URL(raw, base)
371
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) {
372
+ return ''
373
+ }
374
+ return url.href
375
+ } catch {
376
+ return ''
377
+ }
378
+ }