From 6d12ee9ff9e31acb770754b63da74763b4ff0cf1 Mon Sep 17 00:00:00 2001 From: qyyfw Date: Sun, 5 Jul 2026 19:02:22 +0800 Subject: [PATCH] Phase C2: Admin UI Refactoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/App.tsx | 335 ++---- .../src/components/layout/AdminLayout.css | 979 ++++++++++++++---- .../src/components/layout/AdminLayout.tsx | 365 ++++--- frontend/src/pages/admin/Dashboard.tsx | 276 +++-- frontend/src/pages/admin/Models.tsx | 241 +++-- 5 files changed, 1416 insertions(+), 780 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f643b65..bf4fffa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 type { User } from '@/types'; - import { AuthContext, AuthContextType } from '@/contexts/AuthContext'; -import { authAPI } from '@/api'; - - - -// Layouts - +// Layouts - eagerly loaded (needed immediately) import FrontLayout from '@/components/layout/FrontLayout'; - 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 - -import LandingPage from '@/pages/front/LandingPage'; - -import LoginPage from '@/pages/front/LoginPage'; - -import ChatPage from '@/pages/front/ChatPage'; - -import ChannelPage from '@/pages/front/ChannelPage'; - -import KnowledgePage from '@/pages/front/KnowledgePage'; - -import MarketPage from '@/pages/front/MarketPage'; - -import TokenDashboardPage from '@/pages/front/TokenDashboardPage'; - -import PricingPage from '@/pages/front/PricingPage'; - -import ProfilePage from '@/pages/front/ProfilePage'; - -import NotificationPage from '@/pages/front/NotificationPage'; - -import AssetsPage from '@/pages/front/AssetsPage'; - -import NovelPreviewPage from '@/pages/front/NovelPreviewPage'; - -import EvolutionDashboard from '@/pages/front/EvolutionDashboard'; - - - -// Admin Pages - -import AdminDashboard from '@/pages/admin/Dashboard'; - -import AdminUsers from '@/pages/admin/Users'; - -import AdminModels from '@/pages/admin/Models'; - -import AdminSiteSettings from '@/pages/admin/SiteSettings'; - -import AdminPayment from '@/pages/admin/Payment'; - -import AdminServices from '@/pages/admin/Services'; - -import AdminMembership from '@/pages/admin/Membership'; - -import AdminAds from '@/pages/admin/Ads'; - -import AdminMarket from '@/pages/admin/Market'; - -import AdminLogs from '@/pages/admin/Logs'; - -import AdminSkills from '@/pages/admin/Skills' -import AdminAgents from '@/pages/admin/Agents' -import AdminKnowledge from '@/pages/admin/Knowledge'; -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';; - - +// Loading fallback +const PageLoading: React.FC = () => ( +
+
+ 加载中... +
+); const App: React.FC = () => { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); - - - // 初始化:从localStorage恢复登录状态 - useEffect(() => { - const token = localStorage.getItem('aiflow_token'); - const cachedUser = localStorage.getItem('aiflow_user'); - if (token && cachedUser) { - - try { - - setUser(JSON.parse(cachedUser)); - - } catch { - + try { setUser(JSON.parse(cachedUser)); } catch { localStorage.removeItem('aiflow_token'); - localStorage.removeItem('aiflow_user'); - } - } - setLoading(false); - }, []); - - - const login = (u: User) => { - - setUser(u); - - localStorage.setItem('aiflow_user', JSON.stringify(u)); - - }; - - const logout = () => { - - setUser(null); - - localStorage.removeItem('aiflow_token'); - - localStorage.removeItem('aiflow_user'); - - }; - + const login = (u: User) => { setUser(u); localStorage.setItem('aiflow_user', JSON.stringify(u)); }; + const logout = () => { setUser(null); localStorage.removeItem('aiflow_token'); localStorage.removeItem('aiflow_user'); }; const isAdmin = user?.role === 'admin'; - - - if (loading) { - - return
加载中...
; - - } - - - - return ( - - - - - - {/* Login */} - - : ) : - - } /> - - - - {/* Root - Landing for guests, redirect for authenticated users */} - - : ) : - - } /> - - - - {/* Front Routes (authenticated only) */} - - }> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - - - - - {/* Admin Routes */} - - }> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - } /> - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - - } /> - - - - - + if (loading) return ( +
+
+ 加载中... +
); + return ( + + }> + + : ) : } /> + : ) : } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + }> + } /> + {/* 用户体系 */} + } /> + } /> + {/* AI能力 */} + } /> + } /> + } /> + } /> + } /> + {/* 业务运营 */} + } /> + } /> + } /> + } /> + } /> + {/* 系统设置 */} + } /> + } /> + } /> + } /> + } /> + + } /> + + + + ); }; - - export default App; - diff --git a/frontend/src/components/layout/AdminLayout.css b/frontend/src/components/layout/AdminLayout.css index 89d0fb3..ee28297 100644 --- a/frontend/src/components/layout/AdminLayout.css +++ b/frontend/src/components/layout/AdminLayout.css @@ -1,21 +1,32 @@ -/* ===== Admin Layout ===== */ +/* ===== Admin Layout V4 - Dark Theme (R2 Refactored) ===== */ + +/* CSS Variables scoped to admin layout */ +:root { + --admin-sidebar-width: 240px; + --sidebar-collapsed: 64px; +} + +/* ========== Layout Container ========== */ .admin-layout { display: flex; height: 100vh; overflow: hidden; - background: #F1F3F7; + background: #0A0E1A; + color: #E8ECF4; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif; } -/* Admin Sidebar */ +/* ========== Admin Sidebar ========== */ .admin-sidebar { width: var(--admin-sidebar-width); min-width: var(--admin-sidebar-width); - background: #1A1A2E; + background: linear-gradient(180deg, #0E1225 0%, #111627 100%); + border-right: 1px solid rgba(255, 255, 255, 0.06); display: flex; flex-direction: column; - transition: width var(--transition-slow), min-width var(--transition-slow); + transition: width 0.25s ease, min-width 0.25s ease; overflow: hidden; - z-index: var(--z-sticky); + z-index: 100; } .admin-sidebar.collapsed { @@ -23,372 +34,896 @@ min-width: var(--sidebar-collapsed); } +/* Sidebar Header */ .admin-sidebar-header { display: flex; align-items: center; justify-content: space-between; - padding: var(--space-lg); - height: var(--header-height); - border-bottom: 1px solid rgba(255,255,255,0.06); + padding: 16px 16px 16px 20px; + height: 60px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + flex-shrink: 0; +} + +.sidebar-logo { + display: flex; + align-items: center; + gap: 10px; } .admin-brand { - color: #E0E2F0 !important; - font-size: var(--text-base) !important; + font-size: 15px; + font-weight: 700; + color: #E8ECF4; + letter-spacing: -0.02em; + white-space: nowrap; + background: linear-gradient(135deg, #C4B5FD, #A78BFA); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; } -.admin-sidebar .collapse-btn { - color: rgba(255,255,255,0.4); -} -.admin-sidebar .collapse-btn:hover { - background: rgba(255,255,255,0.08); - color: rgba(255,255,255,0.8); +.collapse-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 6px; + border: none; + background: transparent; + color: #5A6480; + transition: all 0.15s ease; + cursor: pointer; + flex-shrink: 0; } -/* Admin Nav */ +.collapse-btn:hover { + background: rgba(255, 255, 255, 0.06); + color: #A78BFA; +} + +.admin-sidebar.collapsed .admin-sidebar-header { + padding: 16px 12px; + justify-content: center; +} + +.admin-sidebar.collapsed .admin-brand { + display: none; +} + +/* Admin Navigation */ .admin-nav { flex: 1; - padding: var(--space-sm); + padding: 8px; overflow-y: auto; - display: flex; - flex-direction: column; - gap: 2px; + overflow-x: hidden; } +.admin-nav::-webkit-scrollbar { + width: 4px; +} + +.admin-nav::-webkit-scrollbar-track { + background: transparent; +} + +.admin-nav::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.08); + border-radius: 2px; +} + +/* Nav Group */ +.nav-group { + margin-bottom: 2px; +} + +.nav-group-header { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + cursor: pointer; + border-radius: 8px; + transition: all 0.15s ease; + user-select: none; +} + +.nav-group-header:hover { + background: rgba(255, 255, 255, 0.04); +} + +.nav-group-label { + font-size: 11px; + font-weight: 700; + color: #4A5578; + text-transform: uppercase; + letter-spacing: 0.1em; + flex: 1; +} + +.active-group .nav-group-label, +.nav-group:hover .nav-group-label { + color: #6B7280; +} + +.nav-group-chevron { + color: #4A5578; + transition: transform 0.2s ease; + flex-shrink: 0; +} + +.nav-group-chevron.open { + transform: rotate(90deg); +} + +/* Nav Group Items */ +.nav-group-items { + display: flex; + flex-direction: column; + gap: 1px; + overflow: hidden; + transition: max-height 0.3s ease; +} + +.nav-group-items.collapsed { + max-height: 0 !important; +} + +/* Nav Item */ .admin-nav-item { display: flex; align-items: center; - gap: var(--space-md); - padding: var(--space-md) var(--space-md); - border-radius: var(--radius-md); - color: rgba(255,255,255,0.55); + gap: 10px; + padding: 9px 12px; + border-radius: 8px; + color: #8892B0; text-decoration: none; - font-size: var(--text-sm); + font-size: 13px; font-weight: 500; - transition: all var(--transition-fast); + transition: all 0.15s ease; white-space: nowrap; + position: relative; + border: none; + background: none; width: 100%; + cursor: pointer; text-align: left; } .admin-nav-item:hover { - background: rgba(255,255,255,0.06); - color: rgba(255,255,255,0.85); + background: rgba(255, 255, 255, 0.05); + color: #CCD6F6; } .admin-nav-item.active { - background: rgba(107, 99, 255, 0.2); - color: #8B8DFF; + background: rgba(108, 99, 255, 0.1); + color: #8B85FF; } -.admin-sidebar-footer { - padding: var(--space-sm); - border-top: 1px solid rgba(255,255,255,0.06); +.nav-icon { display: flex; - flex-direction: column; - gap: 2px; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + flex-shrink: 0; } -.logout-btn { - color: rgba(255,255,255,0.35) !important; -} -.logout-btn:hover { - color: #FF6B6B !important; - background: rgba(255,107,107,0.1) !important; -} - -/* Admin Main */ -.admin-main { - flex: 1; - display: flex; - flex-direction: column; - min-width: 0; +.nav-label { overflow: hidden; + text-overflow: ellipsis; } +/* Active indicator dot */ +.nav-active-dot { + position: absolute; + right: 8px; + width: 6px; + height: 6px; + border-radius: 50%; + background: #6C63FF; + box-shadow: 0 0 8px rgba(108, 99, 255, 0.5); +} + +/* Logout button special style */ +.logout-btn:hover { + background: rgba(239, 68, 68, 0.12) !important; + color: #EF4444 !important; +} + +/* ========== Collapsed Mode ========== */ +.admin-sidebar.collapsed .nav-group-header { + justify-content: center; + padding: 8px; +} + +.admin-sidebar.collapsed .nav-group-label, +.admin-sidebar.collapsed .nav-group-chevron, +.admin-sidebar.collapsed .nav-group-items { + display: none; +} + +.admin-sidebar.collapsed .admin-nav-item { + justify-content: center; + padding: 10px; +} + +.admin-sidebar.collapsed .nav-label, +.admin-sidebar.collapsed .nav-active-dot { + display: none; +} + +/* Sidebar Footer */ +.admin-sidebar-footer { + padding: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + flex-shrink: 0; +} + +/* Version Info */ +.admin-version-info { + padding: 8px 16px 12px; + font-size: 11px; + color: #3A4460; + text-align: center; + border-top: 1px solid rgba(255, 255, 255, 0.04); +} + +/* ========== Top Bar ========== */ .admin-topbar { display: flex; align-items: center; justify-content: space-between; - height: var(--header-height); - padding: 0 var(--space-2xl); - background: #FFFFFF; - border-bottom: 1px solid var(--color-border); + padding: 0 32px; + height: 60px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(10, 14, 26, 0.85); + backdrop-filter: blur(12px); flex-shrink: 0; } .admin-topbar-left { display: flex; align-items: center; + gap: 24px; } -.admin-welcome { - font-size: var(--text-sm); - color: var(--color-text-secondary); +.admin-page-title { + font-size: 17px; + font-weight: 600; + color: #E8ECF4; + margin: 0; + white-space: nowrap; +} + +/* Breadcrumb */ +.admin-breadcrumb { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; +} + +.breadcrumb-item { + color: #5A6480; +} + +.breadcrumb-item.current { + color: #8892B0; + font-weight: 500; +} + +.breadcrumb-sep { + color: #3A4460; + margin: 0 4px; } .admin-topbar-right { display: flex; align-items: center; - gap: var(--space-lg); + gap: 16px; } -.admin-user-tag { - display: flex; - align-items: center; - gap: var(--space-sm); - font-size: var(--text-sm); - font-weight: 500; - color: var(--color-text-primary); +.admin-welcome { + font-size: 13px; + color: #6B7280; } .admin-topbar-action { - display: inline-flex; + display: flex; align-items: center; gap: 6px; - padding: 6px 14px; - border-radius: var(--radius-md); - border: 1px solid var(--color-border); - background: var(--color-bg-primary); - color: var(--color-accent); - font-size: var(--text-sm); - font-weight: 500; + padding: 6px 12px; + border-radius: 8px; + color: #6B7280; text-decoration: none; - cursor: pointer; - transition: all var(--transition-fast); + font-size: 13px; + transition: all 0.15s ease; } .admin-topbar-action:hover { - background: var(--color-accent); - color: #FFFFFF; - border-color: var(--color-accent); + background: rgba(255, 255, 255, 0.05); + color: #A78BFA; +} + +/* User tag in topbar */ +.admin-user-tag { + display: flex; + align-items: center; + gap: 8px; + padding: 2px 12px 2px 4px; + border-radius: 20px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + font-size: 13px; + color: #CCD6F6; } .admin-avatar { - background: #1A1A2E !important; + width: 28px; + height: 28px; + border-radius: 50%; + background: linear-gradient(135deg, #6C63FF, #A78BFA); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; +} + +/* ========== Main Content ========== */ +.admin-main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background: #0A0E1A; } -/* Admin Content Area */ .admin-content { flex: 1; overflow-y: auto; - padding: var(--space-2xl); + overflow-x: hidden; + padding: 32px; } -/* Admin Common Components */ -.admin-page-title { - font-size: var(--text-xl); +.admin-content::-webkit-scrollbar { + width: 6px; +} + +.admin-content::-webkit-scrollbar-track { + background: transparent; +} + +.admin-content::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.08); + border-radius: 3px; +} + +/* ========== Shared Admin Page Components ========== */ + +/* Stat cards grid */ +.admin-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.admin-stat-card { + background: linear-gradient(135deg, rgba(17, 22, 39, 0.8), rgba(17, 22, 39, 0.6)); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px; + padding: 20px; + transition: all 0.2s ease; +} + +.admin-stat-card:hover { + border-color: rgba(108, 99, 255, 0.3); + box-shadow: 0 4px 20px rgba(108, 99, 255, 0.1); + transform: translateY(-1px); +} + +.admin-stat-card .stat-label { + font-size: 13px; + color: #6B7280; + margin-bottom: 8px; +} + +.admin-stat-card .stat-value { + font-size: 28px; font-weight: 700; - color: var(--color-text-primary); - margin-bottom: var(--space-2xl); + color: #E8ECF4; + line-height: 1.2; } -.admin-card { - background: #FFFFFF; - border-radius: var(--radius-xl); - border: 1px solid var(--color-border); - padding: var(--space-2xl); - margin-bottom: var(--space-xl); +.admin-stat-card .stat-change { + font-size: 12px; + margin-top: 4px; } -.admin-card-header { +.admin-stat-card .stat-change.up { + color: #EF4444; +} + +.admin-stat-card .stat-change.down { + color: #10B981; +} + +/* Page section card */ +.admin-section { + background: rgba(17, 22, 39, 0.5); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px; + padding: 24px; + margin-bottom: 20px; +} + +.admin-section-header { display: flex; align-items: center; justify-content: space-between; - margin-bottom: var(--space-xl); + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); } -.admin-card-title { - font-size: var(--text-lg); +.admin-section-header h3 { + font-size: 16px; font-weight: 600; - color: var(--color-text-primary); + color: #E8ECF4; + margin: 0; } +/* Tab bar */ +.admin-tabs { + display: flex; + gap: 4px; + padding: 4px; + background: rgba(255, 255, 255, 0.03); + border-radius: 10px; + margin-bottom: 20px; +} + +.admin-tab { + padding: 8px 16px; + border-radius: 8px; + font-size: 13px; + font-weight: 500; + color: #6B7280; + cursor: pointer; + transition: all 0.15s ease; + border: none; + background: transparent; +} + +.admin-tab:hover { + color: #A78BFA; +} + +.admin-tab.active { + background: rgba(108, 99, 255, 0.15); + color: #A78BFA; +} + +/* Data tables */ .admin-table { width: 100%; border-collapse: collapse; } .admin-table th { - text-align: left; - padding: var(--space-md) var(--space-lg); - font-size: var(--text-xs); + padding: 12px 16px; + font-size: 12px; font-weight: 600; - color: var(--color-text-tertiary); + color: #6B7280; text-transform: uppercase; letter-spacing: 0.05em; - border-bottom: 1px solid var(--color-border); - background: var(--color-bg-tertiary); + text-align: left; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.02); } .admin-table td { - padding: var(--space-md) var(--space-lg); - font-size: var(--text-sm); - color: var(--color-text-primary); - border-bottom: 1px solid var(--color-border-light); + padding: 12px 16px; + font-size: 13px; + color: #8892B0; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); } .admin-table tr:hover td { - background: var(--color-bg-hover); + background: rgba(255, 255, 255, 0.03); } -.admin-badge { - display: inline-flex; - align-items: center; - padding: 2px 10px; - border-radius: var(--radius-full); - font-size: var(--text-xs); - font-weight: 600; - white-space: nowrap; -} - -.badge-success { background: var(--color-success-light); color: var(--color-success); } -.badge-warning { background: var(--color-warning-light); color: var(--color-warning); } -.badge-error { background: var(--color-error-light); color: var(--color-error); } -.badge-info { background: var(--color-info-light); color: var(--color-info); } -.badge-neutral { background: var(--color-bg-tertiary); color: var(--color-text-secondary); } - +/* Action buttons */ .admin-btn { display: inline-flex; align-items: center; - gap: var(--space-sm); - padding: var(--space-sm) var(--space-lg); - border-radius: var(--radius-md); - font-size: var(--text-sm); - font-weight: 600; - transition: all var(--transition-fast); + gap: 6px; + padding: 8px 16px; + border-radius: 8px; + font-size: 13px; + font-weight: 500; + border: none; + cursor: pointer; + transition: all 0.15s ease; white-space: nowrap; } .admin-btn-primary { - background: var(--color-accent); - color: #FFFFFF; -} -.admin-btn-primary:hover { - background: var(--color-accent-hover); + background: linear-gradient(135deg, #6C63FF, #8B85FF); + color: #fff; } -.admin-btn-outline { - background: transparent; - border: 1px solid var(--color-border); - color: var(--color-text-primary); +.admin-btn-primary:hover { + background: linear-gradient(135deg, #7B73FF, #9B95FF); + box-shadow: 0 2px 12px rgba(108, 99, 255, 0.3); } -.admin-btn-outline:hover { - background: var(--color-bg-hover); - border-color: var(--color-border-hover); + +.admin-btn-secondary { + background: rgba(255, 255, 255, 0.06); + color: #8892B0; + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.admin-btn-secondary:hover { + background: rgba(255, 255, 255, 0.1); + color: #CCD6F6; } .admin-btn-danger { - background: transparent; - border: 1px solid var(--color-error); - color: var(--color-error); + background: rgba(239, 68, 68, 0.1); + color: #EF4444; + border: 1px solid rgba(239, 68, 68, 0.2); } + .admin-btn-danger:hover { - background: var(--color-error-light); + background: rgba(239, 68, 68, 0.2); +} + +.admin-btn-success { + background: rgba(16, 185, 129, 0.1); + color: #10B981; + border: 1px solid rgba(16, 185, 129, 0.2); +} + +.admin-btn-success:hover { + background: rgba(16, 185, 129, 0.2); } .admin-btn-sm { - padding: 3px 10px; - font-size: var(--text-xs); + padding: 4px 10px; + font-size: 12px; + border-radius: 6px; } -/* Admin Stats */ -.admin-stats { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: var(--space-lg); - margin-bottom: var(--space-2xl); +/* Search + Filter bar */ +.admin-toolbar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; + flex-wrap: wrap; } -.admin-stat-card { - background: #FFFFFF; - border-radius: var(--radius-xl); - border: 1px solid var(--color-border); - padding: var(--space-xl); +.admin-search { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + color: #8892B0; + flex: 1; + min-width: 200px; + max-width: 360px; + transition: border-color 0.15s ease; } -.admin-stat-label { - font-size: var(--text-xs); - color: var(--color-text-tertiary); - font-weight: 500; - margin-bottom: var(--space-sm); - text-transform: uppercase; - letter-spacing: 0.05em; +.admin-search:focus-within { + border-color: rgba(108, 99, 255, 0.4); } -.admin-stat-value { - font-size: var(--text-2xl); - font-weight: 700; - color: var(--color-text-primary); -} - -/* Admin Form */ -.admin-form-group { - margin-bottom: var(--space-xl); -} - -.admin-form-label { - display: block; - font-size: var(--text-sm); - font-weight: 600; - color: var(--color-text-primary); - margin-bottom: var(--space-sm); -} - -.admin-form-input { - width: 100%; - padding: var(--space-md) var(--space-lg); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - font-size: var(--text-sm); - color: var(--color-text-primary); - background: #FFFFFF; - transition: border-color var(--transition-fast); +.admin-search input { + flex: 1; + border: none; + background: none; + color: #E8ECF4; + font-size: 13px; outline: none; } -.admin-form-input:focus { - border-color: var(--color-accent); - box-shadow: 0 0 0 3px var(--color-accent-light); +.admin-search input::placeholder { + color: #4A5578; } -.admin-form-input::placeholder { - color: var(--color-text-disabled); +/* Badge / Tag */ +.admin-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; } -.admin-form-textarea { - min-height: 100px; +.admin-badge-success { + background: rgba(16, 185, 129, 0.12); + color: #10B981; +} + +.admin-badge-warning { + background: rgba(245, 158, 11, 0.12); + color: #F59E0B; +} + +.admin-badge-danger { + background: rgba(239, 68, 68, 0.12); + color: #EF4444; +} + +.admin-badge-info { + background: rgba(108, 99, 255, 0.12); + color: #A78BFA; +} + +.admin-badge-neutral { + background: rgba(255, 255, 255, 0.06); + color: #6B7280; +} + +/* Empty state */ +.admin-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + color: #4A5578; +} + +.admin-empty svg { + margin-bottom: 16px; + opacity: 0.3; +} + +.admin-empty p { + font-size: 14px; + margin: 0; +} + +/* Modal / Dialog overrides */ +.admin-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.admin-modal { + background: #111627; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + padding: 24px; + min-width: 420px; + max-width: 640px; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +.admin-modal h3 { + font-size: 18px; + font-weight: 600; + color: #E8ECF4; + margin: 0 0 20px 0; +} + +/* Form elements in dark theme */ +.admin-form-group { + margin-bottom: 16px; +} + +.admin-form-group label { + display: block; + font-size: 13px; + font-weight: 500; + color: #8892B0; + margin-bottom: 6px; +} + +.admin-form-group input, +.admin-form-group select, +.admin-form-group textarea { + width: 100%; + padding: 10px 14px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + color: #E8ECF4; + font-size: 13px; + transition: border-color 0.15s ease; + outline: none; resize: vertical; } -.admin-form-row { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: var(--space-lg); +.admin-form-group input:focus, +.admin-form-group select:focus, +.admin-form-group textarea:focus { + border-color: rgba(108, 99, 255, 0.4); + background: rgba(255, 255, 255, 0.06); } -.admin-form-actions { +.admin-form-group select { + cursor: pointer; +} + +.admin-form-group select option { + background: #111627; + color: #E8ECF4; +} + +/* Toggle switch */ +.admin-toggle { + position: relative; + display: inline-block; + width: 44px; + height: 24px; +} + +.admin-toggle input { + opacity: 0; + width: 0; + height: 0; +} + +.admin-toggle .toggle-slider { + position: absolute; + cursor: pointer; + inset: 0; + background: #3A4460; + border-radius: 12px; + transition: 0.2s; +} + +.admin-toggle .toggle-slider::before { + content: ''; + position: absolute; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background: #fff; + border-radius: 50%; + transition: 0.2s; +} + +.admin-toggle input:checked + .toggle-slider { + background: #6C63FF; +} + +.admin-toggle input:checked + .toggle-slider::before { + transform: translateX(20px); +} + +/* Loading spinner in admin area */ +.admin-spinner { + width: 24px; + height: 24px; + border: 2px solid rgba(255, 255, 255, 0.1); + border-top-color: #6C63FF; + border-radius: 50%; + animation: admin-spin 0.8s linear infinite; +} + +@keyframes admin-spin { + to { transform: rotate(360deg); } +} + +/* Pagination */ +.admin-pagination { display: flex; - gap: var(--space-md); - padding-top: var(--space-lg); - border-top: 1px solid var(--color-border-light); + align-items: center; + justify-content: center; + gap: 4px; + margin-top: 20px; } -/* Version info in sidebar */ -.admin-version-info { - padding: var(--space-sm) var(--space-md); - font-size: var(--text-xs); - color: rgba(255,255,255,0.25); - text-align: center; - white-space: nowrap; - border-top: 1px solid rgba(255,255,255,0.04); +.admin-pagination button { + width: 32px; + height: 32px; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: transparent; + color: #8892B0; + font-size: 13px; + cursor: pointer; + transition: all 0.15s ease; } +.admin-pagination button:hover { + background: rgba(255, 255, 255, 0.06); +} + +.admin-pagination button.active { + background: rgba(108, 99, 255, 0.15); + border-color: rgba(108, 99, 255, 0.3); + color: #A78BFA; +} + +/* Toast notifications */ +.admin-toast { + position: fixed; + top: 24px; + right: 24px; + padding: 12px 20px; + border-radius: 10px; + font-size: 13px; + font-weight: 500; + z-index: 2000; + animation: toastSlideIn 0.3s ease; + backdrop-filter: blur(12px); +} + +.admin-toast-success { + background: rgba(16, 185, 129, 0.15); + border: 1px solid rgba(16, 185, 129, 0.3); + color: #34D399; +} + +.admin-toast-error { + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.3); + color: #F87171; +} + +.admin-toast-info { + background: rgba(108, 99, 255, 0.15); + border: 1px solid rgba(108, 99, 255, 0.3); + color: #A78BFA; +} + +@keyframes toastSlideIn { + from { opacity: 0; transform: translateX(20px); } + to { opacity: 1; transform: translateX(0); } +} + +/* ========== Responsive ========== */ @media (max-width: 768px) { - .admin-content { - padding: var(--space-lg); + .admin-sidebar { + position: fixed; + left: -100%; + top: 0; + bottom: 0; + transition: left 0.3s ease; } - .admin-form-row { - grid-template-columns: 1fr; + + .admin-sidebar.mobile-open { + left: 0; + } + + .admin-content { + padding: 16px; + } + + .admin-topbar { + padding: 0 16px; + } + + .admin-stats-grid { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + } + + .admin-modal { + min-width: auto; + width: 90vw; + max-width: 90vw; } } diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index 544ddd8..6034a89 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -1,262 +1,297 @@ import React, { useState } from 'react'; - -import { Outlet, NavLink, useNavigate } from 'react-router-dom'; - +import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; import { useAuth } from '@/contexts/AuthContext'; - import './AdminLayout.css'; +interface NavGroup { + label: string; + items: NavItem[]; +} +interface NavItem { + path: string; + icon: string; + label: string; + exact?: boolean; +} -const adminNavItems = [ - - { path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true }, - - { path: '/admin/users', icon: 'users', label: '用户管理' }, - - { path: '/admin/models', icon: 'models', label: '大模型库管理' }, - - { path: '/admin/settings', icon: 'settings', label: '网站基础设置' }, - - { path: '/admin/payment', icon: 'payment', label: '支付渠道配置' }, - - { path: '/admin/services', icon: 'services', label: '增值服务管理' }, - - { path: '/admin/membership', icon: 'membership', label: '会员订阅体系' }, - - { path: '/admin/ads', icon: 'ads', label: '广告位管理' }, - - { path: '/admin/market', icon: 'market', label: '应用市场审核' }, - - { path: '/admin/logs', icon: 'logs', label: '日志与风控' }, - - - - { path: '/admin/agents', icon: 'agents', label: 'Agent管理' }, - { path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' }, - { path: '/admin/storage', icon: 'storage', label: '存储配置' }, - { path: '/admin/automation', icon: 'automation', label: '自动化任务' }, - { path: '/admin/skills', icon: 'skills', label: '技能管理' }, - { path: '/admin/mcp', icon: 'mcp', label: 'MCP Servers' }, - { path: '/admin/developer', icon: 'developer', label: '开发者API' }, - { path: '/admin/apps', icon: 'apps', label: 'AI应用管理' } +const navGroups: NavGroup[] = [ + { + label: '平台驾驶舱', + items: [ + { path: '/admin', icon: 'dashboard', label: '平台概览', exact: true }, + ], + }, + { + label: '用户体系', + items: [ + { path: '/admin/users', icon: 'users', label: '用户管理' }, + { path: '/admin/membership', icon: 'membership', label: '会员体系' }, + ], + }, + { + label: 'AI能力', + items: [ + { path: '/admin/models', icon: 'models', label: '模型控制台' }, + { path: '/admin/agents', icon: 'agents', label: 'Agent管理' }, + { path: '/admin/skills', icon: 'skills', label: '技能管理' }, + { path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' }, + { path: '/admin/mcp', icon: 'mcp', label: 'MCP服务' }, + ], + }, + { + 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 = { - dashboard: , - users: , - models: , - settings: , - payment: , - services: , - membership: , - ads: , - market: , + logs: , + agents: , + skills: , + knowledge: , + // New icons for orphan pages + mcp: , + apps: , + storage: , + automation: , + developer: , +}; - logs: , - - - - skills: , - automation: , - storage: , - - developer: (),}; - +// 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 { user, logout } = useAuth(); - const navigate = useNavigate(); - + const location = useLocation(); const [collapsed, setCollapsed] = useState(false); - - + const [expandedGroups, setExpandedGroups] = useState>( + new Set(navGroups.map(g => g.label)) + ); if (!user || user.role !== 'admin') { - navigate('/login'); - 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 ( -
- {/* Admin Sidebar */} - - - {/* Admin Main Content */} -
-
-
- - 欢迎,{user.username} - + {/* Page title */} +

{pageTitle}

+ {/* Breadcrumb */} +
+ {breadcrumbs.map((crumb, i) => ( + + {i > 0 && /} + i < breadcrumbs.length - 1 && navigate(crumb.path)} + style={{ cursor: i < breadcrumbs.length - 1 ? 'pointer' : 'default' }} + > + {crumb.label} + + + ))} +
-
- + 欢迎,{user.username} - - 返回前台 - -
-
- {user.username.charAt(0).toUpperCase()} -
- {user.username} -
-
-
-
- -
-
-
- ); - }; - - export default AdminLayout; - diff --git a/frontend/src/pages/admin/Dashboard.tsx b/frontend/src/pages/admin/Dashboard.tsx index 089ebbc..15e812f 100644 --- a/frontend/src/pages/admin/Dashboard.tsx +++ b/frontend/src/pages/admin/Dashboard.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { adminAPI } from '@/api'; @@ -8,6 +8,28 @@ const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ child ); +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 = [ { path: '/admin/models', label: '大模型库', desc: '管理AI模型与供应商', icon: }, { path: '/admin/settings', label: '网站设置', desc: '品牌、邮件、短信配置', icon: }, @@ -18,9 +40,86 @@ const quickEntries = [ { path: '/admin/logs', label: '日志风控', desc: '操作/调用/付费日志与风控', icon: }, ]; +// SVG Bar Chart Component +const BarChart: React.FC<{ data: DailyStat[] }> = ({ data }) => { + if (!data || data.length === 0) return
暂无数据
; + + 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 ( + + {/* Grid lines */} + {yTicks.map((tick, i) => { + const y = padTop + barAreaH - (tick / maxVal) * barAreaH; + return ( + + + {formatK(tick)} + + ); + })} + {/* Bars */} + {data.map((d, i) => { + const barH = (d.tokens / maxVal) * barAreaH; + const x = padLeft + i * (barAreaW / data.length); + const y = padTop + barAreaH - barH; + return ( + + + {`${d.date}: ${d.tokens.toLocaleString()} tokens`} + + {/* X label - every 7th bar */} + {i % Math.max(1, Math.floor(data.length / 8)) === 0 && ( + {formatDate(d.date)} + )} + + ); + })} + + ); +}; + +// 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 ( + + + + + {Math.round(pct * 100)}% + + + ); +}; + const Dashboard: React.FC = () => { 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({ + 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([]); const [operationLogs, setOperationLogs] = useState([]); const [auditLogs, setAuditLogs] = useState([]); const [loading, setLoading] = useState(true); @@ -29,12 +128,14 @@ const Dashboard: React.FC = () => { const fetchData = async () => { setLoading(true); try { - const [statsRes, logsRes, auditRes] = await Promise.all([ - adminAPI.getStats(), + const [statsRes, dailyRes, logsRes, auditRes] = await Promise.all([ + adminAPI.getStats() as any, + (adminAPI as any).getDailyStats?.(30) || Promise.resolve({ data: [] }), adminAPI.getRecentLogs(), adminAPI.getRecentAudit(), ]); if (statsRes.data) setStats(statsRes.data); + if (dailyRes.data) setDailyStats(dailyRes.data || []); if (logsRes.data) setOperationLogs(Array.isArray(logsRes.data) ? logsRes.data : []); if (auditRes.data) setAuditLogs(Array.isArray(auditRes.data) ? auditRes.data : []); } catch (err) { @@ -54,15 +155,24 @@ const Dashboard: React.FC = () => { }; const formatTokens = (n: number): string => { + if (n >= 100000000) return (n / 100000000).toFixed(2) + '亿'; if (n >= 10000) return (n / 10000).toFixed(1) + '万'; return n.toLocaleString('en-US'); }; - const statCards = [ - { label: '用户总数', value: stats.active_users.toLocaleString(), color: '#6C63FF', bg: '#EEEDFF', icon: }, - { label: '活跃模型数', value: stats.enabled_models.toString(), color: '#219B5E', bg: '#E6F7EE', icon: }, - { label: '今日订单数', value: stats.today_orders.toString(), color: '#E8922C', bg: '#FEF4E8', icon: }, - { label: '今日Token消耗', value: formatTokens(stats.today_tokens), color: '#3B82F6', bg: '#EBF2FE', icon: }, + const statCardDefs = [ + { 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)', + icon: }, + { 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)', + icon: }, + { 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: }, + { 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: }, + { 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: }, + { 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: }, ]; if (loading) { @@ -78,85 +188,120 @@ const Dashboard: React.FC = () => {

平台驾驶舱

- {/* Stats Cards */} -
- {statCards.map((s, i) => ( -
+ {/* Stats Cards Row */} +
+ {statCardDefs.map((s, i) => ( +
{s.icon}
-
+
{s.label}
-
{s.value}
+
+ {s.format ? formatTokens(s.value as number) : (s.value as number).toLocaleString()} +
+
{s.sub}
))}
- {/* Platform Cumulative Token Stats */} -
-
-

- - {' '}平台累计Token消耗 -

+ {/* Charts Row */} +
+ {/* Token Trend Chart */} +
+
+

+ + {' '}Token消耗趋势(近30天) +

+
+
+ +
-
- {[ - { 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) => ( -
-
- {item.value} + + {/* System Health */} +
+
+

+ + {' '}平台概览 +

+
+
+ {[ + { 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) => ( +
+
+ + {item.label} +
+ {item.value}
-
- {item.label} + ))} +
+ 服务器时间: {stats.server_time ? new Date(stats.server_time).toLocaleString('zh-CN') : '-'} +
+
+
+
+ + {/* Quick Actions */} +
+

快捷入口

+
+ {quickEntries.map((entry) => ( +
navigate(entry.path)} + style={{ + background: 'rgba(17,22,39,0.5)', borderRadius: 'var(--radius-lg)', border: '1px solid var(--color-border)', + padding: 'var(--space-md) var(--space-lg)', cursor: 'pointer', transition: 'all 0.2s', + display: 'flex', alignItems: 'center', gap: 'var(--space-md)', + }} + onMouseEnter={(e) => { + e.currentTarget.style.borderColor = '#6C63FF'; + e.currentTarget.style.boxShadow = '0 2px 8px rgba(108,99,255,0.15)'; + e.currentTarget.style.transform = 'translateY(-1px)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = 'var(--color-border)'; + e.currentTarget.style.boxShadow = 'none'; + e.currentTarget.style.transform = 'none'; + }} + > +
{entry.icon}
+
+
{entry.label}
+
{entry.desc}
-
{item.unit}
))}
- {/* Quick Entry Cards */} -
- {quickEntries.map((entry) => ( -
navigate(entry.path)} - style={{ - background: '#FFFFFF', borderRadius: 'var(--radius-xl)', border: '1px solid var(--color-border)', - padding: 'var(--space-xl)', cursor: 'pointer', transition: 'all var(--transition-fast)', - display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 'var(--space-sm)', - }} - onMouseEnter={(e) => { - e.currentTarget.style.borderColor = 'var(--color-accent)'; - e.currentTarget.style.boxShadow = 'var(--shadow-md)'; - }} - onMouseLeave={(e) => { - e.currentTarget.style.borderColor = 'var(--color-border)'; - e.currentTarget.style.boxShadow = 'none'; - }} - > -
{entry.icon}
-
{entry.label}
-
{entry.desc}
-
- ))} -
- - {/* Recent Operation Logs */} -
+ {/* Recent Logs */} +

最近操作日志

+ 最近10条
@@ -192,6 +337,7 @@ const Dashboard: React.FC = () => {

最近审计日志

+ 最近10条
diff --git a/frontend/src/pages/admin/Models.tsx b/frontend/src/pages/admin/Models.tsx index b564d84..a8d15e2 100644 --- a/frontend/src/pages/admin/Models.tsx +++ b/frontend/src/pages/admin/Models.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { adminAPI } from '@/api'; import type { ModelProvider, AIModel, ProtocolDef, FetchableModel } from '@/types'; @@ -15,36 +15,25 @@ const formatNumber = (n: number) => { }; const capabilityColors: Record = { - text: '#5B5FED', - vision: '#219B5E', - audio: '#E8922C', - video: '#D9383A', - image: '#D9383A', - thinking: '#8B5CF6', - tool: '#3B82F6', + text: '#5B5FED', vision: '#219B5E', audio: '#E8922C', + video: '#D9383A', image: '#D9383A', thinking: '#8B5CF6', tool: '#3B82F6', }; const capabilityLabels: Record = { - text: '文本', - vision: '视觉', - audio: '音频', - video: '视频', - image: '图像', - thinking: '思考', - tool: '工具', + text: '文本', vision: '视觉', audio: '音频', + video: '视频', image: '图像', thinking: '思考', tool: '工具', }; -// Test result type interface TestResult { success: boolean; message: string; latency_ms?: number; } -// Model editing type interface EditingModel { id?: string; name: string; + display_name: string; provider_id: string; api_key: string; endpoint: string; @@ -55,6 +44,42 @@ interface EditingModel { 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 ( +
+ {items.map((item, i) => ( +
+ {item.icon} +
+
{item.label}
+
+ {item.value} +
+
+
+ ))} +
+ ); +}; + const Models: React.FC = () => { const [providers, setProviders] = useState([]); const [models, setModels] = useState([]); @@ -68,12 +93,9 @@ const Models: React.FC = () => { const [editingProvider, setEditingProvider] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - - // Model test state - const [testingModelId, setTestingModelId] = useState(null); + const [modelSearch, setModelSearch] = useState(''); const [testResult, setTestResult] = useState(null); - - // Model editor state + const [testingModelId, setTestingModelId] = useState(null); const [showModelEditor, setShowModelEditor] = useState(false); const [editingModel, setEditingModel] = useState(null); const [modelSaving, setModelSaving] = useState(false); @@ -97,6 +119,7 @@ const Models: React.FC = () => { const mappedModels = (Array.isArray(rawModels) ? rawModels : []).map((m: any) => ({ ...m, providerId: m.providerId || m.provider_id, + displayName: m.displayName || m.display_name || m.name, apiKey: m.apiKey || m.api_key, pricePerMillionTokens: m.pricePerMillionTokens ?? m.price_per_million_tokens ?? 0, isEnabled: m.isEnabled ?? m.is_enabled ?? true, @@ -114,6 +137,16 @@ const Models: React.FC = () => { 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 const handleTestConnection = async (modelId: string) => { setTestingModelId(modelId); @@ -128,7 +161,6 @@ const Models: React.FC = () => { } }; - // Set default model const handleSetDefault = async (modelId: string) => { try { await adminAPI.setDefaultModel(modelId); @@ -138,17 +170,14 @@ const Models: React.FC = () => { } }; - // Provider operations const handleAddProvider = async (e: React.FormEvent) => { e.preventDefault(); if (!providerForm.name.trim()) return; try { setError(null); await adminAPI.addProvider({ - name: providerForm.name, - protocol: providerForm.protocol, - api_key: (providerForm as any).api_key || providerForm.api_key, - api_base_url: (providerForm as any).api_base_url || providerForm.api_base_url || undefined, + name: providerForm.name, protocol: providerForm.protocol, + api_key: providerForm.api_key, api_base_url: providerForm.api_base_url || undefined, }); setShowAddProvider(false); setProviderForm({ name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '' }); @@ -165,8 +194,7 @@ const Models: React.FC = () => { setError(null); await adminAPI.updateProvider(editingProvider.id, { name: providerForm.name, protocol: providerForm.protocol, - api_key: (providerForm as any).api_key || providerForm.api_key, - api_base_url: (providerForm as any).api_base_url || providerForm.api_base_url || undefined, + api_key: providerForm.api_key, api_base_url: providerForm.api_base_url || undefined, }); setEditingProvider(null); setProviderForm({ name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '' }); @@ -177,7 +205,7 @@ const Models: React.FC = () => { }; const handleDeleteProvider = async (id: string) => { - if (!confirm('确定删除该供应商?')) return; + if (!confirm('确定删除该供应商?关联的模型也会受影响。')) return; try { await adminAPI.deleteProvider(id); await loadData(); @@ -186,7 +214,6 @@ const Models: React.FC = () => { } }; - // Fetch models from provider API const handleFetchModels = async (providerId: string) => { try { const result = await adminAPI.fetchProviderModels(providerId) as any; @@ -200,7 +227,6 @@ const Models: React.FC = () => { } }; - // Add selected models const handleAddModels = async (providerId: string) => { if (selectedModelIds.size === 0) return; try { @@ -214,19 +240,17 @@ const Models: React.FC = () => { } }; - // Toggle model selection const toggleModelSelection = (id: string) => { - const newSet = new Set(selectedModelIds); - if (newSet.has(id)) newSet.delete(id); - else newSet.add(id); - setSelectedModelIds(newSet); + const next = new Set(selectedModelIds); + next.has(id) ? next.delete(id) : next.add(id); + setSelectedModelIds(next); }; - // Open model editor const openModelEditor = (model?: AIModel, providerId?: string) => { if (model) { setEditingModel({ 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 || '', api_key: (model as any).api_key || model.apiKey || '', endpoint: model.endpoint || '', @@ -238,7 +262,7 @@ const Models: React.FC = () => { }); } else { setEditingModel({ - name: '', provider_id: providerId || '', + name: '', display_name: '', provider_id: providerId || '', api_key: '', endpoint: '', capabilities: ['text'], timeout: 60, status: 'paid', price_per_million_tokens: 0, is_enabled: true, @@ -247,7 +271,6 @@ const Models: React.FC = () => { setShowModelEditor(true); }; - // Save model const handleSaveModel = async (e: React.FormEvent) => { e.preventDefault(); if (!editingModel || !editingModel.name.trim()) return; @@ -267,7 +290,6 @@ const Models: React.FC = () => { } }; - // Delete model const handleDeleteModel = async (modelId: string) => { if (!confirm('确定删除该模型?')) return; try { @@ -278,50 +300,62 @@ const Models: React.FC = () => { } }; - // Toggle capability const toggleCapability = (cap: string) => { if (!editingModel) return; const caps = editingModel.capabilities || []; - if (caps.includes(cap)) { - editingModel.capabilities = caps.filter(c => c !== cap); - } else { - editingModel.capabilities = [...caps, cap]; - } + editingModel.capabilities = caps.includes(cap) ? caps.filter(c => c !== cap) : [...caps, cap]; setEditingModel({ ...editingModel }); }; - // Group models by provider const getProviderModels = (providerId: string) => - models.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; - }; + filteredModels.filter(m => ((m as any).provider_id || m.providerId) === providerId); return (
-
+

大模型配置

-

+

管理 AI 模型供应商、模型配置和连接测试

-
+ {/* Stats */} + + {error && (
{error}
)} + {/* Search */} +
+
+ + + + 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' }} + /> +
+ {modelSearch && ( + + 找到 {filteredModels.length} 个模型 + + )} +
+ {loading ? ( -
加载中...
+
加载中...
) : (
{providers.map(provider => { @@ -329,8 +363,7 @@ const Models: React.FC = () => { const isExpanded = expandedProvider === provider.id; return ( -
- {/* Provider Header */} +
@@ -341,8 +374,9 @@ const Models: React.FC = () => { {provModels.filter(m => m.isDefault).length > 0 && ( 默认 )} + {provModels.length} 个模型
-
+
API: {(provider as any).api_base_url || provider.apiBaseUrl || '未配置'} {(provider as any).api_key || provider.apiKey ? ' ✓ 已配置Key' : ' ✗ 未配置Key'}
@@ -357,7 +391,6 @@ const Models: React.FC = () => {
- {/* Model Fetch Panel */} {isExpanded && (
@@ -372,7 +405,8 @@ const Models: React.FC = () => {
{fetchedModels.map(m => ( -
)} - {/* Models List */}
{provModels.length === 0 ? ( -
+
暂无模型,点击"获取模型"或"添加模型"添加
) : (
- + + @@ -404,7 +438,13 @@ const Models: React.FC = () => { +
模型模型名称显示名称 能力 状态 价格
{model.name}
- {model.isDefault && ⭐ 默认模型} +
{model.id?.slice(0, 8)}...
+
+ + {(model as any).display_name || (model as any).displayName || '-'} + + {model.isDefault && ⭐ 默认}
@@ -421,34 +461,28 @@ const Models: React.FC = () => {
- - ¥{model.pricePerMillionTokens?.toFixed(4) || '0.0000'}/M + + ¥{(model.pricePerMillionTokens || 0).toFixed(4)}/M - +
{formatNumber(model.totalCalls || 0)} - +
+
+ {formatNumber(model.totalTokens || 0)} tokens +
- {/* Test Connection Button */} - {/* Test Result Popover */} - {testResult && ( -
- - {testResult.success ? '✓ 成功' : '✗ 失败'} - {testResult.latency_ms && ` (${testResult.latency_ms}ms)`} - -
- {testResult.message} -
- -
+ {testResult && testingModelId === null && ( + + {testResult.success ? `✓ ${testResult.latency_ms || 0}ms` : '✗ 失败'} + )} {!model.isDefault && model.isEnabled && ( @@ -468,9 +502,9 @@ const Models: React.FC = () => { })} {providers.length === 0 && ( -
+
🤖
-

暂无模型供应商,点击上方按钮添加

+

暂无模型供应商,点击上方按钮添加

)}
@@ -478,8 +512,8 @@ const Models: React.FC = () => { {/* Provider Modal */} {showAddProvider && ( -
-
+
{ setShowAddProvider(false); setEditingProvider(null); }}> +
e.stopPropagation()}>

{editingProvider ? '编辑供应商' : '添加供应商'}

@@ -511,8 +545,8 @@ const Models: React.FC = () => { {/* Model Editor Modal */} {showModelEditor && editingModel && ( -
-
+
setShowModelEditor(false)}> +
e.stopPropagation()}>

{editingModel.id ? '编辑模型' : '添加模型'}

@@ -521,12 +555,16 @@ const Models: React.FC = () => { setEditingModel({ ...editingModel, name: e.target.value })} placeholder="如:DeepSeek-V4-Flash" style={inputStyle} required />
- - + + setEditingModel({ ...editingModel, display_name: e.target.value })} placeholder="如:DeepSeek V4" style={inputStyle} />
+
+ + +
setEditingModel({ ...editingModel, api_key: e.target.value })} placeholder="sk-... (留空则使用供应商的Key)" style={inputStyle} /> @@ -539,7 +577,10 @@ const Models: React.FC = () => {
{Object.entries(capabilityLabels).map(([key, label]) => ( -