"use client" import * as React from "react" import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query" import {Database, Loader2, Play, Save} from "lucide-react" import {toast} from "sonner" import {Badge} from "@/components/ui/badge" import {Button} from "@/components/ui/button" import {Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle} from "@/components/ui/card" import {Field, FieldDescription, FieldGroup, FieldLabel} from "@/components/ui/field" import {Input} from "@/components/ui/input" import {Progress} from "@/components/ui/progress" import {Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select" import {Switch} from "@/components/ui/switch" import {AdminService} from "@/lib/services/admin/admin.service" import type { ObjectStorageConfig, StorageConfig, StorageDriver, TaskExecution, TaskExecutionStatus } from "@/lib/services/admin/types" const storageConfigKey = "storage_config" const storageMigrationTaskType = "storage_migration" const driverLabels: Record = { local: "本地文件系统", s3: "AWS S3", r2: "Cloudflare R2", minio: "MinIO", oss: "阿里云 OSS", webdav: "WebDAV", } const emptyObjectConfig: ObjectStorageConfig = { endpoint: "", region: "", bucket: "", access_key_id: "", secret_access_key: "", path_style: false, key_prefix: "", cdn_url: "", } function normalizeConfig(config: StorageConfig): StorageConfig { const s3 = {...emptyObjectConfig, ...config.s3} const r2 = {...emptyObjectConfig, ...config.r2} const minio = {...emptyObjectConfig, ...config.minio} const oss = {...emptyObjectConfig, ...config.oss} const webdav = {...config.webdav} return { ...config, local: {root: config.local?.root || "."}, s3: {...s3, region: s3.region || "us-east-1"}, r2: {...r2, region: r2.region || "auto"}, minio: {...minio, region: minio.region || "us-east-1", path_style: config.minio?.path_style ?? true}, oss, webdav: { endpoint: webdav.endpoint || "", username: webdav.username || "", password: webdav.password || "", base_path: webdav.base_path || "", }, } } type StorageMigrationView = { state: "idle" | TaskExecutionStatus id?: string task_id?: string source_driver?: StorageDriver target_driver?: StorageDriver error?: string } type StorageMigrationPayload = { target: StorageConfig } function parseMigrationPayload(payload: string): StorageConfig | undefined { try { const parsed = JSON.parse(payload) as Partial return parsed.target ? normalizeConfig(parsed.target) : undefined } catch { return undefined } } function driverLabel(driver?: StorageDriver): string { return driver ? driverLabels[driver] : "未知" } function latestMigration(executions: TaskExecution[], current?: StorageConfig): StorageMigrationView { const execution = executions[0] if (!execution || execution.status === "succeeded") { return {state: "idle"} } const target = parseMigrationPayload(execution.payload) return { state: execution.status, id: execution.id, task_id: execution.task_id, source_driver: current?.driver, target_driver: target?.driver, error: execution.error_message, } } export function StorageConfigTab() { const queryClient = useQueryClient() const [config, setConfig] = React.useState(null) const query = useQuery({ queryKey: ["admin", "storage-config"], queryFn: async () => { const [configRecord, executions] = await Promise.all([ AdminService.getSystemConfig(storageConfigKey), AdminService.listTaskExecutions({task_type: storageMigrationTaskType, page: 1, page_size: 1}), ]) const current = normalizeConfig(JSON.parse(configRecord.value) as StorageConfig) const migration = latestMigration(executions.items, current) return { config: current, migration, } }, refetchInterval: (result) => { const state = result.state.data?.migration.state return state === "pending" || state === "running" ? 2000 : false }, }) React.useEffect(() => { if (query.data?.config) { setConfig(normalizeConfig(query.data.config)) } }, [query.data?.config]) const saveMutation = useMutation({ mutationFn: async (value: StorageConfig) => { if (query.data?.config.driver === value.driver) { await AdminService.updateSystemConfig(storageConfigKey, { value: JSON.stringify(value), }) return "" } const payload: StorageMigrationPayload = {target: value} return AdminService.dispatchTask({task_type: storageMigrationTaskType, payload: JSON.stringify(payload)}) }, onSuccess: (taskID) => { toast.success(taskID ? "配置已保存,存储迁移任务已下发" : "存储配置已保存") void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]}) }, onError: (error: Error) => { toast.error(error.message || "保存存储配置失败") void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]}) }, }) const runMutation = useMutation({ mutationFn: (executionID: string) => AdminService.retryTaskExecution(executionID), onSuccess: () => { toast.success("存储迁移任务已重新下发") void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]}) }, onError: (error: Error) => toast.error(error.message || "运行存储迁移失败"), }) if (query.isPending || !config) { return
} const migration = query.data?.migration const isReadOnly = migration ? migration.state !== "idle" : false const isFormDisabled = migration ? (migration.state === "pending" || migration.state === "running") : false const updateObject = (driver: "s3" | "r2" | "minio" | "oss", patch: Partial) => { setConfig((current) => current ? { ...current, [driver]: {...current[driver], ...patch}, } : current) } return (
{isReadOnly && migration && ( 存储维护模式 {migration.state} {driverLabel(migration.source_driver)} → {driverLabel(migration.target_driver)}。迁移期间文件只允许读取,禁止上传、删除和清理。

迁移进度请查看任务执行日志:{migration.task_id || "尚未下发"}

{migration.error &&

{migration.error}

}
)} 文件存储 默认使用本地存储。切换存储类型且已有文件时,系统会自动进入维护模式并迁移文件。 存储类型 {config.driver === "local" && ( setConfig({...config, local: {root}})} /> )} {(config.driver === "s3" || config.driver === "r2" || config.driver === "minio" || config.driver === "oss") && ( updateObject(config.driver as "s3" | "r2" | "minio" | "oss", patch)} /> )} {config.driver === "webdav" && ( <> setConfig({...config, webdav: {...config.webdav, endpoint}})} /> setConfig({...config, webdav: {...config.webdav, username}})} /> setConfig({...config, webdav: {...config.webdav, password}})} /> setConfig({...config, webdav: {...config.webdav, base_path}})} /> )}
) } function ObjectFields({ driver, value, onChange, }: { driver: "s3" | "r2" | "minio" | "oss" value: ObjectStorageConfig onChange: (patch: Partial) => void }) { return ( <> {driver === "r2" && onChange({account_id})} />} {driver !== "s3" && onChange({endpoint})} />} onChange({region})} /> onChange({bucket})} /> onChange({access_key_id})} /> onChange({secret_access_key})} /> onChange({key_prefix})} /> onChange({cdn_url})} /> {(driver === "s3" || driver === "minio") && ( Path Style onChange({path_style})} /> MinIO 等自托管 S3 通常需要开启。 )} ) } function TextField({ label, value, onChange, placeholder, type = "text", }: { label: string value: string onChange: (value: string) => void placeholder?: string type?: React.HTMLInputTypeAttribute }) { return ( {label} onChange(event.target.value)} /> ) }