feat: 初始化 React 数据看板项目
- Vite + React 18 + TypeScript - TailwindCSS 暗色主题 - 仪表板、分析、事件浏览页面 - Recharts 图表组件 - Zustand 状态管理 - TanStack Query 数据请求
This commit is contained in:
parent
0eadc3da69
commit
e0a6ba2dc4
112
README.md
112
README.md
@ -1,3 +1,113 @@
|
||||
# collector-dashboard
|
||||
# Collector Dashboard
|
||||
|
||||
数据分析看板 - 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