add UI
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 2m13s
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 2m13s
This commit is contained in:
parent
fc6dc52f72
commit
cc80acf2b9
@ -27,6 +27,7 @@ class Project(SQLModel, table=True):
|
||||
repo_url: Optional[str] = Field(default=None)
|
||||
local_path: Optional[str] = Field(default=None)
|
||||
description: Optional[str] = Field(default=None)
|
||||
repair_enabled: bool = Field(default=True)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
@ -36,6 +37,7 @@ class ProjectUpdate(SQLModel):
|
||||
repo_url: Optional[str] = None
|
||||
local_path: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
repair_enabled: Optional[bool] = None
|
||||
|
||||
class ErrorLog(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
|
||||
@ -41,13 +41,18 @@ class RepairScheduler:
|
||||
|
||||
@staticmethod
|
||||
def _fetch_projects_from_api() -> list[str]:
|
||||
"""从 Log Center API 动态获取所有已注册项目"""
|
||||
"""从 Log Center API 动态获取所有已启用修复的项目"""
|
||||
try:
|
||||
resp = httpx.get(f"{settings.log_center_url}/api/v1/projects", timeout=10)
|
||||
resp.raise_for_status()
|
||||
projects = resp.json().get("projects", [])
|
||||
project_ids = [p["project_id"] for p in projects]
|
||||
logger.info(f"从 API 获取到 {len(project_ids)} 个项目: {', '.join(project_ids)}")
|
||||
# 只返回 repair_enabled=True 的项目
|
||||
enabled = [p for p in projects if p.get("repair_enabled", True)]
|
||||
disabled = [p["project_id"] for p in projects if not p.get("repair_enabled", True)]
|
||||
project_ids = [p["project_id"] for p in enabled]
|
||||
logger.info(f"从 API 获取到 {len(project_ids)} 个已启用项目: {', '.join(project_ids)}")
|
||||
if disabled:
|
||||
logger.info(f" 已禁用修复的项目: {', '.join(disabled)}")
|
||||
return project_ids
|
||||
except Exception as e:
|
||||
logger.warning(f"从 API 获取项目列表失败: {e},回退到本地配置")
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<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>web</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=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<title>日志中台 - Log Center</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -100,6 +100,7 @@ export interface Project {
|
||||
repo_url: string | null;
|
||||
local_path: string | null;
|
||||
description: string | null;
|
||||
repair_enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@ -163,6 +164,9 @@ export const updateProject = (projectId: string, data: Partial<Project>) =>
|
||||
export const deleteProject = (projectId: string) =>
|
||||
api.delete(`/api/v1/projects/${projectId}`);
|
||||
|
||||
export const toggleProjectRepair = (projectId: string, enabled: boolean) =>
|
||||
api.put<Project>(`/api/v1/projects/${projectId}`, { repair_enabled: enabled });
|
||||
|
||||
export const updateTaskStatus = (taskId: number, status: string, message?: string) =>
|
||||
api.put(`/api/v1/tasks/${taskId}/status`, { status, message });
|
||||
|
||||
|
||||
@ -1516,6 +1516,412 @@ td a:hover {
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ Dashboard Hero ============ */
|
||||
|
||||
.dashboard-hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 200px;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.stats-grid-3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.dashboard-ring-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.fix-rate-ring {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fix-rate-svg {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.fix-rate-text {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fix-rate-number {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.fix-rate-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ============ Dashboard Charts ============ */
|
||||
|
||||
.dashboard-charts {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.chart-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chart-card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chart-card-total {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ============ Bar Chart ============ */
|
||||
|
||||
.bar-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bar-chart-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bar-chart-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
min-width: 70px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bar-chart-track {
|
||||
flex: 1;
|
||||
height: 20px;
|
||||
background: var(--bg-surface);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-chart-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.5s ease;
|
||||
min-width: 4px;
|
||||
}
|
||||
|
||||
.bar-chart-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ============ Project Cards Grid ============ */
|
||||
|
||||
.project-cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
border-color: #3f3f46;
|
||||
}
|
||||
|
||||
.project-card-disabled {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.project-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.project-card-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.project-card-title strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.project-card-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.project-card-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.project-card-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn-icon-danger:hover {
|
||||
color: var(--error) !important;
|
||||
background: var(--error-muted) !important;
|
||||
}
|
||||
|
||||
.project-card-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.project-info-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.project-info-label {
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 500;
|
||||
min-width: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.project-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.project-card-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.project-card-edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.project-card-edit-header {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.project-card-edit-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ============ Repair Toggle ============ */
|
||||
|
||||
.repair-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.repair-toggle:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.repair-toggle:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.repair-toggle-track {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
transition: background 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.repair-toggle-on .repair-toggle-track {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.repair-toggle-off .repair-toggle-track {
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.repair-toggle-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
transition: left 0.2s ease;
|
||||
}
|
||||
|
||||
.repair-toggle-on .repair-toggle-thumb {
|
||||
left: 18px;
|
||||
}
|
||||
|
||||
.repair-toggle-off .repair-toggle-thumb {
|
||||
left: 2px;
|
||||
}
|
||||
|
||||
.repair-toggle-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.repair-toggle-on .repair-toggle-label {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.repair-toggle-off .repair-toggle-label {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ============ Repair Summary Badge ============ */
|
||||
|
||||
.repair-summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--success-muted);
|
||||
color: var(--success);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ============ Modal ============ */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: modal-fade-in 0.15s ease;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
max-width: 440px;
|
||||
width: 90%;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
animation: modal-slide-up 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-desc {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@keyframes modal-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes modal-slide-up {
|
||||
from { transform: translateY(8px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ============ Safe area for notch devices ============ */
|
||||
|
||||
@supports (padding: env(safe-area-inset-bottom)) {
|
||||
@ -1527,3 +1933,70 @@ td a:hover {
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
/* ============ Responsive: Dashboard & Projects ============ */
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.dashboard-hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-grid-3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.dashboard-ring-card {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-charts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.project-cards-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid-3 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.bar-chart-label {
|
||||
min-width: 56px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.project-cards-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.project-card-footer {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.repair-summary {
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stats-grid-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Bug, CalendarPlus, TrendingUp, AlertTriangle } from 'lucide-react';
|
||||
import { Bug, CalendarPlus, AlertTriangle } from 'lucide-react';
|
||||
import { getStats, type DashboardStats } from '../api';
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
@ -20,6 +20,93 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
FIX_FAILED: '修复失败',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
NEW: 'var(--accent)',
|
||||
VERIFYING: 'var(--indigo)',
|
||||
CANNOT_REPRODUCE: 'var(--text-tertiary)',
|
||||
PENDING_FIX: 'var(--warning)',
|
||||
FIXING: 'var(--indigo)',
|
||||
FIXED: 'var(--success)',
|
||||
VERIFIED: '#16a34a',
|
||||
DEPLOYED: '#15803d',
|
||||
FIX_FAILED: 'var(--error)',
|
||||
};
|
||||
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
runtime: 'var(--text-secondary)',
|
||||
cicd: '#60a5fa',
|
||||
deployment: '#f472b6',
|
||||
};
|
||||
|
||||
/** Horizontal bar chart for distribution data */
|
||||
function BarChart({ data, labels, colors }: {
|
||||
data: Record<string, number>;
|
||||
labels: Record<string, string>;
|
||||
colors: Record<string, string>;
|
||||
}) {
|
||||
const entries = Object.entries(data).filter(([, v]) => v > 0);
|
||||
if (entries.length === 0) {
|
||||
return <div className="empty-state" style={{ height: 120 }}>暂无数据</div>;
|
||||
}
|
||||
const maxVal = Math.max(...entries.map(([, v]) => v));
|
||||
|
||||
return (
|
||||
<div className="bar-chart">
|
||||
{entries.map(([key, value]) => (
|
||||
<div key={key} className="bar-chart-row">
|
||||
<span className="bar-chart-label">{labels[key] || key}</span>
|
||||
<div className="bar-chart-track">
|
||||
<div
|
||||
className="bar-chart-fill"
|
||||
style={{
|
||||
width: `${Math.max((value / maxVal) * 100, 4)}%`,
|
||||
background: colors[key] || 'var(--accent)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="bar-chart-value">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Donut ring chart for fix rate */
|
||||
function FixRateRing({ rate }: { rate: number }) {
|
||||
const r = 54;
|
||||
const circumference = 2 * Math.PI * r;
|
||||
const offset = circumference - (rate / 100) * circumference;
|
||||
const color = rate >= 70 ? 'var(--success)' : rate >= 40 ? 'var(--warning)' : 'var(--error)';
|
||||
|
||||
return (
|
||||
<div className="fix-rate-ring">
|
||||
<svg viewBox="0 0 120 120" className="fix-rate-svg">
|
||||
<circle
|
||||
cx="60" cy="60" r={r}
|
||||
fill="none"
|
||||
stroke="var(--border)"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<circle
|
||||
cx="60" cy="60" r={r}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="8"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
transform="rotate(-90 60 60)"
|
||||
style={{ transition: 'stroke-dashoffset 0.6s ease' }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="fix-rate-text">
|
||||
<span className="fix-rate-number" style={{ color }}>{rate}%</span>
|
||||
<span className="fix-rate-label">修复率</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -50,99 +137,70 @@ export default function Dashboard() {
|
||||
return <div className="loading">加载统计数据失败</div>;
|
||||
}
|
||||
|
||||
const pendingCount = (stats.status_distribution['NEW'] || 0)
|
||||
+ (stats.status_distribution['PENDING_FIX'] || 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">仪表盘</h1>
|
||||
<p className="page-subtitle">错误追踪系统概览</p>
|
||||
<p className="page-subtitle">错误追踪与自动修复系统概览</p>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon accent">
|
||||
<Bug size={18} />
|
||||
{/* Hero: stats + fix rate ring */}
|
||||
<div className="dashboard-hero">
|
||||
<div className="stats-grid stats-grid-3">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon accent">
|
||||
<Bug size={18} />
|
||||
</div>
|
||||
<div className="stat-label">缺陷总数</div>
|
||||
<div className="stat-value accent">{stats.total_bugs}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon warning">
|
||||
<CalendarPlus size={18} />
|
||||
</div>
|
||||
<div className="stat-label">今日新增</div>
|
||||
<div className="stat-value warning">{stats.today_bugs}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon error">
|
||||
<AlertTriangle size={18} />
|
||||
</div>
|
||||
<div className="stat-label">待修复</div>
|
||||
<div className="stat-value error">{pendingCount}</div>
|
||||
</div>
|
||||
<div className="stat-label">缺陷总数</div>
|
||||
<div className="stat-value accent">{stats.total_bugs}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon warning">
|
||||
<CalendarPlus size={18} />
|
||||
</div>
|
||||
<div className="stat-label">今日新增</div>
|
||||
<div className="stat-value warning">{stats.today_bugs}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon success">
|
||||
<TrendingUp size={18} />
|
||||
</div>
|
||||
<div className="stat-label">修复率</div>
|
||||
<div className="stat-value success">{stats.fix_rate}%</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon error">
|
||||
<AlertTriangle size={18} />
|
||||
</div>
|
||||
<div className="stat-label">待修复</div>
|
||||
<div className="stat-value error">
|
||||
{(stats.status_distribution['NEW'] || 0) +
|
||||
(stats.status_distribution['PENDING_FIX'] || 0)}
|
||||
</div>
|
||||
<div className="dashboard-ring-card">
|
||||
<FixRateRing rate={stats.fix_rate} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div className="table-container table-compact">
|
||||
<div className="table-header">
|
||||
<h3 className="table-title">状态分布</h3>
|
||||
{/* Charts: status + source distribution */}
|
||||
<div className="dashboard-charts">
|
||||
<div className="chart-card">
|
||||
<div className="chart-card-header">
|
||||
<h3 className="chart-card-title">状态分布</h3>
|
||||
<span className="chart-card-total">共 {stats.total_bugs} 个</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>状态</th>
|
||||
<th>数量</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(stats.status_distribution).map(([status, count]) => (
|
||||
<tr key={status}>
|
||||
<td>
|
||||
<span className={`status-badge status-${status}`}>
|
||||
{STATUS_LABELS[status] || status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<BarChart
|
||||
data={stats.status_distribution}
|
||||
labels={STATUS_LABELS}
|
||||
colors={STATUS_COLORS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{stats.source_distribution && (
|
||||
<div className="table-container table-compact">
|
||||
<div className="table-header">
|
||||
<h3 className="table-title">来源分布</h3>
|
||||
<div className="chart-card">
|
||||
<div className="chart-card-header">
|
||||
<h3 className="chart-card-title">来源分布</h3>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>来源</th>
|
||||
<th>数量</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(stats.source_distribution).map(([source, count]) => (
|
||||
<tr key={source}>
|
||||
<td>
|
||||
<span className={`source-badge source-${source}`}>
|
||||
{SOURCE_LABELS[source] || source}
|
||||
</span>
|
||||
</td>
|
||||
<td>{count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<BarChart
|
||||
data={stats.source_distribution}
|
||||
labels={SOURCE_LABELS}
|
||||
colors={SOURCE_COLORS}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getProjects, updateProject, deleteProject, type Project } from '../api';
|
||||
import { Save, X, Pencil, Trash2 } from 'lucide-react';
|
||||
import { getProjects, updateProject, deleteProject, toggleProjectRepair, type Project } from '../api';
|
||||
import { Save, X, Pencil, Trash2, Wrench, ShieldCheck, ShieldOff } from 'lucide-react';
|
||||
|
||||
export default function ProjectList() {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
@ -8,6 +8,7 @@ export default function ProjectList() {
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editForm, setEditForm] = useState({ name: '', repo_url: '', local_path: '', description: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||||
|
||||
// Delete confirmation state
|
||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
|
||||
@ -55,6 +56,22 @@ export default function ProjectList() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleRepair = async (p: Project) => {
|
||||
setTogglingId(p.project_id);
|
||||
try {
|
||||
await toggleProjectRepair(p.project_id, !p.repair_enabled);
|
||||
setProjects(prev => prev.map(proj =>
|
||||
proj.project_id === p.project_id
|
||||
? { ...proj, repair_enabled: !proj.repair_enabled }
|
||||
: proj
|
||||
));
|
||||
} catch (err) {
|
||||
console.error('切换修复状态失败:', err);
|
||||
} finally {
|
||||
setTogglingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openDeleteConfirm = (p: Project) => {
|
||||
setDeleteTarget(p);
|
||||
setDeleteConfirmName('');
|
||||
@ -93,247 +110,147 @@ export default function ProjectList() {
|
||||
return <div className="loading"><div className="spinner"></div></div>;
|
||||
}
|
||||
|
||||
const enabledCount = projects.filter(p => p.repair_enabled).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">项目管理</h1>
|
||||
<p className="page-subtitle">管理项目的仓库地址和本地路径,供 Repair Agent 使用</p>
|
||||
<div className="title-row">
|
||||
<div>
|
||||
<h1 className="page-title">项目管理</h1>
|
||||
<p className="page-subtitle">管理项目配置,控制 Repair Agent 的自动修复范围</p>
|
||||
</div>
|
||||
<div className="repair-summary">
|
||||
<Wrench size={14} />
|
||||
<span>{enabledCount}/{projects.length} 个项目已启用自动修复</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<div className="empty-state">暂无项目,首次上报日志后会自动创建</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<div className="table-container">
|
||||
<div className="table-desktop">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>项目 ID</th>
|
||||
<th>名称</th>
|
||||
<th>仓库地址</th>
|
||||
<th>本地路径</th>
|
||||
<th>描述</th>
|
||||
<th>更新时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projects.map((p) => (
|
||||
<tr key={p.id}>
|
||||
{editingId === p.id ? (
|
||||
<>
|
||||
<td><strong>{p.project_id}</strong></td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||
placeholder="项目名称"
|
||||
className="edit-input"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.repo_url}
|
||||
onChange={(e) => setEditForm({ ...editForm, repo_url: e.target.value })}
|
||||
placeholder="https://gitea.example.com/..."
|
||||
className="edit-input"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.local_path}
|
||||
onChange={(e) => setEditForm({ ...editForm, local_path: e.target.value })}
|
||||
placeholder="/home/user/projects/..."
|
||||
className="edit-input"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.description}
|
||||
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
|
||||
placeholder="项目描述"
|
||||
className="edit-input"
|
||||
/>
|
||||
</td>
|
||||
<td className="cell-secondary">
|
||||
{new Date(p.updated_at).toLocaleString()}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<button
|
||||
className="btn-link"
|
||||
onClick={() => saveEdit(p.project_id)}
|
||||
disabled={saving}
|
||||
style={{ color: 'var(--success)' }}
|
||||
>
|
||||
<Save size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn-link"
|
||||
onClick={cancelEdit}
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td><strong>{p.project_id}</strong></td>
|
||||
<td>{p.name || <span style={{ color: 'var(--text-tertiary)' }}>-</span>}</td>
|
||||
<td><ConfigBadge value={p.repo_url} /></td>
|
||||
<td><ConfigBadge value={p.local_path} /></td>
|
||||
<td className="cell-secondary">{p.description || '-'}</td>
|
||||
<td className="cell-secondary">
|
||||
{new Date(p.updated_at).toLocaleString()}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<button className="btn-link" onClick={() => startEdit(p)}>
|
||||
<Pencil size={14} /> 编辑
|
||||
</button>
|
||||
<button
|
||||
className="btn-link"
|
||||
onClick={() => openDeleteConfirm(p)}
|
||||
style={{ color: 'var(--error)' }}
|
||||
>
|
||||
<Trash2 size={14} /> 删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile card list */}
|
||||
<div className="mobile-card-list">
|
||||
{projects.map((p) => (
|
||||
<div key={p.id} className="mobile-card-item">
|
||||
{editingId === p.id ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<strong>{p.project_id}</strong>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||
placeholder="项目名称"
|
||||
className="edit-input"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.repo_url}
|
||||
onChange={(e) => setEditForm({ ...editForm, repo_url: e.target.value })}
|
||||
placeholder="仓库地址"
|
||||
className="edit-input"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.local_path}
|
||||
onChange={(e) => setEditForm({ ...editForm, local_path: e.target.value })}
|
||||
placeholder="本地路径"
|
||||
className="edit-input"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.description}
|
||||
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
|
||||
placeholder="描述"
|
||||
className="edit-input"
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
className="btn-link"
|
||||
onClick={() => saveEdit(p.project_id)}
|
||||
disabled={saving}
|
||||
style={{ color: 'var(--success)' }}
|
||||
>
|
||||
<Save size={14} /> 保存
|
||||
</button>
|
||||
<button
|
||||
className="btn-link"
|
||||
onClick={cancelEdit}
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
<X size={14} /> 取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mobile-card-top">
|
||||
<strong>{p.project_id}</strong>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button className="btn-link" onClick={() => startEdit(p)}>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn-link"
|
||||
onClick={() => openDeleteConfirm(p)}
|
||||
style={{ color: 'var(--error)' }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{p.name && <div style={{ fontSize: '13px' }}>{p.name}</div>}
|
||||
<div className="mobile-card-meta" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: '4px', marginTop: '6px' }}>
|
||||
<div>仓库: <ConfigBadge value={p.repo_url} /></div>
|
||||
<div>路径: <ConfigBadge value={p.local_path} /></div>
|
||||
</div>
|
||||
{p.description && (
|
||||
<div style={{ fontSize: '12px', color: 'var(--text-tertiary)', marginTop: '4px' }}>{p.description}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="project-cards-grid">
|
||||
{projects.map((p) => (
|
||||
<div key={p.id} className={`project-card ${!p.repair_enabled ? 'project-card-disabled' : ''}`}>
|
||||
{editingId === p.id ? (
|
||||
<div className="project-card-edit">
|
||||
<div className="project-card-edit-header">
|
||||
<strong>{p.project_id}</strong>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||
placeholder="项目名称"
|
||||
className="edit-input"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.repo_url}
|
||||
onChange={(e) => setEditForm({ ...editForm, repo_url: e.target.value })}
|
||||
placeholder="仓库地址"
|
||||
className="edit-input"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.local_path}
|
||||
onChange={(e) => setEditForm({ ...editForm, local_path: e.target.value })}
|
||||
placeholder="本地路径"
|
||||
className="edit-input"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.description}
|
||||
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
|
||||
placeholder="描述"
|
||||
className="edit-input"
|
||||
/>
|
||||
<div className="project-card-edit-actions">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => saveEdit(p.project_id)}
|
||||
disabled={saving}
|
||||
>
|
||||
<Save size={14} />
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={cancelEdit}>
|
||||
<X size={14} />
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
<>
|
||||
<div className="project-card-header">
|
||||
<div className="project-card-title">
|
||||
<strong>{p.project_id}</strong>
|
||||
{p.name && <span className="project-card-name">{p.name}</span>}
|
||||
</div>
|
||||
<div className="project-card-actions">
|
||||
<button className="btn-ghost btn-icon" onClick={() => startEdit(p)} title="编辑">
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn-ghost btn-icon btn-icon-danger"
|
||||
onClick={() => openDeleteConfirm(p)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{p.description && (
|
||||
<p className="project-card-desc">{p.description}</p>
|
||||
)}
|
||||
|
||||
<div className="project-card-info">
|
||||
<div className="project-info-item">
|
||||
<span className="project-info-label">仓库</span>
|
||||
<ConfigBadge value={p.repo_url} />
|
||||
</div>
|
||||
<div className="project-info-item">
|
||||
<span className="project-info-label">路径</span>
|
||||
<ConfigBadge value={p.local_path} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="project-card-footer">
|
||||
<button
|
||||
className={`repair-toggle ${p.repair_enabled ? 'repair-toggle-on' : 'repair-toggle-off'}`}
|
||||
onClick={() => handleToggleRepair(p)}
|
||||
disabled={togglingId === p.project_id}
|
||||
>
|
||||
<div className="repair-toggle-track">
|
||||
<div className="repair-toggle-thumb" />
|
||||
</div>
|
||||
<span className="repair-toggle-label">
|
||||
{p.repair_enabled ? (
|
||||
<><ShieldCheck size={13} /> 自动修复已启用</>
|
||||
) : (
|
||||
<><ShieldOff size={13} /> 自动修复已停用</>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<span className="project-card-time">
|
||||
更新于 {new Date(p.updated_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
{deleteTarget && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={closeDeleteConfirm}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-secondary)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '24px',
|
||||
maxWidth: '440px',
|
||||
width: '90%',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px' }}>删除项目</h3>
|
||||
<p style={{ margin: '0 0 16px 0', fontSize: '13px', color: 'var(--text-secondary)' }}>
|
||||
<div className="modal-overlay" onClick={closeDeleteConfirm}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="modal-title">删除项目</h3>
|
||||
<p className="modal-desc">
|
||||
此操作不可撤销。请输入项目 ID <strong style={{ color: 'var(--error)' }}>{deleteTarget.project_id}</strong> 以确认删除。
|
||||
</p>
|
||||
<input
|
||||
@ -345,26 +262,15 @@ export default function ProjectList() {
|
||||
style={{ width: '100%', marginBottom: '16px', boxSizing: 'border-box' }}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
<button
|
||||
className="btn-link"
|
||||
onClick={closeDeleteConfirm}
|
||||
style={{ color: 'var(--text-tertiary)', padding: '6px 12px' }}
|
||||
>
|
||||
<div className="modal-actions">
|
||||
<button className="btn btn-secondary" onClick={closeDeleteConfirm}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={confirmDelete}
|
||||
disabled={!isDeleteConfirmed || deleting}
|
||||
style={{
|
||||
background: isDeleteConfirmed ? 'var(--error)' : 'var(--bg-tertiary)',
|
||||
color: isDeleteConfirmed ? '#fff' : 'var(--text-tertiary)',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm, 4px)',
|
||||
padding: '6px 16px',
|
||||
fontSize: '13px',
|
||||
cursor: isDeleteConfirmed ? 'pointer' : 'not-allowed',
|
||||
}}
|
||||
style={{ opacity: isDeleteConfirmed ? 1 : 0.4 }}
|
||||
>
|
||||
{deleting ? '删除中...' : '确认删除'}
|
||||
</button>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user