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)
|
repo_url: Optional[str] = Field(default=None)
|
||||||
local_path: Optional[str] = Field(default=None)
|
local_path: Optional[str] = Field(default=None)
|
||||||
description: 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)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
updated_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
|
repo_url: Optional[str] = None
|
||||||
local_path: Optional[str] = None
|
local_path: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
repair_enabled: Optional[bool] = None
|
||||||
|
|
||||||
class ErrorLog(SQLModel, table=True):
|
class ErrorLog(SQLModel, table=True):
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
|||||||
@ -41,13 +41,18 @@ class RepairScheduler:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _fetch_projects_from_api() -> list[str]:
|
def _fetch_projects_from_api() -> list[str]:
|
||||||
"""从 Log Center API 动态获取所有已注册项目"""
|
"""从 Log Center API 动态获取所有已启用修复的项目"""
|
||||||
try:
|
try:
|
||||||
resp = httpx.get(f"{settings.log_center_url}/api/v1/projects", timeout=10)
|
resp = httpx.get(f"{settings.log_center_url}/api/v1/projects", timeout=10)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
projects = resp.json().get("projects", [])
|
projects = resp.json().get("projects", [])
|
||||||
project_ids = [p["project_id"] for p in projects]
|
# 只返回 repair_enabled=True 的项目
|
||||||
logger.info(f"从 API 获取到 {len(project_ids)} 个项目: {', '.join(project_ids)}")
|
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
|
return project_ids
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"从 API 获取项目列表失败: {e},回退到本地配置")
|
logger.warning(f"从 API 获取项目列表失败: {e},回退到本地配置")
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -100,6 +100,7 @@ export interface Project {
|
|||||||
repo_url: string | null;
|
repo_url: string | null;
|
||||||
local_path: string | null;
|
local_path: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
repair_enabled: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@ -163,6 +164,9 @@ export const updateProject = (projectId: string, data: Partial<Project>) =>
|
|||||||
export const deleteProject = (projectId: string) =>
|
export const deleteProject = (projectId: string) =>
|
||||||
api.delete(`/api/v1/projects/${projectId}`);
|
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) =>
|
export const updateTaskStatus = (taskId: number, status: string, message?: string) =>
|
||||||
api.put(`/api/v1/tasks/${taskId}/status`, { status, message });
|
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 ============ */
|
/* ============ Safe area for notch devices ============ */
|
||||||
|
|
||||||
@supports (padding: env(safe-area-inset-bottom)) {
|
@supports (padding: env(safe-area-inset-bottom)) {
|
||||||
@ -1527,3 +1933,70 @@ td a:hover {
|
|||||||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
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 { 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';
|
import { getStats, type DashboardStats } from '../api';
|
||||||
|
|
||||||
const SOURCE_LABELS: Record<string, string> = {
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
@ -20,6 +20,93 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
FIX_FAILED: '修复失败',
|
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() {
|
export default function Dashboard() {
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -50,99 +137,70 @@ export default function Dashboard() {
|
|||||||
return <div className="loading">加载统计数据失败</div>;
|
return <div className="loading">加载统计数据失败</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pendingCount = (stats.status_distribution['NEW'] || 0)
|
||||||
|
+ (stats.status_distribution['PENDING_FIX'] || 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1 className="page-title">仪表盘</h1>
|
<h1 className="page-title">仪表盘</h1>
|
||||||
<p className="page-subtitle">错误追踪系统概览</p>
|
<p className="page-subtitle">错误追踪与自动修复系统概览</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="stats-grid">
|
{/* Hero: stats + fix rate ring */}
|
||||||
<div className="stat-card">
|
<div className="dashboard-hero">
|
||||||
<div className="stat-icon accent">
|
<div className="stats-grid stats-grid-3">
|
||||||
<Bug size={18} />
|
<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>
|
||||||
<div className="stat-label">缺陷总数</div>
|
|
||||||
<div className="stat-value accent">{stats.total_bugs}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="dashboard-ring-card">
|
||||||
<div className="stat-icon warning">
|
<FixRateRing rate={stats.fix_rate} />
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
{/* Charts: status + source distribution */}
|
||||||
<div className="table-container table-compact">
|
<div className="dashboard-charts">
|
||||||
<div className="table-header">
|
<div className="chart-card">
|
||||||
<h3 className="table-title">状态分布</h3>
|
<div className="chart-card-header">
|
||||||
|
<h3 className="chart-card-title">状态分布</h3>
|
||||||
|
<span className="chart-card-total">共 {stats.total_bugs} 个</span>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<BarChart
|
||||||
<thead>
|
data={stats.status_distribution}
|
||||||
<tr>
|
labels={STATUS_LABELS}
|
||||||
<th>状态</th>
|
colors={STATUS_COLORS}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{stats.source_distribution && (
|
{stats.source_distribution && (
|
||||||
<div className="table-container table-compact">
|
<div className="chart-card">
|
||||||
<div className="table-header">
|
<div className="chart-card-header">
|
||||||
<h3 className="table-title">来源分布</h3>
|
<h3 className="chart-card-title">来源分布</h3>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<BarChart
|
||||||
<thead>
|
data={stats.source_distribution}
|
||||||
<tr>
|
labels={SOURCE_LABELS}
|
||||||
<th>来源</th>
|
colors={SOURCE_COLORS}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { getProjects, updateProject, deleteProject, type Project } from '../api';
|
import { getProjects, updateProject, deleteProject, toggleProjectRepair, type Project } from '../api';
|
||||||
import { Save, X, Pencil, Trash2 } from 'lucide-react';
|
import { Save, X, Pencil, Trash2, Wrench, ShieldCheck, ShieldOff } from 'lucide-react';
|
||||||
|
|
||||||
export default function ProjectList() {
|
export default function ProjectList() {
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
@ -8,6 +8,7 @@ export default function ProjectList() {
|
|||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [editForm, setEditForm] = useState({ name: '', repo_url: '', local_path: '', description: '' });
|
const [editForm, setEditForm] = useState({ name: '', repo_url: '', local_path: '', description: '' });
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Delete confirmation state
|
// Delete confirmation state
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
|
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) => {
|
const openDeleteConfirm = (p: Project) => {
|
||||||
setDeleteTarget(p);
|
setDeleteTarget(p);
|
||||||
setDeleteConfirmName('');
|
setDeleteConfirmName('');
|
||||||
@ -93,247 +110,147 @@ export default function ProjectList() {
|
|||||||
return <div className="loading"><div className="spinner"></div></div>;
|
return <div className="loading"><div className="spinner"></div></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const enabledCount = projects.filter(p => p.repair_enabled).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1 className="page-title">项目管理</h1>
|
<div className="title-row">
|
||||||
<p className="page-subtitle">管理项目的仓库地址和本地路径,供 Repair Agent 使用</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
{projects.length === 0 ? (
|
{projects.length === 0 ? (
|
||||||
<div className="empty-state">暂无项目,首次上报日志后会自动创建</div>
|
<div className="empty-state">暂无项目,首次上报日志后会自动创建</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="project-cards-grid">
|
||||||
{/* Desktop table */}
|
{projects.map((p) => (
|
||||||
<div className="table-container">
|
<div key={p.id} className={`project-card ${!p.repair_enabled ? 'project-card-disabled' : ''}`}>
|
||||||
<div className="table-desktop">
|
{editingId === p.id ? (
|
||||||
<table>
|
<div className="project-card-edit">
|
||||||
<thead>
|
<div className="project-card-edit-header">
|
||||||
<tr>
|
<strong>{p.project_id}</strong>
|
||||||
<th>项目 ID</th>
|
</div>
|
||||||
<th>名称</th>
|
<input
|
||||||
<th>仓库地址</th>
|
type="text"
|
||||||
<th>本地路径</th>
|
value={editForm.name}
|
||||||
<th>描述</th>
|
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||||
<th>更新时间</th>
|
placeholder="项目名称"
|
||||||
<th>操作</th>
|
className="edit-input"
|
||||||
</tr>
|
/>
|
||||||
</thead>
|
<input
|
||||||
<tbody>
|
type="text"
|
||||||
{projects.map((p) => (
|
value={editForm.repo_url}
|
||||||
<tr key={p.id}>
|
onChange={(e) => setEditForm({ ...editForm, repo_url: e.target.value })}
|
||||||
{editingId === p.id ? (
|
placeholder="仓库地址"
|
||||||
<>
|
className="edit-input"
|
||||||
<td><strong>{p.project_id}</strong></td>
|
/>
|
||||||
<td>
|
<input
|
||||||
<input
|
type="text"
|
||||||
type="text"
|
value={editForm.local_path}
|
||||||
value={editForm.name}
|
onChange={(e) => setEditForm({ ...editForm, local_path: e.target.value })}
|
||||||
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
placeholder="本地路径"
|
||||||
placeholder="项目名称"
|
className="edit-input"
|
||||||
className="edit-input"
|
/>
|
||||||
/>
|
<input
|
||||||
</td>
|
type="text"
|
||||||
<td>
|
value={editForm.description}
|
||||||
<input
|
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
|
||||||
type="text"
|
placeholder="描述"
|
||||||
value={editForm.repo_url}
|
className="edit-input"
|
||||||
onChange={(e) => setEditForm({ ...editForm, repo_url: e.target.value })}
|
/>
|
||||||
placeholder="https://gitea.example.com/..."
|
<div className="project-card-edit-actions">
|
||||||
className="edit-input"
|
<button
|
||||||
/>
|
className="btn btn-primary"
|
||||||
</td>
|
onClick={() => saveEdit(p.project_id)}
|
||||||
<td>
|
disabled={saving}
|
||||||
<input
|
>
|
||||||
type="text"
|
<Save size={14} />
|
||||||
value={editForm.local_path}
|
{saving ? '保存中...' : '保存'}
|
||||||
onChange={(e) => setEditForm({ ...editForm, local_path: e.target.value })}
|
</button>
|
||||||
placeholder="/home/user/projects/..."
|
<button className="btn btn-secondary" onClick={cancelEdit}>
|
||||||
className="edit-input"
|
<X size={14} />
|
||||||
/>
|
取消
|
||||||
</td>
|
</button>
|
||||||
<td>
|
</div>
|
||||||
<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>
|
</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>
|
))}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete confirmation modal */}
|
{/* Delete confirmation modal */}
|
||||||
{deleteTarget && (
|
{deleteTarget && (
|
||||||
<div
|
<div className="modal-overlay" onClick={closeDeleteConfirm}>
|
||||||
style={{
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
position: 'fixed',
|
<h3 className="modal-title">删除项目</h3>
|
||||||
top: 0,
|
<p className="modal-desc">
|
||||||
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)' }}>
|
|
||||||
此操作不可撤销。请输入项目 ID <strong style={{ color: 'var(--error)' }}>{deleteTarget.project_id}</strong> 以确认删除。
|
此操作不可撤销。请输入项目 ID <strong style={{ color: 'var(--error)' }}>{deleteTarget.project_id}</strong> 以确认删除。
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
@ -345,26 +262,15 @@ export default function ProjectList() {
|
|||||||
style={{ width: '100%', marginBottom: '16px', boxSizing: 'border-box' }}
|
style={{ width: '100%', marginBottom: '16px', boxSizing: 'border-box' }}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
<div className="modal-actions">
|
||||||
<button
|
<button className="btn btn-secondary" onClick={closeDeleteConfirm}>
|
||||||
className="btn-link"
|
|
||||||
onClick={closeDeleteConfirm}
|
|
||||||
style={{ color: 'var(--text-tertiary)', padding: '6px 12px' }}
|
|
||||||
>
|
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
className="btn btn-danger"
|
||||||
onClick={confirmDelete}
|
onClick={confirmDelete}
|
||||||
disabled={!isDeleteConfirmed || deleting}
|
disabled={!isDeleteConfirmed || deleting}
|
||||||
style={{
|
style={{ opacity: isDeleteConfirmed ? 1 : 0.4 }}
|
||||||
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',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{deleting ? '删除中...' : '确认删除'}
|
{deleting ? '删除中...' : '确认删除'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user