Compare commits

...

3 Commits

Author SHA1 Message Date
yyhuni
9c3833d13d 更新:ui 2025-12-25 11:06:00 +08:00
github-actions[bot]
92f3b722ef chore: bump version to v1.1.8 2025-12-25 02:16:12 +00:00
yyhuni
9ef503c666 更新:ui 2025-12-25 10:12:06 +08:00
8 changed files with 443 additions and 585 deletions

View File

@@ -1 +1 @@
v1.1.7
v1.1.8

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 && (
<>
支持: 域名IPCIDRURL
{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>

View File

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

View File

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