Feature: Multi-tenant + Billing + Analytics - 3 new modules with 40+ API endpoints
This commit is contained in:
1 parent
e9ec429d21
commit
aec63eaf56
10 files changed
+2504
-497
No files matched your search
@@ -42,6 +42,9 @@ import PrivacySettings from '@/pages/admin/PrivacySettings';
|
||||
import ImBots from '@/pages/admin/ImBots';
|
||||
import Promotion from '@/pages/admin/Promotion';
|
||||
import Achievement from '@/pages/admin/Achievement';
|
||||
import Tenant from '@/pages/admin/Tenant';
|
||||
import Billing from '@/pages/admin/Billing';
|
||||
import Analytics from '@/pages/admin/Analytics';
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
@@ -127,6 +130,9 @@ const App: React.FC = () => {
|
||||
<Route path="privacy" element={<PrivacySettings />} />
|
||||
<Route path="promotion" element={<Promotion />} />
|
||||
<Route path="achievement" element={<Achievement />} />
|
||||
<Route path="tenant" element={<Tenant />} />
|
||||
<Route path="billing" element={<Billing />} />
|
||||
<Route path="analytics" element={<Analytics />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
|
||||
+61
-42
@@ -378,48 +378,7 @@ export const adminAPI = {
|
||||
const qs = p.toString();
|
||||
return get<any>(`/admin/membership/user-memberships${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
assignPlan: (userId: string, planId: string, days: number) => put<any>(`/admin/membership/users/${userId}/assign-plan?plan_id=${planId}&duration_days=${days}`),
|
||||
revokeMembership: (userId: string) => put<any>(`/admin/membership/users/${userId}/revoke`),
|
||||
getUserUsage: (userId: string) => get<any>(`/admin/membership/users/${userId}/usage`),
|
||||
getMembershipStats: () => get<any>('/admin/membership/stats'),
|
||||
// 广告管理
|
||||
getAds: () => get<any[]>('/admin/ads/'),
|
||||
addAd: (data: any) => post<any>('/admin/ads/', data),
|
||||
updateAd: (id: string, data: any) => put<any>(`/admin/ads/${id}`, data),
|
||||
deleteAd: (id: string) => del<any>(`/admin/ads/${id}`),
|
||||
toggleAd: (id: string) => put<any>(`/admin/ads/${id}/toggle`),
|
||||
// 应用市场审核
|
||||
getPendingApps: () => get<any[]>('/admin/market/apps/pending'),
|
||||
getMarketOverview: () => get<any>('/admin/market/analytics/overview'),
|
||||
getTopApps: (metric: string, limit: number) => get<any>(`/admin/market/analytics/top-apps?metric=${metric}&limit=${limit}`),
|
||||
getRecentReviews: () => get<any>('/admin/market/analytics/recent-reviews'),
|
||||
getAdminMarketApps: (query: string) => get<any>(`/admin/market/apps?${query}`),
|
||||
getMarketCategories: () => get<any>('/admin/market/categories'),
|
||||
setFeaturedApp: (id: string, featured: boolean, order: number) => put<any>(`/admin/market/apps/${id}/featured`, { featured, featured_order: order }),
|
||||
deleteMarketApp: (id: string) => del<any>(`/admin/market/apps/${id}`),
|
||||
updateMarketApp: (id: string, data: any) => put<any>(`/admin/market/apps/${id}`, data),
|
||||
approveApp: (id: string) => put<any>(`/admin/market/apps/${id}/approve`),
|
||||
rejectApp: (id: string) => put<any>(`/admin/market/apps/${id.includes('?') ? id.split('?')[0] + '/reject?' + id.split('?')[1] : id + '/reject'}`),
|
||||
getAdminApps: () => get<any[]>('/admin/market/apps'),
|
||||
updateApp: (id: string, data: any) => put<any>(`/admin/market/apps/${id}`, data),
|
||||
getCoupons: () => get<any[]>('/admin/market/coupons'),
|
||||
addCoupon: (data: any) => post<any>('/admin/market/coupons', data),
|
||||
updateCoupon: (id: string, data: any) => put<any>(`/admin/market/coupons/${id}`, data),
|
||||
deleteCoupon: (id: string) => del<any>(`/admin/market/coupons/${id}`),
|
||||
assignCoupon: (id: string, userId: string) => post<any>(`/admin/market/coupons/${id}/assign`, { user_id: userId }),
|
||||
// 日志与风控
|
||||
getOperationLogs: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any[]>(`/admin/logs/operations${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
getTokenLogs: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any[]>(`/admin/logs/token-logs${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
getPaymentLogs: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
assignPlan: (userId: string, planId: string, days: number) => put<any>(`/admin/membership/users/${userId}/assign-plan?plan_id=${t sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any[]>(`/admin/logs/payment-logs${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
@@ -908,4 +867,64 @@ export const evolutionAPI = {
|
||||
checkAchievementProgress: () => post<any>('/admin/achievement/check-progress'),
|
||||
getAchievementLeaderboard: (params?: string) => get<any>(`/admin/achievement/leaderboard${params || ''}`),
|
||||
|
||||
// 多租户管理
|
||||
getTenants: (page?: number, keyword?: string, status?: string) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (page) sp.set('page', String(page));
|
||||
if (keyword) sp.set('keyword', keyword);
|
||||
if (status) sp.set('status', status);
|
||||
sp.set('page_size', '20');
|
||||
return get<any>(`/admin/tenant/list${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
createTenant: (params: string) => post<any>(`/admin/tenant/create${params}`),
|
||||
getTenantDetail: (id: string) => get<any>(`/admin/tenant/${id}`),
|
||||
updateTenant: (id: string, params: string) => put<any>(`/admin/tenant/${id}${params}`),
|
||||
deleteTenant: (id: string) => del<any>(`/admin/tenant/${id}`),
|
||||
getTenantMembers: (tenantId: string, page?: number) => {
|
||||
const sp = new URLSearchParams();
|
||||
sp.set('page', String(page || 1));
|
||||
sp.set('page_size', '100');
|
||||
return get<any>(`/admin/tenant/${tenantId}/members?${sp.toString()}`);
|
||||
},
|
||||
addTenantMember: (tenantId: string, params: string) => post<any>(`/admin/tenant/${tenantId}/members/add${params}`),
|
||||
updateTenantMember: (tenantId: string, memberId: string, params: string) => put<any>(`/admin/tenant/${tenantId}/members/${memberId}${params}`),
|
||||
removeTenantMember: (tenantId: string, memberId: string) => del<any>(`/admin/tenant/${tenantId}/members/${memberId}`),
|
||||
getTenantQuotas: (tenantId: string) => get<any>(`/admin/tenant/${tenantId}/quotas`),
|
||||
updateTenantQuota: (tenantId: string, params: string) => put<any>(`/admin/tenant/${tenantId}/quotas${params}`),
|
||||
getTenantInvitations: (tenantId: string) => get<any>(`/admin/tenant/${tenantId}/invitations`),
|
||||
createTenantInvitation: (tenantId: string, params: string) => post<any>(`/admin/tenant/${tenantId}/invitations${params}`),
|
||||
getTenantStats: () => get<any>('/admin/tenant/stats/overview'),
|
||||
|
||||
// 计费与支付
|
||||
getBillingRules: (page?: number) => get<any>(`/admin/billing/rules?page=${page || 1}&page_size=100`),
|
||||
createBillingRule: (params: string) => post<any>(`/admin/billing/rules/create${params}`),
|
||||
updateBillingRule: (id: string, params: string) => put<any>(`/admin/billing/rules/${id}${params}`),
|
||||
deleteBillingRule: (id: string) => del<any>(`/admin/billing/rules/${id}`),
|
||||
getBillingPackages: () => get<any>('/admin/billing/packages'),
|
||||
createBillingPackage: (params: string) => post<any>(`/admin/billing/packages/create${params}`),
|
||||
updateBillingPackage: (id: string, params: string) => put<any>(`/admin/billing/packages/${id}${params}`),
|
||||
deleteBillingPackage: (id: string) => del<any>(`/admin/billing/packages/${id}`),
|
||||
getBillingTransactions: (page?: number) => get<any>(`/admin/billing/transactions?page=${page || 1}&page_size=20`),
|
||||
getInvoices: (page?: number) => get<any>(`/admin/billing/invoices?page=${page || 1}&page_size=20`),
|
||||
generateInvoice: (params: string) => post<any>(`/admin/billing/invoices/generate${params}`),
|
||||
getPaymentConfigs: () => get<any>('/admin/billing/payment-configs'),
|
||||
updatePaymentConfig: (id: string, params: string) => put<any>(`/admin/billing/payment-configs/${id}${params}`),
|
||||
getBillingStats: () => get<any>('/admin/billing/stats/overview'),
|
||||
|
||||
// 数据分析
|
||||
getAnalyticsOverview: (days?: number) => get<any>(`/admin/analytics/overview?days=${days || 30}`),
|
||||
getAnalyticsApiTrends: (days?: number) => get<any>(`/admin/analytics/trends/api-calls?days=${days || 7}`),
|
||||
getAnalyticsTokenTrends: (days?: number) => get<any>(`/admin/analytics/trends/tokens?days=${days || 7}`),
|
||||
getAnalyticsUserTrends: (days?: number) => get<any>(`/admin/analytics/trends/users?days=${days || 30}`),
|
||||
getAnalyticsRevenueTrends: (days?: number) => get<any>(`/admin/analytics/trends/revenue?days=${days || 30}`),
|
||||
getAnalyticsModelUsage: (days?: number) => get<any>(`/admin/analytics/models/usage?days=${days || 30}`),
|
||||
getAnalyticsDashboards: () => get<any>('/admin/analytics/dashboards'),
|
||||
createAnalyticsDashboard: (params: string) => post<any>(`/admin/analytics/dashboards/create${params}`),
|
||||
deleteAnalyticsDashboard: (id: string) => del<any>(`/admin/analytics/dashboards/${id}`),
|
||||
getAnalyticsEvents: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||
return get<any>(`/admin/analytics/events${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
|
||||
};
|
||||
@@ -22,6 +22,9 @@ const adminNavItems = [
|
||||
{ path: '/admin/privacy', icon: 'privacy', label: '隐私与本地代理' },
|
||||
{ path: '/admin/promotion', icon: 'promotion', label: '推广系统' },
|
||||
{ path: '/admin/achievement', icon: 'achievement', label: '成就系统' },
|
||||
{ path: '/admin/tenant', icon: 'tenant', label: '多租户管理' },
|
||||
{ path: '/admin/billing', icon: 'billing', label: '计费与支付' },
|
||||
{ path: '/admin/analytics', icon: 'analytics', label: '数据分析' },
|
||||
{ path: '/admin/im-bots', icon: 'imbot', label: 'IM 机器人' },
|
||||
];
|
||||
|
||||
@@ -52,7 +55,24 @@ const adminIconPaths: Record<string, JSX.Element> = {
|
||||
</svg>,
|
||||
achievement: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.27 7.82 22 7 14.14l-5-4.87 6.91-1.01L12 2z"/>
|
||||
</svg>,};
|
||||
</svg>,
|
||||
tenant: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 21V8l9-5 9 5v13"/>
|
||||
<path d="M9 21v-6h6v6"/>
|
||||
<path d="M3 13h18"/>
|
||||
</svg>,
|
||||
billing: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="5" width="20" height="14" rx="2"/>
|
||||
<line x1="2" y1="10" x2="22" y2="10"/>
|
||||
<line x1="6" y1="15" x2="10" y2="15"/>
|
||||
</svg>,
|
||||
analytics: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
<line x1="3" y1="20" x2="21" y2="20"/>
|
||||
</svg>,
|
||||
};
|
||||
|
||||
const AdminLayout: React.FC = () => {
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
interface Overview {
|
||||
total_users: number; active_users: number; new_users: number;
|
||||
api_calls: number; avg_response_time: number; error_rate: number;
|
||||
tokens_used: number; total_cost: number; total_chats: number; total_messages: number; revenue: number;
|
||||
}
|
||||
interface TrendItem { date: string; calls?: number; errors?: number; tokens?: number; new_users?: number; cumulative?: number; revenue?: number; consumption?: number; }
|
||||
interface ModelUsage { model_name: string; call_count: number; total_tokens: number; total_cost: number; avg_tokens: number; }
|
||||
|
||||
const Analytics: React.FC = () => {
|
||||
const [days, setDays] = useState(7);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [overview, setOverview] = useState<Overview | null>(null);
|
||||
const [apiTrend, setApiTrend] = useState<TrendItem[]>([]);
|
||||
const [tokenTrend, setTokenTrend] = useState<{ daily: any[]; model_distribution: Record<string, number> }>({ daily: [], model_distribution: {} });
|
||||
const [userTrend, setUserTrend] = useState<TrendItem[]>([]);
|
||||
const [revenueTrend, setRevenueTrend] = useState<TrendItem[]>([]);
|
||||
const [modelUsage, setModelUsage] = useState<ModelUsage[]>([]);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [ov, api, tok, usr, rev, mu] = await Promise.all([
|
||||
adminAPI.getAnalyticsOverview(days),
|
||||
adminAPI.getAnalyticsApiTrends(days),
|
||||
adminAPI.getAnalyticsTokenTrends(days),
|
||||
adminAPI.getAnalyticsUserTrends(days),
|
||||
adminAPI.getAnalyticsRevenueTrends(days),
|
||||
adminAPI.getAnalyticsModelUsage(days),
|
||||
]);
|
||||
setOverview((ov as any)?.data || ov);
|
||||
setApiTrend((api as any)?.data || []);
|
||||
const td = (tok as any)?.data || { daily: [], model_distribution: {} };
|
||||
setTokenTrend(td);
|
||||
setUserTrend((usr as any)?.data || []);
|
||||
setRevenueTrend((rev as any)?.data || []);
|
||||
setModelUsage((mu as any)?.data || []);
|
||||
} catch (e) { console.error('fetchAnalytics:', e); }
|
||||
setLoading(false);
|
||||
}, [days]);
|
||||
|
||||
useEffect(() => { fetchAll(); }, [fetchAll]);
|
||||
|
||||
const renderBar = (data: TrendItem[], key: string, color: string, label: string) => {
|
||||
if (!data.length) return <div style={{ textAlign: 'center', padding: '40px', color: '#64748b' }}>暂无数据</div>;
|
||||
const values = data.map((d: any) => d[key] || 0);
|
||||
const max = Math.max(...values, 1);
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '8px', height: '180px', padding: '0 16px' }}>
|
||||
{data.map((d: any, i) => {
|
||||
const v = d[key] || 0;
|
||||
const h = (v / max) * 140;
|
||||
return (
|
||||
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', minWidth: '40px' }}>
|
||||
<div style={{ fontSize: '10px', color: '#94a3b8', marginBottom: '4px' }}>{v > 9999 ? `${(v/1000).toFixed(0)}k` : v}</div>
|
||||
<div style={{ width: '100%', height: `${h}px`, background: `linear-gradient(180deg, ${color}, ${color}aa)`, borderRadius: '4px 4px 0 0', transition: 'height 0.3s' }} />
|
||||
<div style={{ fontSize: '10px', color: '#64748b', marginTop: '6px' }}>{d.date?.substring(5)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLine = (data: TrendItem[], key: string, color: string) => {
|
||||
if (!data.length) return <div style={{ textAlign: 'center', padding: '40px', color: '#64748b' }}>暂无数据</div>;
|
||||
const values = data.map((d: any) => d[key] || 0);
|
||||
const max = Math.max(...values, 1);
|
||||
const w = 100 / (data.length - 1 || 1);
|
||||
const points = data.map((d: any, i) => `${i * w},${100 - ((d[key] || 0) / max) * 90}`).join(' ');
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style={{ width: '100%', height: '180px' }}>
|
||||
<polyline points={points} fill="none" stroke={color} strokeWidth="1.5" />
|
||||
{data.map((d: any, i) => (
|
||||
<circle key={i} cx={i * w} cy={100 - ((d[key] || 0) / max) * 90} r="1" fill={color} />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const statCards = overview ? [
|
||||
{ label: '总用户数', value: overview.total_users, color: '#6366f1', icon: 'U' },
|
||||
{ label: '活跃用户', value: overview.active_users, color: '#10b981', icon: 'A' },
|
||||
{ label: '新增用户', value: overview.new_users, color: '#3b82f6', icon: '+' },
|
||||
{ label: 'API调用数', value: overview.api_calls, color: '#f59e0b', icon: 'API' },
|
||||
{ label: 'Token用量', value: overview.tokens_used, color: '#ec4899', icon: 'T' },
|
||||
{ label: '总费用(元)', value: overview.total_cost?.toFixed(2), color: '#ef4444', icon: '¥' },
|
||||
{ label: '对话总数', value: overview.total_chats, color: '#8b5cf6', icon: 'C' },
|
||||
{ label: '消息总数', value: overview.total_messages, color: '#06b6d4', icon: 'M' },
|
||||
] : [];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', color: '#e2e8f0' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>数据分析面板</h1>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{[7, 14, 30, 90].map(d => (
|
||||
<button key={d} onClick={() => setDays(d)} style={{ padding: '6px 16px', borderRadius: '8px', border: 'none', cursor: 'pointer',
|
||||
background: days === d ? '#6366f1' : '#1e293b', color: days === d ? '#fff' : '#94a3b8', fontWeight: 600, fontSize: '13px' }}>{d}天</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? <div style={{ textAlign: 'center', padding: '80px', color: '#64748b' }}>加载中...</div> : (
|
||||
<>
|
||||
{/* Stats grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '24px' }}>
|
||||
{statCards.map((s, i) => (
|
||||
<div key={i} style={{ background: '#1e293b', borderRadius: '12px', padding: '20px', border: `1px solid ${s.color}33`, display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div style={{ width: '48px', height: '48px', borderRadius: '12px', background: `${s.color}22`, color: s.color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '18px', fontWeight: 800 }}>{s.icon}</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '24px', fontWeight: 800, color: s.color }}>{typeof s.value === 'number' ? s.value.toLocaleString() : s.value}</div>
|
||||
<div style={{ fontSize: '12px', color: '#94a3b8', marginTop: '2px' }}>{s.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Charts grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
|
||||
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px' }}>
|
||||
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>API调用趋势 ({days}天)</h3>
|
||||
{renderBar(apiTrend, 'calls', '#f59e0b', '调用次数')}
|
||||
</div>
|
||||
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px' }}>
|
||||
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>Token使用趋势 ({days}天)</h3>
|
||||
{renderLine(tokenTrend.daily, 'total_tokens', '#ec4899')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
|
||||
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px' }}>
|
||||
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>用户增长 ({days}天)</h3>
|
||||
{renderBar(userTrend, 'new_users', '#3b82f6', '新增用户')}
|
||||
</div>
|
||||
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px' }}>
|
||||
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>收入趋势 ({days}天)</h3>
|
||||
{renderBar(revenueTrend, 'revenue', '#10b981', '收入')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model usage */}
|
||||
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px' }}>
|
||||
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '20px' }}>模型使用分析 ({days}天)</h3>
|
||||
{modelUsage.length > 0 ? (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead><tr style={{ borderBottom: '2px solid #334155' }}>
|
||||
{['模型', '调用次数', '总Tokens', '平均Tokens', '费用(元)'].map(h => (
|
||||
<th key={h} style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#94a3b8', fontWeight: 600 }}>{h}</th>
|
||||
))}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{modelUsage.map((m, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid #334155' }}>
|
||||
<td style={{ padding: '10px 16px', fontSize: '14px', fontWeight: 600 }}>{m.model_name}</td>
|
||||
<td style={{ padding: '10px 16px', fontSize: '14px' }}>{m.call_count.toLocaleString()}</td>
|
||||
<td style={{ padding: '10px 16px', fontSize: '14px', color: '#ec4899', fontWeight: 600 }}>{m.total_tokens.toLocaleString()}</td>
|
||||
<td style={{ padding: '10px 16px', fontSize: '13px', color: '#94a3b8' }}>{m.avg_tokens.toLocaleString()}</td>
|
||||
<td style={{ padding: '10px 16px', fontSize: '14px', color: '#ef4444' }}>¥{m.total_cost.toFixed(4)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : <div style={{ textAlign: 'center', padding: '40px', color: '#64748b' }}>暂无模型使用数据</div>}
|
||||
</div>
|
||||
|
||||
{/* Model distribution pie (CSS) */}
|
||||
{Object.keys(tokenTrend.model_distribution || {}).length > 0 && (
|
||||
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px', marginTop: '16px' }}>
|
||||
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '20px' }}>模型使用分布</h3>
|
||||
<div style={{ display: 'flex', gap: '24px', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', borderRadius: '50%', overflow: 'hidden', width: '160px', height: '160px' }}>
|
||||
{(() => {
|
||||
const entries = Object.entries(tokenTrend.model_distribution);
|
||||
const total = entries.reduce((s, [, v]) => s + v, 1);
|
||||
const colors = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#3b82f6', '#8b5cf6', '#06b6d4', '#ef4444'];
|
||||
return entries.map(([model, val], i) => {
|
||||
const pct = (val / total) * 100;
|
||||
return <div key={model} style={{ width: `${pct}%`, background: colors[i % colors.length] }} title={`${model}: ${pct.toFixed(1)}%`} />;
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
{Object.entries(tokenTrend.model_distribution).map(([model, val], i) => {
|
||||
const colors = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#3b82f6', '#8b5cf6', '#06b6d4', '#ef4444'];
|
||||
const total = Object.values(tokenTrend.model_distribution).reduce((s, v) => s + v, 1);
|
||||
return (
|
||||
<div key={model} style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
||||
<div style={{ width: '12px', height: '12px', borderRadius: '3px', background: colors[i % colors.length] }} />
|
||||
<span style={{ fontSize: '13px', color: '#e2e8f0' }}>{model}</span>
|
||||
<span style={{ fontSize: '13px', color: '#94a3b8', marginLeft: 'auto' }}>{((val / total) * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Analytics;
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
interface Rule { id: string; name: string; billing_type: string; model_name: string; rate: number; free_quota: number; status: string; }
|
||||
interface Package { id: string; name: string; tokens: number; bonus_tokens: number; price: number; original_price: number; is_popular: number; status: string; }
|
||||
interface Transaction { id: string; user_id: string; username: string; transaction_type: string; amount: number; tokens: number; status: string; created_at: string; description: string; }
|
||||
interface Stats { total_revenue: number; total_consumption: number; total_transactions: number; pending_invoices: number; active_rules: number; active_packages: number; daily_revenue_7d: { date: string; revenue: number }[]; }
|
||||
|
||||
const Billing: React.FC = () => {
|
||||
const [tab, setTab] = useState<'overview' | 'rules' | 'packages' | 'transactions'>('overview');
|
||||
const [rules, setRules] = useState<Rule[]>([]);
|
||||
const [packages, setPackages] = useState<Package[]>([]);
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchRules = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminAPI.getBillingRules();
|
||||
const d = (res as any)?.data || res;
|
||||
setRules(d?.items || []);
|
||||
} catch (e) { console.error('fetchRules:', e); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const fetchPackages = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminAPI.getBillingPackages();
|
||||
const d = (res as any)?.data || res;
|
||||
setPackages(Array.isArray(d) ? d : d?.items || []);
|
||||
} catch (e) { console.error('fetchPackages:', e); }
|
||||
}, []);
|
||||
|
||||
const fetchTransactions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminAPI.getBillingTransactions(page);
|
||||
const d = (res as any)?.data || res;
|
||||
setTransactions(d?.items || []);
|
||||
setTotal(d?.total || 0);
|
||||
} catch (e) { console.error('fetchTransactions:', e); }
|
||||
setLoading(false);
|
||||
}, [page]);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminAPI.getBillingStats();
|
||||
setStats((res as any)?.data || res);
|
||||
} catch (e) { console.error('fetchStats:', e); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchStats(); }, [fetchStats]);
|
||||
useEffect(() => { if (tab === 'rules') fetchRules(); }, [tab, fetchRules]);
|
||||
useEffect(() => { if (tab === 'packages') fetchPackages(); }, [tab, fetchPackages]);
|
||||
useEffect(() => { if (tab === 'transactions') fetchTransactions(); }, [tab, fetchTransactions, page]);
|
||||
|
||||
const typeColors: Record<string, string> = { per_token: '#6366f1', per_call: '#3b82f6', per_month: '#10b981', hybrid: '#f59e0b' };
|
||||
const txColors: Record<string, string> = { recharge: '#10b981', consumption: '#ef4444', refund: '#f59e0b', gift: '#ec4899' };
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', color: '#e2e8f0' }}>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 700, marginBottom: '24px' }}>计费与支付管理</h1>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '24px' }}>
|
||||
{[
|
||||
{ label: '总收入(元)', value: stats.total_revenue?.toFixed(2) || '0.00', color: '#10b981' },
|
||||
{ label: '总消费(元)', value: stats.total_consumption?.toFixed(2) || '0.00', color: '#ef4444' },
|
||||
{ label: '交易笔数', value: stats.total_transactions || 0, color: '#3b82f6' },
|
||||
{ label: '待付账单', value: stats.pending_invoices || 0, color: '#f59e0b' },
|
||||
].map((s, i) => (
|
||||
<div key={i} style={{ background: '#1e293b', borderRadius: '12px', padding: '20px', border: `1px solid ${s.color}33` }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: 800, color: s.color }}>{s.value}</div>
|
||||
<div style={{ fontSize: '13px', color: '#94a3b8', marginTop: '4px' }}>{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '20px' }}>
|
||||
{([['overview', '概览'], ['rules', '计费规则'], ['packages', '充值套餐'], ['transactions', '交易记录']] as const).map(([k, label]) => (
|
||||
<button key={k} onClick={() => setTab(k)}
|
||||
style={{ padding: '8px 20px', borderRadius: '8px', border: 'none', cursor: 'pointer',
|
||||
background: tab === k ? '#6366f1' : '#1e293b', color: tab === k ? '#fff' : '#94a3b8', fontWeight: 600, fontSize: '14px' }}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Overview */}
|
||||
{tab === 'overview' && stats && (
|
||||
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px' }}>
|
||||
<h3 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '20px' }}>近7天收入趋势</h3>
|
||||
{stats.daily_revenue_7d && stats.daily_revenue_7d.length > 0 ? (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '12px', height: '200px', padding: '0 20px' }}>
|
||||
{stats.daily_revenue_7d.map((d, i) => {
|
||||
const max = Math.max(...stats.daily_revenue_7d.map(r => r.revenue), 1);
|
||||
const h = (d.revenue / max) * 160;
|
||||
return (
|
||||
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<div style={{ fontSize: '11px', color: '#94a3b8', marginBottom: '4px' }}>¥{d.revenue.toFixed(0)}</div>
|
||||
<div style={{ width: '100%', height: `${h}px`, background: 'linear-gradient(180deg, #6366f1, #818cf8)', borderRadius: '4px 4px 0 0' }} />
|
||||
<div style={{ fontSize: '11px', color: '#64748b', marginTop: '6px' }}>{d.date.substring(5)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : <div style={{ textAlign: 'center', padding: '40px', color: '#64748b' }}>暂无收入数据</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rules */}
|
||||
{tab === 'rules' && (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#1e293b', borderRadius: '12px', overflow: 'hidden' }}>
|
||||
<thead><tr style={{ background: '#0f172a' }}>
|
||||
{['规则名称', '计费类型', '关联模型', '单价', '免费额度', '状态'].map(h => (
|
||||
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#94a3b8', fontWeight: 600 }}>{h}</th>
|
||||
))}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{rules.map(r => (
|
||||
<tr key={r.id} style={{ borderBottom: '1px solid #334155' }}>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', fontWeight: 600 }}>{r.name}</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', fontWeight: 600, background: `${typeColors[r.billing_type]}22`, color: typeColors[r.billing_type] }}>{r.billing_type}</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#94a3b8' }}>{r.model_name || '通用'}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px' }}>¥{r.rate.toFixed(6)}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#94a3b8' }}>{r.free_quota?.toLocaleString() || 0}</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<span style={{ color: r.status === 'active' ? '#10b981' : '#64748b', fontSize: '13px' }}>{r.status === 'active' ? '启用' : '停用'}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* Packages */}
|
||||
{tab === 'packages' && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px' }}>
|
||||
{packages.map(p => (
|
||||
<div key={p.id} style={{ background: '#1e293b', borderRadius: '16px', padding: '24px', position: 'relative', border: p.is_popular ? '2px solid #6366f1' : '1px solid #334155' }}>
|
||||
{p.is_popular === 1 && (
|
||||
<div style={{ position: 'absolute', top: '-10px', right: '16px', background: '#6366f1', color: '#fff', padding: '2px 12px', borderRadius: '12px', fontSize: '11px', fontWeight: 700 }}>推荐</div>
|
||||
)}
|
||||
<h3 style={{ fontSize: '18px', fontWeight: 700, marginBottom: '8px' }}>{p.name}</h3>
|
||||
<p style={{ fontSize: '13px', color: '#94a3b8', marginBottom: '16px' }}>{p.description || ''}</p>
|
||||
<div style={{ fontSize: '32px', fontWeight: 800, color: '#6366f1' }}>¥{p.price}</div>
|
||||
{p.original_price && <div style={{ fontSize: '14px', color: '#64748b', textDecoration: 'line-through' }}>¥{p.original_price}</div>}
|
||||
<div style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid #334155', fontSize: '13px', color: '#94a3b8' }}>
|
||||
<div>主额度: <span style={{ color: '#e2e8f0', fontWeight: 600 }}>{p.tokens.toLocaleString()}</span> tokens</div>
|
||||
{p.bonus_tokens > 0 && <div>赠送: <span style={{ color: '#10b981', fontWeight: 600 }}>{p.bonus_tokens.toLocaleString()}</span> tokens</div>}
|
||||
</div>
|
||||
<div style={{ marginTop: '12px', fontSize: '12px' }}>
|
||||
<span style={{ color: p.status === 'active' ? '#10b981' : '#64748b' }}>{p.status === 'active' ? '● 在售' : '○ 已停用'}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transactions */}
|
||||
{tab === 'transactions' && (
|
||||
<>
|
||||
{loading ? <div style={{ textAlign: 'center', padding: '40px', color: '#64748b' }}>加载中...</div> : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#1e293b', borderRadius: '12px', overflow: 'hidden' }}>
|
||||
<thead><tr style={{ background: '#0f172a' }}>
|
||||
{['时间', '用户', '类型', '金额(元)', 'Tokens', '状态', '描述'].map(h => (
|
||||
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#94a3b8', fontWeight: 600 }}>{h}</th>
|
||||
))}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{transactions.map(t => (
|
||||
<tr key={t.id} style={{ borderBottom: '1px solid #334155' }}>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#64748b' }}>{t.created_at?.substring(0, 16)}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px' }}>{t.username || t.user_id.substring(0, 8)}</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', fontWeight: 600, background: `${txColors[t.transaction_type]}22`, color: txColors[t.transaction_type] }}>{t.transaction_type}</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', fontWeight: 600, color: t.transaction_type === 'recharge' ? '#10b981' : '#ef4444' }}>{t.amount > 0 ? '+' : ''}{t.amount.toFixed(2)}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#94a3b8' }}>{t.tokens?.toLocaleString() || '-'}</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<span style={{ color: t.status === 'completed' ? '#10b981' : '#f59e0b', fontSize: '13px' }}>{t.status}</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#64748b' }}>{t.description || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
{transactions.length === 0 && <tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: '#64748b' }}>暂无交易记录</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{total > 20 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '20px' }}>
|
||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1} style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid #475569', background: '#1e293b', color: '#94a3b8', cursor: page <= 1 ? 'not-allowed' : 'pointer' }}>上一页</button>
|
||||
<span style={{ padding: '6px 16px', color: '#94a3b8' }}>第 {page} 页</span>
|
||||
<button onClick={() => setPage(p => p + 1)} disabled={page * 20 >= total} style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid #475569', background: '#1e293b', color: '#94a3b8', cursor: page * 20 >= total ? 'not-allowed' : 'pointer' }}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Billing;
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
interface Tenant {
|
||||
id: string; name: string; slug: string; logo: string; description: string;
|
||||
status: string; max_members: number; max_storage_mb: number;
|
||||
max_api_calls_per_day: number; member_count?: number; created_at: string; expire_at?: string;
|
||||
}
|
||||
interface Member { id: string; user_id: string; username: string; email: string; role: string; status: string; joined_at: string; avatar?: string; }
|
||||
interface Stats { total_tenants: number; active_tenants: number; suspended_tenants: number; total_members: number; pending_invitations: number; }
|
||||
|
||||
const Tenant: React.FC = () => {
|
||||
const [tab, setTab] = useState<'list' | 'members' | 'stats'>('list');
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [selectedTenant, setSelectedTenant] = useState<string>('');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', slug: '', description: '', max_members: 10, max_storage_mb: 10240, max_api_calls_per_day: 10000 });
|
||||
|
||||
const fetchTenants = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminAPI.getTenants(page);
|
||||
const d = (res as any)?.data || res;
|
||||
setTenants(d?.items || []);
|
||||
setTotal(d?.total || 0);
|
||||
} catch (e) { console.error('fetchTenants:', e); }
|
||||
setLoading(false);
|
||||
}, [page]);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await adminAPI.getTenantStats();
|
||||
setStats((res as any)?.data || res);
|
||||
} catch (e) { console.error('fetchStats:', e); }
|
||||
}, []);
|
||||
|
||||
const fetchMembers = useCallback(async () => {
|
||||
if (!selectedTenant) return;
|
||||
try {
|
||||
const res = await adminAPI.getTenantMembers(selectedTenant);
|
||||
const d = (res as any)?.data || res;
|
||||
setMembers(d?.items || []);
|
||||
} catch (e) { console.error('fetchMembers:', e); }
|
||||
}, [selectedTenant]);
|
||||
|
||||
useEffect(() => { fetchTenants(); fetchStats(); }, [fetchTenants, fetchStats]);
|
||||
useEffect(() => { if (tab === 'members' && selectedTenant) fetchMembers(); }, [tab, selectedTenant, fetchMembers]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const params = `?name=${encodeURIComponent(form.name)}&slug=${encodeURIComponent(form.slug)}&description=${encodeURIComponent(form.description)}&max_members=${form.max_members}&max_storage_mb=${form.max_storage_mb}&max_api_calls_per_day=${form.max_api_calls_per_day}`;
|
||||
await adminAPI.createTenant(params);
|
||||
setShowCreate(false);
|
||||
setForm({ name: '', slug: '', description: '', max_members: 10, max_storage_mb: 10240, max_api_calls_per_day: 10000 });
|
||||
fetchTenants();
|
||||
} catch (e) { console.error('create:', e); }
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = { active: '#10b981', suspended: '#f59e0b', terminated: '#ef4444' };
|
||||
const roleColors: Record<string, string> = { owner: '#a78bfa', admin: '#60a5fa', member: '#94a3b8', viewer: '#64748b' };
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', color: '#e2e8f0' }}>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 700, marginBottom: '24px' }}>多租户管理</h1>
|
||||
|
||||
{/* Stats cards */}
|
||||
{stats && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: '16px', marginBottom: '24px' }}>
|
||||
{[
|
||||
{ label: '总租户数', value: stats.total_tenants, color: '#6366f1' },
|
||||
{ label: '活跃租户', value: stats.active_tenants, color: '#10b981' },
|
||||
{ label: '已暂停', value: stats.suspended_tenants, color: '#f59e0b' },
|
||||
{ label: '总成员数', value: stats.total_members, color: '#3b82f6' },
|
||||
{ label: '待处理邀请', value: stats.pending_invitations, color: '#ec4899' },
|
||||
].map((s, i) => (
|
||||
<div key={i} style={{ background: '#1e293b', borderRadius: '12px', padding: '20px', border: `1px solid ${s.color}33` }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: 800, color: s.color }}>{s.value}</div>
|
||||
<div style={{ fontSize: '13px', color: '#94a3b8', marginTop: '4px' }}>{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '20px' }}>
|
||||
{([['list', '租户列表'], ['members', '成员管理'], ['stats', '统计详情']] as const).map(([k, label]) => (
|
||||
<button key={k} onClick={() => setTab(k)}
|
||||
style={{ padding: '8px 20px', borderRadius: '8px', border: 'none', cursor: 'pointer',
|
||||
background: tab === k ? '#6366f1' : '#1e293b', color: tab === k ? '#fff' : '#94a3b8',
|
||||
fontWeight: 600, fontSize: '14px' }}>{label}</button>
|
||||
))}
|
||||
{tab === 'list' && (
|
||||
<button onClick={() => setShowCreate(true)} style={{ marginLeft: 'auto', padding: '8px 20px', borderRadius: '8px',
|
||||
border: 'none', cursor: 'pointer', background: '#6366f1', color: '#fff', fontWeight: 600, fontSize: '14px' }}>
|
||||
+ 创建租户
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{tab === 'list' && (
|
||||
<div>
|
||||
{loading ? <div style={{ textAlign: 'center', padding: '40px', color: '#64748b' }}>加载中...</div> : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#1e293b', borderRadius: '12px', overflow: 'hidden' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#0f172a' }}>
|
||||
{['租户名称', '标识', '状态', '成员数', '存储上限', 'API/天', '创建时间', '操作'].map(h => (
|
||||
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#94a3b8', fontWeight: 600 }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tenants.map(t => (
|
||||
<tr key={t.id} style={{ borderBottom: '1px solid #334155' }}>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', fontWeight: 600 }}>{t.name}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#94a3b8' }}>{t.slug}</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', fontWeight: 600,
|
||||
background: `${statusColors[t.status]}22`, color: statusColors[t.status] }}>{t.status}</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px' }}>{t.member_count ?? '-'}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#94a3b8' }}>{(t.max_storage_mb / 1024).toFixed(1)} GB</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#94a3b8' }}>{t.max_api_calls_per_day.toLocaleString()}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#64748b' }}>{t.created_at?.substring(0, 10)}</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<button onClick={() => { setSelectedTenant(t.id); setTab('members'); }}
|
||||
style={{ padding: '4px 12px', borderRadius: '6px', border: '1px solid #475569',
|
||||
background: 'transparent', color: '#94a3b8', cursor: 'pointer', fontSize: '12px' }}>管理</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{total > 20 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '20px' }}>
|
||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1}
|
||||
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid #475569', background: '#1e293b', color: '#94a3b8', cursor: page <= 1 ? 'not-allowed' : 'pointer' }}>上一页</button>
|
||||
<span style={{ padding: '6px 16px', color: '#94a3b8' }}>第 {page} 页</span>
|
||||
<button onClick={() => setPage(p => p + 1)} disabled={page * 20 >= total}
|
||||
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid #475569', background: '#1e293b', color: '#94a3b8', cursor: page * 20 >= total ? 'not-allowed' : 'pointer' }}>下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'members' && (
|
||||
<div>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<select value={selectedTenant} onChange={e => setSelectedTenant(e.target.value)}
|
||||
style={{ padding: '8px 12px', borderRadius: '8px', background: '#1e293b', color: '#e2e8f0', border: '1px solid #475569', fontSize: '14px' }}>
|
||||
<option value="">选择租户...</option>
|
||||
{tenants.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{selectedTenant && (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#1e293b', borderRadius: '12px', overflow: 'hidden' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#0f172a' }}>
|
||||
{['用户名', '邮箱', '角色', '状态', '加入时间'].map(h => (
|
||||
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#94a3b8', fontWeight: 600 }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{members.map(m => (
|
||||
<tr key={m.id} style={{ borderBottom: '1px solid #334155' }}>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', fontWeight: 600 }}>{m.username || m.user_id.substring(0, 8)}</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#94a3b8' }}>{m.email || '-'}</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', fontWeight: 600,
|
||||
background: `${roleColors[m.role]}22`, color: roleColors[m.role] }}>{m.role}</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<span style={{ color: m.status === 'active' ? '#10b981' : '#ef4444', fontSize: '13px' }}>{m.status}</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#64748b' }}>{m.joined_at?.substring(0, 16)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'stats' && stats && (
|
||||
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '32px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '24px' }}>
|
||||
{Object.entries(stats).map(([k, v]) => (
|
||||
<div key={k} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px', background: '#0f172a', borderRadius: '8px' }}>
|
||||
<span style={{ fontSize: '14px', color: '#94a3b8' }}>{k.replace(/_/g, ' ').toUpperCase()}</span>
|
||||
<span style={{ fontSize: '24px', fontWeight: 800, color: '#6366f1' }}>{String(v)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create modal */}
|
||||
{showCreate && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}
|
||||
onClick={() => setShowCreate(false)}>
|
||||
<div onClick={e => e.stopPropagation()} style={{ background: '#1e293b', borderRadius: '16px', padding: '32px', width: '480px', maxWidth: '90vw' }}>
|
||||
<h2 style={{ fontSize: '20px', fontWeight: 700, marginBottom: '24px' }}>创建租户</h2>
|
||||
{[
|
||||
{ k: 'name', label: '租户名称', type: 'text' },
|
||||
{ k: 'slug', label: '标识(英文)', type: 'text' },
|
||||
{ k: 'description', label: '描述', type: 'text' },
|
||||
{ k: 'max_members', label: '最大成员数', type: 'number' },
|
||||
{ k: 'max_storage_mb', label: '存储上限(MB)', type: 'number' },
|
||||
{ k: 'max_api_calls_per_day', label: 'API调用/天', type: 'number' },
|
||||
].map(f => (
|
||||
<div key={f.k} style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', fontSize: '13px', color: '#94a3b8', marginBottom: '6px' }}>{f.label}</label>
|
||||
<input type={f.type} value={(form as any)[f.k]} onChange={e => setForm({ ...form, [f.k]: f.type === 'number' ? Number(e.target.value) : e.target.value })}
|
||||
style={{ width: '100%', padding: '10px 12px', borderRadius: '8px', background: '#0f172a', color: '#e2e8f0', border: '1px solid #475569', fontSize: '14px', boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '24px' }}>
|
||||
<button onClick={() => setShowCreate(false)} style={{ flex: 1, padding: '10px', borderRadius: '8px', border: '1px solid #475569', background: 'transparent', color: '#94a3b8', cursor: 'pointer', fontSize: '14px' }}>取消</button>
|
||||
<button onClick={handleCreate} style={{ flex: 1, padding: '10px', borderRadius: '8px', border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontSize: '14px', fontWeight: 600 }}>创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tenant;
|
||||
Reference in new issue
Block a user