@peanut-admin/admin 0.1.0-alpha.2

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 (67) hide show
  1. package/LICENSE +202 -0
  2. package/admin-core/src/access/access.ts +27 -0
  3. package/admin-core/src/api/client.ts +200 -0
  4. package/admin-core/src/api/problem.ts +67 -0
  5. package/admin-core/src/api/refresh.ts +122 -0
  6. package/admin-core/src/auth/stores.ts +121 -0
  7. package/admin-core/src/generated/api.d.ts +22106 -0
  8. package/admin-core/src/governance/audit.ts +56 -0
  9. package/admin-core/src/governance/catalog.ts +86 -0
  10. package/admin-core/src/governance/index.ts +26 -0
  11. package/admin-core/src/governance/menu.ts +63 -0
  12. package/admin-core/src/governance/roles.ts +144 -0
  13. package/admin-core/src/governance/types.ts +64 -0
  14. package/admin-core/src/index.ts +105 -0
  15. package/admin-core/src/lifecycle/tenant.ts +59 -0
  16. package/admin-core/src/module/contribution.ts +124 -0
  17. package/admin-core/src/runtime/config.ts +55 -0
  18. package/admin-core/src/runtime/errors.ts +81 -0
  19. package/admin-core/src/runtime/guard.ts +40 -0
  20. package/admin-core/src/runtime/navigation.ts +105 -0
  21. package/admin-core/src/runtime/overrides.ts +214 -0
  22. package/admin-core/src/targets/store.ts +153 -0
  23. package/admin-shell/src/config.ts +84 -0
  24. package/admin-shell/src/index.ts +40 -0
  25. package/admin-shell/src/layout.ts +332 -0
  26. package/admin-shell/src/overrides.ts +53 -0
  27. package/admin-shell/src/states.ts +93 -0
  28. package/admin-shell/src/targets.ts +128 -0
  29. package/admin-shell/src/theme.ts +15 -0
  30. package/client-core/src/index.ts +325 -0
  31. package/client-nuxt/src/index.ts +40 -0
  32. package/client-uniapp/src/index.ts +50 -0
  33. package/file-media/src/FileAssetSelector.vue +117 -0
  34. package/file-media/src/FileMediaPage.vue +158 -0
  35. package/file-media/src/contracts.ts +220 -0
  36. package/file-media/src/index.ts +19 -0
  37. package/file-media/src/runtime.ts +210 -0
  38. package/import-export/src/ImportExportPage.vue +155 -0
  39. package/import-export/src/contracts.ts +96 -0
  40. package/import-export/src/index.ts +3 -0
  41. package/import-export/src/runtime.ts +128 -0
  42. package/integration-security/src/IntegrationSecurityPage.vue +402 -0
  43. package/integration-security/src/contracts.ts +171 -0
  44. package/integration-security/src/index.ts +3 -0
  45. package/integration-security/src/runtime.ts +180 -0
  46. package/notification-sms/src/NotificationInboxPage.vue +266 -0
  47. package/notification-sms/src/contracts.ts +195 -0
  48. package/notification-sms/src/index.ts +4 -0
  49. package/notification-sms/src/runtime.ts +143 -0
  50. package/ops-console/src/OpsConsolePage.vue +337 -0
  51. package/ops-console/src/contracts.ts +169 -0
  52. package/ops-console/src/index.ts +3 -0
  53. package/ops-console/src/runtime.ts +199 -0
  54. package/package.json +108 -0
  55. package/reference-codes/src/ReferenceCodesPage.vue +942 -0
  56. package/reference-codes/src/contracts.ts +484 -0
  57. package/reference-codes/src/index.ts +53 -0
  58. package/reference-codes/src/runtime.ts +855 -0
  59. package/settings/src/SettingsPage.vue +536 -0
  60. package/settings/src/contracts.ts +331 -0
  61. package/settings/src/index.ts +45 -0
  62. package/settings/src/runtime.ts +545 -0
  63. package/task-job/src/TaskJobPage.vue +120 -0
  64. package/task-job/src/contracts.ts +117 -0
  65. package/task-job/src/index.ts +2 -0
  66. package/task-job/src/runtime.ts +105 -0
  67. package/testing/src/index.ts +141 -0
@@ -0,0 +1,93 @@
1
+ import { ElButton } from 'element-plus'
2
+ import { defineComponent, h } from 'vue'
3
+ import type { PropType } from 'vue'
4
+
5
+ interface StateDefaults {
6
+ title: string
7
+ message: string
8
+ actionLabel?: string
9
+ }
10
+
11
+ const createStateComponent = (name: string, state: string, defaults: StateDefaults) => defineComponent({
12
+ name,
13
+ props: {
14
+ title: { type: String, default: defaults.title },
15
+ message: { type: String, default: defaults.message },
16
+ requestId: { type: String, default: null },
17
+ retryAfter: { type: String as PropType<string | null>, default: null },
18
+ actionLabel: { type: String, default: defaults.actionLabel ?? null },
19
+ onAction: { type: Function as PropType<() => void>, default: null },
20
+ },
21
+ emits: {
22
+ action: () => true,
23
+ },
24
+ setup(props, { emit, slots }) {
25
+ return () => h('section', {
26
+ class: ['pa-state', `pa-state--${name.replace(/State$/, '').toLowerCase()}`],
27
+ 'data-state': state,
28
+ role: 'status',
29
+ 'aria-live': 'polite',
30
+ }, [
31
+ h('h2', { class: 'pa-state__title' }, props.title),
32
+ h('p', { class: 'pa-state__message' }, props.message),
33
+ props.requestId === null
34
+ ? null
35
+ : h('p', { class: 'pa-state__request-id' }, `Request ID: ${props.requestId}`),
36
+ props.retryAfter === null
37
+ ? null
38
+ : h('p', { class: 'pa-state__retry-after' }, `Retry after: ${props.retryAfter}`),
39
+ slots.default?.(),
40
+ props.actionLabel === null
41
+ ? null
42
+ : h(ElButton, {
43
+ onClick: () => {
44
+ emit('action')
45
+ },
46
+ }, () => props.actionLabel),
47
+ ])
48
+ },
49
+ })
50
+
51
+ export const EmptyState = createStateComponent('EmptyState', 'empty', {
52
+ title: 'No data',
53
+ message: 'There is nothing to display.',
54
+ })
55
+
56
+ export const ForbiddenState = createStateComponent('ForbiddenState', 'forbidden', {
57
+ title: 'Access denied',
58
+ message: 'You do not have permission to view this page.',
59
+ })
60
+
61
+ export const NotFoundState = createStateComponent('NotFoundState', 'not-found', {
62
+ title: 'Not found',
63
+ message: 'The requested resource is unavailable.',
64
+ })
65
+
66
+ export const ModuleUnavailableState = createStateComponent('ModuleUnavailableState', 'module-unavailable', {
67
+ title: 'Module unavailable',
68
+ message: 'This module is currently unavailable.',
69
+ actionLabel: 'Retry',
70
+ })
71
+
72
+ export const ConflictState = createStateComponent('ConflictState', 'conflict', {
73
+ title: 'Content changed',
74
+ message: 'Reload the latest version before continuing.',
75
+ actionLabel: 'Reload',
76
+ })
77
+
78
+ export const RateLimitState = createStateComponent('RateLimitState', 'rate-limit', {
79
+ title: 'Too many requests',
80
+ message: 'Wait before trying again.',
81
+ })
82
+
83
+ export const ServiceUnavailableState = createStateComponent('ServiceUnavailableState', 'service-unavailable', {
84
+ title: 'Service unavailable',
85
+ message: 'The service is temporarily unavailable.',
86
+ actionLabel: 'Retry',
87
+ })
88
+
89
+ export const SessionExpiredState = createStateComponent('SessionExpiredState', 'session-expired', {
90
+ title: 'Session expired',
91
+ message: 'Sign in again to continue.',
92
+ actionLabel: 'Sign in',
93
+ })
@@ -0,0 +1,128 @@
1
+ /* eslint-disable vue/one-component-per-file */
2
+
3
+ import type { TargetCandidate, TypedTarget } from '@peanut-admin/admin/core'
4
+ import { ElOption, ElPagination, ElSelect } from 'element-plus'
5
+ import { computed, defineComponent, h } from 'vue'
6
+ import type { Component, PropType } from 'vue'
7
+
8
+ const SelectComponent = ElSelect as unknown as Component
9
+ const OptionComponent = ElOption as unknown as Component
10
+ const PaginationComponent = ElPagination as unknown as Component
11
+
12
+ const targetKey = (target: TypedTarget): string => JSON.stringify([
13
+ target.target_resource_key,
14
+ target.target_role,
15
+ target.target_id,
16
+ ])
17
+
18
+ export const TargetSelector = defineComponent({
19
+ name: 'TargetSelector',
20
+ props: {
21
+ modelValue: {
22
+ type: Array as PropType<readonly TypedTarget[]>,
23
+ default: () => [],
24
+ },
25
+ candidates: {
26
+ type: Array as PropType<readonly TargetCandidate[]>,
27
+ default: () => [],
28
+ },
29
+ multiple: { type: Boolean, default: false },
30
+ loading: { type: Boolean, default: false },
31
+ disabled: { type: Boolean, default: false },
32
+ placeholder: { type: String, default: 'Select target' },
33
+ page: { type: Number, default: 1 },
34
+ pageSize: { type: Number, default: 20 },
35
+ total: { type: Number, default: 0 },
36
+ },
37
+ emits: {
38
+ 'update:modelValue': (targets: readonly TypedTarget[]) => Array.isArray(targets),
39
+ search: (query: string) => typeof query === 'string',
40
+ 'page-change': (page: number) => Number.isInteger(page) && page >= 1,
41
+ },
42
+ setup(props, { emit }) {
43
+ const candidateMap = computed(() => new Map(
44
+ props.candidates.map(candidate => [targetKey(candidate), candidate]),
45
+ ))
46
+ const selectedKeys = computed(() => props.modelValue.map(targetKey))
47
+ const updateSelection = (value: unknown): void => {
48
+ const values = (Array.isArray(value) ? value : [value]).filter(
49
+ (candidate): candidate is string => typeof candidate === 'string',
50
+ )
51
+ const targets = values.flatMap(key => {
52
+ const candidate = candidateMap.value.get(key)
53
+ return candidate === undefined
54
+ ? []
55
+ : [{
56
+ target_resource_key: candidate.target_resource_key,
57
+ target_role: candidate.target_role,
58
+ target_id: candidate.target_id,
59
+ }]
60
+ })
61
+ emit('update:modelValue', props.multiple ? targets : targets.slice(0, 1))
62
+ }
63
+
64
+ return () => h('div', { class: 'pa-target-selector' }, [
65
+ h(SelectComponent, {
66
+ modelValue: props.multiple ? selectedKeys.value : (selectedKeys.value[0] ?? null),
67
+ 'onUpdate:modelValue': updateSelection,
68
+ multiple: props.multiple,
69
+ filterable: true,
70
+ remote: true,
71
+ remoteMethod: (query: string) => emit('search', query),
72
+ loading: props.loading,
73
+ disabled: props.disabled,
74
+ placeholder: props.placeholder,
75
+ class: 'pa-target-selector__select',
76
+ }, () => props.candidates.map(candidate => h(OptionComponent, {
77
+ key: targetKey(candidate),
78
+ value: targetKey(candidate),
79
+ label: candidate.label,
80
+ }))),
81
+ props.total <= props.pageSize
82
+ ? null
83
+ : h(PaginationComponent, {
84
+ class: 'pa-target-selector__pagination',
85
+ currentPage: props.page,
86
+ pageSize: props.pageSize,
87
+ total: props.total,
88
+ layout: 'prev, pager, next',
89
+ 'onUpdate:currentPage': (page: number) => emit('page-change', page),
90
+ }),
91
+ ])
92
+ },
93
+ })
94
+
95
+ export type TargetScopeMode = 'zero' | 'single' | 'multiple' | 'aggregate'
96
+
97
+ export const TargetScopeSummary = defineComponent({
98
+ name: 'TargetScopeSummary',
99
+ props: {
100
+ mode: {
101
+ type: String as PropType<TargetScopeMode>,
102
+ required: true,
103
+ },
104
+ availableCount: { type: Number, required: true },
105
+ selectedCount: { type: Number, default: 0 },
106
+ digest: { type: String, default: null },
107
+ },
108
+ setup(props) {
109
+ const message = computed(() => {
110
+ switch (props.mode) {
111
+ case 'zero':
112
+ return 'No available targets'
113
+ case 'single':
114
+ return '1 available target'
115
+ case 'aggregate':
116
+ return `Read-only aggregate across ${props.availableCount} targets`
117
+ default:
118
+ return `${props.selectedCount} of ${props.availableCount} targets selected`
119
+ }
120
+ })
121
+
122
+ return () => h('div', {
123
+ class: ['pa-target-scope-summary', `is-${props.mode}`],
124
+ role: 'status',
125
+ 'aria-label': 'Target scope',
126
+ }, message.value)
127
+ },
128
+ })
@@ -0,0 +1,15 @@
1
+ export const SHELL_THEME_TOKENS = {
2
+ headerHeight: '--pa-shell-header-height',
3
+ sidebarWidth: '--pa-shell-sidebar-width',
4
+ sidebarCollapsedWidth: '--pa-shell-sidebar-collapsed-width',
5
+ contentMaxWidth: '--pa-shell-content-max-width',
6
+ surfaceColor: '--pa-shell-surface-color',
7
+ borderColor: '--pa-shell-border-color',
8
+ textColor: '--pa-shell-text-color',
9
+ mutedTextColor: '--pa-shell-muted-text-color',
10
+ focusColor: '--pa-shell-focus-color',
11
+ } as const
12
+
13
+ export type ShellThemeToken = typeof SHELL_THEME_TOKENS[keyof typeof SHELL_THEME_TOKENS]
14
+
15
+ export type ShellSlotName = 'header' | 'sidebar' | 'breadcrumb' | 'tabs' | 'default'
@@ -0,0 +1,325 @@
1
+ export type ClientRequestMethod =
2
+ | 'DELETE'
3
+ | 'GET'
4
+ | 'HEAD'
5
+ | 'OPTIONS'
6
+ | 'PATCH'
7
+ | 'POST'
8
+ | 'PUT'
9
+
10
+ export interface ClientRequest<TData = unknown> {
11
+ readonly path: string
12
+ readonly method?: ClientRequestMethod
13
+ readonly data?: TData
14
+ readonly headers?: HeadersInit
15
+ readonly auth?: boolean
16
+ }
17
+
18
+ export interface ClientTransportRequest<TData = unknown> {
19
+ readonly path: string
20
+ readonly method: ClientRequestMethod
21
+ readonly data?: TData
22
+ readonly headers: Headers
23
+ }
24
+
25
+ export type ClientTransport = (request: ClientTransportRequest) => Promise<unknown>
26
+
27
+ export interface ClientSession {
28
+ accessToken: () => string | null | undefined
29
+ clear: () => void | Promise<void>
30
+ }
31
+
32
+ export interface ClientDecodeSuccess<TData = unknown> {
33
+ readonly kind: 'success'
34
+ readonly data: TData
35
+ }
36
+
37
+ export interface ClientDecodeUnauthorized {
38
+ readonly kind: 'unauthorized'
39
+ readonly code?: string
40
+ readonly message?: string
41
+ }
42
+
43
+ export interface ClientDecodeBusiness {
44
+ readonly kind: 'business'
45
+ readonly code?: string
46
+ readonly message?: string
47
+ }
48
+
49
+ export type ClientDecodeResult<TData = unknown> =
50
+ | ClientDecodeSuccess<TData>
51
+ | ClientDecodeUnauthorized
52
+ | ClientDecodeBusiness
53
+
54
+ export type ClientDecoder<TData = unknown> = (
55
+ response: unknown,
56
+ request: ClientTransportRequest,
57
+ ) => ClientDecodeResult<TData> | Promise<ClientDecodeResult<TData>>
58
+
59
+ export type ClientRequestErrorKind =
60
+ | 'business'
61
+ | 'decoder'
62
+ | 'path'
63
+ | 'session'
64
+ | 'transport'
65
+ | 'unauthorized'
66
+
67
+ export class ClientRequestError extends Error {
68
+ readonly kind: ClientRequestErrorKind
69
+ readonly code: string
70
+
71
+ constructor(kind: ClientRequestErrorKind, code: string, message: string) {
72
+ super(message)
73
+ this.name = 'ClientRequestError'
74
+ this.kind = kind
75
+ this.code = code
76
+ }
77
+ }
78
+
79
+ export interface ClientHooks {
80
+ readonly unauthorized?: (error: ClientRequestError) => void | Promise<void>
81
+ readonly businessError?: (error: ClientRequestError) => void | Promise<void>
82
+ }
83
+
84
+ export interface ClientOptions {
85
+ readonly transport: ClientTransport
86
+ readonly session: ClientSession
87
+ readonly decoder: ClientDecoder
88
+ readonly hooks?: ClientHooks
89
+ }
90
+
91
+ export interface Client {
92
+ request: <TData = unknown>(request: ClientRequest) => Promise<TData>
93
+ }
94
+
95
+ const pathControlCharacters = /[\u0000-\u001f\u007f]/
96
+ const controlCharacters = /[\u0000-\u001f\u007f]/g
97
+ const encodedUnsafePathSegment = /%(?:2e|2f|5c)/i
98
+ const absolutePath = /^[A-Za-z][A-Za-z0-9+.-]*:/
99
+ const safeCode = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/
100
+ const defaultUnauthorizedCode = 'CLIENT_UNAUTHORIZED'
101
+ const defaultBusinessCode = 'CLIENT_BUSINESS_ERROR'
102
+ const genericUnauthorizedMessage = 'Authentication is required.'
103
+ const genericBusinessMessage = 'The request was rejected.'
104
+
105
+ const invalidPath = (): ClientRequestError => (
106
+ new ClientRequestError('path', 'CLIENT_PATH_INVALID', 'The request path is invalid.')
107
+ )
108
+
109
+ const isRecord = (value: unknown): value is Record<string, unknown> => (
110
+ typeof value === 'object' && value !== null
111
+ )
112
+
113
+ const assertClientPath = (path: string): void => {
114
+ if (
115
+ typeof path !== 'string'
116
+ || path === ''
117
+ || path.trim() !== path
118
+ || path.startsWith('//')
119
+ || absolutePath.test(path)
120
+ || path.includes('\\')
121
+ || pathControlCharacters.test(path)
122
+ || encodedUnsafePathSegment.test(path)
123
+ ) {
124
+ throw invalidPath()
125
+ }
126
+
127
+ const pathname = path.split(/[?#]/u, 1)[0] ?? ''
128
+ if (pathname === '' || pathname.split('/').some(segment => segment === '.' || segment === '..')) {
129
+ throw invalidPath()
130
+ }
131
+ }
132
+
133
+ const validBaseUrl = (baseUrl: string): URL => {
134
+ let url: URL
135
+ try {
136
+ url = new URL(baseUrl)
137
+ } catch {
138
+ throw new ClientRequestError('path', 'CLIENT_BASE_URL_INVALID', 'The client base URL is invalid.')
139
+ }
140
+
141
+ if (
142
+ !['http:', 'https:'].includes(url.protocol)
143
+ || url.username !== ''
144
+ || url.password !== ''
145
+ || url.search !== ''
146
+ || url.hash !== ''
147
+ ) {
148
+ throw new ClientRequestError('path', 'CLIENT_BASE_URL_INVALID', 'The client base URL is invalid.')
149
+ }
150
+
151
+ return url
152
+ }
153
+
154
+ export const resolveClientUrl = (baseUrl: string, path: string): string => {
155
+ assertClientPath(path)
156
+ const base = validBaseUrl(baseUrl)
157
+ const basePath = base.pathname.endsWith('/') ? base.pathname : `${base.pathname}/`
158
+ const root = new URL(base.origin)
159
+ root.pathname = basePath
160
+ return new URL(path, root).toString()
161
+ }
162
+
163
+ const safeMessage = (value: unknown, fallback: string): string => {
164
+ if (typeof value !== 'string' || value.trim() === '') return fallback
165
+ const normalized = value.replace(controlCharacters, ' ').trim()
166
+ return normalized === '' ? fallback : normalized.slice(0, 512)
167
+ }
168
+
169
+ const safeErrorCode = (value: unknown, fallback: string): string => (
170
+ typeof value === 'string' && safeCode.test(value) ? value : fallback
171
+ )
172
+
173
+ const decodeKind = (value: unknown): string | null => {
174
+ if (!isRecord(value)) return null
175
+ const candidate = value.kind
176
+ return typeof candidate === 'string' ? candidate : null
177
+ }
178
+
179
+ const isDecodedResult = (value: unknown): value is ClientDecodeResult => {
180
+ const kind = decodeKind(value)
181
+ if (kind === 'success') return isRecord(value) && 'data' in value
182
+ if (kind === 'unauthorized' || kind === 'business') return isRecord(value)
183
+ return false
184
+ }
185
+
186
+ const normalizedDecodedResult = (value: ClientDecodeResult): ClientDecodeResult => {
187
+ const kind = decodeKind(value)
188
+ if (kind === 'success') return { kind: 'success', data: (value as ClientDecodeSuccess).data }
189
+ if (kind === 'unauthorized') {
190
+ const result = value as ClientDecodeUnauthorized
191
+ return {
192
+ kind: 'unauthorized',
193
+ code: safeErrorCode(result.code, defaultUnauthorizedCode),
194
+ message: safeMessage(result.message, genericUnauthorizedMessage),
195
+ }
196
+ }
197
+
198
+ const result = value as ClientDecodeBusiness
199
+ return {
200
+ kind: 'business',
201
+ code: safeErrorCode(result.code, defaultBusinessCode),
202
+ message: safeMessage(result.message, genericBusinessMessage),
203
+ }
204
+ }
205
+
206
+ const requestHeaders = (headers: HeadersInit | undefined): Headers => {
207
+ const result = new Headers(headers)
208
+ // Headers is case-insensitive and delete removes all values for this name.
209
+ result.delete('Authorization')
210
+ return result
211
+ }
212
+
213
+ const methodOf = (method: ClientRequestMethod | undefined): ClientRequestMethod => (
214
+ (method ?? 'GET').toUpperCase() as ClientRequestMethod
215
+ )
216
+
217
+ const requestError = (kind: ClientRequestErrorKind, code: string, message: string): ClientRequestError => (
218
+ new ClientRequestError(kind, safeErrorCode(code, `CLIENT_${kind.toUpperCase()}_ERROR`), safeMessage(message, 'The request could not be completed.'))
219
+ )
220
+
221
+ export const createClient = (options: ClientOptions): Client => {
222
+ let unauthorizedHandling: Promise<void> | null = null
223
+
224
+ const invokeUnauthorized = async (error: ClientRequestError): Promise<void> => {
225
+ if (unauthorizedHandling === null) {
226
+ unauthorizedHandling = (async () => {
227
+ try {
228
+ await options.session.clear()
229
+ } catch {
230
+ throw requestError('session', 'CLIENT_SESSION_CLEAR_ERROR', 'The client session could not be cleared.')
231
+ }
232
+ try {
233
+ await options.hooks?.unauthorized?.(error)
234
+ } catch {
235
+ // Hook failures cannot turn an unauthorized response into success.
236
+ }
237
+ })().finally(() => {
238
+ unauthorizedHandling = null
239
+ })
240
+ }
241
+
242
+ await unauthorizedHandling
243
+ }
244
+
245
+ const request = async <TData = unknown>(input: ClientRequest): Promise<TData> => {
246
+ try {
247
+ assertClientPath(input.path)
248
+ } catch (error) {
249
+ if (error instanceof ClientRequestError) throw error
250
+ throw invalidPath()
251
+ }
252
+
253
+ const method = methodOf(input.method)
254
+ let headers: Headers
255
+ try {
256
+ headers = requestHeaders(input.headers)
257
+ } catch {
258
+ throw requestError('path', 'CLIENT_HEADERS_INVALID', 'The request headers are invalid.')
259
+ }
260
+
261
+ if (input.auth !== false) {
262
+ let token: string | null | undefined
263
+ try {
264
+ token = options.session.accessToken()
265
+ } catch {
266
+ throw requestError('session', 'CLIENT_SESSION_ERROR', 'The client session is unavailable.')
267
+ }
268
+ if (typeof token === 'string' && token !== '') headers.set('Authorization', `Bearer ${token}`)
269
+ }
270
+
271
+ const transportRequest: ClientTransportRequest = {
272
+ path: input.path,
273
+ method,
274
+ ...(input.data !== undefined ? { data: input.data } : {}),
275
+ headers,
276
+ }
277
+
278
+ let response: unknown
279
+ try {
280
+ response = await options.transport(transportRequest)
281
+ } catch {
282
+ throw requestError('transport', 'CLIENT_TRANSPORT_ERROR', 'The request could not be completed.')
283
+ }
284
+
285
+ let decoded: ClientDecodeResult
286
+ try {
287
+ decoded = await options.decoder(response, transportRequest)
288
+ if (!isDecodedResult(decoded)) throw new Error('invalid decoder result')
289
+ decoded = normalizedDecodedResult(decoded)
290
+ } catch {
291
+ throw requestError('decoder', 'CLIENT_DECODER_INVALID', 'The response could not be understood.')
292
+ }
293
+
294
+ if (decoded.kind === 'success') return decoded.data as TData
295
+
296
+ if (decoded.kind === 'unauthorized') {
297
+ const error = new ClientRequestError(
298
+ 'unauthorized',
299
+ safeErrorCode(decoded.code, defaultUnauthorizedCode),
300
+ safeMessage(decoded.message, genericUnauthorizedMessage),
301
+ )
302
+ await invokeUnauthorized(error)
303
+ throw error
304
+ }
305
+
306
+ const error = new ClientRequestError(
307
+ 'business',
308
+ safeErrorCode(decoded.code, defaultBusinessCode),
309
+ safeMessage(decoded.message, genericBusinessMessage),
310
+ )
311
+ try {
312
+ await options.hooks?.businessError?.(error)
313
+ } catch {
314
+ // Hook failures remain attached to this stable business failure path.
315
+ }
316
+ throw error
317
+ }
318
+
319
+ return { request }
320
+ }
321
+
322
+ export type ClientResult<TData = unknown> = ClientDecodeResult<TData>
323
+ export type ClientRequestResult<TData = unknown> = Promise<TData>
324
+ export type ClientHook = (error: ClientRequestError) => void | Promise<void>
325
+ export type ClientRequestHeaders = HeadersInit
@@ -0,0 +1,40 @@
1
+ import { resolveClientUrl } from '@peanut-admin/admin/client'
2
+ import type { ClientTransport, ClientTransportRequest } from '@peanut-admin/admin/client'
3
+
4
+ export interface NuxtClientFetchOptions {
5
+ readonly method?: string
6
+ readonly query?: unknown
7
+ readonly body?: unknown
8
+ readonly headers?: HeadersInit
9
+ }
10
+
11
+ export type NuxtClientFetch = (
12
+ url: string,
13
+ options?: NuxtClientFetchOptions,
14
+ ) => Promise<unknown>
15
+
16
+ export interface NuxtClientTransportOptions {
17
+ readonly baseUrl: string
18
+ readonly $fetch: NuxtClientFetch
19
+ }
20
+
21
+ const isQueryMethod = (method: string): boolean => method === 'GET' || method === 'DELETE'
22
+
23
+ export const createNuxtClientTransport = (
24
+ options: NuxtClientTransportOptions,
25
+ ): ClientTransport => {
26
+ return async (request: ClientTransportRequest): Promise<unknown> => {
27
+ const method = request.method.toUpperCase()
28
+ const url = resolveClientUrl(options.baseUrl, request.path)
29
+ const fetchOptions: NuxtClientFetchOptions = {
30
+ method,
31
+ headers: request.headers,
32
+ ...(request.data !== undefined
33
+ ? isQueryMethod(method)
34
+ ? { query: request.data }
35
+ : { body: request.data }
36
+ : {}),
37
+ }
38
+ return options.$fetch(url, fetchOptions)
39
+ }
40
+ }
@@ -0,0 +1,50 @@
1
+ import { resolveClientUrl } from '@peanut-admin/admin/client'
2
+ import type { ClientTransport, ClientTransportRequest } from '@peanut-admin/admin/client'
3
+
4
+ export interface UniAppClientResponse {
5
+ readonly data: unknown
6
+ }
7
+
8
+ export interface UniAppClientRequestOptions {
9
+ readonly url: string
10
+ readonly method: string
11
+ readonly data?: unknown
12
+ readonly header: Record<string, string>
13
+ readonly success?: (response: UniAppClientResponse) => void
14
+ readonly fail?: (error: unknown) => void
15
+ }
16
+
17
+ export type UniAppClientRequest = (
18
+ options: UniAppClientRequestOptions,
19
+ ) => void
20
+
21
+ export interface UniAppClientTransportOptions {
22
+ readonly baseUrl: string
23
+ readonly request: UniAppClientRequest
24
+ }
25
+
26
+ const headersRecord = (headers: Headers): Record<string, string> => {
27
+ const result: Record<string, string> = {}
28
+ headers.forEach((value, key) => {
29
+ result[key] = value
30
+ })
31
+ return result
32
+ }
33
+
34
+ export const createUniAppClientTransport = (
35
+ options: UniAppClientTransportOptions,
36
+ ): ClientTransport => async (request: ClientTransportRequest): Promise<unknown> => (
37
+ new Promise<unknown>((resolve, reject) => {
38
+ const method = request.method.toUpperCase()
39
+ const requestOptions: UniAppClientRequestOptions = {
40
+ url: resolveClientUrl(options.baseUrl, request.path),
41
+ method,
42
+ ...(request.data !== undefined ? { data: request.data } : {}),
43
+ header: headersRecord(request.headers),
44
+ success: response => resolve(response.data),
45
+ fail: error => reject(error),
46
+ }
47
+
48
+ options.request(requestOptions)
49
+ })
50
+ )