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
+7
View File
@@ -53,6 +53,7 @@ import {
CloudDownloadOutlined,
ControlOutlined,
CrownOutlined,
DatabaseOutlined,
FileSearchOutlined,
GlobalOutlined,
LineChartOutlined,
@@ -84,6 +85,7 @@ const menuLeaves: MenuLeaf[] = [
{ key: '/admin-users', label: '用户管理', path: '/admin-users' },
{ key: '/kol-subscriptions', label: '订阅包审批', path: '/kol-subscriptions' },
{ key: '/site-domain-mappings', label: '站点映射', path: '/site-domain-mappings' },
{ key: '/object-storage', label: '对象存储', path: '/object-storage' },
{ key: '/desktop-client/releases', label: '客户端版本', path: '/desktop-client/releases' },
{ key: '/compliance/policy', label: '全局策略', path: '/compliance/policy' },
{ key: '/compliance/dictionaries', label: '合规词库', path: '/compliance/dictionaries' },
@@ -112,6 +114,11 @@ const menuItems = computed<ItemType[]>(() => [
label: '站点映射',
icon: () => h(GlobalOutlined),
},
{
key: '/object-storage',
label: '对象存储',
icon: () => h(DatabaseOutlined),
},
{
key: '/desktop-client/releases',
label: '客户端版本',
+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()}`,
)
},
}
+2
View File
@@ -25,6 +25,7 @@ import {
Result,
Segmented,
Select,
Skeleton,
Space,
Spin,
Statistic,
@@ -98,6 +99,7 @@ bindUnauthorizedHandler(() => {
Result,
Select,
Segmented,
Skeleton,
Space,
Spin,
Statistic,
+6
View File
@@ -61,6 +61,12 @@ export const router = createRouter({
component: () => import('@/views/SiteDomainMappingsView.vue'),
meta: { title: '站点映射' },
},
{
path: 'object-storage',
name: 'object-storage',
component: () => import('@/views/ObjectStorageView.vue'),
meta: { title: '对象存储' },
},
{
path: 'desktop-client/releases',
name: 'desktop-client-releases',
File diff suppressed because it is too large Load Diff
@@ -9,10 +9,27 @@
<span>本页停用 {{ inactiveRows }}</span>
</div>
</div>
<a-button type="primary" @click="openCreate">
<template #icon><PlusOutlined /></template>
新增映射
</a-button>
<div class="site-domain-header-actions">
<a-upload
accept=".csv,text/csv"
:before-upload="beforeImportUpload"
:show-upload-list="false"
:disabled="importing"
>
<a-button :loading="importing">
<template #icon><UploadOutlined /></template>
导入
</a-button>
</a-upload>
<a-button :loading="exporting" @click="exportMappings">
<template #icon><DownloadOutlined /></template>
导出
</a-button>
<a-button type="primary" @click="openCreate">
<template #icon><PlusOutlined /></template>
新增映射
</a-button>
</div>
</div>
<div class="ops-card site-domain-card">
@@ -37,6 +54,10 @@
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
<a-button @click="downloadTemplate">
<template #icon><FileTextOutlined /></template>
下载模板
</a-button>
</div>
<a-table
@@ -133,11 +154,73 @@
</div>
</template>
</a-drawer>
<a-modal
v-model:open="importResultOpen"
title="导入结果"
width="560px"
:footer="null"
:destroy-on-close="true"
>
<div v-if="importResult" class="import-result">
<div class="import-result-grid">
<div>
<span>读取</span>
<strong>{{ importResult.total }}</strong>
</div>
<div>
<span>新增</span>
<strong>{{ importResult.created }}</strong>
</div>
<div>
<span>更新</span>
<strong>{{ importResult.updated }}</strong>
</div>
<div>
<span>跳过</span>
<strong>{{ importResult.skipped }}</strong>
</div>
<div>
<span>失败</span>
<strong>{{ importResult.failed }}</strong>
</div>
<div>
<span>重复</span>
<strong>{{ importResult.duplicate ?? 0 }}</strong>
</div>
<div>
<span>未导入</span>
<strong>{{ importResult.skipped + importResult.failed }}</strong>
</div>
</div>
<a-alert
v-if="importResult.errors.length"
type="warning"
show-icon
message="以下行未导入"
class="import-result-alert"
/>
<div v-if="importResult.errors.length" class="import-error-list">
<div v-for="item in importResult.errors" :key="`${item.row}-${item.message}`">
<span> {{ item.row }} </span>
<p>{{ item.message }}</p>
</div>
</div>
</div>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue'
import {
DeleteOutlined,
DownloadOutlined,
EditOutlined,
FileTextOutlined,
PlusOutlined,
ReloadOutlined,
UploadOutlined,
} from '@ant-design/icons-vue'
import type { TablePaginationConfig } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
@@ -163,6 +246,21 @@ interface ListResult {
size: number
}
interface SiteDomainMappingImportError {
row: number
message: string
}
interface SiteDomainMappingImportResult {
total: number
created: number
updated: number
skipped: number
failed: number
duplicate: number
errors: SiteDomainMappingImportError[]
}
const filter = reactive({
keyword: '',
status: undefined as string | undefined,
@@ -183,10 +281,14 @@ const columns = [
const rows = ref<SiteDomainMappingRow[]>([])
const loading = ref(false)
const importing = ref(false)
const exporting = ref(false)
const page = ref(1)
const size = ref(20)
const total = ref(0)
const statusLoadingId = ref<number | null>(null)
const importResultOpen = ref(false)
const importResult = ref<SiteDomainMappingImportResult | null>(null)
const activeRows = computed(() => rows.value.filter((row) => row.is_active).length)
const inactiveRows = computed(() => rows.value.length - activeRows.value)
@@ -338,6 +440,96 @@ async function deleteMapping(row: SiteDomainMappingRow) {
}
}
function beforeImportUpload(file: File) {
void importMappings(file)
return false
}
async function importMappings(file: File) {
if (!file.name.toLowerCase().endsWith('.csv')) {
message.warning('请选择 CSV 文件')
return
}
importing.value = true
try {
const formData = new FormData()
formData.append('file', file)
importResult.value = await http.post<SiteDomainMappingImportResult, FormData>(
'/site-domain-mappings/import',
formData,
)
importResultOpen.value = true
message.success(
`导入完成:新增 ${importResult.value?.created ?? 0},更新 ${importResult.value?.updated ?? 0}`,
)
page.value = 1
void reload()
} catch (error) {
handleError(error)
} finally {
importing.value = false
}
}
async function exportMappings() {
exporting.value = true
try {
const response = await http.raw.get('/site-domain-mappings/export', {
params: {
keyword: filter.keyword || undefined,
is_active: filter.status || undefined,
},
responseType: 'blob',
})
const blob = new Blob([response.data], { type: 'text/csv;charset=utf-8' })
const fileName = resolveDownloadFileName(response.headers['content-disposition'])
downloadBlob(blob, fileName)
message.success('已导出 CSV')
} catch (error) {
handleError(error)
} finally {
exporting.value = false
}
}
function downloadTemplate() {
const csv = [
['registrable_domain', 'site_key', 'site_name', 'is_active'],
['zhihu.com', 'zhihu.com', '知乎', 'true'],
]
downloadBlob(
new Blob([toCsv(csv)], { type: 'text/csv;charset=utf-8' }),
'site-domain-mappings-template.csv',
)
}
function toCsv(rowsForCsv: string[][]) {
return `\uFEFF${rowsForCsv.map((row) => row.map(escapeCsvCell).join(',')).join('\n')}\n`
}
function escapeCsvCell(value: string) {
if (/[",\n\r]/.test(value)) {
return `"${value.replace(/"/g, '""')}"`
}
return value
}
function resolveDownloadFileName(contentDisposition: string | undefined) {
const matched = contentDisposition?.match(/filename="?([^"]+)"?/i)
return matched?.[1] || `site-domain-mappings-${dayjs().format('YYYYMMDDHHmmss')}.csv`
}
function downloadBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
function formatDate(value: string | null | undefined): string {
return value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '—'
}
@@ -384,6 +576,14 @@ onMounted(() => {
padding: 4px 8px;
}
.site-domain-header-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.site-domain-card {
padding-top: 20px;
}
@@ -430,15 +630,87 @@ onMounted(() => {
gap: 8px;
}
.import-result {
display: flex;
flex-direction: column;
gap: 14px;
}
.import-result-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.import-result-grid div {
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #f8fafc;
padding: 10px 12px;
}
.import-result-grid span {
display: block;
color: #64748b;
font-size: 12px;
}
.import-result-grid strong {
display: block;
margin-top: 4px;
color: #0f172a;
font-size: 20px;
line-height: 1.2;
}
.import-result-alert {
margin-top: 2px;
}
.import-error-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 240px;
overflow: auto;
}
.import-error-list div {
border: 1px solid #fee2e2;
border-radius: 6px;
background: #fff7f7;
padding: 8px 10px;
}
.import-error-list span {
color: #b91c1c;
font-size: 12px;
font-weight: 650;
}
.import-error-list p {
margin: 3px 0 0;
color: #334155;
font-size: 13px;
}
@media (max-width: 768px) {
.site-domain-header {
align-items: stretch;
flex-direction: column;
}
.site-domain-header-actions {
justify-content: flex-start;
}
.site-domain-search,
.site-domain-status {
width: 100%;
}
.import-result-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>