Compare commits

..

9 Commits

Author SHA1 Message Date
repair-agent
fc6dc52f72 add del project
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 1m42s
2026-03-13 10:44:29 +08:00
repair-agent
01f87e38aa add bugfix
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 1m36s
2026-03-02 18:09:53 +08:00
repair-agent
25c9b2d18e fix bug
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 1m39s
2026-03-02 17:46:33 +08:00
zyc
708021c443 Merge pull request 'fix: auto repair bugs #55' (#3) from fix/auto-20260228-144441 into main
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 5m5s
Merge PR #3 (approved via Log Center)
2026-02-28 14:48:08 +08:00
repair-agent
682a70b9af fix: auto repair bugs #55 2026-02-28 14:47:46 +08:00
zyc
7aa6781c6d Merge pull request 'fix: auto repair bugs #54' (#2) from fix/auto-20260228-143852 into main
Some checks failed
Build and Deploy Log Center / build-and-deploy (push) Has been cancelled
Merge PR #2 (approved via Log Center)
2026-02-28 14:46:42 +08:00
repair-agent
c94c7bd420 fix: auto repair bugs #54 2026-02-28 14:41:39 +08:00
repair-agent
f8f8c1174f fix: 添加 httpx 依赖,修复 API 启动崩溃
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 1m37s
gitea_client.py 依赖 httpx 但未列入 requirements.txt,
导致 Docker 镜像中缺少该模块,API 启动时 import 失败。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 10:46:22 +08:00
zyc
18d18dbe14 Merge pull request 'fix: auto repair bugs #49' (#1) from fix/auto-20260226-103348 into main
All checks were successful
Build and Deploy Log Center / build-and-deploy (push) Successful in 1m47s
Merge PR #1 (approved via Log Center)
2026-02-26 10:35:59 +08:00
8 changed files with 223 additions and 39 deletions

View File

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

View File

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

View File

@ -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.status = LogStatus.NEW 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 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.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.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"}

View File

@ -32,6 +32,9 @@ spec:
value: "log_center" value: "log_center"
- name: DB_PASSWORD - name: DB_PASSWORD
value: "JogNQdtrd3WY8CBCAiYfYEGx" value: "JogNQdtrd3WY8CBCAiYfYEGx"
# Gitea TokenURL 从 PR URL 自动解析)
- name: GITEA_TOKEN
value: "443f7f2f556b4832f90e46df9af3e21ccb06b8a3"
resources: resources:
requests: requests:
memory: "128Mi" memory: "128Mi"

View File

@ -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",
] ]

View File

@ -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 用户名")

View File

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

View File

@ -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>
<div style={{ display: 'flex', gap: '6px' }}>
<button className="btn-link" onClick={() => startEdit(p)}> <button className="btn-link" onClick={() => startEdit(p)}>
<Pencil size={14} /> <Pencil size={14} />
</button> </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>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn-link" onClick={() => startEdit(p)}> <button className="btn-link" onClick={() => startEdit(p)}>
<Pencil size={14} /> <Pencil size={14} />
</button> </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>
); );
} }