diff --git a/frontend-v2/src/App.tsx b/frontend-v2/src/App.tsx index 0061f4b..2aae59f 100644 --- a/frontend-v2/src/App.tsx +++ b/frontend-v2/src/App.tsx @@ -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() { /> } /> - - } - /> + } /> (path: string, options: RequestInit = {}): Promise { return res.json(); } +// Backend responses use a unified envelope `{code, data, message}`. +// `request()` returns the raw envelope; `data()` unwraps `.data`. +async function data(path: string, options: RequestInit = {}): Promise { + 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('/token/overview'), + + getRankings: () => data('/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(`/token/logs${q ? `?${q}` : ''}`); + }, + + getBalance: () => data('/token/balance'), }; diff --git a/frontend-v2/src/hooks/useApi.ts b/frontend-v2/src/hooks/useApi.ts index 3d0bba1..9442638 100644 --- a/frontend-v2/src/hooks/useApi.ts +++ b/frontend-v2/src/hooks/useApi.ts @@ -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, // 翻页时保留旧数据,避免闪烁 }); } diff --git a/frontend-v2/src/pages/front/DashboardPage/index.tsx b/frontend-v2/src/pages/front/DashboardPage/index.tsx new file mode 100644 index 0000000..bfc0119 --- /dev/null +++ b/frontend-v2/src/pages/front/DashboardPage/index.tsx @@ -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 ( + + +
+ {label} +
+ +
+
+
{value}
+ {hint &&

{hint}

} +
+
+ ); +} + +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 ( +
+
+ {name} + {formatNumber(total)} +
+
+ +
+
+ ); +} + +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 ( +
+ {/* Header */} +
+
+

Token 驾驶舱

+

+ 实时掌握 Token 消耗、余额与调用明细 +

+
+ {overview?.server_time && ( + + 北京时间 {formatTime(overview.server_time)} + + )} +
+ + {/* Stat cards */} +
+ + + + +
+ + {/* Rankings + balance side panel */} +
+ + + 模型消耗排行 + 按累计 token 消耗排序的模型分布 + + + {!rankings || rankings.model_rankings.length === 0 ? ( +
+ 暂无消耗数据 +
+ ) : ( + rankings.model_rankings.slice(0, 6).map((m) => ( + + )) + )} +
+
+ + + + 余额概览 + 账户 token 余额状态 + + +
+

当前余额

+

+ {balance ? formatNumber(balance.balance) : '—'} +

+

tokens

+
+
+
+ 最近更新 + {formatTime(balance?.updated_at ?? null)} +
+
+ 累计消耗 + + {formatNumber(overview?.total_used ?? 0)} + +
+ {rankings && rankings.top_apps.length > 0 && ( + <> +
+
+

Top 应用(按消耗)

+
+ {rankings.top_apps.map((app, i) => ( +
+ + #{i + 1} {app.conversation_id.slice(0, 8)} + + + {formatNumber(app.total_tokens)} + +
+ ))} +
+
+ + )} + + +
+ + {/* Logs table */} + + +
+ 调用明细 + Token 消耗逐条记录,支持日期筛选与分页 +
+
+ + {/* Filters */} +
+
+ + { + setStartDate(e.target.value); + setPage(1); + }} + className="w-auto" + /> +
+
+ + { + setEndDate(e.target.value); + setPage(1); + }} + className="w-auto" + /> +
+ + {logsFetching && } +
+ + {/* Table */} +
+ + + + + + + + + + + + {logsLoading ? ( + + + + ) : items.length === 0 ? ( + + + + ) : ( + items.map((log) => ( + + + + + + + + )) + )} + +
时间模型PromptCompletionTotal
+ +
+ 暂无调用记录 +
+ {formatTime(log.created_at)} + {log.model_name} + {formatNumber(log.prompt_tokens)} + + {formatNumber(log.completion_tokens)} + + {formatNumber(log.total_tokens)} +
+
+ + {/* Pagination */} +
+ + 第 {page} / {totalPages} 页 · 共 {formatNumber(total)} 条 + +
+ + +
+
+
+
+
+ ); +} + +export default DashboardPage;