Phase G4: Distribution Builder - SaaS product line mgmt, license keys, config export, frontend UI
This commit is contained in:
1 parent
d2e3147fb8
commit
0486210133
8 files changed
+1671
-258
No files matched your search
+128
-128
@@ -1,128 +1,128 @@
|
||||
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 AdminCodeExec from '@/pages/admin/CodeExec';;
|
||||
|
||||
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="code-exec" element={<AdminCodeExec />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
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 AdminCodeExec from '@/pages/admin/CodeExec';;
|
||||
|
||||
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="code-exec" element={<AdminCodeExec />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -663,6 +663,27 @@ export const adminAPI = {
|
||||
getCodeExecTask: (id: string) => get<any>(`/admin/code-exec/tasks/${id}`),
|
||||
deleteCodeExecTask: (id: string) => del<any>(`/admin/code-exec/tasks/${id}`),
|
||||
|
||||
|
||||
// Distribution Builder (Phase G4)
|
||||
getDistributionStats: () => get<any>('/admin/distributions/stats'),
|
||||
getDistributions: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]: [string, any]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any>('/admin/distributions' + (sp.toString() ? '?' + sp.toString() : ''));
|
||||
},
|
||||
getDistributionTemplates: () => get<any>('/admin/distributions/templates'),
|
||||
createDistribution: (data: any) => post<any>('/admin/distributions', data),
|
||||
getDistribution: (id: string) => get<any>('/admin/distributions/' + id),
|
||||
updateDistribution: (id: string, data: any) => put<any>('/admin/distributions/' + id, data),
|
||||
deleteDistribution: (id: string) => del<any>('/admin/distributions/' + id),
|
||||
getDistributionLicenses: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]: [string, any]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any>('/admin/distributions/licenses' + (sp.toString() ? '?' + sp.toString() : ''));
|
||||
},
|
||||
createDistributionLicense: (distroId: string, data: any) => post<any>('/admin/distributions/' + distroId + '/licenses', data),
|
||||
getDistributionLicense: (id: string) => get<any>('/admin/distributions/licenses/' + id),
|
||||
revokeDistributionLicense: (id: string) => post<any>('/admin/distributions/licenses/' + id + '/revoke'),
|
||||
};
|
||||
// ===== AI自进化API =====
|
||||
export const evolutionAPI = {
|
||||
|
||||
@@ -1,129 +1,132 @@
|
||||
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/skills', icon: 'skills', label: '技能管理' },
|
||||
{ path: '/admin/code-exec', icon: 'code', 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>,
|
||||
code: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></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;
|
||||
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/skills', icon: 'skills', label: '技能管理' },
|
||||
{ path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' },
|
||||
{ path: '/admin/distributions', icon: 'distro', 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>,
|
||||
code: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>,
|
||||
distro: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></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;
|
||||
@@ -0,0 +1,493 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
interface Distribution {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
brand_name: string;
|
||||
brand_logo_url: string;
|
||||
brand_color: string;
|
||||
system_prompt: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
mcp_extensions: any[];
|
||||
workflow_ids: string[];
|
||||
knowledge_base_ids: string[];
|
||||
agent_ids: string[];
|
||||
features: string[];
|
||||
status: string;
|
||||
version: string;
|
||||
download_count: number;
|
||||
size_bytes: number;
|
||||
license_count?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface License {
|
||||
id: string;
|
||||
distribution_id: string;
|
||||
distribution_name: string;
|
||||
org_name: string;
|
||||
licensee_email: string;
|
||||
license_key: string;
|
||||
features: string[];
|
||||
max_users: number;
|
||||
max_executions: number;
|
||||
usage_count: number;
|
||||
expiry_date: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
total: number;
|
||||
draft: number;
|
||||
active: number;
|
||||
archived: number;
|
||||
total_downloads: number;
|
||||
total_licenses: number;
|
||||
active_licenses: number;
|
||||
}
|
||||
|
||||
interface Templates {
|
||||
workflows: any[];
|
||||
agents: any[];
|
||||
knowledge_bases: any[];
|
||||
mcp_tools: any[];
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = { draft: '草稿', active: '已发布', archived: '已归档' };
|
||||
const STATUS_COLORS: Record<string, string> = { draft: '#8b949e', active: '#3fb950', archived: '#f85149' };
|
||||
const FEATURE_LABELS: Record<string, string> = {
|
||||
chat: 'AI 对话', code_exec: '代码执行', knowledge_search: '知识库检索',
|
||||
agent_collab: 'Agent 协作', automation: '自动化', webhook: 'Webhook',
|
||||
};
|
||||
|
||||
export default function DistributionBuilder() {
|
||||
const [distributions, setDistributions] = useState<Distribution[]>([]);
|
||||
const [stats, setStats] = useState<Stats>({ total: 0, draft: 0, active: 0, archived: 0, total_downloads: 0, total_licenses: 0, active_licenses: 0 });
|
||||
const [templates, setTemplates] = useState<Templates>({ workflows: [], agents: [], knowledge_bases: [], mcp_tools: [] });
|
||||
const [licenses, setLicenses] = useState<License[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState<'distributions' | 'licenses'>('distributions');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showLicense, setShowLicense] = useState(false);
|
||||
const [selectedDistro, setSelectedDistro] = useState<string>('');
|
||||
const [editDistro, setEditDistro] = useState<Distribution | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [licPage, setLicPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [licTotalPages, setLicTotalPages] = useState(1);
|
||||
|
||||
// Form state
|
||||
const [form, setForm] = useState({
|
||||
name: '', description: '', brand_name: '', brand_color: '#6366f1',
|
||||
system_prompt: '', provider: 'siliconflow', model: 'deepseek-ai/DeepSeek-V4-Flash',
|
||||
features: ['chat'] as string[], version: '1.0.0',
|
||||
workflow_ids: [] as string[], agent_ids: [] as string[], knowledge_base_ids: [] as string[],
|
||||
});
|
||||
|
||||
// License form
|
||||
const [licForm, setLicForm] = useState({
|
||||
org_name: '', licensee_email: '', max_users: 10, max_executions: 10000,
|
||||
expiry_date: '', features: ['chat'] as string[],
|
||||
});
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [s, d, t] = await Promise.all([
|
||||
adminAPI.getDistributionStats(),
|
||||
adminAPI.getDistributions({ page, page_size: 20 }),
|
||||
adminAPI.getDistributionTemplates(),
|
||||
]);
|
||||
if (s.code === 0) setStats(s.data);
|
||||
if (d.code === 0) { setDistributions(d.data.items); setTotalPages(Math.ceil(d.data.total / 20)); }
|
||||
if (t.code === 0) setTemplates(t.data);
|
||||
} catch (e) { console.error(e); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const loadLicenses = async () => {
|
||||
try {
|
||||
const res = await adminAPI.getDistributionLicenses({ page: licPage, page_size: 20 });
|
||||
if (res.code === 0) { setLicenses(res.data.items); setLicTotalPages(Math.ceil(res.data.total / 20)); }
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
useEffect(() => { loadData(); }, [page]);
|
||||
useEffect(() => { if (tab === 'licenses') loadLicenses(); }, [tab, licPage]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const res = await adminAPI.createDistribution(form);
|
||||
if (res.code === 0) { setShowCreate(false); resetForm(); loadData(); }
|
||||
else alert('创建失败: ' + (res.message || ''));
|
||||
} catch (e) { alert('创建失败'); }
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!editDistro) return;
|
||||
try {
|
||||
const res = await adminAPI.updateDistribution(editDistro.id, form);
|
||||
if (res.code === 0) { setEditDistro(null); resetForm(); loadData(); }
|
||||
else alert('更新失败');
|
||||
} catch (e) { alert('更新失败'); }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定删除此发行版?关联的许可证也将被删除。')) return;
|
||||
try {
|
||||
await adminAPI.deleteDistribution(id);
|
||||
loadData();
|
||||
} catch (e) { alert('删除失败'); }
|
||||
};
|
||||
|
||||
const handleBuild = async (id: string) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token') || '';
|
||||
window.open(`/app/api/admin/distributions/${id}/build`, '_blank');
|
||||
} catch (e) { alert('下载失败'); }
|
||||
};
|
||||
|
||||
const handleGenerateLicense = async () => {
|
||||
if (!selectedDistro) return;
|
||||
try {
|
||||
const res = await adminAPI.createDistributionLicense(selectedDistro, licForm);
|
||||
if (res.code === 0) { setShowLicense(false); setLicForm({ org_name: '', licensee_email: '', max_users: 10, max_executions: 10000, expiry_date: '', features: ['chat'] }); loadLicenses(); }
|
||||
else alert('生成失败: ' + (res.message || ''));
|
||||
} catch (e) { alert('生成失败'); }
|
||||
};
|
||||
|
||||
const handleRevokeLicense = async (id: string) => {
|
||||
if (!confirm('确定吊销此许可证?')) return;
|
||||
try {
|
||||
await adminAPI.revokeDistributionLicense(id);
|
||||
loadLicenses();
|
||||
} catch (e) { alert('吊销失败'); }
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setForm({ name: '', description: '', brand_name: '', brand_color: '#6366f1', system_prompt: '', provider: 'siliconflow', model: 'deepseek-ai/DeepSeek-V4-Flash', features: ['chat'], version: '1.0.0', workflow_ids: [], agent_ids: [], knowledge_base_ids: [] });
|
||||
};
|
||||
|
||||
const openEdit = (d: Distribution) => {
|
||||
setEditDistro(d);
|
||||
setForm({
|
||||
name: d.name, description: d.description || '', brand_name: d.brand_name || '',
|
||||
brand_color: d.brand_color || '#6366f1', system_prompt: d.system_prompt || '',
|
||||
provider: d.provider || 'siliconflow', model: d.model || 'deepseek-ai/DeepSeek-V4-Flash',
|
||||
features: d.features || ['chat'], version: d.version || '1.0.0',
|
||||
workflow_ids: d.workflow_ids || [], agent_ids: d.agent_ids || [], knowledge_base_ids: d.knowledge_base_ids || [],
|
||||
});
|
||||
};
|
||||
|
||||
const toggleFeature = (feat: string) => {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
features: prev.features.includes(feat) ? prev.features.filter(f => f !== feat) : [...prev.features, feat],
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleLicFeature = (feat: string) => {
|
||||
setLicForm(prev => ({
|
||||
...prev,
|
||||
features: prev.features.includes(feat) ? prev.features.filter(f => f !== feat) : [...prev.features, feat],
|
||||
}));
|
||||
};
|
||||
|
||||
const isEditing = !!editDistro;
|
||||
|
||||
// Styles
|
||||
const dark = { bg: '#0d1117', card: '#161b22', border: '#21262d', text: '#c9d1d9', sub: '#8b949e', accent: '#6366f1', accentLight: '#818cf8', green: '#3fb950', red: '#f85149', yellow: '#d29922' };
|
||||
|
||||
const cardStyle: React.CSSProperties = { background: dark.card, border: `1px solid ${dark.border}`, borderRadius: 12, padding: 20 };
|
||||
const btnPrimary: React.CSSProperties = { background: dark.accent, color: '#fff', border: 'none', borderRadius: 8, padding: '8px 20px', cursor: 'pointer', fontSize: 14, fontWeight: 600 };
|
||||
const btnDanger: React.CSSProperties = { background: dark.red, color: '#fff', border: 'none', borderRadius: 8, padding: '8px 20px', cursor: 'pointer', fontSize: 14, fontWeight: 600 };
|
||||
const btnGhost: React.CSSProperties = { background: 'transparent', color: dark.text, border: `1px solid ${dark.border}`, borderRadius: 8, padding: '8px 20px', cursor: 'pointer', fontSize: 14 };
|
||||
const inputStyle: React.CSSProperties = { background: '#0d1117', color: dark.text, border: `1px solid ${dark.border}`, borderRadius: 6, padding: '8px 12px', fontSize: 14, width: '100%', boxSizing: 'border-box', outline: 'none' };
|
||||
const textareaStyle: React.CSSProperties = { ...inputStyle, minHeight: 80, resize: 'vertical', fontFamily: 'monospace' };
|
||||
const labelStyle: React.CSSProperties = { color: dark.sub, fontSize: 13, marginBottom: 4, display: 'block' };
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, background: dark.bg, minHeight: '100vh', color: dark.text, fontFamily: '-apple-system, BlinkMacSystemFont, sans-serif' }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
|
||||
<div>
|
||||
<h1 style={{ margin: 0, fontSize: 28, fontWeight: 700 }}>发行版构建器</h1>
|
||||
<p style={{ margin: '4px 0 0', color: dark.sub, fontSize: 13 }}>SaaS 产品线管理 · 自定义发行版配置包生成 · 许可证管理</p>
|
||||
</div>
|
||||
<button onClick={() => { setShowCreate(true); setEditDistro(null); resetForm(); }} style={btnPrimary}>
|
||||
+ 新建发行版
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
|
||||
{[
|
||||
{ label: '发行版总数', value: stats.total },
|
||||
{ label: '已发布', value: stats.active, color: dark.green },
|
||||
{ label: '累计下载', value: stats.total_downloads, color: dark.accentLight },
|
||||
{ label: '有效许可证', value: stats.active_licenses, color: dark.yellow },
|
||||
].map((s, i) => (
|
||||
<div key={i} style={cardStyle}>
|
||||
<div style={{ color: dark.sub, fontSize: 13 }}>{s.label}</div>
|
||||
<div style={{ fontSize: 32, fontWeight: 700, color: s.color || dark.text, marginTop: 4 }}>{s.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', gap: 0, marginBottom: 20, borderBottom: `2px solid ${dark.border}` }}>
|
||||
{[
|
||||
{ key: 'distributions', label: '发行版管理' },
|
||||
{ key: 'licenses', label: '许可证管理' },
|
||||
].map(t => (
|
||||
<button key={t.key}
|
||||
onClick={() => setTab(t.key as any)}
|
||||
style={{
|
||||
padding: '10px 24px', background: 'transparent', border: 'none',
|
||||
color: tab === t.key ? dark.accentLight : dark.sub,
|
||||
borderBottom: tab === t.key ? `2px solid ${dark.accent}` : '2px solid transparent',
|
||||
cursor: 'pointer', fontSize: 15, fontWeight: tab === t.key ? 600 : 400,
|
||||
}}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Distributions Tab */}
|
||||
{tab === 'distributions' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{distributions.map(d => (
|
||||
<div key={d.id} style={{ ...cardStyle, display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<div style={{ width: 48, height: 48, borderRadius: 10, background: d.brand_color || dark.accent, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20, fontWeight: 700, color: '#fff', flexShrink: 0 }}>
|
||||
{(d.brand_name || d.name)[0]}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 16, fontWeight: 600 }}>{d.name}</span>
|
||||
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 10, background: STATUS_COLORS[d.status] + '22', color: STATUS_COLORS[d.status], fontWeight: 600 }}>
|
||||
{STATUS_LABELS[d.status] || d.status}
|
||||
</span>
|
||||
<span style={{ color: dark.sub, fontSize: 12 }}>v{d.version}</span>
|
||||
</div>
|
||||
<div style={{ color: dark.sub, fontSize: 13, marginTop: 2 }}>{d.description || '无描述'}</div>
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 6, fontSize: 12, color: dark.sub }}>
|
||||
{d.features?.map(f => (
|
||||
<span key={f}>✓ {FEATURE_LABELS[f] || f}</span>
|
||||
))}
|
||||
<span>下载 {d.download_count || 0} 次</span>
|
||||
<span>{d.license_count || 0} 个许可证</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<button onClick={() => handleBuild(d.id)} style={btnGhost}>下载配置包</button>
|
||||
<button onClick={() => { setSelectedDistro(d.id); setShowLicense(true); }} style={btnGhost}>生成许可证</button>
|
||||
<button onClick={() => openEdit(d)} style={btnGhost}>编辑</button>
|
||||
<button onClick={() => handleDelete(d.id)} style={btnDanger}>删除</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 20 }}>
|
||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1} style={btnGhost}>上一页</button>
|
||||
<span style={{ padding: '8px 16px', color: dark.sub }}>第 {page}/{totalPages} 页</span>
|
||||
<button onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages} style={btnGhost}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Licenses Tab */}
|
||||
{tab === 'licenses' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
|
||||
<button onClick={() => setShowLicense(true)} style={btnPrimary}>+ 生成许可证</button>
|
||||
</div>
|
||||
<div style={{ overflow: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: `1px solid ${dark.border}` }}>
|
||||
{['许可证密钥', '组织', '发行版', '用量', '状态', '过期', '操作'].map(h => (
|
||||
<th key={h} style={{ padding: '10px 12px', textAlign: 'left', color: dark.sub, fontSize: 12, fontWeight: 600, whiteSpace: 'nowrap' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{licenses.map(lic => (
|
||||
<tr key={lic.id} style={{ borderBottom: `1px solid ${dark.border}` }}>
|
||||
<td style={{ padding: '10px 12px', fontFamily: 'monospace', fontSize: 12, color: dark.accentLight }}>{lic.license_key}</td>
|
||||
<td style={{ padding: '10px 12px' }}>{lic.org_name}</td>
|
||||
<td style={{ padding: '10px 12px' }}>{lic.distribution_name}</td>
|
||||
<td style={{ padding: '10px 12px', color: dark.sub }}>{lic.usage_count}/{lic.max_executions}</td>
|
||||
<td style={{ padding: '10px 12px' }}>
|
||||
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 10, background: lic.status === 'active' ? '#3fb95022' : '#f8514922', color: lic.status === 'active' ? dark.green : dark.red }}>{lic.status}</span>
|
||||
</td>
|
||||
<td style={{ padding: '10px 12px', color: dark.sub, fontSize: 12 }}>{lic.expiry_date || '永久'}</td>
|
||||
<td style={{ padding: '10px 12px' }}>
|
||||
{lic.status === 'active' && (
|
||||
<button onClick={() => handleRevokeLicense(lic.id)} style={{ ...btnDanger, padding: '4px 12px', fontSize: 12 }}>吊销</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{licTotalPages > 1 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 20 }}>
|
||||
<button onClick={() => setLicPage(p => Math.max(1, p - 1))} disabled={licPage <= 1} style={btnGhost}>上一页</button>
|
||||
<span style={{ padding: '8px 16px', color: dark.sub }}>第 {licPage}/{licTotalPages} 页</span>
|
||||
<button onClick={() => setLicPage(p => Math.min(licTotalPages, p + 1))} disabled={licPage >= licTotalPages} style={btnGhost}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{(showCreate || isEditing) && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
|
||||
<div style={{ ...cardStyle, width: 640, maxHeight: '85vh', overflow: 'auto', padding: 28 }}>
|
||||
<h2 style={{ margin: '0 0 20px' }}>{isEditing ? '编辑发行版' : '新建发行版'}</h2>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={labelStyle}>名称 *</label>
|
||||
<input style={inputStyle} value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="例如: Enterprise AI Suite" />
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={labelStyle}>描述</label>
|
||||
<textarea style={textareaStyle} value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} placeholder="发行版描述..." />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
|
||||
<div>
|
||||
<label style={labelStyle}>品牌名称</label>
|
||||
<input style={inputStyle} value={form.brand_name} onChange={e => setForm({ ...form, brand_name: e.target.value })} placeholder="例如: MyAI" />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>品牌主色</label>
|
||||
<input type="color" style={{ ...inputStyle, height: 38, padding: 4, width: 60 }} value={form.brand_color} onChange={e => setForm({ ...form, brand_color: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={labelStyle}>系统提示词</label>
|
||||
<textarea style={textareaStyle} value={form.system_prompt} onChange={e => setForm({ ...form, system_prompt: e.target.value })} placeholder="You are a helpful AI assistant..." />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
|
||||
<div>
|
||||
<label style={labelStyle}>LLM 提供商</label>
|
||||
<select style={inputStyle} value={form.provider} onChange={e => setForm({ ...form, provider: e.target.value })}>
|
||||
<option value="siliconflow">SiliconFlow</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>默认模型</label>
|
||||
<input style={inputStyle} value={form.model} onChange={e => setForm({ ...form, model: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={labelStyle}>版本号</label>
|
||||
<input style={inputStyle} value={form.version} onChange={e => setForm({ ...form, version: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={labelStyle}>功能特性</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{Object.entries(FEATURE_LABELS).map(([key, label]) => (
|
||||
<button key={key}
|
||||
onClick={() => toggleFeature(key)}
|
||||
style={{
|
||||
padding: '6px 14px', borderRadius: 20, fontSize: 13, cursor: 'pointer',
|
||||
background: form.features.includes(key) ? dark.accent : 'transparent',
|
||||
color: form.features.includes(key) ? '#fff' : dark.sub,
|
||||
border: form.features.includes(key) ? `1px solid ${dark.accent}` : `1px solid ${dark.border}`,
|
||||
}}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => { setShowCreate(false); setEditDistro(null); }} style={btnGhost}>取消</button>
|
||||
<button onClick={isEditing ? handleUpdate : handleCreate} style={btnPrimary}>{isEditing ? '保存修改' : '创建'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* License Modal */}
|
||||
{showLicense && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
|
||||
<div style={{ ...cardStyle, width: 500, padding: 28 }}>
|
||||
<h2 style={{ margin: '0 0 20px' }}>生成许可证</h2>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={labelStyle}>发行版 *</label>
|
||||
<select style={inputStyle} value={selectedDistro} onChange={e => setSelectedDistro(e.target.value)}>
|
||||
<option value="">-- 选择发行版 --</option>
|
||||
{distributions.map(d => (
|
||||
<option key={d.id} value={d.id}>{d.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={labelStyle}>组织名称 *</label>
|
||||
<input style={inputStyle} value={licForm.org_name} onChange={e => setLicForm({ ...licForm, org_name: e.target.value })} placeholder="例如: Acme Corp" />
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={labelStyle}>被授权邮箱</label>
|
||||
<input style={inputStyle} value={licForm.licensee_email} onChange={e => setLicForm({ ...licForm, licensee_email: e.target.value })} placeholder="admin@example.com" />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
|
||||
<div>
|
||||
<label style={labelStyle}>最大用户数</label>
|
||||
<input type="number" style={inputStyle} value={licForm.max_users} onChange={e => setLicForm({ ...licForm, max_users: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>最大执行次数</label>
|
||||
<input type="number" style={inputStyle} value={licForm.max_executions} onChange={e => setLicForm({ ...licForm, max_executions: Number(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={labelStyle}>过期日期 (留空为永久)</label>
|
||||
<input type="date" style={inputStyle} value={licForm.expiry_date} onChange={e => setLicForm({ ...licForm, expiry_date: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={labelStyle}>授权功能</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{Object.entries(FEATURE_LABELS).map(([key, label]) => (
|
||||
<button key={key}
|
||||
onClick={() => toggleLicFeature(key)}
|
||||
style={{
|
||||
padding: '6px 14px', borderRadius: 20, fontSize: 13, cursor: 'pointer',
|
||||
background: licForm.features.includes(key) ? dark.accent : 'transparent',
|
||||
color: licForm.features.includes(key) ? '#fff' : dark.sub,
|
||||
border: licForm.features.includes(key) ? `1px solid ${dark.accent}` : `1px solid ${dark.border}`,
|
||||
}}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
|
||||
<button onClick={() => setShowLicense(false)} style={btnGhost}>取消</button>
|
||||
<button onClick={handleGenerateLicense} style={btnPrimary}>生成</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user