feat():learning后台管理前端页面初始化

This commit is contained in:
yuj
2025-12-04 17:51:24 +08:00
commit 83a614bd75
97 changed files with 23324 additions and 0 deletions

View File

@@ -0,0 +1,90 @@
import React, { useEffect, useRef, useState } from 'react';
import { Spin } from 'antd';
import { useAuth } from '../hooks/useAuth';
interface AuthGuardProps {
children: React.ReactNode;
}
const AuthGuard: React.FC<AuthGuardProps> = ({ children }) => {
const {
isAuthenticated,
initAuth,
loginUrl,
redirectToLogin,
loading,
logout,
} = useAuth();
const initedRef = useRef(false);
const [booting, setBooting] = useState(true);
// 调试信息
console.log('🔐 [AuthGuard] 状态:', {
booting,
isAuthenticated,
loading,
hasLoginUrl: !!loginUrl,
});
useEffect(() => {
if (initedRef.current) {
setBooting(false);
return;
}
initedRef.current = true;
(async () => {
try {
await initAuth();
} finally {
setBooting(false);
}
})();
}, [initAuth]);
// 未认证时主动触发SSO登录流程
useEffect(() => {
if (!booting && !isAuthenticated && !loading) {
redirectToLogin();
}
}, [booting, isAuthenticated, loading, redirectToLogin]);
if (booting) {
return (
<div
style={{
display: 'flex',
height: '100vh',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Spin tip="Loading...">
<div className="w-[200px]" />
</Spin>
</div>
);
}
if (!isAuthenticated) {
// 未认证时显示加载等待自动跳转SSO
return (
<div
style={{
display: 'flex',
height: '100vh',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Spin tip="Redirecting to SSO...">
<div className="w-[200px]" />
</Spin>
</div>
);
}
return <>{children}</>;
};
export default AuthGuard;

View File

@@ -0,0 +1,108 @@
/* Ant Design Menu 自定义样式 */
.dynamic-menu-wrapper {
width: 100%;
}
.dynamic-menu-wrapper .ant-menu-inline {
border: none !important;
background: transparent;
}
.dynamic-menu-wrapper .ant-menu-item,
.dynamic-menu-wrapper .ant-menu-submenu-title {
border-radius: 10px;
margin: 4px 0;
padding-left: 16px !important;
height: 40px;
line-height: 40px;
transition: all 0.2s;
border: 1px solid transparent;
}
.dynamic-menu-wrapper .ant-menu-item:hover,
.dynamic-menu-wrapper .ant-menu-submenu-title:hover {
background: var(--soft) !important;
}
.dynamic-menu-wrapper .ant-menu-item-selected {
background: var(--soft) !important;
border-color: var(--border) !important;
}
.dynamic-menu-wrapper .ant-menu-item-selected::after {
display: none;
}
/* 图标样式 */
.dynamic-menu-wrapper .ant-menu-item .anticon,
.dynamic-menu-wrapper .ant-menu-submenu-title .anticon {
font-size: 16px;
color: var(--text);
}
/* 折叠时的样式 */
.dynamic-menu-wrapper .ant-menu-inline-collapsed {
width: 100% !important;
}
.dynamic-menu-wrapper .ant-menu-inline-collapsed > .ant-menu-item,
.dynamic-menu-wrapper .ant-menu-inline-collapsed > .ant-menu-submenu > .ant-menu-submenu-title {
padding: 0 16px !important;
text-align: center;
margin: 4px 0;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.dynamic-menu-wrapper .ant-menu-inline-collapsed .ant-menu-item-icon {
margin-inline-end: 0 !important;
}
.dynamic-menu-wrapper .ant-menu-inline-collapsed > .ant-menu-item .anticon,
.dynamic-menu-wrapper .ant-menu-inline-collapsed > .ant-menu-submenu > .ant-menu-submenu-title .anticon {
font-size: 20px;
line-height: 40px;
margin: 0 !important;
}
/* 子菜单样式 */
.dynamic-menu-wrapper .ant-menu-submenu-title {
color: var(--text);
}
.dynamic-menu-wrapper .ant-menu-sub {
background: transparent !important;
}
.dynamic-menu-wrapper .ant-menu-submenu .ant-menu-item {
padding-left: 48px !important;
}
/* 移除默认的边框和阴影 */
.dynamic-menu-wrapper .ant-menu-item,
.dynamic-menu-wrapper .ant-menu-submenu {
border: none;
}
.dynamic-menu-wrapper .ant-menu:not(.ant-menu-horizontal) .ant-menu-item-selected {
background-color: var(--soft);
}
/* 展开箭头样式 */
.dynamic-menu-wrapper .ant-menu-submenu-arrow {
color: var(--muted);
}
/* 子菜单展开时的样式 */
.dynamic-menu-wrapper .ant-menu-submenu-open > .ant-menu-submenu-title {
color: var(--text);
}
/* 文字超出隐藏 */
.dynamic-menu-wrapper .ant-menu-title-content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,250 @@
import React from 'react';
import { Menu } from 'antd';
import {
PieChartOutlined,
TeamOutlined,
ControlOutlined,
MessageOutlined,
BellOutlined,
LineChartOutlined,
AccountBookOutlined,
SettingOutlined,
DatabaseOutlined,
UsergroupAddOutlined,
CrownOutlined
} from '@ant-design/icons';
import { useNavigate, useLocation } from 'react-router-dom';
import { usePagePermissions } from '../hooks/usePagePermissions';
import './DynamicMenu.css';
interface MenuItem {
key: string;
label: string;
icon?: React.ReactNode;
path?: string;
children?: MenuItem[];
}
interface DynamicMenuProps {
collapsed?: boolean;
}
const DynamicMenu: React.FC<DynamicMenuProps> = ({ collapsed = false }) => {
const navigate = useNavigate();
const location = useLocation();
const { getAccessiblePages, getPageActions } = usePagePermissions();
const accessiblePages = getAccessiblePages();
console.log('🔍 [DynamicMenu] 用户可访问页面:', accessiblePages);
// 菜单配置 - 使用数据库中的页面路径
const menuItems: MenuItem[] = [
{
key: 'overview',
label: '总览',
icon: <PieChartOutlined />,
path: '/overview',
},
{
key: 'operations',
label: '运营',
icon: <TeamOutlined />,
path: '/operations',
children: [
{
key: 'operations-main',
label: '运营管理',
icon: <ControlOutlined />,
path: '/operations',
},
{
key: 'operations-user-feedback',
label: '用户反馈',
icon: <MessageOutlined />,
path: '/user-feedback',
},
{
key: 'operations-message-push',
label: '消息推送',
icon: <BellOutlined />,
path: '/message-push',
},
{
key: 'operations-mcp-pricing',
label: 'MCP价格配置',
icon: <DatabaseOutlined />,
path: '/mcp-provider-pricing',
},
],
},
{
key: 'monitoring',
label: '监控',
icon: <LineChartOutlined />,
path: '/monitoring',
},
{
key: 'finance',
label: '财务',
icon: <AccountBookOutlined />,
path: '/finance',
},
{
key: 'system',
label: '系统管理',
icon: <SettingOutlined />,
path: '/system/user-management',
children: [
{
key: 'system-quota',
label: '配额管理',
icon: <DatabaseOutlined />,
path: '/system',
},
{
key: 'system-goalfymax-users',
label: 'GoalfyMax用户',
icon: <UsergroupAddOutlined />,
path: '/goalfymax-users',
},
{
key: 'system-user-level-configs',
label: '用户等级管理',
icon: <CrownOutlined />,
path: '/user-level-configs',
},
],
},
];
// 过滤可访问的菜单项
const filterAccessibleMenus = (items: MenuItem[]): MenuItem[] => {
return items.map(item => {
// 创建新对象,避免修改原始数据
const newItem = { ...item };
// 移除所有子菜单,只显示一级菜单(子导航在页面内显示)
newItem.children = undefined;
// 父级"系统管理"权限检查
if (newItem.key === 'system' || newItem.path === '/system') {
const hasSystemAccess = accessiblePages.includes('/system');
if (!hasSystemAccess) {
console.log(`🚫 [DynamicMenu] 隐藏系统管理: 用户无 /system 权限`);
return null;
}
console.log(`✅ [DynamicMenu] 显示系统管理菜单项`);
return newItem;
}
// 父级"运营"权限检查
if (newItem.key === 'operations' || newItem.path === '/operations') {
const hasOperationsAccess = accessiblePages.includes('/operations');
if (!hasOperationsAccess) {
console.log(`🚫 [DynamicMenu] 隐藏运营: 用户无 /operations 权限`);
return null;
}
console.log(`✅ [DynamicMenu] 显示运营菜单项`);
return newItem;
}
// 对于有路径的项目,检查权限
if (newItem.path && !accessiblePages.includes(newItem.path)) {
console.log(`🚫 [DynamicMenu] 隐藏菜单项: ${newItem.label} (${newItem.path}) - 用户无权限`);
return null;
}
console.log(`✅ [DynamicMenu] 显示菜单项: ${newItem.label} (${newItem.path}) - 用户有权限`);
return newItem;
}).filter((item): item is MenuItem => item !== null);
};
const accessibleMenus = filterAccessibleMenus(menuItems);
console.log('🔍 [DynamicMenu] 原始菜单项:', menuItems.map(item => ({ key: item.key, label: item.label, path: item.path })));
console.log('🔍 [DynamicMenu] 过滤后的菜单项:', accessibleMenus.map(item => ({ key: item.key, label: item.label, path: item.path })));
const handleMenuClick = ({ key }: { key: string }) => {
const findMenuItem = (items: MenuItem[], targetKey: string): MenuItem | null => {
for (const item of items) {
if (item.key === targetKey) {
return item;
}
if (item.children) {
const found = findMenuItem(item.children, targetKey);
if (found) return found;
}
}
return null;
};
const menuItem = findMenuItem(accessibleMenus, key);
if (menuItem?.path) {
navigate(menuItem.path);
}
};
// 根据当前路径找到对应的菜单key
const getSelectedMenuKey = (): string => {
const currentPath = location.pathname;
// 遍历所有菜单项找到匹配当前路径的菜单key
const findKeyByPath = (items: MenuItem[]): string | null => {
for (const item of items) {
// 精确匹配路径
if (item.path === currentPath) {
return item.key;
}
// 检查是否是子路径(例如 /system/quota-rules 应该选中 system
if (item.path && currentPath.startsWith(item.path)) {
return item.key;
}
// 递归检查子菜单
if (item.children) {
const childKey = findKeyByPath(item.children);
if (childKey) return childKey;
}
}
return null;
};
const key = findKeyByPath(accessibleMenus);
return key || '';
};
// 转换为 Ant Design Menu 所需的格式
const convertToMenuItems = (items: MenuItem[]): any[] => {
return items.map(item => {
if (item.children && item.children.length > 0) {
return {
key: item.key,
label: item.label,
icon: item.icon,
children: convertToMenuItems(item.children),
};
}
return {
key: item.key,
label: item.label,
icon: item.icon,
};
});
};
const selectedKey = getSelectedMenuKey();
return (
<div className="dynamic-menu-wrapper">
<Menu
mode="inline"
inlineCollapsed={collapsed}
selectedKeys={selectedKey ? [selectedKey] : []}
items={convertToMenuItems(accessibleMenus)}
onClick={handleMenuClick}
/>
</div>
);
};
export default DynamicMenu;

391
src/components/Layout.tsx Normal file
View File

@@ -0,0 +1,391 @@
import React, { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
MenuFoldOutlined,
MenuUnfoldOutlined
} from '@ant-design/icons';
import { useAuth } from '../hooks/useAuth';
import DynamicMenu from './DynamicMenu';
const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const location = useLocation();
const navigate = useNavigate();
const { token, logout } = useAuth();
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
// 从路径获取当前激活的标签
const getCurrentTab = () => {
const path = location.pathname;
if (path === '/dashboard' || path === '/overview') return 'overview';
if (path.startsWith('/operations')) return 'operations';
if (path.startsWith('/monitoring')) return 'monitoring';
if (path === '/finance' || path.startsWith('/finance/')) return 'finance';
if (path.startsWith('/system')) return 'admin';
return 'overview';
};
const getCurrentSubTab = () => {
const path = location.pathname;
// 运营页面子导航
if (path.startsWith('/operations/user-feedback')) return 'user-feedback';
if (path.startsWith('/operations/message-push')) return 'message-push';
if (path.startsWith('/operations/vendor-model-pricing')) return 'vendor-model-pricing';
if (path.startsWith('/operations/mcp-provider-pricing')) return 'mcp-provider-pricing';
if (path.startsWith('/operations/vm-pricing')) return 'vm-pricing';
if (path.startsWith('/operations/invite-codes')) return 'invite-codes';
// 监控页面子导航
if (path.startsWith('/monitoring/token-history')) return 'token-history';
if (path.startsWith('/monitoring/token-analytics')) return 'token-analytics';
if (path.startsWith('/monitoring/system-health')) return 'system-health';
// 财务页面子导航
if (path.startsWith('/finance/transaction-logs')) return 'transaction-logs';
if (path.startsWith('/finance/sandbox-costs')) return 'sandbox-costs';
if (path.startsWith('/finance/token-usage')) return 'token-usage';
if (path.startsWith('/finance/mcp-usage')) return 'mcp-usage';
if (path.startsWith('/finance/payment-records')) return 'payment-records';
if (path.startsWith('/finance/mcp-account-recharge-records')) return 'mcp-account-recharge-records';
if (path.startsWith('/finance/model-account-recharge-records')) return 'model-account-recharge-records';
// 系统管理页面子导航
if (path.startsWith('/system/user-project-quota')) return 'user-project-quota';
if (path.startsWith('/system/user-management')) return 'user-management';
if (path.startsWith('/system/role-management')) return 'role-management';
if (path.startsWith('/system/goalfymax-users')) return 'goalfymax-users';
if (path.startsWith('/system/user-level-configs')) return 'user-level-configs';
if (path.startsWith('/system/system-configs')) return 'system-configs';
if (path.startsWith('/system/audit-logs')) return 'audit-logs';
return '';
};
const activeTab = getCurrentTab();
const activeSubTab = getCurrentSubTab();
const handleLogout = async () => {
await logout();
};
return (
<div className="container">
{/* 侧边栏 */}
<aside className={`sidebar${mobileOpen ? ' mobile-open' : ''}`} style={{ width: collapsed ? '80px' : '240px', transition: 'width 0.2s' }}>
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: collapsed ? '16px 0' : '16px',
marginBottom: '8px',
gap: '12px'
}}>
{collapsed ? (
<>
<div className="logo" style={{ margin: 0 }}></div>
<button
onClick={() => setCollapsed(!collapsed)}
style={{
border: '1px solid var(--border)',
background: 'var(--soft)',
cursor: 'pointer',
fontSize: '16px',
color: 'var(--text)',
padding: '8px',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s',
width: '40px',
height: '40px',
flexShrink: 0
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--panel)';
e.currentTarget.style.boxShadow = 'var(--shadow)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--soft)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<MenuUnfoldOutlined />
</button>
</>
) : (
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%'
}}>
<div className="brand" style={{
display: 'flex',
justifyContent: 'flex-start',
alignItems: 'center',
flex: 1
}}>
<div className="logo"></div>
<h1>Goalfy Admin</h1>
</div>
<button
onClick={() => setCollapsed(!collapsed)}
style={{
border: '1px solid var(--border)',
background: 'var(--soft)',
cursor: 'pointer',
fontSize: '16px',
color: 'var(--text)',
padding: '8px',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s',
width: '40px',
height: '40px',
flexShrink: 0
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--panel)';
e.currentTarget.style.boxShadow = 'var(--shadow)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--soft)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<MenuFoldOutlined />
</button>
</div>
)}
</div>
<div className="sidegroup" style={{ flex: 1, overflow: 'hidden' }}>
<DynamicMenu collapsed={collapsed} />
</div>
<div style={{
fontSize: '12px',
color: 'var(--muted)',
textAlign: collapsed ? 'center' : 'left',
padding: '16px',
borderTop: '1px solid var(--border)',
marginTop: 'auto'
}}>
{!collapsed ? 'v1.1 · React Admin' : 'v1.1'}
</div>
</aside>
{/* 主内容区 */}
<section className="main">
<header className="header">
<div style={{display:'flex', alignItems:'center', gap:'10px'}}>
<button
className="btn mobile-toggle"
onClick={() => setMobileOpen(true)}
aria-label="打开菜单"
>
<MenuUnfoldOutlined />
</button>
</div>
<div className="actions">
<div style={{display:'flex', alignItems:'center', gap:'8px'}}>
{token ? (
<>
<span style={{fontSize:12,color:'var(--muted)'}}></span>
<button className="btn" onClick={handleLogout}></button>
</>
) : null}
</div>
</div>
</header>
{/* 监控页面的子导航 */}
{activeTab === 'monitoring' && (
<nav className="subnav">
<div className="segment" data-tabs="mo">
<button
className={activeSubTab === 'token-history' ? 'active' : ''}
onClick={() => navigate('/monitoring/token-history')}
>
Token
</button>
<button
className={activeSubTab === 'token-analytics' ? 'active' : ''}
onClick={() => navigate('/monitoring/token-analytics')}
>
Token
</button>
<button
className={activeSubTab === 'system-health' ? 'active' : ''}
onClick={() => navigate('/monitoring/system-health')}
>
</button>
</div>
</nav>
)}
{/* 运营页面的子导航 */}
{activeTab === 'operations' && (
<nav className="subnav">
<div className="segment" data-tabs="operations">
<button
className={activeSubTab === 'user-feedback' ? 'active' : ''}
onClick={() => navigate('/operations/user-feedback')}
>
</button>
<button
className={activeSubTab === 'message-push' ? 'active' : ''}
onClick={() => navigate('/operations/message-push')}
>
</button>
<button
className={activeSubTab === 'vendor-model-pricing' ? 'active' : ''}
onClick={() => navigate('/operations/vendor-model-pricing')}
>
</button>
<button
className={activeSubTab === 'mcp-provider-pricing' ? 'active' : ''}
onClick={() => navigate('/operations/mcp-provider-pricing')}
>
MCP
</button>
<button
className={activeSubTab === 'vm-pricing' ? 'active' : ''}
onClick={() => navigate('/operations/vm-pricing')}
>
</button>
<button
className={activeSubTab === 'invite-codes' ? 'active' : ''}
onClick={() => navigate('/operations/invite-codes')}
>
</button>
</div>
</nav>
)}
{/* 财务页面的子导航 */}
{activeTab === 'finance' && (
<nav className="subnav">
<div className="segment" data-tabs="finance">
<button
className={activeSubTab === 'transaction-logs' ? 'active' : ''}
onClick={() => navigate('/finance/transaction-logs')}
>
</button>
<button
className={activeSubTab === 'sandbox-costs' ? 'active' : ''}
onClick={() => navigate('/finance/sandbox-costs')}
>
</button>
<button
className={activeSubTab === 'token-usage' ? 'active' : ''}
onClick={() => navigate('/finance/token-usage')}
>
Token费用
</button>
<button
className={activeSubTab === 'mcp-usage' ? 'active' : ''}
onClick={() => navigate('/finance/mcp-usage')}
>
MCP
</button>
<button
className={activeSubTab === 'payment-records' ? 'active' : ''}
onClick={() => navigate('/finance/payment-records')}
>
</button>
<button
className={activeSubTab === 'mcp-account-recharge-records' ? 'active' : ''}
onClick={() => navigate('/finance/mcp-account-recharge-records')}
>
MCP账号管理
</button>
<button
className={activeSubTab === 'model-account-recharge-records' ? 'active' : ''}
onClick={() => navigate('/finance/model-account-recharge-records')}
>
</button>
</div>
</nav>
)}
{/* 系统管理页面的子导航 */}
{activeTab === 'admin' && (
<nav className="subnav">
<div className="segment" data-tabs="admin">
{/* 暂时隐藏用户项目配额菜单 */}
{/* <button
className={activeSubTab === 'user-project-quota' ? 'active' : ''}
onClick={() => navigate('/system/user-project-quota')}
>
用户项目配额
</button> */}
<button
className={activeSubTab === 'user-management' ? 'active' : ''}
onClick={() => navigate('/system/user-management')}
>
</button>
<button
className={activeSubTab === 'role-management' ? 'active' : ''}
onClick={() => navigate('/system/role-management')}
>
</button>
<button
className={activeSubTab === 'goalfymax-users' ? 'active' : ''}
onClick={() => navigate('/system/goalfymax-users')}
>
GoalfyMax用户
</button>
<button
className={activeSubTab === 'user-level-configs' ? 'active' : ''}
onClick={() => navigate('/system/user-level-configs')}
>
</button>
<button
className={activeSubTab === 'system-configs' ? 'active' : ''}
onClick={() => navigate('/system/system-configs')}
>
</button>
<button
className={activeSubTab === 'audit-logs' ? 'active' : ''}
onClick={() => navigate('/system/audit-logs')}
>
</button>
</div>
</nav>
)}
<main className="content">
{children}
</main>
<footer className="footer">© 2025 Goalfy · Internal use only</footer>
</section>
{mobileOpen && (
<div
className="mobile-backdrop"
onClick={() => setMobileOpen(false)}
/>
)}
</div>
);
};
export default Layout;

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { usePagePermissions } from '../hooks/usePagePermissions';
interface PagePermissionGuardProps {
pagePath: string;
action?: string;
children: React.ReactNode;
fallback?: React.ReactNode;
}
const PagePermissionGuard: React.FC<PagePermissionGuardProps> = ({
pagePath,
action = 'access',
children,
fallback = null
}) => {
const { hasPagePermission } = usePagePermissions();
const hasAccess = hasPagePermission(pagePath, action);
return hasAccess ? <>{children}</> : <>{fallback}</>;
};
export default PagePermissionGuard;

View File

@@ -0,0 +1,57 @@
import React from 'react';
import { usePermissions } from '../hooks/usePermissions';
interface PermissionGuardProps {
permission?: string;
permissions?: string[];
requireAll?: boolean;
role?: string;
roles?: string[];
requireAllRoles?: boolean;
children: React.ReactNode;
fallback?: React.ReactNode;
}
const PermissionGuard: React.FC<PermissionGuardProps> = ({
permission,
permissions,
requireAll = false,
role,
roles,
requireAllRoles = false,
children,
fallback = null
}) => {
const {
hasPermission,
hasAnyPermission,
hasAllPermissions,
hasRole,
hasAnyRole,
hasAllRoles
} = usePermissions();
let hasAccess = false;
// 检查权限
if (permission) {
hasAccess = hasPermission(permission);
console.log(`🔐 [PermissionGuard] 检查权限 "${permission}": ${hasAccess}`);
} else if (permissions) {
hasAccess = requireAll ? hasAllPermissions(permissions) : hasAnyPermission(permissions);
console.log(`🔐 [PermissionGuard] 检查权限组 "${permissions}": ${hasAccess}`);
}
// 检查角色
if (role) {
hasAccess = hasRole(role);
console.log(`👤 [PermissionGuard] 检查角色 "${role}": ${hasAccess}`);
} else if (roles) {
hasAccess = requireAllRoles ? hasAllRoles(roles) : hasAnyRole(roles);
console.log(`👤 [PermissionGuard] 检查角色组 "${roles}": ${hasAccess}`);
}
return hasAccess ? <>{children}</> : <>{fallback}</>;
};
export default PermissionGuard;

View File

@@ -0,0 +1,142 @@
import React from 'react';
import { Card, Row, Col, Typography } from 'antd';
import { QuotaHistoryItem } from '../types/quota';
import dayjs from 'dayjs';
const { Title } = Typography;
interface QuotaChartsProps {
data: QuotaHistoryItem[];
loading?: boolean;
}
const QuotaCharts: React.FC<QuotaChartsProps> = ({
data,
loading = false
}) => {
// 计算每日使用量
const dailyUsage = React.useMemo(() => {
const dailyMap = new Map<string, number>();
data.forEach(item => {
const day = item.day;
dailyMap.set(day, (dailyMap.get(day) || 0) + item.quota_used);
});
return Array.from(dailyMap.entries())
.map(([date, quota]) => ({ date, quota }))
.sort((a, b) => dayjs(a.date).unix() - dayjs(b.date).unix());
}, [data]);
// 计算用户使用量排行
const topUsers = React.useMemo(() => {
const userMap = new Map<string, number>();
data.forEach(item => {
userMap.set(item.user_id, (userMap.get(item.user_id) || 0) + item.quota_used);
});
return Array.from(userMap.entries())
.map(([user_id, quota_used]) => ({ user_id, quota_used }))
.sort((a, b) => b.quota_used - a.quota_used)
.slice(0, 10);
}, [data]);
// 计算模型使用量排行
const topModels = React.useMemo(() => {
const modelMap = new Map<string, number>();
data.forEach(item => {
modelMap.set(item.model, (modelMap.get(item.model) || 0) + item.quota_used);
});
return Array.from(modelMap.entries())
.map(([model, quota_used]) => ({ model, quota_used }))
.sort((a, b) => b.quota_used - a.quota_used)
.slice(0, 10);
}, [data]);
return (
<Row gutter={[16, 16]}>
<Col xs={24} lg={12}>
<Card loading={loading}>
<Title level={5}> Token 使</Title>
<div style={{ height: 300, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center', color: '#999' }}>
<div>📈</div>
<div></div>
<div style={{ fontSize: '12px', marginTop: '8px' }}>
: {dailyUsage.length}
</div>
</div>
</div>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card loading={loading}>
<Title level={5}> Token 使</Title>
<div style={{ height: 300, overflowY: 'auto' }}>
{topUsers.map((user, index) => (
<div key={user.user_id} style={{
display: 'flex',
justifyContent: 'space-between',
padding: '8px 0',
borderBottom: index < topUsers.length - 1 ? '1px solid #f0f0f0' : 'none'
}}>
<span>
<span style={{
display: 'inline-block',
width: '20px',
textAlign: 'center',
fontWeight: 'bold',
color: index < 3 ? '#ff4d4f' : '#666'
}}>
{index + 1}
</span>
{user.user_id}
</span>
<span style={{ fontWeight: 'bold', color: '#1890ff' }}>
{user.quota_used.toLocaleString()}
</span>
</div>
))}
</div>
</Card>
</Col>
<Col xs={24}>
<Card loading={loading}>
<Title level={5}> Token 使</Title>
<div style={{ height: 300, overflowY: 'auto' }}>
{topModels.map((model, index) => (
<div key={model.model} style={{
display: 'flex',
justifyContent: 'space-between',
padding: '8px 0',
borderBottom: index < topModels.length - 1 ? '1px solid #f0f0f0' : 'none'
}}>
<span>
<span style={{
display: 'inline-block',
width: '20px',
textAlign: 'center',
fontWeight: 'bold',
color: index < 3 ? '#ff4d4f' : '#666'
}}>
{index + 1}
</span>
{model.model}
</span>
<span style={{ fontWeight: 'bold', color: '#52c41a' }}>
{model.quota_used.toLocaleString()}
</span>
</div>
))}
</div>
</Card>
</Col>
</Row>
);
};
export default QuotaCharts;

View File

@@ -0,0 +1,162 @@
import React from 'react';
import {
Card,
Form,
DatePicker,
Input,
Select,
Button,
Space,
Row,
Col,
Typography
} from 'antd';
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
const { Title } = Typography;
const { RangePicker } = DatePicker;
const { Option } = Select;
interface QuotaFiltersProps {
onSearch: (values: any) => void;
onReset: () => void;
loading?: boolean;
}
const QuotaFilters: React.FC<QuotaFiltersProps> = ({
onSearch,
onReset,
loading = false
}) => {
const [form] = Form.useForm();
const handleSearch = (values: any) => {
const searchParams = {
...values,
start_date: values.dateRange?.[0]?.format('YYYY-MM-DD'),
end_date: values.dateRange?.[1]?.format('YYYY-MM-DD'),
};
delete searchParams.dateRange;
onSearch(searchParams);
};
const handleReset = () => {
form.resetFields();
onReset();
};
return (
<Card style={{ marginBottom: 16 }}>
<Title level={4} style={{ marginBottom: 16 }}>
</Title>
<Form
form={form}
layout="vertical"
onFinish={handleSearch}
initialValues={{
dateRange: [dayjs().subtract(7, 'day'), dayjs()],
period: 'daily'
}}
>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} lg={8}>
<Form.Item
label="时间范围"
name="dateRange"
rules={[{ required: true, message: '请选择时间范围' }]}
>
<RangePicker
style={{ width: '100%' }}
format="YYYY-MM-DD"
placeholder={['开始日期', '结束日期']}
/>
</Form.Item>
</Col>
<Col xs={24} sm={12} lg={8}>
<Form.Item
label="统计周期"
name="period"
>
<Select placeholder="选择统计周期">
<Option value="daily"></Option>
<Option value="monthly"></Option>
</Select>
</Form.Item>
</Col>
<Col xs={24} sm={12} lg={8}>
<Form.Item
label="用户ID"
name="user_id"
>
<Input placeholder="输入用户ID" />
</Form.Item>
</Col>
<Col xs={24} sm={12} lg={8}>
<Form.Item
label="API组"
name="api_group"
>
<Input placeholder="输入API组" />
</Form.Item>
</Col>
<Col xs={24} sm={12} lg={8}>
<Form.Item
label="项目ID"
name="project_id"
>
<Input placeholder="输入项目ID" />
</Form.Item>
</Col>
<Col xs={24} sm={12} lg={8}>
<Form.Item
label="分组维度"
name="group_by"
>
<Select
mode="multiple"
placeholder="选择分组维度"
style={{ width: '100%' }}
>
<Option value="user_id"></Option>
<Option value="api_group">API组</Option>
<Option value="project_id"></Option>
<Option value="model"></Option>
<Option value="day"></Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item>
<Space>
<Button
type="primary"
htmlType="submit"
icon={<SearchOutlined />}
loading={loading}
>
</Button>
<Button
onClick={handleReset}
icon={<ReloadOutlined />}
>
</Button>
</Space>
</Form.Item>
</Form>
</Card>
);
};
export default QuotaFilters;

View File

@@ -0,0 +1,118 @@
import React from 'react';
import { Table, Tag, Card, Typography, Space } from 'antd';
import { QuotaHistoryItem } from '../types/quota';
import dayjs from 'dayjs';
const { Title } = Typography;
interface QuotaHistoryTableProps {
data: QuotaHistoryItem[];
loading?: boolean;
}
const QuotaHistoryTable: React.FC<QuotaHistoryTableProps> = ({
data,
loading = false
}) => {
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '用户ID',
dataIndex: 'user_id',
key: 'user_id',
width: 120,
},
{
title: 'API组',
dataIndex: 'api_group',
key: 'api_group',
width: 120,
render: (apiGroup: string) => (
<Tag color="blue">{apiGroup}</Tag>
),
},
{
title: '项目ID',
dataIndex: 'project_id',
key: 'project_id',
width: 120,
},
{
title: '账户',
dataIndex: 'account',
key: 'account',
width: 120,
},
{
title: '模型',
dataIndex: 'model',
key: 'model',
width: 150,
render: (model: string) => (
<Tag color="green">{model}</Tag>
),
},
{
title: 'Token 使用量',
dataIndex: 'quota_used',
key: 'quota_used',
width: 120,
render: (quota: number) => (
<span style={{ fontWeight: 'bold', color: '#1890ff' }}>
{quota.toLocaleString()}
</span>
),
sorter: (a: QuotaHistoryItem, b: QuotaHistoryItem) => a.quota_used - b.quota_used,
},
{
title: '日期',
dataIndex: 'day',
key: 'day',
width: 120,
render: (day: string) => dayjs(day).format('YYYY-MM-DD'),
sorter: (a: QuotaHistoryItem, b: QuotaHistoryItem) =>
dayjs(a.day).unix() - dayjs(b.day).unix(),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 160,
render: (createdAt: string) => dayjs(createdAt).format('YYYY-MM-DD HH:mm:ss'),
sorter: (a: QuotaHistoryItem, b: QuotaHistoryItem) =>
dayjs(a.created_at).unix() - dayjs(b.created_at).unix(),
},
];
return (
<Card>
<Title level={4} style={{ marginBottom: 16 }}>
Token 使
</Title>
<Table
columns={columns}
dataSource={data}
loading={loading}
rowKey="id"
pagination={{
pageSize: 20,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) =>
`${range[0]}-${range[1]} 条,共 ${total} 条记录`,
}}
scroll={{ x: 1200 }}
size="small"
/>
</Card>
);
};
export default QuotaHistoryTable;

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { Card, Row, Col, Statistic, Typography } from 'antd';
import {
UserOutlined,
ProjectOutlined,
ApiOutlined,
DatabaseOutlined
} from '@ant-design/icons';
const { Title } = Typography;
interface QuotaStatsProps {
totalQuota: number;
totalUsers: number;
totalProjects: number;
totalModels: number;
loading?: boolean;
}
const QuotaStats: React.FC<QuotaStatsProps> = ({
totalQuota,
totalUsers,
totalProjects,
totalModels,
loading = false
}) => {
return (
<div style={{ marginBottom: 24 }}>
<Title level={4} style={{ marginBottom: 16 }}>
Token 使
</Title>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="总 Token 使用量"
value={totalQuota}
precision={2}
loading={loading}
prefix={<DatabaseOutlined />}
suffix="tokens"
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="活跃用户数"
value={totalUsers}
loading={loading}
prefix={<UserOutlined />}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="项目数量"
value={totalProjects}
loading={loading}
prefix={<ProjectOutlined />}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="模型数量"
value={totalModels}
loading={loading}
prefix={<ApiOutlined />}
/>
</Card>
</Col>
</Row>
</div>
);
};
export default QuotaStats;

View File

@@ -0,0 +1,87 @@
import React, { useEffect, useState } from 'react'
import { QuotaApiService } from '../services/api'
import type { PageResponse, UserProjectQuotaItem } from '../types/userProjectQuota'
export default function UserProjectQuotaPage() {
const [items, setItems] = useState<UserProjectQuotaItem[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [size, setSize] = useState(10)
const [userId, setUserId] = useState('')
const [enabled, setEnabled] = useState('')
const [form, setForm] = useState<UserProjectQuotaItem>({ id:0, user_id: '', project_limit: 0, vm_limit: 0, process_limit: 0, enabled: true, description: '' })
const [edit, setEdit] = useState<UserProjectQuotaItem | null>(null)
const load = async () => {
const res: PageResponse<UserProjectQuotaItem> = await QuotaApiService.listUserProjectQuota({ user_id: userId, enabled, page, size })
const list = res?.data?.data || res?.data?.items || []
setItems(Array.isArray(list) ? list : [])
setTotal(res?.data?.total || 0)
}
useEffect(()=>{ load() }, [page, size])
return (
<section className="panel">
<h2></h2>
<div className="desc">//</div>
<div style={{display:'flex', gap:8, alignItems:'center'}}>
<input placeholder="user_id" value={userId} onChange={e=>setUserId(e.target.value)} />
<select value={enabled} onChange={e=>setEnabled(e.target.value)}>
<option value="">enabled=</option>
<option value="true">enabled=true</option>
<option value="false">enabled=false</option>
</select>
<button className="btn" onClick={()=>{ setPage(1); load() }}></button>
</div>
<div className="panel" style={{marginTop:12}}>
<h3>{edit ? '编辑配额' : '创建配额'}</h3>
<div style={{display:'grid', gridTemplateColumns:'repeat(3, 1fr)', gap:8}}>
<input placeholder="user_id" value={edit ? edit.user_id : form.user_id} onChange={e=> edit? setEdit({...edit, user_id:e.target.value}) : setForm({...form, user_id:e.target.value}) } disabled={!!edit} />
<input placeholder="project_limit" type="number" value={edit? edit.project_limit : form.project_limit} onChange={e=> edit? setEdit({...edit, project_limit:Number(e.target.value)}) : setForm({...form, project_limit:Number(e.target.value)}) } />
<input placeholder="vm_limit" type="number" value={edit? edit.vm_limit : form.vm_limit} onChange={e=> edit? setEdit({...edit, vm_limit:Number(e.target.value)}) : setForm({...form, vm_limit:Number(e.target.value)}) } />
<input placeholder="process_limit" type="number" value={edit? edit.process_limit : form.process_limit} onChange={e=> edit? setEdit({...edit, process_limit:Number(e.target.value)}) : setForm({...form, process_limit:Number(e.target.value)}) } />
<label><input type="checkbox" checked={edit? !!edit.enabled : !!form.enabled} onChange={e=> edit? setEdit({...edit, enabled:e.target.checked}) : setForm({...form, enabled:e.target.checked}) } /> enabled</label>
<input placeholder="description" value={edit? (edit.description||'') : form.description} onChange={e=> edit? setEdit({...edit, description:e.target.value}) : setForm({...form, description:e.target.value}) } />
</div>
<div style={{display:'flex', gap:8, marginTop:8}}>
{!edit && (<button className="btn" onClick={async ()=>{ await QuotaApiService.createUserProjectQuota({ user_id:form.user_id, project_limit:form.project_limit, vm_limit:form.vm_limit, process_limit:form.process_limit, enabled:form.enabled, description:form.description }); setForm({ id:0, user_id:'', project_limit:0, vm_limit:0, process_limit:0, enabled:true, description:'' }); load() }}></button>)}
{edit && (<>
<button className="btn primary" onClick={async ()=>{ await QuotaApiService.updateUserProjectQuota(edit.id, { project_limit:edit.project_limit, vm_limit:edit.vm_limit, process_limit:edit.process_limit, enabled:edit.enabled, description:edit.description }); setEdit(null); load() }}></button>
<button className="btn" onClick={()=>setEdit(null)}></button>
</>)}
</div>
</div>
<div className="panel full" style={{marginTop:12}}>
<div className="table-container">
<table className="table">
<thead><tr><th>ID</th><th>user_id</th><th>project_limit</th><th>vm_limit</th><th>process_limit</th><th>enabled</th><th></th></tr></thead>
<tbody>
{items.map((it)=> (
<tr key={it.id}>
<td>{it.id}</td>
<td>{it.user_id}</td>
<td>{it.project_limit}</td>
<td>{it.vm_limit}</td>
<td>{it.process_limit}</td>
<td>{String(it.enabled)}</td>
<td>
<button className="btn" onClick={()=>setEdit({...it})}></button>
<button className="btn" onClick={async ()=>{ await QuotaApiService.deleteUserProjectQuota(it.id); load() }}></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
)
}