@peanut-admin/admin 0.1.0-alpha.11
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/LICENSE +202 -0
- package/admin-core/src/access/access.ts +27 -0
- package/admin-core/src/access/permission-policy.ts +37 -0
- package/admin-core/src/api/client.ts +200 -0
- package/admin-core/src/api/problem.ts +67 -0
- package/admin-core/src/api/refresh.ts +122 -0
- package/admin-core/src/auth/stores.ts +121 -0
- package/admin-core/src/auth/tenant-session.ts +32 -0
- package/admin-core/src/generated/api.d.ts +22106 -0
- package/admin-core/src/governance/audit.ts +56 -0
- package/admin-core/src/governance/catalog.ts +86 -0
- package/admin-core/src/governance/index.ts +26 -0
- package/admin-core/src/governance/menu.ts +63 -0
- package/admin-core/src/governance/roles.ts +144 -0
- package/admin-core/src/governance/types.ts +64 -0
- package/admin-core/src/index.ts +122 -0
- package/admin-core/src/lifecycle/tenant.ts +59 -0
- package/admin-core/src/module/contribution.ts +124 -0
- package/admin-core/src/module/plugin-contribution-policy.ts +51 -0
- package/admin-core/src/module/tenant-modules.ts +20 -0
- package/admin-core/src/runtime/config.ts +55 -0
- package/admin-core/src/runtime/errors.ts +81 -0
- package/admin-core/src/runtime/guard.ts +40 -0
- package/admin-core/src/runtime/navigation.ts +105 -0
- package/admin-core/src/runtime/overrides.ts +214 -0
- package/admin-core/src/targets/store.ts +153 -0
- package/admin-shell/src/config.ts +84 -0
- package/admin-shell/src/deployment-mode.ts +36 -0
- package/admin-shell/src/index.ts +44 -0
- package/admin-shell/src/layout.ts +332 -0
- package/admin-shell/src/overrides.ts +53 -0
- package/admin-shell/src/states.ts +93 -0
- package/admin-shell/src/tabs.ts +31 -0
- package/admin-shell/src/targets.ts +128 -0
- package/admin-shell/src/theme.ts +15 -0
- package/client-core/src/index.ts +415 -0
- package/client-nuxt/src/index.ts +48 -0
- package/client-uniapp/src/index.ts +50 -0
- package/file-media/src/FileAssetSelector.vue +117 -0
- package/file-media/src/FileMediaPage.vue +158 -0
- package/file-media/src/contracts.ts +220 -0
- package/file-media/src/index.ts +19 -0
- package/file-media/src/runtime.ts +210 -0
- package/import-export/src/ImportExportPage.vue +155 -0
- package/import-export/src/contracts.ts +96 -0
- package/import-export/src/index.ts +3 -0
- package/import-export/src/runtime.ts +128 -0
- package/integration-security/src/IntegrationSecurityPage.vue +402 -0
- package/integration-security/src/contracts.ts +171 -0
- package/integration-security/src/index.ts +3 -0
- package/integration-security/src/runtime.ts +180 -0
- package/notification-sms/src/NotificationInboxPage.vue +266 -0
- package/notification-sms/src/contracts.ts +195 -0
- package/notification-sms/src/index.ts +4 -0
- package/notification-sms/src/runtime.ts +143 -0
- package/ops-console/src/OpsConsolePage.vue +337 -0
- package/ops-console/src/contracts.ts +169 -0
- package/ops-console/src/index.ts +3 -0
- package/ops-console/src/runtime.ts +199 -0
- package/package.json +139 -0
- package/reference-codes/src/ReferenceCodesPage.vue +942 -0
- package/reference-codes/src/contracts.ts +484 -0
- package/reference-codes/src/index.ts +53 -0
- package/reference-codes/src/runtime.ts +855 -0
- package/settings/src/SettingsPage.vue +536 -0
- package/settings/src/contracts.ts +331 -0
- package/settings/src/index.ts +45 -0
- package/settings/src/runtime.ts +545 -0
- package/task-job/src/TaskJobPage.vue +120 -0
- package/task-job/src/contracts.ts +117 -0
- package/task-job/src/index.ts +2 -0
- package/task-job/src/runtime.ts +105 -0
- package/testing/src/index.ts +141 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
export type ClientRequestMethod =
|
|
2
|
+
| 'DELETE'
|
|
3
|
+
| 'GET'
|
|
4
|
+
| 'HEAD'
|
|
5
|
+
| 'OPTIONS'
|
|
6
|
+
| 'PATCH'
|
|
7
|
+
| 'POST'
|
|
8
|
+
| 'PUT'
|
|
9
|
+
|
|
10
|
+
export interface ClientHeaderSource {
|
|
11
|
+
forEach: (callback: (value: string, key: string) => void) => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type ClientRequestHeaders =
|
|
15
|
+
| Readonly<Record<string, string>>
|
|
16
|
+
| ReadonlyArray<readonly [string, string]>
|
|
17
|
+
| ClientHeaderSource
|
|
18
|
+
|
|
19
|
+
export interface ClientHeaders extends ClientHeaderSource {
|
|
20
|
+
delete: (name: string) => void
|
|
21
|
+
get: (name: string) => string | null
|
|
22
|
+
has: (name: string) => boolean
|
|
23
|
+
set: (name: string, value: string) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ClientRequest<TData = unknown> {
|
|
27
|
+
readonly path: string
|
|
28
|
+
readonly method?: ClientRequestMethod
|
|
29
|
+
readonly data?: TData
|
|
30
|
+
readonly headers?: ClientRequestHeaders
|
|
31
|
+
readonly auth?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ClientTransportRequest<TData = unknown> {
|
|
35
|
+
readonly path: string
|
|
36
|
+
readonly method: ClientRequestMethod
|
|
37
|
+
readonly data?: TData
|
|
38
|
+
readonly headers: ClientHeaders
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type ClientTransport = (request: ClientTransportRequest) => Promise<unknown>
|
|
42
|
+
|
|
43
|
+
export interface ClientSession {
|
|
44
|
+
accessToken: () => string | null | undefined
|
|
45
|
+
clear: () => void | Promise<void>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ClientDecodeSuccess<TData = unknown> {
|
|
49
|
+
readonly kind: 'success'
|
|
50
|
+
readonly data: TData
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ClientDecodeUnauthorized {
|
|
54
|
+
readonly kind: 'unauthorized'
|
|
55
|
+
readonly code?: string
|
|
56
|
+
readonly message?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ClientDecodeBusiness {
|
|
60
|
+
readonly kind: 'business'
|
|
61
|
+
readonly code?: string
|
|
62
|
+
readonly message?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type ClientDecodeResult<TData = unknown> =
|
|
66
|
+
| ClientDecodeSuccess<TData>
|
|
67
|
+
| ClientDecodeUnauthorized
|
|
68
|
+
| ClientDecodeBusiness
|
|
69
|
+
|
|
70
|
+
export type ClientDecoder<TData = unknown> = (
|
|
71
|
+
response: unknown,
|
|
72
|
+
request: ClientTransportRequest,
|
|
73
|
+
) => ClientDecodeResult<TData> | Promise<ClientDecodeResult<TData>>
|
|
74
|
+
|
|
75
|
+
export type ClientRequestErrorKind =
|
|
76
|
+
| 'business'
|
|
77
|
+
| 'decoder'
|
|
78
|
+
| 'path'
|
|
79
|
+
| 'session'
|
|
80
|
+
| 'transport'
|
|
81
|
+
| 'unauthorized'
|
|
82
|
+
|
|
83
|
+
export class ClientRequestError extends Error {
|
|
84
|
+
readonly kind: ClientRequestErrorKind
|
|
85
|
+
readonly code: string
|
|
86
|
+
|
|
87
|
+
constructor(kind: ClientRequestErrorKind, code: string, message: string) {
|
|
88
|
+
super(message)
|
|
89
|
+
this.name = 'ClientRequestError'
|
|
90
|
+
this.kind = kind
|
|
91
|
+
this.code = code
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ClientHooks {
|
|
96
|
+
readonly unauthorized?: (error: ClientRequestError) => void | Promise<void>
|
|
97
|
+
readonly businessError?: (error: ClientRequestError) => void | Promise<void>
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ClientOptions {
|
|
101
|
+
readonly transport: ClientTransport
|
|
102
|
+
readonly session: ClientSession
|
|
103
|
+
readonly decoder: ClientDecoder
|
|
104
|
+
readonly hooks?: ClientHooks
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface Client {
|
|
108
|
+
request: <TData = unknown>(request: ClientRequest) => Promise<TData>
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const pathControlCharacters = /[\u0000-\u001f\u007f]/
|
|
112
|
+
const controlCharacters = /[\u0000-\u001f\u007f]/g
|
|
113
|
+
const encodedUnsafePathSegment = /%(?:2e|2f|5c)/i
|
|
114
|
+
const absolutePath = /^[A-Za-z][A-Za-z0-9+.-]*:/
|
|
115
|
+
const httpBaseUrl = /^(https?):\/\/([^/?#]+)(\/[^?#]*)?$/i
|
|
116
|
+
const validHeaderName = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/
|
|
117
|
+
const invalidHeaderValue = /[\u0000\r\n]/
|
|
118
|
+
const safeCode = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/
|
|
119
|
+
const defaultUnauthorizedCode = 'CLIENT_UNAUTHORIZED'
|
|
120
|
+
const defaultBusinessCode = 'CLIENT_BUSINESS_ERROR'
|
|
121
|
+
const genericUnauthorizedMessage = 'Authentication is required.'
|
|
122
|
+
const genericBusinessMessage = 'The request was rejected.'
|
|
123
|
+
|
|
124
|
+
const invalidPath = (): ClientRequestError => (
|
|
125
|
+
new ClientRequestError('path', 'CLIENT_PATH_INVALID', 'The request path is invalid.')
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => (
|
|
129
|
+
typeof value === 'object' && value !== null
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
const assertClientPath = (path: string): void => {
|
|
133
|
+
if (
|
|
134
|
+
typeof path !== 'string'
|
|
135
|
+
|| path === ''
|
|
136
|
+
|| path.trim() !== path
|
|
137
|
+
|| path.startsWith('//')
|
|
138
|
+
|| absolutePath.test(path)
|
|
139
|
+
|| path.includes('\\')
|
|
140
|
+
|| pathControlCharacters.test(path)
|
|
141
|
+
|| encodedUnsafePathSegment.test(path)
|
|
142
|
+
) {
|
|
143
|
+
throw invalidPath()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const pathname = path.split(/[?#]/u, 1)[0] ?? ''
|
|
147
|
+
if (pathname === '' || pathname.split('/').some(segment => segment === '.' || segment === '..')) {
|
|
148
|
+
throw invalidPath()
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const invalidBaseUrl = (): ClientRequestError => (
|
|
153
|
+
new ClientRequestError('path', 'CLIENT_BASE_URL_INVALID', 'The client base URL is invalid.')
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
const validBaseUrl = (baseUrl: string): { origin: string; pathname: string } => {
|
|
157
|
+
if (
|
|
158
|
+
typeof baseUrl !== 'string'
|
|
159
|
+
|| baseUrl.trim() !== baseUrl
|
|
160
|
+
|| pathControlCharacters.test(baseUrl)
|
|
161
|
+
|| baseUrl.includes('\\')
|
|
162
|
+
) {
|
|
163
|
+
throw invalidBaseUrl()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const match = httpBaseUrl.exec(baseUrl)
|
|
167
|
+
const protocol = match?.[1]
|
|
168
|
+
const authority = match?.[2]
|
|
169
|
+
const pathname = match?.[3] ?? '/'
|
|
170
|
+
if (
|
|
171
|
+
protocol === undefined
|
|
172
|
+
|| authority === undefined
|
|
173
|
+
|| authority === ''
|
|
174
|
+
|| authority.includes('@')
|
|
175
|
+
|| /\s/.test(authority)
|
|
176
|
+
|| encodedUnsafePathSegment.test(pathname)
|
|
177
|
+
|| pathname.split('/').some(segment => segment === '.' || segment === '..')
|
|
178
|
+
) {
|
|
179
|
+
throw invalidBaseUrl()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
origin: `${protocol.toLowerCase()}://${authority}`,
|
|
184
|
+
pathname,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export const resolveClientUrl = (baseUrl: string, path: string): string => {
|
|
189
|
+
assertClientPath(path)
|
|
190
|
+
const base = validBaseUrl(baseUrl)
|
|
191
|
+
const basePath = base.pathname.endsWith('/') ? base.pathname : `${base.pathname}/`
|
|
192
|
+
return path.startsWith('/')
|
|
193
|
+
? `${base.origin}${path}`
|
|
194
|
+
: `${base.origin}${basePath}${path}`
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const safeMessage = (value: unknown, fallback: string): string => {
|
|
198
|
+
if (typeof value !== 'string' || value.trim() === '') return fallback
|
|
199
|
+
const normalized = value.replace(controlCharacters, ' ').trim()
|
|
200
|
+
return normalized === '' ? fallback : normalized.slice(0, 512)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const safeErrorCode = (value: unknown, fallback: string): string => (
|
|
204
|
+
typeof value === 'string' && safeCode.test(value) ? value : fallback
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
const decodeKind = (value: unknown): string | null => {
|
|
208
|
+
if (!isRecord(value)) return null
|
|
209
|
+
const candidate = value.kind
|
|
210
|
+
return typeof candidate === 'string' ? candidate : null
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const isDecodedResult = (value: unknown): value is ClientDecodeResult => {
|
|
214
|
+
const kind = decodeKind(value)
|
|
215
|
+
if (kind === 'success') return isRecord(value) && 'data' in value
|
|
216
|
+
if (kind === 'unauthorized' || kind === 'business') return isRecord(value)
|
|
217
|
+
return false
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const normalizedDecodedResult = (value: ClientDecodeResult): ClientDecodeResult => {
|
|
221
|
+
const kind = decodeKind(value)
|
|
222
|
+
if (kind === 'success') return { kind: 'success', data: (value as ClientDecodeSuccess).data }
|
|
223
|
+
if (kind === 'unauthorized') {
|
|
224
|
+
const result = value as ClientDecodeUnauthorized
|
|
225
|
+
return {
|
|
226
|
+
kind: 'unauthorized',
|
|
227
|
+
code: safeErrorCode(result.code, defaultUnauthorizedCode),
|
|
228
|
+
message: safeMessage(result.message, genericUnauthorizedMessage),
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const result = value as ClientDecodeBusiness
|
|
233
|
+
return {
|
|
234
|
+
kind: 'business',
|
|
235
|
+
code: safeErrorCode(result.code, defaultBusinessCode),
|
|
236
|
+
message: safeMessage(result.message, genericBusinessMessage),
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const isHeaderSource = (value: unknown): value is ClientHeaderSource => (
|
|
241
|
+
typeof value === 'object'
|
|
242
|
+
&& value !== null
|
|
243
|
+
&& typeof (value as { forEach?: unknown }).forEach === 'function'
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
class PortableClientHeaders implements ClientHeaders {
|
|
247
|
+
private readonly values = new Map<string, string>()
|
|
248
|
+
|
|
249
|
+
constructor(headers?: ClientRequestHeaders) {
|
|
250
|
+
if (headers === undefined) return
|
|
251
|
+
|
|
252
|
+
if (Array.isArray(headers)) {
|
|
253
|
+
for (const entry of headers) {
|
|
254
|
+
if (!Array.isArray(entry) || entry.length !== 2) throw new TypeError('invalid header entry')
|
|
255
|
+
this.set(entry[0], entry[1])
|
|
256
|
+
}
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (isHeaderSource(headers)) {
|
|
261
|
+
headers.forEach((value, key) => this.set(key, value))
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (typeof headers === 'object' && headers !== null) {
|
|
266
|
+
for (const [key, value] of Object.entries(headers)) this.set(key, value)
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
throw new TypeError('invalid headers')
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
delete(name: string): void {
|
|
274
|
+
this.values.delete(name.toLowerCase())
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
get(name: string): string | null {
|
|
278
|
+
return this.values.get(name.toLowerCase()) ?? null
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
has(name: string): boolean {
|
|
282
|
+
return this.values.has(name.toLowerCase())
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
set(name: string, value: string): void {
|
|
286
|
+
if (!validHeaderName.test(name) || typeof value !== 'string' || invalidHeaderValue.test(value)) {
|
|
287
|
+
throw new TypeError('invalid header')
|
|
288
|
+
}
|
|
289
|
+
this.values.set(name.toLowerCase(), value.trim())
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
forEach(callback: (value: string, key: string) => void): void {
|
|
293
|
+
this.values.forEach((value, key) => callback(value, key))
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const requestHeaders = (headers: ClientRequestHeaders | undefined): ClientHeaders => {
|
|
298
|
+
const result = new PortableClientHeaders(headers)
|
|
299
|
+
// Header names are normalized, so delete removes every caller spelling.
|
|
300
|
+
result.delete('Authorization')
|
|
301
|
+
return result
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const methodOf = (method: ClientRequestMethod | undefined): ClientRequestMethod => (
|
|
305
|
+
(method ?? 'GET').toUpperCase() as ClientRequestMethod
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
const requestError = (kind: ClientRequestErrorKind, code: string, message: string): ClientRequestError => (
|
|
309
|
+
new ClientRequestError(kind, safeErrorCode(code, `CLIENT_${kind.toUpperCase()}_ERROR`), safeMessage(message, 'The request could not be completed.'))
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
export const createClient = (options: ClientOptions): Client => {
|
|
313
|
+
let unauthorizedHandling: Promise<void> | null = null
|
|
314
|
+
|
|
315
|
+
const invokeUnauthorized = async (error: ClientRequestError): Promise<void> => {
|
|
316
|
+
if (unauthorizedHandling === null) {
|
|
317
|
+
unauthorizedHandling = (async () => {
|
|
318
|
+
try {
|
|
319
|
+
await options.session.clear()
|
|
320
|
+
} catch {
|
|
321
|
+
throw requestError('session', 'CLIENT_SESSION_CLEAR_ERROR', 'The client session could not be cleared.')
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
await options.hooks?.unauthorized?.(error)
|
|
325
|
+
} catch {
|
|
326
|
+
// Hook failures cannot turn an unauthorized response into success.
|
|
327
|
+
}
|
|
328
|
+
})().finally(() => {
|
|
329
|
+
unauthorizedHandling = null
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
await unauthorizedHandling
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const request = async <TData = unknown>(input: ClientRequest): Promise<TData> => {
|
|
337
|
+
try {
|
|
338
|
+
assertClientPath(input.path)
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (error instanceof ClientRequestError) throw error
|
|
341
|
+
throw invalidPath()
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const method = methodOf(input.method)
|
|
345
|
+
let headers: ClientHeaders
|
|
346
|
+
try {
|
|
347
|
+
headers = requestHeaders(input.headers)
|
|
348
|
+
} catch {
|
|
349
|
+
throw requestError('path', 'CLIENT_HEADERS_INVALID', 'The request headers are invalid.')
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (input.auth !== false) {
|
|
353
|
+
let token: string | null | undefined
|
|
354
|
+
try {
|
|
355
|
+
token = options.session.accessToken()
|
|
356
|
+
} catch {
|
|
357
|
+
throw requestError('session', 'CLIENT_SESSION_ERROR', 'The client session is unavailable.')
|
|
358
|
+
}
|
|
359
|
+
if (typeof token === 'string' && token !== '') headers.set('Authorization', `Bearer ${token}`)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const transportRequest: ClientTransportRequest = {
|
|
363
|
+
path: input.path,
|
|
364
|
+
method,
|
|
365
|
+
...(input.data !== undefined ? { data: input.data } : {}),
|
|
366
|
+
headers,
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
let response: unknown
|
|
370
|
+
try {
|
|
371
|
+
response = await options.transport(transportRequest)
|
|
372
|
+
} catch {
|
|
373
|
+
throw requestError('transport', 'CLIENT_TRANSPORT_ERROR', 'The request could not be completed.')
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
let decoded: ClientDecodeResult
|
|
377
|
+
try {
|
|
378
|
+
decoded = await options.decoder(response, transportRequest)
|
|
379
|
+
if (!isDecodedResult(decoded)) throw new Error('invalid decoder result')
|
|
380
|
+
decoded = normalizedDecodedResult(decoded)
|
|
381
|
+
} catch {
|
|
382
|
+
throw requestError('decoder', 'CLIENT_DECODER_INVALID', 'The response could not be understood.')
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (decoded.kind === 'success') return decoded.data as TData
|
|
386
|
+
|
|
387
|
+
if (decoded.kind === 'unauthorized') {
|
|
388
|
+
const error = new ClientRequestError(
|
|
389
|
+
'unauthorized',
|
|
390
|
+
safeErrorCode(decoded.code, defaultUnauthorizedCode),
|
|
391
|
+
safeMessage(decoded.message, genericUnauthorizedMessage),
|
|
392
|
+
)
|
|
393
|
+
await invokeUnauthorized(error)
|
|
394
|
+
throw error
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const error = new ClientRequestError(
|
|
398
|
+
'business',
|
|
399
|
+
safeErrorCode(decoded.code, defaultBusinessCode),
|
|
400
|
+
safeMessage(decoded.message, genericBusinessMessage),
|
|
401
|
+
)
|
|
402
|
+
try {
|
|
403
|
+
await options.hooks?.businessError?.(error)
|
|
404
|
+
} catch {
|
|
405
|
+
// Hook failures remain attached to this stable business failure path.
|
|
406
|
+
}
|
|
407
|
+
throw error
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return { request }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export type ClientResult<TData = unknown> = ClientDecodeResult<TData>
|
|
414
|
+
export type ClientRequestResult<TData = unknown> = Promise<TData>
|
|
415
|
+
export type ClientHook = (error: ClientRequestError) => void | Promise<void>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { resolveClientUrl } from '@peanut-admin/admin/client'
|
|
2
|
+
import type { ClientHeaders, 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?: Record<string, string>
|
|
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
|
+
const headersRecord = (headers: ClientHeaders): Record<string, string> => {
|
|
24
|
+
const result: Record<string, string> = {}
|
|
25
|
+
headers.forEach((value, key) => {
|
|
26
|
+
result[key] = value
|
|
27
|
+
})
|
|
28
|
+
return result
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const createNuxtClientTransport = (
|
|
32
|
+
options: NuxtClientTransportOptions,
|
|
33
|
+
): ClientTransport => {
|
|
34
|
+
return async (request: ClientTransportRequest): Promise<unknown> => {
|
|
35
|
+
const method = request.method.toUpperCase()
|
|
36
|
+
const url = resolveClientUrl(options.baseUrl, request.path)
|
|
37
|
+
const fetchOptions: NuxtClientFetchOptions = {
|
|
38
|
+
method,
|
|
39
|
+
headers: headersRecord(request.headers),
|
|
40
|
+
...(request.data !== undefined
|
|
41
|
+
? isQueryMethod(method)
|
|
42
|
+
? { query: request.data }
|
|
43
|
+
: { body: request.data }
|
|
44
|
+
: {}),
|
|
45
|
+
}
|
|
46
|
+
return options.$fetch(url, fetchOptions)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { resolveClientUrl } from '@peanut-admin/admin/client'
|
|
2
|
+
import type { ClientHeaders, 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: ClientHeaders): 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
|
+
)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { EmptyState } from '@peanut-admin/admin/shell'
|
|
3
|
+
import { ElButton } from 'element-plus'
|
|
4
|
+
import type { AssetCandidate } from './contracts'
|
|
5
|
+
|
|
6
|
+
withDefaults(defineProps<{
|
|
7
|
+
items: readonly AssetCandidate[]
|
|
8
|
+
selectedFileKey?: string | null
|
|
9
|
+
loading?: boolean
|
|
10
|
+
error?: string | null
|
|
11
|
+
disabled?: boolean
|
|
12
|
+
}>(), {
|
|
13
|
+
selectedFileKey: null,
|
|
14
|
+
loading: false,
|
|
15
|
+
error: null,
|
|
16
|
+
disabled: false,
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const emit = defineEmits<{
|
|
20
|
+
select: [asset: AssetCandidate]
|
|
21
|
+
retry: []
|
|
22
|
+
}>()
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<section
|
|
27
|
+
class="asset-selector"
|
|
28
|
+
aria-labelledby="asset-selector-title"
|
|
29
|
+
>
|
|
30
|
+
<header class="asset-selector__header">
|
|
31
|
+
<h2 id="asset-selector-title">
|
|
32
|
+
Media assets
|
|
33
|
+
</h2>
|
|
34
|
+
<ElButton
|
|
35
|
+
:loading="loading"
|
|
36
|
+
:disabled="disabled"
|
|
37
|
+
@click="emit('retry')"
|
|
38
|
+
>
|
|
39
|
+
Reload
|
|
40
|
+
</ElButton>
|
|
41
|
+
</header>
|
|
42
|
+
<div
|
|
43
|
+
v-if="error"
|
|
44
|
+
class="asset-selector__error"
|
|
45
|
+
role="alert"
|
|
46
|
+
>
|
|
47
|
+
<p>{{ error }}</p>
|
|
48
|
+
</div>
|
|
49
|
+
<div
|
|
50
|
+
v-else-if="loading"
|
|
51
|
+
role="status"
|
|
52
|
+
class="asset-selector__status"
|
|
53
|
+
>
|
|
54
|
+
Loading media assets...
|
|
55
|
+
</div>
|
|
56
|
+
<EmptyState
|
|
57
|
+
v-else-if="items.length === 0"
|
|
58
|
+
title="No media assets"
|
|
59
|
+
message="Upload an image before selecting an asset."
|
|
60
|
+
/>
|
|
61
|
+
<ul
|
|
62
|
+
v-else
|
|
63
|
+
class="asset-selector__grid"
|
|
64
|
+
aria-label="Available media assets"
|
|
65
|
+
>
|
|
66
|
+
<li
|
|
67
|
+
v-for="asset in items"
|
|
68
|
+
:key="asset.fileKey"
|
|
69
|
+
class="asset-selector__item"
|
|
70
|
+
>
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
class="asset-selector__choice"
|
|
74
|
+
:class="{ 'is-selected': selectedFileKey === asset.fileKey }"
|
|
75
|
+
:aria-pressed="selectedFileKey === asset.fileKey"
|
|
76
|
+
:disabled="disabled"
|
|
77
|
+
@click="emit('select', asset)"
|
|
78
|
+
>
|
|
79
|
+
<img
|
|
80
|
+
v-if="asset.previewUri"
|
|
81
|
+
:src="asset.previewUri"
|
|
82
|
+
:alt="asset.originalName"
|
|
83
|
+
class="asset-selector__preview"
|
|
84
|
+
loading="lazy"
|
|
85
|
+
decoding="async"
|
|
86
|
+
referrerpolicy="no-referrer"
|
|
87
|
+
>
|
|
88
|
+
<span
|
|
89
|
+
v-else
|
|
90
|
+
class="asset-selector__placeholder"
|
|
91
|
+
aria-hidden="true"
|
|
92
|
+
>IMG</span>
|
|
93
|
+
<span class="asset-selector__name">{{ asset.originalName }}</span>
|
|
94
|
+
<span class="asset-selector__meta">{{ asset.width }} x {{ asset.height }}</span>
|
|
95
|
+
</button>
|
|
96
|
+
</li>
|
|
97
|
+
</ul>
|
|
98
|
+
</section>
|
|
99
|
+
</template>
|
|
100
|
+
|
|
101
|
+
<style scoped>
|
|
102
|
+
.asset-selector { min-width: 0; }
|
|
103
|
+
.asset-selector__header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
|
104
|
+
.asset-selector__header h2 { margin: 0; font-size: 16px; letter-spacing: 0; }
|
|
105
|
+
.asset-selector__status, .asset-selector__error { padding: 16px 0; }
|
|
106
|
+
.asset-selector__error { color: var(--el-color-danger); }
|
|
107
|
+
.asset-selector__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(144px, 1fr)); gap: 12px; padding: 0; margin: 0; list-style: none; }
|
|
108
|
+
.asset-selector__item { min-width: 0; }
|
|
109
|
+
.asset-selector__choice { width: 100%; padding: 8px; border: 1px solid var(--el-border-color); background: var(--el-bg-color); color: inherit; text-align: left; cursor: pointer; }
|
|
110
|
+
.asset-selector__choice.is-selected { border-color: var(--el-color-primary); box-shadow: 0 0 0 1px var(--el-color-primary) inset; }
|
|
111
|
+
.asset-selector__choice:disabled { cursor: not-allowed; opacity: .65; }
|
|
112
|
+
.asset-selector__preview, .asset-selector__placeholder { display: flex; width: 100%; aspect-ratio: 1; object-fit: cover; align-items: center; justify-content: center; background: var(--el-fill-color-light); }
|
|
113
|
+
.asset-selector__placeholder { color: var(--el-text-color-secondary); font-size: 13px; }
|
|
114
|
+
.asset-selector__name, .asset-selector__meta { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
115
|
+
.asset-selector__name { margin-top: 8px; font-weight: 600; }
|
|
116
|
+
.asset-selector__meta { margin-top: 2px; color: var(--el-text-color-secondary); font-size: 12px; }
|
|
117
|
+
</style>
|