log-center/web/src/pages/ProjectList.tsx
zyc 625e53dc44
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 2m16s
feat(project-mgmt): 项目管理 + 失败原因追踪 + 前端展示
- 新增 Project 模型(repo_url, local_path, name, description)
- 项目 CRUD API(GET/PUT /api/v1/projects)
- 日志上报自动 upsert Project 记录
- ErrorLog 增加 failure_reason 字段
- update_task_status / create_repair_report 写入失败原因
- Repair Agent 优先从 API 获取项目配置,回退 .env
- 新增 Web 端「项目管理」页面(表格 + 行内编辑)
- BugList/BugDetail/RepairList 展示失败原因
- 更新接入指南文档

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:18:27 +08:00

259 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react';
import { getProjects, updateProject, type Project } from '../api';
import { Save, X, Pencil } from 'lucide-react';
export default function ProjectList() {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<number | null>(null);
const [editForm, setEditForm] = useState({ name: '', repo_url: '', local_path: '', description: '' });
const [saving, setSaving] = useState(false);
const fetchProjects = async () => {
setLoading(true);
try {
const res = await getProjects();
setProjects(res.data.projects);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchProjects(); }, []);
const startEdit = (p: Project) => {
setEditingId(p.id);
setEditForm({
name: p.name || '',
repo_url: p.repo_url || '',
local_path: p.local_path || '',
description: p.description || '',
});
};
const cancelEdit = () => {
setEditingId(null);
};
const saveEdit = async (projectId: string) => {
setSaving(true);
try {
await updateProject(projectId, editForm);
setEditingId(null);
await fetchProjects();
} catch (err) {
console.error('保存失败:', err);
} finally {
setSaving(false);
}
};
const ConfigBadge = ({ value }: { value: string | null }) => (
value ? (
<span style={{ fontSize: '12px', color: 'var(--text-secondary)', wordBreak: 'break-all' }}>{value}</span>
) : (
<span style={{ fontSize: '12px', color: 'var(--error)', fontWeight: 500 }}></span>
)
);
if (loading) {
return <div className="loading"><div className="spinner"></div></div>;
}
return (
<div>
<div className="page-header">
<h1 className="page-title"></h1>
<p className="page-subtitle"> Repair Agent 使</p>
</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>
<button className="btn-link" onClick={() => startEdit(p)}>
<Pencil size={14} />
</button>
</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>
<button className="btn-link" onClick={() => startEdit(p)}>
<Pencil size={14} />
</button>
</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>
</>
)}
</div>
);
}