Phase C2: Admin UI Refactoring
- Restructured admin nav to 5 business domain groups (平台驾驶舱/用户体系/AI能力/业务运营/系统设置) - Added 5 orphan pages to routes: Apps, Automation, Developer, Mcp, Storage - Unified dark theme CSS with shared admin component styles - Fixed Dashboard and Models hardcoded light colors for dark theme
This commit is contained in:
1 parent
6583bd2a62
commit
6d12ee9ff9
5 files changed
+1350
-714
No files matched your search
+77
-198
@@ -1,260 +1,139 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, Suspense, lazy } from 'react';
|
||||||
|
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
|
||||||
import type { User } from '@/types';
|
import type { User } from '@/types';
|
||||||
|
|
||||||
import { AuthContext, AuthContextType } from '@/contexts/AuthContext';
|
import { AuthContext, AuthContextType } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
import { authAPI } from '@/api';
|
// Layouts - eagerly loaded (needed immediately)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Layouts
|
|
||||||
|
|
||||||
import FrontLayout from '@/components/layout/FrontLayout';
|
import FrontLayout from '@/components/layout/FrontLayout';
|
||||||
|
|
||||||
import AdminLayout from '@/components/layout/AdminLayout';
|
import AdminLayout from '@/components/layout/AdminLayout';
|
||||||
|
|
||||||
|
// Front Pages - lazy loaded
|
||||||
|
const LandingPage = lazy(() => import('@/pages/front/LandingPage'));
|
||||||
|
const LoginPage = lazy(() => import('@/pages/front/LoginPage'));
|
||||||
|
const ChatPage = lazy(() => import('@/pages/front/ChatPage'));
|
||||||
|
const ChannelPage = lazy(() => import('@/pages/front/ChannelPage'));
|
||||||
|
const KnowledgePage = lazy(() => import('@/pages/front/KnowledgePage'));
|
||||||
|
const MarketPage = lazy(() => import('@/pages/front/MarketPage'));
|
||||||
|
const TokenDashboardPage = lazy(() => import('@/pages/front/TokenDashboardPage'));
|
||||||
|
const PricingPage = lazy(() => import('@/pages/front/PricingPage'));
|
||||||
|
const ProfilePage = lazy(() => import('@/pages/front/ProfilePage'));
|
||||||
|
const NotificationPage = lazy(() => import('@/pages/front/NotificationPage'));
|
||||||
|
const AssetsPage = lazy(() => import('@/pages/front/AssetsPage'));
|
||||||
|
const NovelPreviewPage = lazy(() => import('@/pages/front/NovelPreviewPage'));
|
||||||
|
const EvolutionDashboard = lazy(() => import('@/pages/front/EvolutionDashboard'));
|
||||||
|
|
||||||
|
// Admin Pages - lazy loaded
|
||||||
|
const AdminDashboard = lazy(() => import('@/pages/admin/Dashboard'));
|
||||||
|
const AdminUsers = lazy(() => import('@/pages/admin/Users'));
|
||||||
|
const AdminModels = lazy(() => import('@/pages/admin/Models'));
|
||||||
|
const AdminSiteSettings = lazy(() => import('@/pages/admin/SiteSettings'));
|
||||||
|
const AdminPayment = lazy(() => import('@/pages/admin/Payment'));
|
||||||
|
const AdminServices = lazy(() => import('@/pages/admin/Services'));
|
||||||
|
const AdminMembership = lazy(() => import('@/pages/admin/Membership'));
|
||||||
|
const AdminAds = lazy(() => import('@/pages/admin/Ads'));
|
||||||
|
const AdminMarket = lazy(() => import('@/pages/admin/Market'));
|
||||||
|
const AdminLogs = lazy(() => import('@/pages/admin/Logs'));
|
||||||
|
const AdminSkills = lazy(() => import('@/pages/admin/Skills'));
|
||||||
|
const AdminAgents = lazy(() => import('@/pages/admin/Agents'));
|
||||||
|
const AdminKnowledge = lazy(() => import('@/pages/admin/Knowledge'));
|
||||||
|
// Phase C2: New orphan page routes
|
||||||
|
const AdminApps = lazy(() => import('@/pages/admin/Apps'));
|
||||||
|
const AdminAutomation = lazy(() => import('@/pages/admin/Automation'));
|
||||||
|
const AdminDeveloper = lazy(() => import('@/pages/admin/Developer'));
|
||||||
|
const AdminMcp = lazy(() => import('@/pages/admin/Mcp'));
|
||||||
|
const AdminStorage = lazy(() => import('@/pages/admin/Storage'));
|
||||||
|
|
||||||
// Front Pages
|
// Loading fallback
|
||||||
|
const PageLoading: React.FC = () => (
|
||||||
import LandingPage from '@/pages/front/LandingPage';
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
import LoginPage from '@/pages/front/LoginPage';
|
padding: '60px 20px', color: 'var(--color-text-tertiary)', fontSize: '14px',
|
||||||
|
gap: '12px',
|
||||||
import ChatPage from '@/pages/front/ChatPage';
|
}}>
|
||||||
|
<div className="audit-spinner" />
|
||||||
import ChannelPage from '@/pages/front/ChannelPage';
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
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';
|
|
||||||
import AdminStorage from '@/pages/admin/Storage';
|
|
||||||
import AdminMcp from '@/pages/admin/Mcp';
|
|
||||||
import AdminAutomation from '@/pages/admin/Automation';
|
|
||||||
import AdminDeveloper from '@/pages/admin/Developer';
|
|
||||||
import AdminApps from '@/pages/admin/Apps';;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 初始化:从localStorage恢复登录状态
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
||||||
const token = localStorage.getItem('aiflow_token');
|
const token = localStorage.getItem('aiflow_token');
|
||||||
|
|
||||||
const cachedUser = localStorage.getItem('aiflow_user');
|
const cachedUser = localStorage.getItem('aiflow_user');
|
||||||
|
|
||||||
if (token && cachedUser) {
|
if (token && cachedUser) {
|
||||||
|
try { setUser(JSON.parse(cachedUser)); } catch {
|
||||||
try {
|
|
||||||
|
|
||||||
setUser(JSON.parse(cachedUser));
|
|
||||||
|
|
||||||
} catch {
|
|
||||||
|
|
||||||
localStorage.removeItem('aiflow_token');
|
localStorage.removeItem('aiflow_token');
|
||||||
|
|
||||||
localStorage.removeItem('aiflow_user');
|
localStorage.removeItem('aiflow_user');
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
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 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';
|
const isAdmin = user?.role === 'admin';
|
||||||
|
|
||||||
|
if (loading) return (
|
||||||
|
<div style={{
|
||||||
if (loading) {
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
height: '100vh', color: '#6C63FF', flexDirection: 'column', gap: 16,
|
||||||
return <div className="loading-screen">加载中...</div>;
|
}}>
|
||||||
|
<div className="audit-spinner" style={{ width: 40, height: 40 }} />
|
||||||
}
|
<span style={{ fontSize: 14, color: '#9CA3AF' }}>加载中...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<AuthContext.Provider value={{ user, login, logout, isAdmin }}>
|
<AuthContext.Provider value={{ user, login, logout, isAdmin }}>
|
||||||
|
<Suspense fallback={<PageLoading />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route path="/login" element={user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LoginPage />} />
|
||||||
{/* Login */}
|
<Route path="/" element={user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LandingPage />} />
|
||||||
|
|
||||||
<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="/" element={<FrontLayout />}>
|
||||||
|
|
||||||
<Route path="chat" element={<ChatPage />} />
|
<Route path="chat" element={<ChatPage />} />
|
||||||
|
|
||||||
<Route path="chat/:id" element={<ChatPage />} />
|
<Route path="chat/:id" element={<ChatPage />} />
|
||||||
|
|
||||||
<Route path="channels" element={<ChannelPage />} />
|
<Route path="channels" element={<ChannelPage />} />
|
||||||
|
|
||||||
<Route path="knowledge" element={<KnowledgePage />} />
|
<Route path="knowledge" element={<KnowledgePage />} />
|
||||||
|
|
||||||
<Route path="market" element={<MarketPage />} />
|
<Route path="market" element={<MarketPage />} />
|
||||||
|
|
||||||
<Route path="token" element={<TokenDashboardPage />} />
|
<Route path="token" element={<TokenDashboardPage />} />
|
||||||
|
|
||||||
<Route path="pricing" element={<PricingPage />} />
|
<Route path="pricing" element={<PricingPage />} />
|
||||||
|
|
||||||
<Route path="profile" element={<ProfilePage />} />
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
|
|
||||||
<Route path="notifications" element={<NotificationPage />} />
|
<Route path="notifications" element={<NotificationPage />} />
|
||||||
|
|
||||||
<Route path="assets" element={<AssetsPage />} />
|
<Route path="assets" element={<AssetsPage />} />
|
||||||
|
|
||||||
<Route path="assets/novel/:id" element={<NovelPreviewPage />} />
|
<Route path="assets/novel/:id" element={<NovelPreviewPage />} />
|
||||||
|
|
||||||
<Route path="evolution" element={<EvolutionDashboard />} />
|
<Route path="evolution" element={<EvolutionDashboard />} />
|
||||||
|
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Admin Routes */}
|
|
||||||
|
|
||||||
<Route path="/admin" element={<AdminLayout />}>
|
<Route path="/admin" element={<AdminLayout />}>
|
||||||
|
|
||||||
<Route index element={<AdminDashboard />} />
|
<Route index element={<AdminDashboard />} />
|
||||||
|
{/* 用户体系 */}
|
||||||
<Route path="users" element={<AdminUsers />} />
|
<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="membership" element={<AdminMembership />} />
|
||||||
|
{/* AI能力 */}
|
||||||
<Route path="ads" element={<AdminAds />} />
|
<Route path="models" element={<AdminModels />} />
|
||||||
|
|
||||||
<Route path="market" element={<AdminMarket />} />
|
|
||||||
|
|
||||||
<Route path="logs" element={<AdminLogs />} />
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<Route path="skills" element={<AdminSkills />} />
|
|
||||||
<Route path="agents" element={<AdminAgents />} />
|
<Route path="agents" element={<AdminAgents />} />
|
||||||
|
<Route path="skills" element={<AdminSkills />} />
|
||||||
<Route path="knowledge" element={<AdminKnowledge />} />
|
<Route path="knowledge" element={<AdminKnowledge />} />
|
||||||
|
<Route path="mcp" element={<AdminMcp />} />
|
||||||
|
{/* 业务运营 */}
|
||||||
|
<Route path="market" element={<AdminMarket />} />
|
||||||
|
<Route path="services" element={<AdminServices />} />
|
||||||
|
<Route path="payment" element={<AdminPayment />} />
|
||||||
|
<Route path="apps" element={<AdminApps />} />
|
||||||
|
<Route path="ads" element={<AdminAds />} />
|
||||||
|
{/* 系统设置 */}
|
||||||
|
<Route path="settings" element={<AdminSiteSettings />} />
|
||||||
|
<Route path="logs" element={<AdminLogs />} />
|
||||||
<Route path="storage" element={<AdminStorage />} />
|
<Route path="storage" element={<AdminStorage />} />
|
||||||
<Route path="automation" element={<AdminAutomation />} />
|
<Route path="automation" element={<AdminAutomation />} />
|
||||||
<Route path="developer" element={<AdminDeveloper />} />
|
<Route path="developer" element={<AdminDeveloper />} />
|
||||||
<Route path="apps" element={<AdminApps />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" />} />
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
|
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,262 +1,297 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
import './AdminLayout.css';
|
import './AdminLayout.css';
|
||||||
|
|
||||||
|
interface NavGroup {
|
||||||
|
label: string;
|
||||||
|
items: NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
path: string;
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
exact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const adminNavItems = [
|
const navGroups: NavGroup[] = [
|
||||||
|
{
|
||||||
{ path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true },
|
label: '平台驾驶舱',
|
||||||
|
items: [
|
||||||
|
{ path: '/admin', icon: 'dashboard', label: '平台概览', exact: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '用户体系',
|
||||||
|
items: [
|
||||||
{ path: '/admin/users', icon: 'users', label: '用户管理' },
|
{ path: '/admin/users', icon: 'users', label: '用户管理' },
|
||||||
|
{ path: '/admin/membership', icon: 'membership', label: '会员体系' },
|
||||||
{ path: '/admin/models', icon: 'models', label: '大模型库管理' },
|
],
|
||||||
|
},
|
||||||
{ path: '/admin/settings', icon: 'settings', label: '网站基础设置' },
|
{
|
||||||
|
label: 'AI能力',
|
||||||
{ path: '/admin/payment', icon: 'payment', label: '支付渠道配置' },
|
items: [
|
||||||
|
{ path: '/admin/models', icon: 'models', 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/agents', icon: 'agents', label: 'Agent管理' },
|
||||||
{ path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' },
|
|
||||||
{ path: '/admin/storage', icon: 'storage', label: '存储配置' },
|
|
||||||
{ path: '/admin/automation', icon: 'automation', label: '自动化任务' },
|
|
||||||
{ path: '/admin/skills', icon: 'skills', label: '技能管理' },
|
{ path: '/admin/skills', icon: 'skills', label: '技能管理' },
|
||||||
{ path: '/admin/mcp', icon: 'mcp', label: 'MCP Servers' },
|
{ path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' },
|
||||||
{ path: '/admin/developer', icon: 'developer', label: '开发者API' },
|
{ path: '/admin/mcp', icon: 'mcp', label: 'MCP服务' },
|
||||||
{ path: '/admin/apps', icon: 'apps', label: 'AI应用管理' }
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '业务运营',
|
||||||
|
items: [
|
||||||
|
{ path: '/admin/market', icon: 'market', label: '应用市场' },
|
||||||
|
{ path: '/admin/services', icon: 'services', label: '增值服务' },
|
||||||
|
{ path: '/admin/payment', icon: 'payment', label: '支付管理' },
|
||||||
|
{ path: '/admin/apps', icon: 'apps', label: 'AI应用' },
|
||||||
|
{ path: '/admin/ads', icon: 'ads', label: '广告管理' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '系统设置',
|
||||||
|
items: [
|
||||||
|
{ path: '/admin/settings', icon: 'settings', label: '站点设置' },
|
||||||
|
{ path: '/admin/logs', icon: 'logs', label: '日志管理' },
|
||||||
|
{ path: '/admin/storage', icon: 'storage', label: '存储管理' },
|
||||||
|
{ path: '/admin/automation', icon: 'automation', label: '自动化任务' },
|
||||||
|
{ path: '/admin/developer', icon: 'developer', label: '开发者工具' },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminIconPaths: Record<string, JSX.Element> = {
|
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>,
|
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>,
|
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>,
|
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>,
|
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>,
|
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>,
|
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>,
|
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>,
|
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>,
|
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"/></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>,
|
agents: <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="10" r="3"/><path d="M12 2a8 8 0 0 0-8 8c0 5.4 8 12 8 12s8-6.6 8-12a8 8 0 0 0-8-8z"/></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>,
|
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>,
|
||||||
automation: <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="M12 1v2"/><path d="M12 21v2"/><path d="M4.22 4.22l1.42 1.42"/><path d="M18.36 18.36l1.42 1.42"/><path d="M1 12h2"/><path d="M21 12h2"/><path d="M4.22 19.78l1.42-1.42"/><path d="M18.36 5.64l1.42-1.42"/></svg>,
|
knowledge: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="8" y1="7" x2="16" y2="7"/><line x1="8" y1="11" x2="14" y2="11"/></svg>,
|
||||||
|
// New icons for orphan pages
|
||||||
|
mcp: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>,
|
||||||
|
apps: <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="2" width="6" height="6" rx="1"/><rect x="9" y="2" width="6" height="6" rx="1"/><rect x="16" y="2" width="6" height="6" rx="1"/><rect x="2" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/><rect x="16" y="9" width="6" height="6" rx="1"/><rect x="2" y="16" width="6" height="6" rx="1"/><rect x="9" y="16" width="6" height="6" rx="1"/><rect x="16" y="16" width="6" height="6" rx="1"/></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>,
|
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>,
|
||||||
|
automation: <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="10"/><polyline points="12 6 12 12 16 14"/></svg>,
|
||||||
|
developer: <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>,
|
||||||
|
};
|
||||||
|
|
||||||
developer: (<svg width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='2' strokeLinecap='round' strokeLinejoin='round'><polyline points='16 18 22 12 16 6'/><polyline points='8 6 2 12 8 18'/></svg>),};
|
// Find current page title based on location
|
||||||
|
function getPageTitle(pathname: string): string {
|
||||||
|
for (const group of navGroups) {
|
||||||
|
for (const item of group.items) {
|
||||||
|
if (item.exact ? pathname === item.path : pathname.startsWith(item.path)) {
|
||||||
|
return item.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate breadcrumbs from pathname
|
||||||
|
function getBreadcrumbs(pathname: string): { path: string; label: string }[] {
|
||||||
|
const crumbs: { path: string; label: string }[] = [];
|
||||||
|
for (const group of navGroups) {
|
||||||
|
for (const item of group.items) {
|
||||||
|
if (item.exact ? pathname === item.path : pathname.startsWith(item.path)) {
|
||||||
|
crumbs.push({ path: item.path, label: item.label });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return crumbs.length > 0 ? crumbs : [{ path: '/admin', label: '管理后台' }];
|
||||||
|
}
|
||||||
|
|
||||||
const AdminLayout: React.FC = () => {
|
const AdminLayout: React.FC = () => {
|
||||||
|
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
|
||||||
|
new Set(navGroups.map(g => g.label))
|
||||||
|
);
|
||||||
|
|
||||||
if (!user || user.role !== 'admin') {
|
if (!user || user.role !== 'admin') {
|
||||||
|
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleGroup = (label: string) => {
|
||||||
|
const next = new Set(expandedGroups);
|
||||||
|
if (next.has(label)) next.delete(label);
|
||||||
|
else next.add(label);
|
||||||
|
setExpandedGroups(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageTitle = getPageTitle(location.pathname);
|
||||||
|
const breadcrumbs = getBreadcrumbs(location.pathname);
|
||||||
|
|
||||||
|
// Check if a nav item is active
|
||||||
|
const isItemActive = (item: NavItem) => {
|
||||||
|
return item.exact ? location.pathname === item.path : location.pathname.startsWith(item.path);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if any item in group is active
|
||||||
|
const isGroupActive = (group: NavGroup) => group.items.some(item => isItemActive(item));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<div className="admin-layout">
|
<div className="admin-layout">
|
||||||
|
|
||||||
{/* Admin Sidebar */}
|
{/* Admin Sidebar */}
|
||||||
|
|
||||||
<aside className={`admin-sidebar ${collapsed ? 'collapsed' : ''}`}>
|
<aside className={`admin-sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||||
|
|
||||||
<div className="admin-sidebar-header">
|
<div className="admin-sidebar-header">
|
||||||
|
|
||||||
<div className="sidebar-logo">
|
<div className="sidebar-logo">
|
||||||
|
|
||||||
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
|
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
|
||||||
|
<rect width="32" height="32" rx="8" fill="url(#logoGrad)"/>
|
||||||
<rect width="32" height="32" rx="8" fill="#1A1A2E"/>
|
<defs>
|
||||||
|
<linearGradient id="logoGrad" x1="0" y1="0" x2="32" y2="32">
|
||||||
<path d="M8 20V12l4 4 4-4v8" stroke="#6C63FF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
<stop stopColor="#6C63FF"/>
|
||||||
|
<stop offset="1" stopColor="#A78BFA"/>
|
||||||
<path d="M6 22h20" stroke="#6C63FF" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d="M8 20V12l4 4 4-4v8" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||||
|
<path d="M6 22h20" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
{!collapsed && <span className="sidebar-brand admin-brand">Qihu-AIFlow</span>}
|
||||||
{!collapsed && <span className="sidebar-brand admin-brand">Qihu-AIFlow 管理后台</span>}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<button className="collapse-btn" onClick={() => setCollapsed(!collapsed)} title={collapsed ? '展开菜单' : '折叠菜单'}>
|
||||||
<button className="collapse-btn" onClick={() => setCollapsed(!collapsed)}>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
{collapsed
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
? <><polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/></>
|
||||||
|
: <><polyline points="11 17 6 12 11 7"/><polyline points="18 17 13 12 18 7"/></>
|
||||||
<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>
|
</svg>
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<nav className="admin-nav">
|
<nav className="admin-nav">
|
||||||
|
{navGroups.map(group => {
|
||||||
{adminNavItems.map(item => (
|
if (collapsed) {
|
||||||
|
// Collapsed mode: show just icons
|
||||||
|
return (
|
||||||
|
<div key={group.label} className="nav-group">
|
||||||
|
{group.items.map(item => (
|
||||||
<NavLink
|
<NavLink
|
||||||
|
|
||||||
key={item.path}
|
key={item.path}
|
||||||
|
|
||||||
to={item.path}
|
to={item.path}
|
||||||
|
|
||||||
end={item.exact}
|
end={item.exact}
|
||||||
|
|
||||||
className={({ isActive }) => `admin-nav-item ${isActive ? 'active' : ''}`}
|
className={({ isActive }) => `admin-nav-item ${isActive ? 'active' : ''}`}
|
||||||
|
title={item.label}
|
||||||
>
|
>
|
||||||
|
|
||||||
<span className="nav-icon">{adminIconPaths[item.icon]}</span>
|
<span className="nav-icon">{adminIconPaths[item.icon]}</span>
|
||||||
|
|
||||||
{!collapsed && <span className="nav-label">{item.label}</span>}
|
|
||||||
|
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.label} className={`nav-group ${isGroupActive(group) ? 'active-group' : ''}`}>
|
||||||
|
<div
|
||||||
|
className="nav-group-header"
|
||||||
|
onClick={() => toggleGroup(group.label)}
|
||||||
|
>
|
||||||
|
<span className="nav-group-label">{group.label}</span>
|
||||||
|
<svg
|
||||||
|
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
className={`nav-group-chevron ${expandedGroups.has(group.label) ? 'open' : ''}`}
|
||||||
|
>
|
||||||
|
<polyline points="9 18 15 12 9 6"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`nav-group-items ${expandedGroups.has(group.label) ? '' : 'collapsed'}`}
|
||||||
|
>
|
||||||
|
{group.items.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>
|
||||||
|
<span className="nav-label">{item.label}</span>
|
||||||
|
{isItemActive(item) && <span className="nav-active-dot" />}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Sidebar bottom area */}
|
||||||
|
|
||||||
<div className="admin-sidebar-footer">
|
<div className="admin-sidebar-footer">
|
||||||
|
{/* Links row */}
|
||||||
<NavLink to="/chat" className="admin-nav-item">
|
<NavLink to="/" className="admin-nav-item">
|
||||||
|
|
||||||
<span className="nav-icon">
|
<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"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||||
<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>
|
</span>
|
||||||
|
|
||||||
{!collapsed && <span className="nav-label">返回前台</span>}
|
{!collapsed && <span className="nav-label">返回前台</span>}
|
||||||
|
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<button className="admin-nav-item logout-btn" onClick={() => { logout(); navigate('/login'); }}>
|
<button className="admin-nav-item logout-btn" onClick={() => { logout(); navigate('/login'); }}>
|
||||||
|
|
||||||
<span className="nav-icon">
|
<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>
|
<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>
|
</span>
|
||||||
|
|
||||||
{!collapsed && <span className="nav-label">退出登录</span>}
|
{!collapsed && <span className="nav-label">退出登录</span>}
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
|
|
||||||
<div className="admin-version-info">
|
<div className="admin-version-info">
|
||||||
|
|
||||||
Qihu-AIFlow v2.0.0
|
Qihu-AIFlow v2.0.0
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Admin Main Content */}
|
{/* Admin Main Content */}
|
||||||
|
|
||||||
<main className="admin-main">
|
<main className="admin-main">
|
||||||
|
|
||||||
<header className="admin-topbar">
|
<header className="admin-topbar">
|
||||||
|
|
||||||
<div className="admin-topbar-left">
|
<div className="admin-topbar-left">
|
||||||
|
{/* Page title */}
|
||||||
<span className="admin-welcome">欢迎,{user.username}</span>
|
<h1 className="admin-page-title">{pageTitle}</h1>
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="admin-breadcrumb">
|
||||||
|
{breadcrumbs.map((crumb, i) => (
|
||||||
|
<span key={crumb.path}>
|
||||||
|
{i > 0 && <span className="breadcrumb-sep">/</span>}
|
||||||
|
<span
|
||||||
|
className={`breadcrumb-item ${i === breadcrumbs.length - 1 ? 'current' : ''}`}
|
||||||
|
onClick={() => i < breadcrumbs.length - 1 && navigate(crumb.path)}
|
||||||
|
style={{ cursor: i < breadcrumbs.length - 1 ? 'pointer' : 'default' }}
|
||||||
|
>
|
||||||
|
{crumb.label}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-topbar-right">
|
<div className="admin-topbar-right">
|
||||||
|
<span className="admin-welcome">欢迎,{user.username}</span>
|
||||||
<a href="#/chat" className="admin-topbar-action">
|
<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>
|
<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>
|
<span>返回前台</span>
|
||||||
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div className="admin-user-tag">
|
<div className="admin-user-tag">
|
||||||
|
|
||||||
<div className="user-avatar-sm admin-avatar">
|
<div className="user-avatar-sm admin-avatar">
|
||||||
|
|
||||||
{user.username.charAt(0).toUpperCase()}
|
{user.username.charAt(0).toUpperCase()}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span>{user.username}</span>
|
<span>{user.username}</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="admin-content">
|
<div className="admin-content">
|
||||||
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default AdminLayout;
|
export default AdminLayout;
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { adminAPI } from '@/api';
|
import { adminAPI } from '@/api';
|
||||||
|
|
||||||
@@ -8,6 +8,28 @@ const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ child
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
interface DashboardStats {
|
||||||
|
total_users: number;
|
||||||
|
active_users: number;
|
||||||
|
total_models: number;
|
||||||
|
enabled_models: number;
|
||||||
|
total_conversations: number;
|
||||||
|
active_conversations: number;
|
||||||
|
total_knowledge_bases: number;
|
||||||
|
total_documents: number;
|
||||||
|
today_orders: number;
|
||||||
|
today_tokens: number;
|
||||||
|
week_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
today_conversations: number;
|
||||||
|
server_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DailyStat {
|
||||||
|
date: string;
|
||||||
|
tokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
const quickEntries = [
|
const quickEntries = [
|
||||||
{ path: '/admin/models', label: '大模型库', desc: '管理AI模型与供应商', icon: <SVGIcon><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon> },
|
{ path: '/admin/models', label: '大模型库', desc: '管理AI模型与供应商', icon: <SVGIcon><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon> },
|
||||||
{ path: '/admin/settings', label: '网站设置', desc: '品牌、邮件、短信配置', icon: <SVGIcon><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"/></SVGIcon> },
|
{ path: '/admin/settings', label: '网站设置', desc: '品牌、邮件、短信配置', icon: <SVGIcon><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"/></SVGIcon> },
|
||||||
@@ -18,9 +40,86 @@ const quickEntries = [
|
|||||||
{ path: '/admin/logs', label: '日志风控', desc: '操作/调用/付费日志与风控', icon: <SVGIcon><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"/></SVGIcon> },
|
{ path: '/admin/logs', label: '日志风控', desc: '操作/调用/付费日志与风控', icon: <SVGIcon><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"/></SVGIcon> },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// SVG Bar Chart Component
|
||||||
|
const BarChart: React.FC<{ data: DailyStat[] }> = ({ data }) => {
|
||||||
|
if (!data || data.length === 0) return <div style={{ color: '#6B7280', textAlign: 'center', padding: 40 }}>暂无数据</div>;
|
||||||
|
|
||||||
|
const maxVal = Math.max(...data.map(d => d.tokens), 1);
|
||||||
|
const w = 600, h = 200, padLeft = 55, padRight = 10, padTop = 10, padBottom = 25;
|
||||||
|
const barAreaW = w - padLeft - padRight;
|
||||||
|
const barAreaH = h - padTop - padBottom;
|
||||||
|
const barGap = 2;
|
||||||
|
const barW = Math.max(2, (barAreaW / data.length) - barGap);
|
||||||
|
|
||||||
|
// Y axis ticks
|
||||||
|
const yTicks = [0, Math.round(maxVal * 0.25), Math.round(maxVal * 0.5), Math.round(maxVal * 0.75), maxVal];
|
||||||
|
|
||||||
|
const formatK = (n: number) => n >= 10000 ? (n / 1000).toFixed(0) + 'k' : String(n);
|
||||||
|
const formatDate = (d: string) => {
|
||||||
|
const parts = d.split('-');
|
||||||
|
return parts.length >= 2 ? `${parts[1]}/${parts[2]}` : d;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ width: '100%', height: 'auto', maxWidth: 600 }}>
|
||||||
|
{/* Grid lines */}
|
||||||
|
{yTicks.map((tick, i) => {
|
||||||
|
const y = padTop + barAreaH - (tick / maxVal) * barAreaH;
|
||||||
|
return (
|
||||||
|
<g key={i}>
|
||||||
|
<line x1={padLeft} y1={y} x2={w - padRight} y2={y} stroke="rgba(255,255,255,0.08)" strokeWidth="0.5" />
|
||||||
|
<text x={padLeft - 8} y={y + 4} textAnchor="end" fontSize="10" fill="#6B7280">{formatK(tick)}</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Bars */}
|
||||||
|
{data.map((d, i) => {
|
||||||
|
const barH = (d.tokens / maxVal) * barAreaH;
|
||||||
|
const x = padLeft + i * (barAreaW / data.length);
|
||||||
|
const y = padTop + barAreaH - barH;
|
||||||
|
return (
|
||||||
|
<g key={i}>
|
||||||
|
<rect x={x + barGap / 2} y={y} width={barW} height={Math.max(barH, 1)} rx="2" fill="#6C63FF" opacity="0.85">
|
||||||
|
<title>{`${d.date}: ${d.tokens.toLocaleString()} tokens`}</title>
|
||||||
|
</rect>
|
||||||
|
{/* X label - every 7th bar */}
|
||||||
|
{i % Math.max(1, Math.floor(data.length / 8)) === 0 && (
|
||||||
|
<text x={x + barW / 2} y={h - 6} textAnchor="middle" fontSize="9" fill="#6B7280">{formatDate(d.date)}</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mini Donut for stat cards
|
||||||
|
const MiniDonut: React.FC<{ value: number; max: number; color: string }> = ({ value, max, color }) => {
|
||||||
|
const pct = max > 0 ? Math.min(value / max, 1) : 0;
|
||||||
|
const r = 16, c = 20, circumference = 2 * Math.PI * r;
|
||||||
|
const dash = circumference * pct;
|
||||||
|
return (
|
||||||
|
<svg width={c * 2} height={c * 2} viewBox={`0 0 ${c * 2} ${c * 2}`}>
|
||||||
|
<circle cx={c} cy={c} r={r} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth="3" />
|
||||||
|
<circle cx={c} cy={c} r={r} fill="none" stroke={color} strokeWidth="3"
|
||||||
|
strokeDasharray={`${dash} ${circumference - dash}`} strokeLinecap="round"
|
||||||
|
transform={`rotate(-90 ${c} ${c})`} style={{ transition: 'stroke-dasharray 0.6s ease' }} />
|
||||||
|
<text x={c} y={c + 4} textAnchor="middle" fontSize="9" fontWeight="600" fill={color}>
|
||||||
|
{Math.round(pct * 100)}%
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const Dashboard: React.FC = () => {
|
const Dashboard: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [stats, setStats] = useState({ active_users: 0, enabled_models: 0, today_orders: 0, today_tokens: 0, total_tokens: 0 });
|
const [stats, setStats] = useState<DashboardStats>({
|
||||||
|
total_users: 0, active_users: 0, total_models: 0, enabled_models: 0,
|
||||||
|
total_conversations: 0, active_conversations: 0, total_knowledge_bases: 0, total_documents: 0,
|
||||||
|
today_orders: 0, today_tokens: 0, week_tokens: 0, total_tokens: 0,
|
||||||
|
today_conversations: 0, server_time: '',
|
||||||
|
});
|
||||||
|
const [dailyStats, setDailyStats] = useState<DailyStat[]>([]);
|
||||||
const [operationLogs, setOperationLogs] = useState<any[]>([]);
|
const [operationLogs, setOperationLogs] = useState<any[]>([]);
|
||||||
const [auditLogs, setAuditLogs] = useState<any[]>([]);
|
const [auditLogs, setAuditLogs] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -29,12 +128,14 @@ const Dashboard: React.FC = () => {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [statsRes, logsRes, auditRes] = await Promise.all([
|
const [statsRes, dailyRes, logsRes, auditRes] = await Promise.all([
|
||||||
adminAPI.getStats(),
|
adminAPI.getStats() as any,
|
||||||
|
(adminAPI as any).getDailyStats?.(30) || Promise.resolve({ data: [] }),
|
||||||
adminAPI.getRecentLogs(),
|
adminAPI.getRecentLogs(),
|
||||||
adminAPI.getRecentAudit(),
|
adminAPI.getRecentAudit(),
|
||||||
]);
|
]);
|
||||||
if (statsRes.data) setStats(statsRes.data);
|
if (statsRes.data) setStats(statsRes.data);
|
||||||
|
if (dailyRes.data) setDailyStats(dailyRes.data || []);
|
||||||
if (logsRes.data) setOperationLogs(Array.isArray(logsRes.data) ? logsRes.data : []);
|
if (logsRes.data) setOperationLogs(Array.isArray(logsRes.data) ? logsRes.data : []);
|
||||||
if (auditRes.data) setAuditLogs(Array.isArray(auditRes.data) ? auditRes.data : []);
|
if (auditRes.data) setAuditLogs(Array.isArray(auditRes.data) ? auditRes.data : []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -54,15 +155,24 @@ const Dashboard: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatTokens = (n: number): string => {
|
const formatTokens = (n: number): string => {
|
||||||
|
if (n >= 100000000) return (n / 100000000).toFixed(2) + '亿';
|
||||||
if (n >= 10000) return (n / 10000).toFixed(1) + '万';
|
if (n >= 10000) return (n / 10000).toFixed(1) + '万';
|
||||||
return n.toLocaleString('en-US');
|
return n.toLocaleString('en-US');
|
||||||
};
|
};
|
||||||
|
|
||||||
const statCards = [
|
const statCardDefs = [
|
||||||
{ label: '用户总数', value: stats.active_users.toLocaleString(), color: '#6C63FF', bg: '#EEEDFF', icon: <SVGIcon size={22}><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"/></SVGIcon> },
|
{ label: '总用户', value: stats.total_users, sub: `活跃 ${stats.active_users}`, color: '#6C63FF', bg: 'rgba(108,99,255,0.12)', subBg: 'rgba(108,99,255,0.08)',
|
||||||
{ label: '活跃模型数', value: stats.enabled_models.toString(), color: '#219B5E', bg: '#E6F7EE', icon: <SVGIcon size={22}><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon> },
|
icon: <SVGIcon size={22}><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"/></SVGIcon> },
|
||||||
{ label: '今日订单数', value: stats.today_orders.toString(), color: '#E8922C', bg: '#FEF4E8', icon: <SVGIcon size={22}><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></SVGIcon> },
|
{ label: 'AI模型', value: stats.total_models, sub: `启用 ${stats.enabled_models}`, color: '#219B5E', bg: 'rgba(33,155,94,0.12)', subBg: 'rgba(33,155,94,0.08)',
|
||||||
{ label: '今日Token消耗', value: formatTokens(stats.today_tokens), color: '#3B82F6', bg: '#EBF2FE', icon: <SVGIcon size={22}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon> },
|
icon: <SVGIcon size={22}><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon> },
|
||||||
|
{ label: '对话数', value: stats.total_conversations, sub: `活跃 ${stats.active_conversations}`, color: '#E8922C', bg: 'rgba(232,146,44,0.12)', subBg: 'rgba(232,146,44,0.08)',
|
||||||
|
icon: <SVGIcon size={22}><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></SVGIcon> },
|
||||||
|
{ label: '知识库', value: stats.total_knowledge_bases, sub: `文档 ${stats.total_documents}`, color: '#3B82F6', bg: 'rgba(59,130,246,0.12)', subBg: 'rgba(59,130,246,0.08)',
|
||||||
|
icon: <SVGIcon size={22}><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></SVGIcon> },
|
||||||
|
{ label: '今日对话', value: stats.today_conversations, sub: `订单 ${stats.today_orders}`, color: '#8B5CF6', bg: 'rgba(139,92,246,0.12)', subBg: 'rgba(139,92,246,0.08)',
|
||||||
|
icon: <SVGIcon size={22}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon> },
|
||||||
|
{ label: '今日Token', value: stats.today_tokens, sub: `本周 ${formatTokens(stats.week_tokens)}`, color: '#EC4899', bg: 'rgba(236,72,153,0.12)', subBg: 'rgba(236,72,153,0.08)', format: true,
|
||||||
|
icon: <SVGIcon size={22}><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></SVGIcon> },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -78,85 +188,120 @@ const Dashboard: React.FC = () => {
|
|||||||
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
|
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
|
||||||
<h1 className="admin-page-title">平台驾驶舱</h1>
|
<h1 className="admin-page-title">平台驾驶舱</h1>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards Row */}
|
||||||
<div className="admin-stats">
|
<div className="admin-stats" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))' }}>
|
||||||
{statCards.map((s, i) => (
|
{statCardDefs.map((s, i) => (
|
||||||
<div key={i} className="admin-stat-card" style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--space-lg)' }}>
|
<div key={i} className="admin-stat-card" style={{
|
||||||
|
display: 'flex', alignItems: 'flex-start', gap: 'var(--space-lg)',
|
||||||
|
padding: 'var(--space-lg) var(--space-xl)',
|
||||||
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 48, height: 48, borderRadius: 'var(--radius-lg)',
|
width: 48, height: 48, borderRadius: 14,
|
||||||
background: s.bg, color: s.color,
|
background: s.bg, color: s.color,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
{s.icon}
|
{s.icon}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div className="admin-stat-label">{s.label}</div>
|
<div className="admin-stat-label">{s.label}</div>
|
||||||
<div className="admin-stat-value">{s.value}</div>
|
<div className="admin-stat-value" style={{ fontSize: 'var(--text-xl)' }}>
|
||||||
|
{s.format ? formatTokens(s.value as number) : (s.value as number).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 11, color: s.color, marginTop: 2,
|
||||||
|
background: s.subBg || s.bg, padding: '1px 6px', borderRadius: 4,
|
||||||
|
display: 'inline-block',
|
||||||
|
}}>{s.sub}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Platform Cumulative Token Stats */}
|
{/* Charts Row */}
|
||||||
<div className="admin-card" style={{ marginBottom: 'var(--space-xl)' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 'var(--space-xl)', marginBottom: 'var(--space-xl)' }}>
|
||||||
|
{/* Token Trend Chart */}
|
||||||
|
<div className="admin-card">
|
||||||
<div className="admin-card-header">
|
<div className="admin-card-header">
|
||||||
<h3 className="admin-card-title">
|
<h3 className="admin-card-title">
|
||||||
<SVGIcon size={18}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
|
<SVGIcon size={18}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
|
||||||
{' '}平台累计Token消耗
|
{' '}Token消耗趋势(近30天)
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 'var(--space-xl)' }}>
|
<div style={{ padding: 'var(--space-lg)', display: 'flex', justifyContent: 'center' }}>
|
||||||
{[
|
<BarChart data={dailyStats} />
|
||||||
{ label: '今日消耗', value: formatTokens(stats.today_tokens), unit: 'Tokens', color: '#3B82F6' },
|
|
||||||
{ label: '累计总消耗', value: formatTokens(stats.total_tokens || 0), unit: 'Tokens', color: '#6C63FF' },
|
|
||||||
{ label: '日均消耗(近30天)', value: formatTokens(Math.round((stats.total_tokens || 0) / 30)), unit: 'Tokens', color: '#219B5E' },
|
|
||||||
{ label: '活跃调用模型', value: stats.enabled_models.toString(), unit: '个', color: '#E8922C' },
|
|
||||||
].map((item, i) => (
|
|
||||||
<div key={i} style={{ textAlign: 'center', padding: 'var(--space-lg)' }}>
|
|
||||||
<div style={{ fontSize: 'var(--text-2xl)', fontWeight: 800, color: item.color, fontFamily: 'var(--font-mono)' }}>
|
|
||||||
{item.value}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)', marginTop: 'var(--space-xs)' }}>
|
|
||||||
{item.label}
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>{item.unit}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Entry Cards */}
|
{/* System Health */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 'var(--space-lg)', marginBottom: 'var(--space-2xl)' }}>
|
<div className="admin-card">
|
||||||
|
<div className="admin-card-header">
|
||||||
|
<h3 className="admin-card-title">
|
||||||
|
<SVGIcon size={18}><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></SVGIcon>
|
||||||
|
{' '}平台概览
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 'var(--space-lg)' }}>
|
||||||
|
{[
|
||||||
|
{ label: '累计Token消耗', value: formatTokens(stats.total_tokens), color: '#6C63FF', pct: 100 },
|
||||||
|
{ label: '模型利用率', value: `${stats.enabled_models}/${stats.total_models}`, color: '#219B5E', pct: stats.total_models > 0 ? stats.enabled_models / stats.total_models : 0 },
|
||||||
|
{ label: '用户活跃率', value: `${stats.active_users}/${stats.total_users}`, color: '#E8922C', pct: stats.total_users > 0 ? stats.active_users / stats.total_users : 0 },
|
||||||
|
{ label: '对话活跃率', value: `${stats.active_conversations}/${stats.total_conversations}`, color: '#3B82F6', pct: stats.total_conversations > 0 ? stats.active_conversations / stats.total_conversations : 0 },
|
||||||
|
].map((item, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 0', borderBottom: i < 3 ? '1px solid rgba(255,255,255,0.06)' : 'none' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<MiniDonut value={item.pct * 100} max={100} color={item.color} />
|
||||||
|
<span style={{ fontSize: 13, color: '#CCD6F6' }}>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 15, fontWeight: 700, color: item.color, fontFamily: 'var(--font-mono)' }}>{item.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{ marginTop: 12, fontSize: 11, color: '#6B7280', textAlign: 'center' }}>
|
||||||
|
服务器时间: {stats.server_time ? new Date(stats.server_time).toLocaleString('zh-CN') : '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div style={{ marginBottom: 'var(--space-xl)' }}>
|
||||||
|
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12, color: '#CCD6F6' }}>快捷入口</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 'var(--space-md)' }}>
|
||||||
{quickEntries.map((entry) => (
|
{quickEntries.map((entry) => (
|
||||||
<div
|
<div
|
||||||
key={entry.path}
|
key={entry.path}
|
||||||
onClick={() => navigate(entry.path)}
|
onClick={() => navigate(entry.path)}
|
||||||
style={{
|
style={{
|
||||||
background: '#FFFFFF', borderRadius: 'var(--radius-xl)', border: '1px solid var(--color-border)',
|
background: 'rgba(17,22,39,0.5)', borderRadius: 'var(--radius-lg)', border: '1px solid var(--color-border)',
|
||||||
padding: 'var(--space-xl)', cursor: 'pointer', transition: 'all var(--transition-fast)',
|
padding: 'var(--space-md) var(--space-lg)', cursor: 'pointer', transition: 'all 0.2s',
|
||||||
display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 'var(--space-sm)',
|
display: 'flex', alignItems: 'center', gap: 'var(--space-md)',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.borderColor = 'var(--color-accent)';
|
e.currentTarget.style.borderColor = '#6C63FF';
|
||||||
e.currentTarget.style.boxShadow = 'var(--shadow-md)';
|
e.currentTarget.style.boxShadow = '0 2px 8px rgba(108,99,255,0.15)';
|
||||||
|
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.borderColor = 'var(--color-border)';
|
e.currentTarget.style.borderColor = 'var(--color-border)';
|
||||||
e.currentTarget.style.boxShadow = 'none';
|
e.currentTarget.style.boxShadow = 'none';
|
||||||
|
e.currentTarget.style.transform = 'none';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ color: 'var(--color-accent)', marginBottom: 'var(--space-xs)' }}>{entry.icon}</div>
|
<div style={{ color: '#6C63FF', flexShrink: 0 }}>{entry.icon}</div>
|
||||||
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 600, color: 'var(--color-text-primary)' }}>{entry.label}</div>
|
<div>
|
||||||
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{entry.desc}</div>
|
<div style={{ fontSize: 13, fontWeight: 600, color: '#CCD6F6' }}>{entry.label}</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#6B7280' }}>{entry.desc}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Recent Operation Logs */}
|
{/* Recent Logs */}
|
||||||
<div className="admin-card">
|
<div className="admin-card" style={{ marginBottom: 'var(--space-xl)' }}>
|
||||||
<div className="admin-card-header">
|
<div className="admin-card-header">
|
||||||
<h3 className="admin-card-title">最近操作日志</h3>
|
<h3 className="admin-card-title">最近操作日志</h3>
|
||||||
|
<span style={{ fontSize: 12, color: '#6B7280' }}>最近10条</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table className="admin-table">
|
<table className="admin-table">
|
||||||
@@ -192,6 +337,7 @@ const Dashboard: React.FC = () => {
|
|||||||
<div className="admin-card">
|
<div className="admin-card">
|
||||||
<div className="admin-card-header">
|
<div className="admin-card-header">
|
||||||
<h3 className="admin-card-title">最近审计日志</h3>
|
<h3 className="admin-card-title">最近审计日志</h3>
|
||||||
|
<span style={{ fontSize: 12, color: '#6B7280' }}>最近10条</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table className="admin-table">
|
<table className="admin-table">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { adminAPI } from '@/api';
|
import { adminAPI } from '@/api';
|
||||||
import type { ModelProvider, AIModel, ProtocolDef, FetchableModel } from '@/types';
|
import type { ModelProvider, AIModel, ProtocolDef, FetchableModel } from '@/types';
|
||||||
|
|
||||||
@@ -15,36 +15,25 @@ const formatNumber = (n: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const capabilityColors: Record<string, string> = {
|
const capabilityColors: Record<string, string> = {
|
||||||
text: '#5B5FED',
|
text: '#5B5FED', vision: '#219B5E', audio: '#E8922C',
|
||||||
vision: '#219B5E',
|
video: '#D9383A', image: '#D9383A', thinking: '#8B5CF6', tool: '#3B82F6',
|
||||||
audio: '#E8922C',
|
|
||||||
video: '#D9383A',
|
|
||||||
image: '#D9383A',
|
|
||||||
thinking: '#8B5CF6',
|
|
||||||
tool: '#3B82F6',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const capabilityLabels: Record<string, string> = {
|
const capabilityLabels: Record<string, string> = {
|
||||||
text: '文本',
|
text: '文本', vision: '视觉', audio: '音频',
|
||||||
vision: '视觉',
|
video: '视频', image: '图像', thinking: '思考', tool: '工具',
|
||||||
audio: '音频',
|
|
||||||
video: '视频',
|
|
||||||
image: '图像',
|
|
||||||
thinking: '思考',
|
|
||||||
tool: '工具',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Test result type
|
|
||||||
interface TestResult {
|
interface TestResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
latency_ms?: number;
|
latency_ms?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Model editing type
|
|
||||||
interface EditingModel {
|
interface EditingModel {
|
||||||
id?: string;
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
display_name: string;
|
||||||
provider_id: string;
|
provider_id: string;
|
||||||
api_key: string;
|
api_key: string;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
@@ -55,6 +44,42 @@ interface EditingModel {
|
|||||||
is_enabled: boolean;
|
is_enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stats panel
|
||||||
|
const ModelStatsPanel: React.FC<{ models: AIModel[]; providers: ModelProvider[] }> = ({ models, providers }) => {
|
||||||
|
const enabled = models.filter(m => m.isEnabled).length;
|
||||||
|
const defaults = models.filter(m => m.isDefault).length;
|
||||||
|
const totalCalls = models.reduce((s, m) => s + (m.totalCalls || 0), 0);
|
||||||
|
const paidModels = models.filter(m => m.status === 'paid').length;
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ label: '供应商', value: providers.length, color: '#5B5FED', icon: '🔌' },
|
||||||
|
{ label: '模型总数', value: models.length, color: '#219B5E', icon: '🤖' },
|
||||||
|
{ label: '已启用', value: enabled, color: '#16A34A', icon: '✅' },
|
||||||
|
{ label: '总调用量', value: formatNumber(totalCalls), color: '#E8922C', icon: '📊' },
|
||||||
|
{ label: '默认模型', value: defaults, color: '#8B5CF6', icon: '⭐' },
|
||||||
|
{ label: '付费模型', value: paidModels, color: '#EC4899', icon: '💎' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 12, marginBottom: 20 }}>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
background: 'rgba(17,22,39,0.5)', borderRadius: 10, border: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 20 }}>{item.icon}</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 11, color: '#6B7280' }}>{item.label}</div>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 700, color: item.color, fontFamily: 'var(--font-mono)' }}>
|
||||||
|
{item.value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const Models: React.FC = () => {
|
const Models: React.FC = () => {
|
||||||
const [providers, setProviders] = useState<ModelProvider[]>([]);
|
const [providers, setProviders] = useState<ModelProvider[]>([]);
|
||||||
const [models, setModels] = useState<AIModel[]>([]);
|
const [models, setModels] = useState<AIModel[]>([]);
|
||||||
@@ -68,12 +93,9 @@ const Models: React.FC = () => {
|
|||||||
const [editingProvider, setEditingProvider] = useState<ModelProvider | null>(null);
|
const [editingProvider, setEditingProvider] = useState<ModelProvider | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [modelSearch, setModelSearch] = useState('');
|
||||||
// Model test state
|
|
||||||
const [testingModelId, setTestingModelId] = useState<string | null>(null);
|
|
||||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||||
|
const [testingModelId, setTestingModelId] = useState<string | null>(null);
|
||||||
// Model editor state
|
|
||||||
const [showModelEditor, setShowModelEditor] = useState(false);
|
const [showModelEditor, setShowModelEditor] = useState(false);
|
||||||
const [editingModel, setEditingModel] = useState<EditingModel | null>(null);
|
const [editingModel, setEditingModel] = useState<EditingModel | null>(null);
|
||||||
const [modelSaving, setModelSaving] = useState(false);
|
const [modelSaving, setModelSaving] = useState(false);
|
||||||
@@ -97,6 +119,7 @@ const Models: React.FC = () => {
|
|||||||
const mappedModels = (Array.isArray(rawModels) ? rawModels : []).map((m: any) => ({
|
const mappedModels = (Array.isArray(rawModels) ? rawModels : []).map((m: any) => ({
|
||||||
...m,
|
...m,
|
||||||
providerId: m.providerId || m.provider_id,
|
providerId: m.providerId || m.provider_id,
|
||||||
|
displayName: m.displayName || m.display_name || m.name,
|
||||||
apiKey: m.apiKey || m.api_key,
|
apiKey: m.apiKey || m.api_key,
|
||||||
pricePerMillionTokens: m.pricePerMillionTokens ?? m.price_per_million_tokens ?? 0,
|
pricePerMillionTokens: m.pricePerMillionTokens ?? m.price_per_million_tokens ?? 0,
|
||||||
isEnabled: m.isEnabled ?? m.is_enabled ?? true,
|
isEnabled: m.isEnabled ?? m.is_enabled ?? true,
|
||||||
@@ -114,6 +137,16 @@ const Models: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => { loadData(); }, [loadData]);
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
|
|
||||||
|
// Filtered models
|
||||||
|
const filteredModels = useMemo(() => {
|
||||||
|
if (!modelSearch.trim()) return models;
|
||||||
|
const q = modelSearch.toLowerCase();
|
||||||
|
return models.filter(m =>
|
||||||
|
m.name.toLowerCase().includes(q) ||
|
||||||
|
((m as any).display_name || '').toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [models, modelSearch]);
|
||||||
|
|
||||||
// Test model connection
|
// Test model connection
|
||||||
const handleTestConnection = async (modelId: string) => {
|
const handleTestConnection = async (modelId: string) => {
|
||||||
setTestingModelId(modelId);
|
setTestingModelId(modelId);
|
||||||
@@ -128,7 +161,6 @@ const Models: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set default model
|
|
||||||
const handleSetDefault = async (modelId: string) => {
|
const handleSetDefault = async (modelId: string) => {
|
||||||
try {
|
try {
|
||||||
await adminAPI.setDefaultModel(modelId);
|
await adminAPI.setDefaultModel(modelId);
|
||||||
@@ -138,17 +170,14 @@ const Models: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Provider operations
|
|
||||||
const handleAddProvider = async (e: React.FormEvent) => {
|
const handleAddProvider = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!providerForm.name.trim()) return;
|
if (!providerForm.name.trim()) return;
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
await adminAPI.addProvider({
|
await adminAPI.addProvider({
|
||||||
name: providerForm.name,
|
name: providerForm.name, protocol: providerForm.protocol,
|
||||||
protocol: providerForm.protocol,
|
api_key: providerForm.api_key, api_base_url: providerForm.api_base_url || undefined,
|
||||||
api_key: (providerForm as any).api_key || providerForm.api_key,
|
|
||||||
api_base_url: (providerForm as any).api_base_url || providerForm.api_base_url || undefined,
|
|
||||||
});
|
});
|
||||||
setShowAddProvider(false);
|
setShowAddProvider(false);
|
||||||
setProviderForm({ name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '' });
|
setProviderForm({ name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '' });
|
||||||
@@ -165,8 +194,7 @@ const Models: React.FC = () => {
|
|||||||
setError(null);
|
setError(null);
|
||||||
await adminAPI.updateProvider(editingProvider.id, {
|
await adminAPI.updateProvider(editingProvider.id, {
|
||||||
name: providerForm.name, protocol: providerForm.protocol,
|
name: providerForm.name, protocol: providerForm.protocol,
|
||||||
api_key: (providerForm as any).api_key || providerForm.api_key,
|
api_key: providerForm.api_key, api_base_url: providerForm.api_base_url || undefined,
|
||||||
api_base_url: (providerForm as any).api_base_url || providerForm.api_base_url || undefined,
|
|
||||||
});
|
});
|
||||||
setEditingProvider(null);
|
setEditingProvider(null);
|
||||||
setProviderForm({ name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '' });
|
setProviderForm({ name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '' });
|
||||||
@@ -177,7 +205,7 @@ const Models: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteProvider = async (id: string) => {
|
const handleDeleteProvider = async (id: string) => {
|
||||||
if (!confirm('确定删除该供应商?')) return;
|
if (!confirm('确定删除该供应商?关联的模型也会受影响。')) return;
|
||||||
try {
|
try {
|
||||||
await adminAPI.deleteProvider(id);
|
await adminAPI.deleteProvider(id);
|
||||||
await loadData();
|
await loadData();
|
||||||
@@ -186,7 +214,6 @@ const Models: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch models from provider API
|
|
||||||
const handleFetchModels = async (providerId: string) => {
|
const handleFetchModels = async (providerId: string) => {
|
||||||
try {
|
try {
|
||||||
const result = await adminAPI.fetchProviderModels(providerId) as any;
|
const result = await adminAPI.fetchProviderModels(providerId) as any;
|
||||||
@@ -200,7 +227,6 @@ const Models: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add selected models
|
|
||||||
const handleAddModels = async (providerId: string) => {
|
const handleAddModels = async (providerId: string) => {
|
||||||
if (selectedModelIds.size === 0) return;
|
if (selectedModelIds.size === 0) return;
|
||||||
try {
|
try {
|
||||||
@@ -214,19 +240,17 @@ const Models: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Toggle model selection
|
|
||||||
const toggleModelSelection = (id: string) => {
|
const toggleModelSelection = (id: string) => {
|
||||||
const newSet = new Set(selectedModelIds);
|
const next = new Set(selectedModelIds);
|
||||||
if (newSet.has(id)) newSet.delete(id);
|
next.has(id) ? next.delete(id) : next.add(id);
|
||||||
else newSet.add(id);
|
setSelectedModelIds(next);
|
||||||
setSelectedModelIds(newSet);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Open model editor
|
|
||||||
const openModelEditor = (model?: AIModel, providerId?: string) => {
|
const openModelEditor = (model?: AIModel, providerId?: string) => {
|
||||||
if (model) {
|
if (model) {
|
||||||
setEditingModel({
|
setEditingModel({
|
||||||
id: model.id, name: model.name,
|
id: model.id, name: model.name,
|
||||||
|
display_name: (model as any).display_name || (model as any).displayName || model.name,
|
||||||
provider_id: (model as any).provider_id || model.providerId || providerId || '',
|
provider_id: (model as any).provider_id || model.providerId || providerId || '',
|
||||||
api_key: (model as any).api_key || model.apiKey || '',
|
api_key: (model as any).api_key || model.apiKey || '',
|
||||||
endpoint: model.endpoint || '',
|
endpoint: model.endpoint || '',
|
||||||
@@ -238,7 +262,7 @@ const Models: React.FC = () => {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setEditingModel({
|
setEditingModel({
|
||||||
name: '', provider_id: providerId || '',
|
name: '', display_name: '', provider_id: providerId || '',
|
||||||
api_key: '', endpoint: '',
|
api_key: '', endpoint: '',
|
||||||
capabilities: ['text'], timeout: 60,
|
capabilities: ['text'], timeout: 60,
|
||||||
status: 'paid', price_per_million_tokens: 0, is_enabled: true,
|
status: 'paid', price_per_million_tokens: 0, is_enabled: true,
|
||||||
@@ -247,7 +271,6 @@ const Models: React.FC = () => {
|
|||||||
setShowModelEditor(true);
|
setShowModelEditor(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save model
|
|
||||||
const handleSaveModel = async (e: React.FormEvent) => {
|
const handleSaveModel = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!editingModel || !editingModel.name.trim()) return;
|
if (!editingModel || !editingModel.name.trim()) return;
|
||||||
@@ -267,7 +290,6 @@ const Models: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Delete model
|
|
||||||
const handleDeleteModel = async (modelId: string) => {
|
const handleDeleteModel = async (modelId: string) => {
|
||||||
if (!confirm('确定删除该模型?')) return;
|
if (!confirm('确定删除该模型?')) return;
|
||||||
try {
|
try {
|
||||||
@@ -278,33 +300,22 @@ const Models: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Toggle capability
|
|
||||||
const toggleCapability = (cap: string) => {
|
const toggleCapability = (cap: string) => {
|
||||||
if (!editingModel) return;
|
if (!editingModel) return;
|
||||||
const caps = editingModel.capabilities || [];
|
const caps = editingModel.capabilities || [];
|
||||||
if (caps.includes(cap)) {
|
editingModel.capabilities = caps.includes(cap) ? caps.filter(c => c !== cap) : [...caps, cap];
|
||||||
editingModel.capabilities = caps.filter(c => c !== cap);
|
|
||||||
} else {
|
|
||||||
editingModel.capabilities = [...caps, cap];
|
|
||||||
}
|
|
||||||
setEditingModel({ ...editingModel });
|
setEditingModel({ ...editingModel });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Group models by provider
|
|
||||||
const getProviderModels = (providerId: string) =>
|
const getProviderModels = (providerId: string) =>
|
||||||
models.filter(m => ((m as any).provider_id || m.providerId) === providerId);
|
filteredModels.filter(m => ((m as any).provider_id || m.providerId) === providerId);
|
||||||
|
|
||||||
const getProviderName = (providerId: string) => {
|
|
||||||
const p = providers.find(p => p.id === providerId);
|
|
||||||
return p ? p.name : providerId;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
|
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 600 }}>大模型配置</h2>
|
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 600 }}>大模型配置</h2>
|
||||||
<p style={{ margin: '4px 0 0', color: '#666', fontSize: '13px' }}>
|
<p style={{ margin: '4px 0 0', color: '#8892B0', fontSize: '13px' }}>
|
||||||
管理 AI 模型供应商、模型配置和连接测试
|
管理 AI 模型供应商、模型配置和连接测试
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -314,14 +325,37 @@ const Models: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<ModelStatsPanel models={models} providers={providers} />
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div style={{ ...alertStyle('#FEE2E2', '#DC2626'), marginBottom: '16px' }}>
|
<div style={{ ...alertStyle('#FEE2E2', '#DC2626'), marginBottom: '16px' }}>
|
||||||
{error} <button onClick={() => setError(null)} style={{ float: 'right', background: 'none', border: 'none', cursor: 'pointer', fontSize: '16px' }}>×</button>
|
{error} <button onClick={() => setError(null)} style={{ float: 'right', background: 'none', border: 'none', cursor: 'pointer', fontSize: '16px' }}>×</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div style={{ marginBottom: 16, display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||||
|
<div style={{ position: 'relative', flex: 1, maxWidth: 360 }}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9CA3AF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)' }}>
|
||||||
|
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text" placeholder="搜索模型名称..."
|
||||||
|
value={modelSearch} onChange={e => setModelSearch(e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '8px 12px 8px 36px', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', fontSize: '13px', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{modelSearch && (
|
||||||
|
<span style={{ fontSize: 12, color: '#6B7280' }}>
|
||||||
|
找到 {filteredModels.length} 个模型
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ textAlign: 'center', padding: '60px', color: '#999' }}>加载中...</div>
|
<div style={{ textAlign: 'center', padding: '60px', color: '#6B7280' }}>加载中...</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
{providers.map(provider => {
|
{providers.map(provider => {
|
||||||
@@ -329,8 +363,7 @@ const Models: React.FC = () => {
|
|||||||
const isExpanded = expandedProvider === provider.id;
|
const isExpanded = expandedProvider === provider.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={provider.id} style={{ background: '#fff', borderRadius: '8px', border: '1px solid #E5E7EB', overflow: 'hidden' }}>
|
<div key={provider.id} style={{ background: 'rgba(17,22,39,0.5)', borderRadius: '10px', border: '1px solid rgba(255,255,255,0.08)', overflow: 'hidden' }}>
|
||||||
{/* Provider Header */}
|
|
||||||
<div style={{ padding: '16px 20px', background: '#F9FAFB', borderBottom: '1px solid #E5E7EB', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ padding: '16px 20px', background: '#F9FAFB', borderBottom: '1px solid #E5E7EB', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
@@ -341,8 +374,9 @@ const Models: React.FC = () => {
|
|||||||
{provModels.filter(m => m.isDefault).length > 0 && (
|
{provModels.filter(m => m.isDefault).length > 0 && (
|
||||||
<span style={{ fontSize: '11px', padding: '2px 8px', background: '#DCFCE7', color: '#16A34A', borderRadius: '4px' }}>默认</span>
|
<span style={{ fontSize: '11px', padding: '2px 8px', background: '#DCFCE7', color: '#16A34A', borderRadius: '4px' }}>默认</span>
|
||||||
)}
|
)}
|
||||||
|
<span style={{ fontSize: '11px', color: '#9CA3AF' }}>{provModels.length} 个模型</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
|
<div style={{ fontSize: '12px', color: '#8892B0', marginTop: '4px' }}>
|
||||||
API: {(provider as any).api_base_url || provider.apiBaseUrl || '未配置'}
|
API: {(provider as any).api_base_url || provider.apiBaseUrl || '未配置'}
|
||||||
{(provider as any).api_key || provider.apiKey ? ' ✓ 已配置Key' : ' ✗ 未配置Key'}
|
{(provider as any).api_key || provider.apiKey ? ' ✓ 已配置Key' : ' ✗ 未配置Key'}
|
||||||
</div>
|
</div>
|
||||||
@@ -357,7 +391,6 @@ const Models: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model Fetch Panel */}
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div style={{ padding: '16px 20px', background: '#FFFBEB', borderBottom: '1px solid #FDE68A' }}>
|
<div style={{ padding: '16px 20px', background: '#FFFBEB', borderBottom: '1px solid #FDE68A' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||||
@@ -372,7 +405,8 @@ const Models: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
{fetchedModels.map(m => (
|
{fetchedModels.map(m => (
|
||||||
<label key={m.id} style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '13px', cursor: 'pointer', padding: '4px 8px', background: selectedModelIds.has(m.id) ? '#EDE9FE' : '#fff', border: '1px solid #D1D5DB', borderRadius: '4px' }}>
|
<label key={m.id} style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '13px', cursor: 'pointer',
|
||||||
|
padding: '4px 8px', background: selectedModelIds.has(m.id) ? 'rgba(108,99,255,0.15)' : 'rgba(17,22,39,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px' }}>
|
||||||
<input type="checkbox" checked={selectedModelIds.has(m.id)} onChange={() => toggleModelSelection(m.id)} />
|
<input type="checkbox" checked={selectedModelIds.has(m.id)} onChange={() => toggleModelSelection(m.id)} />
|
||||||
{m.name || m.id}
|
{m.name || m.id}
|
||||||
</label>
|
</label>
|
||||||
@@ -381,17 +415,17 @@ const Models: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Models List */}
|
|
||||||
<div style={{ padding: '0' }}>
|
<div style={{ padding: '0' }}>
|
||||||
{provModels.length === 0 ? (
|
{provModels.length === 0 ? (
|
||||||
<div style={{ padding: '24px', textAlign: 'center', color: '#999', fontSize: '13px' }}>
|
<div style={{ padding: '24px', textAlign: 'center', color: '#6B7280', fontSize: '13px' }}>
|
||||||
暂无模型,点击"获取模型"或"添加模型"添加
|
暂无模型,点击"获取模型"或"添加模型"添加
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ background: '#F3F4F6' }}>
|
<tr style={{ background: '#F3F4F6' }}>
|
||||||
<th style={thStyle}>模型</th>
|
<th style={thStyle}>模型名称</th>
|
||||||
|
<th style={thStyle}>显示名称</th>
|
||||||
<th style={thStyle}>能力</th>
|
<th style={thStyle}>能力</th>
|
||||||
<th style={thStyle}>状态</th>
|
<th style={thStyle}>状态</th>
|
||||||
<th style={thStyle}>价格</th>
|
<th style={thStyle}>价格</th>
|
||||||
@@ -404,7 +438,13 @@ const Models: React.FC = () => {
|
|||||||
<tr key={model.id} style={{ borderBottom: '1px solid #F3F4F6' }}>
|
<tr key={model.id} style={{ borderBottom: '1px solid #F3F4F6' }}>
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
<div style={{ fontWeight: 500 }}>{model.name}</div>
|
<div style={{ fontWeight: 500 }}>{model.name}</div>
|
||||||
{model.isDefault && <span style={{ fontSize: '11px', color: '#16A34A' }}>⭐ 默认模型</span>}
|
<div style={{ fontSize: 11, color: '#9CA3AF', fontFamily: 'var(--font-mono)' }}>{model.id?.slice(0, 8)}...</div>
|
||||||
|
</td>
|
||||||
|
<td style={tdStyle}>
|
||||||
|
<span style={{ fontSize: 13, color: '#374151' }}>
|
||||||
|
{(model as any).display_name || (model as any).displayName || '-'}
|
||||||
|
</span>
|
||||||
|
{model.isDefault && <span style={{ fontSize: '11px', color: '#16A34A', marginLeft: 6 }}>⭐ 默认</span>}
|
||||||
</td>
|
</td>
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
|
||||||
@@ -421,34 +461,28 @@ const Models: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
<span style={{ fontSize: '12px', color: '#666' }}>
|
<span style={{ fontSize: '12px', color: '#8892B0' }}>
|
||||||
¥{model.pricePerMillionTokens?.toFixed(4) || '0.0000'}/M
|
¥{(model.pricePerMillionTokens || 0).toFixed(4)}/M
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
<span style={{ fontSize: '12px', color: '#666' }}>
|
<div style={{ fontSize: '13px', fontWeight: 500, color: '#374151' }}>
|
||||||
{formatNumber(model.totalCalls || 0)}
|
{formatNumber(model.totalCalls || 0)}
|
||||||
</span>
|
</div>
|
||||||
|
<div style={{ fontSize: '10px', color: '#9CA3AF' }}>
|
||||||
|
{formatNumber(model.totalTokens || 0)} tokens
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
|
||||||
{/* Test Connection Button */}
|
|
||||||
<button onClick={() => handleTestConnection(model.id)} disabled={testingModelId === model.id}
|
<button onClick={() => handleTestConnection(model.id)} disabled={testingModelId === model.id}
|
||||||
style={{ ...btnStyle(testingModelId === model.id ? '#9CA3AF' : '#059669', 'small') }}>
|
style={{ ...btnStyle(testingModelId === model.id ? '#9CA3AF' : '#059669', 'small') }}>
|
||||||
{testingModelId === model.id ? '测试中...' : '连接测试'}
|
{testingModelId === model.id ? '测试中...' : '测试'}
|
||||||
</button>
|
</button>
|
||||||
{/* Test Result Popover */}
|
{testResult && testingModelId === null && (
|
||||||
{testResult && (
|
<span style={{ fontSize: '11px', padding: '4px 8px', background: testResult.success ? '#DCFCE7' : '#FEE2E2', color: testResult.success ? '#16A34A' : '#DC2626', borderRadius: '4px' }}>
|
||||||
<div style={{ position: 'relative' }}>
|
{testResult.success ? `✓ ${testResult.latency_ms || 0}ms` : '✗ 失败'}
|
||||||
<span style={{ fontSize: '11px', padding: '4px 8px', background: testResult.success ? '#DCFCE7' : '#FEE2E2', color: testResult.success ? '#16A34A' : '#DC2626', borderRadius: '4px', display: 'inline-block' }}>
|
|
||||||
{testResult.success ? '✓ 成功' : '✗ 失败'}
|
|
||||||
{testResult.latency_ms && ` (${testResult.latency_ms}ms)`}
|
|
||||||
</span>
|
</span>
|
||||||
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 10, background: '#fff', border: '1px solid #E5E7EB', borderRadius: '6px', padding: '8px 12px', fontSize: '12px', color: '#333', minWidth: '200px', boxShadow: '0 4px 12px rgba(0,0,0,0.1)', marginTop: '4px' }}>
|
|
||||||
{testResult.message}
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setTestResult(null)} style={{ marginLeft: '8px', fontSize: '11px', color: '#999', background: 'none', border: 'none', cursor: 'pointer' }}>×</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{!model.isDefault && model.isEnabled && (
|
{!model.isDefault && model.isEnabled && (
|
||||||
<button onClick={() => handleSetDefault(model.id)} style={btnStyle('#5B5FED', 'small')}>设为默认</button>
|
<button onClick={() => handleSetDefault(model.id)} style={btnStyle('#5B5FED', 'small')}>设为默认</button>
|
||||||
@@ -468,9 +502,9 @@ const Models: React.FC = () => {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{providers.length === 0 && (
|
{providers.length === 0 && (
|
||||||
<div style={{ textAlign: 'center', padding: '60px', background: '#fff', borderRadius: '8px', border: '1px solid #E5E7EB' }}>
|
<div style={{ textAlign: 'center', padding: '60px', background: 'rgba(17,22,39,0.5)', borderRadius: '10px', border: '1px solid rgba(255,255,255,0.08)' }}>
|
||||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🤖</div>
|
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🤖</div>
|
||||||
<p style={{ color: '#666', marginBottom: '16px' }}>暂无模型供应商,点击上方按钮添加</p>
|
<p style={{ color: '#8892B0', marginBottom: '16px' }}>暂无模型供应商,点击上方按钮添加</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -478,8 +512,8 @@ const Models: React.FC = () => {
|
|||||||
|
|
||||||
{/* Provider Modal */}
|
{/* Provider Modal */}
|
||||||
{showAddProvider && (
|
{showAddProvider && (
|
||||||
<div style={modalOverlay}>
|
<div style={modalOverlay} onClick={() => { setShowAddProvider(false); setEditingProvider(null); }}>
|
||||||
<div style={modalContent}>
|
<div style={modalContent} onClick={e => e.stopPropagation()}>
|
||||||
<h3 style={{ marginTop: 0 }}>{editingProvider ? '编辑供应商' : '添加供应商'}</h3>
|
<h3 style={{ marginTop: 0 }}>{editingProvider ? '编辑供应商' : '添加供应商'}</h3>
|
||||||
<form onSubmit={editingProvider ? handleUpdateProvider : handleAddProvider}>
|
<form onSubmit={editingProvider ? handleUpdateProvider : handleAddProvider}>
|
||||||
<div style={formGroup}>
|
<div style={formGroup}>
|
||||||
@@ -511,8 +545,8 @@ const Models: React.FC = () => {
|
|||||||
|
|
||||||
{/* Model Editor Modal */}
|
{/* Model Editor Modal */}
|
||||||
{showModelEditor && editingModel && (
|
{showModelEditor && editingModel && (
|
||||||
<div style={modalOverlay}>
|
<div style={modalOverlay} onClick={() => setShowModelEditor(false)}>
|
||||||
<div style={{ ...modalContent, maxWidth: '600px' }}>
|
<div style={{ ...modalContent, maxWidth: '600px' }} onClick={e => e.stopPropagation()}>
|
||||||
<h3 style={{ marginTop: 0 }}>{editingModel.id ? '编辑模型' : '添加模型'}</h3>
|
<h3 style={{ marginTop: 0 }}>{editingModel.id ? '编辑模型' : '添加模型'}</h3>
|
||||||
<form onSubmit={handleSaveModel}>
|
<form onSubmit={handleSaveModel}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||||
@@ -520,13 +554,17 @@ const Models: React.FC = () => {
|
|||||||
<label style={labelStyle}>模型名称 *</label>
|
<label style={labelStyle}>模型名称 *</label>
|
||||||
<input value={editingModel.name} onChange={e => setEditingModel({ ...editingModel, name: e.target.value })} placeholder="如:DeepSeek-V4-Flash" style={inputStyle} required />
|
<input value={editingModel.name} onChange={e => setEditingModel({ ...editingModel, name: e.target.value })} placeholder="如:DeepSeek-V4-Flash" style={inputStyle} required />
|
||||||
</div>
|
</div>
|
||||||
|
<div style={formGroup}>
|
||||||
|
<label style={labelStyle}>显示名称</label>
|
||||||
|
<input value={editingModel.display_name} onChange={e => setEditingModel({ ...editingModel, display_name: e.target.value })} placeholder="如:DeepSeek V4" style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div style={formGroup}>
|
<div style={formGroup}>
|
||||||
<label style={labelStyle}>供应商</label>
|
<label style={labelStyle}>供应商</label>
|
||||||
<select value={editingModel.provider_id} onChange={e => setEditingModel({ ...editingModel, provider_id: e.target.value })} style={inputStyle}>
|
<select value={editingModel.provider_id} onChange={e => setEditingModel({ ...editingModel, provider_id: e.target.value })} style={inputStyle}>
|
||||||
{providers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
{providers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div style={formGroup}>
|
<div style={formGroup}>
|
||||||
<label style={labelStyle}>API Key</label>
|
<label style={labelStyle}>API Key</label>
|
||||||
<input type="password" value={editingModel.api_key} onChange={e => setEditingModel({ ...editingModel, api_key: e.target.value })} placeholder="sk-... (留空则使用供应商的Key)" style={inputStyle} />
|
<input type="password" value={editingModel.api_key} onChange={e => setEditingModel({ ...editingModel, api_key: e.target.value })} placeholder="sk-... (留空则使用供应商的Key)" style={inputStyle} />
|
||||||
@@ -539,7 +577,10 @@ const Models: React.FC = () => {
|
|||||||
<label style={labelStyle}>模型能力 (可多选)</label>
|
<label style={labelStyle}>模型能力 (可多选)</label>
|
||||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||||
{Object.entries(capabilityLabels).map(([key, label]) => (
|
{Object.entries(capabilityLabels).map(([key, label]) => (
|
||||||
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer', padding: '4px 10px', background: (editingModel.capabilities || []).includes(key) ? capabilityColors[key] + '22' : '#F3F4F6', border: `1px solid ${(editingModel.capabilities || []).includes(key) ? capabilityColors[key] : '#D1D5DB'}`, borderRadius: '4px', fontSize: '13px' }}>
|
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer',
|
||||||
|
padding: '4px 10px', background: (editingModel.capabilities || []).includes(key) ? capabilityColors[key] + '22' : '#F3F4F6',
|
||||||
|
border: `1px solid ${(editingModel.capabilities || []).includes(key) ? capabilityColors[key] : '#D1D5DB'}`,
|
||||||
|
borderRadius: '4px', fontSize: '13px' }}>
|
||||||
<input type="checkbox" checked={(editingModel.capabilities || []).includes(key)} onChange={() => toggleCapability(key)} style={{ display: 'none' }} />
|
<input type="checkbox" checked={(editingModel.capabilities || []).includes(key)} onChange={() => toggleCapability(key)} style={{ display: 'none' }} />
|
||||||
<span style={{ color: capabilityColors[key] }}>●</span> {label}
|
<span style={{ color: capabilityColors[key] }}>●</span> {label}
|
||||||
</label>
|
</label>
|
||||||
@@ -585,16 +626,16 @@ const Models: React.FC = () => {
|
|||||||
const btnStyle = (color: string, size: 'normal' | 'small' = 'normal'): React.CSSProperties => ({
|
const btnStyle = (color: string, size: 'normal' | 'small' = 'normal'): React.CSSProperties => ({
|
||||||
background: color, color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer',
|
background: color, color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer',
|
||||||
fontSize: size === 'small' ? '12px' : '14px', padding: size === 'small' ? '4px 10px' : '8px 16px',
|
fontSize: size === 'small' ? '12px' : '14px', padding: size === 'small' ? '4px 10px' : '8px 16px',
|
||||||
fontWeight: 500, transition: 'opacity 0.2s',
|
fontWeight: 500, transition: 'opacity 0.2s', whiteSpace: 'nowrap' as const,
|
||||||
});
|
});
|
||||||
|
|
||||||
const thStyle: React.CSSProperties = { padding: '10px 16px', textAlign: 'left', fontSize: '12px', color: '#6B7280', fontWeight: 600 };
|
const thStyle: React.CSSProperties = { padding: '10px 16px', textAlign: 'left', fontSize: '12px', color: '#6B7280', fontWeight: 600 };
|
||||||
const tdStyle: React.CSSProperties = { padding: '12px 16px', fontSize: '13px', verticalAlign: 'middle' };
|
const tdStyle: React.CSSProperties = { padding: '12px 16px', fontSize: '13px', verticalAlign: 'middle' };
|
||||||
const formGroup: React.CSSProperties = { marginBottom: '16px' };
|
const formGroup: React.CSSProperties = { marginBottom: '16px' };
|
||||||
const labelStyle: React.CSSProperties = { display: 'block', marginBottom: '6px', fontSize: '13px', fontWeight: 500, color: '#374151' };
|
const labelStyle: React.CSSProperties = { display: 'block', marginBottom: '6px', fontSize: '13px', fontWeight: 500, color: '#374151' };
|
||||||
const inputStyle: React.CSSProperties = { width: '100%', padding: '8px 12px', border: '1px solid #D1D5DB', borderRadius: '6px', fontSize: '13px', boxSizing: 'border-box' };
|
const inputStyle: React.CSSProperties = { width: '100%', padding: '8px 12px', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', fontSize: '13px', boxSizing: 'border-box' };
|
||||||
const alertStyle = (bg: string, color: string): React.CSSProperties => ({ background: bg, color, padding: '10px 16px', borderRadius: '6px', fontSize: '13px' });
|
const alertStyle = (bg: string, color: string): React.CSSProperties => ({ background: bg, color, padding: '10px 16px', borderRadius: '6px', fontSize: '13px' });
|
||||||
const modalOverlay: React.CSSProperties = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 };
|
const modalOverlay: React.CSSProperties = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 };
|
||||||
const modalContent: React.CSSProperties = { background: '#fff', borderRadius: '12px', padding: '24px', width: '480px', maxWidth: '90vw', maxHeight: '90vh', overflow: 'auto', boxSizing: 'border-box' };
|
const modalContent: React.CSSProperties = { background: 'rgba(17,22,39,0.5)', borderRadius: '12px', padding: '24px', width: '480px', maxWidth: '90vw', maxHeight: '90vh', overflow: 'auto', boxSizing: 'border-box' };
|
||||||
|
|
||||||
export default Models;
|
export default Models;
|
||||||
Reference in new issue
Block a user