"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 services from "@/lib/services" 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([ services.adminSystemConfig.getSystemConfig(storageConfigKey), services.adminTask.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((current) => { const normalized = normalizeConfig(query.data.config) if (current) { return { ...normalized, driver: current.driver, } } return normalized }) } }, [query.data?.config]) const saveMutation = useMutation({ mutationFn: async (value: StorageConfig) => { await services.adminSystemConfig.updateSystemConfig(storageConfigKey, { value: JSON.stringify(value), }) }, onSuccess: () => { toast.success("存储配置已保存") void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]}) }, onError: (error: Error) => { toast.error(error.message || "保存存储配置失败") void queryClient.invalidateQueries({queryKey: ["admin", "storage-config"]}) }, }) const migrateMutation = useMutation({ mutationFn: async (value: StorageConfig) => { const payload: StorageMigrationPayload = {target: value} return services.adminTask.dispatchTask({ task_type: storageMigrationTaskType, payload: JSON.stringify(payload), }) }, onSuccess: () => { toast.success("存储迁移任务已下发") 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) => services.adminTask.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) || saveMutation.isPending || migrateMutation.isPending 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}})} />
)}
{config.driver !== query.data?.config.driver && ( ⚠️ 您已切换存储类型。点击「保存配置」将立即切换活动存储引擎,并同步更新已有文件的存储驱动标记。仅在需要复制物理文件时才使用「开始迁移」。 )}
{config.driver !== query.data?.config.driver && ( )}
) } 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 MinIO 等自托管 S3 通常需要开启。
onChange({path_style})} />
)} ) } 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)} className="border-dashed bg-card text-xs" /> ) }