All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 2m16s
- 新增 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>
259 lines
15 KiB
TypeScript
259 lines
15 KiB
TypeScript
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>
|
||
);
|
||
}
|