CodeReview/frontend/src/pages/Dashboard.tsx

629 lines
26 KiB
TypeScript
Raw Normal View History

/**
* Dashboard Page
* Cyberpunk Terminal Aesthetic
*/
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
LineChart, Line, PieChart, Pie, Cell,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer
} from "recharts";
import {
Activity, AlertTriangle, Clock, Code,
FileText, GitBranch, Shield, TrendingUp, Zap,
BarChart3, Target, ArrowUpRight, Calendar,
MessageSquare, Bot, Cpu, Terminal
} from "lucide-react";
import { api, dbMode, isDemoMode } from "@/shared/config/database";
import type { Project, AuditTask, ProjectStats } from "@/shared/types";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import { getRuleSets } from "@/shared/api/rules";
import { getPromptTemplates } from "@/shared/api/prompts";
export default function Dashboard() {
const [stats, setStats] = useState<ProjectStats | null>(null);
const [recentProjects, setRecentProjects] = useState<Project[]>([]);
const [recentTasks, setRecentTasks] = useState<AuditTask[]>([]);
const [loading, setLoading] = useState(true);
const [issueTypeData, setIssueTypeData] = useState<Array<{ name: string; value: number; color: string }>>([]);
const [qualityTrendData, setQualityTrendData] = useState<Array<{ date: string; score: number }>>([]);
const [ruleStats, setRuleStats] = useState({ total: 0, enabled: 0 });
const [templateStats, setTemplateStats] = useState({ total: 0, active: 0 });
useEffect(() => {
loadDashboardData();
}, []);
const loadDashboardData = async () => {
try {
setLoading(true);
const results = await Promise.allSettled([
api.getProjectStats(),
api.getProjects(),
api.getAuditTasks()
]);
if (results[0].status === 'fulfilled') {
setStats(results[0].value);
} else {
setStats({
total_projects: 0,
active_projects: 0,
total_tasks: 0,
completed_tasks: 0,
total_issues: 0,
resolved_issues: 0,
avg_quality_score: 0
});
}
if (results[1].status === 'fulfilled') {
setRecentProjects(Array.isArray(results[1].value) ? results[1].value.slice(0, 6) : []);
} else {
setRecentProjects([]);
}
let tasks: AuditTask[] = [];
if (results[2].status === 'fulfilled') {
tasks = Array.isArray(results[2].value) ? results[2].value : [];
setRecentTasks(tasks.slice(0, 10));
} else {
setRecentTasks([]);
}
if (tasks.length > 0) {
const tasksByDate = tasks
.filter(t => t.completed_at && t.quality_score > 0)
.sort((a, b) => new Date(a.completed_at!).getTime() - new Date(b.completed_at!).getTime())
.slice(-6);
const trendData = tasksByDate.map((task) => ({
date: new Date(task.completed_at!).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }),
score: task.quality_score
}));
setQualityTrendData(trendData.length > 0 ? trendData : []);
} else {
setQualityTrendData([]);
}
try {
const allIssues = await Promise.all(
tasks.map(task => api.getAuditIssues(task.id).catch(() => []))
);
const flatIssues = allIssues.flat();
if (flatIssues.length > 0) {
const typeCount: Record<string, number> = {};
flatIssues.forEach(issue => {
typeCount[issue.issue_type] = (typeCount[issue.issue_type] || 0) + 1;
});
const typeMap: Record<string, { name: string; color: string }> = {
security: { name: '安全问题', color: '#f43f5e' },
bug: { name: '潜在Bug', color: '#f97316' },
performance: { name: '性能问题', color: '#eab308' },
style: { name: '代码风格', color: '#3b82f6' },
maintainability: { name: '可维护性', color: '#8b5cf6' }
};
const issueData = Object.entries(typeCount).map(([type, count]) => ({
name: typeMap[type]?.name || type,
value: count,
color: typeMap[type]?.color || '#6b7280'
}));
setIssueTypeData(issueData);
} else {
setIssueTypeData([]);
}
} catch (error) {
setIssueTypeData([]);
}
try {
const [rulesRes, promptsRes] = await Promise.all([
getRuleSets(),
getPromptTemplates(),
]);
const totalRules = rulesRes.items.reduce((acc, rs) => acc + rs.rules_count, 0);
const enabledRules = rulesRes.items.reduce((acc, rs) => acc + rs.enabled_rules_count, 0);
setRuleStats({ total: totalRules, enabled: enabledRules });
setTemplateStats({
total: promptsRes.items.length,
active: promptsRes.items.filter(t => t.is_active).length
});
} catch (error) {
console.error('获取规则和模板统计失败:', error);
}
} catch (error) {
console.error('仪表盘数据加载失败:', error);
toast.error("数据加载失败");
} finally {
setLoading(false);
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'completed':
return <Badge className="cyber-badge-success"></Badge>;
case 'running':
return <Badge className="cyber-badge-info"></Badge>;
case 'failed':
return <Badge className="cyber-badge-danger"></Badge>;
default:
return <Badge className="cyber-badge-muted"></Badge>;
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center space-y-4">
<div className="loading-spinner mx-auto" />
<p className="text-muted-foreground font-mono text-base uppercase tracking-wider">...</p>
</div>
</div>
);
}
return (
<div className="space-y-6 p-6 bg-background min-h-screen font-mono relative">
{/* Grid background */}
<div className="absolute inset-0 cyber-grid-subtle pointer-events-none" />
{/* Demo Mode Warning */}
{isDemoMode && (
<div className="relative z-10 cyber-card p-4 border-amber-500/30 bg-amber-500/5">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-400 mt-0.5" />
<div className="text-sm text-foreground/80">
使<span className="text-amber-400 font-bold"></span>
<Link to="/admin" className="ml-2 text-primary font-bold hover:underline">
</Link>
</div>
</div>
</div>
)}
{/* Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 relative z-10">
{/* Total Projects */}
<div className="cyber-card p-4">
<div className="flex items-center justify-between">
<div>
<p className="stat-label"></p>
<p className="stat-value">{stats?.total_projects || 0}</p>
<p className="text-sm text-emerald-400 mt-1 flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-emerald-400" />
: {stats?.active_projects || 0}
</p>
</div>
<div className="stat-icon text-primary">
<Code className="w-6 h-6" />
</div>
</div>
</div>
{/* Audit Tasks */}
<div className="cyber-card p-4">
<div className="flex items-center justify-between">
<div>
<p className="stat-label"></p>
<p className="stat-value">{stats?.total_tasks || 0}</p>
<p className="text-sm text-emerald-400 mt-1 flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-emerald-400" />
: {stats?.completed_tasks || 0}
</p>
</div>
<div className="stat-icon text-emerald-400">
<Activity className="w-6 h-6" />
</div>
</div>
</div>
{/* Issues Found */}
<div className="cyber-card p-4">
<div className="flex items-center justify-between">
<div>
<p className="stat-label"></p>
<p className="stat-value">{stats?.total_issues || 0}</p>
<p className="text-sm text-amber-400 mt-1 flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-amber-400" />
: {stats?.resolved_issues || 0}
</p>
</div>
<div className="stat-icon text-amber-400">
<AlertTriangle className="w-6 h-6" />
</div>
</div>
</div>
{/* Quality Score */}
<div className="cyber-card p-4">
<div className="flex items-center justify-between">
<div>
<p className="stat-label"></p>
<p className="stat-value">
{stats?.avg_quality_score ? stats.avg_quality_score.toFixed(1) : '0.0'}
</p>
{stats?.avg_quality_score ? (
<p className="text-sm text-emerald-400 mt-1 flex items-center gap-1">
<TrendingUp className="w-4 h-4" />
</p>
) : (
<p className="text-sm text-muted-foreground mt-1"></p>
)}
</div>
<div className="stat-icon text-violet-400">
<Target className="w-6 h-6" />
</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="grid grid-cols-1 xl:grid-cols-4 gap-4 relative z-10">
{/* Left Content */}
<div className="xl:col-span-3 space-y-4">
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Quality Trend */}
<div className="cyber-card p-4">
<div className="section-header">
<TrendingUp className="w-5 h-5 text-primary" />
<h3 className="section-title"></h3>
</div>
{qualityTrendData.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={qualityTrendData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--cyber-border)" />
<XAxis dataKey="date" stroke="var(--cyber-text-muted)" fontSize={11} tick={{ fontFamily: 'monospace' }} />
<YAxis stroke="var(--cyber-text-muted)" fontSize={11} domain={[0, 100]} tick={{ fontFamily: 'monospace' }} />
<Tooltip
contentStyle={{
backgroundColor: 'var(--cyber-bg-elevated)',
border: '1px solid var(--cyber-border)',
borderRadius: '4px',
fontFamily: 'monospace',
fontSize: '12px',
color: 'var(--cyber-text)'
}}
/>
<Line
type="monotone"
dataKey="score"
stroke="hsl(var(--primary))"
strokeWidth={2}
dot={{ fill: 'hsl(var(--primary))', stroke: 'var(--cyber-bg)', strokeWidth: 2, r: 4 }}
activeDot={{ r: 6, fill: 'hsl(var(--primary))' }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<div className="empty-state h-[220px]">
<TrendingUp className="empty-state-icon" />
<p className="empty-state-description"></p>
</div>
)}
</div>
{/* Issue Distribution */}
<div className="cyber-card p-4">
<div className="section-header">
<BarChart3 className="w-5 h-5 text-violet-400" />
<h3 className="section-title"></h3>
</div>
{issueTypeData.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={issueTypeData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
outerRadius={70}
dataKey="value"
stroke="var(--cyber-bg)"
strokeWidth={2}
>
{issueTypeData.map((entry) => (
<Cell key={`cell-${entry.name}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: 'var(--cyber-bg-elevated)',
border: '1px solid var(--cyber-border)',
borderRadius: '4px',
fontFamily: 'monospace',
fontSize: '12px',
color: 'var(--cyber-text)'
}}
/>
</PieChart>
</ResponsiveContainer>
) : (
<div className="empty-state h-[220px]">
<BarChart3 className="empty-state-icon" />
<p className="empty-state-description"></p>
</div>
)}
</div>
</div>
{/* Projects Overview */}
<div className="cyber-card p-4">
<div className="section-header">
<FileText className="w-5 h-5 text-primary" />
<h3 className="section-title"></h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{recentProjects.length > 0 ? (
recentProjects.map((project) => (
<Link
key={project.id}
to={`/projects/${project.id}`}
className="block p-4 rounded-lg transition-all group"
style={{
background: 'var(--cyber-bg-elevated)',
border: '1px solid var(--cyber-border)'
}}
onMouseOver={(e) => {
e.currentTarget.style.background = 'var(--cyber-hover-bg)';
e.currentTarget.style.borderColor = 'var(--cyber-border-accent)';
}}
onMouseOut={(e) => {
e.currentTarget.style.background = 'var(--cyber-bg-elevated)';
e.currentTarget.style.borderColor = 'var(--cyber-border)';
}}
>
<div className="flex items-start justify-between mb-2">
<h4 className="font-semibold text-foreground group-hover:text-primary transition-colors truncate">
{project.name}
</h4>
<Badge className={`ml-2 flex-shrink-0 ${project.is_active ? 'cyber-badge-success' : 'cyber-badge-muted'}`}>
{project.is_active ? '活跃' : '暂停'}
</Badge>
</div>
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">
{project.description || '暂无描述'}
</p>
<div className="flex items-center text-sm text-muted-foreground">
<Calendar className="w-4 h-4 mr-1" />
{new Date(project.created_at).toLocaleDateString('zh-CN')}
</div>
</Link>
))
) : (
<div className="col-span-full empty-state">
<Code className="empty-state-icon" />
<p className="empty-state-title"></p>
<p className="empty-state-description"></p>
</div>
)}
</div>
</div>
{/* Recent Tasks */}
<div className="cyber-card p-4">
<div className="section-header">
<Clock className="w-5 h-5 text-emerald-400" />
<h3 className="section-title"></h3>
<Link to="/audit-tasks" className="ml-auto">
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-foreground">
<ArrowUpRight className="w-3 h-3 ml-1" />
</Button>
</Link>
</div>
<div className="space-y-2">
{recentTasks.length > 0 ? (
recentTasks.slice(0, 6).map((task) => (
<Link
key={task.id}
to={`/tasks/${task.id}`}
className="flex items-center justify-between p-3 rounded-lg transition-all group"
style={{
background: 'var(--cyber-bg-elevated)',
}}
onMouseOver={(e) => {
e.currentTarget.style.background = 'var(--cyber-hover-bg)';
}}
onMouseOut={(e) => {
e.currentTarget.style.background = 'var(--cyber-bg-elevated)';
}}
>
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${
task.status === 'completed' ? 'bg-emerald-500/20 text-emerald-400' :
task.status === 'running' ? 'bg-sky-500/20 text-sky-400' :
'bg-rose-500/20 text-rose-400'
}`}>
{task.status === 'completed' ? <Activity className="w-4 h-4" /> :
task.status === 'running' ? <Clock className="w-4 h-4" /> :
<AlertTriangle className="w-4 h-4" />}
</div>
<div>
<p className="text-base font-medium text-foreground group-hover:text-primary transition-colors">
{task.project?.name || '未知项目'}
</p>
<p className="text-sm text-muted-foreground">
: <span className="text-foreground">{task.quality_score?.toFixed(1) || '0.0'}</span>
</p>
</div>
</div>
{getStatusBadge(task.status)}
</Link>
))
) : (
<div className="empty-state">
<Activity className="empty-state-icon" />
<p className="empty-state-title"></p>
</div>
)}
</div>
</div>
</div>
{/* Right Sidebar */}
<div className="xl:col-span-1 space-y-4">
{/* Quick Actions */}
<div className="cyber-card p-4">
<div className="section-header">
<Zap className="w-5 h-5 text-primary" />
<h3 className="section-title"></h3>
</div>
<div className="space-y-2">
<Link to="/agent-audit" className="block">
<Button className="w-full justify-start cyber-btn-primary h-10">
<Bot className="w-4 h-4 mr-2" />
Agent
</Button>
</Link>
<Link to="/instant-analysis" className="block">
<Button variant="outline" className="w-full justify-start cyber-btn-outline h-10">
<Zap className="w-4 h-4 mr-2" />
</Button>
</Link>
<Link to="/projects" className="block">
<Button variant="outline" className="w-full justify-start cyber-btn-outline h-10">
<GitBranch className="w-4 h-4 mr-2" />
</Button>
</Link>
<Link to="/audit-tasks" className="block">
<Button variant="outline" className="w-full justify-start cyber-btn-outline h-10">
<Shield className="w-4 h-4 mr-2" />
</Button>
</Link>
</div>
</div>
{/* System Status */}
<div className="cyber-card p-4">
<div className="section-header">
<Cpu className="w-5 h-5 text-emerald-400" />
<h3 className="section-title"></h3>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-base text-muted-foreground"></span>
<Badge className={`
${dbMode === 'api' ? 'cyber-badge-primary' :
dbMode === 'local' ? 'cyber-badge-info' :
dbMode === 'supabase' ? 'cyber-badge-success' :
'cyber-badge-muted'}
`}>
{dbMode === 'api' ? '后端' : dbMode === 'local' ? '本地' : dbMode === 'supabase' ? '云端' : '演示'}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-base text-muted-foreground"></span>
<span className="text-base font-bold text-foreground">{stats?.active_projects || 0}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-base text-muted-foreground"></span>
<span className="text-base font-bold text-sky-400">
{recentTasks.filter(t => t.status === 'running').length}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-base text-muted-foreground"></span>
<span className="text-base font-bold text-amber-400">
{stats ? stats.total_issues - stats.resolved_issues : 0}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-base text-muted-foreground flex items-center gap-1">
<Shield className="w-4 h-4" />
</span>
<span className="text-base font-bold text-violet-400">
{ruleStats.enabled}/{ruleStats.total}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-base text-muted-foreground flex items-center gap-1">
<MessageSquare className="w-4 h-4" />
</span>
<span className="text-base font-bold text-emerald-400">
{templateStats.active}/{templateStats.total}
</span>
</div>
</div>
</div>
{/* Recent Activity */}
<div className="cyber-card p-4">
<div className="section-header">
<Terminal className="w-5 h-5 text-amber-400" />
<h3 className="section-title"></h3>
</div>
<div className="space-y-2">
{recentTasks.length > 0 ? (
recentTasks.slice(0, 3).map((task) => {
const timeAgo = (() => {
const now = new Date();
const taskDate = new Date(task.created_at);
const diffMs = now.getTime() - taskDate.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 60) return `${diffMins}分钟前`;
if (diffHours < 24) return `${diffHours}小时前`;
return `${diffDays}天前`;
})();
const statusText =
task.status === 'completed' ? '任务完成' :
task.status === 'running' ? '任务运行中' :
task.status === 'failed' ? '任务失败' : '任务待处理';
return (
<Link
key={task.id}
to={`/tasks/${task.id}`}
className={`block p-3 rounded-lg border transition-all ${
task.status === 'completed' ? 'bg-emerald-500/5 border-emerald-500/20 hover:border-emerald-500/40' :
task.status === 'running' ? 'bg-sky-500/5 border-sky-500/20 hover:border-sky-500/40' :
task.status === 'failed' ? 'bg-rose-500/5 border-rose-500/20 hover:border-rose-500/40' :
'bg-muted/30 border-border hover:border-border'
}`}
>
<p className="text-base font-medium text-foreground">{statusText}</p>
<p className="text-sm text-muted-foreground mt-1 line-clamp-1">
"{task.project?.name || '未知项目'}"
{task.status === 'completed' && task.issues_count > 0 &&
` - 发现 ${task.issues_count} 个问题`
}
</p>
<p className="text-sm text-muted-foreground/70 mt-1">{timeAgo}</p>
</Link>
);
})
) : (
<div className="empty-state py-6">
<Clock className="w-10 h-10 text-muted-foreground mb-2" />
<p className="text-base text-muted-foreground"></p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}