feat(ops): add object storage management and site-mapping CSV import/export

Ship the Ops backstage 对象存储 page (browse / upload / move / delete /
folder ops) backed by a new object-storage service + handler, with the
shared storage client extended with List/Stat/Copy/Usage/DownloadURL so
both MinIO and Aliyun providers cover the new surface. Add
ForcePathStyle to the object-storage config and treat r2/s3/custom_s3
as external providers so Cloudflare R2 and other S3-compatibles can
share the MinIO client path without spinning up the bundled MinIO.

Also add CSV import/export + template download to the site-domain
mappings page, raise gin MaxMultipartMemory to 8 MiB for the new
multipart endpoints, register Ops swagger routes, and extend the
descriptions-parity test to cover the ops router.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 01:19:01 +08:00
parent 5f6e9f11da
commit 7d8e82c69f
37 changed files with 5111 additions and 66 deletions
+107
View File
@@ -0,0 +1,107 @@
import { http } from '@/lib/http'
export interface ObjectStorageObject {
key: string
size: number
etag?: string
last_modified: string
content_type?: string
storage_class?: string
}
export interface ObjectStorageList {
objects: ObjectStorageObject[]
common_prefixes: string[]
continuation_token?: string
next_continuation_token?: string
is_truncated: boolean
prefix: string
keyword?: string
recursive: boolean
}
export interface ObjectStorageDetail extends ObjectStorageObject {
public_url?: string | null
preview_url?: string | null
}
export interface ObjectStorageUploadResult {
object: ObjectStorageObject
}
export interface ObjectStorageFolderCreateResult {
prefix: string
}
export interface ObjectStorageFolderDeleteResult {
prefix: string
deleted_count: number
}
export interface ObjectStorageUsage {
available: boolean
provider?: string
storage_size: number
object_count: number
}
export const objectStorageApi = {
list(params: {
prefix?: string
keyword?: string
continuation_token?: string
start_after?: string
recursive?: boolean
size?: number
}) {
return http.get<ObjectStorageList>('/object-storage/objects', params)
},
usage() {
return http.get<ObjectStorageUsage>('/object-storage/usage')
},
detail(key: string) {
return http.get<ObjectStorageDetail>('/object-storage/objects/detail', { key })
},
resolveURL(key: string) {
return http.get<{ url: string }>('/object-storage/objects/url', { key })
},
resolveDownloadURL(key: string) {
return http.get<{ url: string }>('/object-storage/objects/download-url', { key })
},
async upload(file: File, target: { key: string; overwrite: boolean; content_type?: string }) {
const form = new FormData()
form.append('file', file, file.name)
form.append('key', target.key)
form.append('overwrite', String(target.overwrite))
if (target.content_type) form.append('content_type', target.content_type)
const response = await http.raw.post<{
code: number
message: string
data: ObjectStorageUploadResult
}>('/object-storage/objects/upload', form)
return response.data.data
},
move(payload: {
source_key: string
destination_key: string
overwrite: boolean
delete_source: boolean
}) {
return http.post<ObjectStorageDetail, typeof payload>('/object-storage/objects/move', payload)
},
delete(key: string) {
return http.delete<{ key: string }>(
`/object-storage/objects?${new URLSearchParams({ key }).toString()}`,
)
},
createFolder(prefix: string) {
return http.post<ObjectStorageFolderCreateResult, { prefix: string }>('/object-storage/folders', {
prefix,
})
},
deleteFolder(prefix: string) {
return http.delete<ObjectStorageFolderDeleteResult>(
`/object-storage/folders?${new URLSearchParams({ prefix }).toString()}`,
)
},
}