mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-01 15:03:57 +08:00
feat(discovery): add FindingDrawer component and restructure i18n keys
- Add FindingDrawer component for displaying finding details when no associated issue exists - Refactor i18n keys for better organization: - status.* → session.status.* (session-related) - severity.* → findings.severity.* (finding-related) - Update DiscoveryDetail to show FindingDrawer for orphan findings - Add severity/priority mapping in discovery-routes for compatibility
This commit is contained in:
@@ -21,17 +21,17 @@ const statusConfig = {
|
||||
running: {
|
||||
icon: Clock,
|
||||
variant: 'warning' as const,
|
||||
label: 'issues.discovery.status.running',
|
||||
label: 'issues.discovery.session.status.running',
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle,
|
||||
variant: 'success' as const,
|
||||
label: 'issues.discovery.status.completed',
|
||||
label: 'issues.discovery.session.status.completed',
|
||||
},
|
||||
failed: {
|
||||
icon: XCircle,
|
||||
variant: 'destructive' as const,
|
||||
label: 'issues.discovery.status.failed',
|
||||
label: 'issues.discovery.session.status.failed',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -79,7 +79,7 @@ export function DiscoveryCard({ session, isActive, onClick }: DiscoveryCardProps
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">{formatMessage({ id: 'issues.discovery.findings' })}:</span>
|
||||
<span className="text-muted-foreground">{formatMessage({ id: 'issues.discovery.session.findings' }, { count: session.findings_count })}:</span>
|
||||
<span className="font-medium text-foreground">{session.findings_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Progress } from '@/components/ui/Progress';
|
||||
import { IssueDrawer } from '@/components/issue/hub/IssueDrawer';
|
||||
import { FindingDrawer } from './FindingDrawer';
|
||||
import type { DiscoverySession, Finding } from '@/lib/api';
|
||||
import type { Issue } from '@/lib/api';
|
||||
import type { FindingFilters } from '@/hooks/useIssues';
|
||||
@@ -43,6 +44,7 @@ export function DiscoveryDetail({
|
||||
const { formatMessage } = useIntl();
|
||||
const [activeTab, setActiveTab] = useState('findings');
|
||||
const [selectedIssue, setSelectedIssue] = useState<Issue | null>(null);
|
||||
const [selectedFinding, setSelectedFinding] = useState<Finding | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
|
||||
const handleFindingClick = (finding: Finding) => {
|
||||
@@ -51,14 +53,21 @@ export function DiscoveryDetail({
|
||||
const relatedIssue = issues.find(i => i.id === finding.issue_id);
|
||||
if (relatedIssue) {
|
||||
setSelectedIssue(relatedIssue);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Otherwise, show the finding details in FindingDrawer
|
||||
setSelectedFinding(finding);
|
||||
};
|
||||
|
||||
const handleCloseDrawer = () => {
|
||||
const handleCloseIssueDrawer = () => {
|
||||
setSelectedIssue(null);
|
||||
};
|
||||
|
||||
const handleCloseFindingDrawer = () => {
|
||||
setSelectedFinding(null);
|
||||
};
|
||||
|
||||
const handleExportSelected = async () => {
|
||||
if (onExportSelected && selectedIds.length > 0) {
|
||||
await onExportSelected(selectedIds);
|
||||
@@ -130,7 +139,7 @@ export function DiscoveryDetail({
|
||||
<Badge
|
||||
variant={session.status === 'completed' ? 'success' : session.status === 'failed' ? 'destructive' : 'warning'}
|
||||
>
|
||||
{formatMessage({ id: `issues.discovery.status.${session.status}` })}
|
||||
{formatMessage({ id: `issues.discovery.session.status.${session.status}` })}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'issues.discovery.createdAt' })}: {formatDate(session.created_at)}
|
||||
@@ -192,7 +201,7 @@ export function DiscoveryDetail({
|
||||
<Badge
|
||||
variant={severity === 'critical' || severity === 'high' ? 'destructive' : severity === 'medium' ? 'warning' : 'secondary'}
|
||||
>
|
||||
{formatMessage({ id: `issues.discovery.severity.${severity}` })}
|
||||
{formatMessage({ id: `issues.discovery.findings.severity.${severity}` })}
|
||||
</Badge>
|
||||
<span className="font-medium">{count}</span>
|
||||
</div>
|
||||
@@ -240,7 +249,7 @@ export function DiscoveryDetail({
|
||||
<Badge
|
||||
variant={session.status === 'completed' ? 'success' : session.status === 'failed' ? 'destructive' : 'warning'}
|
||||
>
|
||||
{formatMessage({ id: `issues.discovery.status.${session.status}` })}
|
||||
{formatMessage({ id: `issues.discovery.session.status.${session.status}` })}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
@@ -277,7 +286,14 @@ export function DiscoveryDetail({
|
||||
<IssueDrawer
|
||||
issue={selectedIssue}
|
||||
isOpen={selectedIssue !== null}
|
||||
onClose={handleCloseDrawer}
|
||||
onClose={handleCloseIssueDrawer}
|
||||
/>
|
||||
|
||||
{/* Finding Detail Drawer */}
|
||||
<FindingDrawer
|
||||
finding={selectedFinding}
|
||||
isOpen={selectedFinding !== null}
|
||||
onClose={handleCloseFindingDrawer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
213
ccw/frontend/src/components/issue/discovery/FindingDrawer.tsx
Normal file
213
ccw/frontend/src/components/issue/discovery/FindingDrawer.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
// ========================================
|
||||
// FindingDrawer Component
|
||||
// ========================================
|
||||
// Right-side finding detail drawer for displaying discovery finding details
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { X, FileText, AlertTriangle, ExternalLink, MapPin, Code, Lightbulb, Target } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Finding } from '@/lib/api';
|
||||
|
||||
// ========== Types ==========
|
||||
export interface FindingDrawerProps {
|
||||
finding: Finding | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// ========== Severity Configuration ==========
|
||||
const severityConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'info' }> = {
|
||||
critical: { label: 'issues.discovery.findings.severity.critical', variant: 'destructive' },
|
||||
high: { label: 'issues.discovery.findings.severity.high', variant: 'destructive' },
|
||||
medium: { label: 'issues.discovery.findings.severity.medium', variant: 'warning' },
|
||||
low: { label: 'issues.discovery.findings.severity.low', variant: 'secondary' },
|
||||
};
|
||||
|
||||
function getSeverityConfig(severity: string) {
|
||||
return severityConfig[severity] || { label: 'issues.discovery.findings.severity.unknown', variant: 'outline' };
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function FindingDrawer({ finding, isOpen, onClose }: FindingDrawerProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// ESC key to close
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleEsc);
|
||||
return () => window.removeEventListener('keydown', handleEsc);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!finding || !isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const severity = getSeverityConfig(finding.severity);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 bg-black/40 transition-opacity z-40',
|
||||
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
)}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed top-0 right-0 h-full w-1/2 bg-background border-l border-border shadow-2xl z-50 flex flex-col transition-transform duration-300 ease-in-out',
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{ minWidth: '400px', maxWidth: '800px' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between p-6 border-b border-border bg-card">
|
||||
<div className="flex-1 min-w-0 mr-4">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="text-xs font-mono text-muted-foreground">{finding.id}</span>
|
||||
<Badge variant={severity.variant}>
|
||||
{formatMessage({ id: severity.label })}
|
||||
</Badge>
|
||||
{finding.type && (
|
||||
<Badge variant="outline">{finding.type}</Badge>
|
||||
)}
|
||||
{finding.category && (
|
||||
<Badge variant="info">{finding.category}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{finding.title}
|
||||
</h2>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="flex-shrink-0 hover:bg-secondary">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{/* Description */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{formatMessage({ id: 'issues.discovery.findings.description' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{finding.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* File Location */}
|
||||
{finding.file && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4" />
|
||||
{formatMessage({ id: 'issues.discovery.findings.location' })}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<code className="px-2 py-1 bg-muted rounded text-xs">
|
||||
{finding.file}
|
||||
{finding.line && `:${finding.line}`}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Code Snippet */}
|
||||
{finding.code_snippet && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
|
||||
<Code className="h-4 w-4" />
|
||||
{formatMessage({ id: 'issues.discovery.findings.codeSnippet' })}
|
||||
</h3>
|
||||
<pre className="p-3 bg-muted rounded-md overflow-x-auto text-xs border border-border">
|
||||
<code>{finding.code_snippet}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggested Fix */}
|
||||
{finding.suggested_issue && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
|
||||
<Lightbulb className="h-4 w-4" />
|
||||
{formatMessage({ id: 'issues.discovery.findings.suggestedFix' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{finding.suggested_issue}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confidence */}
|
||||
{finding.confidence !== undefined && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
|
||||
<Target className="h-4 w-4" />
|
||||
{formatMessage({ id: 'issues.discovery.findings.confidence' })}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full transition-all",
|
||||
finding.confidence >= 0.9 ? "bg-green-500" :
|
||||
finding.confidence >= 0.7 ? "bg-yellow-500" : "bg-red-500"
|
||||
)}
|
||||
style={{ width: `${finding.confidence * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{Math.round(finding.confidence * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reference */}
|
||||
{finding.reference && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-2">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
{formatMessage({ id: 'issues.discovery.findings.reference' })}
|
||||
</h3>
|
||||
<a
|
||||
href={finding.reference}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary hover:underline break-all"
|
||||
>
|
||||
{finding.reference}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Perspective */}
|
||||
{finding.perspective && (
|
||||
<div className="pt-4 border-t border-border">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{formatMessage({ id: 'issues.discovery.findings.perspective' })}: {finding.perspective}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default FindingDrawer;
|
||||
@@ -24,14 +24,14 @@ interface FindingListProps {
|
||||
}
|
||||
|
||||
const severityConfig: Record<string, { variant: 'destructive' | 'warning' | 'secondary' | 'outline' | 'success' | 'info' | 'default'; label: string }> = {
|
||||
critical: { variant: 'destructive', label: 'issues.discovery.severity.critical' },
|
||||
high: { variant: 'destructive', label: 'issues.discovery.severity.high' },
|
||||
medium: { variant: 'warning', label: 'issues.discovery.severity.medium' },
|
||||
low: { variant: 'secondary', label: 'issues.discovery.severity.low' },
|
||||
critical: { variant: 'destructive', label: 'issues.discovery.findings.severity.critical' },
|
||||
high: { variant: 'destructive', label: 'issues.discovery.findings.severity.high' },
|
||||
medium: { variant: 'warning', label: 'issues.discovery.findings.severity.medium' },
|
||||
low: { variant: 'secondary', label: 'issues.discovery.findings.severity.low' },
|
||||
};
|
||||
|
||||
function getSeverityConfig(severity: string) {
|
||||
return severityConfig[severity] || { variant: 'outline', label: 'issues.discovery.severity.unknown' };
|
||||
return severityConfig[severity] || { variant: 'outline', label: 'issues.discovery.findings.severity.unknown' };
|
||||
}
|
||||
|
||||
export function FindingList({
|
||||
@@ -116,10 +116,10 @@ export function FindingList({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{formatMessage({ id: 'issues.discovery.findings.severity.all' })}</SelectItem>
|
||||
<SelectItem value="critical">{formatMessage({ id: 'issues.discovery.severity.critical' })}</SelectItem>
|
||||
<SelectItem value="high">{formatMessage({ id: 'issues.discovery.severity.high' })}</SelectItem>
|
||||
<SelectItem value="medium">{formatMessage({ id: 'issues.discovery.severity.medium' })}</SelectItem>
|
||||
<SelectItem value="low">{formatMessage({ id: 'issues.discovery.severity.low' })}</SelectItem>
|
||||
<SelectItem value="critical">{formatMessage({ id: 'issues.discovery.findings.severity.critical' })}</SelectItem>
|
||||
<SelectItem value="high">{formatMessage({ id: 'issues.discovery.findings.severity.high' })}</SelectItem>
|
||||
<SelectItem value="medium">{formatMessage({ id: 'issues.discovery.findings.severity.medium' })}</SelectItem>
|
||||
<SelectItem value="low">{formatMessage({ id: 'issues.discovery.findings.severity.low' })}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{uniqueTypes.length > 0 && (
|
||||
|
||||
@@ -1077,6 +1077,12 @@ export interface Finding {
|
||||
created_at: string;
|
||||
issue_id?: string; // Associated issue ID if exported
|
||||
exported?: boolean; // Whether this finding has been exported as an issue
|
||||
// Additional fields from discovery backend
|
||||
category?: string;
|
||||
suggested_issue?: string;
|
||||
confidence?: number;
|
||||
reference?: string;
|
||||
perspective?: string;
|
||||
}
|
||||
|
||||
export async function fetchDiscoveries(projectPath?: string): Promise<DiscoverySession[]> {
|
||||
@@ -1131,7 +1137,11 @@ export async function fetchDiscoveryFindings(
|
||||
? `/api/discoveries/${encodeURIComponent(sessionId)}/findings?path=${encodeURIComponent(projectPath)}`
|
||||
: `/api/discoveries/${encodeURIComponent(sessionId)}/findings`;
|
||||
const data = await fetchApi<{ findings?: Finding[] }>(url);
|
||||
return data.findings ?? [];
|
||||
// Map backend 'priority' to frontend 'severity' for compatibility
|
||||
return (data.findings ?? []).map(f => ({
|
||||
...f,
|
||||
severity: f.severity || (f as any).priority || 'medium'
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -353,7 +353,14 @@
|
||||
"hasIssue": "Linked",
|
||||
"export": "Export",
|
||||
"selectAll": "Select All",
|
||||
"deselectAll": "Deselect All"
|
||||
"deselectAll": "Deselect All",
|
||||
"description": "Description",
|
||||
"location": "Location",
|
||||
"codeSnippet": "Code Snippet",
|
||||
"suggestedFix": "Suggested Fix",
|
||||
"confidence": "Confidence",
|
||||
"reference": "Reference",
|
||||
"perspective": "Perspective"
|
||||
},
|
||||
"tabs": {
|
||||
"findings": "Findings",
|
||||
|
||||
@@ -353,7 +353,14 @@
|
||||
"hasIssue": "已关联",
|
||||
"export": "导出",
|
||||
"selectAll": "全选",
|
||||
"deselectAll": "取消全选"
|
||||
"deselectAll": "取消全选",
|
||||
"description": "问题描述",
|
||||
"location": "文件位置",
|
||||
"codeSnippet": "代码片段",
|
||||
"suggestedFix": "建议修复",
|
||||
"confidence": "置信度",
|
||||
"reference": "参考链接",
|
||||
"perspective": "视角"
|
||||
},
|
||||
"tabs": {
|
||||
"findings": "发现",
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useIntl } from 'react-intl';
|
||||
import { Radar, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useIssueDiscovery } from '@/hooks/useIssues';
|
||||
import { useIssueDiscovery, useIssues } from '@/hooks/useIssues';
|
||||
import { DiscoveryCard } from '@/components/issue/discovery/DiscoveryCard';
|
||||
import { DiscoveryDetail } from '@/components/issue/discovery/DiscoveryDetail';
|
||||
|
||||
@@ -29,6 +29,12 @@ export function DiscoveryPage() {
|
||||
isExporting,
|
||||
} = useIssueDiscovery({ refetchInterval: 3000 });
|
||||
|
||||
// Fetch issues to find related ones when clicking findings
|
||||
const { issues } = useIssues({
|
||||
// Don't apply filters to get all issues for matching
|
||||
filter: undefined
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -167,6 +173,7 @@ export function DiscoveryPage() {
|
||||
onExport={exportFindings}
|
||||
onExportSelected={exportSelectedFindings}
|
||||
isExporting={isExporting}
|
||||
issues={issues}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user