add UI
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 2m13s

This commit is contained in:
repair-agent 2026-03-17 17:50:58 +08:00
parent fc6dc52f72
commit cc80acf2b9
7 changed files with 778 additions and 327 deletions

View File

@ -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)

View File

@ -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},回退到本地配置")

View File

@ -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>

View File

@ -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 });

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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>