Phase 3.1: OSS storage config management (MinIO/AliyunOSS/AWS S3)
Backend: - Add OSSStorageConfig model (models.py) - Create storage_admin.py API (9 endpoints): stats, CRUD, toggle, set-default, test connection - Register route /app/api/admin/storage in main.py - Create oss_storage_configs table in MySQL Frontend: - Add Storage.tsx admin page (713 lines) with stats, table, create/edit modal - Register /admin/storage route in App.tsx - Add nav item and icon in AdminLayout.tsx - Add 9 API methods in api/index.ts
This commit is contained in:
1 parent
5d1ad26874
commit
b2ba75a666
7 files changed
+2265
-468
No files matched your search
@@ -0,0 +1,713 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
interface OSSConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
endpoint: string;
|
||||
access_key: string;
|
||||
bucket: string;
|
||||
region: string;
|
||||
base_path: string;
|
||||
is_default: boolean;
|
||||
is_enabled: boolean;
|
||||
status: string;
|
||||
config: Record<string, any>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface StorageStats {
|
||||
total: number;
|
||||
enabled_count: number;
|
||||
default_count: number;
|
||||
by_provider: Record<string, number>;
|
||||
by_status: Record<string, number>;
|
||||
}
|
||||
|
||||
const PROVIDER_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
|
||||
minio: { label: 'MinIO', color: '#C9362C', bg: '#FEF0EF' },
|
||||
aliyun_oss: { label: '阿里云OSS', color: '#FF6A00', bg: '#FFF3E8' },
|
||||
aws_s3: { label: 'AWS S3', color: '#FF9900', bg: '#FFF8E1' },
|
||||
};
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
|
||||
connected: { label: '已连接', color: '#10B981', bg: '#D1FAE5' },
|
||||
disconnected: { label: '未连接', color: '#6B7280', bg: '#F3F4F6' },
|
||||
error: { label: '连接异常', color: '#EF4444', bg: '#FEE2E2' },
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
const Storage: React.FC = () => {
|
||||
// Data state
|
||||
const [configs, setConfigs] = useState<OSSConfig[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState<StorageStats | null>(null);
|
||||
|
||||
// Filter state
|
||||
const [filterProvider, setFilterProvider] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
// Modal state
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingConfig, setEditingConfig] = useState<OSSConfig | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
provider: 'minio',
|
||||
endpoint: '',
|
||||
access_key: '',
|
||||
secret_key: '',
|
||||
bucket: '',
|
||||
region: '',
|
||||
base_path: '',
|
||||
is_default: false,
|
||||
is_enabled: true,
|
||||
config_str: '',
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Test connection state
|
||||
const [testingId, setTestingId] = useState<string | null>(null);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string; latency_ms: number } | null>(null);
|
||||
|
||||
// Toast
|
||||
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
|
||||
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
|
||||
setToast({ msg, type });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
|
||||
// Load stats
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const result = await adminAPI.getAdminStorageStats() as any;
|
||||
setStats(result.data || null);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// Load configs
|
||||
const loadConfigs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = { page, page_size: PAGE_SIZE };
|
||||
if (filterProvider) params.provider = filterProvider;
|
||||
if (keyword) params.keyword = keyword;
|
||||
const result = await adminAPI.getAdminStorageConfigs(params) as any;
|
||||
const data = result.data || result;
|
||||
setConfigs(data.items || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch (e: any) {
|
||||
showToast('加载失败: ' + (e.message || e), 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigs();
|
||||
}, [page, filterProvider, keyword]);
|
||||
|
||||
// Open create modal
|
||||
const openCreateModal = () => {
|
||||
setEditingConfig(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
provider: 'minio',
|
||||
endpoint: '',
|
||||
access_key: '',
|
||||
secret_key: '',
|
||||
bucket: '',
|
||||
region: '',
|
||||
base_path: '',
|
||||
is_default: false,
|
||||
is_enabled: true,
|
||||
config_str: '',
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
// Open edit modal
|
||||
const openEditModal = async (config: OSSConfig) => {
|
||||
try {
|
||||
const result = await adminAPI.getAdminStorageConfig(config.id) as any;
|
||||
const detail = result.data;
|
||||
setEditingConfig(config);
|
||||
setFormData({
|
||||
name: detail.name || '',
|
||||
provider: detail.provider || 'minio',
|
||||
endpoint: detail.endpoint || '',
|
||||
access_key: '', // Not returned for security
|
||||
secret_key: '', // Not returned for security
|
||||
bucket: detail.bucket || '',
|
||||
region: detail.region || '',
|
||||
base_path: detail.base_path || '',
|
||||
is_default: detail.is_default || false,
|
||||
is_enabled: detail.is_enabled !== false,
|
||||
config_str: detail.config ? JSON.stringify(detail.config, null, 2) : '',
|
||||
});
|
||||
setModalVisible(true);
|
||||
} catch (e: any) {
|
||||
showToast('获取配置详情失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Submit form
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name.trim()) { showToast('请输入配置名称', 'error'); return; }
|
||||
if (!formData.endpoint.trim()) { showToast('请输入端点地址', 'error'); return; }
|
||||
if (!formData.bucket.trim()) { showToast('请输入Bucket名称', 'error'); return; }
|
||||
|
||||
// For create, require credentials
|
||||
if (!editingConfig && (!formData.access_key.trim() || !formData.secret_key.trim())) {
|
||||
showToast('请输入Access Key和Secret Key', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
let configObj: any = undefined;
|
||||
if (formData.config_str.trim()) {
|
||||
try {
|
||||
configObj = JSON.parse(formData.config_str);
|
||||
} catch {
|
||||
showToast('扩展配置JSON格式不正确', 'error');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const data: any = {
|
||||
name: formData.name.trim(),
|
||||
provider: formData.provider,
|
||||
endpoint: formData.endpoint.trim(),
|
||||
bucket: formData.bucket.trim(),
|
||||
region: formData.region.trim(),
|
||||
base_path: formData.base_path.trim(),
|
||||
is_default: formData.is_default,
|
||||
is_enabled: formData.is_enabled,
|
||||
config: configObj,
|
||||
};
|
||||
|
||||
// Include credentials if provided (for create or update)
|
||||
if (formData.access_key.trim()) data.access_key = formData.access_key.trim();
|
||||
if (formData.secret_key.trim()) data.secret_key = formData.secret_key.trim();
|
||||
|
||||
if (editingConfig) {
|
||||
await adminAPI.updateAdminStorageConfig(editingConfig.id, data);
|
||||
showToast('更新成功');
|
||||
} else {
|
||||
await adminAPI.createAdminStorageConfig(data);
|
||||
showToast('创建成功');
|
||||
}
|
||||
|
||||
setModalVisible(false);
|
||||
loadConfigs();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
showToast('操作失败: ' + (e.message || e), 'error');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete config
|
||||
const handleDelete = async (config: OSSConfig) => {
|
||||
if (!window.confirm(`确定要删除配置 "${config.name}" 吗?此操作不可恢复。`)) return;
|
||||
try {
|
||||
await adminAPI.deleteAdminStorageConfig(config.id);
|
||||
showToast('删除成功');
|
||||
loadConfigs();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
showToast('删除失败: ' + (e.message || e), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle enabled
|
||||
const handleToggle = async (config: OSSConfig) => {
|
||||
try {
|
||||
await adminAPI.toggleAdminStorageConfig(config.id);
|
||||
showToast(config.is_enabled ? '已禁用' : '已启用');
|
||||
loadConfigs();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
showToast('操作失败: ' + (e.message || e), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Set default
|
||||
const handleSetDefault = async (config: OSSConfig) => {
|
||||
if (config.is_default) return;
|
||||
try {
|
||||
await adminAPI.setDefaultAdminStorageConfig(config.id);
|
||||
showToast(`已将 "${config.name}" 设为默认存储`);
|
||||
loadConfigs();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
showToast('操作失败: ' + (e.message || e), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Test connection
|
||||
const handleTestConnection = async (config: OSSConfig) => {
|
||||
setTestingId(config.id);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const result = await adminAPI.testAdminStorageConfig({ config_id: config.id }) as any;
|
||||
const data = result.data;
|
||||
setTestResult(data);
|
||||
showToast(data.success ? '连接测试成功' : '连接测试失败', data.success ? 'success' : 'error');
|
||||
loadConfigs();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
showToast('测试失败: ' + (e.message || e), 'error');
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-header">
|
||||
<h2>OSS存储配置管理</h2>
|
||||
<button className="btn btn-primary" onClick={openCreateModal}>+ 添加存储配置</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="stats-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.total}</div>
|
||||
<div className="stat-label">总配置数</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: '#10B981' }}>{stats.enabled_count}</div>
|
||||
<div className="stat-label">已启用</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: '#6C63FF' }}>{stats.default_count}</div>
|
||||
<div className="stat-label">默认存储</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label" style={{ marginTop: '8px' }}>
|
||||
{Object.entries(stats.by_status || {}).map(([s, c]) => (
|
||||
<span key={s} style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 10px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
margin: '0 4px',
|
||||
color: (STATUS_CONFIG[s] || {}).color || '#666',
|
||||
background: (STATUS_CONFIG[s] || {}).bg || '#f0f0f0',
|
||||
}}>
|
||||
{(STATUS_CONFIG[s] || {}).label || s}: {c}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="stat-label">连接状态</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className="filter-bar" style={{ display: 'flex', gap: '12px', marginBottom: '16px', alignItems: 'center' }}>
|
||||
<select
|
||||
value={filterProvider}
|
||||
onChange={(e) => { setFilterProvider(e.target.value); setPage(1); }}
|
||||
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px' }}
|
||||
>
|
||||
<option value="">全部类型</option>
|
||||
{Object.entries(PROVIDER_CONFIG).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索名称/端点/Bucket..."
|
||||
value={keyword}
|
||||
onChange={(e) => { setKeyword(e.target.value); setPage(1); }}
|
||||
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', flex: 1, maxWidth: '400px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="table-container" style={{ background: '#fff', borderRadius: '12px', border: '1px solid #E5E7EB', overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#F9FAFB', borderBottom: '1px solid #E5E7EB' }}>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>配置名称</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>提供商</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>端点地址</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>Bucket</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'center', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>状态</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'center', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>默认</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'right', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '240px' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: '#9CA3AF' }}>加载中...</td></tr>
|
||||
) : configs.length === 0 ? (
|
||||
<tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: '#9CA3AF' }}>暂无存储配置,点击"添加存储配置"开始</td></tr>
|
||||
) : (
|
||||
configs.map(cfg => (
|
||||
<tr key={cfg.id} style={{ borderBottom: '1px solid #F3F4F6' }}>
|
||||
<td style={{ padding: '12px 16px', fontWeight: 500 }}>{cfg.name}</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<span style={{
|
||||
padding: '2px 10px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
color: (PROVIDER_CONFIG[cfg.provider] || {}).color || '#666',
|
||||
background: (PROVIDER_CONFIG[cfg.provider] || {}).bg || '#f0f0f0',
|
||||
}}>
|
||||
{(PROVIDER_CONFIG[cfg.provider] || {}).label || cfg.provider}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#6B7280', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={cfg.endpoint}>
|
||||
{cfg.endpoint}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px', fontFamily: 'monospace' }}>{cfg.bucket}</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
|
||||
<span style={{
|
||||
padding: '2px 10px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
color: (STATUS_CONFIG[cfg.status] || STATUS_CONFIG.disconnected).color,
|
||||
background: (STATUS_CONFIG[cfg.status] || STATUS_CONFIG.disconnected).bg,
|
||||
}}>
|
||||
{(STATUS_CONFIG[cfg.status] || STATUS_CONFIG.disconnected).label}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
|
||||
{cfg.is_default ? (
|
||||
<span style={{ color: '#6C63FF', fontSize: '13px', fontWeight: 600 }}>默认</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleSetDefault(cfg)}
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #D1D5DB',
|
||||
background: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
color: '#6B7280',
|
||||
}}
|
||||
>
|
||||
设为默认
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
|
||||
<div style={{ display: 'flex', gap: '6px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => handleTestConnection(cfg)}
|
||||
disabled={testingId === cfg.id}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #D1D5DB',
|
||||
background: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
color: '#374151',
|
||||
}}
|
||||
>
|
||||
{testingId === cfg.id ? '测试中...' : '测试连接'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggle(cfg)}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #D1D5DB',
|
||||
background: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
color: cfg.is_enabled ? '#EF4444' : '#10B981',
|
||||
}}
|
||||
>
|
||||
{cfg.is_enabled ? '禁用' : '启用'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditModal(cfg)}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #D1D5DB',
|
||||
background: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
color: '#374151',
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(cfg)}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #FEE2E2',
|
||||
background: '#FEF2F2',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
color: '#EF4444',
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{total > PAGE_SIZE && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '20px' }}>
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #D1D5DB',
|
||||
background: '#fff',
|
||||
cursor: page <= 1 ? 'not-allowed' : 'pointer',
|
||||
opacity: page <= 1 ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span style={{ padding: '6px 12px', color: '#6B7280', fontSize: '14px' }}>
|
||||
第 {page} / {Math.ceil(total / PAGE_SIZE)} 页 (共 {total} 条)
|
||||
</span>
|
||||
<button
|
||||
disabled={page >= Math.ceil(total / PAGE_SIZE)}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #D1D5DB',
|
||||
background: '#fff',
|
||||
cursor: page >= Math.ceil(total / PAGE_SIZE) ? 'not-allowed' : 'pointer',
|
||||
opacity: page >= Math.ceil(total / PAGE_SIZE) ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{modalVisible && (
|
||||
<div className="modal-overlay" style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', zIndex: 1000,
|
||||
}}>
|
||||
<div className="modal-content" style={{
|
||||
background: '#fff', borderRadius: '12px', padding: '24px',
|
||||
width: '560px', maxHeight: '90vh', overflow: 'auto',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 20px', fontSize: '18px' }}>
|
||||
{editingConfig ? '编辑存储配置' : '添加存储配置'}
|
||||
</h3>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>配置名称 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="如: 生产环境MinIO"
|
||||
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Provider */}
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>存储提供商</label>
|
||||
<select
|
||||
value={formData.provider}
|
||||
onChange={(e) => setFormData({ ...formData, provider: e.target.value })}
|
||||
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }}
|
||||
>
|
||||
<option value="minio">MinIO</option>
|
||||
<option value="aliyun_oss">阿里云OSS</option>
|
||||
<option value="aws_s3">AWS S3</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Endpoint */}
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>端点地址 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.endpoint}
|
||||
onChange={(e) => setFormData({ ...formData, endpoint: e.target.value })}
|
||||
placeholder={formData.provider === 'minio' ? 'http://localhost:9000' : formData.provider === 'aliyun_oss' ? 'https://oss-cn-hangzhou.aliyuncs.com' : 'https://s3.amazonaws.com'}
|
||||
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Access Key & Secret Key */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>
|
||||
Access Key {!editingConfig ? '*' : ''}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.access_key}
|
||||
onChange={(e) => setFormData({ ...formData, access_key: e.target.value })}
|
||||
placeholder={editingConfig ? '留空则不修改' : '输入Access Key'}
|
||||
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>
|
||||
Secret Key {!editingConfig ? '*' : ''}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.secret_key}
|
||||
onChange={(e) => setFormData({ ...formData, secret_key: e.target.value })}
|
||||
placeholder={editingConfig ? '留空则不修改' : '输入Secret Key'}
|
||||
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bucket & Region */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>Bucket *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.bucket}
|
||||
onChange={(e) => setFormData({ ...formData, bucket: e.target.value })}
|
||||
placeholder="my-bucket"
|
||||
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>区域 (Region)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.region}
|
||||
onChange={(e) => setFormData({ ...formData, region: e.target.value })}
|
||||
placeholder="如: us-east-1"
|
||||
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Base Path */}
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>基础路径 (Base Path)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.base_path}
|
||||
onChange={(e) => setFormData({ ...formData, base_path: e.target.value })}
|
||||
placeholder="如: aipaaS/uploads/"
|
||||
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Checkboxes */}
|
||||
<div style={{ display: 'flex', gap: '20px' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '14px', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_default}
|
||||
onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })}
|
||||
/>
|
||||
设为默认存储
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '14px', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_enabled}
|
||||
onChange={(e) => setFormData({ ...formData, is_enabled: e.target.checked })}
|
||||
/>
|
||||
启用
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Advanced Config */}
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>
|
||||
扩展配置 (JSON, 可选)
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.config_str}
|
||||
onChange={(e) => setFormData({ ...formData, config_str: e.target.value })}
|
||||
placeholder='{"use_ssl": true, "timeout": 30}'
|
||||
rows={3}
|
||||
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '13px', fontFamily: 'monospace', boxSizing: 'border-box', resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '20px' }}>
|
||||
<button
|
||||
onClick={() => setModalVisible(false)}
|
||||
style={{
|
||||
padding: '8px 20px', borderRadius: '6px', border: '1px solid #D1D5DB',
|
||||
background: '#fff', cursor: 'pointer', fontSize: '14px', color: '#374151',
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
style={{
|
||||
padding: '8px 20px', borderRadius: '6px', border: 'none',
|
||||
background: '#6C63FF', cursor: submitting ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px', color: '#fff', opacity: submitting ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{submitting ? '保存中...' : (editingConfig ? '保存修改' : '创建配置')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div style={{
|
||||
position: 'fixed', top: '20px', right: '20px', zIndex: 2000,
|
||||
padding: '12px 24px', borderRadius: '8px', color: '#fff',
|
||||
background: toast.type === 'success' ? '#10B981' : '#EF4444',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', fontSize: '14px',
|
||||
}}>
|
||||
{toast.msg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Storage;
|
||||
Reference in new issue
Block a user