feat(frontend-v2): #986 Token 驾驶舱真实数据页 (token/overview|rankings|logs|balance)

This commit is contained in:
QihuAI Design committed 2026-08-23 12:02:37 +08:00
1 parent 4a2920e637
commit 910394423d
4 files changed
+501 -35

No files matched your search

+2 -16
View File
@@ -8,6 +8,7 @@ import { ChatPage } from '@/pages/front/ChatPage';
import { KnowledgePage } from '@/pages/front/KnowledgePage'; import { KnowledgePage } from '@/pages/front/KnowledgePage';
import { ImBotHub } from '@/pages/front/ImBotHub'; import { ImBotHub } from '@/pages/front/ImBotHub';
import { AppServices } from '@/pages/front/AppServices'; import { AppServices } from '@/pages/front/AppServices';
import { DashboardPage } from '@/pages/front/DashboardPage';
import { ComingSoon } from '@/components/common/ComingSoon'; import { ComingSoon } from '@/components/common/ComingSoon';
import { import {
Zap, Zap,
@@ -16,7 +17,6 @@ import {
Workflow, Workflow,
Shield, Shield,
Radio, Radio,
LayoutDashboard,
User, User,
Bell, Bell,
Sparkles, Sparkles,
@@ -125,21 +125,7 @@ function App() {
/> />
} }
/> />
<Route <Route path="dashboard" element={<DashboardPage />} />
path="dashboard"
element={
<ComingSoon
title="驾驶舱"
icon={LayoutDashboard}
description="全局数据驾驶舱,实时掌握平台运行与资源消耗。"
features={[
'Token 消耗与趋势分析',
'模型调用与成功率监控',
'租户维度用量报表',
]}
/>
}
/>
<Route <Route
path="profile" path="profile"
element={ element={
+79 -13
View File
@@ -24,6 +24,13 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
return res.json(); return res.json();
} }
// Backend responses use a unified envelope `{code, data, message}`.
// `request<T>()` returns the raw envelope; `data<T>()` unwraps `.data`.
async function data<T>(path: string, options: RequestInit = {}): Promise<T> {
const env = await request<{ code: number; data: T; message: string }>(path, options);
return env.data;
}
// ─── Auth ─────────────────────────────────────────────────────────────────────── // ─── Auth ───────────────────────────────────────────────────────────────────────
export const authApi = { export const authApi = {
@@ -229,19 +236,78 @@ export const profileApi = {
getBalance: () => request<{ balance: number; total_used: number }>('/profile/balance'), getBalance: () => request<{ balance: number; total_used: number }>('/profile/balance'),
}; };
// ─── Dashboard / Stats ────────────────────────────────────────────────────────── // ─── Token 驾驶舱 ────────────────────────────────────────────────────────────────
export const dashboardApi = { export interface TokenOverview {
getStats: () => total_used: number;
request<{ balance: number;
active_users: number; today_apps: number;
today_conversations: number; server_time: string;
skill_count: number; }
revenue: number;
user_change: string;
conv_change: string;
}>('/token/usage'),
getHotSkills: () => export interface ModelRanking {
request<{ name: string; downloads: string }[]>('/market/apps'), model_id: string;
model_name: string;
total_tokens: number;
usage_count: number;
}
export interface TopApp {
conversation_id: string;
total_tokens: number;
}
export interface TokenRankings {
model_rankings: ModelRanking[];
top_apps: TopApp[];
}
export interface TokenLogItem {
id: string;
model_id: string;
model_name: string;
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
conversation_id: string;
created_at: string | null;
}
export interface TokenLogPage {
total: number;
page: number;
page_size: number;
items: TokenLogItem[];
}
export interface TokenBalance {
balance: number;
updated_at: string | null;
}
export const tokenApi = {
getOverview: () => data<TokenOverview>('/token/overview'),
getRankings: () => data<TokenRankings>('/token/rankings'),
getLogs: (params: {
start_date?: string;
end_date?: string;
model_id?: string;
app_type?: string;
page?: number;
page_size?: number;
} = {}) => {
const qs = new URLSearchParams();
if (params.start_date) qs.set('start_date', params.start_date);
if (params.end_date) qs.set('end_date', params.end_date);
if (params.model_id) qs.set('model_id', params.model_id);
if (params.app_type) qs.set('app_type', params.app_type);
if (params.page) qs.set('page', String(params.page));
if (params.page_size) qs.set('page_size', String(params.page_size));
const q = qs.toString();
return data<TokenLogPage>(`/token/logs${q ? `?${q}` : ''}`);
},
getBalance: () => data<TokenBalance>('/token/balance'),
}; };
+54 -6
View File
@@ -6,7 +6,8 @@ import {
marketApi, marketApi,
imBotApi, imBotApi,
profileApi, profileApi,
dashboardApi, tokenApi,
type TokenOverview,
} from '@/api/client'; } from '@/api/client';
// ─── Auth ─────────────────────────────────────────────────────────────────────── // ─── Auth ───────────────────────────────────────────────────────────────────────
@@ -127,7 +128,7 @@ export function useMarketApps() {
return useQuery({ return useQuery({
queryKey: ['market', 'apps'], queryKey: ['market', 'apps'],
queryFn: marketApi.listApps, queryFn: marketApi.listApps,
select: (d) => asArray(d), select: (d: unknown) => asArray(d),
}); });
} }
@@ -135,7 +136,7 @@ export function useMyApps() {
return useQuery({ return useQuery({
queryKey: ['market', 'my-apps'], queryKey: ['market', 'my-apps'],
queryFn: marketApi.getMyApps, queryFn: marketApi.getMyApps,
select: (d) => asArray(d), select: (d: unknown) => asArray(d),
}); });
} }
@@ -193,11 +194,58 @@ export function useUpdateProfile() {
}); });
} }
// ─── Dashboard ─────────────────────────────────────────────────────────────────── // ─── Token 驾驶舱 ─────────────────────────────────────────────────────────────────
// Landing hero still shows a few headline numbers; reuse the real token overview
// so the homepage never ships fake/mock values.
export function useDashboardStats() { export function useDashboardStats() {
return useQuery({ return useQuery({
queryKey: ['dashboard', 'stats'], queryKey: ['token', 'overview'],
queryFn: dashboardApi.getStats, queryFn: tokenApi.getOverview,
select: (d: TokenOverview) => ({
active_users: d.today_apps,
today_conversations: d.today_apps,
skill_count: d.total_used,
revenue: d.balance,
user_change: '',
conv_change: '',
}),
});
}
export function useTokenOverview() {
return useQuery({
queryKey: ['token', 'overview'],
queryFn: tokenApi.getOverview,
refetchInterval: 60000, // 每分钟刷新一次概览
});
}
export function useTokenRankings() {
return useQuery({
queryKey: ['token', 'rankings'],
queryFn: tokenApi.getRankings,
});
}
export function useTokenBalance() {
return useQuery({
queryKey: ['token', 'balance'],
queryFn: tokenApi.getBalance,
});
}
export function useTokenLogs(params: {
start_date?: string;
end_date?: string;
model_id?: string;
app_type?: string;
page?: number;
page_size?: number;
}) {
return useQuery({
queryKey: ['token', 'logs', params],
queryFn: () => tokenApi.getLogs(params),
placeholderData: (prev) => prev, // 翻页时保留旧数据,避免闪烁
}); });
} }
@@ -0,0 +1,366 @@
import { useState, useMemo } from 'react';
import { motion } from 'framer-motion';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Coins,
TrendingDown,
Boxes,
Clock,
Loader2,
ChevronLeft,
ChevronRight,
Filter,
type LucideIcon,
} from 'lucide-react';
import {
useTokenOverview,
useTokenRankings,
useTokenBalance,
useTokenLogs,
} from '@/hooks/useApi';
import type { TokenLogItem } from '@/api/client';
function StatCard({
icon: Icon,
label,
value,
hint,
accent,
}: {
icon: LucideIcon;
label: string;
value: string;
hint?: string;
accent: string;
}) {
return (
<Card>
<CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-muted-foreground">{label}</span>
<div className={`w-9 h-9 rounded-lg flex items-center justify-center ${accent}`}>
<Icon size={18} />
</div>
</div>
<div className="text-2xl font-semibold text-foreground tabular-nums">{value}</div>
{hint && <p className="text-xs text-muted-foreground mt-1">{hint}</p>}
</CardContent>
</Card>
);
}
function formatNumber(n: number): string {
return n.toLocaleString('zh-CN');
}
function formatTime(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleString('zh-CN', { hour12: false });
}
function ModelRankingBar({ name, total, max }: { name: string; total: number; max: number }) {
const pct = max > 0 ? Math.round((total / max) * 100) : 0;
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-foreground truncate pr-2">{name}</span>
<span className="text-muted-foreground tabular-nums">{formatNumber(total)}</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
transition={{ duration: 0.5, ease: 'easeOut' }}
className="h-full rounded-full bg-primary"
/>
</div>
</div>
);
}
export function DashboardPage() {
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [page, setPage] = useState(1);
const pageSize = 15;
const { data: overview, isLoading: ovLoading } = useTokenOverview();
const { data: rankings } = useTokenRankings();
const { data: balance } = useTokenBalance();
const { data: logs, isLoading: logsLoading, isFetching: logsFetching } = useTokenLogs({
start_date: startDate || undefined,
end_date: endDate || undefined,
page,
page_size: pageSize,
});
const maxModelTokens = useMemo(
() => Math.max(0, ...(rankings?.model_rankings ?? []).map((m) => m.total_tokens)),
[rankings]
);
const total = logs?.total ?? 0;
const items: TokenLogItem[] = logs?.items ?? [];
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const handleReset = () => {
setStartDate('');
setEndDate('');
setPage(1);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">Token 驾驶舱</h2>
<p className="text-sm text-muted-foreground mt-1">
实时掌握 Token 消耗、余额与调用明细
</p>
</div>
{overview?.server_time && (
<Badge variant="outline" className="gap-1">
<Clock size={12} /> 北京时间 {formatTime(overview.server_time)}
</Badge>
)}
</div>
{/* Stat cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
icon={Coins}
label="当前余额"
value={ovLoading ? '—' : `${formatNumber(overview?.balance ?? 0)}`}
hint="tokens 可用额度"
accent="bg-primary/10 text-primary"
/>
<StatCard
icon={TrendingDown}
label="累计消耗"
value={ovLoading ? '—' : formatNumber(overview?.total_used ?? 0)}
hint="历史总 token 用量"
accent="bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"
/>
<StatCard
icon={Boxes}
label="今日活跃模型"
value={ovLoading ? '—' : `${overview?.today_apps ?? 0}`}
hint="当日有调用的模型数"
accent="bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300"
/>
<StatCard
icon={Clock}
label="调用记录"
value={logsLoading ? '—' : formatNumber(total)}
hint="筛选范围内总条数"
accent="bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300"
/>
</div>
{/* Rankings + balance side panel */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>模型消耗排行</CardTitle>
<CardDescription>按累计 token 消耗排序的模型分布</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!rankings || rankings.model_rankings.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
暂无消耗数据
</div>
) : (
rankings.model_rankings.slice(0, 6).map((m) => (
<ModelRankingBar
key={m.model_id}
name={m.model_name}
total={m.total_tokens}
max={maxModelTokens}
/>
))
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>余额概览</CardTitle>
<CardDescription>账户 token 余额状态</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-sm text-muted-foreground">当前余额</p>
<p className="text-3xl font-semibold text-foreground tabular-nums mt-1">
{balance ? formatNumber(balance.balance) : '—'}
</p>
<p className="text-xs text-muted-foreground mt-1">tokens</p>
</div>
<div className="h-px bg-border" />
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">最近更新</span>
<span className="text-foreground">{formatTime(balance?.updated_at ?? null)}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">累计消耗</span>
<span className="text-foreground tabular-nums">
{formatNumber(overview?.total_used ?? 0)}
</span>
</div>
{rankings && rankings.top_apps.length > 0 && (
<>
<div className="h-px bg-border" />
<div>
<p className="text-sm text-muted-foreground mb-2">Top 应用(按消耗)</p>
<div className="space-y-1">
{rankings.top_apps.map((app, i) => (
<div
key={app.conversation_id}
className="flex items-center justify-between text-sm"
>
<span className="text-foreground truncate pr-2">
#{i + 1} {app.conversation_id.slice(0, 8)}
</span>
<span className="text-muted-foreground tabular-nums">
{formatNumber(app.total_tokens)}
</span>
</div>
))}
</div>
</div>
</>
)}
</CardContent>
</Card>
</div>
{/* Logs table */}
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardTitle>调用明细</CardTitle>
<CardDescription>Token 消耗逐条记录,支持日期筛选与分页</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Filters */}
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<label className="text-xs text-muted-foreground">开始日期</label>
<Input
type="date"
value={startDate}
onChange={(e) => {
setStartDate(e.target.value);
setPage(1);
}}
className="w-auto"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">结束日期</label>
<Input
type="date"
value={endDate}
onChange={(e) => {
setEndDate(e.target.value);
setPage(1);
}}
className="w-auto"
/>
</div>
<Button variant="outline" className="gap-1" onClick={handleReset}>
<Filter size={14} /> 重置
</Button>
{logsFetching && <Loader2 size={16} className="animate-spin text-primary" />}
</div>
{/* Table */}
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 text-muted-foreground">
<th className="text-left font-medium px-4 py-2.5">时间</th>
<th className="text-left font-medium px-4 py-2.5">模型</th>
<th className="text-right font-medium px-4 py-2.5">Prompt</th>
<th className="text-right font-medium px-4 py-2.5">Completion</th>
<th className="text-right font-medium px-4 py-2.5">Total</th>
</tr>
</thead>
<tbody>
{logsLoading ? (
<tr>
<td colSpan={5} className="text-center py-10 text-muted-foreground">
<Loader2 size={20} className="animate-spin mx-auto text-primary" />
</td>
</tr>
) : items.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-10 text-muted-foreground">
暂无调用记录
</td>
</tr>
) : (
items.map((log) => (
<tr
key={log.id}
className="border-t border-border hover:bg-muted/30 transition-colors"
>
<td className="px-4 py-2.5 text-muted-foreground whitespace-nowrap">
{formatTime(log.created_at)}
</td>
<td className="px-4 py-2.5 text-foreground">{log.model_name}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-muted-foreground">
{formatNumber(log.prompt_tokens)}
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-muted-foreground">
{formatNumber(log.completion_tokens)}
</td>
<td className="px-4 py-2.5 text-right tabular-nums font-medium text-foreground">
{formatNumber(log.total_tokens)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
第 {page} / {totalPages} 页 · 共 {formatNumber(total)} 条
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="gap-1"
disabled={page <= 1 || logsFetching}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft size={14} /> 上一页
</Button>
<Button
variant="outline"
size="sm"
className="gap-1"
disabled={page >= totalPages || logsFetching}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
下一页 <ChevronRight size={14} />
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
export default DashboardPage;