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:
qyyfw committed 2026-07-03 14:45:10 +08:00
1 parent 5d1ad26874
commit b2ba75a666
7 files changed
+2265 -468

No files matched your search

+251 -126
View File
@@ -1,128 +1,253 @@
import React, { useState, useEffect } from 'react';␍
import { Routes, Route, Navigate } from 'react-router-dom';␍
import type { User } from '@/types';␍
import { AuthContext, AuthContextType } from '@/contexts/AuthContext';␍
import { authAPI } from '@/api';␍
␍
// Layouts␍
import FrontLayout from '@/components/layout/FrontLayout';␍
import AdminLayout from '@/components/layout/AdminLayout';␍
␍
// Front Pages␍
import LandingPage from '@/pages/front/LandingPage';␍
import LoginPage from '@/pages/front/LoginPage';␍
import ChatPage from '@/pages/front/ChatPage';␍
import ChannelPage from '@/pages/front/ChannelPage';␍
import KnowledgePage from '@/pages/front/KnowledgePage';␍
import MarketPage from '@/pages/front/MarketPage';␍
import TokenDashboardPage from '@/pages/front/TokenDashboardPage';␍
import PricingPage from '@/pages/front/PricingPage';␍
import ProfilePage from '@/pages/front/ProfilePage';␍
import NotificationPage from '@/pages/front/NotificationPage';␍
import AssetsPage from '@/pages/front/AssetsPage';␍
import NovelPreviewPage from '@/pages/front/NovelPreviewPage';␍
import EvolutionDashboard from '@/pages/front/EvolutionDashboard';␍
␍
// Admin Pages␍
import AdminDashboard from '@/pages/admin/Dashboard';␍
import AdminUsers from '@/pages/admin/Users';␍
import AdminModels from '@/pages/admin/Models';␍
import AdminSiteSettings from '@/pages/admin/SiteSettings';␍
import AdminPayment from '@/pages/admin/Payment';␍
import AdminServices from '@/pages/admin/Services';␍
import AdminMembership from '@/pages/admin/Membership';␍
import AdminAds from '@/pages/admin/Ads';␍
import AdminMarket from '@/pages/admin/Market';␍
import AdminLogs from '@/pages/admin/Logs';␍
import AdminSkills from '@/pages/admin/Skills'
import React, { useState, useEffect } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import type { User } from '@/types';
import { AuthContext, AuthContextType } from '@/contexts/AuthContext';
import { authAPI } from '@/api';
// Layouts
import FrontLayout from '@/components/layout/FrontLayout';
import AdminLayout from '@/components/layout/AdminLayout';
// Front Pages
import LandingPage from '@/pages/front/LandingPage';
import LoginPage from '@/pages/front/LoginPage';
import ChatPage from '@/pages/front/ChatPage';
import ChannelPage from '@/pages/front/ChannelPage';
import KnowledgePage from '@/pages/front/KnowledgePage';
import MarketPage from '@/pages/front/MarketPage';
import TokenDashboardPage from '@/pages/front/TokenDashboardPage';
import PricingPage from '@/pages/front/PricingPage';
import ProfilePage from '@/pages/front/ProfilePage';
import NotificationPage from '@/pages/front/NotificationPage';
import AssetsPage from '@/pages/front/AssetsPage';
import NovelPreviewPage from '@/pages/front/NovelPreviewPage';
import EvolutionDashboard from '@/pages/front/EvolutionDashboard';
// Admin Pages
import AdminDashboard from '@/pages/admin/Dashboard';
import AdminUsers from '@/pages/admin/Users';
import AdminModels from '@/pages/admin/Models';
import AdminSiteSettings from '@/pages/admin/SiteSettings';
import AdminPayment from '@/pages/admin/Payment';
import AdminServices from '@/pages/admin/Services';
import AdminMembership from '@/pages/admin/Membership';
import AdminAds from '@/pages/admin/Ads';
import AdminMarket from '@/pages/admin/Market';
import AdminLogs from '@/pages/admin/Logs';
import AdminSkills from '@/pages/admin/Skills'
import AdminAgents from '@/pages/admin/Agents'
import AdminKnowledge from '@/pages/admin/Knowledge';;␍
␍
const App: React.FC = () => {␍
const [user, setUser] = useState<User | null>(null);␍
const [loading, setLoading] = useState(true);␍
␍
// 初始化:从localStorage恢复登录状态␍
useEffect(() => {␍
const token = localStorage.getItem('aiflow_token');␍
const cachedUser = localStorage.getItem('aiflow_user');␍
if (token && cachedUser) {␍
try {␍
setUser(JSON.parse(cachedUser));␍
} catch {␍
localStorage.removeItem('aiflow_token');␍
localStorage.removeItem('aiflow_user');␍
}␍
}␍
setLoading(false);␍
}, []);␍
␍
const login = (u: User) => {␍
setUser(u);␍
localStorage.setItem('aiflow_user', JSON.stringify(u));␍
};␍
const logout = () => {␍
setUser(null);␍
localStorage.removeItem('aiflow_token');␍
localStorage.removeItem('aiflow_user');␍
};␍
const isAdmin = user?.role === 'admin';␍
␍
if (loading) {␍
return <div className="loading-screen">加载中...</div>;␍
}␍
␍
return (␍
<AuthContext.Provider value={{ user, login, logout, isAdmin }}>␍
<Routes>␍
{/* Login */}␍
<Route path="/login" element={␍
user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LoginPage />␍
} />␍
␍
{/* Root - Landing for guests, redirect for authenticated users */}␍
<Route path="/" element={␍
user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LandingPage />␍
} />␍
␍
{/* Front Routes (authenticated only) */}␍
<Route path="/" element={<FrontLayout />}>␍
<Route path="chat" element={<ChatPage />} />␍
<Route path="chat/:id" element={<ChatPage />} />␍
<Route path="channels" element={<ChannelPage />} />␍
<Route path="knowledge" element={<KnowledgePage />} />␍
<Route path="market" element={<MarketPage />} />␍
<Route path="token" element={<TokenDashboardPage />} />␍
<Route path="pricing" element={<PricingPage />} />␍
<Route path="profile" element={<ProfilePage />} />␍
<Route path="notifications" element={<NotificationPage />} />␍
<Route path="assets" element={<AssetsPage />} />␍
<Route path="assets/novel/:id" element={<NovelPreviewPage />} />␍
<Route path="evolution" element={<EvolutionDashboard />} />␍
</Route>␍
␍
{/* Admin Routes */}␍
<Route path="/admin" element={<AdminLayout />}>␍
<Route index element={<AdminDashboard />} />␍
<Route path="users" element={<AdminUsers />} />␍
<Route path="models" element={<AdminModels />} />␍
<Route path="settings" element={<AdminSiteSettings />} />␍
<Route path="payment" element={<AdminPayment />} />␍
<Route path="services" element={<AdminServices />} />␍
<Route path="membership" element={<AdminMembership />} />␍
<Route path="ads" element={<AdminAds />} />␍
<Route path="market" element={<AdminMarket />} />␍
<Route path="logs" element={<AdminLogs />} />␍
␍
<Route path="skills" element={<AdminSkills />} />
<Route path="agents" element={<AdminAgents />} />
import AdminKnowledge from '@/pages/admin/Knowledge';
import AdminStorage from '@/pages/admin/Storage';;
const App: React.FC = () => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
// 初始化:从localStorage恢复登录状态
useEffect(() => {
const token = localStorage.getItem('aiflow_token');
const cachedUser = localStorage.getItem('aiflow_user');
if (token && cachedUser) {
try {
setUser(JSON.parse(cachedUser));
} catch {
localStorage.removeItem('aiflow_token');
localStorage.removeItem('aiflow_user');
}
}
setLoading(false);
}, []);
const login = (u: User) => {
setUser(u);
localStorage.setItem('aiflow_user', JSON.stringify(u));
};
const logout = () => {
setUser(null);
localStorage.removeItem('aiflow_token');
localStorage.removeItem('aiflow_user');
};
const isAdmin = user?.role === 'admin';
if (loading) {
return <div className="loading-screen">加载中...</div>;
}
return (
<AuthContext.Provider value={{ user, login, logout, isAdmin }}>
<Routes>
{/* Login */}
<Route path="/login" element={
user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LoginPage />
} />
{/* Root - Landing for guests, redirect for authenticated users */}
<Route path="/" element={
user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LandingPage />
} />
{/* Front Routes (authenticated only) */}
<Route path="/" element={<FrontLayout />}>
<Route path="chat" element={<ChatPage />} />
<Route path="chat/:id" element={<ChatPage />} />
<Route path="channels" element={<ChannelPage />} />
<Route path="knowledge" element={<KnowledgePage />} />
<Route path="market" element={<MarketPage />} />
<Route path="token" element={<TokenDashboardPage />} />
<Route path="pricing" element={<PricingPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="notifications" element={<NotificationPage />} />
<Route path="assets" element={<AssetsPage />} />
<Route path="assets/novel/:id" element={<NovelPreviewPage />} />
<Route path="evolution" element={<EvolutionDashboard />} />
</Route>
{/* Admin Routes */}
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<AdminDashboard />} />
<Route path="users" element={<AdminUsers />} />
<Route path="models" element={<AdminModels />} />
<Route path="settings" element={<AdminSiteSettings />} />
<Route path="payment" element={<AdminPayment />} />
<Route path="services" element={<AdminServices />} />
<Route path="membership" element={<AdminMembership />} />
<Route path="ads" element={<AdminAds />} />
<Route path="market" element={<AdminMarket />} />
<Route path="logs" element={<AdminLogs />} />
<Route path="skills" element={<AdminSkills />} />
<Route path="agents" element={<AdminAgents />} />
<Route path="knowledge" element={<AdminKnowledge />} />
</Route>␍
␍
<Route path="*" element={<Navigate to="/" />} />␍
</Routes>␍
</AuthContext.Provider>␍
);␍
};␍
␍
export default App;␍
<Route path="storage" element={<AdminStorage />} />
</Route>
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</AuthContext.Provider>
);
};
export default App;
+14
View File
@@ -498,6 +498,20 @@ export const adminAPI = {
checkAdminKnowledgeExtStatus: (id: string) => get<any>(`/admin/knowledge/external/services/${id}/status`),
syncAdminKnowledge: (kbId: string, data: any) => post<any>(`/admin/knowledge/external/sync/${kbId}`, data),
batchDeleteAdminKnowledge: (ids: string[]) => post<any>('/admin/knowledge/batch/delete', ids),
// OSS存储配置 (Phase 3.1)
getAdminStorageStats: () => get<any>('/admin/storage/stats/overview'),
getAdminStorageConfigs: (params?: any) => {
const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : '';
return get<any>(`/admin/storage/${query}`);
},
getAdminStorageConfig: (id: string) => get<any>(`/admin/storage/${id}`),
createAdminStorageConfig: (data: any) => post<any>('/admin/storage/', data),
updateAdminStorageConfig: (id: string, data: any) => put<any>(`/admin/storage/${id}`, data),
deleteAdminStorageConfig: (id: string) => del<any>(`/admin/storage/${id}`),
toggleAdminStorageConfig: (id: string) => put<any>(`/admin/storage/${id}/toggle`),
setDefaultAdminStorageConfig: (id: string) => put<any>(`/admin/storage/${id}/set-default`),
testAdminStorageConfig: (data: any) => post<any>('/admin/storage/test', data),
};
// ===== AI自进化API =====
export const evolutionAPI = {
+256 -127
View File
@@ -1,128 +1,257 @@
import React, { useState } from 'react';␍
import { Outlet, NavLink, useNavigate } from 'react-router-dom';␍
import { useAuth } from '@/contexts/AuthContext';␍
import './AdminLayout.css';␍
␍
const adminNavItems = [␍
{ path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true },␍
{ path: '/admin/users', icon: 'users', label: '用户管理' },␍
{ path: '/admin/models', icon: 'models', label: '大模型库管理' },␍
{ path: '/admin/settings', icon: 'settings', label: '网站基础设置' },␍
{ path: '/admin/payment', icon: 'payment', label: '支付渠道配置' },␍
{ path: '/admin/services', icon: 'services', label: '增值服务管理' },␍
{ path: '/admin/membership', icon: 'membership', label: '会员订阅体系' },␍
{ path: '/admin/ads', icon: 'ads', label: '广告位管理' },␍
{ path: '/admin/market', icon: 'market', label: '应用市场审核' },␍
{ path: '/admin/logs', icon: 'logs', label: '日志与风控' },␍
␍
{ path: '/admin/agents', icon: 'agents', label: 'Agent管理' },
import React, { useState } from 'react';
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import './AdminLayout.css';
const adminNavItems = [
{ path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true },
{ path: '/admin/users', icon: 'users', label: '用户管理' },
{ path: '/admin/models', icon: 'models', label: '大模型库管理' },
{ path: '/admin/settings', icon: 'settings', label: '网站基础设置' },
{ path: '/admin/payment', icon: 'payment', label: '支付渠道配置' },
{ path: '/admin/services', icon: 'services', label: '增值服务管理' },
{ path: '/admin/membership', icon: 'membership', label: '会员订阅体系' },
{ path: '/admin/ads', icon: 'ads', label: '广告位管理' },
{ path: '/admin/market', icon: 'market', label: '应用市场审核' },
{ path: '/admin/logs', icon: 'logs', label: '日志与风控' },
{ path: '/admin/agents', icon: 'agents', label: 'Agent管理' },
{ path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' },
{ path: '/admin/skills', icon: 'skills', label: '技能管理' },];␍
␍
const adminIconPaths: Record<string, JSX.Element> = {␍
dashboard: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>,␍
users: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,␍
models: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>,␍
settings: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>,␍
payment: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>,␍
services: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>,␍
membership: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>,␍
ads: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>,␍
market: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></svg>,␍
logs: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>,␍
␍
skills: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,};␍
␍
const AdminLayout: React.FC = () => {␍
const { user, logout } = useAuth();␍
const navigate = useNavigate();␍
const [collapsed, setCollapsed] = useState(false);␍
␍
if (!user || user.role !== 'admin') {␍
navigate('/login');␍
return null;␍
}␍
␍
return (␍
<div className="admin-layout">␍
{/* Admin Sidebar */}␍
<aside className={`admin-sidebar ${collapsed ? 'collapsed' : ''}`}>␍
<div className="admin-sidebar-header">␍
<div className="sidebar-logo">␍
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">␍
<rect width="32" height="32" rx="8" fill="#1A1A2E"/>␍
<path d="M8 20V12l4 4 4-4v8" stroke="#6C63FF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>␍
<path d="M6 22h20" stroke="#6C63FF" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>␍
</svg>␍
{!collapsed && <span className="sidebar-brand admin-brand">Qihu-AIFlow 管理后台</span>}␍
</div>␍
<button className="collapse-btn" onClick={() => setCollapsed(!collapsed)}>␍
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">␍
<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>␍
</svg>␍
</button>␍
</div>␍
␍
<nav className="admin-nav">␍
{adminNavItems.map(item => (␍
<NavLink␍
key={item.path}␍
to={item.path}␍
end={item.exact}␍
className={({ isActive }) => `admin-nav-item ${isActive ? 'active' : ''}`}␍
>␍
<span className="nav-icon">{adminIconPaths[item.icon]}</span>␍
{!collapsed && <span className="nav-label">{item.label}</span>}␍
</NavLink>␍
))}␍
</nav>␍
␍
<div className="admin-sidebar-footer">␍
<NavLink to="/chat" className="admin-nav-item">␍
<span className="nav-icon">␍
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>␍
</span>␍
{!collapsed && <span className="nav-label">返回前台</span>}␍
</NavLink>␍
<button className="admin-nav-item logout-btn" onClick={() => { logout(); navigate('/login'); }}>␍
<span className="nav-icon">␍
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>␍
</span>␍
{!collapsed && <span className="nav-label">退出登录</span>}␍
</button>␍
</div>␍
{!collapsed && (␍
<div className="admin-version-info">␍
Qihu-AIFlow v2.0.0␍
</div>␍
)}␍
</aside>␍
␍
{/* Admin Main Content */}␍
<main className="admin-main">␍
<header className="admin-topbar">␍
<div className="admin-topbar-left">␍
<span className="admin-welcome">欢迎,{user.username}</span>␍
</div>␍
<div className="admin-topbar-right">␍
<a href="#/chat" className="admin-topbar-action">␍
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>␍
<span>返回前台</span>␍
</a>␍
<div className="admin-user-tag">␍
<div className="user-avatar-sm admin-avatar">␍
{user.username.charAt(0).toUpperCase()}␍
</div>␍
<span>{user.username}</span>␍
</div>␍
</div>␍
</header>␍
<div className="admin-content">␍
<Outlet />␍
</div>␍
</main>␍
</div>␍
);␍
};␍
␍
export default AdminLayout;␍
{ path: '/admin/storage', icon: 'storage', label: '存储配置' },
{ path: '/admin/skills', icon: 'skills', label: '技能管理' },];
const adminIconPaths: Record<string, JSX.Element> = {
dashboard: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>,
users: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,
models: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>,
settings: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>,
payment: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>,
services: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>,
membership: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>,
ads: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>,
market: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></svg>,
logs: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>,
skills: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
storage: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>,
};
const AdminLayout: React.FC = () => {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [collapsed, setCollapsed] = useState(false);
if (!user || user.role !== 'admin') {
navigate('/login');
return null;
}
return (
<div className="admin-layout">
{/* Admin Sidebar */}
<aside className={`admin-sidebar ${collapsed ? 'collapsed' : ''}`}>
<div className="admin-sidebar-header">
<div className="sidebar-logo">
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#1A1A2E"/>
<path d="M8 20V12l4 4 4-4v8" stroke="#6C63FF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
<path d="M6 22h20" stroke="#6C63FF" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
</svg>
{!collapsed && <span className="sidebar-brand admin-brand">Qihu-AIFlow 管理后台</span>}
</div>
<button className="collapse-btn" onClick={() => setCollapsed(!collapsed)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
</div>
<nav className="admin-nav">
{adminNavItems.map(item => (
<NavLink
key={item.path}
to={item.path}
end={item.exact}
className={({ isActive }) => `admin-nav-item ${isActive ? 'active' : ''}`}
>
<span className="nav-icon">{adminIconPaths[item.icon]}</span>
{!collapsed && <span className="nav-label">{item.label}</span>}
</NavLink>
))}
</nav>
<div className="admin-sidebar-footer">
<NavLink to="/chat" className="admin-nav-item">
<span className="nav-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
</span>
{!collapsed && <span className="nav-label">返回前台</span>}
</NavLink>
<button className="admin-nav-item logout-btn" onClick={() => { logout(); navigate('/login'); }}>
<span className="nav-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</span>
{!collapsed && <span className="nav-label">退出登录</span>}
</button>
</div>
{!collapsed && (
<div className="admin-version-info">
Qihu-AIFlow v2.0.0
</div>
)}
</aside>
{/* Admin Main Content */}
<main className="admin-main">
<header className="admin-topbar">
<div className="admin-topbar-left">
<span className="admin-welcome">欢迎,{user.username}</span>
</div>
<div className="admin-topbar-right">
<a href="#/chat" className="admin-topbar-action">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span>返回前台</span>
</a>
<div className="admin-user-tag">
<div className="user-avatar-sm admin-avatar">
{user.username.charAt(0).toUpperCase()}
</div>
<span>{user.username}</span>
</div>
</div>
</header>
<div className="admin-content">
<Outlet />
</div>
</main>
</div>
);
};
export default AdminLayout;
+713
View File
@@ -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;