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:
@@ -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: '客户端版本',
|
||||
|
||||
@@ -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()}`,
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Result,
|
||||
Segmented,
|
||||
Select,
|
||||
Skeleton,
|
||||
Space,
|
||||
Spin,
|
||||
Statistic,
|
||||
@@ -98,6 +99,7 @@ bindUnauthorizedHandler(() => {
|
||||
Result,
|
||||
Select,
|
||||
Segmented,
|
||||
Skeleton,
|
||||
Space,
|
||||
Spin,
|
||||
Statistic,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user