'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' /> ); }