@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,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
|
+
})
|