@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,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
|
+
})
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { EmptyState, ForbiddenState, ModuleUnavailableState, PageContent, PageHeader, PageToolbar, SessionExpiredState } from '@peanut-admin/admin/shell'
|
|
3
|
+
import { ElButton, ElInput } from 'element-plus'
|
|
4
|
+
import { computed, onMounted, ref } from 'vue'
|
|
5
|
+
import { useImportExportRuntime } from './runtime'
|
|
6
|
+
|
|
7
|
+
const runtime = useImportExportRuntime(); const state = runtime.state
|
|
8
|
+
const providerKey = ref(''); const inputFileKey = ref(''); const mappingJson = ref('{}')
|
|
9
|
+
const canCreate = computed(runtime.canCreate); const canCancel = computed(runtime.canCancel)
|
|
10
|
+
const statuses = ['queued', 'running', 'cancel_requested', 'succeeded', 'failed', 'cancelled', 'expired'] as const
|
|
11
|
+
const submitImport = async (): Promise<void> => { try { const mapping = JSON.parse(mappingJson.value) as unknown; if (typeof mapping !== 'object' || mapping === null || Array.isArray(mapping)) throw new Error(); await runtime.submitImport(providerKey.value, inputFileKey.value, mapping as Record<string, string>) } catch { /* Runtime validation reports request errors; malformed JSON stays local. */ } }
|
|
12
|
+
onMounted(runtime.load)
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<PageContent class="import-export-page">
|
|
17
|
+
<PageHeader>
|
|
18
|
+
Import / Export<template #actions>
|
|
19
|
+
<ElButton
|
|
20
|
+
:loading="state.loading"
|
|
21
|
+
:disabled="state.mutating"
|
|
22
|
+
@click="runtime.load"
|
|
23
|
+
>
|
|
24
|
+
Reload
|
|
25
|
+
</ElButton>
|
|
26
|
+
</template>
|
|
27
|
+
</PageHeader>
|
|
28
|
+
<section
|
|
29
|
+
v-if="canCreate"
|
|
30
|
+
class="submission"
|
|
31
|
+
aria-label="Create import or export"
|
|
32
|
+
>
|
|
33
|
+
<ElInput
|
|
34
|
+
v-model="providerKey"
|
|
35
|
+
placeholder="Registered provider key"
|
|
36
|
+
aria-label="Provider key"
|
|
37
|
+
/>
|
|
38
|
+
<ElInput
|
|
39
|
+
v-model="inputFileKey"
|
|
40
|
+
placeholder="Private CSV file key"
|
|
41
|
+
aria-label="Input file key"
|
|
42
|
+
/>
|
|
43
|
+
<ElInput
|
|
44
|
+
v-model="mappingJson"
|
|
45
|
+
type="textarea"
|
|
46
|
+
placeholder="{"CSV heading":"column_key"}"
|
|
47
|
+
aria-label="Column mapping JSON"
|
|
48
|
+
/>
|
|
49
|
+
<div>
|
|
50
|
+
<ElButton
|
|
51
|
+
type="primary"
|
|
52
|
+
:disabled="state.mutating"
|
|
53
|
+
@click="submitImport"
|
|
54
|
+
>
|
|
55
|
+
Import CSV
|
|
56
|
+
</ElButton><ElButton
|
|
57
|
+
:disabled="state.mutating"
|
|
58
|
+
@click="runtime.submitExport(providerKey)"
|
|
59
|
+
>
|
|
60
|
+
Export CSV
|
|
61
|
+
</ElButton>
|
|
62
|
+
</div>
|
|
63
|
+
</section>
|
|
64
|
+
<PageToolbar label="Operation status">
|
|
65
|
+
<ElButton
|
|
66
|
+
v-for="status in statuses"
|
|
67
|
+
:key="status"
|
|
68
|
+
:type="state.status === status ? 'primary' : 'default'"
|
|
69
|
+
@click="runtime.setStatus(status)"
|
|
70
|
+
>
|
|
71
|
+
{{ status }}
|
|
72
|
+
</ElButton>
|
|
73
|
+
</PageToolbar>
|
|
74
|
+
<SessionExpiredState
|
|
75
|
+
v-if="state.error?.status === 401"
|
|
76
|
+
:message="state.error.message"
|
|
77
|
+
/>
|
|
78
|
+
<ForbiddenState
|
|
79
|
+
v-else-if="state.error?.status === 403"
|
|
80
|
+
:message="state.error.message"
|
|
81
|
+
/>
|
|
82
|
+
<ModuleUnavailableState
|
|
83
|
+
v-else-if="state.error?.status === 503"
|
|
84
|
+
:message="state.error.message"
|
|
85
|
+
@action="runtime.load"
|
|
86
|
+
/>
|
|
87
|
+
<section
|
|
88
|
+
v-else-if="state.error"
|
|
89
|
+
role="alert"
|
|
90
|
+
>
|
|
91
|
+
<h2>Unable to complete the import/export request</h2><p>{{ state.error.message }}</p><p v-if="state.error.requestId">
|
|
92
|
+
Request ID: {{ state.error.requestId }}
|
|
93
|
+
</p>
|
|
94
|
+
</section>
|
|
95
|
+
<div
|
|
96
|
+
v-else-if="state.loading"
|
|
97
|
+
role="status"
|
|
98
|
+
>
|
|
99
|
+
Loading import/export operations...
|
|
100
|
+
</div>
|
|
101
|
+
<EmptyState
|
|
102
|
+
v-else-if="state.items.length === 0"
|
|
103
|
+
title="No operations"
|
|
104
|
+
message="No import/export operations match this status."
|
|
105
|
+
/>
|
|
106
|
+
<div
|
|
107
|
+
v-else
|
|
108
|
+
class="table-wrap"
|
|
109
|
+
>
|
|
110
|
+
<table>
|
|
111
|
+
<thead><tr><th>Provider</th><th>Direction</th><th>Status</th><th>Progress</th><th>Rows</th><th>Expires</th><th>Actions</th></tr></thead><tbody>
|
|
112
|
+
<tr
|
|
113
|
+
v-for="operation in state.items"
|
|
114
|
+
:key="operation.operationKey"
|
|
115
|
+
>
|
|
116
|
+
<td>{{ operation.providerKey }}</td><td>{{ operation.direction }}</td><td>{{ operation.status }}</td><td>
|
|
117
|
+
<progress
|
|
118
|
+
:value="operation.processedRows"
|
|
119
|
+
:max="Math.max(operation.totalRows, operation.processedRows, 1)"
|
|
120
|
+
/>
|
|
121
|
+
</td><td>{{ operation.acceptedRows }} accepted / {{ operation.rejectedRows }} rejected</td><td>{{ operation.retentionUntil }}</td><td>
|
|
122
|
+
<ElButton
|
|
123
|
+
v-if="['queued','running'].includes(operation.status)"
|
|
124
|
+
text
|
|
125
|
+
:disabled="!canCancel || state.mutating"
|
|
126
|
+
@click="runtime.cancel(operation)"
|
|
127
|
+
>
|
|
128
|
+
Cancel
|
|
129
|
+
</ElButton><ElButton
|
|
130
|
+
v-if="operation.resultFileKey"
|
|
131
|
+
text
|
|
132
|
+
:disabled="state.mutating"
|
|
133
|
+
@click="runtime.download(operation.resultFileKey)"
|
|
134
|
+
>
|
|
135
|
+
Result
|
|
136
|
+
</ElButton><ElButton
|
|
137
|
+
v-if="operation.errorFileKey"
|
|
138
|
+
text
|
|
139
|
+
:disabled="state.mutating"
|
|
140
|
+
@click="runtime.download(operation.errorFileKey)"
|
|
141
|
+
>
|
|
142
|
+
Errors
|
|
143
|
+
</ElButton>
|
|
144
|
+
</td>
|
|
145
|
+
</tr>
|
|
146
|
+
</tbody>
|
|
147
|
+
</table>
|
|
148
|
+
</div>
|
|
149
|
+
</PageContent>
|
|
150
|
+
</template>
|
|
151
|
+
|
|
152
|
+
<style scoped>
|
|
153
|
+
.submission { display: grid; gap: 8px; max-width: 720px; margin-bottom: 16px; }
|
|
154
|
+
.table-wrap { overflow-x: auto; } table { width: 100%; border-collapse: collapse; } th, td { padding: 10px 8px; border-bottom: 1px solid var(--el-border-color); text-align: left; } progress { min-width: 120px; }
|
|
155
|
+
</style>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export type ImportExportDirection = 'import' | 'export'
|
|
2
|
+
export type ImportExportStatus = 'queued' | 'running' | 'cancel_requested' | 'succeeded' | 'failed' | 'cancelled' | 'expired'
|
|
3
|
+
|
|
4
|
+
export interface ImportExportOperation {
|
|
5
|
+
operationKey: string
|
|
6
|
+
providerKey: string
|
|
7
|
+
direction: ImportExportDirection
|
|
8
|
+
status: ImportExportStatus
|
|
9
|
+
inputFileKey: string | null
|
|
10
|
+
resultFileKey: string | null
|
|
11
|
+
errorFileKey: string | null
|
|
12
|
+
taskJobKey: string | null
|
|
13
|
+
schemaRevision: string
|
|
14
|
+
processedRows: number
|
|
15
|
+
acceptedRows: number
|
|
16
|
+
rejectedRows: number
|
|
17
|
+
totalRows: number
|
|
18
|
+
revision: number
|
|
19
|
+
lastErrorCode: string | null
|
|
20
|
+
retentionUntil: string
|
|
21
|
+
createdAt: string
|
|
22
|
+
updatedAt: string
|
|
23
|
+
completedAt: string | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ImportExportList { items: ImportExportOperation[]; page: number; pageSize: number; total: number }
|
|
27
|
+
export interface ImportExportTransportResult { status: number; body: unknown; headers: Headers }
|
|
28
|
+
export interface ImportExportTransport {
|
|
29
|
+
list(status: ImportExportStatus, page: number, pageSize: number, signal: AbortSignal): Promise<ImportExportTransportResult>
|
|
30
|
+
submitImport(providerKey: string, fileKey: string, mapping: Record<string, string>, idempotencyKey: string, signal: AbortSignal): Promise<ImportExportTransportResult>
|
|
31
|
+
submitExport(providerKey: string, idempotencyKey: string, signal: AbortSignal): Promise<ImportExportTransportResult>
|
|
32
|
+
cancel(operationKey: string, revision: number, signal: AbortSignal): Promise<ImportExportTransportResult>
|
|
33
|
+
download(fileKey: string, signal: AbortSignal): Promise<Response>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const operationPattern = /^iox_[0-9a-f]{32}$/
|
|
37
|
+
const filePattern = /^file_[0-9a-f]{32}$/
|
|
38
|
+
const jobPattern = /^job_[0-9a-f]{32}$/
|
|
39
|
+
const providerPattern = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
|
|
40
|
+
const revisionPattern = /^[a-z0-9][a-z0-9._-]{0,63}$/
|
|
41
|
+
const errorPattern = /^[A-Z][A-Z0-9_]{2,63}$/
|
|
42
|
+
const datePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
|
43
|
+
|
|
44
|
+
const record = (value: unknown): Record<string, unknown> => {
|
|
45
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('IMPORT_EXPORT_RESPONSE_INVALID')
|
|
46
|
+
return value as Record<string, unknown>
|
|
47
|
+
}
|
|
48
|
+
const exact = (value: Record<string, unknown>, keys: readonly string[]): void => {
|
|
49
|
+
if (Object.keys(value).sort().join('|') !== [...keys].sort().join('|')) throw new Error('IMPORT_EXPORT_RESPONSE_INVALID')
|
|
50
|
+
}
|
|
51
|
+
const nullablePattern = (value: unknown, pattern: RegExp): value is string | null => value === null || (typeof value === 'string' && pattern.test(value))
|
|
52
|
+
const integer = (value: unknown, minimum = 0): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum
|
|
53
|
+
|
|
54
|
+
export const parseOperation = (value: unknown): ImportExportOperation => {
|
|
55
|
+
const item = record(value)
|
|
56
|
+
exact(item, ['operation_key', 'provider_key', 'direction', 'format', 'status', 'input_file_key', 'result_file_key', 'error_file_key', 'task_job_key', 'schema_revision', 'mapping', 'processed_rows', 'accepted_rows', 'rejected_rows', 'total_rows', 'revision', 'last_error_code', 'retention_until', 'created_at', 'updated_at', 'completed_at'])
|
|
57
|
+
if (!operationPattern.test(String(item.operation_key)) || !providerPattern.test(String(item.provider_key))
|
|
58
|
+
|| (item.direction !== 'import' && item.direction !== 'export') || item.format !== 'csv'
|
|
59
|
+
|| !['queued', 'running', 'cancel_requested', 'succeeded', 'failed', 'cancelled', 'expired'].includes(String(item.status))
|
|
60
|
+
|| !nullablePattern(item.input_file_key, filePattern) || !nullablePattern(item.result_file_key, filePattern)
|
|
61
|
+
|| !nullablePattern(item.error_file_key, filePattern) || !nullablePattern(item.task_job_key, jobPattern)
|
|
62
|
+
|| !revisionPattern.test(String(item.schema_revision)) || !integer(item.processed_rows) || !integer(item.accepted_rows)
|
|
63
|
+
|| !integer(item.rejected_rows) || !integer(item.total_rows) || !integer(item.revision, 1)
|
|
64
|
+
|| (item.accepted_rows as number) + (item.rejected_rows as number) > (item.processed_rows as number)
|
|
65
|
+
|| (item.last_error_code !== null && (typeof item.last_error_code !== 'string' || !errorPattern.test(item.last_error_code)))
|
|
66
|
+
|| !datePattern.test(String(item.retention_until)) || !datePattern.test(String(item.created_at)) || !datePattern.test(String(item.updated_at))
|
|
67
|
+
|| (item.completed_at !== null && (typeof item.completed_at !== 'string' || !datePattern.test(item.completed_at)))) {
|
|
68
|
+
throw new Error('IMPORT_EXPORT_RESPONSE_INVALID')
|
|
69
|
+
}
|
|
70
|
+
const terminal = ['succeeded', 'failed', 'cancelled', 'expired'].includes(String(item.status))
|
|
71
|
+
if ((item.direction === 'import') !== (item.input_file_key !== null) || terminal !== (item.completed_at !== null)) throw new Error('IMPORT_EXPORT_RESPONSE_INVALID')
|
|
72
|
+
const mapping = record(item.mapping)
|
|
73
|
+
for (const [source, target] of Object.entries(mapping)) if (source === '' || source.length > 120 || typeof target !== 'string' || !/^[a-z][a-z0-9_]{0,63}$/.test(target)) throw new Error('IMPORT_EXPORT_RESPONSE_INVALID')
|
|
74
|
+
return {
|
|
75
|
+
operationKey: item.operation_key as string, providerKey: item.provider_key as string,
|
|
76
|
+
direction: item.direction as ImportExportDirection, status: item.status as ImportExportStatus,
|
|
77
|
+
inputFileKey: item.input_file_key as string | null, resultFileKey: item.result_file_key as string | null,
|
|
78
|
+
errorFileKey: item.error_file_key as string | null, taskJobKey: item.task_job_key as string | null,
|
|
79
|
+
schemaRevision: item.schema_revision as string, processedRows: item.processed_rows as number,
|
|
80
|
+
acceptedRows: item.accepted_rows as number, rejectedRows: item.rejected_rows as number,
|
|
81
|
+
totalRows: item.total_rows as number, revision: item.revision as number,
|
|
82
|
+
lastErrorCode: item.last_error_code as string | null, retentionUntil: item.retention_until as string,
|
|
83
|
+
createdAt: item.created_at as string, updatedAt: item.updated_at as string, completedAt: item.completed_at as string | null,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const parseOperationResponse = (value: unknown): ImportExportOperation => {
|
|
88
|
+
const body = record(value); exact(body, ['data', 'meta']); record(body.meta); return parseOperation(body.data)
|
|
89
|
+
}
|
|
90
|
+
export const parseOperationList = (value: unknown): ImportExportList => {
|
|
91
|
+
const body = record(value); exact(body, ['data', 'meta'])
|
|
92
|
+
const data = record(body.data); exact(data, ['items']); if (!Array.isArray(data.items)) throw new Error('IMPORT_EXPORT_RESPONSE_INVALID')
|
|
93
|
+
const meta = record(body.meta); exact(meta, ['request_id', 'page', 'page_size', 'total'])
|
|
94
|
+
if (typeof meta.request_id !== 'string' || meta.request_id === '' || !integer(meta.page, 1) || !integer(meta.page_size, 1) || !integer(meta.total)) throw new Error('IMPORT_EXPORT_RESPONSE_INVALID')
|
|
95
|
+
return { items: data.items.map(parseOperation), page: meta.page, pageSize: meta.page_size, total: meta.total }
|
|
96
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { defineAdminModule } from '@peanut-admin/admin/core'
|
|
2
|
+
import type { AdminModuleContribution } from '@peanut-admin/admin/core'
|
|
3
|
+
import { inject, reactive } from 'vue'
|
|
4
|
+
import type { InjectionKey } from 'vue'
|
|
5
|
+
import { parseOperationList, parseOperationResponse } from './contracts'
|
|
6
|
+
import type { ImportExportOperation, ImportExportStatus, ImportExportTransport, ImportExportTransportResult } from './contracts'
|
|
7
|
+
|
|
8
|
+
export const IMPORT_EXPORT_MODULE_KEY = 'peanut.import-export' as const
|
|
9
|
+
export const IMPORT_EXPORT_ROUTE_NAME = 'peanut.import-export.list' as const
|
|
10
|
+
export const IMPORT_EXPORT_ROUTE_PATH = '/app/import-export' as const
|
|
11
|
+
export const IMPORT_EXPORT_READ_PERMISSION = 'peanut.import-export.read' as const
|
|
12
|
+
export const IMPORT_EXPORT_CREATE_PERMISSION = 'peanut.import-export.create' as const
|
|
13
|
+
export const IMPORT_EXPORT_CANCEL_PERMISSION = 'peanut.import-export.cancel' as const
|
|
14
|
+
export const IMPORT_EXPORT_STORE_KEY = 'peanut.import-export.runtime' as const
|
|
15
|
+
|
|
16
|
+
export interface ImportExportError { message: string; requestId: string | null; status: number | null }
|
|
17
|
+
export interface ImportExportState {
|
|
18
|
+
items: ImportExportOperation[]; status: ImportExportStatus; page: number; pageSize: number; total: number
|
|
19
|
+
loading: boolean; mutating: boolean; error: ImportExportError | null
|
|
20
|
+
}
|
|
21
|
+
export interface ImportExportRuntime {
|
|
22
|
+
state: ImportExportState
|
|
23
|
+
canCreate(): boolean
|
|
24
|
+
canCancel(): boolean
|
|
25
|
+
load(): Promise<void>
|
|
26
|
+
setStatus(status: ImportExportStatus): Promise<void>
|
|
27
|
+
submitImport(providerKey: string, fileKey: string, mapping: Record<string, string>): Promise<void>
|
|
28
|
+
submitExport(providerKey: string): Promise<void>
|
|
29
|
+
cancel(operation: ImportExportOperation): Promise<void>
|
|
30
|
+
download(fileKey: string): Promise<void>
|
|
31
|
+
dispose(): void
|
|
32
|
+
}
|
|
33
|
+
export interface ImportExportRuntimeOptions {
|
|
34
|
+
transport: ImportExportTransport
|
|
35
|
+
canRead(): boolean
|
|
36
|
+
canCreate(): boolean
|
|
37
|
+
canCancel(): boolean
|
|
38
|
+
saveDownload?: (response: Response, fileKey: string, signal: AbortSignal) => Promise<void>
|
|
39
|
+
idempotencyKey?: () => string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const requestId = (result: ImportExportTransportResult): string | null => {
|
|
43
|
+
const body = typeof result.body === 'object' && result.body !== null && !Array.isArray(result.body) ? result.body as Record<string, unknown> : {}
|
|
44
|
+
const value = body.request_id ?? result.headers.get('X-Request-Id')
|
|
45
|
+
return typeof value === 'string' && value !== '' ? value : null
|
|
46
|
+
}
|
|
47
|
+
const failure = (result: ImportExportTransportResult): ImportExportError => {
|
|
48
|
+
const body = typeof result.body === 'object' && result.body !== null && !Array.isArray(result.body) ? result.body as Record<string, unknown> : {}
|
|
49
|
+
return { message: typeof body.detail === 'string' && body.detail !== '' ? body.detail : `Import/export request failed (${result.status}).`, requestId: requestId(result), status: result.status }
|
|
50
|
+
}
|
|
51
|
+
const key = (): string => `web-${crypto.randomUUID()}`
|
|
52
|
+
const save = async (response: Response, fileKey: string, signal: AbortSignal): Promise<void> => {
|
|
53
|
+
const blob = await response.blob(); if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
|
|
54
|
+
const url = URL.createObjectURL(blob); const anchor = document.createElement('a')
|
|
55
|
+
anchor.href = url; anchor.download = `${fileKey}.csv`; anchor.click(); URL.revokeObjectURL(url)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const createImportExportRuntime = (options: ImportExportRuntimeOptions): ImportExportRuntime => {
|
|
59
|
+
const state = reactive<ImportExportState>({ items: [], status: 'queued', page: 1, pageSize: 20, total: 0, loading: false, mutating: false, error: null })
|
|
60
|
+
let disposeEpoch = 0; let readEpoch = 0; let downloadEpoch = 0
|
|
61
|
+
let readController: AbortController | null = null; let commandController: AbortController | null = null; let downloadController: AbortController | null = null
|
|
62
|
+
const aborted = (error: unknown): boolean => error instanceof DOMException && error.name === 'AbortError'
|
|
63
|
+
const load = async (): Promise<void> => {
|
|
64
|
+
const currentDispose = disposeEpoch; const currentRead = ++readEpoch
|
|
65
|
+
readController?.abort(); const controller = new AbortController(); readController = controller
|
|
66
|
+
state.loading = true; state.error = null
|
|
67
|
+
try {
|
|
68
|
+
if (!options.canRead()) throw new Error('IMPORT_EXPORT_PERMISSION_DENIED')
|
|
69
|
+
const status = state.status; const page = state.page; const pageSize = state.pageSize
|
|
70
|
+
const result = await options.transport.list(status, page, pageSize, controller.signal)
|
|
71
|
+
if (currentDispose !== disposeEpoch || currentRead !== readEpoch) return
|
|
72
|
+
if (result.status !== 200) { state.error = failure(result); return }
|
|
73
|
+
const list = parseOperationList(result.body); state.items = [...list.items]; state.page = list.page; state.pageSize = list.pageSize; state.total = list.total
|
|
74
|
+
} catch (error) { if (currentDispose === disposeEpoch && currentRead === readEpoch && !aborted(error)) state.error = { message: 'The import/export service could not be reached.', requestId: null, status: null } }
|
|
75
|
+
finally {
|
|
76
|
+
if (readController === controller) readController = null
|
|
77
|
+
if (currentDispose === disposeEpoch && currentRead === readEpoch) state.loading = false
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const mutate = async (operation: (signal: AbortSignal) => Promise<ImportExportTransportResult>): Promise<void> => {
|
|
81
|
+
if (state.mutating || commandController !== null) return
|
|
82
|
+
const currentDispose = disposeEpoch; const controller = new AbortController(); commandController = controller
|
|
83
|
+
state.mutating = true; state.error = null
|
|
84
|
+
try {
|
|
85
|
+
const result = await operation(controller.signal); if (currentDispose !== disposeEpoch) return
|
|
86
|
+
if (result.status !== 201 && result.status !== 200) { state.error = failure(result); return }
|
|
87
|
+
parseOperationResponse(result.body); await load()
|
|
88
|
+
} catch (error) { if (currentDispose === disposeEpoch && !aborted(error)) state.error = { message: 'The import/export request could not be completed.', requestId: null, status: null } }
|
|
89
|
+
finally {
|
|
90
|
+
if (commandController === controller) commandController = null
|
|
91
|
+
if (currentDispose === disposeEpoch) state.mutating = false
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
state, canCreate: options.canCreate, canCancel: options.canCancel, load,
|
|
96
|
+
async setStatus(status) { state.status = status; state.page = 1; await load() },
|
|
97
|
+
async submitImport(providerKey, fileKey, mapping) { if (!options.canCreate()) return; await mutate(signal => options.transport.submitImport(providerKey, fileKey, mapping, (options.idempotencyKey ?? key)(), signal)) },
|
|
98
|
+
async submitExport(providerKey) { if (!options.canCreate()) return; await mutate(signal => options.transport.submitExport(providerKey, (options.idempotencyKey ?? key)(), signal)) },
|
|
99
|
+
async cancel(operation) { if (!options.canCancel() || !['queued', 'running'].includes(operation.status)) return; await mutate(signal => options.transport.cancel(operation.operationKey, operation.revision, signal)) },
|
|
100
|
+
async download(fileKey) {
|
|
101
|
+
const currentDispose = disposeEpoch; const currentDownload = ++downloadEpoch
|
|
102
|
+
downloadController?.abort(); const controller = new AbortController(); downloadController = controller; state.error = null
|
|
103
|
+
try {
|
|
104
|
+
const response = await options.transport.download(fileKey, controller.signal)
|
|
105
|
+
if (currentDispose !== disposeEpoch || currentDownload !== downloadEpoch) return
|
|
106
|
+
if (!response.ok) { let body: unknown = null; try { body = await response.json() } catch { body = null }; if (currentDispose === disposeEpoch && currentDownload === downloadEpoch) state.error = failure({ status: response.status, body, headers: response.headers }); return }
|
|
107
|
+
await (options.saveDownload ?? save)(response, fileKey, controller.signal)
|
|
108
|
+
} catch (error) { if (currentDispose === disposeEpoch && currentDownload === downloadEpoch && !aborted(error)) state.error = { message: 'The CSV file could not be downloaded.', requestId: null, status: null } }
|
|
109
|
+
finally { if (downloadController === controller) downloadController = null }
|
|
110
|
+
},
|
|
111
|
+
dispose() {
|
|
112
|
+
disposeEpoch += 1; readEpoch += 1; downloadEpoch += 1
|
|
113
|
+
readController?.abort(); commandController?.abort(); downloadController?.abort()
|
|
114
|
+
readController = null; commandController = null; downloadController = null
|
|
115
|
+
state.items = []; state.status = 'queued'; state.page = 1; state.pageSize = 20; state.total = 0
|
|
116
|
+
state.loading = false; state.mutating = false; state.error = null
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const importExportRuntimeKey: InjectionKey<ImportExportRuntime> = Symbol(IMPORT_EXPORT_STORE_KEY)
|
|
122
|
+
export const useImportExportRuntime = (): ImportExportRuntime => { const runtime = inject(importExportRuntimeKey); if (runtime === undefined) throw new Error('IMPORT_EXPORT_RUNTIME_MISSING'); return runtime }
|
|
123
|
+
export const createImportExportModuleContribution = (runtime: ImportExportRuntime): AdminModuleContribution => defineAdminModule({
|
|
124
|
+
key: IMPORT_EXPORT_MODULE_KEY,
|
|
125
|
+
routes: [{ name: IMPORT_EXPORT_ROUTE_NAME, path: IMPORT_EXPORT_ROUTE_PATH, component: async () => ({ default: (await import('./ImportExportPage.vue')).default }), access: { moduleKey: IMPORT_EXPORT_MODULE_KEY, permissionKeys: [IMPORT_EXPORT_READ_PERMISSION] } }],
|
|
126
|
+
disposeOnTenantChange: true,
|
|
127
|
+
stores: [{ key: IMPORT_EXPORT_STORE_KEY, dispose: runtime.dispose }],
|
|
128
|
+
})
|