mirror of
https://github.com/yyhuni/xingrin.git
synced 2026-01-31 19:53:11 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c3833d13d | ||
|
|
92f3b722ef | ||
|
|
9ef503c666 |
@@ -293,4 +293,54 @@
|
||||
);
|
||||
background-size: 1rem 1rem;
|
||||
animation: progress-stripes 1s linear infinite;
|
||||
}
|
||||
|
||||
/* 闪电闪烁动画 - 快速扫描按钮 */
|
||||
@keyframes flash {
|
||||
0%, 90%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
filter: drop-shadow(0 0 2px rgba(250, 204, 21, 0.4));
|
||||
}
|
||||
93% {
|
||||
opacity: 1;
|
||||
transform: scale(1.3);
|
||||
filter: drop-shadow(0 0 8px rgba(250, 204, 21, 0.8));
|
||||
}
|
||||
96% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1);
|
||||
filter: drop-shadow(0 0 2px rgba(250, 204, 21, 0.4));
|
||||
}
|
||||
}
|
||||
|
||||
/* 按钮整体发光动画 */
|
||||
@keyframes glow {
|
||||
0%, 85%, 100% {
|
||||
box-shadow: 0 0 0 transparent;
|
||||
}
|
||||
90% {
|
||||
box-shadow: 0 0 12px oklch(from var(--primary) l c h / 0.5), 0 0 24px oklch(from var(--primary) l c h / 0.3);
|
||||
}
|
||||
95% {
|
||||
box-shadow: 0 0 4px oklch(from var(--primary) l c h / 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-glow {
|
||||
animation: glow 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 边框流光动画 */
|
||||
@keyframes border-flow {
|
||||
0% {
|
||||
transform: translateX(-100%) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-border-flow {
|
||||
animation: border-flow 2s linear infinite;
|
||||
}
|
||||
@@ -26,11 +26,11 @@ import type { VulnerabilitySeverity } from "@/types/vulnerability.types"
|
||||
|
||||
// 统一的漏洞严重程度颜色配置(与图表一致)
|
||||
const severityConfig: Record<VulnerabilitySeverity, { label: string; className: string }> = {
|
||||
critical: { label: "严重", className: "bg-[#da3633]/10 text-[#da3633] border border-[#da3633]/20 dark:text-[#f85149]" },
|
||||
high: { label: "高危", className: "bg-[#d29922]/10 text-[#d29922] border border-[#d29922]/20" },
|
||||
medium: { label: "中危", className: "bg-[#d4a72c]/10 text-[#d4a72c] border border-[#d4a72c]/20" },
|
||||
low: { label: "低危", className: "bg-[#238636]/10 text-[#238636] border border-[#238636]/20 dark:text-[#3fb950]" },
|
||||
info: { label: "信息", className: "bg-[#848d97]/10 text-[#848d97] border border-[#848d97]/20" },
|
||||
critical: { label: "Critical", className: "bg-[#da3633]/10 text-[#da3633] border border-[#da3633]/20 dark:text-[#f85149]" },
|
||||
high: { label: "High", className: "bg-[#d29922]/10 text-[#d29922] border border-[#d29922]/20" },
|
||||
medium: { label: "Medium", className: "bg-[#d4a72c]/10 text-[#d4a72c] border border-[#d4a72c]/20" },
|
||||
low: { label: "Low", className: "bg-[#238636]/10 text-[#238636] border border-[#238636]/20 dark:text-[#3fb950]" },
|
||||
info: { label: "Info", className: "bg-[#848d97]/10 text-[#848d97] border border-[#848d97]/20" },
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string) {
|
||||
|
||||
@@ -20,26 +20,26 @@ import { Skeleton } from "@/components/ui/skeleton"
|
||||
// 漏洞严重程度使用固定语义化颜色
|
||||
const chartConfig = {
|
||||
count: {
|
||||
label: "数量",
|
||||
label: "Count",
|
||||
},
|
||||
critical: {
|
||||
label: "严重",
|
||||
label: "Critical",
|
||||
color: "#dc2626", // 红色
|
||||
},
|
||||
high: {
|
||||
label: "高危",
|
||||
label: "High",
|
||||
color: "#f97316", // 橙色
|
||||
},
|
||||
medium: {
|
||||
label: "中危",
|
||||
label: "Medium",
|
||||
color: "#eab308", // 黄色
|
||||
},
|
||||
low: {
|
||||
label: "低危",
|
||||
label: "Low",
|
||||
color: "#3b82f6", // 蓝色
|
||||
},
|
||||
info: {
|
||||
label: "信息",
|
||||
label: "Info",
|
||||
color: "#6b7280", // 灰色
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState } from "react"
|
||||
import {
|
||||
Play,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from "lucide-react"
|
||||
import React, { useState, useMemo } from "react"
|
||||
import { Play, Settings2 } from "lucide-react"
|
||||
|
||||
// 导入 UI 组件
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -19,41 +14,26 @@ import {
|
||||
} from "@/components/ui/dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
||||
import { LoadingSpinner } from "@/components/loading-spinner"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CAPABILITY_CONFIG, getEngineIcon, parseEngineCapabilities } from "@/lib/engine-config"
|
||||
|
||||
// 导入类型定义
|
||||
import type { Organization } from "@/types/organization.types"
|
||||
import type { ScanEngine } from "@/types/engine.types"
|
||||
|
||||
// 导入扫描服务和Toast
|
||||
import { initiateScan } from "@/services/scan.service"
|
||||
import { toast } from "sonner"
|
||||
|
||||
// 导入引擎 hooks
|
||||
import { useEngines } from "@/hooks/use-engines"
|
||||
|
||||
// 组件属性类型定义
|
||||
interface InitiateScanDialogProps {
|
||||
organization?: Organization | null // 选中的组织(可选,用于显示信息)
|
||||
organizationId?: number // 组织ID(用于发起扫描)
|
||||
targetId?: number // 目标ID(用于发起扫描,与organizationId二选一)
|
||||
targetName?: string // 目标名称(可选,如果提供则显示为目标扫描)
|
||||
open: boolean // 对话框开关状态
|
||||
onOpenChange: (open: boolean) => void // 对话框开关回调
|
||||
onSuccess?: () => void // 扫描发起成功的回调
|
||||
organization?: Organization | null
|
||||
organizationId?: number
|
||||
targetId?: number
|
||||
targetName?: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起扫描对话框组件
|
||||
*
|
||||
* 功能特性:
|
||||
* 1. 选择扫描引擎
|
||||
* 2. 展示引擎详细信息
|
||||
* 3. 发起扫描操作
|
||||
*/
|
||||
export function InitiateScanDialog({
|
||||
organization,
|
||||
organizationId,
|
||||
@@ -65,257 +45,199 @@ export function InitiateScanDialog({
|
||||
}: InitiateScanDialogProps) {
|
||||
const [selectedEngineId, setSelectedEngineId] = useState<string>("")
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [expandedEngineId, setExpandedEngineId] = useState<string | null>(null)
|
||||
|
||||
// 从后端获取引擎列表
|
||||
const { data: engines, isLoading, error } = useEngines()
|
||||
|
||||
// 切换展开/收起
|
||||
const toggleExpand = (engineId: string) => {
|
||||
setExpandedEngineId(
|
||||
expandedEngineId === engineId ? null : engineId
|
||||
)
|
||||
}
|
||||
const selectedEngine = useMemo(() => {
|
||||
if (!selectedEngineId || !engines) return null
|
||||
return engines.find((e) => e.id.toString() === selectedEngineId) || null
|
||||
}, [selectedEngineId, engines])
|
||||
|
||||
const selectedCapabilities = useMemo(() => {
|
||||
if (!selectedEngine) return []
|
||||
return parseEngineCapabilities(selectedEngine.configuration || "")
|
||||
}, [selectedEngine])
|
||||
|
||||
// 处理发起扫描
|
||||
const handleInitiate = async () => {
|
||||
if (!selectedEngineId) return
|
||||
|
||||
// 验证必须有 organizationId 或 targetId
|
||||
if (!organizationId && !targetId) {
|
||||
toast.error("参数错误", {
|
||||
description: "必须提供组织ID或目标ID",
|
||||
})
|
||||
toast.error("参数错误", { description: "必须提供组织ID或目标ID" })
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
// 调用 API 发起扫描
|
||||
const response = await initiateScan({
|
||||
organizationId,
|
||||
targetId,
|
||||
engineId: Number(selectedEngineId),
|
||||
})
|
||||
|
||||
// 显示成功消息
|
||||
toast.success("扫描已发起", {
|
||||
description: response.message || `成功创建 ${response.count} 个扫描任务`,
|
||||
})
|
||||
|
||||
// 调用成功回调
|
||||
if (onSuccess) {
|
||||
onSuccess()
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
onSuccess?.()
|
||||
onOpenChange(false)
|
||||
|
||||
// 重置选择
|
||||
setSelectedEngineId("")
|
||||
} catch (error) {
|
||||
console.error("Failed to initiate scan:", error)
|
||||
} catch (err) {
|
||||
console.error("Failed to initiate scan:", err)
|
||||
toast.error("发起扫描失败", {
|
||||
description: error instanceof Error ? error.message : "未知错误",
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理对话框关闭
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!isSubmitting) {
|
||||
onOpenChange(newOpen)
|
||||
if (!newOpen) {
|
||||
// 关闭时重置所有状态
|
||||
setSelectedEngineId("")
|
||||
setExpandedEngineId(null)
|
||||
}
|
||||
if (!newOpen) setSelectedEngineId("")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[650px]">
|
||||
<DialogHeader>
|
||||
<DialogContent className="max-w-[90vw] sm:max-w-[900px] p-0 gap-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-4">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Play className="h-5 w-5" />
|
||||
发起扫描
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{targetName ? (
|
||||
<>为目标 <span className="font-semibold text-foreground">{targetName}</span> 选择扫描引擎并开始安全扫描</>
|
||||
<>
|
||||
为目标 <span className="font-semibold text-foreground">{targetName}</span> 选择扫描引擎
|
||||
</>
|
||||
) : (
|
||||
<>为组织 <span className="font-semibold text-foreground">{organization?.name}</span> 选择扫描引擎并开始安全扫描</>
|
||||
<>
|
||||
为组织 <span className="font-semibold text-foreground">{organization?.name}</span> 选择扫描引擎
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
{/* 引擎列表容器 - 固定最大高度,预留滚动条空间 */}
|
||||
<div className="max-h-[500px] overflow-y-auto" style={{ scrollbarGutter: 'stable' }}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<LoadingSpinner />
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
加载引擎中...
|
||||
</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-destructive mb-2">加载引擎失败</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{error instanceof Error ? error.message : '未知错误'}
|
||||
</p>
|
||||
</div>
|
||||
) : !engines || engines.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
暂无可用引擎
|
||||
</div>
|
||||
) : (
|
||||
<RadioGroup
|
||||
value={selectedEngineId}
|
||||
onValueChange={(value) => {
|
||||
setSelectedEngineId(value)
|
||||
// 选中时自动展开该引擎详情
|
||||
setExpandedEngineId(value)
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="space-y-2"
|
||||
>
|
||||
{engines.map((engine) => {
|
||||
const capabilities = parseEngineCapabilities(engine.configuration || '')
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
key={engine.id}
|
||||
open={expandedEngineId === engine.id.toString()}
|
||||
onOpenChange={() => toggleExpand(engine.id.toString())}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border transition-all",
|
||||
selectedEngineId === engine.id.toString()
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary/20"
|
||||
: "border-border hover:border-muted-foreground/50 hover:bg-muted/30"
|
||||
)}
|
||||
>
|
||||
{/* 引擎主信息 */}
|
||||
<div className="flex items-center gap-3 p-4">
|
||||
{/* Radio 按钮 */}
|
||||
<div className="flex border-t h-[480px]">
|
||||
{/* 左侧引擎列表 */}
|
||||
<div className="w-[260px] border-r flex flex-col shrink-0">
|
||||
<div className="px-4 py-3 border-b bg-muted/30 shrink-0">
|
||||
<h3 className="text-sm font-medium">选择引擎</h3>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<LoadingSpinner />
|
||||
<span className="ml-2 text-sm text-muted-foreground">加载中...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-8 text-center text-sm text-destructive">加载失败</div>
|
||||
) : !engines?.length ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">暂无可用引擎</div>
|
||||
) : (
|
||||
<RadioGroup
|
||||
value={selectedEngineId}
|
||||
onValueChange={setSelectedEngineId}
|
||||
disabled={isSubmitting}
|
||||
className="space-y-1"
|
||||
>
|
||||
{engines.map((engine) => {
|
||||
const capabilities = parseEngineCapabilities(engine.configuration || "")
|
||||
const EngineIcon = getEngineIcon(capabilities)
|
||||
const primaryCap = capabilities[0]
|
||||
const iconConfig = primaryCap ? CAPABILITY_CONFIG[primaryCap] : null
|
||||
const isSelected = selectedEngineId === engine.id.toString()
|
||||
|
||||
return (
|
||||
<label
|
||||
key={engine.id}
|
||||
htmlFor={`engine-${engine.id}`}
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all",
|
||||
isSelected
|
||||
? "bg-primary/10 border border-primary/30"
|
||||
: "hover:bg-muted/50 border border-transparent"
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={engine.id.toString()}
|
||||
id={`engine-${engine.id}`}
|
||||
className="mt-0.5"
|
||||
className="sr-only"
|
||||
/>
|
||||
|
||||
{/* 引擎图标 - 根据能力动态显示 */}
|
||||
{(() => {
|
||||
const primaryCap = capabilities[0]
|
||||
const EngineIcon = getEngineIcon(capabilities)
|
||||
const iconConfig = primaryCap ? CAPABILITY_CONFIG[primaryCap] : null
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex h-9 w-9 items-center justify-center rounded-lg",
|
||||
iconConfig?.color || "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
<EngineIcon className="h-4 w-4" />
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* 引擎名称 */}
|
||||
<label
|
||||
htmlFor={`engine-${engine.id}`}
|
||||
className="flex-1 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{engine.name}</span>
|
||||
</div>
|
||||
{/* 能力数量预览 */}
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{capabilities.length > 0
|
||||
? `${capabilities.length} 项扫描能力`
|
||||
: "点击展开查看详情"}
|
||||
</p>
|
||||
</label>
|
||||
|
||||
{/* 展开按钮 */}
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
{expandedEngineId === engine.id.toString() ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
|
||||
{/* 可展开的详情内容 */}
|
||||
<CollapsibleContent>
|
||||
<div className="border-t px-4 py-3 space-y-3">
|
||||
{/* 能力标签 */}
|
||||
{capabilities.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{capabilities.map((capKey) => {
|
||||
const config = CAPABILITY_CONFIG[capKey]
|
||||
return (
|
||||
<Badge
|
||||
key={capKey}
|
||||
variant="outline"
|
||||
className={cn("text-xs font-normal", config?.color)}
|
||||
>
|
||||
{config?.label || capKey}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
暂无能力信息
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-md shrink-0",
|
||||
iconConfig?.color || "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<EngineIcon className="h-4 w-4" />
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
)
|
||||
})}
|
||||
</RadioGroup>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">{engine.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{capabilities.length > 0 ? `${capabilities.length} 项能力` : "无配置"}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && <div className="w-2 h-2 rounded-full bg-primary shrink-0" />}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</RadioGroup>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧引擎详情 */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
{selectedEngine ? (
|
||||
<>
|
||||
<div className="px-4 py-3 border-b bg-muted/30 flex items-center gap-2 shrink-0">
|
||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium truncate">{selectedEngine.name}</h3>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden p-4 gap-3">
|
||||
{selectedCapabilities.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 shrink-0">
|
||||
{selectedCapabilities.map((capKey) => {
|
||||
const config = CAPABILITY_CONFIG[capKey]
|
||||
return (
|
||||
<Badge key={capKey} variant="outline" className={cn("text-xs", config?.color)}>
|
||||
{config?.label || capKey}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 bg-muted/50 rounded-lg border overflow-hidden min-h-0">
|
||||
<pre className="h-full p-3 text-xs font-mono overflow-auto whitespace-pre-wrap break-all">
|
||||
{selectedEngine.configuration || "# 无配置"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Settings2 className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">选择左侧引擎查看配置详情</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<DialogFooter className="px-6 py-4 border-t">
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isSubmitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleInitiate}
|
||||
disabled={!selectedEngineId || isSubmitting}
|
||||
>
|
||||
<Button onClick={handleInitiate} disabled={!selectedEngineId || isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<LoadingSpinner />
|
||||
发起扫描中...
|
||||
发起中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play />
|
||||
<Play className="h-4 w-4" />
|
||||
开始扫描
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -10,27 +10,18 @@ import {
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Zap, Target, Settings, Check, ChevronRight, ChevronLeft, Loader2, ChevronDown, ChevronUp, AlertCircle
|
||||
} from "lucide-react"
|
||||
import { Zap, Settings, ChevronRight, ChevronLeft, Loader2, AlertCircle } from "lucide-react"
|
||||
import { getEngines } from "@/services/engine.service"
|
||||
import { quickScan } from "@/services/scan.service"
|
||||
import { CAPABILITY_CONFIG, getEngineIcon, parseEngineCapabilities } from "@/lib/engine-config"
|
||||
import { TargetValidator } from "@/lib/target-validator"
|
||||
import type { ScanEngine } from "@/types/engine.types"
|
||||
|
||||
// 步骤定义
|
||||
const STEPS = [
|
||||
{ id: 1, title: "输入目标", icon: Target },
|
||||
{ id: 2, title: "选择引擎", icon: Settings },
|
||||
{ id: 3, title: "确认", icon: Check },
|
||||
] as const
|
||||
const STEP_TITLES = ["输入目标", "选择引擎", "确认扫描"]
|
||||
|
||||
interface QuickScanDialogProps {
|
||||
trigger?: React.ReactNode
|
||||
@@ -42,91 +33,48 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
|
||||
const [isLoading, setIsLoading] = React.useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
||||
|
||||
// 表单数据
|
||||
const [targetInput, setTargetInput] = React.useState("")
|
||||
const [selectedEngineId, setSelectedEngineId] = React.useState<string>("")
|
||||
const [expandedEngineId, setExpandedEngineId] = React.useState<string | null>(null)
|
||||
const [engines, setEngines] = React.useState<ScanEngine[]>([])
|
||||
|
||||
// 行号列和输入框的 ref(用于同步滚动)
|
||||
const lineNumbersRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
// 同步输入框和行号列的滚动
|
||||
const handleTextareaScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
|
||||
if (lineNumbersRef.current) {
|
||||
lineNumbersRef.current.scrollTop = e.currentTarget.scrollTop
|
||||
}
|
||||
}
|
||||
|
||||
// 解析目标列表(多行)
|
||||
const parseTargets = (input: string): string[] => {
|
||||
return input
|
||||
.split(/[\n,;]+/)
|
||||
.map(t => t.trim())
|
||||
.filter(t => t.length > 0)
|
||||
}
|
||||
|
||||
// 使用 TargetValidator 验证输入(支持 URL)
|
||||
const validationResults = React.useMemo(() => {
|
||||
const lines = targetInput.split('\n')
|
||||
return TargetValidator.validateInputBatch(lines)
|
||||
}, [targetInput])
|
||||
|
||||
// 过滤掉空行,只保留有效输入
|
||||
const validInputs = validationResults.filter(r => r.isValid && !r.isEmptyLine)
|
||||
const invalidInputs = validationResults.filter(r => !r.isValid)
|
||||
const hasErrors = invalidInputs.length > 0
|
||||
|
||||
|
||||
// 加载引擎列表
|
||||
React.useEffect(() => {
|
||||
if (open && step === 2 && engines.length === 0) {
|
||||
setIsLoading(true)
|
||||
getEngines()
|
||||
.then((data) => {
|
||||
setEngines(data)
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("获取引擎列表失败")
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
.then(setEngines)
|
||||
.catch(() => toast.error("获取引擎列表失败"))
|
||||
.finally(() => setIsLoading(false))
|
||||
}
|
||||
}, [open, step, engines.length])
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
setStep(1)
|
||||
setTargetInput("")
|
||||
setSelectedEngineId("")
|
||||
setExpandedEngineId(null)
|
||||
}
|
||||
|
||||
// 关闭弹框
|
||||
const handleClose = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
if (!isOpen) {
|
||||
resetForm()
|
||||
}
|
||||
if (!isOpen) resetForm()
|
||||
}
|
||||
|
||||
// 验证单个目标(保留用于兼容,但实际使用 TargetValidator)
|
||||
const validateSingleTarget = (target: string): boolean => {
|
||||
const result = TargetValidator.validateInput(target)
|
||||
return result.isValid && !result.isEmptyLine
|
||||
}
|
||||
|
||||
// 验证所有目标
|
||||
const validateTargets = (): { valid: boolean; targets: string[]; invalid: string[] } => {
|
||||
// 使用已计算的验证结果
|
||||
const targets = validInputs.map(r => r.originalInput)
|
||||
const invalid = invalidInputs.map(r => r.originalInput)
|
||||
return { valid: invalid.length === 0, targets, invalid }
|
||||
}
|
||||
|
||||
// 下一步
|
||||
const handleNext = () => {
|
||||
if (step === 1) {
|
||||
if (validInputs.length === 0) {
|
||||
@@ -138,28 +86,21 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (step === 2) {
|
||||
if (!selectedEngineId) {
|
||||
toast.error("请选择扫描引擎")
|
||||
return
|
||||
}
|
||||
if (step === 2 && !selectedEngineId) {
|
||||
toast.error("请选择扫描引擎")
|
||||
return
|
||||
}
|
||||
setStep(step + 1)
|
||||
}
|
||||
|
||||
// 上一步
|
||||
const handlePrev = () => {
|
||||
setStep(step - 1)
|
||||
}
|
||||
const handlePrev = () => setStep(step - 1)
|
||||
|
||||
// 提交扫描
|
||||
const handleSubmit = async () => {
|
||||
const targets = validInputs.map(r => r.originalInput)
|
||||
if (targets.length === 0) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
// 调用快速扫描接口,一次性提交所有目标
|
||||
const response = await quickScan({
|
||||
targets: targets.map(name => ({ name })),
|
||||
engineId: Number(selectedEngineId),
|
||||
@@ -176,9 +117,7 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
|
||||
handleClose(false)
|
||||
} else {
|
||||
toast.error("创建扫描任务失败", {
|
||||
description: targetStats.failed > 0
|
||||
? `${targetStats.failed} 个目标处理失败`
|
||||
: undefined
|
||||
description: targetStats.failed > 0 ? `${targetStats.failed} 个目标处理失败` : undefined
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -188,265 +127,179 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取选中的引擎
|
||||
const selectedEngine = engines.find(e => String(e.id) === selectedEngineId)
|
||||
const parsedTargets = parseTargets(targetInput)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="ghost" size="sm" className="gap-1.5 group">
|
||||
<Zap className="h-4 w-4 transition-transform group-hover:scale-125 group-hover:rotate-12" />
|
||||
快速扫描
|
||||
</Button>
|
||||
<div className="relative group">
|
||||
{/* 边框流光效果 */}
|
||||
<div className="absolute -inset-[1px] rounded-md overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-primary to-transparent animate-border-flow" />
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 relative bg-background border-primary/20"
|
||||
>
|
||||
<Zap className="h-4 w-4 text-primary" />
|
||||
快速扫描
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[550px]">
|
||||
<DialogHeader>
|
||||
<DialogContent className="max-w-[90vw] sm:max-w-[900px] p-0 gap-0">
|
||||
<DialogHeader className="px-6 py-4 border-b">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Zap className="h-5 w-5 text-primary" />
|
||||
快速扫描
|
||||
<span className="text-muted-foreground font-normal text-sm ml-2">
|
||||
步骤 {step}/3 · {STEP_TITLES[step - 1]}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 步骤指示器 */}
|
||||
<div className="flex items-center justify-between px-2 py-4">
|
||||
{STEPS.map((s, index) => (
|
||||
<React.Fragment key={s.id}>
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-full border-2 transition-colors",
|
||||
step === s.id && "border-primary bg-primary text-primary-foreground",
|
||||
step > s.id && "border-primary bg-primary/10 text-primary",
|
||||
step < s.id && "border-muted-foreground/30 text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{step > s.id ? (
|
||||
<Check className="h-5 w-5" />
|
||||
) : (
|
||||
<s.icon className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
step >= s.id ? "text-foreground" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{s.title}
|
||||
</span>
|
||||
</div>
|
||||
{index < STEPS.length - 1 && (
|
||||
<div
|
||||
className={cn(
|
||||
"h-0.5 flex-1 mx-2 rounded-full transition-colors",
|
||||
step > s.id ? "bg-primary" : "bg-muted-foreground/30"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 步骤内容 */}
|
||||
<div className="min-h-[200px] py-4">
|
||||
|
||||
<div className="h-[380px]">
|
||||
{/* 第一步:输入目标 */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="target">目标列表</Label>
|
||||
<div className="flex border rounded-md overflow-hidden h-[280px]">
|
||||
{/* 行号列 - 固定宽度 */}
|
||||
<div className="flex-shrink-0 w-10 border-r bg-muted/50">
|
||||
<div
|
||||
ref={lineNumbersRef}
|
||||
className="py-2 px-1.5 text-right font-mono text-xs text-muted-foreground leading-[1.4] h-full overflow-y-auto scrollbar-hide"
|
||||
>
|
||||
{Array.from({ length: Math.max(targetInput.split('\n').length, 12) }, (_, i) => (
|
||||
<div key={i + 1} className="h-[20px]">
|
||||
{i + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* 输入框区域 - 占据剩余空间 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
id="target"
|
||||
value={targetInput}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setTargetInput(e.target.value)}
|
||||
onScroll={handleTextareaScroll}
|
||||
placeholder={`输入目标,每行一个。支持以下格式:
|
||||
- 域名: example.com
|
||||
- IP: 192.168.1.1
|
||||
- CIDR: 10.0.0.0/8
|
||||
- URL: https://example.com/api/v1`}
|
||||
className="font-mono h-full overflow-y-auto resize-none border-0 focus-visible:ring-0 focus-visible:ring-offset-0 leading-[1.4] text-sm py-2"
|
||||
style={{ lineHeight: '20px' }}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 flex overflow-hidden border-t">
|
||||
<div className="flex-shrink-0 w-12 border-r bg-muted/30">
|
||||
<div
|
||||
ref={lineNumbersRef}
|
||||
className="py-3 px-2 text-right font-mono text-xs text-muted-foreground leading-[1.5] h-full overflow-y-auto scrollbar-hide"
|
||||
>
|
||||
{Array.from({ length: Math.max(targetInput.split('\n').length, 20) }, (_, i) => (
|
||||
<div key={i + 1} className="h-[21px]">{i + 1}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{validInputs.length > 0 ? (
|
||||
<span className="text-primary">{validInputs.length} 个有效目标</span>
|
||||
) : (
|
||||
"0 个目标"
|
||||
)}
|
||||
{hasErrors && (
|
||||
<span className="text-destructive ml-2">
|
||||
{invalidInputs.length} 个无效
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{/* 显示验证错误 */}
|
||||
{hasErrors && (
|
||||
<div className="mt-2 max-h-[60px] overflow-y-auto space-y-1">
|
||||
{invalidInputs.slice(0, 3).map((r) => (
|
||||
<div key={r.lineNumber} className="flex items-center gap-1 text-xs text-destructive">
|
||||
<AlertCircle className="h-3 w-3 flex-shrink-0" />
|
||||
<span>行 {r.lineNumber}: {r.error}</span>
|
||||
</div>
|
||||
))}
|
||||
{invalidInputs.length > 3 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
还有 {invalidInputs.length - 3} 个错误...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Textarea
|
||||
value={targetInput}
|
||||
onChange={(e) => setTargetInput(e.target.value)}
|
||||
onScroll={handleTextareaScroll}
|
||||
placeholder={`每行输入一个目标,支持以下格式:
|
||||
|
||||
域名: example.com, sub.example.com
|
||||
IP地址: 192.168.1.1, 10.0.0.1
|
||||
CIDR网段: 192.168.0.0/24, 10.0.0.0/8
|
||||
URL: https://example.com/api/v1`}
|
||||
className="font-mono h-full overflow-y-auto resize-none border-0 rounded-none focus-visible:ring-0 focus-visible:ring-offset-0 text-sm py-3 px-4"
|
||||
style={{ lineHeight: '21px' }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{hasErrors && (
|
||||
<div className="px-4 py-2 border-t bg-destructive/5 max-h-[60px] overflow-y-auto">
|
||||
{invalidInputs.slice(0, 3).map((r) => (
|
||||
<div key={r.lineNumber} className="flex items-center gap-1 text-xs text-destructive">
|
||||
<AlertCircle className="h-3 w-3 shrink-0" />
|
||||
<span>行 {r.lineNumber}: {r.error}</span>
|
||||
</div>
|
||||
))}
|
||||
{invalidInputs.length > 3 && (
|
||||
<div className="text-xs text-muted-foreground">还有 {invalidInputs.length - 3} 个错误...</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 第二步:选择引擎 */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-2">
|
||||
<Label>扫描引擎</Label>
|
||||
<div className="max-h-[300px] overflow-y-auto" style={{ scrollbarGutter: 'stable' }}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : engines.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
暂无可用引擎
|
||||
</div>
|
||||
) : (
|
||||
<RadioGroup
|
||||
value={selectedEngineId}
|
||||
onValueChange={(value: string) => {
|
||||
setSelectedEngineId(value)
|
||||
setExpandedEngineId(value)
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="space-y-2"
|
||||
>
|
||||
{engines.map((engine) => {
|
||||
const capabilities = parseEngineCapabilities(engine.configuration || '')
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
key={engine.id}
|
||||
open={expandedEngineId === engine.id.toString()}
|
||||
onOpenChange={() => setExpandedEngineId(
|
||||
expandedEngineId === engine.id.toString() ? null : engine.id.toString()
|
||||
)}
|
||||
>
|
||||
<div
|
||||
<div className="flex h-full">
|
||||
<div className="w-[260px] border-r flex flex-col shrink-0">
|
||||
<div className="px-4 py-3 border-b bg-muted/30 shrink-0">
|
||||
<h3 className="text-sm font-medium">选择引擎</h3>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : engines.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">暂无可用引擎</div>
|
||||
) : (
|
||||
<RadioGroup
|
||||
value={selectedEngineId}
|
||||
onValueChange={setSelectedEngineId}
|
||||
disabled={isSubmitting}
|
||||
className="p-2 space-y-1"
|
||||
>
|
||||
{engines.map((engine) => {
|
||||
const capabilities = parseEngineCapabilities(engine.configuration || '')
|
||||
const EngineIcon = getEngineIcon(capabilities)
|
||||
const primaryCap = capabilities[0]
|
||||
const iconConfig = primaryCap ? CAPABILITY_CONFIG[primaryCap] : null
|
||||
const isSelected = selectedEngineId === engine.id.toString()
|
||||
|
||||
return (
|
||||
<label
|
||||
key={engine.id}
|
||||
htmlFor={`engine-${engine.id}`}
|
||||
className={cn(
|
||||
"rounded-lg border transition-all",
|
||||
selectedEngineId === engine.id.toString()
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary/20"
|
||||
: "border-border hover:border-muted-foreground/50 hover:bg-muted/30"
|
||||
"flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all",
|
||||
isSelected
|
||||
? "bg-primary/10 border border-primary/30"
|
||||
: "hover:bg-muted/50 border border-transparent"
|
||||
)}
|
||||
>
|
||||
{/* 引擎主信息 */}
|
||||
<div className="flex items-center gap-3 p-4">
|
||||
{/* Radio 按钮 */}
|
||||
<RadioGroupItem
|
||||
value={engine.id.toString()}
|
||||
id={`engine-${engine.id}`}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
|
||||
{/* 引擎图标 - 根据能力动态显示 */}
|
||||
{(() => {
|
||||
const primaryCap = capabilities[0]
|
||||
const EngineIcon = getEngineIcon(capabilities)
|
||||
const iconConfig = primaryCap ? CAPABILITY_CONFIG[primaryCap] : null
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex h-9 w-9 items-center justify-center rounded-lg",
|
||||
iconConfig?.color || "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
<EngineIcon className="h-4 w-4" />
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* 引擎名称 */}
|
||||
<label
|
||||
htmlFor={`engine-${engine.id}`}
|
||||
className="flex-1 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{engine.name}</span>
|
||||
</div>
|
||||
{/* 能力数量预览 */}
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{capabilities.length > 0
|
||||
? `${capabilities.length} 项扫描能力`
|
||||
: "点击展开查看详情"}
|
||||
</p>
|
||||
</label>
|
||||
|
||||
{/* 展开按钮 */}
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
{expandedEngineId === engine.id.toString() ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<RadioGroupItem value={engine.id.toString()} id={`engine-${engine.id}`} className="sr-only" />
|
||||
<div className={cn("flex h-8 w-8 items-center justify-center rounded-md shrink-0", iconConfig?.color || "bg-muted text-muted-foreground")}>
|
||||
<EngineIcon className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
{/* 可展开的详情内容 */}
|
||||
<CollapsibleContent>
|
||||
<div className="border-t px-4 py-3 space-y-3">
|
||||
{/* 能力标签 */}
|
||||
{capabilities.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{capabilities.map((capKey) => {
|
||||
const config = CAPABILITY_CONFIG[capKey]
|
||||
return (
|
||||
<Badge
|
||||
key={capKey}
|
||||
variant="outline"
|
||||
className={cn("text-xs font-normal", config?.color)}
|
||||
>
|
||||
{config?.label || capKey}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
暂无能力信息
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">{engine.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{capabilities.length > 0 ? `${capabilities.length} 项能力` : "无配置"}</div>
|
||||
</div>
|
||||
{isSelected && <div className="w-2 h-2 rounded-full bg-primary shrink-0" />}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</RadioGroup>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
{selectedEngine ? (
|
||||
<>
|
||||
<div className="px-4 py-3 border-b bg-muted/30 flex items-center gap-2 shrink-0">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium truncate">{selectedEngine.name}</h3>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden p-4 gap-3">
|
||||
{(() => {
|
||||
const caps = parseEngineCapabilities(selectedEngine.configuration || '')
|
||||
return caps.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 shrink-0">
|
||||
{caps.map((capKey) => {
|
||||
const config = CAPABILITY_CONFIG[capKey]
|
||||
return (
|
||||
<Badge key={capKey} variant="outline" className={cn("text-xs", config?.color)}>
|
||||
{config?.label || capKey}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Collapsible>
|
||||
)
|
||||
})}
|
||||
</RadioGroup>
|
||||
)
|
||||
})()}
|
||||
<div className="flex-1 bg-muted/50 rounded-lg border overflow-hidden min-h-0">
|
||||
<pre className="h-full p-3 text-xs font-mono overflow-auto whitespace-pre-wrap break-all">
|
||||
{selectedEngine.configuration || "# 无配置"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Settings className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">选择左侧引擎查看配置详情</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -454,61 +307,94 @@ export function QuickScanDialog({ trigger }: QuickScanDialogProps) {
|
||||
|
||||
{/* 第三步:确认 */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border bg-muted/50 p-4 space-y-3">
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">目标</span>
|
||||
<div className="mt-1 max-h-[100px] overflow-y-auto">
|
||||
<div className="flex h-full">
|
||||
<div className="w-[260px] border-r flex flex-col shrink-0">
|
||||
<div className="px-4 py-3 border-b bg-muted/30 shrink-0">
|
||||
<h3 className="text-sm font-medium">扫描目标</h3>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-1">
|
||||
{validInputs.map((r, idx) => (
|
||||
<div key={idx} className="font-mono text-sm">{r.originalInput}</div>
|
||||
<div key={idx} className="font-mono text-xs truncate">{r.originalInput}</div>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">共 {validInputs.length} 个目标</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2 border-t">
|
||||
<span className="text-sm text-muted-foreground">引擎</span>
|
||||
<Badge variant="secondary">{selectedEngine?.name}</Badge>
|
||||
<div className="px-4 py-3 border-t bg-muted/30 text-xs text-muted-foreground">
|
||||
共 {validInputs.length} 个目标
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b bg-muted/30 flex items-center gap-2 shrink-0">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium truncate">{selectedEngine?.name}</h3>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col overflow-hidden p-4 gap-3">
|
||||
{(() => {
|
||||
const caps = parseEngineCapabilities(selectedEngine?.configuration || '')
|
||||
return caps.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 shrink-0">
|
||||
{caps.map((capKey) => {
|
||||
const config = CAPABILITY_CONFIG[capKey]
|
||||
return (
|
||||
<Badge key={capKey} variant="outline" className={cn("text-xs", config?.color)}>
|
||||
{config?.label || capKey}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<div className="flex-1 bg-muted/50 rounded-lg border overflow-hidden min-h-0">
|
||||
<pre className="h-full p-3 text-xs font-mono overflow-auto whitespace-pre-wrap break-all">
|
||||
{selectedEngine?.configuration || "# 无配置"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
确认以上信息无误后,点击开始扫描
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex justify-between pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePrev}
|
||||
disabled={step === 1}
|
||||
className={cn(step === 1 && "invisible")}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
上一步
|
||||
</Button>
|
||||
|
||||
{step < 3 ? (
|
||||
<Button onClick={handleNext}>
|
||||
下一步
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
|
||||
<div className="border-t flex items-center justify-between px-4 py-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{step === 1 && (
|
||||
<>
|
||||
支持: 域名、IP、CIDR、URL
|
||||
{validInputs.length > 0 && (
|
||||
<span className="text-primary font-medium ml-2">{validInputs.length} 个有效目标</span>
|
||||
)}
|
||||
{hasErrors && (
|
||||
<span className="text-destructive ml-2">{invalidInputs.length} 个无效</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handlePrev} disabled={step === 1} className={cn(step === 1 && "invisible")}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
上一步
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
创建中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="h-4 w-4 mr-2" />
|
||||
开始扫描
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{step < 3 ? (
|
||||
<Button size="sm" onClick={handleNext}>
|
||||
下一步
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
创建中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="h-4 w-4" />
|
||||
开始扫描
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -12,11 +12,11 @@ import type { Vulnerability, VulnerabilitySeverity } from "@/types/vulnerability
|
||||
|
||||
// 统一的漏洞严重程度颜色配置(与图表一致)
|
||||
const severityConfig: Record<VulnerabilitySeverity, { label: string; className: string }> = {
|
||||
critical: { label: "严重", className: "bg-[#da3633]/10 text-[#da3633] border border-[#da3633]/20 dark:text-[#f85149]" },
|
||||
high: { label: "高危", className: "bg-[#d29922]/10 text-[#d29922] border border-[#d29922]/20" },
|
||||
medium: { label: "中危", className: "bg-[#d4a72c]/10 text-[#d4a72c] border border-[#d4a72c]/20" },
|
||||
low: { label: "低危", className: "bg-[#238636]/10 text-[#238636] border border-[#238636]/20 dark:text-[#3fb950]" },
|
||||
info: { label: "信息", className: "bg-[#848d97]/10 text-[#848d97] border border-[#848d97]/20" },
|
||||
critical: { label: "Critical", className: "bg-[#da3633]/10 text-[#da3633] border border-[#da3633]/20 dark:text-[#f85149]" },
|
||||
high: { label: "High", className: "bg-[#d29922]/10 text-[#d29922] border border-[#d29922]/20" },
|
||||
medium: { label: "Medium", className: "bg-[#d4a72c]/10 text-[#d4a72c] border border-[#d4a72c]/20" },
|
||||
low: { label: "Low", className: "bg-[#238636]/10 text-[#238636] border border-[#238636]/20 dark:text-[#3fb950]" },
|
||||
info: { label: "Info", className: "bg-[#848d97]/10 text-[#848d97] border border-[#848d97]/20" },
|
||||
}
|
||||
|
||||
interface ColumnActions {
|
||||
|
||||
@@ -16,11 +16,11 @@ import { toast } from "sonner"
|
||||
import type { Vulnerability, VulnerabilitySeverity } from "@/types/vulnerability.types"
|
||||
|
||||
const severityConfig: Record<VulnerabilitySeverity, { label: string; variant: "default" | "secondary" | "destructive" | "outline"; color: string; className: string }> = {
|
||||
critical: { label: "严重", variant: "outline", color: "bg-[#da3633]", className: "bg-[#da3633]/10 text-[#da3633] border-[#da3633]/20 dark:text-[#f85149]" },
|
||||
high: { label: "高危", variant: "outline", color: "bg-[#d29922]", className: "bg-[#d29922]/10 text-[#d29922] border-[#d29922]/20" },
|
||||
medium: { label: "中危", variant: "outline", color: "bg-[#d4a72c]", className: "bg-[#d4a72c]/10 text-[#d4a72c] border-[#d4a72c]/20" },
|
||||
low: { label: "低危", variant: "outline", color: "bg-[#238636]", className: "bg-[#238636]/10 text-[#238636] border-[#238636]/20 dark:text-[#3fb950]" },
|
||||
info: { label: "信息", variant: "outline", color: "bg-[#848d97]", className: "bg-[#848d97]/10 text-[#848d97] border-[#848d97]/20" },
|
||||
critical: { label: "Critical", variant: "outline", color: "bg-[#da3633]", className: "bg-[#da3633]/10 text-[#da3633] border-[#da3633]/20 dark:text-[#f85149]" },
|
||||
high: { label: "High", variant: "outline", color: "bg-[#d29922]", className: "bg-[#d29922]/10 text-[#d29922] border-[#d29922]/20" },
|
||||
medium: { label: "Medium", variant: "outline", color: "bg-[#d4a72c]", className: "bg-[#d4a72c]/10 text-[#d4a72c] border-[#d4a72c]/20" },
|
||||
low: { label: "Low", variant: "outline", color: "bg-[#238636]", className: "bg-[#238636]/10 text-[#238636] border-[#238636]/20 dark:text-[#3fb950]" },
|
||||
info: { label: "Info", variant: "outline", color: "bg-[#848d97]", className: "bg-[#848d97]/10 text-[#848d97] border-[#848d97]/20" },
|
||||
}
|
||||
|
||||
interface VulnerabilityDetailDialogProps {
|
||||
|
||||
Reference in New Issue
Block a user