feat(frontend): 项目管理界面代码优化

This commit is contained in:
Xingjing Deng
2026-01-07 14:41:13 +08:00
parent b6c1f90f83
commit 0360b30b03
9 changed files with 762 additions and 289 deletions
+34 -289
View File
@@ -13,19 +13,16 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Progress } from "@/components/ui/progress";
import {
ArrowLeft,
Edit,
ExternalLink,
Code,
Shield,
Activity,
AlertTriangle,
CheckCircle,
Clock,
XCircle,
Play,
FileText,
Upload,
GitBranch,
@@ -45,6 +42,15 @@ import FileSelectionDialog from "@/components/audit/FileSelectionDialog";
import TerminalProgressDialog from "@/components/audit/TerminalProgressDialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { SUPPORTED_LANGUAGES, REPOSITORY_PLATFORMS } from "@/shared/constants";
import type { AggregatedAgentFinding, AggregatedAuditIssue, IssuesSummary, LatestProblem, UnifiedTask } from "@/shared/types";
import {
PROJECT_DETAIL_ISSUES_FETCH_CONCURRENCY as ISSUES_FETCH_CONCURRENCY,
PROJECT_DETAIL_ISSUES_MAX_TASKS as ISSUES_MAX_TASKS,
PROJECT_DETAIL_REQUEST_TIMEOUT_MS as REQUEST_TIMEOUT_MS
} from "@/shared/constants";
import { ProjectIssuesTab } from "@/pages/project-detail/components/ProjectIssuesTab";
import { ProjectTasksTab } from "@/pages/project-detail/components/ProjectTasksTab";
import { ProjectStatsCards, type ProjectCombinedStats } from "@/pages/project-detail/components/ProjectStatsCards";
export default function ProjectDetail() {
const { id } = useParams<{ id: string }>();
@@ -66,27 +72,10 @@ export default function ProjectDetail() {
programming_languages: []
});
const [activeTab, setActiveTab] = useState("overview");
type AggregatedAuditIssue = AuditIssue & {
task_created_at?: string;
task_completed_at?: string;
};
const [latestIssues, setLatestIssues] = useState<AggregatedAuditIssue[]>([]);
type AggregatedAgentFinding = AgentFinding & {
task_created_at?: string;
task_completed_at?: string | null;
};
const [latestFindings, setLatestFindings] = useState<AggregatedAgentFinding[]>([]);
const [loadingIssues, setLoadingIssues] = useState(false);
const [issuesSummary, setIssuesSummary] = useState<{
completedAuditTasksCount: number;
completedAgentTasksCount: number;
fetchedAuditTasksCount: number;
fetchedAgentTasksCount: number;
isLimited: boolean;
maxTasks: number;
}>({
const [issuesSummary, setIssuesSummary] = useState<IssuesSummary>({
completedAuditTasksCount: 0,
completedAgentTasksCount: 0,
fetchedAuditTasksCount: 0,
@@ -99,9 +88,6 @@ export default function ProjectDetail() {
const [showAuditOptionsDialog, setShowAuditOptionsDialog] = useState(false);
// ============ Helpers ============
const REQUEST_TIMEOUT_MS = 12_000;
const ISSUES_MAX_TASKS = 20;
const ISSUES_FETCH_CONCURRENCY = 5;
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timeoutId: number | undefined;
@@ -184,8 +170,8 @@ export default function ProjectDetail() {
return;
}
setLoadingIssues(true);
try {
setLoadingIssues(true);
try {
const [issuesResults, findingsResults] = await Promise.all([
mapWithConcurrency(limitedAuditTasks, ISSUES_FETCH_CONCURRENCY, async (task: AuditTask) => {
const issues = await fetchAuditIssues(task.id);
@@ -244,27 +230,12 @@ export default function ProjectDetail() {
return taskCreatedAtB - taskCreatedAtA;
});
setLatestFindings(flatFindings);
} catch (error) {
console.error('Failed to load issues:', error);
toast.error("加载问题列表失败");
} finally {
setLoadingIssues(false);
}
};
type LatestProblem = {
kind: 'audit' | 'agent';
id: string;
task_id: string;
task_created_at?: string;
created_at: string;
severity: 'critical' | 'high' | 'medium' | 'low';
title: string;
description?: string | null;
file_path?: string | null;
line_number?: number | null;
line_end?: number | null;
category?: string | null;
} catch (error) {
console.error('Failed to load issues:', error);
toast.error("加载问题列表失败");
} finally {
setLoadingIssues(false);
}
};
const latestProblems: LatestProblem[] = useMemo(() => {
@@ -450,10 +421,6 @@ export default function ProjectDetail() {
}
};
type UnifiedTask =
| { kind: 'audit'; task: AuditTask }
| { kind: 'agent'; task: AgentTask };
const unifiedTasks: UnifiedTask[] = useMemo(() => {
const merged: UnifiedTask[] = [
...auditTasks.map((t) => ({ kind: 'audit' as const, task: t })),
@@ -463,7 +430,7 @@ export default function ProjectDetail() {
return merged;
}, [auditTasks, agentTasks]);
const combinedStats = useMemo(() => {
const combinedStats: ProjectCombinedStats = useMemo(() => {
const totalTasks = auditTasks.length + agentTasks.length;
const completedTasks =
auditTasks.filter((t) => t.status === 'completed').length +
@@ -706,55 +673,7 @@ export default function ProjectDetail() {
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 relative z-10">
<div className="cyber-card p-4">
<div className="flex items-center justify-between">
<div>
<p className="stat-label"></p>
<p className="stat-value">{combinedStats.totalTasks}</p>
</div>
<div className="stat-icon text-sky-400">
<Activity className="w-6 h-6" />
</div>
</div>
</div>
<div className="cyber-card p-4">
<div className="flex items-center justify-between">
<div>
<p className="stat-label"></p>
<p className="stat-value">{combinedStats.completedTasks}</p>
</div>
<div className="stat-icon text-emerald-400">
<CheckCircle className="w-6 h-6" />
</div>
</div>
</div>
<div className="cyber-card p-4">
<div className="flex items-center justify-between">
<div>
<p className="stat-label"></p>
<p className="stat-value">{combinedStats.totalIssues}</p>
</div>
<div className="stat-icon text-amber-400">
<AlertTriangle className="w-6 h-6" />
</div>
</div>
</div>
<div className="cyber-card p-4">
<div className="flex items-center justify-between">
<div>
<p className="stat-label"></p>
<p className="stat-value">{combinedStats.avgQualityScore.toFixed(1)}</p>
</div>
<div className="stat-icon text-violet-400">
<Code className="w-6 h-6" />
</div>
</div>
</div>
</div>
<ProjectStatsCards stats={combinedStats} />
{/* 主要内容 */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full relative z-10">
@@ -894,197 +813,23 @@ export default function ProjectDetail() {
</TabsContent>
<TabsContent value="tasks" className="flex flex-col gap-6 mt-6">
<div className="flex items-center justify-between">
<div className="section-header mb-0 pb-0 border-0">
<FileText className="w-5 h-5 text-primary" />
<h3 className="section-title"></h3>
</div>
<Button onClick={handleCreateTask} className="cyber-btn-primary">
<Play className="w-4 h-4 mr-2" />
</Button>
</div>
{unifiedTasks.length > 0 ? (
<div className="space-y-4">
{unifiedTasks.map((t) => {
const isAudit = t.kind === 'audit';
const task = t.task as any;
const issuesOrFindings = isAudit ? (task.issues_count ?? 0) : (task.findings_count ?? 0);
const totalFiles = task.total_files ?? 0;
const totalLines = task.total_lines ?? '-';
const qualityScore = typeof task.quality_score === 'number' ? task.quality_score : 0;
return (
<div key={`${t.kind}:${task.id}`} className="cyber-card p-6">
<div className="flex items-center justify-between mb-4 pb-4 border-b border-border">
<div className="flex items-center space-x-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${task.status === 'completed' ? 'bg-emerald-500/20' :
task.status === 'running' ? 'bg-sky-500/20' :
task.status === 'failed' ? 'bg-rose-500/20' :
'bg-muted'
}`}>
{getStatusIcon(task.status)}
</div>
<div>
<h4 className="font-bold text-foreground uppercase">
{isAudit
? (task.task_type === 'repository' ? '审计任务' : '即时分析任务')
: 'Agent 审计任务'}
</h4>
<p className="text-sm text-muted-foreground font-mono">
{formatDate(task.created_at)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge className={t.kind === 'agent' ? 'cyber-badge-info' : 'cyber-badge-muted'}>
{t.kind === 'agent' ? 'AGENT' : 'AUDIT'}
</Badge>
{getStatusBadge(task.status)}
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4 font-mono">
<div className="text-center p-3 bg-muted rounded-lg border border-border">
<p className="text-2xl font-bold text-foreground">{totalFiles}</p>
<p className="text-xs text-muted-foreground uppercase"></p>
</div>
<div className="text-center p-3 bg-muted rounded-lg border border-border">
<p className="text-2xl font-bold text-foreground">{totalLines}</p>
<p className="text-xs text-muted-foreground uppercase"></p>
</div>
<div className="text-center p-3 bg-muted rounded-lg border border-border">
<p className="text-2xl font-bold text-amber-400">{issuesOrFindings}</p>
<p className="text-xs text-muted-foreground uppercase">{isAudit ? '发现问题' : '发现漏洞'}</p>
</div>
<div className="text-center p-3 bg-muted rounded-lg border border-border">
<p className="text-2xl font-bold text-primary">{qualityScore.toFixed(1)}</p>
<p className="text-xs text-muted-foreground uppercase"></p>
</div>
</div>
{task.status === 'completed' && typeof qualityScore === 'number' && (
<div className="space-y-2 mb-4">
<div className="flex items-center justify-between text-sm font-mono">
<span className="text-muted-foreground"></span>
<span className="text-foreground font-bold">{qualityScore.toFixed(1)}/100</span>
</div>
<Progress value={qualityScore} className="h-2 bg-muted [&>div]:bg-primary" />
</div>
)}
<div className="flex justify-end space-x-2 pt-4 border-t border-border">
<Link to={isAudit ? `/tasks/${task.id}` : `/agent-audit/${task.id}`}>
<Button variant="outline" size="sm" className="cyber-btn-outline">
<FileText className="w-4 h-4 mr-2" />
</Button>
</Link>
</div>
</div>
)})}
</div>
) : (
<div className="cyber-card p-12 text-center">
<Activity className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-bold text-foreground mb-2 uppercase"></h3>
<p className="text-sm text-muted-foreground mb-6 font-mono"></p>
<Button onClick={handleCreateTask} className="cyber-btn-primary">
<Play className="w-4 h-4 mr-2" />
</Button>
</div>
)}
<ProjectTasksTab
unifiedTasks={unifiedTasks}
onCreateTask={handleCreateTask}
formatDate={formatDate}
renderStatusBadge={getStatusBadge}
renderStatusIcon={getStatusIcon}
/>
</TabsContent>
<TabsContent value="issues" className="flex flex-col gap-6 mt-6">
<div className="flex items-center justify-between">
<div className="section-header mb-0 pb-0 border-0">
<AlertTriangle className="w-5 h-5 text-amber-400" />
<h3 className="section-title"></h3>
</div>
{(auditTasks.length > 0 || agentTasks.length > 0) && (
<p className="text-sm text-muted-foreground font-mono">
{issuesSummary.completedAuditTasksCount} / Agent审计{issuesSummary.completedAgentTasksCount}
{issuesSummary.isLimited ? `(各仅展示最近 ${issuesSummary.maxTasks} 次)` : ''}
{latestProblems.length} /
</p>
)}
</div>
{loadingIssues ? (
<div className="text-center py-12">
<div className="loading-spinner mx-auto mb-4"></div>
<p className="text-muted-foreground font-mono">...</p>
</div>
) : latestProblems.length > 0 ? (
<div className="space-y-4">
{latestProblems.map((issue, index) => (
<div key={index} className="cyber-card p-4 hover:border-border transition-all">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${issue.severity === 'critical' ? 'bg-rose-500/20 text-rose-600 dark:text-rose-400' :
issue.severity === 'high' ? 'bg-orange-500/20 text-orange-600 dark:text-orange-400' :
issue.severity === 'medium' ? 'bg-amber-500/20 text-amber-600 dark:text-amber-400' :
'bg-sky-500/20 text-sky-600 dark:text-sky-400'
}`}>
<AlertTriangle className="w-4 h-4" />
</div>
<div>
<h4 className="font-bold text-base text-foreground mb-1 uppercase">{issue.title}</h4>
<div className="flex items-center space-x-2 text-xs text-muted-foreground font-mono">
<span className="bg-muted px-2 py-0.5 rounded border border-border">
{issue.file_path || '未知文件'}
{issue.line_number != null
? (issue.line_end != null && issue.line_end !== issue.line_number
? `:${issue.line_number}-${issue.line_end}`
: `:${issue.line_number}`)
: ''
}
</span>
<span>{issue.category || '-'}</span>
{issue.task_created_at && (
<span className="bg-muted px-2 py-0.5 rounded border border-border">
{issue.kind === 'agent' ? 'Agent' : 'Audit'} {issue.task_id?.slice(0, 8)} · {formatDate(issue.task_created_at)}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Link to={issue.kind === 'agent' ? `/agent-audit/${issue.task_id}` : `/tasks/${issue.task_id}`}>
<Button variant="outline" size="sm" className="cyber-btn-outline">
<FileText className="w-4 h-4 mr-2" />
</Button>
</Link>
<Badge className={`
${issue.severity === 'critical' ? 'severity-critical' :
issue.severity === 'high' ? 'severity-high' :
issue.severity === 'medium' ? 'severity-medium' :
'severity-low'}
font-bold uppercase px-2 py-1 rounded text-xs
`}>
{issue.severity === 'critical' ? '严重' :
issue.severity === 'high' ? '高' :
issue.severity === 'medium' ? '中等' : '低'}
</Badge>
</div>
</div>
<p className="mt-3 text-sm text-muted-foreground font-mono border-t border-border pt-3">
{issue.description || '-'}
</p>
</div>
))}
</div>
) : (
<div className="cyber-card p-12 text-center">
<CheckCircle className="w-16 h-16 text-emerald-600 dark:text-emerald-500 mx-auto mb-4" />
<h3 className="text-lg font-bold text-foreground mb-2 uppercase"></h3>
<p className="text-sm text-muted-foreground font-mono">/Agent审计未发现明显问题</p>
</div>
)}
<ProjectIssuesTab
hasAnyTasks={auditTasks.length > 0 || agentTasks.length > 0}
issuesSummary={issuesSummary}
loading={loadingIssues}
latestProblems={latestProblems}
formatDate={formatDate}
/>
</TabsContent>
<TabsContent value="settings" className="flex flex-col gap-6 mt-6">
@@ -0,0 +1,115 @@
import { Link } from "react-router-dom";
import { AlertTriangle, CheckCircle, FileText } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import type { IssuesSummary, LatestProblem } from "@/shared/types";
export function ProjectIssuesTab(props: {
hasAnyTasks: boolean;
issuesSummary: IssuesSummary;
loading: boolean;
latestProblems: LatestProblem[];
formatDate: (dateString: string) => string;
}) {
const { hasAnyTasks, issuesSummary, loading, latestProblems, formatDate } = props;
return (
<>
<div className="flex items-center justify-between">
<div className="section-header mb-0 pb-0 border-0">
<AlertTriangle className="w-5 h-5 text-amber-400" />
<h3 className="section-title"></h3>
</div>
{hasAnyTasks && (
<p className="text-sm text-muted-foreground font-mono">
{issuesSummary.completedAuditTasksCount} / Agent审计{issuesSummary.completedAgentTasksCount}
{issuesSummary.isLimited ? `(各仅展示最近 ${issuesSummary.maxTasks} 次)` : ""}
{latestProblems.length} /
</p>
)}
</div>
{loading ? (
<div className="text-center py-12">
<div className="loading-spinner mx-auto mb-4"></div>
<p className="text-muted-foreground font-mono">...</p>
</div>
) : latestProblems.length > 0 ? (
<div className="space-y-4">
{latestProblems.map((issue, index) => (
<div key={index} className="cyber-card p-4 hover:border-border transition-all">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-3">
<div
className={`w-8 h-8 rounded-lg flex items-center justify-center ${issue.severity === "critical"
? "bg-rose-500/20 text-rose-600 dark:text-rose-400"
: issue.severity === "high"
? "bg-orange-500/20 text-orange-600 dark:text-orange-400"
: issue.severity === "medium"
? "bg-amber-500/20 text-amber-600 dark:text-amber-400"
: "bg-sky-500/20 text-sky-600 dark:text-sky-400"
}`}
>
<AlertTriangle className="w-4 h-4" />
</div>
<div>
<h4 className="font-bold text-base text-foreground mb-1 uppercase">{issue.title}</h4>
<div className="flex items-center space-x-2 text-xs text-muted-foreground font-mono">
<span className="bg-muted px-2 py-0.5 rounded border border-border">
{issue.file_path || "未知文件"}
{issue.line_number != null
? issue.line_end != null && issue.line_end !== issue.line_number
? `:${issue.line_number}-${issue.line_end}`
: `:${issue.line_number}`
: ""}
</span>
<span>{issue.category || "-"}</span>
{issue.task_created_at && (
<span className="bg-muted px-2 py-0.5 rounded border border-border">
{issue.kind === "agent" ? "Agent" : "Audit"} {issue.task_id?.slice(0, 8)} ·{" "}
{formatDate(issue.task_created_at)}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Link to={issue.kind === "agent" ? `/agent-audit/${issue.task_id}` : `/tasks/${issue.task_id}`}>
<Button variant="outline" size="sm" className="cyber-btn-outline">
<FileText className="w-4 h-4 mr-2" />
</Button>
</Link>
<Badge
className={`
${issue.severity === "critical"
? "severity-critical"
: issue.severity === "high"
? "severity-high"
: issue.severity === "medium"
? "severity-medium"
: "severity-low"}
font-bold uppercase px-2 py-1 rounded text-xs
`}
>
{issue.severity === "critical" ? "严重" : issue.severity === "high" ? "高" : issue.severity === "medium" ? "中等" : "低"}
</Badge>
</div>
</div>
<p className="mt-3 text-sm text-muted-foreground font-mono border-t border-border pt-3">{issue.description || "-"}</p>
</div>
))}
</div>
) : (
<div className="cyber-card p-12 text-center">
<CheckCircle className="w-16 h-16 text-emerald-600 dark:text-emerald-500 mx-auto mb-4" />
<h3 className="text-lg font-bold text-foreground mb-2 uppercase"></h3>
<p className="text-sm text-muted-foreground font-mono">/Agent审计未发现明显问题</p>
</div>
)}
</>
);
}
@@ -0,0 +1,65 @@
import { Activity, AlertTriangle, CheckCircle, Code } from "lucide-react";
export type ProjectCombinedStats = {
totalTasks: number;
completedTasks: number;
totalIssues: number;
avgQualityScore: number;
};
export function ProjectStatsCards(props: { stats: ProjectCombinedStats }) {
const { stats } = props;
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 relative z-10">
<div className="cyber-card p-4">
<div className="flex items-center justify-between">
<div>
<p className="stat-label"></p>
<p className="stat-value">{stats.totalTasks}</p>
</div>
<div className="stat-icon text-sky-400">
<Activity className="w-6 h-6" />
</div>
</div>
</div>
<div className="cyber-card p-4">
<div className="flex items-center justify-between">
<div>
<p className="stat-label"></p>
<p className="stat-value">{stats.completedTasks}</p>
</div>
<div className="stat-icon text-emerald-400">
<CheckCircle className="w-6 h-6" />
</div>
</div>
</div>
<div className="cyber-card p-4">
<div className="flex items-center justify-between">
<div>
<p className="stat-label"></p>
<p className="stat-value">{stats.totalIssues}</p>
</div>
<div className="stat-icon text-amber-400">
<AlertTriangle className="w-6 h-6" />
</div>
</div>
</div>
<div className="cyber-card p-4">
<div className="flex items-center justify-between">
<div>
<p className="stat-label"></p>
<p className="stat-value">{stats.avgQualityScore.toFixed(1)}</p>
</div>
<div className="stat-icon text-violet-400">
<Code className="w-6 h-6" />
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,132 @@
import { Link } from "react-router-dom";
import { FileText, Play, Activity } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import type { AuditTask } from "@/shared/types";
import type { UnifiedTask } from "@/shared/types";
export function ProjectTasksTab(props: {
unifiedTasks: UnifiedTask[];
onCreateTask: () => void;
formatDate: (dateString: string) => string;
renderStatusBadge: (status: string) => React.ReactNode;
renderStatusIcon: (status: string) => React.ReactNode;
}) {
const { unifiedTasks, onCreateTask, formatDate, renderStatusBadge, renderStatusIcon } = props;
return (
<>
<div className="flex items-center justify-between">
<div className="section-header mb-0 pb-0 border-0">
<FileText className="w-5 h-5 text-primary" />
<h3 className="section-title"></h3>
</div>
<Button onClick={onCreateTask} className="cyber-btn-primary">
<Play className="w-4 h-4 mr-2" />
</Button>
</div>
{unifiedTasks.length > 0 ? (
<div className="space-y-4">
{unifiedTasks.map((wrappedTask) => {
const isAuditTask = wrappedTask.kind === "audit";
const task: any = wrappedTask.task as any;
const issueCount = isAuditTask ? (task.issues_count ?? 0) : (task.findings_count ?? 0);
const totalFiles = task.total_files ?? 0;
const totalLines = task.total_lines ?? "-";
const qualityScore = typeof task.quality_score === "number" ? task.quality_score : 0;
return (
<div key={`${wrappedTask.kind}:${task.id}`} className="cyber-card p-6">
<div className="flex items-center justify-between mb-4 pb-4 border-b border-border">
<div className="flex items-center space-x-3">
<div
className={`w-10 h-10 rounded-lg flex items-center justify-center ${task.status === "completed"
? "bg-emerald-500/20"
: task.status === "running"
? "bg-sky-500/20"
: task.status === "failed"
? "bg-rose-500/20"
: "bg-muted"
}`}
>
{renderStatusIcon(task.status)}
</div>
<div>
<h4 className="font-bold text-foreground uppercase">
{isAuditTask
? ((task as AuditTask).task_type === "repository" ? "审计任务" : "即时分析任务")
: "Agent 审计任务"}
</h4>
<p className="text-sm text-muted-foreground font-mono"> {formatDate(task.created_at)}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge className={wrappedTask.kind === "agent" ? "cyber-badge-info" : "cyber-badge-muted"}>
{wrappedTask.kind === "agent" ? "AGENT" : "AUDIT"}
</Badge>
{renderStatusBadge(task.status)}
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4 font-mono">
<div className="text-center p-3 bg-muted rounded-lg border border-border">
<p className="text-2xl font-bold text-foreground">{totalFiles}</p>
<p className="text-xs text-muted-foreground uppercase"></p>
</div>
<div className="text-center p-3 bg-muted rounded-lg border border-border">
<p className="text-2xl font-bold text-foreground">{totalLines}</p>
<p className="text-xs text-muted-foreground uppercase"></p>
</div>
<div className="text-center p-3 bg-muted rounded-lg border border-border">
<p className="text-2xl font-bold text-amber-400">{issueCount}</p>
<p className="text-xs text-muted-foreground uppercase">{isAuditTask ? "发现问题" : "发现漏洞"}</p>
</div>
<div className="text-center p-3 bg-muted rounded-lg border border-border">
<p className="text-2xl font-bold text-primary">{qualityScore.toFixed(1)}</p>
<p className="text-xs text-muted-foreground uppercase"></p>
</div>
</div>
{task.status === "completed" && typeof qualityScore === "number" && (
<div className="space-y-2 mb-4">
<div className="flex items-center justify-between text-sm font-mono">
<span className="text-muted-foreground"></span>
<span className="text-foreground font-bold">{qualityScore.toFixed(1)}/100</span>
</div>
<Progress value={qualityScore} className="h-2 bg-muted [&>div]:bg-primary" />
</div>
)}
<div className="flex justify-end space-x-2 pt-4 border-t border-border">
<Link to={isAuditTask ? `/tasks/${task.id}` : `/agent-audit/${task.id}`}>
<Button variant="outline" size="sm" className="cyber-btn-outline">
<FileText className="w-4 h-4 mr-2" />
</Button>
</Link>
</div>
</div>
);
})}
</div>
) : (
<div className="cyber-card p-12 text-center">
<Activity className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-bold text-foreground mb-2 uppercase"></h3>
<p className="text-sm text-muted-foreground mb-6 font-mono"></p>
<Button onClick={onCreateTask} className="cyber-btn-primary">
<Play className="w-4 h-4 mr-2" />
</Button>
</div>
)}
</>
);
}
@@ -0,0 +1,8 @@
// 兼容旧 import:新代码请统一使用 `@/shared/constants`
export {
PROJECT_DETAIL_REQUEST_TIMEOUT_MS as REQUEST_TIMEOUT_MS,
PROJECT_DETAIL_ISSUES_MAX_TASKS as ISSUES_MAX_TASKS,
PROJECT_DETAIL_ISSUES_FETCH_CONCURRENCY as ISSUES_FETCH_CONCURRENCY,
} from "@/shared/constants";
@@ -0,0 +1,93 @@
import { useEffect, useMemo, useState } from "react";
import { api } from "@/shared/config/database";
import type { Project, AuditTask } from "@/shared/types";
import type { AgentTask } from "@/shared/api/agentTasks";
import { getAgentTasks } from "@/shared/api/agentTasks";
import type { UnifiedTask } from "@/shared/types";
export type ProjectDetailCombinedStats = {
totalTasks: number;
completedTasks: number;
totalIssues: number;
avgQualityScore: number;
};
export function useProjectDetailData(projectId: string | undefined) {
const [project, setProject] = useState<Project | null>(null);
const [auditTasks, setAuditTasks] = useState<AuditTask[]>([]);
const [agentTasks, setAgentTasks] = useState<AgentTask[]>([]);
const [loading, setLoading] = useState(true);
const reload = async () => {
if (!projectId) return;
try {
setLoading(true);
const [projectRes, auditTasksRes, agentTasksRes] = await Promise.allSettled([
api.getProjectById(projectId),
api.getAuditTasks(projectId),
getAgentTasks({ project_id: projectId }),
]);
if (projectRes.status === "fulfilled") setProject(projectRes.value);
else setProject(null);
if (auditTasksRes.status === "fulfilled") setAuditTasks(Array.isArray(auditTasksRes.value) ? auditTasksRes.value : []);
else setAuditTasks([]);
if (agentTasksRes.status === "fulfilled") setAgentTasks(Array.isArray(agentTasksRes.value) ? agentTasksRes.value : []);
else setAgentTasks([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
reload();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
const unifiedTasks: UnifiedTask[] = useMemo(() => {
const merged: UnifiedTask[] = [
...auditTasks.map((task) => ({ kind: "audit" as const, task })),
...agentTasks.map((task) => ({ kind: "agent" as const, task })),
];
merged.sort((a, b) => new Date(b.task.created_at).getTime() - new Date(a.task.created_at).getTime());
return merged;
}, [auditTasks, agentTasks]);
const combinedStats: ProjectDetailCombinedStats = useMemo(() => {
const totalTasks = auditTasks.length + agentTasks.length;
const completedTasks =
auditTasks.filter((t) => t.status === "completed").length +
agentTasks.filter((t) => t.status === "completed").length;
const totalIssues =
auditTasks.reduce((sum, t) => sum + (t.issues_count || 0), 0) +
agentTasks.reduce((sum, t) => sum + (t.findings_count || 0), 0);
const avgQualityScore =
totalTasks > 0
? (auditTasks.reduce((sum, t) => sum + (t.quality_score || 0), 0) +
agentTasks.reduce((sum, t) => sum + (t.quality_score || 0), 0)) /
totalTasks
: 0;
return { totalTasks, completedTasks, totalIssues, avgQualityScore };
}, [auditTasks, agentTasks]);
return {
project,
auditTasks,
agentTasks,
unifiedTasks,
combinedStats,
loading,
reload,
setProject,
setAuditTasks,
setAgentTasks,
};
}
@@ -0,0 +1,268 @@
import { useEffect, useMemo, useState } from "react";
import { apiClient } from "@/shared/api/serverClient";
import type { AuditTask } from "@/shared/types";
import type { AgentTask, AgentFinding } from "@/shared/api/agentTasks";
import type {
AggregatedAuditIssue,
AggregatedAgentFinding,
IssuesSummary,
LatestProblem,
} from "@/shared/types";
import {
PROJECT_DETAIL_ISSUES_FETCH_CONCURRENCY,
PROJECT_DETAIL_ISSUES_MAX_TASKS,
PROJECT_DETAIL_REQUEST_TIMEOUT_MS,
} from "@/shared/constants";
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timeoutId: number | undefined;
const timeoutPromise = new Promise<T>((_resolve, reject) => {
timeoutId = window.setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
});
try {
return await Promise.race([promise, timeoutPromise]);
} finally {
if (timeoutId != null) window.clearTimeout(timeoutId);
}
}
async function mapWithConcurrency<T, R>(
items: T[],
concurrency: number,
mapper: (item: T) => Promise<R>
): Promise<PromiseSettledResult<R>[]> {
const results: PromiseSettledResult<R>[] = new Array(items.length);
let nextIndex = 0;
async function worker(): Promise<void> {
while (true) {
const currentIndex = nextIndex++;
if (currentIndex >= items.length) return;
try {
const value = await mapper(items[currentIndex]);
results[currentIndex] = { status: "fulfilled", value };
} catch (reason) {
results[currentIndex] = { status: "rejected", reason };
}
}
}
const workers = Array.from({ length: Math.max(1, concurrency) }, () => worker());
await Promise.all(workers);
return results;
}
async function fetchAuditIssues(taskId: string) {
const res = await withTimeout(
apiClient.get(`/tasks/${taskId}/issues`),
PROJECT_DETAIL_REQUEST_TIMEOUT_MS,
`GET /tasks/${taskId}/issues`
);
return res.data;
}
async function fetchAgentFindings(taskId: string): Promise<AgentFinding[]> {
const res = await withTimeout(
apiClient.get(`/agent-tasks/${taskId}/findings`),
PROJECT_DETAIL_REQUEST_TIMEOUT_MS,
`GET /agent-tasks/${taskId}/findings`
);
return res.data;
}
function normalizeSeverity(severity: unknown): LatestProblem["severity"] {
const value = String(severity || "").toLowerCase();
if (value === "critical") return "critical";
if (value === "high") return "high";
if (value === "medium") return "medium";
return "low";
}
function parsePathLineFromTitle(title: string) {
// Security hardening:
// - Cap title length
// - Restrict acceptable path characters
// - Reject absolute paths and path traversal segments
const safeTitle = String(title || "").slice(0, 500);
const match = safeTitle.match(/^([A-Za-z0-9_.\-\/]+):(\d+)(?:-(\d+))?\s*-\s*(.+)$/);
if (!match) return null;
const [, rawPath, lineStartStr, lineEndStr, rest] = match;
if (rawPath.startsWith("/") || rawPath.includes("..") || rawPath.includes("\u0000")) return null;
const lineStart = Number(lineStartStr);
const lineEnd = lineEndStr ? Number(lineEndStr) : null;
const normalizedLineStart = Number.isFinite(lineStart) ? lineStart : NaN;
const normalizedLineEnd = lineEnd != null && Number.isFinite(lineEnd) ? lineEnd : null;
if (!Number.isFinite(normalizedLineStart) || normalizedLineStart <= 0) return null;
return {
file_path: rawPath,
line_start: normalizedLineStart,
line_end: normalizedLineEnd != null && normalizedLineEnd > 0 ? normalizedLineEnd : null,
rest_title: rest,
};
}
export function useProjectProblems(params: {
enabled: boolean;
auditTasks: AuditTask[];
agentTasks: AgentTask[];
}) {
const { enabled, auditTasks, agentTasks } = params;
const [latestIssues, setLatestIssues] = useState<AggregatedAuditIssue[]>([]);
const [latestFindings, setLatestFindings] = useState<AggregatedAgentFinding[]>([]);
const [loading, setLoading] = useState(false);
const [summary, setSummary] = useState<IssuesSummary>({
completedAuditTasksCount: 0,
completedAgentTasksCount: 0,
fetchedAuditTasksCount: 0,
fetchedAgentTasksCount: 0,
isLimited: false,
maxTasks: PROJECT_DETAIL_ISSUES_MAX_TASKS,
});
const load = async () => {
const completedAuditTasks = [...auditTasks]
.filter((t) => t.status === "completed")
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
const completedAgentTasks = [...agentTasks]
.filter((t) => t.status === "completed")
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
const limitedAuditTasks = completedAuditTasks.slice(0, PROJECT_DETAIL_ISSUES_MAX_TASKS);
const limitedAgentTasks = completedAgentTasks.slice(0, PROJECT_DETAIL_ISSUES_MAX_TASKS);
setSummary({
completedAuditTasksCount: completedAuditTasks.length,
completedAgentTasksCount: completedAgentTasks.length,
fetchedAuditTasksCount: limitedAuditTasks.length,
fetchedAgentTasksCount: limitedAgentTasks.length,
isLimited:
completedAuditTasks.length > PROJECT_DETAIL_ISSUES_MAX_TASKS ||
completedAgentTasks.length > PROJECT_DETAIL_ISSUES_MAX_TASKS,
maxTasks: PROJECT_DETAIL_ISSUES_MAX_TASKS,
});
if (limitedAuditTasks.length === 0 && limitedAgentTasks.length === 0) {
setLatestIssues([]);
setLatestFindings([]);
return;
}
setLoading(true);
try {
const [issuesResults, findingsResults] = await Promise.all([
mapWithConcurrency(limitedAuditTasks, PROJECT_DETAIL_ISSUES_FETCH_CONCURRENCY, async (task) => {
const issues = await fetchAuditIssues(task.id);
const enriched: AggregatedAuditIssue[] = (issues || []).map((issue: any) => ({
...issue,
task_created_at: task.created_at,
task_completed_at: task.completed_at ?? null,
}));
return enriched;
}),
mapWithConcurrency(limitedAgentTasks, PROJECT_DETAIL_ISSUES_FETCH_CONCURRENCY, async (task) => {
const findings = await fetchAgentFindings(task.id);
const enriched: AggregatedAgentFinding[] = (findings || []).map((finding) => ({
...finding,
task_created_at: task.created_at,
task_completed_at: task.completed_at ?? null,
}));
return enriched;
}),
]);
const flattenedIssues = issuesResults
.filter(
(result): result is PromiseFulfilledResult<AggregatedAuditIssue[]> => result.status === "fulfilled"
)
.flatMap((result) => result.value);
const flattenedFindings = findingsResults
.filter(
(result): result is PromiseFulfilledResult<AggregatedAgentFinding[]> => result.status === "fulfilled"
)
.flatMap((result) => result.value);
setLatestIssues(flattenedIssues);
setLatestFindings(flattenedFindings);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!enabled) return;
if (auditTasks.length === 0 && agentTasks.length === 0) return;
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, auditTasks, agentTasks]);
const latestProblems: LatestProblem[] = useMemo(() => {
const auditProblems: LatestProblem[] = latestIssues.map((issue: any) => ({
kind: "audit",
id: issue.id,
task_id: issue.task_id,
task_created_at: issue.task_created_at,
created_at: issue.created_at,
severity: normalizeSeverity(issue.severity),
title: issue.title || "(未命名问题)",
description:
issue.description ??
issue.message ??
issue.ai_explanation ??
issue.suggestion ??
issue.code_snippet ??
null,
file_path: issue.file_path ?? null,
line_number: issue.line_number ?? null,
line_end: null,
category: issue.issue_type ?? null,
}));
const agentProblems: LatestProblem[] = latestFindings.map((finding: any) => {
const rawTitle = finding.title || "(未命名漏洞)";
const parsed = (!finding.file_path || finding.file_path === "-") ? parsePathLineFromTitle(rawTitle) : null;
return {
kind: "agent",
id: finding.id,
task_id: finding.task_id,
task_created_at: finding.task_created_at,
created_at: finding.created_at,
severity: normalizeSeverity(finding.severity),
title: parsed?.rest_title || rawTitle,
description: finding.description ?? null,
file_path: finding.file_path ?? parsed?.file_path ?? null,
line_number: finding.line_start ?? parsed?.line_start ?? null,
line_end: finding.line_end ?? parsed?.line_end ?? null,
category: finding.vulnerability_type ?? null,
};
});
const merged = [...auditProblems, ...agentProblems];
const severityRank: Record<string, number> = { critical: 4, high: 3, medium: 2, low: 1 };
merged.sort((a, b) => {
const createdAtA = new Date(a.created_at).getTime();
const createdAtB = new Date(b.created_at).getTime();
if (createdAtA !== createdAtB) return createdAtB - createdAtA;
const severityA = severityRank[a.severity] ?? 0;
const severityB = severityRank[b.severity] ?? 0;
if (severityA !== severityB) return severityB - severityA;
const taskCreatedAtA = a.task_created_at ? new Date(a.task_created_at).getTime() : 0;
const taskCreatedAtB = b.task_created_at ? new Date(b.task_created_at).getTime() : 0;
return taskCreatedAtB - taskCreatedAtA;
});
return merged;
}, [latestIssues, latestFindings]);
return {
loading,
summary,
latestProblems,
reload: load,
};
}
+8
View File
@@ -77,6 +77,14 @@ export const DEFAULT_CONFIG = {
DEBOUNCE_DELAY: 300, // 300ms
} as const;
// ProjectDetail 页面专用常量
// 单请求超时(ms):避免外部/后端卡死导致 UI 长时间无响应
export const PROJECT_DETAIL_REQUEST_TIMEOUT_MS = 12_000;
// Issues/Findings 面板:最多抓取最近 N 个已完成任务
export const PROJECT_DETAIL_ISSUES_MAX_TASKS = 20;
// Issues/Findings 拉取并发度:防止对后端形成突发压力
export const PROJECT_DETAIL_ISSUES_FETCH_CONCURRENCY = 5;
// API 端点
export const API_ENDPOINTS = {
PROJECTS: '/api/projects',
+39
View File
@@ -111,6 +111,45 @@ export interface InstantAnalysis {
user?: Profile;
}
// ProjectDetail 页面:前端聚合层类型(用于把 AuditTask / AgentTask 的结果统一展示)
export type AggregatedAuditIssue = AuditIssue & {
task_created_at?: string;
task_completed_at?: string | null;
};
export type AggregatedAgentFinding = import("@/shared/api/agentTasks").AgentFinding & {
task_created_at?: string;
task_completed_at?: string | null;
};
export type IssuesSummary = {
completedAuditTasksCount: number;
completedAgentTasksCount: number;
fetchedAuditTasksCount: number;
fetchedAgentTasksCount: number;
isLimited: boolean;
maxTasks: number;
};
export type LatestProblem = {
kind: "audit" | "agent";
id: string;
task_id: string;
task_created_at?: string;
created_at: string;
severity: "critical" | "high" | "medium" | "low";
title: string;
description?: string | null;
file_path?: string | null;
line_number?: number | null;
line_end?: number | null;
category?: string | null;
};
export type UnifiedTask =
| { kind: "audit"; task: AuditTask }
| { kind: "agent"; task: import("@/shared/api/agentTasks").AgentTask };
// 表单相关类型
export interface CreateProjectForm {
name: string;