@@ -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" > 对象键位于 1 d 生命周期前缀 , 数据库按精确过期时间清理 。 < / 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 >