Compare commits
1 Commits
main
...
fix/auto-2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b152832440 |
@ -16,38 +16,10 @@ 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 = None
|
engine = create_async_engine(DATABASE_URL, echo=True, future=True)
|
||||||
|
|
||||||
|
|
||||||
def get_engine():
|
|
||||||
"""Lazy engine creation to ensure pool is bound to the current event loop."""
|
|
||||||
global _engine
|
|
||||||
if _engine is None:
|
|
||||||
_engine = create_async_engine(
|
|
||||||
DATABASE_URL,
|
|
||||||
echo=True,
|
|
||||||
future=True,
|
|
||||||
pool_pre_ping=True,
|
|
||||||
pool_recycle=300,
|
|
||||||
)
|
|
||||||
return _engine
|
|
||||||
|
|
||||||
|
|
||||||
async def dispose_engine():
|
|
||||||
"""Dispose engine and reset so next call creates a fresh one."""
|
|
||||||
global _engine
|
|
||||||
if _engine is not None:
|
|
||||||
await _engine.dispose()
|
|
||||||
_engine = None
|
|
||||||
|
|
||||||
|
|
||||||
# Module-level alias for backward compatibility
|
|
||||||
engine = None # Use get_engine() instead
|
|
||||||
|
|
||||||
|
|
||||||
async def init_db():
|
async def init_db():
|
||||||
eng = get_engine()
|
async with engine.begin() as conn:
|
||||||
async with eng.begin() as conn:
|
|
||||||
# await conn.run_sync(SQLModel.metadata.drop_all)
|
# await conn.run_sync(SQLModel.metadata.drop_all)
|
||||||
await conn.run_sync(SQLModel.metadata.create_all)
|
await conn.run_sync(SQLModel.metadata.create_all)
|
||||||
|
|
||||||
@ -65,8 +37,6 @@ async def init_db():
|
|||||||
# Bug severity (1-10 AI评估等级)
|
# Bug severity (1-10 AI评估等级)
|
||||||
"ALTER TABLE errorlog ADD COLUMN IF NOT EXISTS severity INTEGER",
|
"ALTER TABLE errorlog ADD COLUMN IF NOT EXISTS severity INTEGER",
|
||||||
"ALTER TABLE errorlog ADD COLUMN IF NOT EXISTS severity_reason TEXT",
|
"ALTER TABLE errorlog ADD COLUMN IF NOT EXISTS severity_reason TEXT",
|
||||||
# Project repair_enabled toggle
|
|
||||||
"ALTER TABLE project ADD COLUMN IF NOT EXISTS repair_enabled BOOLEAN DEFAULT TRUE",
|
|
||||||
# Seed Project table from existing ErrorLog data
|
# Seed Project table from existing ErrorLog data
|
||||||
"""INSERT INTO project (project_id, created_at, updated_at)
|
"""INSERT INTO project (project_id, created_at, updated_at)
|
||||||
SELECT DISTINCT e.project_id, NOW(), NOW()
|
SELECT DISTINCT e.project_id, NOW(), NOW()
|
||||||
@ -81,7 +51,7 @@ async def init_db():
|
|||||||
|
|
||||||
async def get_session() -> AsyncSession:
|
async def get_session() -> AsyncSession:
|
||||||
async_session = sessionmaker(
|
async_session = sessionmaker(
|
||||||
get_engine(), class_=AsyncSession, expire_on_commit=False
|
engine, class_=AsyncSession, expire_on_commit=False
|
||||||
)
|
)
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
yield session
|
yield session
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
Gitea API 客户端
|
Gitea API 客户端
|
||||||
"""
|
"""
|
||||||
import re
|
|
||||||
import httpx
|
import httpx
|
||||||
import os
|
import os
|
||||||
from typing import Tuple, Optional
|
from typing import Tuple
|
||||||
|
|
||||||
|
|
||||||
class GiteaClient:
|
class GiteaClient:
|
||||||
@ -13,7 +12,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" if self.gitea_url else ""
|
self.base_api_url = f"{self.gitea_url}/api/v1"
|
||||||
self.client = httpx.Client(timeout=30)
|
self.client = httpx.Client(timeout=30)
|
||||||
|
|
||||||
def _headers(self) -> dict:
|
def _headers(self) -> dict:
|
||||||
@ -23,31 +22,21 @@ class GiteaClient:
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
def _parse_pr_url(self, pr_url: str) -> Tuple[str, str, str, int]:
|
def _extract_owner_repo_from_pr_url(self, pr_url: str) -> Tuple[str, str, int]:
|
||||||
"""
|
"""
|
||||||
从 PR URL 提取 base_url, owner, repo, pr_number
|
从 PR 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)
|
|
||||||
"""
|
"""
|
||||||
match = re.search(r'(https?://[^/]+)/([^/]+)/([^/]+)/pulls/(\d+)', pr_url)
|
import re
|
||||||
|
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}")
|
||||||
|
|
||||||
base_url, owner, repo, pr_number = match.groups()
|
owner, repo, pr_number = match.groups()
|
||||||
return base_url, owner, repo, int(pr_number)
|
return 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, api_base_url: str = None
|
self, owner: str, repo: str, pr_number: int
|
||||||
) -> Tuple[bool, str]:
|
) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
合并 PR
|
合并 PR
|
||||||
@ -56,13 +45,11 @@ class GiteaClient:
|
|||||||
owner: 仓库所有者
|
owner: 仓库所有者
|
||||||
repo: 仓库名称
|
repo: 仓库名称
|
||||||
pr_number: PR 编号
|
pr_number: PR 编号
|
||||||
api_base_url: 可选,API base URL(从 pr_url 解析得到)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(是否成功, 消息)
|
(是否成功, 消息)
|
||||||
"""
|
"""
|
||||||
base = api_base_url or self.base_api_url
|
url = f"{self.base_api_url}/repos/{owner}/{repo}/pulls/{pr_number}/merge"
|
||||||
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)",
|
||||||
@ -73,11 +60,10 @@ 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:
|
||||||
if e.response.status_code == 405:
|
|
||||||
# PR 已经被合并或关闭,视为成功(期望的结果已达成)
|
|
||||||
return True, "PR 已经被合并或关闭"
|
|
||||||
error_msg = f"HTTP {e.response.status_code}"
|
error_msg = f"HTTP {e.response.status_code}"
|
||||||
if e.response.status_code == 409:
|
if e.response.status_code == 405:
|
||||||
|
error_msg += ": PR 已经合并或已关闭"
|
||||||
|
elif 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 权限不足"
|
||||||
@ -88,7 +74,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 = "", api_base_url: str = None
|
self, owner: str, repo: str, pr_number: int, reason: str = ""
|
||||||
) -> Tuple[bool, str]:
|
) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
关闭 PR(可选添加评论说明原因)
|
关闭 PR(可选添加评论说明原因)
|
||||||
@ -98,24 +84,20 @@ 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"{base}/repos/{owner}/{repo}/pulls/{pr_number}"
|
url = f"{self.base_api_url}/repos/{owner}/{repo}/pulls/{pr_number}"
|
||||||
payload = {"state": "closed"}
|
payload = {"state": "closed"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -128,7 +110,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, api_base_url: str = None
|
self, owner: str, repo: str, pr_number: int, comment: str
|
||||||
) -> Tuple[bool, str]:
|
) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
添加 PR 评论
|
添加 PR 评论
|
||||||
@ -138,14 +120,12 @@ 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"{base}/repos/{owner}/{repo}/issues/{pr_number}/comments"
|
url = f"{self.base_api_url}/repos/{owner}/{repo}/issues/{pr_number}/comments"
|
||||||
payload = {"body": comment}
|
payload = {"body": comment}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -156,19 +136,17 @@ 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 直接合并(从 URL 自动解析 Gitea 地址)"""
|
"""通过 PR URL 直接合并"""
|
||||||
try:
|
try:
|
||||||
base_url, owner, repo, pr_number = self._parse_pr_url(pr_url)
|
owner, repo, pr_number = self._extract_owner_repo_from_pr_url(pr_url)
|
||||||
api_base_url = f"{base_url}/api/v1"
|
return self.merge_pr(owner, repo, pr_number)
|
||||||
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 直接关闭(从 URL 自动解析 Gitea 地址)"""
|
"""通过 PR URL 直接关闭"""
|
||||||
try:
|
try:
|
||||||
base_url, owner, repo, pr_number = self._parse_pr_url(pr_url)
|
owner, repo, pr_number = self._extract_owner_repo_from_pr_url(pr_url)
|
||||||
api_base_url = f"{base_url}/api/v1"
|
return self.close_pr(owner, repo, pr_number, reason)
|
||||||
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)
|
||||||
|
|||||||
45
app/main.py
45
app/main.py
@ -3,7 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
from sqlmodel import select, func, text
|
from sqlmodel import select, func, text
|
||||||
from .database import init_db, get_session, get_engine
|
from .database import init_db, get_session, engine
|
||||||
from .models import ErrorLog, ErrorLogCreate, LogStatus, TaskStatusUpdate, RepairTask, RepairTaskCreate, Project, ProjectUpdate
|
from .models import ErrorLog, ErrorLogCreate, LogStatus, TaskStatusUpdate, RepairTask, RepairTaskCreate, Project, ProjectUpdate
|
||||||
from .gitea_client import GiteaClient
|
from .gitea_client import GiteaClient
|
||||||
from .self_report import self_report_error
|
from .self_report import self_report_error
|
||||||
@ -49,7 +49,7 @@ async def _register_self_projects():
|
|||||||
"description": "日志中台 React 管理端",
|
"description": "日志中台 React 管理端",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
async_session = sa_sessionmaker(get_engine(), class_=AsyncSession, expire_on_commit=False)
|
async_session = sa_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
for proj_data in projects:
|
for proj_data in projects:
|
||||||
stmt = select(Project).where(Project.project_id == proj_data["project_id"])
|
stmt = select(Project).where(Project.project_id == proj_data["project_id"])
|
||||||
@ -85,8 +85,7 @@ def generate_fingerprint(log: ErrorLogCreate) -> str:
|
|||||||
|
|
||||||
if source == "cicd":
|
if source == "cicd":
|
||||||
ctx = log.context or {}
|
ctx = log.context or {}
|
||||||
# 加入 error_message 避免同一 job 不同错误被去重
|
raw = f"{log.project_id}|cicd|{log.error.get('type')}|{ctx.get('job_name', 'unknown')}|{ctx.get('step_name', 'unknown')}"
|
||||||
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')}"
|
||||||
@ -106,28 +105,13 @@ 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, update error content but keep status
|
# If exists and not resolved, just ignore or update count (implied)
|
||||||
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}
|
||||||
existing_log.error_message = log_data.error.get("message", existing_log.error_message)
|
# If it was resolved but happened again -> Regression! Reset to 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()
|
|
||||||
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
|
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)
|
session.add(existing_log)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(existing_log)
|
await session.refresh(existing_log)
|
||||||
@ -370,7 +354,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=502, detail=f"合并 PR 失败: {message}")
|
raise HTTPException(status_code=500, detail=f"合并 PR 失败: {message}")
|
||||||
|
|
||||||
# 更新报告状态
|
# 更新报告状态
|
||||||
report.status = LogStatus.FIXED
|
report.status = LogStatus.FIXED
|
||||||
@ -604,19 +588,6 @@ 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"}
|
||||||
|
|||||||
@ -27,7 +27,6 @@ class Project(SQLModel, table=True):
|
|||||||
repo_url: Optional[str] = Field(default=None)
|
repo_url: Optional[str] = Field(default=None)
|
||||||
local_path: Optional[str] = Field(default=None)
|
local_path: Optional[str] = Field(default=None)
|
||||||
description: 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)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|
||||||
@ -37,7 +36,6 @@ class ProjectUpdate(SQLModel):
|
|||||||
repo_url: Optional[str] = None
|
repo_url: Optional[str] = None
|
||||||
local_path: Optional[str] = None
|
local_path: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
repair_enabled: Optional[bool] = None
|
|
||||||
|
|
||||||
class ErrorLog(SQLModel, table=True):
|
class ErrorLog(SQLModel, table=True):
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
|||||||
@ -9,7 +9,7 @@ from sqlmodel import select
|
|||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from .database import get_engine
|
from .database import engine
|
||||||
from .models import ErrorLog, LogStatus, Project
|
from .models import ErrorLog, LogStatus, Project
|
||||||
|
|
||||||
PROJECT_ID = "log_center_api"
|
PROJECT_ID = "log_center_api"
|
||||||
@ -35,7 +35,7 @@ async def self_report_error(exc: Exception, context: dict = None):
|
|||||||
raw = f"{PROJECT_ID}|{error_type}|{file_path}|{line_number}"
|
raw = f"{PROJECT_ID}|{error_type}|{file_path}|{line_number}"
|
||||||
fingerprint = hashlib.md5(raw.encode()).hexdigest()
|
fingerprint = hashlib.md5(raw.encode()).hexdigest()
|
||||||
|
|
||||||
async_session = sessionmaker(get_engine(), class_=AsyncSession, expire_on_commit=False)
|
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
# 去重检查
|
# 去重检查
|
||||||
stmt = select(ErrorLog).where(ErrorLog.fingerprint == fingerprint)
|
stmt = select(ErrorLog).where(ErrorLog.fingerprint == fingerprint)
|
||||||
|
|||||||
@ -32,9 +32,6 @@ 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,7 +38,6 @@ class ClaudeService:
|
|||||||
cmd = [
|
cmd = [
|
||||||
self.cli_path,
|
self.cli_path,
|
||||||
"-p", prompt,
|
"-p", prompt,
|
||||||
"--model", settings.claude_model,
|
|
||||||
"--dangerously-skip-permissions",
|
"--dangerously-skip-permissions",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -41,18 +41,13 @@ class RepairScheduler:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _fetch_projects_from_api() -> list[str]:
|
def _fetch_projects_from_api() -> list[str]:
|
||||||
"""从 Log Center API 动态获取所有已启用修复的项目"""
|
"""从 Log Center API 动态获取所有已注册项目"""
|
||||||
try:
|
try:
|
||||||
resp = httpx.get(f"{settings.log_center_url}/api/v1/projects", timeout=10)
|
resp = httpx.get(f"{settings.log_center_url}/api/v1/projects", timeout=10)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
projects = resp.json().get("projects", [])
|
projects = resp.json().get("projects", [])
|
||||||
# 只返回 repair_enabled=True 的项目
|
project_ids = [p["project_id"] for p in projects]
|
||||||
enabled = [p for p in projects if p.get("repair_enabled", True)]
|
logger.info(f"从 API 获取到 {len(project_ids)} 个项目: {', '.join(project_ids)}")
|
||||||
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
|
return project_ids
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"从 API 获取项目列表失败: {e},回退到本地配置")
|
logger.warning(f"从 API 获取项目列表失败: {e},回退到本地配置")
|
||||||
|
|||||||
@ -19,7 +19,6 @@ 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 用户名")
|
||||||
|
|||||||
@ -1,13 +1,10 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<title>web</title>
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600;700&family=Fira+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
|
||||||
<title>日志中台 - Log Center</title>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -100,7 +100,6 @@ export interface Project {
|
|||||||
repo_url: string | null;
|
repo_url: string | null;
|
||||||
local_path: string | null;
|
local_path: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
repair_enabled: boolean;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@ -161,12 +160,6 @@ 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 toggleProjectRepair = (projectId: string, enabled: boolean) =>
|
|
||||||
api.put<Project>(`/api/v1/projects/${projectId}`, { repair_enabled: enabled });
|
|
||||||
|
|
||||||
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,36 +1,31 @@
|
|||||||
/* ============================================
|
/* ============================================
|
||||||
Log Center - OLED Dark Design System
|
Log Center - Modern Minimalist Design System
|
||||||
Style: Dark Mode (OLED) per ui-ux-pro-max
|
|
||||||
Colors: #020617 bg / #0F172A primary / #22C55E CTA
|
|
||||||
Fonts: Fira Sans + Fira Code
|
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #020617;
|
--bg-primary: #09090b;
|
||||||
--bg-secondary: #0F172A;
|
--bg-secondary: #0f0f11;
|
||||||
--bg-card: #1E293B;
|
--bg-card: #18181b;
|
||||||
--bg-surface: #1E293B;
|
--bg-surface: #1f1f23;
|
||||||
--bg-hover: #334155;
|
--bg-hover: #27272a;
|
||||||
--accent: #3b82f6;
|
--accent: #3b82f6;
|
||||||
--accent-hover: #2563eb;
|
--accent-hover: #2563eb;
|
||||||
--accent-muted: rgba(59, 130, 246, 0.12);
|
--accent-muted: rgba(59, 130, 246, 0.12);
|
||||||
--indigo: #6366f1;
|
--indigo: #6366f1;
|
||||||
--text-primary: #F8FAFC;
|
--text-primary: #fafafa;
|
||||||
--text-secondary: #94A3B8;
|
--text-secondary: #a1a1aa;
|
||||||
--text-tertiary: #64748B;
|
--text-tertiary: #71717a;
|
||||||
--success: #22c55e;
|
--success: #22c55e;
|
||||||
--success-muted: rgba(34, 197, 94, 0.12);
|
--success-muted: rgba(34, 197, 94, 0.12);
|
||||||
--warning: #f59e0b;
|
--warning: #f59e0b;
|
||||||
--warning-muted: rgba(245, 158, 11, 0.12);
|
--warning-muted: rgba(245, 158, 11, 0.12);
|
||||||
--error: #ef4444;
|
--error: #ef4444;
|
||||||
--error-muted: rgba(239, 68, 68, 0.12);
|
--error-muted: rgba(239, 68, 68, 0.12);
|
||||||
--border: rgba(148, 163, 184, 0.1);
|
--border: #27272a;
|
||||||
--border-subtle: rgba(148, 163, 184, 0.06);
|
--border-subtle: #1f1f23;
|
||||||
--radius-sm: 6px;
|
--radius-sm: 6px;
|
||||||
--radius-md: 8px;
|
--radius-md: 8px;
|
||||||
--radius-lg: 12px;
|
--radius-lg: 12px;
|
||||||
--glow-accent: 0 0 12px rgba(59, 130, 246, 0.15);
|
|
||||||
--glow-success: 0 0 12px rgba(34, 197, 94, 0.15);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@ -40,7 +35,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Fira Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@ -133,7 +128,6 @@ a:hover {
|
|||||||
.nav-link.active {
|
.nav-link.active {
|
||||||
background: var(--accent-muted);
|
background: var(--accent-muted);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
box-shadow: var(--glow-accent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link svg {
|
.nav-link svg {
|
||||||
@ -191,8 +185,7 @@ a:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-card:hover {
|
.stat-card:hover {
|
||||||
border-color: rgba(148, 163, 184, 0.2);
|
border-color: #3f3f46;
|
||||||
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
@ -210,10 +203,10 @@ a:hover {
|
|||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value.accent { color: var(--accent); text-shadow: 0 0 10px rgba(59, 130, 246, 0.3); }
|
.stat-value.accent { color: var(--accent); }
|
||||||
.stat-value.success { color: var(--success); text-shadow: 0 0 10px rgba(34, 197, 94, 0.3); }
|
.stat-value.success { color: var(--success); }
|
||||||
.stat-value.warning { color: var(--warning); text-shadow: 0 0 10px rgba(245, 158, 11, 0.3); }
|
.stat-value.warning { color: var(--warning); }
|
||||||
.stat-value.error { color: var(--error); text-shadow: 0 0 10px rgba(239, 68, 68, 0.3); }
|
.stat-value.error { color: var(--error); }
|
||||||
|
|
||||||
.stat-icon {
|
.stat-icon {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -295,7 +288,7 @@ td a:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cell-mono {
|
.cell-mono {
|
||||||
font-family: 'Fira Code', 'SF Mono', 'Cascadia Code', monospace;
|
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
@ -471,7 +464,7 @@ td a:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-tab:hover {
|
.status-tab:hover {
|
||||||
border-color: rgba(148, 163, 184, 0.2);
|
border-color: #3f3f46;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -649,7 +642,7 @@ td a:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-section-value {
|
.detail-section-value {
|
||||||
font-family: 'Fira Code', 'SF Mono', 'Cascadia Code', monospace;
|
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
@ -661,7 +654,7 @@ td a:hover {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
font-family: 'Fira Code', 'SF Mono', 'Cascadia Code', monospace;
|
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@ -687,7 +680,7 @@ td a:hover {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
font-family: 'Fira Code', 'SF Mono', 'Cascadia Code', monospace;
|
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@ -757,7 +750,7 @@ td a:hover {
|
|||||||
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
.btn-secondary:hover:not(:disabled) {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
border-color: rgba(148, 163, 184, 0.2);
|
border-color: #3f3f46;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:disabled {
|
.btn-secondary:disabled {
|
||||||
@ -930,7 +923,7 @@ td a:hover {
|
|||||||
|
|
||||||
.pagination button:hover:not(:disabled) {
|
.pagination button:hover:not(:disabled) {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
border-color: rgba(148, 163, 184, 0.2);
|
border-color: #3f3f46;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination button:disabled {
|
.pagination button:disabled {
|
||||||
@ -1292,8 +1285,6 @@ td a:hover {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
text-decoration: none;
|
|
||||||
color: inherit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-card-item:last-child {
|
.mobile-card-item:last-child {
|
||||||
@ -1335,7 +1326,7 @@ td a:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mobile-card-file {
|
.mobile-card-file {
|
||||||
font-family: 'Fira Code', 'SF Mono', 'Cascadia Code', monospace;
|
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
@ -1525,413 +1516,6 @@ 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: rgba(148, 163, 184, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
box-shadow: var(--glow-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 ============ */
|
/* ============ Safe area for notch devices ============ */
|
||||||
|
|
||||||
@supports (padding: env(safe-area-inset-bottom)) {
|
@supports (padding: env(safe-area-inset-bottom)) {
|
||||||
@ -1943,402 +1527,3 @@ td a:hover {
|
|||||||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ Button Color Variants ============ */
|
|
||||||
|
|
||||||
.trigger-repair-btn.btn-success {
|
|
||||||
background: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger-repair-btn.btn-success:hover:not(:disabled) {
|
|
||||||
background: #16a34a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger-repair-btn.btn-error {
|
|
||||||
background: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger-repair-btn.btn-error:hover:not(:disabled) {
|
|
||||||
background: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger-repair-btn.btn-warning {
|
|
||||||
background: var(--warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger-repair-btn.btn-warning:hover:not(:disabled) {
|
|
||||||
background: #d97706;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ Detail Card Variants ============ */
|
|
||||||
|
|
||||||
.detail-card--error {
|
|
||||||
border-left: 3px solid var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-card--warning {
|
|
||||||
border-left: 3px solid var(--warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-card--accent {
|
|
||||||
border-left: 3px solid var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ Card Variants ============ */
|
|
||||||
|
|
||||||
.card--error {
|
|
||||||
border-left: 3px solid var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card--warning {
|
|
||||||
border-left: 3px solid var(--warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card--accent {
|
|
||||||
border-left: 3px solid var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ Severity Badge ============ */
|
|
||||||
|
|
||||||
.severity-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.severity-badge--high {
|
|
||||||
background: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.severity-badge--mid {
|
|
||||||
background: var(--warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.severity-badge--low {
|
|
||||||
background: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ Severity Section ============ */
|
|
||||||
|
|
||||||
.severity-section {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.severity-section--critical {
|
|
||||||
background: rgba(239, 68, 68, 0.08);
|
|
||||||
border-left: 3px solid var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.severity-section--mid {
|
|
||||||
border-left: 3px solid var(--warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.severity-section--low {
|
|
||||||
border-left: 3px solid var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.severity-section .detail-section-title {
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.severity-section-desc {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ PR Info Section ============ */
|
|
||||||
|
|
||||||
.pr-section {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pr-section .detail-section-title {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pr-section-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pr-section-id {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pr-section-link {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pr-section-rejections {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ Action Messages ============ */
|
|
||||||
|
|
||||||
.action-message {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-message--success {
|
|
||||||
color: var(--success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-message--error {
|
|
||||||
color: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ Action Hint ============ */
|
|
||||||
|
|
||||||
.action-hint {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ Detail Header Right ============ */
|
|
||||||
|
|
||||||
.detail-header-badges {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ Reject Modal ============ */
|
|
||||||
|
|
||||||
.reject-modal-content {
|
|
||||||
max-width: 500px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reject-modal-desc {
|
|
||||||
margin: 0 0 12px 0;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reject-templates {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reject-templates-label {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reject-templates-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reject-template-btn {
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reject-template-btn:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border-color: rgba(148, 163, 184, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reject-textarea {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 120px;
|
|
||||||
padding: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
resize: vertical;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.15s ease;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reject-textarea:focus {
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ Failure Hint (in table) ============ */
|
|
||||||
|
|
||||||
.failure-hint {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--error);
|
|
||||||
margin-top: 4px;
|
|
||||||
max-width: 200px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ Failure Cell (in repair table) ============ */
|
|
||||||
|
|
||||||
.failure-cell {
|
|
||||||
max-width: 200px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ Failure Cell Wide (in repair history) ============ */
|
|
||||||
|
|
||||||
.failure-cell-wide {
|
|
||||||
max-width: 300px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ Detail Error Title ============ */
|
|
||||||
|
|
||||||
.detail-title--error {
|
|
||||||
color: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ Error Alert Card ============ */
|
|
||||||
|
|
||||||
.alert-card-header {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-card-header--error {
|
|
||||||
color: var(--error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-card-header--warning {
|
|
||||||
color: var(--warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ Review Section ============ */
|
|
||||||
|
|
||||||
.review-section-desc {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.review-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ Report Header ============ */
|
|
||||||
|
|
||||||
.report-header {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ Reduced Motion ============ */
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
*, *::before, *::after {
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
animation-iteration-count: 1 !important;
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
animation: none;
|
|
||||||
border-top-color: var(--accent);
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -28,12 +28,6 @@ const REJECT_REASON_TEMPLATES = [
|
|||||||
'需要补充异常处理',
|
'需要补充异常处理',
|
||||||
];
|
];
|
||||||
|
|
||||||
function severityLevel(s: number) {
|
|
||||||
if (s >= 8) return 'high';
|
|
||||||
if (s >= 5) return 'mid';
|
|
||||||
return 'low';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BugDetail() {
|
export default function BugDetail() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@ -43,16 +37,19 @@ export default function BugDetail() {
|
|||||||
const [repairMessage, setRepairMessage] = useState('');
|
const [repairMessage, setRepairMessage] = useState('');
|
||||||
const [repairHistory, setRepairHistory] = useState<RepairReport[]>([]);
|
const [repairHistory, setRepairHistory] = useState<RepairReport[]>([]);
|
||||||
|
|
||||||
|
// PR 操作相关状态
|
||||||
const [mergingPR, setMergingPR] = useState(false);
|
const [mergingPR, setMergingPR] = useState(false);
|
||||||
const [closingPR, setClosingPR] = useState(false);
|
const [closingPR, setClosingPR] = useState(false);
|
||||||
const [prMessage, setPRMessage] = useState('');
|
const [prMessage, setPRMessage] = useState('');
|
||||||
const [showRejectModal, setShowRejectModal] = useState(false);
|
const [showRejectModal, setShowRejectModal] = useState(false);
|
||||||
const [rejectReason, setRejectReason] = useState('');
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
|
|
||||||
|
// 人工审核操作状态(无 PR 时使用)
|
||||||
const [approving, setApproving] = useState(false);
|
const [approving, setApproving] = useState(false);
|
||||||
const [rejecting, setRejecting] = useState(false);
|
const [rejecting, setRejecting] = useState(false);
|
||||||
const [reviewMessage, setReviewMessage] = useState('');
|
const [reviewMessage, setReviewMessage] = useState('');
|
||||||
|
|
||||||
|
// 重试操作状态
|
||||||
const [retrying, setRetrying] = useState(false);
|
const [retrying, setRetrying] = useState(false);
|
||||||
const [retryMessage, setRetryMessage] = useState('');
|
const [retryMessage, setRetryMessage] = useState('');
|
||||||
|
|
||||||
@ -104,10 +101,10 @@ export default function BugDetail() {
|
|||||||
try {
|
try {
|
||||||
await mergePR(bug.id);
|
await mergePR(bug.id);
|
||||||
setBug({ ...bug, status: 'FIXED', merged_at: new Date().toISOString() });
|
setBug({ ...bug, status: 'FIXED', merged_at: new Date().toISOString() });
|
||||||
setPRMessage('PR 已成功合并');
|
setPRMessage('✅ PR 已成功合并');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to merge PR:', error);
|
console.error('Failed to merge PR:', error);
|
||||||
setPRMessage(`合并失败: ${error.response?.data?.detail || error.message}`);
|
setPRMessage(`❌ 合并失败: ${error.response?.data?.detail || error.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setMergingPR(false);
|
setMergingPR(false);
|
||||||
}
|
}
|
||||||
@ -115,7 +112,7 @@ export default function BugDetail() {
|
|||||||
|
|
||||||
const handleClosePR = async () => {
|
const handleClosePR = async () => {
|
||||||
if (!bug || !rejectReason.trim()) {
|
if (!bug || !rejectReason.trim()) {
|
||||||
setPRMessage('请输入拒绝原因');
|
setPRMessage('❌ 请输入拒绝原因');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setClosingPR(true);
|
setClosingPR(true);
|
||||||
@ -128,12 +125,12 @@ export default function BugDetail() {
|
|||||||
rejection_count: (bug.rejection_count || 0) + 1,
|
rejection_count: (bug.rejection_count || 0) + 1,
|
||||||
last_rejected_at: new Date().toISOString()
|
last_rejected_at: new Date().toISOString()
|
||||||
});
|
});
|
||||||
setPRMessage('PR 已拒绝,Bug 将重新修复');
|
setPRMessage('✅ PR 已拒绝,Bug 将重新修复');
|
||||||
setShowRejectModal(false);
|
setShowRejectModal(false);
|
||||||
setRejectReason('');
|
setRejectReason('');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to close PR:', error);
|
console.error('Failed to close PR:', error);
|
||||||
setPRMessage(`关闭失败: ${error.response?.data?.detail || error.message}`);
|
setPRMessage(`❌ 关闭失败: ${error.response?.data?.detail || error.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setClosingPR(false);
|
setClosingPR(false);
|
||||||
}
|
}
|
||||||
@ -146,10 +143,10 @@ export default function BugDetail() {
|
|||||||
try {
|
try {
|
||||||
await retryFix(bug.id);
|
await retryFix(bug.id);
|
||||||
setBug({ ...bug, status: 'NEW', failure_reason: null });
|
setBug({ ...bug, status: 'NEW', failure_reason: null });
|
||||||
setRetryMessage('Bug 已重置,repair agent 将重新扫描');
|
setRetryMessage('✅ Bug 已重置,repair agent 将重新扫描');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to retry:', error);
|
console.error('Failed to retry:', error);
|
||||||
setRetryMessage(`重试失败: ${error.response?.data?.detail || error.message}`);
|
setRetryMessage(`❌ 重试失败: ${error.response?.data?.detail || error.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setRetrying(false);
|
setRetrying(false);
|
||||||
}
|
}
|
||||||
@ -215,12 +212,10 @@ export default function BugDetail() {
|
|||||||
const isPendingReview = bug.status === 'PENDING_FIX';
|
const isPendingReview = bug.status === 'PENDING_FIX';
|
||||||
const canOperatePR = hasPR && isPendingReview;
|
const canOperatePR = hasPR && isPendingReview;
|
||||||
const canManualReview = !hasPR && isPendingReview;
|
const canManualReview = !hasPR && isPendingReview;
|
||||||
|
// PR 信息只在 PENDING_FIX 或 FIXED 状态时显示
|
||||||
const shouldShowPR = hasPR && (isPendingReview || bug.status === 'FIXED');
|
const shouldShowPR = hasPR && (isPendingReview || bug.status === 'FIXED');
|
||||||
const canRetry = bug.status === 'FIX_FAILED';
|
const canRetry = bug.status === 'FIX_FAILED';
|
||||||
|
|
||||||
const msgIsSuccess = (msg: string) =>
|
|
||||||
msg.includes('成功') || msg.includes('确认') || msg.includes('驳回') || msg.includes('已重置') || msg.includes('已拒绝');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Link to={`/bugs${backSearch ? `?${backSearch}` : ''}`} className="back-link">
|
<Link to={`/bugs${backSearch ? `?${backSearch}` : ''}`} className="back-link">
|
||||||
@ -231,7 +226,7 @@ export default function BugDetail() {
|
|||||||
<div className="detail-card">
|
<div className="detail-card">
|
||||||
<div className="detail-header">
|
<div className="detail-header">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="detail-title detail-title--error">
|
<h2 className="detail-title" style={{ color: 'var(--error)' }}>
|
||||||
{bug.error_type}: {bug.error_message}
|
{bug.error_type}: {bug.error_message}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="detail-meta">
|
<div className="detail-meta">
|
||||||
@ -241,9 +236,19 @@ export default function BugDetail() {
|
|||||||
<span>级别:{bug.level}</span>
|
<span>级别:{bug.level}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-header-badges">
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
{bug.severity != null && bug.status !== 'NEW' && (
|
{bug.severity != null && bug.status !== 'NEW' && (
|
||||||
<span className={`severity-badge severity-badge--${severityLevel(bug.severity)}`}>
|
<span style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
padding: '4px 10px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#fff',
|
||||||
|
background: bug.severity >= 8 ? 'var(--error)' : bug.severity >= 5 ? 'var(--warning)' : 'var(--success)',
|
||||||
|
}}>
|
||||||
等级 {bug.severity}/10
|
等级 {bug.severity}/10
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -255,29 +260,35 @@ export default function BugDetail() {
|
|||||||
|
|
||||||
{/* 严重等级说明 */}
|
{/* 严重等级说明 */}
|
||||||
{bug.severity != null && bug.severity_reason && bug.status !== 'NEW' && (
|
{bug.severity != null && bug.severity_reason && bug.status !== 'NEW' && (
|
||||||
<div className={`severity-section severity-section--${bug.severity >= 8 ? 'critical' : severityLevel(bug.severity)}`}>
|
<div className="detail-section" style={{
|
||||||
<div className="detail-section-title">
|
background: bug.severity >= 8 ? 'rgba(239,68,68,0.08)' : 'var(--bg-secondary)',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
marginTop: '12px',
|
||||||
|
borderLeft: `3px solid ${bug.severity >= 8 ? 'var(--error)' : bug.severity >= 5 ? 'var(--warning)' : 'var(--success)'}`,
|
||||||
|
}}>
|
||||||
|
<div className="detail-section-title" style={{ marginBottom: '4px' }}>
|
||||||
严重等级:{bug.severity}/10 {bug.severity >= 8 ? '(需人工审核)' : ''}
|
严重等级:{bug.severity}/10 {bug.severity >= 8 ? '(需人工审核)' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div className="severity-section-desc">
|
<div style={{ fontSize: '13px', color: 'var(--text-secondary)' }}>
|
||||||
{bug.severity_reason}
|
{bug.severity_reason}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* PR 信息显示 */}
|
{/* PR 信息显示 - 仅在 PENDING_FIX 或 FIXED 状态时显示 */}
|
||||||
{shouldShowPR && (
|
{shouldShowPR && (
|
||||||
<div className="detail-section pr-section">
|
<div className="detail-section" style={{ background: 'var(--bg-secondary)', padding: '12px', borderRadius: '6px', marginTop: '16px' }}>
|
||||||
<div className="detail-section-title">Pull Request</div>
|
<div className="detail-section-title" style={{ marginBottom: '8px' }}>Pull Request</div>
|
||||||
<div className="pr-section-row">
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
|
||||||
<span className="pr-section-id">
|
<span style={{ fontSize: '14px' }}>
|
||||||
PR #{bug.pr_number} | {bug.branch_name || 'fix branch'}
|
PR #{bug.pr_number} | {bug.branch_name || 'fix branch'}
|
||||||
</span>
|
</span>
|
||||||
<a href={bug.pr_url || undefined} target="_blank" rel="noopener noreferrer" className="btn-link pr-section-link">
|
<a href={bug.pr_url || undefined} target="_blank" rel="noopener noreferrer" className="btn-link" style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||||
查看 PR <ExternalLink size={12} />
|
查看 PR <ExternalLink size={12} />
|
||||||
</a>
|
</a>
|
||||||
{((bug.rejection_count ?? 0) > 0) && (
|
{((bug.rejection_count ?? 0) > 0) && (
|
||||||
<span className="pr-section-rejections">
|
<span style={{ fontSize: '13px', color: 'var(--warning)' }}>
|
||||||
已拒绝 {bug.rejection_count ?? 0} 次
|
已拒绝 {bug.rejection_count ?? 0} 次
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -350,22 +361,29 @@ export default function BugDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 操作按钮区 */}
|
{/* 操作按钮区 */}
|
||||||
<div className="actions-bar">
|
<div className="actions-bar" style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
{/* PR 操作按钮 */}
|
{/* PR 操作按钮 */}
|
||||||
{canOperatePR && (
|
{canOperatePR && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className="trigger-repair-btn btn-success"
|
className="trigger-repair-btn"
|
||||||
onClick={handleMergePR}
|
onClick={handleMergePR}
|
||||||
disabled={mergingPR}
|
disabled={mergingPR}
|
||||||
|
style={{ background: 'var(--success)', borderColor: 'var(--success)' }}
|
||||||
>
|
>
|
||||||
{mergingPR ? <Loader2 size={14} className="spinner" /> : <Check size={14} />}
|
{mergingPR ? (
|
||||||
|
<Loader2 size={14} className="spinner" />
|
||||||
|
) : (
|
||||||
|
<Check size={14} />
|
||||||
|
)}
|
||||||
{mergingPR ? '合并中...' : '批准并合并'}
|
{mergingPR ? '合并中...' : '批准并合并'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="trigger-repair-btn btn-error"
|
className="trigger-repair-btn"
|
||||||
onClick={() => setShowRejectModal(true)}
|
onClick={() => setShowRejectModal(true)}
|
||||||
disabled={closingPR}
|
disabled={closingPR}
|
||||||
|
style={{ background: 'var(--error)', borderColor: 'var(--error)' }}
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
拒绝修复
|
拒绝修复
|
||||||
@ -377,17 +395,24 @@ export default function BugDetail() {
|
|||||||
{canManualReview && (
|
{canManualReview && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className="trigger-repair-btn btn-success"
|
className="trigger-repair-btn"
|
||||||
onClick={handleApproveFix}
|
onClick={handleApproveFix}
|
||||||
disabled={approving}
|
disabled={approving}
|
||||||
|
style={{ background: 'var(--success)', borderColor: 'var(--success)' }}
|
||||||
>
|
>
|
||||||
{approving ? <Loader2 size={14} className="spinner" /> : <Check size={14} />}
|
{approving ? (
|
||||||
|
<Loader2 size={14} className="spinner" />
|
||||||
|
) : (
|
||||||
|
<Check size={14} />
|
||||||
|
)}
|
||||||
{approving ? '确认中...' : '确认修复'}
|
{approving ? '确认中...' : '确认修复'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="trigger-repair-btn btn-error"
|
className="trigger-repair-btn"
|
||||||
onClick={() => setShowRejectModal(true)}
|
onClick={() => setShowRejectModal(true)}
|
||||||
disabled={rejecting}
|
disabled={rejecting}
|
||||||
|
style={{ background: 'var(--error)', borderColor: 'var(--error)' }}
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
驳回修复
|
驳回修复
|
||||||
@ -395,54 +420,78 @@ export default function BugDetail() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 触发修复按钮 */}
|
{/* 原有的触发修复按钮 */}
|
||||||
{!hasPR && !isPendingReview && (
|
{!hasPR && !isPendingReview && (
|
||||||
<button
|
<button
|
||||||
className="trigger-repair-btn"
|
className="trigger-repair-btn"
|
||||||
onClick={handleTriggerRepair}
|
onClick={handleTriggerRepair}
|
||||||
disabled={!canTriggerRepair || repairing}
|
disabled={!canTriggerRepair || repairing}
|
||||||
>
|
>
|
||||||
{repairing ? <Loader2 size={14} className="spinner" /> : <Play size={14} />}
|
{repairing ? (
|
||||||
|
<Loader2 size={14} className="spinner" />
|
||||||
|
) : (
|
||||||
|
<Play size={14} />
|
||||||
|
)}
|
||||||
{repairing ? '触发中...' : '触发修复'}
|
{repairing ? '触发中...' : '触发修复'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 重新尝试按钮 */}
|
{/* 重新尝试按钮 - 仅在修复失败时显示 */}
|
||||||
{canRetry && (
|
{canRetry && (
|
||||||
<button
|
<button
|
||||||
className="trigger-repair-btn btn-warning"
|
className="trigger-repair-btn"
|
||||||
onClick={handleRetry}
|
onClick={handleRetry}
|
||||||
disabled={retrying}
|
disabled={retrying}
|
||||||
|
style={{ background: 'var(--warning)', borderColor: 'var(--warning)' }}
|
||||||
>
|
>
|
||||||
{retrying ? <Loader2 size={14} className="spinner" /> : <Play size={14} />}
|
{retrying ? (
|
||||||
|
<Loader2 size={14} className="spinner" />
|
||||||
|
) : (
|
||||||
|
<Play size={14} />
|
||||||
|
)}
|
||||||
{retrying ? '重置中...' : '重新尝试'}
|
{retrying ? '重置中...' : '重新尝试'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 消息显示 */}
|
{/* 消息显示 */}
|
||||||
{prMessage && (
|
{prMessage && (
|
||||||
<span className={`action-message ${msgIsSuccess(prMessage) ? 'action-message--success' : 'action-message--error'}`}>
|
<span style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
color: prMessage.includes('✅') ? 'var(--success)' : 'var(--error)'
|
||||||
|
}}>
|
||||||
{prMessage}
|
{prMessage}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{repairMessage && (
|
{repairMessage && (
|
||||||
<span className={`action-message ${msgIsSuccess(repairMessage) ? 'action-message--success' : 'action-message--error'}`}>
|
<span style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
color: repairMessage.includes('成功') ? 'var(--success)' : 'var(--error)'
|
||||||
|
}}>
|
||||||
{repairMessage}
|
{repairMessage}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{retryMessage && (
|
{retryMessage && (
|
||||||
<span className={`action-message ${msgIsSuccess(retryMessage) ? 'action-message--success' : 'action-message--error'}`}>
|
<span style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
color: retryMessage.includes('✅') ? 'var(--success)' : 'var(--error)'
|
||||||
|
}}>
|
||||||
{retryMessage}
|
{retryMessage}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{reviewMessage && (
|
{reviewMessage && (
|
||||||
<span className={`action-message ${msgIsSuccess(reviewMessage) ? 'action-message--success' : 'action-message--error'}`}>
|
<span style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
color: reviewMessage.includes('确认') || reviewMessage.includes('驳回') ? 'var(--success)' : 'var(--error)'
|
||||||
|
}}>
|
||||||
{reviewMessage}
|
{reviewMessage}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!canTriggerRepair && !repairing && !hasPR && !canRetry && !canManualReview && (
|
{!canTriggerRepair && !repairing && !hasPR && !canRetry && !canManualReview && (
|
||||||
<span className="action-hint">
|
<span style={{ fontSize: '13px', color: 'var(--text-tertiary)' }}>
|
||||||
{!isRuntime
|
{!isRuntime
|
||||||
? 'CI/CD 和部署错误暂不支持自动修复'
|
? 'CI/CD 和部署错误暂不支持自动修复'
|
||||||
: '仅"新发现"或"修复失败"状态的缺陷可触发修复'}
|
: '仅"新发现"或"修复失败"状态的缺陷可触发修复'}
|
||||||
@ -452,8 +501,8 @@ export default function BugDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{bug.failure_reason && (
|
{bug.failure_reason && (
|
||||||
<div className="detail-card detail-card--error">
|
<div className="detail-card" style={{ borderLeft: '3px solid var(--error)' }}>
|
||||||
<div className="alert-card-header alert-card-header--error">
|
<div className="detail-section-title" style={{ marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '6px', color: 'var(--error)' }}>
|
||||||
<AlertTriangle size={14} />
|
<AlertTriangle size={14} />
|
||||||
修复失败原因
|
修复失败原因
|
||||||
</div>
|
</div>
|
||||||
@ -462,8 +511,8 @@ export default function BugDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{bug.rejection_reason && (
|
{bug.rejection_reason && (
|
||||||
<div className="detail-card detail-card--warning">
|
<div className="detail-card" style={{ borderLeft: '3px solid var(--warning)' }}>
|
||||||
<div className="alert-card-header alert-card-header--warning">
|
<div className="detail-section-title" style={{ marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '6px', color: 'var(--warning)' }}>
|
||||||
<AlertTriangle size={14} />
|
<AlertTriangle size={14} />
|
||||||
上次拒绝原因
|
上次拒绝原因
|
||||||
</div>
|
</div>
|
||||||
@ -472,7 +521,7 @@ export default function BugDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="detail-card">
|
<div className="detail-card">
|
||||||
<div className="detail-section-title">元数据</div>
|
<div className="detail-section-title" style={{ marginBottom: '12px' }}>元数据</div>
|
||||||
<table className="meta-table">
|
<table className="meta-table">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
@ -503,7 +552,7 @@ export default function BugDetail() {
|
|||||||
|
|
||||||
{repairHistory.length > 0 && (
|
{repairHistory.length > 0 && (
|
||||||
<div className="detail-card">
|
<div className="detail-card">
|
||||||
<div className="alert-card-header">
|
<div className="detail-section-title" style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
<History size={14} />
|
<History size={14} />
|
||||||
修复历史 ({repairHistory.length} 次尝试)
|
修复历史 ({repairHistory.length} 次尝试)
|
||||||
</div>
|
</div>
|
||||||
@ -533,7 +582,7 @@ export default function BugDetail() {
|
|||||||
{report.test_passed ? '通过' : '失败'}
|
{report.test_passed ? '通过' : '失败'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="failure-cell-wide">
|
<td style={{ maxWidth: '300px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{report.failure_reason || '-'}
|
{report.failure_reason || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="cell-secondary">
|
<td className="cell-secondary">
|
||||||
@ -554,23 +603,48 @@ export default function BugDetail() {
|
|||||||
|
|
||||||
{/* 拒绝原因模态框 */}
|
{/* 拒绝原因模态框 */}
|
||||||
{showRejectModal && (
|
{showRejectModal && (
|
||||||
<div className="modal-overlay" onClick={() => { setShowRejectModal(false); setRejectReason(''); }}>
|
<div style={{
|
||||||
<div className="modal-content reject-modal-content" onClick={(e) => e.stopPropagation()}>
|
position: 'fixed',
|
||||||
<h3 className="modal-title">{hasPR ? '拒绝修复' : '驳回修复'}</h3>
|
top: 0,
|
||||||
<p className="reject-modal-desc">
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0,0,0,0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-primary)',
|
||||||
|
padding: '24px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
maxWidth: '500px',
|
||||||
|
width: '90%',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
|
||||||
|
}}>
|
||||||
|
<h3 style={{ marginBottom: '16px' }}>{hasPR ? '拒绝修复' : '驳回修复'}</h3>
|
||||||
|
<p style={{ marginBottom: '12px', fontSize: '14px', color: 'var(--text-secondary)' }}>
|
||||||
请说明{hasPR ? '拒绝' : '驳回'}原因,Agent 将根据您的反馈重新修复:
|
请说明{hasPR ? '拒绝' : '驳回'}原因,Agent 将根据您的反馈重新修复:
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="reject-templates">
|
<div style={{ marginBottom: '12px' }}>
|
||||||
<label className="reject-templates-label">
|
<label style={{ fontSize: '13px', color: 'var(--text-tertiary)', marginBottom: '6px', display: 'block' }}>
|
||||||
常用模板(点击填充):
|
常用模板(点击填充):
|
||||||
</label>
|
</label>
|
||||||
<div className="reject-templates-list">
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||||
{REJECT_REASON_TEMPLATES.map((template, idx) => (
|
{REJECT_REASON_TEMPLATES.map((template, idx) => (
|
||||||
<button
|
<button
|
||||||
key={idx}
|
key={idx}
|
||||||
className="reject-template-btn"
|
|
||||||
onClick={() => setRejectReason(template)}
|
onClick={() => setRejectReason(template)}
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
padding: '4px 8px',
|
||||||
|
background: 'var(--bg-secondary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{template}
|
{template}
|
||||||
</button>
|
</button>
|
||||||
@ -579,26 +653,43 @@ export default function BugDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
className="reject-textarea"
|
|
||||||
value={rejectReason}
|
value={rejectReason}
|
||||||
onChange={(e) => setRejectReason(e.target.value)}
|
onChange={(e) => setRejectReason(e.target.value)}
|
||||||
placeholder="请输入详细的拒绝原因..."
|
placeholder="请输入详细的拒绝原因..."
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
minHeight: '120px',
|
||||||
|
padding: '8px',
|
||||||
|
fontSize: '14px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--bg-secondary)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
resize: 'vertical'
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="modal-actions">
|
<div style={{ display: 'flex', gap: '12px', marginTop: '16px', justifyContent: 'flex-end' }}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowRejectModal(false);
|
setShowRejectModal(false);
|
||||||
setRejectReason('');
|
setRejectReason('');
|
||||||
}}
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: 'var(--bg-secondary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="trigger-repair-btn btn-error"
|
|
||||||
onClick={hasPR ? handleClosePR : handleRejectFix}
|
onClick={hasPR ? handleClosePR : handleRejectFix}
|
||||||
disabled={(hasPR ? closingPR : rejecting) || !rejectReason.trim()}
|
disabled={(hasPR ? closingPR : rejecting) || !rejectReason.trim()}
|
||||||
|
className="trigger-repair-btn"
|
||||||
|
style={{ background: 'var(--error)', borderColor: 'var(--error)' }}
|
||||||
>
|
>
|
||||||
{(hasPR ? closingPR : rejecting) ? (
|
{(hasPR ? closingPR : rejecting) ? (
|
||||||
<Loader2 size={14} className="spinner" />
|
<Loader2 size={14} className="spinner" />
|
||||||
|
|||||||
@ -210,7 +210,7 @@ export default function BugList() {
|
|||||||
{STATUS_LABELS[bug.status] || bug.status}
|
{STATUS_LABELS[bug.status] || bug.status}
|
||||||
</span>
|
</span>
|
||||||
{bug.status === 'FIX_FAILED' && bug.failure_reason && (
|
{bug.status === 'FIX_FAILED' && bug.failure_reason && (
|
||||||
<div className="failure-hint">
|
<div style={{ fontSize: '11px', color: 'var(--error)', marginTop: '4px', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{bug.failure_reason}
|
{bug.failure_reason}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -232,6 +232,7 @@ export default function BugList() {
|
|||||||
to={`/bugs/${bug.id}`}
|
to={`/bugs/${bug.id}`}
|
||||||
state={{ fromSearch: searchParams.toString() }}
|
state={{ fromSearch: searchParams.toString() }}
|
||||||
className="mobile-card-item"
|
className="mobile-card-item"
|
||||||
|
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||||
>
|
>
|
||||||
<div className="mobile-card-top">
|
<div className="mobile-card-top">
|
||||||
<span className="mobile-card-id">#{bug.id}</span>
|
<span className="mobile-card-id">#{bug.id}</span>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Bug, CalendarPlus, AlertTriangle } from 'lucide-react';
|
import { Bug, CalendarPlus, TrendingUp, AlertTriangle } from 'lucide-react';
|
||||||
import { getStats, type DashboardStats } from '../api';
|
import { getStats, type DashboardStats } from '../api';
|
||||||
|
|
||||||
const SOURCE_LABELS: Record<string, string> = {
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
@ -20,103 +20,6 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
FIX_FAILED: '修复失败',
|
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">
|
|
||||||
<defs>
|
|
||||||
<filter id="glow">
|
|
||||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
|
||||||
<feMerge>
|
|
||||||
<feMergeNode in="blur" />
|
|
||||||
<feMergeNode in="SourceGraphic" />
|
|
||||||
</feMerge>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
<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"
|
|
||||||
filter="url(#glow)"
|
|
||||||
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() {
|
export default function Dashboard() {
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -147,19 +50,14 @@ export default function Dashboard() {
|
|||||||
return <div className="loading">加载统计数据失败</div>;
|
return <div className="loading">加载统计数据失败</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingCount = (stats.status_distribution['NEW'] || 0)
|
|
||||||
+ (stats.status_distribution['PENDING_FIX'] || 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1 className="page-title">仪表盘</h1>
|
<h1 className="page-title">仪表盘</h1>
|
||||||
<p className="page-subtitle">错误追踪与自动修复系统概览</p>
|
<p className="page-subtitle">错误追踪系统概览</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hero: stats + fix rate ring */}
|
<div className="stats-grid">
|
||||||
<div className="dashboard-hero">
|
|
||||||
<div className="stats-grid stats-grid-3">
|
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-icon accent">
|
<div className="stat-icon accent">
|
||||||
<Bug size={18} />
|
<Bug size={18} />
|
||||||
@ -174,43 +72,77 @@ export default function Dashboard() {
|
|||||||
<div className="stat-label">今日新增</div>
|
<div className="stat-label">今日新增</div>
|
||||||
<div className="stat-value warning">{stats.today_bugs}</div>
|
<div className="stat-value warning">{stats.today_bugs}</div>
|
||||||
</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-card">
|
||||||
<div className="stat-icon error">
|
<div className="stat-icon error">
|
||||||
<AlertTriangle size={18} />
|
<AlertTriangle size={18} />
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-label">待修复</div>
|
<div className="stat-label">待修复</div>
|
||||||
<div className="stat-value error">{pendingCount}</div>
|
<div className="stat-value error">
|
||||||
|
{(stats.status_distribution['NEW'] || 0) +
|
||||||
|
(stats.status_distribution['PENDING_FIX'] || 0)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="dashboard-ring-card">
|
|
||||||
<FixRateRing rate={stats.fix_rate} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts: status + source distribution */}
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||||
<div className="dashboard-charts">
|
<div className="table-container table-compact">
|
||||||
<div className="chart-card">
|
<div className="table-header">
|
||||||
<div className="chart-card-header">
|
<h3 className="table-title">状态分布</h3>
|
||||||
<h3 className="chart-card-title">状态分布</h3>
|
|
||||||
<span className="chart-card-total">共 {stats.total_bugs} 个</span>
|
|
||||||
</div>
|
</div>
|
||||||
<BarChart
|
<table>
|
||||||
data={stats.status_distribution}
|
<thead>
|
||||||
labels={STATUS_LABELS}
|
<tr>
|
||||||
colors={STATUS_COLORS}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{stats.source_distribution && (
|
{stats.source_distribution && (
|
||||||
<div className="chart-card">
|
<div className="table-container table-compact">
|
||||||
<div className="chart-card-header">
|
<div className="table-header">
|
||||||
<h3 className="chart-card-title">来源分布</h3>
|
<h3 className="table-title">来源分布</h3>
|
||||||
</div>
|
</div>
|
||||||
<BarChart
|
<table>
|
||||||
data={stats.source_distribution}
|
<thead>
|
||||||
labels={SOURCE_LABELS}
|
<tr>
|
||||||
colors={SOURCE_COLORS}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { getProjects, updateProject, deleteProject, toggleProjectRepair, type Project } from '../api';
|
import { getProjects, updateProject, type Project } from '../api';
|
||||||
import { Save, X, Pencil, Trash2, Wrench, ShieldCheck, ShieldOff } from 'lucide-react';
|
import { Save, X, Pencil } from 'lucide-react';
|
||||||
|
|
||||||
export default function ProjectList() {
|
export default function ProjectList() {
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
@ -8,12 +8,6 @@ export default function ProjectList() {
|
|||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
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);
|
||||||
const [togglingId, setTogglingId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// 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);
|
||||||
@ -56,48 +50,6 @@ 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('');
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
||||||
@ -110,34 +62,127 @@ export default function ProjectList() {
|
|||||||
return <div className="loading"><div className="spinner"></div></div>;
|
return <div className="loading"><div className="spinner"></div></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const enabledCount = projects.filter(p => p.repair_enabled).length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div className="title-row">
|
|
||||||
<div>
|
|
||||||
<h1 className="page-title">项目管理</h1>
|
<h1 className="page-title">项目管理</h1>
|
||||||
<p className="page-subtitle">管理项目配置,控制 Repair Agent 的自动修复范围</p>
|
<p className="page-subtitle">管理项目的仓库地址和本地路径,供 Repair Agent 使用</p>
|
||||||
</div>
|
|
||||||
<div className="repair-summary">
|
|
||||||
<Wrench size={14} />
|
|
||||||
<span>{enabledCount}/{projects.length} 个项目已启用自动修复</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{projects.length === 0 ? (
|
{projects.length === 0 ? (
|
||||||
<div className="empty-state">暂无项目,首次上报日志后会自动创建</div>
|
<div className="empty-state">暂无项目,首次上报日志后会自动创建</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="project-cards-grid">
|
<>
|
||||||
|
{/* 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) => (
|
{projects.map((p) => (
|
||||||
<div key={p.id} className={`project-card ${!p.repair_enabled ? 'project-card-disabled' : ''}`}>
|
<tr key={p.id}>
|
||||||
{editingId === p.id ? (
|
{editingId === p.id ? (
|
||||||
<div className="project-card-edit">
|
<>
|
||||||
<div className="project-card-edit-header">
|
<td><strong>{p.project_id}</strong></td>
|
||||||
<strong>{p.project_id}</strong>
|
<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>
|
</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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editForm.name}
|
value={editForm.name}
|
||||||
@ -166,117 +211,47 @@ export default function ProjectList() {
|
|||||||
placeholder="描述"
|
placeholder="描述"
|
||||||
className="edit-input"
|
className="edit-input"
|
||||||
/>
|
/>
|
||||||
<div className="project-card-edit-actions">
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn-link"
|
||||||
onClick={() => saveEdit(p.project_id)}
|
onClick={() => saveEdit(p.project_id)}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
|
style={{ color: 'var(--success)' }}
|
||||||
>
|
>
|
||||||
<Save size={14} />
|
<Save size={14} /> 保存
|
||||||
{saving ? '保存中...' : '保存'}
|
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-secondary" onClick={cancelEdit}>
|
<button
|
||||||
<X size={14} />
|
className="btn-link"
|
||||||
取消
|
onClick={cancelEdit}
|
||||||
|
style={{ color: 'var(--text-tertiary)' }}
|
||||||
|
>
|
||||||
|
<X size={14} /> 取消
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="project-card-header">
|
<div className="mobile-card-top">
|
||||||
<div className="project-card-title">
|
|
||||||
<strong>{p.project_id}</strong>
|
<strong>{p.project_id}</strong>
|
||||||
{p.name && <span className="project-card-name">{p.name}</span>}
|
<button className="btn-link" onClick={() => startEdit(p)}>
|
||||||
</div>
|
|
||||||
<div className="project-card-actions">
|
|
||||||
<button className="btn-ghost btn-icon" onClick={() => startEdit(p)} title="编辑">
|
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
className="btn-ghost btn-icon btn-icon-danger"
|
|
||||||
onClick={() => openDeleteConfirm(p)}
|
|
||||||
title="删除"
|
|
||||||
>
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{p.description && (
|
{p.description && (
|
||||||
<p className="project-card-desc">{p.description}</p>
|
<div style={{ fontSize: '12px', color: 'var(--text-tertiary)', marginTop: '4px' }}>{p.description}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete confirmation modal */}
|
|
||||||
{deleteTarget && (
|
|
||||||
<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
|
|
||||||
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 className="modal-actions">
|
|
||||||
<button className="btn btn-secondary" onClick={closeDeleteConfirm}>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-danger"
|
|
||||||
onClick={confirmDelete}
|
|
||||||
disabled={!isDeleteConfirmed || deleting}
|
|
||||||
style={{ opacity: isDeleteConfirmed ? 1 : 0.4 }}
|
|
||||||
>
|
|
||||||
{deleting ? '删除中...' : '确认删除'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -27,6 +27,7 @@ export default function RepairDetail() {
|
|||||||
const [report, setReport] = useState<RepairReport | null>(null);
|
const [report, setReport] = useState<RepairReport | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// 审核操作状态
|
||||||
const [approving, setApproving] = useState(false);
|
const [approving, setApproving] = useState(false);
|
||||||
const [rejecting, setRejecting] = useState(false);
|
const [rejecting, setRejecting] = useState(false);
|
||||||
const [actionMessage, setActionMessage] = useState('');
|
const [actionMessage, setActionMessage] = useState('');
|
||||||
@ -101,9 +102,6 @@ export default function RepairDetail() {
|
|||||||
const isPendingReview = report.status === 'PENDING_FIX';
|
const isPendingReview = report.status === 'PENDING_FIX';
|
||||||
const hasPR = !!report.pr_url;
|
const hasPR = !!report.pr_url;
|
||||||
|
|
||||||
const msgIsSuccess = (msg: string) =>
|
|
||||||
msg.includes('批准') || msg.includes('驳回');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Link to="/repairs" className="back-link">
|
<Link to="/repairs" className="back-link">
|
||||||
@ -111,7 +109,7 @@ export default function RepairDetail() {
|
|||||||
返回修复报告列表
|
返回修复报告列表
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="report-header">
|
<div style={{ marginBottom: '20px' }}>
|
||||||
<div className="title-row">
|
<div className="title-row">
|
||||||
<h1 className="page-title">修复报告 #{report.id}</h1>
|
<h1 className="page-title">修复报告 #{report.id}</h1>
|
||||||
<span className={`status-badge status-${report.status}`}>
|
<span className={`status-badge status-${report.status}`}>
|
||||||
@ -155,13 +153,13 @@ export default function RepairDetail() {
|
|||||||
|
|
||||||
{/* PR 信息 */}
|
{/* PR 信息 */}
|
||||||
{hasPR && (
|
{hasPR && (
|
||||||
<div className="card card--accent">
|
<div className="card" style={{ borderLeft: '3px solid var(--accent)' }}>
|
||||||
<h2>Pull Request</h2>
|
<h2>Pull Request</h2>
|
||||||
<div className="pr-section-row">
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' }}>
|
||||||
<span className="pr-section-id">
|
<span style={{ fontSize: '14px' }}>
|
||||||
PR #{report.pr_number} | {report.branch_name || 'fix branch'}
|
PR #{report.pr_number} | {report.branch_name || 'fix branch'}
|
||||||
</span>
|
</span>
|
||||||
<a href={report.pr_url!} target="_blank" rel="noopener noreferrer" className="btn-link pr-section-link">
|
<a href={report.pr_url!} target="_blank" rel="noopener noreferrer" className="btn-link" style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||||
查看 PR <ExternalLink size={12} />
|
查看 PR <ExternalLink size={12} />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -170,34 +168,43 @@ export default function RepairDetail() {
|
|||||||
|
|
||||||
{/* 审核操作区 */}
|
{/* 审核操作区 */}
|
||||||
{isPendingReview && (
|
{isPendingReview && (
|
||||||
<div className="card card--warning">
|
<div className="card" style={{ borderLeft: '3px solid var(--warning)' }}>
|
||||||
<h2>审核操作</h2>
|
<h2>审核操作</h2>
|
||||||
<p className="review-section-desc">
|
<p style={{ fontSize: '14px', color: 'var(--text-secondary)', marginBottom: '12px' }}>
|
||||||
{hasPR
|
{hasPR
|
||||||
? '批准将合并 PR 并标记所有关联缺陷为已修复'
|
? '批准将合并 PR 并标记所有关联缺陷为已修复'
|
||||||
: '确认修复将标记所有关联缺陷为已修复'}
|
: '确认修复将标记所有关联缺陷为已修复'}
|
||||||
</p>
|
</p>
|
||||||
<div className="review-actions">
|
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
<button
|
<button
|
||||||
className="trigger-repair-btn btn-success"
|
className="trigger-repair-btn"
|
||||||
onClick={handleApprove}
|
onClick={handleApprove}
|
||||||
disabled={approving}
|
disabled={approving}
|
||||||
|
style={{ background: 'var(--success)', borderColor: 'var(--success)' }}
|
||||||
>
|
>
|
||||||
{approving ? <Loader2 size={14} className="spinner" /> : <Check size={14} />}
|
{approving ? (
|
||||||
|
<Loader2 size={14} className="spinner" />
|
||||||
|
) : (
|
||||||
|
<Check size={14} />
|
||||||
|
)}
|
||||||
{approving ? '处理中...' : (hasPR ? '批准并合并' : '确认修复')}
|
{approving ? '处理中...' : (hasPR ? '批准并合并' : '确认修复')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="trigger-repair-btn btn-error"
|
className="trigger-repair-btn"
|
||||||
onClick={() => setShowRejectModal(true)}
|
onClick={() => setShowRejectModal(true)}
|
||||||
disabled={rejecting}
|
disabled={rejecting}
|
||||||
|
style={{ background: 'var(--error)', borderColor: 'var(--error)' }}
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
{hasPR ? '拒绝修复' : '驳回修复'}
|
{hasPR ? '拒绝修复' : '驳回修复'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{actionMessage && (
|
{actionMessage && (
|
||||||
<span className={`action-message ${msgIsSuccess(actionMessage) ? 'action-message--success' : 'action-message--error'}`}>
|
<span style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
color: actionMessage.includes('批准') || actionMessage.includes('驳回') ? 'var(--success)' : 'var(--error)'
|
||||||
|
}}>
|
||||||
{actionMessage}
|
{actionMessage}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -206,7 +213,7 @@ export default function RepairDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{report.failure_reason && (
|
{report.failure_reason && (
|
||||||
<div className="card card--error">
|
<div className="card" style={{ borderLeft: '3px solid var(--error)' }}>
|
||||||
<h2><AlertTriangle size={16} /> 失败原因</h2>
|
<h2><AlertTriangle size={16} /> 失败原因</h2>
|
||||||
<pre className="code-block error">{report.failure_reason}</pre>
|
<pre className="code-block error">{report.failure_reason}</pre>
|
||||||
</div>
|
</div>
|
||||||
@ -229,23 +236,48 @@ export default function RepairDetail() {
|
|||||||
|
|
||||||
{/* 驳回原因模态框 */}
|
{/* 驳回原因模态框 */}
|
||||||
{showRejectModal && (
|
{showRejectModal && (
|
||||||
<div className="modal-overlay" onClick={() => { setShowRejectModal(false); setRejectReason(''); }}>
|
<div style={{
|
||||||
<div className="modal-content reject-modal-content" onClick={(e) => e.stopPropagation()}>
|
position: 'fixed',
|
||||||
<h3 className="modal-title">{hasPR ? '拒绝修复' : '驳回修复'}</h3>
|
top: 0,
|
||||||
<p className="reject-modal-desc">
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0,0,0,0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-primary)',
|
||||||
|
padding: '24px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
maxWidth: '500px',
|
||||||
|
width: '90%',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
|
||||||
|
}}>
|
||||||
|
<h3 style={{ marginBottom: '16px' }}>{hasPR ? '拒绝修复' : '驳回修复'}</h3>
|
||||||
|
<p style={{ marginBottom: '12px', fontSize: '14px', color: 'var(--text-secondary)' }}>
|
||||||
请说明原因,Agent 将根据您的反馈重新修复所有关联缺陷:
|
请说明原因,Agent 将根据您的反馈重新修复所有关联缺陷:
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="reject-templates">
|
<div style={{ marginBottom: '12px' }}>
|
||||||
<label className="reject-templates-label">
|
<label style={{ fontSize: '13px', color: 'var(--text-tertiary)', marginBottom: '6px', display: 'block' }}>
|
||||||
常用模板(点击填充):
|
常用模板(点击填充):
|
||||||
</label>
|
</label>
|
||||||
<div className="reject-templates-list">
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||||
{REJECT_REASON_TEMPLATES.map((template, idx) => (
|
{REJECT_REASON_TEMPLATES.map((template, idx) => (
|
||||||
<button
|
<button
|
||||||
key={idx}
|
key={idx}
|
||||||
className="reject-template-btn"
|
|
||||||
onClick={() => setRejectReason(template)}
|
onClick={() => setRejectReason(template)}
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
padding: '4px 8px',
|
||||||
|
background: 'var(--bg-secondary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{template}
|
{template}
|
||||||
</button>
|
</button>
|
||||||
@ -254,28 +286,49 @@ export default function RepairDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
className="reject-textarea"
|
|
||||||
value={rejectReason}
|
value={rejectReason}
|
||||||
onChange={(e) => setRejectReason(e.target.value)}
|
onChange={(e) => setRejectReason(e.target.value)}
|
||||||
placeholder="请输入详细原因..."
|
placeholder="请输入详细原因..."
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
minHeight: '120px',
|
||||||
|
padding: '8px',
|
||||||
|
fontSize: '14px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--bg-secondary)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
resize: 'vertical'
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="modal-actions">
|
<div style={{ display: 'flex', gap: '12px', marginTop: '16px', justifyContent: 'flex-end' }}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowRejectModal(false);
|
setShowRejectModal(false);
|
||||||
setRejectReason('');
|
setRejectReason('');
|
||||||
}}
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: 'var(--bg-secondary)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="trigger-repair-btn btn-error"
|
|
||||||
onClick={handleReject}
|
onClick={handleReject}
|
||||||
disabled={rejecting || !rejectReason.trim()}
|
disabled={rejecting || !rejectReason.trim()}
|
||||||
|
className="trigger-repair-btn"
|
||||||
|
style={{ background: 'var(--error)', borderColor: 'var(--error)' }}
|
||||||
>
|
>
|
||||||
{rejecting ? <Loader2 size={14} className="spinner" /> : <X size={14} />}
|
{rejecting ? (
|
||||||
|
<Loader2 size={14} className="spinner" />
|
||||||
|
) : (
|
||||||
|
<X size={14} />
|
||||||
|
)}
|
||||||
{rejecting ? '提交中...' : '确认驳回'}
|
{rejecting ? '提交中...' : '确认驳回'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -135,7 +135,7 @@ export default function RepairList() {
|
|||||||
{STATUS_LABELS[report.status] || report.status}
|
{STATUS_LABELS[report.status] || report.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="failure-cell">
|
<td style={{ maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: '12px', color: 'var(--error)' }}>
|
||||||
{report.failure_reason || '-'}
|
{report.failure_reason || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="cell-secondary">
|
<td className="cell-secondary">
|
||||||
@ -159,6 +159,7 @@ export default function RepairList() {
|
|||||||
key={report.id}
|
key={report.id}
|
||||||
to={`/repairs/${report.id}`}
|
to={`/repairs/${report.id}`}
|
||||||
className="mobile-card-item"
|
className="mobile-card-item"
|
||||||
|
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||||
>
|
>
|
||||||
<div className="mobile-card-top">
|
<div className="mobile-card-top">
|
||||||
<span className="mobile-card-id">#{report.id}</span>
|
<span className="mobile-card-id">#{report.id}</span>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user