Compare commits
9 Commits
fix/auto-2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc6dc52f72 | ||
|
|
01f87e38aa | ||
|
|
25c9b2d18e | ||
| 708021c443 | |||
|
|
682a70b9af | ||
| 7aa6781c6d | |||
|
|
c94c7bd420 | ||
|
|
f8f8c1174f | ||
| 18d18dbe14 |
@ -16,7 +16,13 @@ DB_NAME = os.getenv("DB_NAME")
|
|||||||
|
|
||||||
DATABASE_URL = f"postgresql+asyncpg://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
DATABASE_URL = f"postgresql+asyncpg://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
||||||
|
|
||||||
engine = create_async_engine(DATABASE_URL, echo=True, future=True)
|
engine = create_async_engine(
|
||||||
|
DATABASE_URL,
|
||||||
|
echo=True,
|
||||||
|
future=True,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_recycle=300,
|
||||||
|
)
|
||||||
|
|
||||||
async def init_db():
|
async def init_db():
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Gitea API 客户端
|
Gitea API 客户端
|
||||||
"""
|
"""
|
||||||
|
import re
|
||||||
import httpx
|
import httpx
|
||||||
import os
|
import os
|
||||||
from typing import Tuple
|
from typing import Tuple, Optional
|
||||||
|
|
||||||
|
|
||||||
class GiteaClient:
|
class GiteaClient:
|
||||||
@ -12,7 +13,7 @@ class GiteaClient:
|
|||||||
def __init__(self, gitea_url: str = None, token: str = None):
|
def __init__(self, gitea_url: str = None, token: str = None):
|
||||||
self.gitea_url = (gitea_url or os.getenv("GITEA_URL", "")).rstrip("/")
|
self.gitea_url = (gitea_url or os.getenv("GITEA_URL", "")).rstrip("/")
|
||||||
self.token = token or os.getenv("GITEA_TOKEN", "")
|
self.token = token or os.getenv("GITEA_TOKEN", "")
|
||||||
self.base_api_url = f"{self.gitea_url}/api/v1"
|
self.base_api_url = f"{self.gitea_url}/api/v1" if self.gitea_url else ""
|
||||||
self.client = httpx.Client(timeout=30)
|
self.client = httpx.Client(timeout=30)
|
||||||
|
|
||||||
def _headers(self) -> dict:
|
def _headers(self) -> dict:
|
||||||
@ -22,21 +23,31 @@ class GiteaClient:
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
def _extract_owner_repo_from_pr_url(self, pr_url: str) -> Tuple[str, str, int]:
|
def _parse_pr_url(self, pr_url: str) -> Tuple[str, str, str, int]:
|
||||||
"""
|
"""
|
||||||
从 PR URL 提取 owner, repo, pr_number
|
从 PR URL 提取 base_url, owner, repo, pr_number
|
||||||
例如: https://gitea.airlabs.art/owner/repo/pulls/45
|
例如: https://gitea.airlabs.art/owner/repo/pulls/45
|
||||||
|
返回: ("https://gitea.airlabs.art", "owner", "repo", 45)
|
||||||
"""
|
"""
|
||||||
import re
|
match = re.search(r'(https?://[^/]+)/([^/]+)/([^/]+)/pulls/(\d+)', pr_url)
|
||||||
match = re.search(r'([^/]+)/([^/]+)/pulls/(\d+)', pr_url)
|
|
||||||
if not match:
|
if not match:
|
||||||
raise ValueError(f"无法解析 PR URL: {pr_url}")
|
raise ValueError(f"无法解析 PR URL: {pr_url}")
|
||||||
|
|
||||||
owner, repo, pr_number = match.groups()
|
base_url, owner, repo, pr_number = match.groups()
|
||||||
return owner, repo, int(pr_number)
|
return base_url, owner, repo, int(pr_number)
|
||||||
|
|
||||||
|
def _get_api_url(self, pr_url: Optional[str] = None) -> str:
|
||||||
|
"""获取 API base URL,优先从 pr_url 解析,否则用 self.base_api_url"""
|
||||||
|
if pr_url:
|
||||||
|
match = re.search(r'(https?://[^/]+)/', pr_url)
|
||||||
|
if match:
|
||||||
|
return f"{match.group(1)}/api/v1"
|
||||||
|
if self.base_api_url:
|
||||||
|
return self.base_api_url
|
||||||
|
raise ValueError("无法确定 Gitea API 地址:未配置 GITEA_URL 且 PR URL 中无法提取")
|
||||||
|
|
||||||
def merge_pr(
|
def merge_pr(
|
||||||
self, owner: str, repo: str, pr_number: int
|
self, owner: str, repo: str, pr_number: int, api_base_url: str = None
|
||||||
) -> Tuple[bool, str]:
|
) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
合并 PR
|
合并 PR
|
||||||
@ -45,11 +56,13 @@ class GiteaClient:
|
|||||||
owner: 仓库所有者
|
owner: 仓库所有者
|
||||||
repo: 仓库名称
|
repo: 仓库名称
|
||||||
pr_number: PR 编号
|
pr_number: PR 编号
|
||||||
|
api_base_url: 可选,API base URL(从 pr_url 解析得到)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(是否成功, 消息)
|
(是否成功, 消息)
|
||||||
"""
|
"""
|
||||||
url = f"{self.base_api_url}/repos/{owner}/{repo}/pulls/{pr_number}/merge"
|
base = api_base_url or self.base_api_url
|
||||||
|
url = f"{base}/repos/{owner}/{repo}/pulls/{pr_number}/merge"
|
||||||
payload = {
|
payload = {
|
||||||
"Do": "merge", # merge / squash / rebase
|
"Do": "merge", # merge / squash / rebase
|
||||||
"MergeMessageField": f"Merge PR #{pr_number} (approved via Log Center)",
|
"MergeMessageField": f"Merge PR #{pr_number} (approved via Log Center)",
|
||||||
@ -60,10 +73,11 @@ class GiteaClient:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return True, "PR 合并成功"
|
return True, "PR 合并成功"
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
error_msg = f"HTTP {e.response.status_code}"
|
|
||||||
if e.response.status_code == 405:
|
if e.response.status_code == 405:
|
||||||
error_msg += ": PR 已经合并或已关闭"
|
# PR 已经被合并或关闭,视为成功(期望的结果已达成)
|
||||||
elif e.response.status_code == 409:
|
return True, "PR 已经被合并或关闭"
|
||||||
|
error_msg = f"HTTP {e.response.status_code}"
|
||||||
|
if e.response.status_code == 409:
|
||||||
error_msg += ": 存在合并冲突"
|
error_msg += ": 存在合并冲突"
|
||||||
elif e.response.status_code == 403:
|
elif e.response.status_code == 403:
|
||||||
error_msg += ": Token 权限不足"
|
error_msg += ": Token 权限不足"
|
||||||
@ -74,7 +88,7 @@ class GiteaClient:
|
|||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|
||||||
def close_pr(
|
def close_pr(
|
||||||
self, owner: str, repo: str, pr_number: int, reason: str = ""
|
self, owner: str, repo: str, pr_number: int, reason: str = "", api_base_url: str = None
|
||||||
) -> Tuple[bool, str]:
|
) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
关闭 PR(可选添加评论说明原因)
|
关闭 PR(可选添加评论说明原因)
|
||||||
@ -84,20 +98,24 @@ class GiteaClient:
|
|||||||
repo: 仓库名称
|
repo: 仓库名称
|
||||||
pr_number: PR 编号
|
pr_number: PR 编号
|
||||||
reason: 关闭原因(将作为评论添加)
|
reason: 关闭原因(将作为评论添加)
|
||||||
|
api_base_url: 可选,API base URL(从 pr_url 解析得到)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(是否成功, 消息)
|
(是否成功, 消息)
|
||||||
"""
|
"""
|
||||||
|
base = api_base_url or self.base_api_url
|
||||||
|
|
||||||
# Step 1: 如果提供了原因,先添加评论
|
# Step 1: 如果提供了原因,先添加评论
|
||||||
if reason:
|
if reason:
|
||||||
comment_success, comment_msg = self.add_pr_comment(
|
comment_success, comment_msg = self.add_pr_comment(
|
||||||
owner, repo, pr_number, f"## ❌ 修复被拒绝\n\n**原因:**\n{reason}"
|
owner, repo, pr_number, f"## ❌ 修复被拒绝\n\n**原因:**\n{reason}",
|
||||||
|
api_base_url=base,
|
||||||
)
|
)
|
||||||
if not comment_success:
|
if not comment_success:
|
||||||
return False, f"添加评论失败: {comment_msg}"
|
return False, f"添加评论失败: {comment_msg}"
|
||||||
|
|
||||||
# Step 2: 关闭 PR
|
# Step 2: 关闭 PR
|
||||||
url = f"{self.base_api_url}/repos/{owner}/{repo}/pulls/{pr_number}"
|
url = f"{base}/repos/{owner}/{repo}/pulls/{pr_number}"
|
||||||
payload = {"state": "closed"}
|
payload = {"state": "closed"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -110,7 +128,7 @@ class GiteaClient:
|
|||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|
||||||
def add_pr_comment(
|
def add_pr_comment(
|
||||||
self, owner: str, repo: str, pr_number: int, comment: str
|
self, owner: str, repo: str, pr_number: int, comment: str, api_base_url: str = None
|
||||||
) -> Tuple[bool, str]:
|
) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
添加 PR 评论
|
添加 PR 评论
|
||||||
@ -120,12 +138,14 @@ class GiteaClient:
|
|||||||
repo: 仓库名称
|
repo: 仓库名称
|
||||||
pr_number: PR 编号
|
pr_number: PR 编号
|
||||||
comment: 评论内容
|
comment: 评论内容
|
||||||
|
api_base_url: 可选,API base URL
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(是否成功, 消息)
|
(是否成功, 消息)
|
||||||
"""
|
"""
|
||||||
|
base = api_base_url or self.base_api_url
|
||||||
# 注意: Gitea 中 PR 和 Issue 共用评论 API
|
# 注意: Gitea 中 PR 和 Issue 共用评论 API
|
||||||
url = f"{self.base_api_url}/repos/{owner}/{repo}/issues/{pr_number}/comments"
|
url = f"{base}/repos/{owner}/{repo}/issues/{pr_number}/comments"
|
||||||
payload = {"body": comment}
|
payload = {"body": comment}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -136,17 +156,19 @@ class GiteaClient:
|
|||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|
||||||
def merge_pr_by_url(self, pr_url: str) -> Tuple[bool, str]:
|
def merge_pr_by_url(self, pr_url: str) -> Tuple[bool, str]:
|
||||||
"""通过 PR URL 直接合并"""
|
"""通过 PR URL 直接合并(从 URL 自动解析 Gitea 地址)"""
|
||||||
try:
|
try:
|
||||||
owner, repo, pr_number = self._extract_owner_repo_from_pr_url(pr_url)
|
base_url, owner, repo, pr_number = self._parse_pr_url(pr_url)
|
||||||
return self.merge_pr(owner, repo, pr_number)
|
api_base_url = f"{base_url}/api/v1"
|
||||||
|
return self.merge_pr(owner, repo, pr_number, api_base_url=api_base_url)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|
||||||
def close_pr_by_url(self, pr_url: str, reason: str = "") -> Tuple[bool, str]:
|
def close_pr_by_url(self, pr_url: str, reason: str = "") -> Tuple[bool, str]:
|
||||||
"""通过 PR URL 直接关闭"""
|
"""通过 PR URL 直接关闭(从 URL 自动解析 Gitea 地址)"""
|
||||||
try:
|
try:
|
||||||
owner, repo, pr_number = self._extract_owner_repo_from_pr_url(pr_url)
|
base_url, owner, repo, pr_number = self._parse_pr_url(pr_url)
|
||||||
return self.close_pr(owner, repo, pr_number, reason)
|
api_base_url = f"{base_url}/api/v1"
|
||||||
|
return self.close_pr(owner, repo, pr_number, reason, api_base_url=api_base_url)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|||||||
41
app/main.py
41
app/main.py
@ -85,7 +85,8 @@ def generate_fingerprint(log: ErrorLogCreate) -> str:
|
|||||||
|
|
||||||
if source == "cicd":
|
if source == "cicd":
|
||||||
ctx = log.context or {}
|
ctx = log.context or {}
|
||||||
raw = f"{log.project_id}|cicd|{log.error.get('type')}|{ctx.get('job_name', 'unknown')}|{ctx.get('step_name', 'unknown')}"
|
# 加入 error_message 避免同一 job 不同错误被去重
|
||||||
|
raw = f"{log.project_id}|cicd|{log.error.get('type')}|{ctx.get('job_name', 'unknown')}|{ctx.get('step_name', 'unknown')}|{log.error.get('message', '')}"
|
||||||
elif source == "deployment":
|
elif source == "deployment":
|
||||||
ctx = log.context or {}
|
ctx = log.context or {}
|
||||||
raw = f"{log.project_id}|deployment|{log.error.get('type')}|{ctx.get('namespace', 'default')}|{ctx.get('deployment_name', 'unknown')}"
|
raw = f"{log.project_id}|deployment|{log.error.get('type')}|{ctx.get('namespace', 'default')}|{ctx.get('deployment_name', 'unknown')}"
|
||||||
@ -105,13 +106,28 @@ async def report_log(log_data: ErrorLogCreate, session: AsyncSession = Depends(g
|
|||||||
existing_log = results.first()
|
existing_log = results.first()
|
||||||
|
|
||||||
if existing_log:
|
if existing_log:
|
||||||
# If exists and not resolved, just ignore or update count (implied)
|
# If exists and not resolved, update error content but keep status
|
||||||
if existing_log.status not in [LogStatus.DEPLOYED, LogStatus.FIXED, LogStatus.VERIFIED]:
|
if existing_log.status not in [LogStatus.DEPLOYED, LogStatus.FIXED, LogStatus.VERIFIED]:
|
||||||
return {"message": "Log deduplicated", "id": existing_log.id, "status": existing_log.status}
|
# 更新错误内容,确保始终反映最新的错误信息
|
||||||
# If it was resolved but happened again -> Regression! Reset to NEW?
|
existing_log.error_message = log_data.error.get("message", existing_log.error_message)
|
||||||
|
existing_log.stack_trace = log_data.error.get("stack_trace", existing_log.stack_trace)
|
||||||
|
existing_log.context = log_data.context or existing_log.context
|
||||||
|
existing_log.timestamp = log_data.timestamp or datetime.utcnow()
|
||||||
|
if log_data.commit_hash:
|
||||||
|
existing_log.commit_hash = log_data.commit_hash
|
||||||
|
session.add(existing_log)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(existing_log)
|
||||||
|
return {"message": "Log deduplicated (content updated)", "id": existing_log.id, "status": existing_log.status}
|
||||||
|
# If it was resolved but happened again -> Regression! Reset to NEW
|
||||||
existing_log.status = LogStatus.NEW
|
existing_log.status = LogStatus.NEW
|
||||||
|
existing_log.error_message = log_data.error.get("message", existing_log.error_message)
|
||||||
|
existing_log.stack_trace = log_data.error.get("stack_trace", existing_log.stack_trace)
|
||||||
|
existing_log.context = log_data.context or existing_log.context
|
||||||
existing_log.timestamp = log_data.timestamp or datetime.utcnow()
|
existing_log.timestamp = log_data.timestamp or datetime.utcnow()
|
||||||
existing_log.retry_count = 0 # Reset retries for new occurrence
|
existing_log.retry_count = 0
|
||||||
|
if log_data.commit_hash:
|
||||||
|
existing_log.commit_hash = log_data.commit_hash
|
||||||
session.add(existing_log)
|
session.add(existing_log)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(existing_log)
|
await session.refresh(existing_log)
|
||||||
@ -354,7 +370,7 @@ async def approve_report(report_id: int, session: AsyncSession = Depends(get_ses
|
|||||||
gitea_client = GiteaClient()
|
gitea_client = GiteaClient()
|
||||||
success, message = gitea_client.merge_pr_by_url(report.pr_url)
|
success, message = gitea_client.merge_pr_by_url(report.pr_url)
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(status_code=500, detail=f"合并 PR 失败: {message}")
|
raise HTTPException(status_code=502, detail=f"合并 PR 失败: {message}")
|
||||||
|
|
||||||
# 更新报告状态
|
# 更新报告状态
|
||||||
report.status = LogStatus.FIXED
|
report.status = LogStatus.FIXED
|
||||||
@ -588,6 +604,19 @@ async def update_project(project_id: str, data: ProjectUpdate, session: AsyncSes
|
|||||||
await session.refresh(project)
|
await session.refresh(project)
|
||||||
return project
|
return project
|
||||||
|
|
||||||
|
@app.delete("/api/v1/projects/{project_id}", tags=["Projects"])
|
||||||
|
async def delete_project(project_id: str, session: AsyncSession = Depends(get_session)):
|
||||||
|
"""Delete a project by project_id"""
|
||||||
|
statement = select(Project).where(Project.project_id == project_id)
|
||||||
|
results = await session.exec(statement)
|
||||||
|
project = results.first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
await session.delete(project)
|
||||||
|
await session.commit()
|
||||||
|
return {"message": "Project deleted"}
|
||||||
|
|
||||||
@app.get("/", tags=["Health"])
|
@app.get("/", tags=["Health"])
|
||||||
async def health_check():
|
async def health_check():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
@ -32,6 +32,9 @@ spec:
|
|||||||
value: "log_center"
|
value: "log_center"
|
||||||
- name: DB_PASSWORD
|
- name: DB_PASSWORD
|
||||||
value: "JogNQdtrd3WY8CBCAiYfYEGx"
|
value: "JogNQdtrd3WY8CBCAiYfYEGx"
|
||||||
|
# Gitea Token(URL 从 PR URL 自动解析)
|
||||||
|
- name: GITEA_TOKEN
|
||||||
|
value: "443f7f2f556b4832f90e46df9af3e21ccb06b8a3"
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
memory: "128Mi"
|
memory: "128Mi"
|
||||||
|
|||||||
@ -38,6 +38,7 @@ class ClaudeService:
|
|||||||
cmd = [
|
cmd = [
|
||||||
self.cli_path,
|
self.cli_path,
|
||||||
"-p", prompt,
|
"-p", prompt,
|
||||||
|
"--model", settings.claude_model,
|
||||||
"--dangerously-skip-permissions",
|
"--dangerously-skip-permissions",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ class Settings(BaseSettings):
|
|||||||
# Claude CLI
|
# Claude CLI
|
||||||
claude_cli_path: str = Field(default="claude", description="Claude CLI 路径")
|
claude_cli_path: str = Field(default="claude", description="Claude CLI 路径")
|
||||||
claude_timeout: int = Field(default=1000, description="Claude CLI 超时时间(秒)")
|
claude_timeout: int = Field(default=1000, description="Claude CLI 超时时间(秒)")
|
||||||
|
claude_model: str = Field(default="claude-opus-4-6", description="Claude 模型")
|
||||||
|
|
||||||
# Git
|
# Git
|
||||||
git_user_name: str = Field(default="repair-agent", description="Git 用户名")
|
git_user_name: str = Field(default="repair-agent", description="Git 用户名")
|
||||||
|
|||||||
@ -160,6 +160,9 @@ export const getProjectDetail = (projectId: string) =>
|
|||||||
export const updateProject = (projectId: string, data: Partial<Project>) =>
|
export const updateProject = (projectId: string, data: Partial<Project>) =>
|
||||||
api.put<Project>(`/api/v1/projects/${projectId}`, data);
|
api.put<Project>(`/api/v1/projects/${projectId}`, data);
|
||||||
|
|
||||||
|
export const deleteProject = (projectId: string) =>
|
||||||
|
api.delete(`/api/v1/projects/${projectId}`);
|
||||||
|
|
||||||
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 });
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { getProjects, updateProject, type Project } from '../api';
|
import { getProjects, updateProject, deleteProject, type Project } from '../api';
|
||||||
import { Save, X, Pencil } from 'lucide-react';
|
import { Save, X, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
export default function ProjectList() {
|
export default function ProjectList() {
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
@ -9,6 +9,11 @@ export default function ProjectList() {
|
|||||||
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);
|
||||||
|
|
||||||
|
// Delete confirmation state
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
|
||||||
|
const [deleteConfirmName, setDeleteConfirmName] = useState('');
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
const fetchProjects = async () => {
|
const fetchProjects = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@ -50,6 +55,32 @@ export default function ProjectList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openDeleteConfirm = (p: Project) => {
|
||||||
|
setDeleteTarget(p);
|
||||||
|
setDeleteConfirmName('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDeleteConfirm = () => {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setDeleteConfirmName('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteProject(deleteTarget.project_id);
|
||||||
|
closeDeleteConfirm();
|
||||||
|
await fetchProjects();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('删除失败:', err);
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDeleteConfirmed = deleteTarget && deleteConfirmName === deleteTarget.project_id;
|
||||||
|
|
||||||
const ConfigBadge = ({ value }: { value: string | null }) => (
|
const ConfigBadge = ({ value }: { value: string | null }) => (
|
||||||
value ? (
|
value ? (
|
||||||
<span style={{ fontSize: '12px', color: 'var(--text-secondary)', wordBreak: 'break-all' }}>{value}</span>
|
<span style={{ fontSize: '12px', color: 'var(--text-secondary)', wordBreak: 'break-all' }}>{value}</span>
|
||||||
@ -164,9 +195,18 @@ export default function ProjectList() {
|
|||||||
{new Date(p.updated_at).toLocaleString()}
|
{new Date(p.updated_at).toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button className="btn-link" onClick={() => startEdit(p)}>
|
<div style={{ display: 'flex', gap: '6px' }}>
|
||||||
<Pencil size={14} /> 编辑
|
<button className="btn-link" onClick={() => startEdit(p)}>
|
||||||
</button>
|
<Pencil size={14} /> 编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-link"
|
||||||
|
onClick={() => openDeleteConfirm(p)}
|
||||||
|
style={{ color: 'var(--error)' }}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} /> 删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -233,9 +273,18 @@ export default function ProjectList() {
|
|||||||
<>
|
<>
|
||||||
<div className="mobile-card-top">
|
<div className="mobile-card-top">
|
||||||
<strong>{p.project_id}</strong>
|
<strong>{p.project_id}</strong>
|
||||||
<button className="btn-link" onClick={() => startEdit(p)}>
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
<Pencil size={14} />
|
<button className="btn-link" onClick={() => startEdit(p)}>
|
||||||
</button>
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-link"
|
||||||
|
onClick={() => openDeleteConfirm(p)}
|
||||||
|
style={{ color: 'var(--error)' }}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{p.name && <div style={{ fontSize: '13px' }}>{p.name}</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 className="mobile-card-meta" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: '4px', marginTop: '6px' }}>
|
||||||
@ -253,6 +302,76 @@ export default function ProjectList() {
|
|||||||
</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)' }}>
|
||||||
|
此操作不可撤销。请输入项目 ID <strong style={{ color: 'var(--error)' }}>{deleteTarget.project_id}</strong> 以确认删除。
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={deleteConfirmName}
|
||||||
|
onChange={(e) => setDeleteConfirmName(e.target.value)}
|
||||||
|
placeholder={deleteTarget.project_id}
|
||||||
|
className="edit-input"
|
||||||
|
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' }}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deleting ? '删除中...' : '确认删除'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user