feat(frontend-v2): #986 Token 驾驶舱真实数据页 (token/overview|rankings|logs|balance)
This commit is contained in:
1 parent
4a2920e637
commit
910394423d
4 files changed
+501
-35
No files matched your search
+2
-16
@@ -8,6 +8,7 @@ import { ChatPage } from '@/pages/front/ChatPage';
|
||||
import { KnowledgePage } from '@/pages/front/KnowledgePage';
|
||||
import { ImBotHub } from '@/pages/front/ImBotHub';
|
||||
import { AppServices } from '@/pages/front/AppServices';
|
||||
import { DashboardPage } from '@/pages/front/DashboardPage';
|
||||
import { ComingSoon } from '@/components/common/ComingSoon';
|
||||
import {
|
||||
Zap,
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
Workflow,
|
||||
Shield,
|
||||
Radio,
|
||||
LayoutDashboard,
|
||||
User,
|
||||
Bell,
|
||||
Sparkles,
|
||||
@@ -125,21 +125,7 @@ function App() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="dashboard"
|
||||
element={
|
||||
<ComingSoon
|
||||
title="驾驶舱"
|
||||
icon={LayoutDashboard}
|
||||
description="全局数据驾驶舱,实时掌握平台运行与资源消耗。"
|
||||
features={[
|
||||
'Token 消耗与趋势分析',
|
||||
'模型调用与成功率监控',
|
||||
'租户维度用量报表',
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route
|
||||
path="profile"
|
||||
element={
|
||||
|
||||
@@ -24,6 +24,13 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
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 ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const authApi = {
|
||||
@@ -229,19 +236,78 @@ export const profileApi = {
|
||||
getBalance: () => request<{ balance: number; total_used: number }>('/profile/balance'),
|
||||
};
|
||||
|
||||
// ─── Dashboard / Stats ──────────────────────────────────────────────────────────
|
||||
// ─── Token 驾驶舱 ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const dashboardApi = {
|
||||
getStats: () =>
|
||||
request<{
|
||||
active_users: number;
|
||||
today_conversations: number;
|
||||
skill_count: number;
|
||||
revenue: number;
|
||||
user_change: string;
|
||||
conv_change: string;
|
||||
}>('/token/usage'),
|
||||
export interface TokenOverview {
|
||||
total_used: number;
|
||||
balance: number;
|
||||
today_apps: number;
|
||||
server_time: string;
|
||||
}
|
||||
|
||||
getHotSkills: () =>
|
||||
request<{ name: string; downloads: string }[]>('/market/apps'),
|
||||
export interface ModelRanking {
|
||||
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'),
|
||||
};
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
marketApi,
|
||||
imBotApi,
|
||||
profileApi,
|
||||
dashboardApi,
|
||||
tokenApi,
|
||||
type TokenOverview,
|
||||
} from '@/api/client';
|
||||
|
||||
// ─── Auth ───────────────────────────────────────────────────────────────────────
|
||||
@@ -127,7 +128,7 @@ export function useMarketApps() {
|
||||
return useQuery({
|
||||
queryKey: ['market', 'apps'],
|
||||
queryFn: marketApi.listApps,
|
||||
select: (d) => asArray(d),
|
||||
select: (d: unknown) => asArray(d),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -135,7 +136,7 @@ export function useMyApps() {
|
||||
return useQuery({
|
||||
queryKey: ['market', 'my-apps'],
|
||||
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() {
|
||||
return useQuery({
|
||||
queryKey: ['dashboard', 'stats'],
|
||||
queryFn: dashboardApi.getStats,
|
||||
queryKey: ['token', 'overview'],
|
||||
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;
|
||||
Reference in new issue
Block a user