@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,158 @@
1
+ <script setup lang="ts">
2
+ import { EmptyState, ForbiddenState, ModuleUnavailableState, PageContent, PageHeader, PageToolbar, SessionExpiredState } from '@peanut-admin/admin/shell'
3
+ import { ElButton } from 'element-plus'
4
+ import { computed, onMounted, ref } from 'vue'
5
+ import FileAssetSelector from './FileAssetSelector.vue'
6
+ import { useFileMediaRuntime } from './runtime'
7
+
8
+ const runtime = useFileMediaRuntime()
9
+ const state = runtime.state
10
+ const input = ref<HTMLInputElement | null>(null)
11
+ const canCreate = computed(runtime.canCreate)
12
+ const canDelete = computed(runtime.canDelete)
13
+ const selectedAssetKey = ref<string | null>(null)
14
+
15
+ const upload = async (event: Event): Promise<void> => {
16
+ const target = event.currentTarget
17
+ if (!(target instanceof HTMLInputElement) || target.files?.length !== 1) return
18
+ const file = target.files.item(0)
19
+ if (file !== null) await runtime.upload(file)
20
+ target.value = ''
21
+ }
22
+
23
+ onMounted(() => Promise.all([runtime.load(), runtime.loadAssets()]))
24
+ </script>
25
+
26
+ <template>
27
+ <PageContent class="file-media-page">
28
+ <PageHeader>
29
+ Files
30
+ <template #actions>
31
+ <input
32
+ ref="input"
33
+ class="file-input"
34
+ type="file"
35
+ aria-label="Choose private file"
36
+ @change="upload"
37
+ >
38
+ <ElButton
39
+ :disabled="!canCreate || state.mutating"
40
+ type="primary"
41
+ @click="input?.click()"
42
+ >
43
+ Upload
44
+ </ElButton>
45
+ <ElButton
46
+ :loading="state.loading"
47
+ :disabled="state.mutating"
48
+ @click="runtime.load"
49
+ >
50
+ Reload
51
+ </ElButton>
52
+ </template>
53
+ </PageHeader>
54
+ <PageToolbar label="File status">
55
+ <ElButton
56
+ :type="state.status === 'ready' ? 'primary' : 'default'"
57
+ @click="runtime.setStatus('ready')"
58
+ >
59
+ Ready
60
+ </ElButton>
61
+ <ElButton
62
+ :type="state.status === 'archived' ? 'primary' : 'default'"
63
+ @click="runtime.setStatus('archived')"
64
+ >
65
+ Archived
66
+ </ElButton>
67
+ </PageToolbar>
68
+
69
+ <FileAssetSelector
70
+ :items="state.assets"
71
+ :selected-file-key="selectedAssetKey"
72
+ :loading="state.assetsLoading"
73
+ :error="state.assetsError?.message ?? null"
74
+ :disabled="state.mutating"
75
+ @select="selectedAssetKey = $event.fileKey"
76
+ @retry="runtime.loadAssets"
77
+ />
78
+
79
+ <SessionExpiredState
80
+ v-if="state.error?.status === 401"
81
+ :message="state.error.message"
82
+ />
83
+ <ForbiddenState
84
+ v-else-if="state.error?.status === 403"
85
+ :message="state.error.message"
86
+ />
87
+ <ModuleUnavailableState
88
+ v-else-if="state.error?.status === 503"
89
+ :message="state.error.message"
90
+ @action="runtime.load"
91
+ />
92
+ <section
93
+ v-else-if="state.error"
94
+ role="alert"
95
+ class="file-state"
96
+ >
97
+ <h2>Unable to complete the file request</h2>
98
+ <p>{{ state.error.message }}</p>
99
+ <p v-if="state.error.requestId">
100
+ Request ID: {{ state.error.requestId }}
101
+ </p>
102
+ </section>
103
+ <div
104
+ v-else-if="state.loading"
105
+ class="file-state"
106
+ role="status"
107
+ >
108
+ Loading files...
109
+ </div>
110
+ <EmptyState
111
+ v-else-if="state.items.length === 0"
112
+ title="No files"
113
+ message="No private files match this status."
114
+ />
115
+ <div
116
+ v-else
117
+ class="file-table-wrap"
118
+ >
119
+ <table class="file-table">
120
+ <thead><tr><th>Name</th><th>Type</th><th>Size</th><th>Status</th><th>Revision</th><th>Actions</th></tr></thead>
121
+ <tbody>
122
+ <tr
123
+ v-for="file in state.items"
124
+ :key="file.fileKey"
125
+ >
126
+ <td>{{ file.originalName }}</td><td>{{ file.mediaType }}</td><td>{{ file.sizeBytes }}</td>
127
+ <td>{{ file.status }}</td><td>{{ file.revision }}</td>
128
+ <td>
129
+ <ElButton
130
+ v-if="file.status === 'ready'"
131
+ text
132
+ @click="runtime.download(file)"
133
+ >
134
+ Download
135
+ </ElButton>
136
+ <ElButton
137
+ v-if="file.status === 'ready'"
138
+ text
139
+ :disabled="!canDelete || state.mutating"
140
+ @click="runtime.archive(file)"
141
+ >
142
+ Archive
143
+ </ElButton>
144
+ </td>
145
+ </tr>
146
+ </tbody>
147
+ </table>
148
+ </div>
149
+ </PageContent>
150
+ </template>
151
+
152
+ <style scoped>
153
+ .file-input { display: none; }
154
+ .file-state { padding: 24px 0; }
155
+ .file-table-wrap { overflow-x: auto; }
156
+ .file-table { width: 100%; border-collapse: collapse; }
157
+ .file-table th, .file-table td { padding: 10px 8px; border-bottom: 1px solid var(--el-border-color); text-align: left; }
158
+ </style>
@@ -0,0 +1,220 @@
1
+ export type FileStatus = 'ready' | 'archived'
2
+
3
+ export interface FileObject {
4
+ readonly fileKey: string
5
+ readonly originalName: string
6
+ readonly mediaType: string
7
+ readonly sizeBytes: number
8
+ readonly sha256: string
9
+ readonly status: FileStatus
10
+ readonly revision: number
11
+ readonly createdAt: string
12
+ readonly updatedAt: string
13
+ readonly archivedAt: string | null
14
+ }
15
+
16
+ export interface FileList {
17
+ readonly items: readonly FileObject[]
18
+ readonly page: number
19
+ readonly pageSize: number
20
+ readonly total: number
21
+ }
22
+
23
+ export interface FileTransportResult {
24
+ readonly body: unknown
25
+ readonly headers: Headers
26
+ readonly status: number
27
+ }
28
+
29
+ export interface FileMediaTransport {
30
+ list: (status: FileStatus, page: number, pageSize: number, signal: AbortSignal) => Promise<FileTransportResult>
31
+ assets: (page: number, pageSize: number, signal: AbortSignal) => Promise<FileTransportResult>
32
+ upload: (file: File, signal: AbortSignal) => Promise<FileTransportResult>
33
+ download: (fileKey: string, signal: AbortSignal) => Promise<Response>
34
+ archive: (fileKey: string, etag: string, signal: AbortSignal) => Promise<FileTransportResult>
35
+ }
36
+
37
+ export interface ImageVariant {
38
+ readonly variantKey: string
39
+ readonly width: number
40
+ readonly height: number
41
+ readonly mediaType: 'image/jpeg' | 'image/png'
42
+ readonly deliveryUri: string | null
43
+ }
44
+
45
+ export interface AssetCandidate {
46
+ readonly fileKey: string
47
+ readonly originalName: string
48
+ readonly mediaType: 'image/jpeg' | 'image/png'
49
+ readonly width: number
50
+ readonly height: number
51
+ readonly previewUri: string | null
52
+ readonly variants: readonly ImageVariant[]
53
+ }
54
+
55
+ export interface AssetList {
56
+ readonly items: readonly AssetCandidate[]
57
+ readonly page: number
58
+ readonly pageSize: number
59
+ readonly total: number
60
+ }
61
+
62
+ const fileKeyPattern = /^file_[0-9a-f]{32}$/
63
+ const shaPattern = /^[0-9a-f]{64}$/
64
+
65
+ const record = (value: unknown): Record<string, unknown> => {
66
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('FILE_MEDIA_RESPONSE_INVALID')
67
+ return value as Record<string, unknown>
68
+ }
69
+
70
+ const exactKeys = (value: Record<string, unknown>, keys: readonly string[]): void => {
71
+ const actual = Object.keys(value).sort()
72
+ const expected = [...keys].sort()
73
+ if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
74
+ throw new Error('FILE_MEDIA_RESPONSE_INVALID')
75
+ }
76
+ }
77
+
78
+ const deliveryUri = (value: unknown): string | null => {
79
+ if (value === null) return null
80
+ if (typeof value !== 'string' || value === '' || value.length > 2048 || value.includes('#')) {
81
+ throw new Error('FILE_MEDIA_RESPONSE_INVALID')
82
+ }
83
+ if (value.startsWith('/')) {
84
+ if (value.startsWith('//') || !value.startsWith('/api/')) throw new Error('FILE_MEDIA_RESPONSE_INVALID')
85
+ return value
86
+ }
87
+ let parsed: URL
88
+ try { parsed = new URL(value) } catch { throw new Error('FILE_MEDIA_RESPONSE_INVALID') }
89
+ if (parsed.protocol !== 'https:' || parsed.username !== '' || parsed.password !== '') {
90
+ throw new Error('FILE_MEDIA_RESPONSE_INVALID')
91
+ }
92
+ return value
93
+ }
94
+
95
+ export const parseFileObject = (value: unknown): FileObject => {
96
+ const item = record(value)
97
+ exactKeys(item, [
98
+ 'file_key', 'original_name', 'media_type', 'size_bytes', 'sha256', 'status',
99
+ 'revision', 'created_at', 'updated_at', 'archived_at',
100
+ ])
101
+ if (
102
+ typeof item.file_key !== 'string' || !fileKeyPattern.test(item.file_key)
103
+ || typeof item.original_name !== 'string' || item.original_name === '' || [...item.original_name].length > 255
104
+ || typeof item.media_type !== 'string' || item.media_type === '' || item.media_type.length > 127
105
+ || typeof item.size_bytes !== 'number' || !Number.isSafeInteger(item.size_bytes) || item.size_bytes < 1
106
+ || typeof item.sha256 !== 'string' || !shaPattern.test(item.sha256)
107
+ || (item.status !== 'ready' && item.status !== 'archived')
108
+ || typeof item.revision !== 'number' || !Number.isSafeInteger(item.revision) || item.revision < 1
109
+ || typeof item.created_at !== 'string' || !Number.isFinite(Date.parse(item.created_at))
110
+ || typeof item.updated_at !== 'string' || !Number.isFinite(Date.parse(item.updated_at))
111
+ || (item.archived_at !== null && (typeof item.archived_at !== 'string' || !Number.isFinite(Date.parse(item.archived_at))))
112
+ || (item.status === 'ready') !== (item.archived_at === null)
113
+ ) throw new Error('FILE_MEDIA_RESPONSE_INVALID')
114
+
115
+ return {
116
+ fileKey: item.file_key,
117
+ originalName: item.original_name,
118
+ mediaType: item.media_type,
119
+ sizeBytes: item.size_bytes,
120
+ sha256: item.sha256,
121
+ status: item.status,
122
+ revision: item.revision,
123
+ createdAt: item.created_at,
124
+ updatedAt: item.updated_at,
125
+ archivedAt: item.archived_at,
126
+ }
127
+ }
128
+
129
+ export const parseFileResponse = (value: unknown): FileObject => {
130
+ const body = record(value)
131
+ exactKeys(body, ['data', 'meta'])
132
+ record(body.meta)
133
+ return parseFileObject(body.data)
134
+ }
135
+
136
+ export const parseFileList = (value: unknown): FileList => {
137
+ const body = record(value)
138
+ exactKeys(body, ['data', 'meta'])
139
+ const data = record(body.data)
140
+ exactKeys(data, ['items'])
141
+ const meta = record(body.meta)
142
+ if (!Array.isArray(data.items)) throw new Error('FILE_MEDIA_RESPONSE_INVALID')
143
+ for (const key of ['page', 'page_size', 'total'] as const) {
144
+ if (typeof meta[key] !== 'number' || !Number.isSafeInteger(meta[key]) || meta[key] < (key === 'total' ? 0 : 1)) {
145
+ throw new Error('FILE_MEDIA_RESPONSE_INVALID')
146
+ }
147
+ }
148
+ return {
149
+ items: data.items.map(parseFileObject),
150
+ page: meta.page as number,
151
+ pageSize: meta.page_size as number,
152
+ total: meta.total as number,
153
+ }
154
+ }
155
+
156
+ export const parseAssetCandidate = (value: unknown): AssetCandidate => {
157
+ const item = record(value)
158
+ exactKeys(item, ['file_key', 'original_name', 'media_type', 'width', 'height', 'preview_uri', 'variants'])
159
+ if (
160
+ typeof item.file_key !== 'string' || !fileKeyPattern.test(item.file_key)
161
+ || typeof item.original_name !== 'string' || item.original_name === '' || [...item.original_name].length > 255
162
+ || (item.media_type !== 'image/jpeg' && item.media_type !== 'image/png')
163
+ || typeof item.width !== 'number' || !Number.isSafeInteger(item.width) || item.width < 1 || item.width > 50000
164
+ || typeof item.height !== 'number' || !Number.isSafeInteger(item.height) || item.height < 1 || item.height > 50000
165
+ || item.width * item.height > 100_000_000
166
+ || !Array.isArray(item.variants) || item.variants.length > 16
167
+ ) throw new Error('FILE_MEDIA_RESPONSE_INVALID')
168
+ const keys = new Set<string>()
169
+ const variants = item.variants.map((value): ImageVariant => {
170
+ const variant = record(value)
171
+ exactKeys(variant, ['variant_key', 'width', 'height', 'media_type', 'delivery_uri'])
172
+ if (
173
+ typeof variant.variant_key !== 'string' || !/^[a-z][a-z0-9-]{0,31}$/.test(variant.variant_key)
174
+ || keys.has(variant.variant_key)
175
+ || typeof variant.width !== 'number' || !Number.isSafeInteger(variant.width) || variant.width < 1 || variant.width > 4096
176
+ || typeof variant.height !== 'number' || !Number.isSafeInteger(variant.height) || variant.height < 1 || variant.height > 4096
177
+ || (variant.media_type !== 'image/jpeg' && variant.media_type !== 'image/png')
178
+ ) throw new Error('FILE_MEDIA_RESPONSE_INVALID')
179
+ keys.add(variant.variant_key)
180
+ return {
181
+ variantKey: variant.variant_key,
182
+ width: variant.width,
183
+ height: variant.height,
184
+ mediaType: variant.media_type,
185
+ deliveryUri: deliveryUri(variant.delivery_uri),
186
+ }
187
+ })
188
+ return {
189
+ fileKey: item.file_key,
190
+ originalName: item.original_name,
191
+ mediaType: item.media_type,
192
+ width: item.width,
193
+ height: item.height,
194
+ previewUri: deliveryUri(item.preview_uri),
195
+ variants,
196
+ }
197
+ }
198
+
199
+ export const parseAssetList = (value: unknown): AssetList => {
200
+ const body = record(value)
201
+ exactKeys(body, ['data', 'meta'])
202
+ const data = record(body.data)
203
+ const meta = record(body.meta)
204
+ exactKeys(data, ['items'])
205
+ exactKeys(meta, ['request_id', 'page', 'page_size', 'total'])
206
+ if (!Array.isArray(data.items) || typeof meta.request_id !== 'string' || meta.request_id === '') {
207
+ throw new Error('FILE_MEDIA_RESPONSE_INVALID')
208
+ }
209
+ for (const key of ['page', 'page_size', 'total'] as const) {
210
+ if (typeof meta[key] !== 'number' || !Number.isSafeInteger(meta[key]) || meta[key] < (key === 'total' ? 0 : 1)) {
211
+ throw new Error('FILE_MEDIA_RESPONSE_INVALID')
212
+ }
213
+ }
214
+ return {
215
+ items: data.items.map(parseAssetCandidate),
216
+ page: meta.page as number,
217
+ pageSize: meta.page_size as number,
218
+ total: meta.total as number,
219
+ }
220
+ }
@@ -0,0 +1,19 @@
1
+ export const FILE_MEDIA_PACKAGE = '@peanut-admin/admin/file-media' as const
2
+ export const FILE_MEDIA_VERSION = '0.1.0' as const
3
+ export { default as FileAssetSelector } from './FileAssetSelector.vue'
4
+ export { parseAssetCandidate, parseFileList, parseFileObject, parseFileResponse } from './contracts'
5
+ export type { AssetCandidate, AssetList, FileList, FileMediaTransport, FileObject, FileStatus, FileTransportResult, ImageVariant } from './contracts'
6
+ export {
7
+ createFileMediaModuleContribution,
8
+ createFileMediaRuntime,
9
+ FILE_MEDIA_CREATE_PERMISSION,
10
+ FILE_MEDIA_DELETE_PERMISSION,
11
+ FILE_MEDIA_MODULE_KEY,
12
+ FILE_MEDIA_READ_PERMISSION,
13
+ FILE_MEDIA_ROUTE_NAME,
14
+ FILE_MEDIA_ROUTE_PATH,
15
+ FILE_MEDIA_STORE_KEY,
16
+ fileMediaRuntimeKey,
17
+ useFileMediaRuntime,
18
+ } from './runtime'
19
+ export type { FileMediaError, FileMediaRuntime, FileMediaRuntimeOptions, FileMediaState } from './runtime'
@@ -0,0 +1,210 @@
1
+ import { defineAdminModule } from '@peanut-admin/admin/core'
2
+ import { inject, reactive } from 'vue'
3
+ import type { AdminModuleContribution } from '@peanut-admin/admin/core'
4
+ import type { InjectionKey } from 'vue'
5
+
6
+ import { parseAssetList, parseFileList, parseFileResponse } from './contracts'
7
+ import type { AssetCandidate, FileMediaTransport, FileObject, FileStatus, FileTransportResult } from './contracts'
8
+
9
+ export const FILE_MEDIA_MODULE_KEY = 'peanut.file-media' as const
10
+ export const FILE_MEDIA_ROUTE_NAME = 'peanut.file-media.list' as const
11
+ export const FILE_MEDIA_ROUTE_PATH = '/app/files' as const
12
+ export const FILE_MEDIA_READ_PERMISSION = 'peanut.file-media.read' as const
13
+ export const FILE_MEDIA_CREATE_PERMISSION = 'peanut.file-media.create' as const
14
+ export const FILE_MEDIA_DELETE_PERMISSION = 'peanut.file-media.delete' as const
15
+ export const FILE_MEDIA_STORE_KEY = 'peanut.file-media.runtime' as const
16
+
17
+ export interface FileMediaError {
18
+ readonly message: string
19
+ readonly requestId: string | null
20
+ readonly status: number | null
21
+ }
22
+
23
+ export interface FileMediaState {
24
+ items: FileObject[]
25
+ status: FileStatus
26
+ page: number
27
+ pageSize: number
28
+ total: number
29
+ assets: AssetCandidate[]
30
+ assetsLoading: boolean
31
+ assetsError: FileMediaError | null
32
+ loading: boolean
33
+ mutating: boolean
34
+ error: FileMediaError | null
35
+ }
36
+
37
+ export interface FileMediaRuntime {
38
+ readonly state: FileMediaState
39
+ readonly canCreate: () => boolean
40
+ readonly canDelete: () => boolean
41
+ load: () => Promise<void>
42
+ loadAssets: () => Promise<void>
43
+ setStatus: (status: FileStatus) => Promise<void>
44
+ upload: (file: File) => Promise<void>
45
+ download: (file: FileObject) => Promise<void>
46
+ archive: (file: FileObject) => Promise<void>
47
+ dispose: () => void
48
+ }
49
+
50
+ export interface FileMediaRuntimeOptions {
51
+ readonly transport: FileMediaTransport
52
+ readonly canRead: () => boolean
53
+ readonly canCreate: () => boolean
54
+ readonly canDelete: () => boolean
55
+ readonly saveDownload?: (response: Response, file: FileObject) => Promise<void>
56
+ }
57
+
58
+ const requestId = (result: FileTransportResult): string | null => {
59
+ const body = typeof result.body === 'object' && result.body !== null && !Array.isArray(result.body)
60
+ ? result.body as Record<string, unknown>
61
+ : {}
62
+ const value = body.request_id ?? result.headers.get('X-Request-Id')
63
+ return typeof value === 'string' && value !== '' ? value : null
64
+ }
65
+
66
+ const failure = (result: FileTransportResult): FileMediaError => {
67
+ const body = typeof result.body === 'object' && result.body !== null && !Array.isArray(result.body)
68
+ ? result.body as Record<string, unknown>
69
+ : {}
70
+ return {
71
+ message: typeof body.detail === 'string' && body.detail !== '' ? body.detail : `File request failed (${result.status}).`,
72
+ requestId: requestId(result),
73
+ status: result.status,
74
+ }
75
+ }
76
+
77
+ const defaultSave = async (response: Response, file: FileObject): Promise<void> => {
78
+ const blob = await response.blob()
79
+ const url = URL.createObjectURL(blob)
80
+ const anchor = document.createElement('a')
81
+ anchor.href = url
82
+ anchor.download = file.originalName
83
+ anchor.click()
84
+ URL.revokeObjectURL(url)
85
+ }
86
+
87
+ export const createFileMediaRuntime = (options: FileMediaRuntimeOptions): FileMediaRuntime => {
88
+ const state = reactive<FileMediaState>({
89
+ items: [], status: 'ready', page: 1, pageSize: 20, total: 0,
90
+ assets: [], assetsLoading: false, assetsError: null,
91
+ loading: false, mutating: false, error: null,
92
+ })
93
+ const controllers = new Set<AbortController>()
94
+ let generation = 0
95
+ const run = async <T>(operation: (signal: AbortSignal) => Promise<T>): Promise<T> => {
96
+ const controller = new AbortController()
97
+ controllers.add(controller)
98
+ try { return await operation(controller.signal) } finally { controllers.delete(controller) }
99
+ }
100
+
101
+ const load = async (): Promise<void> => {
102
+ const current = ++generation
103
+ state.loading = true
104
+ state.error = null
105
+ try {
106
+ if (!options.canRead()) throw new Error('FILE_MEDIA_PERMISSION_DENIED')
107
+ const result = await run(signal => options.transport.list(state.status, state.page, state.pageSize, signal))
108
+ if (current !== generation) return
109
+ if (result.status !== 200) { state.error = failure(result); return }
110
+ const list = parseFileList(result.body)
111
+ state.items = [...list.items]
112
+ state.page = list.page
113
+ state.pageSize = list.pageSize
114
+ state.total = list.total
115
+ } catch (error) {
116
+ if (current === generation && !(error instanceof DOMException && error.name === 'AbortError')) {
117
+ state.error = { message: 'The file service could not be reached.', requestId: null, status: null }
118
+ }
119
+ } finally {
120
+ if (current === generation) state.loading = false
121
+ }
122
+ }
123
+
124
+ const loadAssets = async (): Promise<void> => {
125
+ const current = generation
126
+ state.assetsLoading = true
127
+ state.assetsError = null
128
+ try {
129
+ if (!options.canRead()) throw new Error('FILE_MEDIA_PERMISSION_DENIED')
130
+ const result = await run(signal => options.transport.assets(1, 50, signal))
131
+ if (current !== generation) return
132
+ if (result.status !== 200) { state.assetsError = failure(result); return }
133
+ state.assets = [...parseAssetList(result.body).items]
134
+ } catch (error) {
135
+ if (current === generation && !(error instanceof DOMException && error.name === 'AbortError')) {
136
+ state.assetsError = { message: 'The media asset service could not be reached.', requestId: null, status: null }
137
+ }
138
+ } finally {
139
+ if (current === generation) state.assetsLoading = false
140
+ }
141
+ }
142
+
143
+ return {
144
+ state,
145
+ canCreate: options.canCreate,
146
+ canDelete: options.canDelete,
147
+ load,
148
+ loadAssets,
149
+ async setStatus(status) { state.status = status; state.page = 1; await load() },
150
+ async upload(file) {
151
+ if (!options.canCreate() || state.mutating) return
152
+ state.mutating = true
153
+ state.error = null
154
+ try {
155
+ const result = await run(signal => options.transport.upload(file, signal))
156
+ if (result.status !== 201) { state.error = failure(result); return }
157
+ parseFileResponse(result.body)
158
+ state.status = 'ready'
159
+ await Promise.all([load(), loadAssets()])
160
+ } catch { state.error = { message: 'The upload could not be completed.', requestId: null, status: null } }
161
+ finally { state.mutating = false }
162
+ },
163
+ async download(file) {
164
+ state.error = null
165
+ try {
166
+ const response = await run(signal => options.transport.download(file.fileKey, signal))
167
+ if (!response.ok) {
168
+ let body: unknown = null
169
+ try { body = await response.json() } catch { body = null }
170
+ state.error = failure({ body, headers: response.headers, status: response.status })
171
+ return
172
+ }
173
+ await (options.saveDownload ?? defaultSave)(response, file)
174
+ } catch { state.error = { message: 'The download could not be completed.', requestId: null, status: null } }
175
+ },
176
+ async archive(file) {
177
+ if (!options.canDelete() || state.mutating) return
178
+ state.mutating = true
179
+ state.error = null
180
+ try {
181
+ const result = await run(signal => options.transport.archive(file.fileKey, `"rev-${file.revision}"`, signal))
182
+ if (result.status !== 200) { state.error = failure(result); return }
183
+ parseFileResponse(result.body)
184
+ await load()
185
+ } catch { state.error = { message: 'The file could not be archived.', requestId: null, status: null } }
186
+ finally { state.mutating = false }
187
+ },
188
+ dispose() { generation += 1; for (const controller of controllers) controller.abort(); controllers.clear(); state.assets = [] },
189
+ }
190
+ }
191
+
192
+ export const fileMediaRuntimeKey: InjectionKey<FileMediaRuntime> = Symbol(FILE_MEDIA_STORE_KEY)
193
+
194
+ export const useFileMediaRuntime = (): FileMediaRuntime => {
195
+ const runtime = inject(fileMediaRuntimeKey)
196
+ if (runtime === undefined) throw new Error('FILE_MEDIA_RUNTIME_MISSING')
197
+ return runtime
198
+ }
199
+
200
+ export const createFileMediaModuleContribution = (runtime: FileMediaRuntime): AdminModuleContribution => defineAdminModule({
201
+ key: FILE_MEDIA_MODULE_KEY,
202
+ routes: [{
203
+ name: FILE_MEDIA_ROUTE_NAME,
204
+ path: FILE_MEDIA_ROUTE_PATH,
205
+ component: async () => ({ default: (await import('./FileMediaPage.vue')).default }),
206
+ access: { moduleKey: FILE_MEDIA_MODULE_KEY, permissionKeys: [FILE_MEDIA_READ_PERMISSION] },
207
+ }],
208
+ disposeOnTenantChange: true,
209
+ stores: [{ key: FILE_MEDIA_STORE_KEY, dispose: runtime.dispose }],
210
+ })