457 lines
15 KiB
TypeScript
457 lines
15 KiB
TypeScript
import React from 'react';
|
|
import { useStore } from '../../store/useStore';
|
|
import type { TestCaseNode } from '../../store/useStore';
|
|
|
|
import { BarChart3, PieChart, TrendingUp, CheckCircle2, XCircle, AlertCircle } from 'lucide-react';
|
|
|
|
const DashboardView: React.FC = () => {
|
|
const { spaceData, currentSpaceId, testTasks, setViewMode, setSelectedTaskId, currentUser } = useStore();
|
|
|
|
const testCases = spaceData[currentSpaceId] || [];
|
|
|
|
const handleOpenTask = (taskId: string) => {
|
|
setSelectedTaskId(taskId);
|
|
setViewMode('execution');
|
|
};
|
|
|
|
|
|
|
|
// Simple aggregation logic
|
|
const getStats = (nodes: TestCaseNode[]) => {
|
|
let stats = { total: 0, P0: 0, P1: 0, P2: 0, P3: 0, pass: 0, fail: 0, untested: 0 };
|
|
|
|
const traverse = (items: TestCaseNode[]) => {
|
|
items.forEach(node => {
|
|
stats.total++;
|
|
if (node.priority) stats[node.priority]++;
|
|
if (node.executionStatus === 'PASS') stats.pass++;
|
|
else if (node.executionStatus === 'FAIL') stats.fail++;
|
|
else stats.untested++;
|
|
|
|
|
|
if (node.children) traverse(node.children);
|
|
});
|
|
};
|
|
|
|
traverse(nodes);
|
|
return stats;
|
|
};
|
|
|
|
const stats = getStats(testCases);
|
|
const passRate = stats.total > 0 ? Math.round((stats.pass / stats.total) * 100) : 0;
|
|
|
|
return (
|
|
<div className="dashboard-container">
|
|
<div className="stats-grid">
|
|
<div className="stat-card">
|
|
<div className="stat-header">
|
|
<span className="stat-label">总用例数</span>
|
|
<BarChart3 size={20} className="icon-blue" />
|
|
</div>
|
|
<div className="stat-value">{stats.total}</div>
|
|
<div className="stat-footer">
|
|
<TrendingUp size={14} />
|
|
<span>较上周 +12%</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="stat-card">
|
|
<div className="stat-header">
|
|
<span className="stat-label">通过率</span>
|
|
<CheckCircle2 size={20} className="icon-green" />
|
|
</div>
|
|
<div className="stat-value">{passRate}%</div>
|
|
<div className="progress-bar-bg">
|
|
<div className="progress-bar-fill" style={{ width: `${passRate}%` }}></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="stat-card">
|
|
<div className="stat-header">
|
|
<span className="stat-label">P0 核心用例</span>
|
|
<AlertCircle size={20} className="icon-red" />
|
|
</div>
|
|
<div className="stat-value">{stats.P0}</div>
|
|
<div className="priority-distribution">
|
|
<div className="p-segment p0" style={{ flex: stats.P0 }}></div>
|
|
<div className="p-segment p1" style={{ flex: stats.P1 }}></div>
|
|
<div className="p-segment p2" style={{ flex: stats.P2 }}></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="main-charts">
|
|
<div className="chart-card glass">
|
|
<h3>执行分布 (Execution Distribution)</h3>
|
|
<div className="distribution-list">
|
|
<div className="dist-item">
|
|
<div className="dist-label">
|
|
<CheckCircle2 size={16} color="#00B42A" />
|
|
<span>已通过 (Pass)</span>
|
|
</div>
|
|
<div className="dist-bar-container">
|
|
<div className="dist-bar pass" style={{ width: `${stats.total > 0 ? (stats.pass/stats.total)*100 : 0}%` }}></div>
|
|
<span className="dist-value">{stats.pass}</span>
|
|
</div>
|
|
</div>
|
|
<div className="dist-item">
|
|
<div className="dist-label">
|
|
<XCircle size={16} color="#F53F3F" />
|
|
<span>未通过 (Fail)</span>
|
|
</div>
|
|
<div className="dist-bar-container">
|
|
<div className="dist-bar fail" style={{ width: `${stats.total > 0 ? (stats.fail/stats.total)*100 : 0}%` }}></div>
|
|
<span className="dist-value">{stats.fail}</span>
|
|
</div>
|
|
</div>
|
|
<div className="dist-item">
|
|
<div className="dist-label">
|
|
<PieChart size={16} color="#86909C" />
|
|
<span>未开始 (Untested)</span>
|
|
</div>
|
|
<div className="dist-bar-container">
|
|
<div className="dist-bar untested" style={{ width: `${stats.total > 0 ? (stats.untested/stats.total)*100 : 0}%` }}></div>
|
|
<span className="dist-value">{stats.untested}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="chart-card glass">
|
|
<h3>优先级分布 (Priority Analysis)</h3>
|
|
<div className="priority-chart">
|
|
{['P0', 'P1', 'P2', 'P3'].map(p => {
|
|
const count = stats[p as keyof typeof stats] as number;
|
|
const percentage = stats.total > 0 ? (count / stats.total) * 100 : 0;
|
|
return (
|
|
<div key={p} className="p-chart-item">
|
|
<div className="p-bar-wrapper">
|
|
<div className={`p-bar fill-${p}`} style={{ height: `${Math.max(percentage, 5)}%` }}>
|
|
<span className="p-count">{count}</span>
|
|
</div>
|
|
</div>
|
|
<span className="p-label">{p}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="chart-card glass donut-container">
|
|
<h3>通过率概览 (Pass Rate Donut)</h3>
|
|
<div className="donut-wrapper">
|
|
<div className="donut-chart" style={{ background: `conic-gradient(#00B42A 0% ${passRate}%, #F2F3F5 ${passRate}% 100%)` }}>
|
|
<div className="donut-center">
|
|
<span className="donut-pct">{passRate}%</span>
|
|
<span className="donut-label">通过</span>
|
|
</div>
|
|
</div>
|
|
<div className="donut-legend">
|
|
<div className="legend-item"><span className="dot pass"></span> 通过: {stats.pass}</div>
|
|
<div className="legend-item"><span className="dot fail"></span> 失败: {stats.fail}</div>
|
|
<div className="legend-item"><span className="dot untested"></span> 未执行: {stats.untested}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="chart-card glass full-width">
|
|
<h3>任务看板 (Task Board)</h3>
|
|
<div className="task-list">
|
|
{testTasks.length === 0 ? (
|
|
<div className="empty-state">暂无执行任务</div>
|
|
) : (
|
|
testTasks.map(task => (
|
|
<div
|
|
key={task.id}
|
|
className="task-item clickable"
|
|
onClick={() => handleOpenTask(task.id)}
|
|
>
|
|
<div className="task-info">
|
|
<span className="task-name">{task.name}</span>
|
|
<span className="task-meta">{((task.assignee && task.assignee.startsWith('ou_')) || !task.assignee) ? (currentUser?.name || '未知用户') : task.assignee} · {task.createdAt?.split('T')[0]}</span>
|
|
|
|
</div>
|
|
<div className={`task-status status-${task.status.toLowerCase()}`}>
|
|
{task.status === 'RUNNING' && '运行中'}
|
|
{task.status === 'PENDING' && '未运行'}
|
|
{task.status === 'COMPLETED' && '已完成'}
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<style>{`
|
|
.task-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
.task-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 12px;
|
|
background: #F8FAFC;
|
|
border-radius: 8px;
|
|
border: 1px solid #E2E8F0;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
}
|
|
.task-item:hover {
|
|
border-color: #3B82F6;
|
|
background: #F0F9FF;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
|
}
|
|
|
|
.task-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.task-name {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: #1E293B;
|
|
}
|
|
.task-meta {
|
|
font-size: 12px;
|
|
color: #64748B;
|
|
}
|
|
.task-status {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
}
|
|
.status-running { background: #E0F2FE; color: #0284C7; }
|
|
.status-pending { background: #F1F5F9; color: #64748B; }
|
|
.status-completed { background: #DCFCE7; color: #15803D; }
|
|
.empty-state {
|
|
text-align: center;
|
|
color: #94A3B8;
|
|
padding: 20px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.dashboard-container {
|
|
padding: 32px;
|
|
height: 100%;
|
|
overflow-y: auto;
|
|
background: #F8FAFC;
|
|
}
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 24px;
|
|
margin-bottom: 32px;
|
|
}
|
|
.stat-card {
|
|
background: white;
|
|
padding: 24px;
|
|
border-radius: 16px;
|
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
|
|
border: 1px solid #E2E8F0;
|
|
}
|
|
.stat-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 12px;
|
|
}
|
|
.stat-label {
|
|
color: #64748B;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
}
|
|
.stat-value {
|
|
font-size: 32px;
|
|
font-weight: 700;
|
|
color: #0F172A;
|
|
margin-bottom: 12px;
|
|
}
|
|
.stat-footer {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
color: #10B981;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
}
|
|
.icon-blue { color: #3B82F6; }
|
|
.icon-green { color: #10B981; }
|
|
.icon-red { color: #EF4444; }
|
|
|
|
.progress-bar-bg {
|
|
height: 8px;
|
|
background: #E2E8F0;
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
}
|
|
.progress-bar-fill {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, #10B981, #34D399);
|
|
}
|
|
|
|
.priority-distribution {
|
|
display: flex;
|
|
height: 8px;
|
|
gap: 2px;
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
}
|
|
.p-segment { height: 100%; }
|
|
.p-segment.p0 { background: #EF4444; }
|
|
.p-segment.p1 { background: #F59E0B; }
|
|
.p-segment.p2 { background: #3B82F6; }
|
|
|
|
.main-charts {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 24px;
|
|
}
|
|
.chart-card {
|
|
padding: 24px;
|
|
border-radius: 16px;
|
|
background: white;
|
|
border: 1px solid #E2E8F0;
|
|
}
|
|
.chart-card h3 {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
margin-bottom: 24px;
|
|
color: #1E293B;
|
|
}
|
|
|
|
.distribution-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
}
|
|
.dist-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
.dist-label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 13px;
|
|
color: #475569;
|
|
}
|
|
.dist-bar-container {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
.dist-bar {
|
|
height: 12px;
|
|
border-radius: 6px;
|
|
min-width: 4px;
|
|
}
|
|
.dist-bar.pass { background: #10B981; }
|
|
.dist-bar.fail { background: #EF4444; }
|
|
.dist-bar.untested { background: #CBD5E1; }
|
|
.dist-value {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: #334155;
|
|
min-width: 24px;
|
|
}
|
|
|
|
.priority-chart {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
justify-content: space-around;
|
|
height: 200px;
|
|
padding-top: 20px;
|
|
}
|
|
.p-chart-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 12px;
|
|
flex: 1;
|
|
}
|
|
.p-bar-wrapper {
|
|
width: 32px;
|
|
height: 100%;
|
|
background: #F1F5F9;
|
|
border-radius: 16px;
|
|
position: relative;
|
|
display: flex;
|
|
align-items: flex-end;
|
|
}
|
|
.p-bar {
|
|
width: 100%;
|
|
border-radius: 16px;
|
|
transition: height 1s ease-out;
|
|
display: flex;
|
|
justify-content: center;
|
|
padding-top: 8px;
|
|
}
|
|
.p-bar.fill-P0 { background: #F53F3F; }
|
|
.p-bar.fill-P1 { background: #FF7D00; }
|
|
.p-bar.fill-P2 { background: #F7BA1E; }
|
|
.p-bar.fill-P3 { background: #165DFF; }
|
|
.p-count { color: white; font-size: 10px; font-weight: 700; }
|
|
.p-label { font-size: 12px; color: #64748B; font-weight: 600; }
|
|
|
|
.donut-container { display: flex; flex-direction: column; align-items: center; }
|
|
.donut-wrapper { display: flex; align-items: center; gap: 32px; width: 100%; justify-content: center; }
|
|
.donut-chart {
|
|
width: 140px;
|
|
height: 140px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
position: relative;
|
|
}
|
|
.donut-center {
|
|
width: 100px;
|
|
height: 100px;
|
|
background: white;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-shadow: inset 0 2px 4px rgba(0,0,0,0.05);
|
|
}
|
|
.donut-pct { font-size: 24px; font-weight: 700; color: #1D2129; }
|
|
.donut-label { font-size: 12px; color: #86909C; }
|
|
.donut-legend { display: flex; flex-direction: column; gap: 8px; }
|
|
.legend-item { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #4E5969; }
|
|
.dot { width: 8px; height: 8px; border-radius: 50%; }
|
|
.dot.pass { background: #00B42A; }
|
|
.dot.fail { background: #F53F3F; }
|
|
.dot.untested { background: #F2F3F5; }
|
|
|
|
.full-width { grid-column: span 2; }
|
|
|
|
.insights-list {
|
|
list-style: none;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
.insights-list li {
|
|
padding: 16px;
|
|
background: #F1F5F9;
|
|
border-radius: 12px;
|
|
font-size: 14px;
|
|
color: #475569;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
`}</style>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default DashboardView;
|