@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.
Files changed (73) hide show
  1. package/LICENSE +202 -0
  2. package/admin-core/src/access/access.ts +27 -0
  3. package/admin-core/src/access/permission-policy.ts +37 -0
  4. package/admin-core/src/api/client.ts +200 -0
  5. package/admin-core/src/api/problem.ts +67 -0
  6. package/admin-core/src/api/refresh.ts +122 -0
  7. package/admin-core/src/auth/stores.ts +121 -0
  8. package/admin-core/src/auth/tenant-session.ts +32 -0
  9. package/admin-core/src/generated/api.d.ts +22106 -0
  10. package/admin-core/src/governance/audit.ts +56 -0
  11. package/admin-core/src/governance/catalog.ts +86 -0
  12. package/admin-core/src/governance/index.ts +26 -0
  13. package/admin-core/src/governance/menu.ts +63 -0
  14. package/admin-core/src/governance/roles.ts +144 -0
  15. package/admin-core/src/governance/types.ts +64 -0
  16. package/admin-core/src/index.ts +122 -0
  17. package/admin-core/src/lifecycle/tenant.ts +59 -0
  18. package/admin-core/src/module/contribution.ts +124 -0
  19. package/admin-core/src/module/plugin-contribution-policy.ts +51 -0
  20. package/admin-core/src/module/tenant-modules.ts +20 -0
  21. package/admin-core/src/runtime/config.ts +55 -0
  22. package/admin-core/src/runtime/errors.ts +81 -0
  23. package/admin-core/src/runtime/guard.ts +40 -0
  24. package/admin-core/src/runtime/navigation.ts +105 -0
  25. package/admin-core/src/runtime/overrides.ts +214 -0
  26. package/admin-core/src/targets/store.ts +153 -0
  27. package/admin-shell/src/config.ts +84 -0
  28. package/admin-shell/src/deployment-mode.ts +36 -0
  29. package/admin-shell/src/index.ts +44 -0
  30. package/admin-shell/src/layout.ts +332 -0
  31. package/admin-shell/src/overrides.ts +53 -0
  32. package/admin-shell/src/states.ts +93 -0
  33. package/admin-shell/src/tabs.ts +31 -0
  34. package/admin-shell/src/targets.ts +128 -0
  35. package/admin-shell/src/theme.ts +15 -0
  36. package/client-core/src/index.ts +415 -0
  37. package/client-nuxt/src/index.ts +48 -0
  38. package/client-uniapp/src/index.ts +50 -0
  39. package/file-media/src/FileAssetSelector.vue +117 -0
  40. package/file-media/src/FileMediaPage.vue +158 -0
  41. package/file-media/src/contracts.ts +220 -0
  42. package/file-media/src/index.ts +19 -0
  43. package/file-media/src/runtime.ts +210 -0
  44. package/import-export/src/ImportExportPage.vue +155 -0
  45. package/import-export/src/contracts.ts +96 -0
  46. package/import-export/src/index.ts +3 -0
  47. package/import-export/src/runtime.ts +128 -0
  48. package/integration-security/src/IntegrationSecurityPage.vue +402 -0
  49. package/integration-security/src/contracts.ts +171 -0
  50. package/integration-security/src/index.ts +3 -0
  51. package/integration-security/src/runtime.ts +180 -0
  52. package/notification-sms/src/NotificationInboxPage.vue +266 -0
  53. package/notification-sms/src/contracts.ts +195 -0
  54. package/notification-sms/src/index.ts +4 -0
  55. package/notification-sms/src/runtime.ts +143 -0
  56. package/ops-console/src/OpsConsolePage.vue +337 -0
  57. package/ops-console/src/contracts.ts +169 -0
  58. package/ops-console/src/index.ts +3 -0
  59. package/ops-console/src/runtime.ts +199 -0
  60. package/package.json +139 -0
  61. package/reference-codes/src/ReferenceCodesPage.vue +942 -0
  62. package/reference-codes/src/contracts.ts +484 -0
  63. package/reference-codes/src/index.ts +53 -0
  64. package/reference-codes/src/runtime.ts +855 -0
  65. package/settings/src/SettingsPage.vue +536 -0
  66. package/settings/src/contracts.ts +331 -0
  67. package/settings/src/index.ts +45 -0
  68. package/settings/src/runtime.ts +545 -0
  69. package/task-job/src/TaskJobPage.vue +120 -0
  70. package/task-job/src/contracts.ts +117 -0
  71. package/task-job/src/index.ts +2 -0
  72. package/task-job/src/runtime.ts +105 -0
  73. 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>