@peanut-admin/admin 0.1.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/admin-core/src/access/access.ts +27 -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/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 +105 -0
- package/admin-core/src/lifecycle/tenant.ts +59 -0
- package/admin-core/src/module/contribution.ts +124 -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/index.ts +40 -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/targets.ts +128 -0
- package/admin-shell/src/theme.ts +15 -0
- package/client-core/src/index.ts +325 -0
- package/client-nuxt/src/index.ts +40 -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 +108 -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,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>
|
|
@@ -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'
|