feat():learning后台管理前端页面初始化
This commit is contained in:
90
src/components/AuthGuard.tsx
Normal file
90
src/components/AuthGuard.tsx
Normal 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;
|
||||
108
src/components/DynamicMenu.css
Normal file
108
src/components/DynamicMenu.css
Normal 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;
|
||||
}
|
||||
250
src/components/DynamicMenu.tsx
Normal file
250
src/components/DynamicMenu.tsx
Normal 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
391
src/components/Layout.tsx
Normal 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;
|
||||
24
src/components/PagePermissionGuard.tsx
Normal file
24
src/components/PagePermissionGuard.tsx
Normal 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;
|
||||
57
src/components/PermissionGuard.tsx
Normal file
57
src/components/PermissionGuard.tsx
Normal 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;
|
||||
142
src/components/QuotaCharts.tsx
Normal file
142
src/components/QuotaCharts.tsx
Normal 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;
|
||||
|
||||
|
||||
162
src/components/QuotaFilters.tsx
Normal file
162
src/components/QuotaFilters.tsx
Normal 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;
|
||||
|
||||
|
||||
118
src/components/QuotaHistoryTable.tsx
Normal file
118
src/components/QuotaHistoryTable.tsx
Normal 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;
|
||||
|
||||
|
||||
82
src/components/QuotaStats.tsx
Normal file
82
src/components/QuotaStats.tsx
Normal 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;
|
||||
|
||||
|
||||
87
src/components/UserProjectQuotaPage.tsx
Normal file
87
src/components/UserProjectQuotaPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user