feat: 初始化 React 数据看板项目

- Vite + React 18 + TypeScript
- TailwindCSS 暗色主题
- 仪表板、分析、事件浏览页面
- Recharts 图表组件
- Zustand 状态管理
- TanStack Query 数据请求
This commit is contained in:
tangweijie 2026-01-05 18:08:27 +08:00
parent 0eadc3da69
commit e0a6ba2dc4
28 changed files with 1444 additions and 2 deletions

114
README.md
View File

@ -1,3 +1,113 @@
# collector-dashboard # Collector Dashboard
数据分析看板 - React + TypeScript + Vite 数据分析看板 - React + TypeScript + Vite
## 项目介绍
Collector Dashboard 是 IDE 数据采集系统的可视化前端,提供数据分析、事件浏览和配置管理等功能。
## 技术栈
- **框架**: React 18
- **语言**: TypeScript
- **构建工具**: Vite
- **样式**: TailwindCSS
- **状态管理**: Zustand
- **数据请求**: TanStack Query
- **图表**: Recharts
- **路由**: React Router
## 功能特性
- 📊 数据概览仪表板
- 📈 采纳率趋势分析
- 📋 事件浏览和搜索
- ⚙️ 配置管理界面
- 🌙 暗色主题设计
## 快速开始
### 环境要求
- Node.js >= 18
- pnpm >= 8
### 安装
```bash
# 安装依赖
pnpm install
# 启动开发服务器
pnpm dev
# 构建生产版本
pnpm build
```
### 开发
```bash
# 启动开发服务器(端口 5173
pnpm dev
# 代码检查
pnpm lint
# 预览生产构建
pnpm preview
```
## 项目结构
```
collector-dashboard/
├── src/
│ ├── components/
│ │ ├── layout/ # 布局组件
│ │ ├── charts/ # 图表组件
│ │ └── common/ # 通用组件
│ ├── pages/
│ │ ├── Dashboard/ # 仪表板页面
│ │ ├── Analytics/ # 分析页面
│ │ ├── Events/ # 事件页面
│ │ └── Settings/ # 设置页面
│ ├── hooks/ # 自定义 Hooks
│ ├── services/ # API 服务
│ ├── stores/ # 状态管理
│ ├── types/ # 类型定义
│ ├── lib/ # 工具函数
│ ├── App.tsx
│ └── main.tsx
├── public/
├── index.html
├── package.json
├── tailwind.config.js
├── vite.config.ts
└── tsconfig.json
```
## API 代理
开发模式下API 请求会代理到 `http://localhost:8000`
```typescript
// vite.config.ts
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
}
```
## 相关仓库
- [ide-data-collector](../ide-data-collector) - IDE插件 Monorepo
- [collector-backend](../collector-backend) - 数据采集后端
## 许可证
MIT License

17
index.html Normal file
View File

@ -0,0 +1,17 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>IDE Collector Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

43
package.json Normal file
View File

@ -0,0 +1,43 @@
{
"name": "collector-dashboard",
"version": "0.1.0",
"private": true,
"description": "数据分析看板 - React + TypeScript + Vite",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.17.0",
"axios": "^1.6.0",
"clsx": "^2.1.0",
"date-fns": "^3.0.0",
"lucide-react": "^0.303.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.0",
"recharts": "^2.10.0",
"tailwind-merge": "^2.2.0",
"zustand": "^4.4.0"
},
"devDependencies": {
"@types/node": "^20.10.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.0",
"vite": "^5.0.0"
}
}

7
postcss.config.js Normal file
View File

@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

4
public/vite.svg Normal file
View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" fill="#0ea5e9" stroke="#0ea5e9"/>
</svg>

After

Width:  |  Height:  |  Size: 260 B

22
src/App.tsx Normal file
View File

@ -0,0 +1,22 @@
import { Routes, Route } from 'react-router-dom';
import Layout from '@/components/layout/Layout';
import Dashboard from '@/pages/Dashboard';
import Analytics from '@/pages/Analytics';
import Events from '@/pages/Events';
import Settings from '@/pages/Settings';
function App() {
return (
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="analytics" element={<Analytics />} />
<Route path="events" element={<Events />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
);
}
export default App;

View File

@ -0,0 +1,48 @@
import { Bell, Search, RefreshCw } from 'lucide-react';
import { useAppStore } from '@/stores/appStore';
export default function Header() {
const { lastRefresh, refreshData } = useAppStore();
return (
<header className="h-16 bg-[var(--card)] border-b border-[var(--border)] px-6 flex items-center justify-between">
{/* Search */}
<div className="flex items-center gap-3 flex-1 max-w-md">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<input
type="text"
placeholder="搜索事件、用户..."
className="w-full pl-10 pr-4 py-2 bg-zinc-800/50 border border-[var(--border)] rounded-lg text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500/50"
/>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-4">
{/* Last Refresh */}
<div className="text-xs text-zinc-500">
: {lastRefresh ? new Date(lastRefresh).toLocaleTimeString() : '-'}
</div>
{/* Refresh Button */}
<button
onClick={refreshData}
className="p-2 rounded-lg bg-zinc-800/50 border border-[var(--border)] hover:bg-zinc-700/50 transition-colors"
>
<RefreshCw className="w-4 h-4 text-zinc-400" />
</button>
{/* Notifications */}
<button className="p-2 rounded-lg bg-zinc-800/50 border border-[var(--border)] hover:bg-zinc-700/50 transition-colors relative">
<Bell className="w-4 h-4 text-zinc-400" />
<span className="absolute top-1 right-1 w-2 h-2 bg-primary-500 rounded-full" />
</button>
{/* User */}
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-primary-500 to-accent-500" />
</div>
</header>
);
}

View File

@ -0,0 +1,18 @@
import { Outlet } from 'react-router-dom';
import Sidebar from './Sidebar';
import Header from './Header';
export default function Layout() {
return (
<div className="flex h-screen bg-[var(--background)]">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-auto p-6 grid-background">
<Outlet />
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,68 @@
import { NavLink } from 'react-router-dom';
import {
LayoutDashboard,
BarChart3,
FileText,
Settings,
Zap
} from 'lucide-react';
import { cn } from '@/lib/utils';
const navItems = [
{ to: '/', icon: LayoutDashboard, label: '仪表板' },
{ to: '/analytics', icon: BarChart3, label: '数据分析' },
{ to: '/events', icon: FileText, label: '事件浏览' },
{ to: '/settings', icon: Settings, label: '设置' },
];
export default function Sidebar() {
return (
<aside className="w-64 bg-[var(--card)] border-r border-[var(--border)] flex flex-col">
{/* Logo */}
<div className="p-6 border-b border-[var(--border)]">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-accent-500 flex items-center justify-center">
<Zap className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="font-display font-semibold text-lg">IDE Collector</h1>
<p className="text-xs text-zinc-500"></p>
</div>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 p-4 space-y-1">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-all duration-200',
isActive
? 'bg-primary-500/10 text-primary-400 border border-primary-500/20'
: 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50'
)
}
>
<item.icon className="w-5 h-5" />
{item.label}
</NavLink>
))}
</nav>
{/* Footer */}
<div className="p-4 border-t border-[var(--border)]">
<div className="bg-zinc-800/50 rounded-lg p-4">
<p className="text-xs text-zinc-500 mb-2"></p>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-sm text-emerald-400"></span>
</div>
</div>
</div>
</aside>
);
}

27
src/hooks/useAnalytics.ts Normal file
View File

@ -0,0 +1,27 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
export function useOverview() {
return useQuery({
queryKey: ['overview'],
queryFn: api.getOverview,
staleTime: 1000 * 60 * 5, // 5 minutes
});
}
export function useAcceptanceRate() {
return useQuery({
queryKey: ['acceptanceRate'],
queryFn: api.getAcceptanceRate,
staleTime: 1000 * 60 * 5,
});
}
export function useEvents(params: { skip?: number; limit?: number } = {}) {
return useQuery({
queryKey: ['events', params],
queryFn: () => api.getEvents(params),
staleTime: 1000 * 60, // 1 minute
});
}

74
src/index.css Normal file
View File

@ -0,0 +1,74 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #0a0a0f;
--foreground: #fafafa;
--card: #111118;
--card-foreground: #fafafa;
--border: #27272a;
--ring: #3b82f6;
}
body {
@apply bg-[var(--background)] text-[var(--foreground)] antialiased;
font-feature-settings: 'liga' 1, 'calt' 1;
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@apply bg-zinc-900;
}
::-webkit-scrollbar-thumb {
@apply bg-zinc-700 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-zinc-600;
}
/* 图表渐变 */
.chart-gradient {
background: linear-gradient(180deg, rgba(14, 165, 233, 0.2) 0%, rgba(14, 165, 233, 0) 100%);
}
/* 卡片悬浮效果 */
.card-hover {
@apply transition-all duration-300 ease-out;
}
.card-hover:hover {
@apply transform -translate-y-1 shadow-lg shadow-primary-500/10;
}
/* 数字动画 */
@keyframes countUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-count-up {
animation: countUp 0.5s ease-out forwards;
}
/* 网格背景 */
.grid-background {
background-image:
linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
background-size: 50px 50px;
}

34
src/lib/utils.ts Normal file
View File

@ -0,0 +1,34 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatNumber(num: number): string {
return new Intl.NumberFormat('zh-CN').format(num);
}
export function formatPercentage(value: number, decimals = 1): string {
return `${(value * 100).toFixed(decimals)}%`;
}
export function formatDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
}
export function formatDateTime(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleString('zh-CN');
}
export function truncate(str: string, length: number): string {
if (str.length <= length) return str;
return str.slice(0, length) + '...';
}

26
src/main.tsx Normal file
View File

@ -0,0 +1,26 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
refetchOnWindowFocus: false,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
);

View File

@ -0,0 +1,48 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
export default function Analytics() {
const { data: acceptanceRate } = useQuery({
queryKey: ['acceptanceRate'],
queryFn: api.getAcceptanceRate,
});
return (
<div className="space-y-6 animate-fade-in">
<div>
<h1 className="text-2xl font-display font-bold"></h1>
<p className="text-zinc-500 text-sm mt-1">AI编程工具使用数据</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Acceptance Rate Section */}
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="text-4xl font-bold text-primary-400">
{((acceptanceRate?.overall ?? 0) * 100).toFixed(1)}%
</div>
<p className="text-zinc-500 text-sm mt-2"></p>
</div>
{/* Token Usage Section */}
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
<h3 className="text-lg font-semibold mb-4">Token 使</h3>
<p className="text-zinc-500"> Token </p>
</div>
{/* Provider Comparison */}
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
<h3 className="text-lg font-semibold mb-4"></h3>
<p className="text-zinc-500">AI服务商的效果对比</p>
</div>
{/* Language Distribution */}
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
<h3 className="text-lg font-semibold mb-4"></h3>
<p className="text-zinc-500">使</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,69 @@
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
const data = [
{ date: '1/1', rate: 65 },
{ date: '1/2', rate: 72 },
{ date: '1/3', rate: 68 },
{ date: '1/4', rate: 75 },
{ date: '1/5', rate: 82 },
{ date: '1/6', rate: 78 },
{ date: '1/7', rate: 85 },
];
export default function AcceptanceChart() {
return (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data}>
<defs>
<linearGradient id="colorRate" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#0ea5e9" stopOpacity={0.3} />
<stop offset="95%" stopColor="#0ea5e9" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#27272a" />
<XAxis
dataKey="date"
stroke="#71717a"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="#71717a"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}%`}
/>
<Tooltip
contentStyle={{
backgroundColor: '#18181b',
border: '1px solid #27272a',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number) => [`${value}%`, '采纳率']}
/>
<Area
type="monotone"
dataKey="rate"
stroke="#0ea5e9"
strokeWidth={2}
fillOpacity={1}
fill="url(#colorRate)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}

View File

@ -0,0 +1,60 @@
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
const data = [
{ hour: '00', events: 120 },
{ hour: '04', events: 80 },
{ hour: '08', events: 450 },
{ hour: '12', events: 380 },
{ hour: '16', events: 520 },
{ hour: '20', events: 280 },
];
export default function ActivityChart() {
return (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="#27272a" />
<XAxis
dataKey="hour"
stroke="#71717a"
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}:00`}
/>
<YAxis
stroke="#71717a"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: '#18181b',
border: '1px solid #27272a',
borderRadius: '8px',
fontSize: '12px',
}}
formatter={(value: number) => [value, '事件数']}
/>
<Bar
dataKey="events"
fill="#d946ef"
radius={[4, 4, 0, 0]}
opacity={0.8}
/>
</BarChart>
</ResponsiveContainer>
</div>
);
}

View File

@ -0,0 +1,74 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { cn } from '@/lib/utils';
import { CheckCircle2, XCircle, MessageSquare, Code } from 'lucide-react';
const eventTypeConfig: Record<string, { icon: typeof CheckCircle2; color: string; label: string }> = {
code_completion_accepted: {
icon: CheckCircle2,
color: 'text-emerald-400',
label: '补全接受',
},
code_completion_rejected: {
icon: XCircle,
color: 'text-rose-400',
label: '补全拒绝',
},
chat_message_sent: {
icon: MessageSquare,
color: 'text-primary-400',
label: '聊天消息',
},
code_completion_shown: {
icon: Code,
color: 'text-zinc-400',
label: '补全展示',
},
};
export default function RecentEvents() {
const { data: events, isLoading } = useQuery({
queryKey: ['recentEvents'],
queryFn: () => api.getEvents({ limit: 10 }),
});
if (isLoading) {
return (
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-16 bg-zinc-800/50 rounded-lg animate-pulse" />
))}
</div>
);
}
return (
<div className="space-y-2">
{(events ?? []).map((event) => {
const config = eventTypeConfig[event.eventType] ?? eventTypeConfig.code_completion_shown;
const Icon = config.icon;
return (
<div
key={event.eventId}
className="flex items-center gap-4 p-4 rounded-lg bg-zinc-800/30 hover:bg-zinc-800/50 transition-colors"
>
<div className={cn('p-2 rounded-lg bg-zinc-800', config.color)}>
<Icon className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm">{config.label}</p>
<p className="text-xs text-zinc-500 truncate">
{event.language ?? 'unknown'} · {event.ideType}
</p>
</div>
<div className="text-xs text-zinc-500">
{new Date(event.timestamp).toLocaleTimeString()}
</div>
</div>
);
})}
</div>
);
}

View File

@ -0,0 +1,84 @@
import { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
interface StatsCardProps {
title: string;
value: string | number;
change: string;
icon: LucideIcon;
color: 'primary' | 'emerald' | 'violet' | 'amber';
delay?: number;
isLoading?: boolean;
}
const colorMap = {
primary: {
bg: 'bg-primary-500/10',
text: 'text-primary-400',
border: 'border-primary-500/20',
},
emerald: {
bg: 'bg-emerald-500/10',
text: 'text-emerald-400',
border: 'border-emerald-500/20',
},
violet: {
bg: 'bg-violet-500/10',
text: 'text-violet-400',
border: 'border-violet-500/20',
},
amber: {
bg: 'bg-amber-500/10',
text: 'text-amber-400',
border: 'border-amber-500/20',
},
};
export default function StatsCard({
title,
value,
change,
icon: Icon,
color,
delay = 0,
isLoading,
}: StatsCardProps) {
const colors = colorMap[color];
const isPositive = change.startsWith('+');
return (
<div
className={cn(
'bg-[var(--card)] rounded-xl border border-[var(--border)] p-6 card-hover',
'animate-slide-up'
)}
style={{ animationDelay: `${delay}ms` }}
>
<div className="flex items-start justify-between">
<div>
<p className="text-zinc-500 text-sm">{title}</p>
{isLoading ? (
<div className="h-8 w-24 bg-zinc-800 rounded animate-pulse mt-2" />
) : (
<p className="text-3xl font-bold mt-2 animate-count-up">{value}</p>
)}
</div>
<div className={cn('p-3 rounded-xl', colors.bg, 'border', colors.border)}>
<Icon className={cn('w-5 h-5', colors.text)} />
</div>
</div>
<div className="mt-4 flex items-center gap-2">
<span
className={cn(
'text-sm font-medium',
isPositive ? 'text-emerald-400' : 'text-rose-400'
)}
>
{change}
</span>
<span className="text-zinc-500 text-sm">vs </span>
</div>
</div>
);
}

View File

@ -0,0 +1,103 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import StatsCard from './StatsCard';
import AcceptanceChart from './AcceptanceChart';
import ActivityChart from './ActivityChart';
import RecentEvents from './RecentEvents';
import {
Activity,
Users,
CheckCircle2,
Zap,
TrendingUp,
Clock
} from 'lucide-react';
export default function Dashboard() {
const { data: overview, isLoading } = useQuery({
queryKey: ['overview'],
queryFn: api.getOverview,
});
const stats = [
{
title: '总事件数',
value: overview?.totalEvents ?? 0,
change: '+12%',
icon: Activity,
color: 'primary',
},
{
title: '活跃用户',
value: overview?.activeUsers ?? 0,
change: '+8%',
icon: Users,
color: 'emerald',
},
{
title: '采纳率',
value: `${((overview?.acceptanceRate ?? 0) * 100).toFixed(1)}%`,
change: '+5%',
icon: CheckCircle2,
color: 'violet',
},
{
title: '平均延迟',
value: `${overview?.avgLatencyMs ?? 0}ms`,
change: '-15%',
icon: Clock,
color: 'amber',
},
];
return (
<div className="space-y-6 animate-fade-in">
{/* Page Title */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-display font-bold"></h1>
<p className="text-zinc-500 text-sm mt-1">AI编程工具使用数据概览</p>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-zinc-500">:</span>
<select className="bg-zinc-800 border border-[var(--border)] rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/50">
<option>7</option>
<option>30</option>
<option>90</option>
</select>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((stat, index) => (
<StatsCard
key={stat.title}
{...stat}
delay={index * 100}
isLoading={isLoading}
/>
))}
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
<h3 className="text-lg font-semibold mb-4"></h3>
<AcceptanceChart />
</div>
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
<h3 className="text-lg font-semibold mb-4"></h3>
<ActivityChart />
</div>
</div>
{/* Recent Events */}
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
<h3 className="text-lg font-semibold mb-4"></h3>
<RecentEvents />
</div>
</div>
);
}

120
src/pages/Events/index.tsx Normal file
View File

@ -0,0 +1,120 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { cn } from '@/lib/utils';
import { Search, Filter, ChevronLeft, ChevronRight } from 'lucide-react';
export default function Events() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const limit = 20;
const { data: events, isLoading } = useQuery({
queryKey: ['events', page, limit],
queryFn: () => api.getEvents({ skip: (page - 1) * limit, limit }),
});
return (
<div className="space-y-6 animate-fade-in">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-display font-bold"></h1>
<p className="text-zinc-500 text-sm mt-1"></p>
</div>
</div>
{/* Filters */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<input
type="text"
placeholder="搜索事件..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-[var(--card)] border border-[var(--border)] rounded-lg text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-primary-500/50"
/>
</div>
<button className="flex items-center gap-2 px-4 py-2 bg-[var(--card)] border border-[var(--border)] rounded-lg text-sm hover:bg-zinc-800 transition-colors">
<Filter className="w-4 h-4" />
</button>
</div>
{/* Events Table */}
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
<table className="w-full">
<thead className="bg-zinc-800/50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-zinc-400 uppercase">ID</th>
<th className="px-6 py-3 text-left text-xs font-medium text-zinc-400 uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-zinc-400 uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-zinc-400 uppercase">IDE</th>
<th className="px-6 py-3 text-left text-xs font-medium text-zinc-400 uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border)]">
{isLoading ? (
[...Array(10)].map((_, i) => (
<tr key={i}>
<td colSpan={5} className="px-6 py-4">
<div className="h-4 bg-zinc-800 rounded animate-pulse" />
</td>
</tr>
))
) : (
(events ?? []).map((event) => (
<tr key={event.eventId} className="hover:bg-zinc-800/30 transition-colors">
<td className="px-6 py-4 text-sm font-mono text-zinc-300">
{event.eventId.substring(0, 8)}...
</td>
<td className="px-6 py-4">
<span className={cn(
'px-2 py-1 text-xs rounded-full',
event.eventType.includes('accepted')
? 'bg-emerald-500/10 text-emerald-400'
: event.eventType.includes('rejected')
? 'bg-rose-500/10 text-rose-400'
: 'bg-zinc-500/10 text-zinc-400'
)}>
{event.eventType.replace(/_/g, ' ')}
</span>
</td>
<td className="px-6 py-4 text-sm text-zinc-300">{event.language ?? '-'}</td>
<td className="px-6 py-4 text-sm text-zinc-300">{event.ideType}</td>
<td className="px-6 py-4 text-sm text-zinc-500">
{new Date(event.timestamp).toLocaleString()}
</td>
</tr>
))
)}
</tbody>
</table>
{/* Pagination */}
<div className="flex items-center justify-between px-6 py-4 border-t border-[var(--border)]">
<p className="text-sm text-zinc-500">
{(page - 1) * limit + 1} - {page * limit}
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 1}
className="p-2 rounded-lg border border-[var(--border)] disabled:opacity-50 hover:bg-zinc-800 transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm text-zinc-400"> {page} </span>
<button
onClick={() => setPage(page + 1)}
className="p-2 rounded-lg border border-[var(--border)] hover:bg-zinc-800 transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,125 @@
import { useState } from 'react';
import { Save, RefreshCw } from 'lucide-react';
export default function Settings() {
const [settings, setSettings] = useState({
apiEndpoint: 'http://localhost:8000/api/v1',
samplingRate: 1.0,
batchSize: 50,
flushInterval: 60,
anonymizeUser: true,
obfuscateCode: true,
});
const handleSave = () => {
console.log('Saving settings:', settings);
// TODO: Save to backend
};
return (
<div className="space-y-6 animate-fade-in max-w-2xl">
<div>
<h1 className="text-2xl font-display font-bold"></h1>
<p className="text-zinc-500 text-sm mt-1"></p>
</div>
{/* API Settings */}
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6 space-y-4">
<h3 className="text-lg font-semibold">API </h3>
<div>
<label className="block text-sm text-zinc-400 mb-2">API </label>
<input
type="text"
value={settings.apiEndpoint}
onChange={(e) => setSettings({ ...settings, apiEndpoint: e.target.value })}
className="w-full px-4 py-2 bg-zinc-800 border border-[var(--border)] rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/50"
/>
</div>
</div>
{/* Collection Settings */}
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6 space-y-4">
<h3 className="text-lg font-semibold"></h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-zinc-400 mb-2"></label>
<input
type="number"
min="0"
max="1"
step="0.1"
value={settings.samplingRate}
onChange={(e) => setSettings({ ...settings, samplingRate: parseFloat(e.target.value) })}
className="w-full px-4 py-2 bg-zinc-800 border border-[var(--border)] rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/50"
/>
</div>
<div>
<label className="block text-sm text-zinc-400 mb-2"></label>
<input
type="number"
value={settings.batchSize}
onChange={(e) => setSettings({ ...settings, batchSize: parseInt(e.target.value) })}
className="w-full px-4 py-2 bg-zinc-800 border border-[var(--border)] rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/50"
/>
</div>
<div>
<label className="block text-sm text-zinc-400 mb-2"></label>
<input
type="number"
value={settings.flushInterval}
onChange={(e) => setSettings({ ...settings, flushInterval: parseInt(e.target.value) })}
className="w-full px-4 py-2 bg-zinc-800 border border-[var(--border)] rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/50"
/>
</div>
</div>
</div>
{/* Privacy Settings */}
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6 space-y-4">
<h3 className="text-lg font-semibold"></h3>
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={settings.anonymizeUser}
onChange={(e) => setSettings({ ...settings, anonymizeUser: e.target.checked })}
className="w-4 h-4 rounded border-zinc-600 bg-zinc-800 text-primary-500 focus:ring-primary-500/50"
/>
<span className="text-sm">ID</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={settings.obfuscateCode}
onChange={(e) => setSettings({ ...settings, obfuscateCode: e.target.checked })}
className="w-4 h-4 rounded border-zinc-600 bg-zinc-800 text-primary-500 focus:ring-primary-500/50"
/>
<span className="text-sm"></span>
</label>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-4">
<button
onClick={handleSave}
className="flex items-center gap-2 px-6 py-2 bg-primary-500 hover:bg-primary-600 rounded-lg text-sm font-medium transition-colors"
>
<Save className="w-4 h-4" />
</button>
<button
onClick={() => window.location.reload()}
className="flex items-center gap-2 px-6 py-2 bg-zinc-800 hover:bg-zinc-700 border border-[var(--border)] rounded-lg text-sm font-medium transition-colors"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
</div>
);
}

48
src/services/api.ts Normal file
View File

@ -0,0 +1,48 @@
import axios from 'axios';
import type { OverviewStats, AcceptanceRateStats, EventResponse } from '@/types';
const client = axios.create({
baseURL: '/api/v1',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
export const api = {
// Health
async healthCheck() {
const { data } = await client.get('/health');
return data;
},
// Overview
async getOverview(): Promise<OverviewStats> {
const { data } = await client.get<OverviewStats>('/analytics/overview');
return data;
},
// Acceptance Rate
async getAcceptanceRate(): Promise<AcceptanceRateStats> {
const { data } = await client.get<AcceptanceRateStats>('/analytics/acceptance-rate');
return data;
},
// Events
async getEvents(params: { skip?: number; limit?: number } = {}): Promise<EventResponse[]> {
const { data } = await client.get<EventResponse[]>('/events', { params });
return data;
},
async getEvent(eventId: string): Promise<EventResponse> {
const { data } = await client.get<EventResponse>(`/events/${eventId}`);
return data;
},
// Config
async getConfig() {
const { data } = await client.get('/config');
return data;
},
};

26
src/stores/appStore.ts Normal file
View File

@ -0,0 +1,26 @@
import { create } from 'zustand';
interface AppState {
lastRefresh: Date | null;
isLoading: boolean;
error: string | null;
refreshData: () => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
}
export const useAppStore = create<AppState>((set) => ({
lastRefresh: null,
isLoading: false,
error: null,
refreshData: () => {
set({ lastRefresh: new Date() });
// Trigger refetch via React Query
window.dispatchEvent(new CustomEvent('app:refresh'));
},
setLoading: (loading) => set({ isLoading: loading }),
setError: (error) => set({ error }),
}));

59
src/types/index.ts Normal file
View File

@ -0,0 +1,59 @@
export interface OverviewStats {
totalEvents: number;
totalUsers: number;
activeUsers: number;
totalCompletions: number;
totalAccepted: number;
acceptanceRate: number;
avgLatencyMs: number;
totalTokens: number;
periodStart: string;
periodEnd: string;
}
export interface AcceptanceRateStats {
overall: number;
timeSeries: TimeSeriesPoint[];
byProvider: Record<string, number>;
byLanguage: Record<string, number>;
byIDE: Record<string, number>;
}
export interface TimeSeriesPoint {
timestamp: string;
value: number;
}
export interface TokenUsageStats {
totalPromptTokens: number;
totalCompletionTokens: number;
totalTokens: number;
avgTokensPerCompletion: number;
timeSeries: TimeSeriesPoint[];
byProvider: Record<string, number>;
}
export interface EventResponse {
id: number;
eventId: string;
eventType: string;
timestamp: string;
userId: string;
ideType: string;
language: string | null;
aiProvider: string | null;
accepted: boolean | null;
createdAt: string;
}
export interface UserActivityStats {
dailyActiveUsers: TimeSeriesPoint[];
hourlyDistribution: Record<number, number>;
topUsers: Array<{
userId: string;
eventCount: number;
}>;
avgEventsPerUser: number;
avgSessionsPerUser: number;
}

61
tailwind.config.js Normal file
View File

@ -0,0 +1,61 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
950: '#082f49',
},
accent: {
50: '#fdf4ff',
100: '#fae8ff',
200: '#f5d0fe',
300: '#f0abfc',
400: '#e879f9',
500: '#d946ef',
600: '#c026d3',
700: '#a21caf',
800: '#86198f',
900: '#701a75',
950: '#4a044e',
},
},
fontFamily: {
sans: ['JetBrains Mono', 'Fira Code', 'monospace'],
display: ['Outfit', 'sans-serif'],
},
animation: {
'fade-in': 'fadeIn 0.3s ease-out',
'slide-up': 'slideUp 0.4s ease-out',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
},
},
},
plugins: [],
};

32
tsconfig.json Normal file
View File

@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Paths */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

12
tsconfig.node.json Normal file
View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

23
vite.config.ts Normal file
View File

@ -0,0 +1,23 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
});