Feature: User Portal - overview/API keys/recharge/tenants/analytics (7 endpoints + frontend)
This commit is contained in:
1 parent
aec63eaf56
commit
144af68a0a
2 files changed
+493
No files matched your search
@@ -45,6 +45,7 @@ import Achievement from '@/pages/admin/Achievement';
|
|||||||
import Tenant from '@/pages/admin/Tenant';
|
import Tenant from '@/pages/admin/Tenant';
|
||||||
import Billing from '@/pages/admin/Billing';
|
import Billing from '@/pages/admin/Billing';
|
||||||
import Analytics from '@/pages/admin/Analytics';
|
import Analytics from '@/pages/admin/Analytics';
|
||||||
|
import UserPortal from '@/pages/UserPortal';
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
@@ -133,6 +134,7 @@ const App: React.FC = () => {
|
|||||||
<Route path="tenant" element={<Tenant />} />
|
<Route path="tenant" element={<Tenant />} />
|
||||||
<Route path="billing" element={<Billing />} />
|
<Route path="billing" element={<Billing />} />
|
||||||
<Route path="analytics" element={<Analytics />} />
|
<Route path="analytics" element={<Analytics />} />
|
||||||
|
<Route path="portal" element={<UserPortal />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" />} />
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
|
|||||||
@@ -0,0 +1,491 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { request, adminAPI } from '@/api';
|
||||||
|
|
||||||
|
// ==================== Types ====================
|
||||||
|
interface UserOverview {
|
||||||
|
user: { id: string; username: string; email: string; avatar: string | null; role: string; membership_level: number; membership_expire_at: string | null; created_at: string; };
|
||||||
|
token_stats: { total_tokens: number; prompt_tokens: number; completion_tokens: number; call_count: number; };
|
||||||
|
daily_usage: { date: string; calls: number; tokens: number; }[];
|
||||||
|
tenants: { id: string; name: string; slug: string; status: string; role: string; }[];
|
||||||
|
recent_transactions: { id: string; type: string; amount: number; tokens: number; description: string; status: string; created_at: string; }[];
|
||||||
|
api_key_count: number;
|
||||||
|
}
|
||||||
|
interface ApiKey { id: string; key_name: string; key_prefix: string; status: string; created_at: string; last_used_at: string | null; }
|
||||||
|
interface Package { id: string; name: string; description: string; tokens: number; price: number; original_price: number | null; bonus_tokens: number; is_popular: boolean; }
|
||||||
|
interface Tenant { id: string; name: string; slug: string; description: string; status: string; max_members: number; max_storage_mb: number; max_api_calls_per_day: number; role: string; joined_at: string; member_count: number; }
|
||||||
|
interface UsageData { daily: { date: string; calls: number; tokens: number; prompt_tokens: number; completion_tokens: number; }[]; model_distribution: { model: string; calls: number; tokens: number; }[]; total: { calls: number; tokens: number; }; days: number; }
|
||||||
|
|
||||||
|
const API_BASE = '/app/api/portal';
|
||||||
|
|
||||||
|
// ==================== Component ====================
|
||||||
|
export default function UserPortal() {
|
||||||
|
const [activeTab, setActiveTab] = useState('overview');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'overview', label: '概览', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
|
||||||
|
{ key: 'apikeys', label: 'API密钥', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' },
|
||||||
|
{ key: 'recharge', label: '充值中心', icon: 'M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z' },
|
||||||
|
{ key: 'tenants', label: '我的租户', icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5' },
|
||||||
|
{ key: 'analytics', label: '用量分析', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', background: '#0d1117', color: '#e6edf3', padding: '24px' }}>
|
||||||
|
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
|
<h1 style={{ fontSize: '28px', marginBottom: '24px' }}>用户门户</h1>
|
||||||
|
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<div style={{ display: 'flex', gap: '4px', borderBottom: '1px solid #30363d', marginBottom: '24px' }}>
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<button key={tab.key} onClick={() => setActiveTab(tab.key)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px', background: 'none', border: 'none', borderBottom: activeTab === tab.key ? '2px solid #58a6ff' : '2px solid transparent',
|
||||||
|
color: activeTab === tab.key ? '#58a6ff' : '#8b949e', cursor: 'pointer', fontSize: '14px', fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'overview' && <OverviewTab />}
|
||||||
|
{activeTab === 'apikeys' && <ApiKeysTab />}
|
||||||
|
{activeTab === 'recharge' && <RechargeTab />}
|
||||||
|
{activeTab === 'tenants' && <TenantsTab />}
|
||||||
|
{activeTab === 'analytics' && <AnalyticsTab />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Overview Tab ====================
|
||||||
|
function OverviewTab() {
|
||||||
|
const [data, setData] = useState<UserOverview | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<any>(`${API_BASE}/overview`).then(res => {
|
||||||
|
setData(res.data || res);
|
||||||
|
setLoading(false);
|
||||||
|
}).catch(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <div style={{ padding: '40px', textAlign: 'center', color: '#8b949e' }}>加载中...</div>;
|
||||||
|
if (!data) return <div style={{ padding: '40px', textAlign: 'center', color: '#f85149' }}>加载失败</div>;
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{ label: '本月Token用量', value: data.token_stats.total_tokens.toLocaleString(), color: '#58a6ff' },
|
||||||
|
{ label: 'API调用次数', value: data.token_stats.call_count.toLocaleString(), color: '#3fb950' },
|
||||||
|
{ label: 'API密钥数', value: data.api_key_count.toString(), color: '#bc8cff' },
|
||||||
|
{ label: '所属租户', value: data.tenants.length.toString(), color: '#f0883e' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '24px' }}>
|
||||||
|
{cards.map((c, i) => (
|
||||||
|
<div key={i} style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px' }}>
|
||||||
|
<div style={{ color: c.color, fontSize: '28px', fontWeight: 700 }}>{c.value}</div>
|
||||||
|
<div style={{ color: '#8b949e', fontSize: '13px', marginTop: '4px' }}>{c.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||||
|
{/* Daily Usage Chart */}
|
||||||
|
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px' }}>
|
||||||
|
<h3 style={{ fontSize: '16px', marginBottom: '16px' }}>近7天用量</h3>
|
||||||
|
{data.daily_usage.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '8px', height: '120px' }}>
|
||||||
|
{data.daily_usage.map((d, i) => {
|
||||||
|
const maxTokens = Math.max(...data.daily_usage.map(x => x.tokens), 1);
|
||||||
|
const height = (d.tokens / maxTokens) * 100;
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||||
|
<div style={{ width: '100%', height: `${height}%`, background: '#58a6ff', borderRadius: '4px 4px 0 0', minHeight: '4px' }} />
|
||||||
|
<div style={{ fontSize: '10px', color: '#8b949e', marginTop: '4px' }}>{d.date.slice(5)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : <div style={{ color: '#8b949e', textAlign: 'center', padding: '40px' }}>暂无数据</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Transactions */}
|
||||||
|
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px' }}>
|
||||||
|
<h3 style={{ fontSize: '16px', marginBottom: '16px' }}>最近交易</h3>
|
||||||
|
{data.recent_transactions.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{data.recent_transactions.map(t => (
|
||||||
|
<div key={t.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid #21262d' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '13px' }}>{t.description}</div>
|
||||||
|
<div style={{ fontSize: '11px', color: '#8b949e' }}>{t.created_at}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: t.amount >= 0 ? '#3fb950' : '#f85149', fontWeight: 600, fontSize: '14px' }}>
|
||||||
|
{t.amount >= 0 ? '+' : ''}¥{t.amount.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : <div style={{ color: '#8b949e', textAlign: 'center', padding: '40px' }}>暂无交易</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tenants */}
|
||||||
|
{data.tenants.length > 0 && (
|
||||||
|
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px', marginTop: '16px' }}>
|
||||||
|
<h3 style={{ fontSize: '16px', marginBottom: '16px' }}>我的租户</h3>
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
{data.tenants.map(t => (
|
||||||
|
<div key={t.id} style={{ padding: '12px 16px', background: '#21262d', borderRadius: '8px', flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 600 }}>{t.name}</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#8b949e' }}>角色: {t.role}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== API Keys Tab ====================
|
||||||
|
function ApiKeysTab() {
|
||||||
|
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [newKeyName, setNewKeyName] = useState('');
|
||||||
|
const [newKey, setNewKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
request<any>(`${API_BASE}/api-keys`).then(res => {
|
||||||
|
setKeys(res.data || []);
|
||||||
|
setLoading(false);
|
||||||
|
}).catch(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const createKey = () => {
|
||||||
|
if (!newKeyName.trim()) return;
|
||||||
|
request<any>(`${API_BASE}/api-keys/create?key_name=${encodeURIComponent(newKeyName)}`, { method: 'POST' }).then(res => {
|
||||||
|
setNewKey(res.data?.api_key || null);
|
||||||
|
setNewKeyName('');
|
||||||
|
setShowCreate(false);
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const revokeKey = (id: string) => {
|
||||||
|
if (!confirm('确定撤销此密钥?')) return;
|
||||||
|
request<any>(`${API_BASE}/api-keys/${id}`, { method: 'DELETE' }).then(() => load());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '16px' }}>
|
||||||
|
<h2 style={{ fontSize: '20px' }}>API 密钥管理</h2>
|
||||||
|
<button onClick={() => setShowCreate(!showCreate)} style={{ padding: '8px 16px', background: '#238636', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>
|
||||||
|
创建密钥
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{newKey && (
|
||||||
|
<div style={{ background: '#1a2332', border: '1px solid #58a6ff', borderRadius: '8px', padding: '16px', marginBottom: '16px' }}>
|
||||||
|
<div style={{ color: '#58a6ff', fontWeight: 600, marginBottom: '8px' }}>密钥已创建 - 请立即保存</div>
|
||||||
|
<code style={{ display: 'block', padding: '8px', background: '#0d1117', borderRadius: '4px', wordBreak: 'break-all', fontSize: '13px' }}>{newKey}</code>
|
||||||
|
<button onClick={() => setNewKey(null)} style={{ marginTop: '8px', padding: '4px 12px', background: '#30363d', color: '#e6edf3', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>我已保存</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '8px', padding: '16px', marginBottom: '16px' }}>
|
||||||
|
<input value={newKeyName} onChange={e => setNewKeyName(e.target.value)} placeholder="密钥名称(如:生产环境)"
|
||||||
|
style={{ width: '300px', padding: '8px 12px', background: '#0d1117', border: '1px solid #30363d', borderRadius: '6px', color: '#e6edf3', marginRight: '8px' }} />
|
||||||
|
<button onClick={createKey} style={{ padding: '8px 16px', background: '#238636', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>确认创建</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? <div style={{ textAlign: 'center', padding: '40px', color: '#8b949e' }}>加载中...</div> : (
|
||||||
|
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', overflow: 'hidden' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid #30363d' }}>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', color: '#8b949e', textTransform: 'uppercase' }}>名称</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', color: '#8b949e', textTransform: 'uppercase' }}>密钥前缀</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', color: '#8b949e', textTransform: 'uppercase' }}>状态</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', color: '#8b949e', textTransform: 'uppercase' }}>创建时间</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', color: '#8b949e', textTransform: 'uppercase' }}>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{keys.length === 0 ? (
|
||||||
|
<tr><td colSpan={5} style={{ padding: '40px', textAlign: 'center', color: '#8b949e' }}>暂无API密钥</td></tr>
|
||||||
|
) : keys.map(k => (
|
||||||
|
<tr key={k.id} style={{ borderBottom: '1px solid #21262d' }}>
|
||||||
|
<td style={{ padding: '12px' }}>{k.key_name}</td>
|
||||||
|
<td style={{ padding: '12px' }}><code style={{ color: '#f0883e' }}>{k.key_prefix}...</code></td>
|
||||||
|
<td style={{ padding: '12px' }}>
|
||||||
|
<span style={{ padding: '2px 8px', borderRadius: '12px', fontSize: '12px', background: k.status === 'active' ? 'rgba(63,185,80,0.15)' : 'rgba(248,81,73,0.15)', color: k.status === 'active' ? '#3fb950' : '#f85149' }}>
|
||||||
|
{k.status === 'active' ? '活跃' : '已撤销'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px', color: '#8b949e' }}>{k.created_at}</td>
|
||||||
|
<td style={{ padding: '12px' }}>
|
||||||
|
{k.status === 'active' && <button onClick={() => revokeKey(k.id)} style={{ padding: '4px 10px', background: 'rgba(248,81,73,0.1)', color: '#f85149', border: '1px solid rgba(248,81,73,0.3)', borderRadius: '4px', cursor: 'pointer' }}>撤销</button>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Recharge Tab ====================
|
||||||
|
function RechargeTab() {
|
||||||
|
const [packages, setPackages] = useState<Package[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<any>(`${API_BASE}/billing/packages`).then(res => {
|
||||||
|
setPackages(res.data || []);
|
||||||
|
setLoading(false);
|
||||||
|
}).catch(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <div style={{ textAlign: 'center', padding: '40px', color: '#8b949e' }}>加载中...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '20px', marginBottom: '24px' }}>充值中心</h2>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px' }}>
|
||||||
|
{packages.map(pkg => (
|
||||||
|
<div key={pkg.id} style={{
|
||||||
|
background: '#161b22', border: pkg.is_popular ? '2px solid #58a6ff' : '1px solid #30363d',
|
||||||
|
borderRadius: '12px', padding: '24px', position: 'relative',
|
||||||
|
}}>
|
||||||
|
{pkg.is_popular && <div style={{ position: 'absolute', top: '-10px', left: '50%', transform: 'translateX(-50%)', background: '#58a6ff', color: '#0d1117', padding: '2px 12px', borderRadius: '10px', fontSize: '12px', fontWeight: 600 }}>推荐</div>}
|
||||||
|
<h3 style={{ fontSize: '18px', marginBottom: '8px' }}>{pkg.name}</h3>
|
||||||
|
<p style={{ color: '#8b949e', fontSize: '13px', minHeight: '40px' }}>{pkg.description}</p>
|
||||||
|
<div style={{ margin: '16px 0' }}>
|
||||||
|
<span style={{ fontSize: '32px', fontWeight: 700, color: '#58a6ff' }}>¥{pkg.price}</span>
|
||||||
|
{pkg.original_price && <span style={{ marginLeft: '8px', color: '#8b949e', textDecoration: 'line-through' }}>¥{pkg.original_price}</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#3fb950', fontSize: '14px', marginBottom: '16px' }}>
|
||||||
|
{(pkg.tokens / 10000).toFixed(0)} 万 Token{pkg.bonus_tokens > 0 && ` +${(pkg.bonus_tokens / 10000).toFixed(0)}万 赠送`}
|
||||||
|
</div>
|
||||||
|
<button style={{
|
||||||
|
width: '100%', padding: '10px', background: pkg.is_popular ? '#58a6ff' : '#238636',
|
||||||
|
color: pkg.is_popular ? '#0d1117' : '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600,
|
||||||
|
}}>立即购买</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '32px' }}>
|
||||||
|
<h3 style={{ fontSize: '16px', marginBottom: '16px' }}>交易记录</h3>
|
||||||
|
<TransactionsList />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TransactionsList() {
|
||||||
|
const [txns, setTxns] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<any>(`${API_BASE}/billing/transactions`).then(res => {
|
||||||
|
setTxns(res.data?.items || []);
|
||||||
|
setLoading(false);
|
||||||
|
}).catch(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <div style={{ color: '#8b949e' }}>加载中...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', overflow: 'hidden' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead><tr style={{ borderBottom: '1px solid #30363d' }}>
|
||||||
|
{['描述', '类型', '金额', 'Token', '状态', '时间'].map(h => <th key={h} style={{ padding: '12px', textAlign: 'left', fontSize: '12px', color: '#8b949e' }}>{h}</th>)}
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{txns.length === 0 ? <tr><td colSpan={6} style={{ padding: '40px', textAlign: 'center', color: '#8b949e' }}>暂无交易记录</td></tr> : txns.map(t => (
|
||||||
|
<tr key={t.id} style={{ borderBottom: '1px solid #21262d' }}>
|
||||||
|
<td style={{ padding: '12px' }}>{t.description}</td>
|
||||||
|
<td style={{ padding: '12px' }}>{t.type}</td>
|
||||||
|
<td style={{ padding: '12px', color: t.amount >= 0 ? '#3fb950' : '#f85149' }}>{t.amount >= 0 ? '+' : ''}¥{t.amount.toFixed(2)}</td>
|
||||||
|
<td style={{ padding: '12px' }}>{t.tokens?.toLocaleString() || '-'}</td>
|
||||||
|
<td style={{ padding: '12px' }}><span style={{ color: t.status === 'completed' ? '#3fb950' : '#d29922' }}>{t.status}</span></td>
|
||||||
|
<td style={{ padding: '12px', color: '#8b949e' }}>{t.created_at}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Tenants Tab ====================
|
||||||
|
function TenantsTab() {
|
||||||
|
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [slug, setSlug] = useState('');
|
||||||
|
const [desc, setDesc] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
request<any>(`${API_BASE}/tenants`).then(res => {
|
||||||
|
setTenants(res.data || []);
|
||||||
|
setLoading(false);
|
||||||
|
}).catch(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const create = () => {
|
||||||
|
if (!name.trim() || !slug.trim()) return;
|
||||||
|
request<any>(`${API_BASE}/tenants/create?name=${encodeURIComponent(name)}&slug=${encodeURIComponent(slug)}&description=${encodeURIComponent(desc)}`, { method: 'POST' }).then(() => {
|
||||||
|
setShowCreate(false); setName(''); setSlug(''); setDesc('');
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '16px' }}>
|
||||||
|
<h2 style={{ fontSize: '20px' }}>我的租户</h2>
|
||||||
|
<button onClick={() => setShowCreate(!showCreate)} style={{ padding: '8px 16px', background: '#238636', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>创建租户</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '8px', padding: '20px', marginBottom: '16px' }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '12px' }}>
|
||||||
|
<input value={name} onChange={e => setName(e.target.value)} placeholder="租户名称" style={{ padding: '8px 12px', background: '#0d1117', border: '1px solid #30363d', borderRadius: '6px', color: '#e6edf3' }} />
|
||||||
|
<input value={slug} onChange={e => setSlug(e.target.value)} placeholder="标识(如:my-company)" style={{ padding: '8px 12px', background: '#0d1117', border: '1px solid #30363d', borderRadius: '6px', color: '#e6edf3' }} />
|
||||||
|
</div>
|
||||||
|
<input value={desc} onChange={e => setDesc(e.target.value)} placeholder="描述(选填)" style={{ width: '100%', padding: '8px 12px', background: '#0d1117', border: '1px solid #30363d', borderRadius: '6px', color: '#e6edf3', marginBottom: '12px' }} />
|
||||||
|
<button onClick={create} style={{ padding: '8px 16px', background: '#238636', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>确认创建</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? <div style={{ color: '#8b949e', padding: '40px', textAlign: 'center' }}>加载中...</div> : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||||
|
{tenants.length === 0 ? <div style={{ color: '#8b949e', gridColumn: '1/3', textAlign: 'center', padding: '40px' }}>暂无租户,点击上方创建</div> : tenants.map(t => (
|
||||||
|
<div key={t.id} style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
||||||
|
<h3 style={{ fontSize: '18px' }}>{t.name}</h3>
|
||||||
|
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', background: t.role === 'owner' ? 'rgba(188,140,255,0.15)' : 'rgba(88,166,255,0.15)', color: t.role === 'owner' ? '#bc8cff' : '#58a6ff' }}>{t.role}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ color: '#8b949e', fontSize: '13px', marginBottom: '12px' }}>{t.description || '暂无描述'}</p>
|
||||||
|
<div style={{ display: 'flex', gap: '16px', fontSize: '13px', color: '#8b949e' }}>
|
||||||
|
<span>成员: {t.member_count}/{t.max_members}</span>
|
||||||
|
<span>存储: {(t.max_storage_mb / 1024).toFixed(0)}GB</span>
|
||||||
|
<span>API/日: {t.max_api_calls_per_day.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Analytics Tab ====================
|
||||||
|
function AnalyticsTab() {
|
||||||
|
const [data, setData] = useState<UsageData | null>(null);
|
||||||
|
const [days, setDays] = useState(30);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
request<any>(`${API_BASE}/analytics/usage?days=${days}`).then(res => {
|
||||||
|
setData(res.data);
|
||||||
|
setLoading(false);
|
||||||
|
}).catch(() => setLoading(false));
|
||||||
|
}, [days]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '24px' }}>
|
||||||
|
<h2 style={{ fontSize: '20px' }}>用量分析</h2>
|
||||||
|
<select value={days} onChange={e => setDays(Number(e.target.value))} style={{ padding: '8px 12px', background: '#161b22', border: '1px solid #30363d', borderRadius: '6px', color: '#e6edf3' }}>
|
||||||
|
<option value={7}>最近7天</option>
|
||||||
|
<option value={14}>最近14天</option>
|
||||||
|
<option value={30}>最近30天</option>
|
||||||
|
<option value={90}>最近90天</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? <div style={{ color: '#8b949e', textAlign: 'center', padding: '40px' }}>加载中...</div> : data ? (
|
||||||
|
<div>
|
||||||
|
{/* Total Stats */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '16px', marginBottom: '24px' }}>
|
||||||
|
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '28px', fontWeight: 700, color: '#58a6ff' }}>{data.total.calls.toLocaleString()}</div>
|
||||||
|
<div style={{ color: '#8b949e', fontSize: '13px' }}>API调用总数</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '28px', fontWeight: 700, color: '#3fb950' }}>{data.total.tokens.toLocaleString()}</div>
|
||||||
|
<div style={{ color: '#8b949e', fontSize: '13px' }}>Token消耗总数</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '28px', fontWeight: 700, color: '#bc8cff' }}>{data.daily.length}</div>
|
||||||
|
<div style={{ color: '#8b949e', fontSize: '13px' }}>活跃天数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Daily Usage Chart */}
|
||||||
|
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px', marginBottom: '16px' }}>
|
||||||
|
<h3 style={{ fontSize: '16px', marginBottom: '16px' }}>每日用量趋势</h3>
|
||||||
|
{data.daily.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '4px', height: '150px' }}>
|
||||||
|
{data.daily.map((d, i) => {
|
||||||
|
const max = Math.max(...data.daily.map(x => x.tokens), 1);
|
||||||
|
const h = (d.tokens / max) * 100;
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center' }} title={`${d.date}: ${d.tokens} tokens`}>
|
||||||
|
<div style={{ width: '100%', height: `${h}%`, background: 'linear-gradient(180deg, #58a6ff, #1f6feb)', borderRadius: '4px 4px 0 0', minHeight: '2px' }} />
|
||||||
|
{data.daily.length <= 14 && <div style={{ fontSize: '9px', color: '#8b949e', marginTop: '2px', transform: 'rotate(-45deg)' }}>{d.date.slice(5)}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : <div style={{ color: '#8b949e', textAlign: 'center', padding: '40px' }}>暂无数据</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model Distribution */}
|
||||||
|
<div style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: '12px', padding: '20px' }}>
|
||||||
|
<h3 style={{ fontSize: '16px', marginBottom: '16px' }}>模型使用分布</h3>
|
||||||
|
{data.model_distribution.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{data.model_distribution.map((m, i) => {
|
||||||
|
const maxTokens = Math.max(...data.model_distribution.map(x => x.tokens), 1);
|
||||||
|
const pct = (m.tokens / maxTokens) * 100;
|
||||||
|
const colors = ['#58a6ff', '#3fb950', '#bc8cff', '#f0883e', '#d29922'];
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<div style={{ width: '120px', fontSize: '13px' }}>{m.model}</div>
|
||||||
|
<div style={{ flex: 1, height: '20px', background: '#21262d', borderRadius: '4px', overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${pct}%`, height: '100%', background: colors[i % colors.length] }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ width: '100px', fontSize: '12px', color: '#8b949e', textAlign: 'right' }}>{m.tokens.toLocaleString()} tokens</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : <div style={{ color: '#8b949e', textAlign: 'center', padding: '40px' }}>暂无数据</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : <div style={{ color: '#f85149', textAlign: 'center', padding: '40px' }}>加载失败</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user