feat: 初始化 React 数据看板项目
- Vite + React 18 + TypeScript - TailwindCSS 暗色主题 - 仪表板、分析、事件浏览页面 - Recharts 图表组件 - Zustand 状态管理 - TanStack Query 数据请求
This commit is contained in:
parent
0eadc3da69
commit
e0a6ba2dc4
114
README.md
114
README.md
@ -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
17
index.html
Normal 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
43
package.json
Normal 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
7
postcss.config.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
4
public/vite.svg
Normal file
4
public/vite.svg
Normal 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
22
src/App.tsx
Normal 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;
|
||||||
|
|
||||||
48
src/components/layout/Header.tsx
Normal file
48
src/components/layout/Header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
18
src/components/layout/Layout.tsx
Normal file
18
src/components/layout/Layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
68
src/components/layout/Sidebar.tsx
Normal file
68
src/components/layout/Sidebar.tsx
Normal 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
27
src/hooks/useAnalytics.ts
Normal 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
74
src/index.css
Normal 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
34
src/lib/utils.ts
Normal 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
26
src/main.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
|
||||||
48
src/pages/Analytics/index.tsx
Normal file
48
src/pages/Analytics/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
69
src/pages/Dashboard/AcceptanceChart.tsx
Normal file
69
src/pages/Dashboard/AcceptanceChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
60
src/pages/Dashboard/ActivityChart.tsx
Normal file
60
src/pages/Dashboard/ActivityChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
74
src/pages/Dashboard/RecentEvents.tsx
Normal file
74
src/pages/Dashboard/RecentEvents.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
84
src/pages/Dashboard/StatsCard.tsx
Normal file
84
src/pages/Dashboard/StatsCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
103
src/pages/Dashboard/index.tsx
Normal file
103
src/pages/Dashboard/index.tsx
Normal 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
120
src/pages/Events/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
125
src/pages/Settings/index.tsx
Normal file
125
src/pages/Settings/index.tsx
Normal 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
48
src/services/api.ts
Normal 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
26
src/stores/appStore.ts
Normal 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
59
src/types/index.ts
Normal 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
61
tailwind.config.js
Normal 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
32
tsconfig.json
Normal 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
12
tsconfig.node.json
Normal 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
23
vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user