From 144af68a0a3415b4a7cf69b15a35e45e8f1c9a05 Mon Sep 17 00:00:00 2001 From: qyyfw Date: Tue, 7 Jul 2026 20:46:31 +0800 Subject: [PATCH] Feature: User Portal - overview/API keys/recharge/tenants/analytics (7 endpoints + frontend) --- frontend/src/App.tsx | 2 + frontend/src/pages/UserPortal.tsx | 491 ++++++++++++++++++++++++++++++ 2 files changed, 493 insertions(+) create mode 100644 frontend/src/pages/UserPortal.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f9b4fa5..088aec8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -45,6 +45,7 @@ import Achievement from '@/pages/admin/Achievement'; import Tenant from '@/pages/admin/Tenant'; import Billing from '@/pages/admin/Billing'; import Analytics from '@/pages/admin/Analytics'; +import UserPortal from '@/pages/UserPortal'; const App: React.FC = () => { const [user, setUser] = useState(null); @@ -133,6 +134,7 @@ const App: React.FC = () => { } /> } /> } /> + } /> } /> diff --git a/frontend/src/pages/UserPortal.tsx b/frontend/src/pages/UserPortal.tsx new file mode 100644 index 0000000..5091662 --- /dev/null +++ b/frontend/src/pages/UserPortal.tsx @@ -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 ( +
+
+

用户门户

+ + {/* Tab Navigation */} +
+ {tabs.map(tab => ( + + ))} +
+ + {activeTab === 'overview' && } + {activeTab === 'apikeys' && } + {activeTab === 'recharge' && } + {activeTab === 'tenants' && } + {activeTab === 'analytics' && } +
+
+ ); +} + +// ==================== Overview Tab ==================== +function OverviewTab() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + request(`${API_BASE}/overview`).then(res => { + setData(res.data || res); + setLoading(false); + }).catch(() => setLoading(false)); + }, []); + + if (loading) return
加载中...
; + if (!data) return
加载失败
; + + 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 ( +
+ {/* Stats Cards */} +
+ {cards.map((c, i) => ( +
+
{c.value}
+
{c.label}
+
+ ))} +
+ +
+ {/* Daily Usage Chart */} +
+

近7天用量

+ {data.daily_usage.length > 0 ? ( +
+ {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 ( +
+
+
{d.date.slice(5)}
+
+ ); + })} +
+ ) :
暂无数据
} +
+ + {/* Recent Transactions */} +
+

最近交易

+ {data.recent_transactions.length > 0 ? ( +
+ {data.recent_transactions.map(t => ( +
+
+
{t.description}
+
{t.created_at}
+
+
= 0 ? '#3fb950' : '#f85149', fontWeight: 600, fontSize: '14px' }}> + {t.amount >= 0 ? '+' : ''}¥{t.amount.toFixed(2)} +
+
+ ))} +
+ ) :
暂无交易
} +
+
+ + {/* Tenants */} + {data.tenants.length > 0 && ( +
+

我的租户

+
+ {data.tenants.map(t => ( +
+
{t.name}
+
角色: {t.role}
+
+ ))} +
+
+ )} +
+ ); +} + +// ==================== API Keys Tab ==================== +function ApiKeysTab() { + const [keys, setKeys] = useState([]); + const [loading, setLoading] = useState(true); + const [showCreate, setShowCreate] = useState(false); + const [newKeyName, setNewKeyName] = useState(''); + const [newKey, setNewKey] = useState(null); + + const load = useCallback(() => { + setLoading(true); + request(`${API_BASE}/api-keys`).then(res => { + setKeys(res.data || []); + setLoading(false); + }).catch(() => setLoading(false)); + }, []); + + useEffect(() => { load(); }, [load]); + + const createKey = () => { + if (!newKeyName.trim()) return; + request(`${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(`${API_BASE}/api-keys/${id}`, { method: 'DELETE' }).then(() => load()); + }; + + return ( +
+
+

API 密钥管理

+ +
+ + {newKey && ( +
+
密钥已创建 - 请立即保存
+ {newKey} + +
+ )} + + {showCreate && ( +
+ setNewKeyName(e.target.value)} placeholder="密钥名称(如:生产环境)" + style={{ width: '300px', padding: '8px 12px', background: '#0d1117', border: '1px solid #30363d', borderRadius: '6px', color: '#e6edf3', marginRight: '8px' }} /> + +
+ )} + + {loading ?
加载中...
: ( +
+ + + + + + + + + + + + {keys.length === 0 ? ( + + ) : keys.map(k => ( + + + + + + + + ))} + +
名称密钥前缀状态创建时间操作
暂无API密钥
{k.key_name}{k.key_prefix}... + + {k.status === 'active' ? '活跃' : '已撤销'} + + {k.created_at} + {k.status === 'active' && } +
+
+ )} +
+ ); +} + +// ==================== Recharge Tab ==================== +function RechargeTab() { + const [packages, setPackages] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + request(`${API_BASE}/billing/packages`).then(res => { + setPackages(res.data || []); + setLoading(false); + }).catch(() => setLoading(false)); + }, []); + + if (loading) return
加载中...
; + + return ( +
+

充值中心

+
+ {packages.map(pkg => ( +
+ {pkg.is_popular &&
推荐
} +

{pkg.name}

+

{pkg.description}

+
+ ¥{pkg.price} + {pkg.original_price && ¥{pkg.original_price}} +
+
+ {(pkg.tokens / 10000).toFixed(0)} 万 Token{pkg.bonus_tokens > 0 && ` +${(pkg.bonus_tokens / 10000).toFixed(0)}万 赠送`} +
+ +
+ ))} +
+ +
+

交易记录

+ +
+
+ ); +} + +function TransactionsList() { + const [txns, setTxns] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + request(`${API_BASE}/billing/transactions`).then(res => { + setTxns(res.data?.items || []); + setLoading(false); + }).catch(() => setLoading(false)); + }, []); + + if (loading) return
加载中...
; + + return ( +
+ + + {['描述', '类型', '金额', 'Token', '状态', '时间'].map(h => )} + + + {txns.length === 0 ? : txns.map(t => ( + + + + + + + + + ))} + +
{h}
暂无交易记录
{t.description}{t.type}= 0 ? '#3fb950' : '#f85149' }}>{t.amount >= 0 ? '+' : ''}¥{t.amount.toFixed(2)}{t.tokens?.toLocaleString() || '-'}{t.status}{t.created_at}
+
+ ); +} + +// ==================== Tenants Tab ==================== +function TenantsTab() { + const [tenants, setTenants] = useState([]); + 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(`${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(`${API_BASE}/tenants/create?name=${encodeURIComponent(name)}&slug=${encodeURIComponent(slug)}&description=${encodeURIComponent(desc)}`, { method: 'POST' }).then(() => { + setShowCreate(false); setName(''); setSlug(''); setDesc(''); + load(); + }); + }; + + return ( +
+
+

我的租户

+ +
+ + {showCreate && ( +
+
+ setName(e.target.value)} placeholder="租户名称" style={{ padding: '8px 12px', background: '#0d1117', border: '1px solid #30363d', borderRadius: '6px', color: '#e6edf3' }} /> + setSlug(e.target.value)} placeholder="标识(如:my-company)" style={{ padding: '8px 12px', background: '#0d1117', border: '1px solid #30363d', borderRadius: '6px', color: '#e6edf3' }} /> +
+ setDesc(e.target.value)} placeholder="描述(选填)" style={{ width: '100%', padding: '8px 12px', background: '#0d1117', border: '1px solid #30363d', borderRadius: '6px', color: '#e6edf3', marginBottom: '12px' }} /> + +
+ )} + + {loading ?
加载中...
: ( +
+ {tenants.length === 0 ?
暂无租户,点击上方创建
: tenants.map(t => ( +
+
+

{t.name}

+ {t.role} +
+

{t.description || '暂无描述'}

+
+ 成员: {t.member_count}/{t.max_members} + 存储: {(t.max_storage_mb / 1024).toFixed(0)}GB + API/日: {t.max_api_calls_per_day.toLocaleString()} +
+
+ ))} +
+ )} +
+ ); +} + +// ==================== Analytics Tab ==================== +function AnalyticsTab() { + const [data, setData] = useState(null); + const [days, setDays] = useState(30); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(true); + request(`${API_BASE}/analytics/usage?days=${days}`).then(res => { + setData(res.data); + setLoading(false); + }).catch(() => setLoading(false)); + }, [days]); + + return ( +
+
+

用量分析

+ +
+ + {loading ?
加载中...
: data ? ( +
+ {/* Total Stats */} +
+
+
{data.total.calls.toLocaleString()}
+
API调用总数
+
+
+
{data.total.tokens.toLocaleString()}
+
Token消耗总数
+
+
+
{data.daily.length}
+
活跃天数
+
+
+ + {/* Daily Usage Chart */} +
+

每日用量趋势

+ {data.daily.length > 0 ? ( +
+ {data.daily.map((d, i) => { + const max = Math.max(...data.daily.map(x => x.tokens), 1); + const h = (d.tokens / max) * 100; + return ( +
+
+ {data.daily.length <= 14 &&
{d.date.slice(5)}
} +
+ ); + })} +
+ ) :
暂无数据
} +
+ + {/* Model Distribution */} +
+

模型使用分布

+ {data.model_distribution.length > 0 ? ( +
+ {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 ( +
+
{m.model}
+
+
+
+
{m.tokens.toLocaleString()} tokens
+
+ ); + })} +
+ ) :
暂无数据
} +
+
+ ) :
加载失败
} +
+ ); +}