Compare commits

..

No commits in common. "main" and "fix/auto-20260228-144441" have entirely different histories.

18 changed files with 563 additions and 1427 deletions

View File

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

View File

@ -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)",
@ -88,7 +75,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 +85,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 +111,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 +121,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 +137,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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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},回退到本地配置")

View File

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

View File

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

View File

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

View File

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

View File

@ -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" />

View File

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

View File

@ -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,70 +50,99 @@ 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="stat-card">
<div className="stats-grid stats-grid-3"> <div className="stat-icon accent">
<div className="stat-card"> <Bug size={18} />
<div className="stat-icon accent">
<Bug size={18} />
</div>
<div className="stat-label"></div>
<div className="stat-value accent">{stats.total_bugs}</div>
</div>
<div className="stat-card">
<div className="stat-icon warning">
<CalendarPlus size={18} />
</div>
<div className="stat-label"></div>
<div className="stat-value warning">{stats.today_bugs}</div>
</div>
<div className="stat-card">
<div className="stat-icon error">
<AlertTriangle size={18} />
</div>
<div className="stat-label"></div>
<div className="stat-value error">{pendingCount}</div>
</div> </div>
<div className="stat-label"></div>
<div className="stat-value accent">{stats.total_bugs}</div>
</div> </div>
<div className="dashboard-ring-card"> <div className="stat-card">
<FixRateRing rate={stats.fix_rate} /> <div className="stat-icon warning">
<CalendarPlus size={18} />
</div>
<div className="stat-label"></div>
<div className="stat-value warning">{stats.today_bugs}</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-icon error">
<AlertTriangle size={18} />
</div>
<div className="stat-label"></div>
<div className="stat-value error">
{(stats.status_distribution['NEW'] || 0) +
(stats.status_distribution['PENDING_FIX'] || 0)}
</div>
</div> </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>

View File

@ -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,173 +62,196 @@ 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"> <h1 className="page-title"></h1>
<div> <p className="page-subtitle"> Repair Agent 使</p>
<h1 className="page-title"></h1>
<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"> <>
{projects.map((p) => ( {/* Desktop table */}
<div key={p.id} className={`project-card ${!p.repair_enabled ? 'project-card-disabled' : ''}`}> <div className="table-container">
{editingId === p.id ? ( <div className="table-desktop">
<div className="project-card-edit"> <table>
<div className="project-card-edit-header"> <thead>
<strong>{p.project_id}</strong> <tr>
</div> <th> ID</th>
<input <th></th>
type="text" <th></th>
value={editForm.name} <th></th>
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} <th></th>
placeholder="项目名称" <th></th>
className="edit-input" <th></th>
/> </tr>
<input </thead>
type="text" <tbody>
value={editForm.repo_url} {projects.map((p) => (
onChange={(e) => setEditForm({ ...editForm, repo_url: e.target.value })} <tr key={p.id}>
placeholder="仓库地址" {editingId === p.id ? (
className="edit-input" <>
/> <td><strong>{p.project_id}</strong></td>
<input <td>
type="text" <input
value={editForm.local_path} type="text"
onChange={(e) => setEditForm({ ...editForm, local_path: e.target.value })} value={editForm.name}
placeholder="本地路径" onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
className="edit-input" placeholder="项目名称"
/> className="edit-input"
<input />
type="text" </td>
value={editForm.description} <td>
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })} <input
placeholder="描述" type="text"
className="edit-input" value={editForm.repo_url}
/> onChange={(e) => setEditForm({ ...editForm, repo_url: e.target.value })}
<div className="project-card-edit-actions"> placeholder="https://gitea.example.com/..."
<button className="edit-input"
className="btn btn-primary" />
onClick={() => saveEdit(p.project_id)} </td>
disabled={saving} <td>
> <input
<Save size={14} /> type="text"
{saving ? '保存中...' : '保存'} value={editForm.local_path}
</button> onChange={(e) => setEditForm({ ...editForm, local_path: e.target.value })}
<button className="btn btn-secondary" onClick={cancelEdit}> placeholder="/home/user/projects/..."
<X size={14} /> className="edit-input"
/>
</button> </td>
</div> <td>
</div> <input
) : ( type="text"
<> value={editForm.description}
<div className="project-card-header"> onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
<div className="project-card-title"> placeholder="项目描述"
<strong>{p.project_id}</strong> className="edit-input"
{p.name && <span className="project-card-name">{p.name}</span>} />
</div> </td>
<div className="project-card-actions"> <td className="cell-secondary">
<button className="btn-ghost btn-icon" onClick={() => startEdit(p)} title="编辑"> {new Date(p.updated_at).toLocaleString()}
<Pencil size={14} /> </td>
</button> <td>
<button <div style={{ display: 'flex', gap: '6px' }}>
className="btn-ghost btn-icon btn-icon-danger" <button
onClick={() => openDeleteConfirm(p)} className="btn-link"
title="删除" onClick={() => saveEdit(p.project_id)}
> disabled={saving}
<Trash2 size={14} /> style={{ color: 'var(--success)' }}
</button> >
</div> <Save size={14} />
</div> </button>
<button
{p.description && ( className="btn-link"
<p className="project-card-desc">{p.description}</p> onClick={cancelEdit}
)} style={{ color: 'var(--text-tertiary)' }}
>
<div className="project-card-info"> <X size={14} />
<div className="project-info-item"> </button>
<span className="project-info-label"></span> </div>
<ConfigBadge value={p.repo_url} /> </td>
</div> </>
<div className="project-info-item"> ) : (
<span className="project-info-label"></span> <>
<ConfigBadge value={p.local_path} /> <td><strong>{p.project_id}</strong></td>
</div> <td>{p.name || <span style={{ color: 'var(--text-tertiary)' }}>-</span>}</td>
</div> <td><ConfigBadge value={p.repo_url} /></td>
<td><ConfigBadge value={p.local_path} /></td>
<div className="project-card-footer"> <td className="cell-secondary">{p.description || '-'}</td>
<button <td className="cell-secondary">
className={`repair-toggle ${p.repair_enabled ? 'repair-toggle-on' : 'repair-toggle-off'}`} {new Date(p.updated_at).toLocaleString()}
onClick={() => handleToggleRepair(p)} </td>
disabled={togglingId === p.project_id} <td>
> <button className="btn-link" onClick={() => startEdit(p)}>
<div className="repair-toggle-track"> <Pencil size={14} />
<div className="repair-toggle-thumb" /> </button>
</div> </td>
<span className="repair-toggle-label"> </>
{p.repair_enabled ? ( )}
<><ShieldCheck size={13} /> </> </tr>
) : ( ))}
<><ShieldOff size={13} /> </> </tbody>
)} </table>
</span>
</button>
<span className="project-card-time">
{new Date(p.updated_at).toLocaleDateString()}
</span>
</div>
</>
)}
</div> </div>
))}
</div>
)}
{/* Delete confirmation modal */} {/* Mobile card list */}
{deleteTarget && ( <div className="mobile-card-list">
<div className="modal-overlay" onClick={closeDeleteConfirm}> {projects.map((p) => (
<div className="modal-content" onClick={(e) => e.stopPropagation()}> <div key={p.id} className="mobile-card-item">
<h3 className="modal-title"></h3> {editingId === p.id ? (
<p className="modal-desc"> <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
ID <strong style={{ color: 'var(--error)' }}>{deleteTarget.project_id}</strong> <strong>{p.project_id}</strong>
</p> <input
<input type="text"
type="text" value={editForm.name}
value={deleteConfirmName} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
onChange={(e) => setDeleteConfirmName(e.target.value)} placeholder="项目名称"
placeholder={deleteTarget.project_id} className="edit-input"
className="edit-input" />
style={{ width: '100%', marginBottom: '16px', boxSizing: 'border-box' }} <input
autoFocus type="text"
/> value={editForm.repo_url}
<div className="modal-actions"> onChange={(e) => setEditForm({ ...editForm, repo_url: e.target.value })}
<button className="btn btn-secondary" onClick={closeDeleteConfirm}> placeholder="仓库地址"
className="edit-input"
</button> />
<button <input
className="btn btn-danger" type="text"
onClick={confirmDelete} value={editForm.local_path}
disabled={!isDeleteConfirmed || deleting} onChange={(e) => setEditForm({ ...editForm, local_path: e.target.value })}
style={{ opacity: isDeleteConfirmed ? 1 : 0.4 }} placeholder="本地路径"
> className="edit-input"
{deleting ? '删除中...' : '确认删除'} />
</button> <input
type="text"
value={editForm.description}
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
placeholder="描述"
className="edit-input"
/>
<div style={{ display: 'flex', gap: '8px' }}>
<button
className="btn-link"
onClick={() => saveEdit(p.project_id)}
disabled={saving}
style={{ color: 'var(--success)' }}
>
<Save size={14} />
</button>
<button
className="btn-link"
onClick={cancelEdit}
style={{ color: 'var(--text-tertiary)' }}
>
<X size={14} />
</button>
</div>
</div>
) : (
<>
<div className="mobile-card-top">
<strong>{p.project_id}</strong>
<button className="btn-link" onClick={() => startEdit(p)}>
<Pencil size={14} />
</button>
</div>
{p.name && <div style={{ fontSize: '13px' }}>{p.name}</div>}
<div className="mobile-card-meta" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: '4px', marginTop: '6px' }}>
<div>: <ConfigBadge value={p.repo_url} /></div>
<div>: <ConfigBadge value={p.local_path} /></div>
</div>
{p.description && (
<div style={{ fontSize: '12px', color: 'var(--text-tertiary)', marginTop: '4px' }}>{p.description}</div>
)}
</>
)}
</div>
))}
</div> </div>
</div> </div>
</div> </>
)} )}
</div> </div>
); );

View File

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

View File

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