feat: add configurable S3 object storage
This commit is contained in:
414
frontend/src/pages/admin/AdminStoragePage.vue
Normal file
414
frontend/src/pages/admin/AdminStoragePage.vue
Normal file
@@ -0,0 +1,414 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
activateStorageEndpoint,
|
||||
createStorageEndpoint,
|
||||
deleteStorageEndpoint,
|
||||
listStorageEndpoints,
|
||||
testStorageEndpoint,
|
||||
updateStorageEndpoint,
|
||||
type AdminStorageEndpoint,
|
||||
type AdminStorageEndpointPayload,
|
||||
} from '@/services/api'
|
||||
import { ApiError } from '@/services/http'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { formatBytes } from '@/utils/format'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const message = ref<string | null>(null)
|
||||
const activeBackend = ref<'local' | 's3'>('local')
|
||||
const endpoints = ref<AdminStorageEndpoint[]>([])
|
||||
const editingId = ref<string | null>(null)
|
||||
const formOpen = ref(false)
|
||||
const busy = ref<string | null>(null)
|
||||
|
||||
const emptyForm = () => ({
|
||||
name: '',
|
||||
internal_endpoint: '',
|
||||
public_endpoint: '',
|
||||
bucket: 'imageforge-results',
|
||||
region: 'garage',
|
||||
access_key: '',
|
||||
secret_key: '',
|
||||
force_path_style: true,
|
||||
presign_ttl_seconds: 300,
|
||||
})
|
||||
const form = ref(emptyForm())
|
||||
|
||||
const formTitle = computed(() => (editingId.value ? '编辑存储端点' : '新增存储端点'))
|
||||
const submitLabel = computed(() => (editingId.value ? '保存并停用待测' : '保存端点'))
|
||||
|
||||
function errorText(err: unknown, fallback: string) {
|
||||
if (err instanceof ApiError) return `[${err.code}] ${err.message}`
|
||||
return fallback
|
||||
}
|
||||
|
||||
function formatTime(value?: string | null) {
|
||||
if (!value) return '尚未测试'
|
||||
return new Date(value).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
async function loadEndpoints(clearError = true) {
|
||||
if (!auth.token) return
|
||||
loading.value = true
|
||||
if (clearError) error.value = null
|
||||
try {
|
||||
const response = await listStorageEndpoints(auth.token)
|
||||
endpoints.value = response.endpoints
|
||||
activeBackend.value = response.active_backend
|
||||
} catch (err) {
|
||||
error.value = errorText(err, '加载存储配置失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
form.value = emptyForm()
|
||||
formOpen.value = true
|
||||
message.value = null
|
||||
error.value = null
|
||||
}
|
||||
|
||||
function openEdit(endpoint: AdminStorageEndpoint) {
|
||||
editingId.value = endpoint.id
|
||||
form.value = {
|
||||
name: endpoint.name,
|
||||
internal_endpoint: endpoint.internal_endpoint,
|
||||
public_endpoint: endpoint.public_endpoint,
|
||||
bucket: endpoint.bucket,
|
||||
region: endpoint.region,
|
||||
access_key: '',
|
||||
secret_key: '',
|
||||
force_path_style: endpoint.force_path_style,
|
||||
presign_ttl_seconds: endpoint.presign_ttl_seconds,
|
||||
}
|
||||
formOpen.value = true
|
||||
message.value = null
|
||||
error.value = null
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
formOpen.value = false
|
||||
editingId.value = null
|
||||
form.value = emptyForm()
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (!auth.token) return
|
||||
error.value = null
|
||||
message.value = null
|
||||
busy.value = 'save'
|
||||
try {
|
||||
const payload: AdminStorageEndpointPayload = {
|
||||
name: form.value.name.trim(),
|
||||
internal_endpoint: form.value.internal_endpoint.trim(),
|
||||
public_endpoint: form.value.public_endpoint.trim(),
|
||||
bucket: form.value.bucket.trim(),
|
||||
region: form.value.region.trim(),
|
||||
force_path_style: form.value.force_path_style,
|
||||
presign_ttl_seconds: Number(form.value.presign_ttl_seconds),
|
||||
}
|
||||
if (form.value.access_key.trim()) payload.access_key = form.value.access_key.trim()
|
||||
if (form.value.secret_key.trim()) payload.secret_key = form.value.secret_key.trim()
|
||||
|
||||
if (editingId.value) {
|
||||
await updateStorageEndpoint(auth.token, editingId.value, payload)
|
||||
message.value = '端点已保存并停用,请重新测试后启用。'
|
||||
} else {
|
||||
if (!payload.access_key || !payload.secret_key) {
|
||||
error.value = '新增端点必须填写 Access Key 和 Secret Key。'
|
||||
return
|
||||
}
|
||||
await createStorageEndpoint(auth.token, {
|
||||
...payload,
|
||||
access_key: payload.access_key,
|
||||
secret_key: payload.secret_key,
|
||||
})
|
||||
message.value = '端点已保存,请执行读写删测试。'
|
||||
}
|
||||
closeForm()
|
||||
await loadEndpoints(false)
|
||||
} catch (err) {
|
||||
error.value = errorText(err, '保存存储端点失败')
|
||||
} finally {
|
||||
busy.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function runTest(endpoint: AdminStorageEndpoint) {
|
||||
if (!auth.token) return
|
||||
busy.value = `test:${endpoint.id}`
|
||||
error.value = null
|
||||
message.value = null
|
||||
try {
|
||||
const response = await testStorageEndpoint(auth.token, endpoint.id)
|
||||
message.value = `${endpoint.name}:${response.message}`
|
||||
} catch (err) {
|
||||
error.value = errorText(err, '存储测试失败')
|
||||
} finally {
|
||||
busy.value = null
|
||||
await loadEndpoints(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function activate(endpoint: AdminStorageEndpoint) {
|
||||
if (!auth.token) return
|
||||
const confirmed = window.confirm(
|
||||
`启用“${endpoint.name}”后,新生成的图片和 ZIP 将写入该端点。系统会先执行一次读写删测试,是否继续?`,
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
busy.value = `activate:${endpoint.id}`
|
||||
error.value = null
|
||||
message.value = null
|
||||
try {
|
||||
const response = await activateStorageEndpoint(auth.token, endpoint.id)
|
||||
message.value = response.message
|
||||
} catch (err) {
|
||||
error.value = errorText(err, '启用存储端点失败')
|
||||
} finally {
|
||||
busy.value = null
|
||||
await loadEndpoints(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeEndpoint(endpoint: AdminStorageEndpoint) {
|
||||
if (!auth.token) return
|
||||
const confirmed = window.confirm(`确定删除“${endpoint.name}”吗?关联对象未清空时后端会拒绝删除。`)
|
||||
if (!confirmed) return
|
||||
|
||||
busy.value = `delete:${endpoint.id}`
|
||||
error.value = null
|
||||
message.value = null
|
||||
try {
|
||||
const response = await deleteStorageEndpoint(auth.token, endpoint.id)
|
||||
message.value = response.message
|
||||
} catch (err) {
|
||||
error.value = errorText(err, '删除存储端点失败')
|
||||
} finally {
|
||||
busy.value = null
|
||||
await loadEndpoints(false)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadEndpoints)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<section class="overflow-hidden rounded-2xl bg-slate-950 text-white shadow-sm">
|
||||
<div class="grid gap-6 p-6 lg:grid-cols-[1.35fr_1fr] lg:p-8">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-cyan-300">Storage control plane</p>
|
||||
<h2 class="mt-3 text-2xl font-semibold">对象存储</h2>
|
||||
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-300">
|
||||
应用完成鉴权后返回短期签名地址,图片与批量 ZIP 的下载流量直接由高带宽 S3 节点承担。每个对象固定绑定写入端点,后续切换或扩容不会影响旧文件。
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-sm text-slate-300">当前新文件后端</span>
|
||||
<span
|
||||
class="rounded-full px-3 py-1 text-xs font-semibold"
|
||||
:class="activeBackend === 's3' ? 'bg-emerald-400/15 text-emerald-300' : 'bg-amber-400/15 text-amber-200'"
|
||||
>
|
||||
{{ activeBackend === 's3' ? 'S3 已启用' : '本地回退' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-3 text-xs leading-5 text-slate-400">
|
||||
推荐内部 Endpoint 走两台服务器之间的 WireGuard 地址;公网 Endpoint 使用带 TLS 的下载域名。签名默认 5 分钟有效。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div v-if="message" class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
|
||||
{{ message }}
|
||||
</div>
|
||||
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-900">存储端点</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">同一时间只有一个端点接收新对象,停用端点继续服务其历史对象。</p>
|
||||
</div>
|
||||
<button
|
||||
class="rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800"
|
||||
type="button"
|
||||
@click="openCreate"
|
||||
>
|
||||
新增端点
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="mt-6 text-sm text-slate-500">正在加载...</div>
|
||||
<div v-else-if="endpoints.length === 0" class="mt-6 rounded-xl border border-dashed border-slate-300 bg-slate-50 p-8 text-center">
|
||||
<p class="font-medium text-slate-800">尚未配置 S3</p>
|
||||
<p class="mt-1 text-sm text-slate-500">系统继续使用本地磁盘,保存端点并测试通过后才会切换。</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-5 grid gap-4">
|
||||
<article
|
||||
v-for="endpoint in endpoints"
|
||||
:key="endpoint.id"
|
||||
class="rounded-xl border p-5"
|
||||
:class="endpoint.is_active ? 'border-emerald-300 bg-emerald-50/40' : 'border-slate-200 bg-white'"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h4 class="font-semibold text-slate-900">{{ endpoint.name }}</h4>
|
||||
<span v-if="endpoint.is_active" class="rounded-full bg-emerald-600 px-2.5 py-0.5 text-xs font-semibold text-white">活动</span>
|
||||
<span
|
||||
class="rounded-full px-2.5 py-0.5 text-xs font-medium"
|
||||
:class="endpoint.last_test_ok === true
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: endpoint.last_test_ok === false
|
||||
? 'bg-rose-100 text-rose-700'
|
||||
: 'bg-slate-100 text-slate-600'"
|
||||
>
|
||||
{{ endpoint.last_test_ok === true ? '测试通过' : endpoint.last_test_ok === false ? '测试失败' : '未测试' }}
|
||||
</span>
|
||||
</div>
|
||||
<dl class="mt-3 grid gap-x-8 gap-y-2 text-sm sm:grid-cols-2">
|
||||
<div><dt class="inline text-slate-500">内部:</dt><dd class="inline break-all text-slate-800">{{ endpoint.internal_endpoint }}</dd></div>
|
||||
<div><dt class="inline text-slate-500">公网:</dt><dd class="inline break-all text-slate-800">{{ endpoint.public_endpoint }}</dd></div>
|
||||
<div><dt class="inline text-slate-500">Bucket:</dt><dd class="inline text-slate-800">{{ endpoint.bucket }}</dd></div>
|
||||
<div><dt class="inline text-slate-500">Region:</dt><dd class="inline text-slate-800">{{ endpoint.region }}</dd></div>
|
||||
<div><dt class="inline text-slate-500">Access Key:</dt><dd class="inline text-slate-800">{{ endpoint.access_key_hint }}</dd></div>
|
||||
<div><dt class="inline text-slate-500">签名:</dt><dd class="inline text-slate-800">{{ endpoint.presign_ttl_seconds }} 秒</dd></div>
|
||||
<div><dt class="inline text-slate-500">对象:</dt><dd class="inline text-slate-800">{{ endpoint.object_count }} 个</dd></div>
|
||||
<div><dt class="inline text-slate-500">记录容量:</dt><dd class="inline text-slate-800">{{ formatBytes(endpoint.stored_bytes) }}</dd></div>
|
||||
</dl>
|
||||
<p class="mt-3 text-xs text-slate-500">最近测试:{{ formatTime(endpoint.last_test_at) }}</p>
|
||||
<p v-if="endpoint.last_test_error" class="mt-2 text-xs text-rose-600">{{ endpoint.last_test_error }}</p>
|
||||
<p v-if="!endpoint.public_endpoint.startsWith('https://')" class="mt-2 text-xs font-medium text-amber-700">
|
||||
公网 Endpoint 未使用 HTTPS,不建议生产启用。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-if="!endpoint.is_active && endpoint.object_count === 0"
|
||||
class="rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-700 hover:bg-white"
|
||||
type="button"
|
||||
@click="openEdit(endpoint)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
class="rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-700 hover:bg-white disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="busy !== null"
|
||||
@click="runTest(endpoint)"
|
||||
>
|
||||
{{ busy === `test:${endpoint.id}` ? '测试中...' : '全链路测试' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!endpoint.is_active"
|
||||
class="rounded-md bg-emerald-700 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-600 disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="busy !== null"
|
||||
@click="activate(endpoint)"
|
||||
>
|
||||
{{ busy === `activate:${endpoint.id}` ? '验证并启用...' : '验证并启用' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!endpoint.is_active"
|
||||
class="rounded-md px-3 py-1.5 text-sm text-rose-700 hover:bg-rose-50 disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="busy !== null || endpoint.object_count > 0"
|
||||
@click="removeEndpoint(endpoint)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="formOpen" class="rounded-2xl border border-cyan-200 bg-cyan-50/40 p-5 shadow-sm sm:p-6">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-900">{{ formTitle }}</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">编辑连接参数后端点会自动停用,避免未经验证的配置接收新文件。</p>
|
||||
</div>
|
||||
<button type="button" class="text-sm text-slate-500 hover:text-slate-900" @click="closeForm">关闭</button>
|
||||
</div>
|
||||
|
||||
<form class="mt-5 grid gap-4 sm:grid-cols-2" @submit.prevent="submitForm">
|
||||
<label class="text-sm text-slate-700">
|
||||
端点名称
|
||||
<input v-model="form.name" required maxlength="100" class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2" placeholder="119 高带宽 S3" />
|
||||
</label>
|
||||
<label class="text-sm text-slate-700">
|
||||
Bucket
|
||||
<input v-model="form.bucket" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 font-mono text-sm" />
|
||||
</label>
|
||||
<label class="text-sm text-slate-700 sm:col-span-2">
|
||||
内部 Endpoint
|
||||
<input v-model="form.internal_endpoint" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 font-mono text-sm" placeholder="http://10.70.0.2:3900" />
|
||||
<span class="mt-1 block text-xs text-slate-500">应用服务器上传、清理和打包读取使用,推荐走 WireGuard 私网。</span>
|
||||
</label>
|
||||
<label class="text-sm text-slate-700 sm:col-span-2">
|
||||
公网 Endpoint
|
||||
<input v-model="form.public_endpoint" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 font-mono text-sm" placeholder="https://files.example.com" />
|
||||
<span class="mt-1 block text-xs text-slate-500">签名下载 URL 使用,必须由公网客户端可达,生产环境使用 HTTPS。</span>
|
||||
</label>
|
||||
<label class="text-sm text-slate-700">
|
||||
Region
|
||||
<input v-model="form.region" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 font-mono text-sm" />
|
||||
</label>
|
||||
<label class="text-sm text-slate-700">
|
||||
签名有效期(秒)
|
||||
<input v-model.number="form.presign_ttl_seconds" type="number" min="60" max="3600" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2" />
|
||||
</label>
|
||||
<label class="text-sm text-slate-700">
|
||||
Access Key
|
||||
<input v-model="form.access_key" :required="!editingId" autocomplete="off" class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 font-mono text-sm" :placeholder="editingId ? '留空不修改' : '输入 Access Key'" />
|
||||
</label>
|
||||
<label class="text-sm text-slate-700">
|
||||
Secret Key
|
||||
<input v-model="form.secret_key" :required="!editingId" type="password" autocomplete="new-password" class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 font-mono text-sm" :placeholder="editingId ? '留空不修改' : '输入 Secret Key'" />
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700 sm:col-span-2">
|
||||
<input v-model="form.force_path_style" type="checkbox" class="h-4 w-4 rounded border-slate-300" />
|
||||
强制 Path-style(Garage / MinIO 推荐)
|
||||
</label>
|
||||
<div class="flex gap-3 sm:col-span-2">
|
||||
<button type="submit" :disabled="busy !== null" class="rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800 disabled:opacity-50">
|
||||
{{ busy === 'save' ? '保存中...' : submitLabel }}
|
||||
</button>
|
||||
<button type="button" class="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm text-slate-700" @click="closeForm">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4 md:grid-cols-3">
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-slate-400">1 day</p>
|
||||
<p class="mt-2 font-medium text-slate-900">未登录与免费用户</p>
|
||||
<p class="mt-1 text-sm text-slate-500">对象键位于 1d 生命周期前缀,数据库按精确过期时间清理。</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-slate-400">7 days</p>
|
||||
<p class="mt-2 font-medium text-slate-900">低级会员</p>
|
||||
<p class="mt-1 text-sm text-slate-500">Pro 套餐保留 7 天,端点切换不改变已生成对象的位置。</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-slate-400">15 days</p>
|
||||
<p class="mt-2 font-medium text-slate-900">高级会员</p>
|
||||
<p class="mt-1 text-sm text-slate-500">Business 套餐保留 15 天,S3 生命周期作为兜底清理。</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user