This commit is contained in:
yyhuni
2025-12-28 19:55:57 +08:00
parent 99d384ce29
commit fba7f7c508
26 changed files with 1335 additions and 623 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
import Link from "next/link"
import { useState, useMemo, type FormEvent } from "react"
import { GitBranch, Search, RefreshCw, Settings, Trash2, FolderOpen } from "lucide-react"
import { GitBranch, Search, RefreshCw, Settings, Trash2, FolderOpen, Plus } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
@@ -173,7 +173,8 @@ export default function NucleiReposPage() {
</div>
</div>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4" />
</Button>
</div>

View File

@@ -229,18 +229,22 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
isActive={current === normalize(subItem.url)}
>
<Link href={subItem.url}>
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
{item.items?.map((subItem) => {
const subUrl = normalize(subItem.url)
const isSubActive = current === subUrl || current.startsWith(subUrl + "/")
return (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
isActive={isSubActive}
>
<Link href={subItem.url}>
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
)
})}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>

View File

@@ -5,7 +5,7 @@ import { ColumnDef } from "@tanstack/react-table"
import { Checkbox } from "@/components/ui/checkbox"
import { Badge } from "@/components/ui/badge"
import { DataTableColumnHeader } from "@/components/ui/data-table/column-header"
import { TruncatedUrlCell } from "@/components/ui/truncated-cell"
import { ExpandableCell } from "@/components/ui/data-table/expandable-cell"
import type { Directory } from "@/types/directory.types"
/**
@@ -88,10 +88,9 @@ export function createDirectoryColumns({
header: ({ column }) => (
<DataTableColumnHeader column={column} title="URL" />
),
cell: ({ row }) => {
const url = row.getValue("url") as string
return <TruncatedUrlCell value={url} />
},
cell: ({ row }) => (
<ExpandableCell value={row.getValue("url")} />
),
},
// Status 列
{
@@ -99,6 +98,7 @@ export function createDirectoryColumns({
size: 80,
minSize: 60,
maxSize: 120,
enableResizing: false,
meta: { title: "Status" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
@@ -156,6 +156,7 @@ export function createDirectoryColumns({
size: 120,
minSize: 80,
maxSize: 200,
enableResizing: false,
meta: { title: "Content Type" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Content Type" />
@@ -190,6 +191,7 @@ export function createDirectoryColumns({
size: 150,
minSize: 120,
maxSize: 200,
enableResizing: false,
meta: { title: "Created At" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Created At" />

View File

@@ -2,8 +2,6 @@
import * as React from "react"
import type { ColumnDef } from "@tanstack/react-table"
import { IconPlus } from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import { UnifiedDataTable } from "@/components/ui/data-table"
import type { FilterField } from "@/components/common/smart-filter-input"
import type { Directory } from "@/types/directory.types"
@@ -114,19 +112,13 @@ export function DirectoriesDataTable({
onBulkDelete={onBulkDelete}
bulkDeleteLabel="Delete"
showAddButton={false}
// 批量添加按钮
onBulkAdd={onBulkAdd}
bulkAddLabel="批量添加"
// 下载
downloadOptions={downloadOptions.length > 0 ? downloadOptions : undefined}
// 空状态
emptyMessage="暂无数据"
// 自定义工具栏按钮
toolbarRight={
onBulkAdd ? (
<Button onClick={onBulkAdd} size="sm" variant="outline">
<IconPlus className="h-4 w-4" />
</Button>
) : undefined
}
/>
)
}

View File

@@ -2,12 +2,11 @@
import React from "react"
import { ColumnDef } from "@tanstack/react-table"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { DataTableColumnHeader } from "@/components/ui/data-table/column-header"
import type { Endpoint } from "@/types/endpoint.types"
import { TruncatedCell, TruncatedUrlCell } from "@/components/ui/truncated-cell"
import { ExpandableCell, ExpandableTagList } from "@/components/ui/data-table/expandable-cell"
interface CreateColumnsProps {
formatDate: (dateString: string) => string
@@ -45,37 +44,6 @@ function HttpStatusBadge({ statusCode }: { statusCode: number | null | undefined
)
}
/**
* Body Preview 单元格组件 - 最多显示3行超出折叠点击展开查看完整内容
*/
function BodyPreviewCell({ value }: { value: string | null | undefined }) {
const [expanded, setExpanded] = React.useState(false)
if (!value) {
return <span className="text-muted-foreground text-sm">-</span>
}
return (
<div className="flex flex-col gap-1">
<div
className={`text-sm text-muted-foreground break-all leading-relaxed whitespace-normal cursor-pointer hover:text-foreground transition-colors ${!expanded ? 'line-clamp-3' : ''}`}
onClick={() => setExpanded(!expanded)}
title={expanded ? "点击收起" : "点击展开"}
>
{value}
</div>
{value.length > 100 && (
<button
onClick={() => setExpanded(!expanded)}
className="text-xs text-primary hover:underline self-start"
>
{expanded ? "收起" : "展开"}
</button>
)}
</div>
)
}
export function createEndpointColumns({
formatDate,
}: CreateColumnsProps): ColumnDef<Endpoint>[] {
@@ -115,10 +83,9 @@ export function createEndpointColumns({
size: 400,
minSize: 200,
maxSize: 700,
cell: ({ row }) => {
const url = row.getValue("url") as string
return <TruncatedUrlCell value={url} />
},
cell: ({ row }) => (
<ExpandableCell value={row.getValue("url")} />
),
},
{
accessorKey: "title",
@@ -130,7 +97,7 @@ export function createEndpointColumns({
minSize: 100,
maxSize: 300,
cell: ({ row }) => (
<TruncatedCell value={row.getValue("title")} maxLength="title" />
<ExpandableCell value={row.getValue("title")} />
),
},
{
@@ -174,7 +141,7 @@ export function createEndpointColumns({
minSize: 100,
maxSize: 300,
cell: ({ row }) => (
<TruncatedCell value={row.getValue("location")} maxLength="location" />
<ExpandableCell value={row.getValue("location")} />
),
},
{
@@ -187,7 +154,7 @@ export function createEndpointColumns({
minSize: 80,
maxSize: 200,
cell: ({ row }) => (
<TruncatedCell value={row.getValue("webserver")} maxLength="webServer" />
<ExpandableCell value={row.getValue("webserver")} />
),
},
{
@@ -200,7 +167,7 @@ export function createEndpointColumns({
minSize: 80,
maxSize: 200,
cell: ({ row }) => (
<TruncatedCell value={row.getValue("contentType")} maxLength="contentType" />
<ExpandableCell value={row.getValue("contentType")} />
),
},
{
@@ -213,16 +180,12 @@ export function createEndpointColumns({
minSize: 150,
cell: ({ row }) => {
const tech = (row.getValue("tech") as string[] | null | undefined) || []
if (!tech.length) return <span className="text-sm text-muted-foreground">-</span>
return (
<div className="flex flex-wrap items-center gap-1.5">
{tech.map((t, index) => (
<Badge key={index} variant="outline" className="text-xs">
{t}
</Badge>
))}
</div>
<ExpandableTagList
items={tech}
maxVisible={3}
variant="outline"
/>
)
},
},
@@ -235,7 +198,7 @@ export function createEndpointColumns({
size: 350,
minSize: 250,
cell: ({ row }) => (
<BodyPreviewCell value={row.getValue("bodyPreview")} />
<ExpandableCell value={row.getValue("bodyPreview")} />
),
},
{
@@ -264,21 +227,12 @@ export function createEndpointColumns({
maxSize: 250,
cell: ({ row }) => {
const tags = (row.getValue("tags") as string[] | null | undefined) || []
if (!tags.length) {
return <span className="text-muted-foreground text-sm">-</span>
}
return (
<div className="flex flex-wrap gap-1">
{tags.map((tag, idx) => (
<Badge
key={idx}
variant={/xss|sqli|idor|rce|ssrf|lfi|rfi|xxe|csrf|open.?redirect|interesting/i.test(tag) ? "destructive" : "secondary"}
className="text-xs"
>
{tag}
</Badge>
))}
</div>
<ExpandableTagList
items={tags}
maxVisible={3}
variant="secondary"
/>
)
},
enableSorting: false,

View File

@@ -2,8 +2,6 @@
import * as React from "react"
import type { ColumnDef } from "@tanstack/react-table"
import { IconPlus } from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import { UnifiedDataTable } from "@/components/ui/data-table"
import type { FilterField } from "@/components/common/smart-filter-input"
import type { DownloadOption, PaginationState } from "@/types/data-table.types"
@@ -109,14 +107,6 @@ export function EndpointsDataTable<TData extends { id: number | string }, TValue
})
}
// 自定义工具栏右侧按钮
const toolbarRightContent = onBulkAdd ? (
<Button onClick={onBulkAdd} size="sm" variant="outline">
<IconPlus className="h-4 w-4" />
</Button>
) : undefined
return (
<UnifiedDataTable
data={data}
@@ -140,12 +130,13 @@ export function EndpointsDataTable<TData extends { id: number | string }, TValue
showBulkDelete={false}
onAddNew={onAddNew}
addButtonLabel={addButtonText}
// 批量添加按钮
onBulkAdd={onBulkAdd}
bulkAddLabel="批量添加"
// 下载
downloadOptions={downloadOptions.length > 0 ? downloadOptions : undefined}
// 空状态
emptyMessage="No results"
// 自定义工具栏按钮
toolbarRight={toolbarRightContent}
/>
)
}

View File

@@ -1,5 +1,6 @@
"use client"
import React from "react"
import { ColumnDef } from "@tanstack/react-table"
import { Checkbox } from "@/components/ui/checkbox"
import { Badge } from "@/components/ui/badge"
@@ -10,6 +11,38 @@ interface ColumnOptions {
formatDate: (date: string) => string
}
/**
* 关键词列表单元格 - 默认显示3个超出可展开
*/
function KeywordListCell({ keywords }: { keywords: string[] }) {
const [expanded, setExpanded] = React.useState(false)
if (!keywords || keywords.length === 0) return <span className="text-muted-foreground">-</span>
const displayKeywords = expanded ? keywords : keywords.slice(0, 3)
const hasMore = keywords.length > 3
return (
<div className="flex flex-col gap-1">
<div className="font-mono text-xs space-y-0.5">
{displayKeywords.map((kw, idx) => (
<div key={idx} className={expanded ? "break-all" : "truncate"}>
{kw}
</div>
))}
</div>
{hasMore && (
<button
onClick={() => setExpanded(!expanded)}
className="text-xs text-primary hover:underline self-start"
>
{expanded ? "收起" : "展开"}
</button>
)}
</div>
)
}
/**
* 创建 EHole 指纹表格列定义
*/
@@ -70,8 +103,8 @@ export function createEholeFingerprintColumns({
</Badge>
)
},
enableResizing: true,
size: 100,
enableResizing: false,
size: 120,
},
// 匹配位置
{
@@ -88,7 +121,7 @@ export function createEholeFingerprintColumns({
</Badge>
)
},
enableResizing: true,
enableResizing: false,
size: 100,
},
// 关键词
@@ -98,17 +131,7 @@ export function createEholeFingerprintColumns({
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Keyword" />
),
cell: ({ row }) => {
const keywords = row.getValue("keyword") as string[]
if (!keywords || keywords.length === 0) return "-"
return (
<div className="font-mono text-xs text-muted-foreground space-y-0.5">
{keywords.map((kw, idx) => (
<div key={idx}>{kw}</div>
))}
</div>
)
},
cell: ({ row }) => <KeywordListCell keywords={row.getValue("keyword") || []} />,
enableResizing: true,
size: 300,
},
@@ -124,7 +147,7 @@ export function createEholeFingerprintColumns({
if (!type || type === "-") return "-"
return <Badge variant="outline">{type}</Badge>
},
enableResizing: true,
enableResizing: false,
size: 100,
},
// 重点资产
@@ -135,13 +158,11 @@ export function createEholeFingerprintColumns({
<DataTableColumnHeader column={column} title="Important" />
),
cell: ({ row }) => {
const isImportant = row.getValue("isImportant") as boolean
return isImportant ? (
<Badge variant="destructive"></Badge>
) : null
const isImportant = row.getValue("isImportant")
return <span>{String(isImportant)}</span>
},
enableResizing: true,
size: 80,
enableResizing: false,
size: 100,
},
// 创建时间
{
@@ -158,7 +179,7 @@ export function createEholeFingerprintColumns({
</div>
)
},
enableResizing: true,
enableResizing: false,
size: 160,
},
]

View File

@@ -12,26 +12,22 @@ interface ColumnOptions {
}
/**
* 规则详情单元格组件 - 默认显示3条超出可展开
* 规则详情单元格组件 - 显示原始 JSON 数据
*/
function RuleDetailsCell({ rules }: { rules: any[] }) {
const [expanded, setExpanded] = React.useState(false)
if (!rules || rules.length === 0) return <span className="text-muted-foreground">-</span>
const displayRules = expanded ? rules : rules.slice(0, 3)
const hasMore = rules.length > 3
const displayRules = expanded ? rules : rules.slice(0, 2)
const hasMore = rules.length > 2
return (
<div className="flex flex-col gap-1">
<div
className="font-mono text-xs text-muted-foreground space-y-0.5 max-w-md cursor-pointer hover:text-foreground transition-colors"
onClick={() => hasMore && setExpanded(!expanded)}
title={hasMore ? (expanded ? "点击收起" : "点击展开") : undefined}
>
<div className="font-mono text-xs space-y-0.5">
{displayRules.map((r, idx) => (
<div key={idx} className={expanded ? "break-all" : "truncate"}>
<span className="text-primary">{r.label}</span>: {r.feature}
{JSON.stringify(r)}
</div>
))}
</div>
@@ -101,14 +97,10 @@ export function createGobyFingerprintColumns({
),
cell: ({ row }) => {
const logic = row.getValue("logic") as string
return (
<Badge variant="outline" className="font-mono text-xs">
{logic}
</Badge>
)
return <span className="font-mono text-xs">{logic}</span>
},
enableResizing: true,
size: 120,
enableResizing: false,
size: 100,
},
// 规则数量
{
@@ -119,14 +111,10 @@ export function createGobyFingerprintColumns({
),
cell: ({ row }) => {
const rules = row.getValue("rule") as any[]
return (
<Badge variant="secondary">
{rules?.length || 0}
</Badge>
)
return <span>{rules?.length || 0}</span>
},
enableResizing: true,
size: 100,
enableResizing: false,
size: 80,
},
// 规则详情
{
@@ -154,7 +142,7 @@ export function createGobyFingerprintColumns({
</div>
)
},
enableResizing: true,
enableResizing: false,
size: 160,
},
]

View File

@@ -1,10 +1,10 @@
"use client"
import { useState } from "react"
import React from "react"
import { ColumnDef } from "@tanstack/react-table"
import { Checkbox } from "@/components/ui/checkbox"
import { Badge } from "@/components/ui/badge"
import { DataTableColumnHeader } from "@/components/ui/data-table/column-header"
import { ExpandableCell } from "@/components/ui/data-table/expandable-cell"
import type { WappalyzerFingerprint } from "@/types/fingerprint.types"
interface ColumnOptions {
@@ -12,25 +12,24 @@ interface ColumnOptions {
}
/**
* 可展开文本单元格组件
* JSON 对象展示单元格 - 显示原始 JSON
*/
function ExpandableTextCell({ value, maxLength = 80 }: { value: string | null | undefined; maxLength?: number }) {
const [expanded, setExpanded] = useState(false)
function JsonCell({ data }: { data: any }) {
const [expanded, setExpanded] = React.useState(false)
if (!value) return <span className="text-muted-foreground">-</span>
if (!data || (typeof data === 'object' && Object.keys(data).length === 0)) {
return <span className="text-muted-foreground">-</span>
}
const needsExpand = value.length > maxLength
const jsonStr = JSON.stringify(data)
const isLong = jsonStr.length > 50
return (
<div className="flex flex-col gap-1 overflow-hidden w-full">
<div
className={`text-sm text-muted-foreground break-words cursor-pointer hover:text-foreground transition-colors ${!expanded ? 'line-clamp-2' : ''}`}
onClick={() => needsExpand && setExpanded(!expanded)}
title={needsExpand ? (expanded ? "点击收起" : "点击展开") : undefined}
>
{value}
<div className="flex flex-col gap-1">
<div className={`font-mono text-xs ${expanded ? "break-all whitespace-pre-wrap" : "truncate"}`}>
{expanded ? JSON.stringify(data, null, 2) : jsonStr}
</div>
{needsExpand && (
{isLong && (
<button
onClick={() => setExpanded(!expanded)}
className="text-xs text-primary hover:underline self-start"
@@ -43,23 +42,28 @@ function ExpandableTextCell({ value, maxLength = 80 }: { value: string | null |
}
/**
* 可展开链接单元格组件
* 数组展示单元格 - 显示原始数组
*/
function ExpandableLinkCell({ value, maxLength = 50 }: { value: string | null | undefined; maxLength?: number }) {
const [expanded, setExpanded] = useState(false)
function ArrayCell({ data }: { data: any[] }) {
const [expanded, setExpanded] = React.useState(false)
if (!value) return <span className="text-muted-foreground">-</span>
if (!data || data.length === 0) {
return <span className="text-muted-foreground">-</span>
}
const needsExpand = value.length > maxLength
const displayItems = expanded ? data : data.slice(0, 2)
const hasMore = data.length > 2
return (
<div className="flex flex-col gap-1 overflow-hidden w-full">
<div
className={`text-sm text-muted-foreground break-words ${!expanded ? 'line-clamp-1' : ''}`}
>
{value}
<div className="flex flex-col gap-1">
<div className="font-mono text-xs space-y-0.5">
{displayItems.map((item, idx) => (
<div key={idx} className={expanded ? "break-all" : "truncate"}>
{typeof item === 'object' ? JSON.stringify(item) : String(item)}
</div>
))}
</div>
{needsExpand && (
{hasMore && (
<button
onClick={() => setExpanded(!expanded)}
className="text-xs text-primary hover:underline self-start"
@@ -116,34 +120,98 @@ export function createWappalyzerFingerprintColumns({
enableResizing: true,
size: 180,
},
// 分类
// 分类 - 直接显示数组
{
accessorKey: "cats",
meta: { title: "Categories" },
meta: { title: "Cats" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Categories" />
<DataTableColumnHeader column={column} title="Cats" />
),
cell: ({ row }) => {
const cats = row.getValue("cats") as number[]
if (!cats || cats.length === 0) return "-"
return (
<div className="flex flex-wrap gap-1">
{cats.slice(0, 3).map((cat) => (
<Badge key={cat} variant="secondary" className="text-xs">
{cat}
</Badge>
))}
{cats.length > 3 && (
<Badge variant="outline" className="text-xs">
+{cats.length - 3}
</Badge>
)}
</div>
)
return <span className="font-mono text-xs">{JSON.stringify(cats)}</span>
},
enableResizing: true,
size: 120,
},
// Cookies
{
accessorKey: "cookies",
meta: { title: "Cookies" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Cookies" />
),
cell: ({ row }) => <JsonCell data={row.getValue("cookies")} />,
enableResizing: true,
size: 200,
},
// Headers
{
accessorKey: "headers",
meta: { title: "Headers" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Headers" />
),
cell: ({ row }) => <JsonCell data={row.getValue("headers")} />,
enableResizing: true,
size: 200,
},
// Script Src
{
accessorKey: "scriptSrc",
meta: { title: "ScriptSrc" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title="ScriptSrc" />
),
cell: ({ row }) => <ArrayCell data={row.getValue("scriptSrc")} />,
enableResizing: true,
size: 200,
},
// JS
{
accessorKey: "js",
meta: { title: "JS" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title="JS" />
),
cell: ({ row }) => <ArrayCell data={row.getValue("js")} />,
enableResizing: true,
size: 180,
},
// Implies
{
accessorKey: "implies",
meta: { title: "Implies" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Implies" />
),
cell: ({ row }) => <ArrayCell data={row.getValue("implies")} />,
enableResizing: true,
size: 180,
},
// Meta
{
accessorKey: "meta",
meta: { title: "Meta" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Meta" />
),
cell: ({ row }) => <JsonCell data={row.getValue("meta")} />,
enableResizing: true,
size: 200,
},
// HTML
{
accessorKey: "html",
meta: { title: "HTML" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title="HTML" />
),
cell: ({ row }) => <ArrayCell data={row.getValue("html")} />,
enableResizing: true,
size: 200,
},
// 描述
{
accessorKey: "description",
@@ -151,7 +219,7 @@ export function createWappalyzerFingerprintColumns({
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Description" />
),
cell: ({ row }) => <ExpandableTextCell value={row.getValue("description")} />,
cell: ({ row }) => <ExpandableCell value={row.getValue("description")} maxLines={2} />,
enableResizing: true,
size: 250,
},
@@ -162,37 +230,20 @@ export function createWappalyzerFingerprintColumns({
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Website" />
),
cell: ({ row }) => <ExpandableLinkCell value={row.getValue("website")} />,
cell: ({ row }) => <ExpandableCell value={row.getValue("website")} variant="url" maxLines={1} />,
enableResizing: true,
size: 200,
},
// 检测方式数量
// CPE
{
id: "detectionMethods",
meta: { title: "Detection" },
accessorKey: "cpe",
meta: { title: "CPE" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Detection" />
<DataTableColumnHeader column={column} title="CPE" />
),
cell: ({ row }) => {
const fp = row.original
const methods: string[] = []
if (fp.cookies && Object.keys(fp.cookies).length > 0) methods.push("cookies")
if (fp.headers && Object.keys(fp.headers).length > 0) methods.push("headers")
if (fp.scriptSrc && fp.scriptSrc.length > 0) methods.push("script")
if (fp.js && fp.js.length > 0) methods.push("js")
if (fp.meta && Object.keys(fp.meta).length > 0) methods.push("meta")
if (fp.html && fp.html.length > 0) methods.push("html")
if (methods.length === 0) return "-"
return (
<div className="flex flex-wrap gap-1">
{methods.map((m) => (
<Badge key={m} variant="outline" className="text-xs">
{m}
</Badge>
))}
</div>
)
const cpe = row.getValue("cpe") as string
return cpe ? <span className="font-mono text-xs">{cpe}</span> : "-"
},
enableResizing: true,
size: 180,
@@ -212,7 +263,7 @@ export function createWappalyzerFingerprintColumns({
</div>
)
},
enableResizing: true,
enableResizing: false,
size: 160,
},
]

View File

@@ -11,7 +11,7 @@ import {
} from "@/components/ui/popover"
import { DataTableColumnHeader } from "@/components/ui/data-table/column-header"
import type { IPAddress } from "@/types/ip-address.types"
import { TruncatedCell } from "@/components/ui/truncated-cell"
import { ExpandableCell } from "@/components/ui/data-table/expandable-cell"
export function createIPAddressColumns(params: {
formatDate: (value: string) => string
@@ -57,7 +57,7 @@ export function createIPAddressColumns(params: {
<DataTableColumnHeader column={column} title="IP Address" />
),
cell: ({ row }) => (
<TruncatedCell value={row.original.ip} maxLength="ip" mono />
<ExpandableCell value={row.original.ip} />
),
},
// host 列
@@ -83,7 +83,7 @@ export function createIPAddressColumns(params: {
return (
<div className="flex flex-col gap-1">
{displayHosts.map((host, index) => (
<TruncatedCell key={index} value={host} maxLength="host" mono />
<ExpandableCell key={index} value={host} maxLines={1} />
))}
{hasMore && (
<Popover>
@@ -97,7 +97,7 @@ export function createIPAddressColumns(params: {
<h4 className="font-medium text-sm">All Hosts ({hosts.length})</h4>
<div className="flex flex-col gap-1 max-h-48 overflow-y-auto">
{hosts.map((host, index) => (
<span key={index} className="text-sm font-mono break-all">
<span key={index} className="text-sm break-all">
{host}
</span>
))}
@@ -116,6 +116,7 @@ export function createIPAddressColumns(params: {
size: 150,
minSize: 120,
maxSize: 200,
enableResizing: false,
meta: { title: "Created At" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Created At" />

View File

@@ -15,6 +15,7 @@ import {
// 导入图标组件
import { MoreHorizontal, Play, Calendar, Edit, Trash2, Eye } from "lucide-react"
import { DataTableColumnHeader } from "@/components/ui/data-table/column-header"
import { ExpandableCell } from "@/components/ui/data-table/expandable-cell"
import {
Tooltip,
TooltipContent,
@@ -161,21 +162,9 @@ export const createOrganizationColumns = ({
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Description" />
),
cell: ({ row }) => {
const description = row.getValue("description") as string
if (!description) {
return <span className="text-muted-foreground">-</span>
}
return (
<div className="flex-1 min-w-0">
<span className="text-sm text-muted-foreground break-all leading-relaxed whitespace-normal">
{description}
</span>
</div>
)
},
cell: ({ row }) => (
<ExpandableCell value={row.getValue("description")} variant="muted" />
),
},
// Total Targets 列

View File

@@ -172,6 +172,7 @@ export const createEngineColumns = ({
size: 80,
minSize: 60,
maxSize: 100,
enableResizing: false,
cell: ({ row }) => {
const features = parseEngineFeatures(row.original)
return <FeatureStatus enabled={features.subdomain_discovery} />
@@ -189,6 +190,7 @@ export const createEngineColumns = ({
size: 80,
minSize: 60,
maxSize: 100,
enableResizing: false,
cell: ({ row }) => {
const features = parseEngineFeatures(row.original)
return <FeatureStatus enabled={features.port_scan} />
@@ -206,6 +208,7 @@ export const createEngineColumns = ({
size: 80,
minSize: 60,
maxSize: 100,
enableResizing: false,
cell: ({ row }) => {
const features = parseEngineFeatures(row.original)
return <FeatureStatus enabled={features.site_scan} />
@@ -223,6 +226,7 @@ export const createEngineColumns = ({
size: 80,
minSize: 60,
maxSize: 100,
enableResizing: false,
cell: ({ row }) => {
const features = parseEngineFeatures(row.original)
return <FeatureStatus enabled={features.directory_scan} />
@@ -240,6 +244,7 @@ export const createEngineColumns = ({
size: 80,
minSize: 60,
maxSize: 100,
enableResizing: false,
cell: ({ row }) => {
const features = parseEngineFeatures(row.original)
return <FeatureStatus enabled={features.url_fetch} />
@@ -257,6 +262,7 @@ export const createEngineColumns = ({
size: 80,
minSize: 60,
maxSize: 100,
enableResizing: false,
cell: ({ row }) => {
const features = parseEngineFeatures(row.original)
return <FeatureStatus enabled={features.osint} />
@@ -274,6 +280,7 @@ export const createEngineColumns = ({
size: 80,
minSize: 60,
maxSize: 100,
enableResizing: false,
cell: ({ row }) => {
const features = parseEngineFeatures(row.original)
return <FeatureStatus enabled={features.vulnerability_scan} />
@@ -291,6 +298,7 @@ export const createEngineColumns = ({
size: 80,
minSize: 60,
maxSize: 100,
enableResizing: false,
cell: ({ row }) => {
const features = parseEngineFeatures(row.original)
return <FeatureStatus enabled={features.waf_detection} />
@@ -308,6 +316,7 @@ export const createEngineColumns = ({
size: 80,
minSize: 60,
maxSize: 100,
enableResizing: false,
cell: ({ row }) => {
const features = parseEngineFeatures(row.original)
return <FeatureStatus enabled={features.screenshot} />

View File

@@ -370,6 +370,7 @@ export const createScanHistoryColumns = ({
size: 120,
minSize: 80,
maxSize: 180,
enableResizing: false,
meta: { title: "Engine Name" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Engine Name" />
@@ -390,6 +391,7 @@ export const createScanHistoryColumns = ({
size: 150,
minSize: 120,
maxSize: 200,
enableResizing: false,
meta: { title: "Created At" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Created At" />
@@ -407,9 +409,10 @@ export const createScanHistoryColumns = ({
// Status 列
{
accessorKey: "status",
size: 100,
minSize: 80,
size: 110,
minSize: 90,
maxSize: 130,
enableResizing: false,
meta: { title: "Status" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />

View File

@@ -1,9 +1,9 @@
"use client"
import React from "react"
import { ColumnDef } from "@tanstack/react-table"
import { Checkbox } from "@/components/ui/checkbox"
import { DataTableColumnHeader } from "@/components/ui/data-table/column-header"
import { ExpandableCell } from "@/components/ui/data-table/expandable-cell"
import type { Subdomain } from "@/types/subdomain.types"
// 列创建函数的参数类型
@@ -54,16 +54,9 @@ export const createSubdomainColumns = ({
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Subdomain" />
),
cell: ({ row }) => {
const name = row.getValue("name") as string
return (
<div className="flex-1 min-w-0">
<span className="text-sm font-medium font-mono break-all leading-relaxed whitespace-normal">
{name}
</span>
</div>
)
},
cell: ({ row }) => (
<ExpandableCell value={row.getValue("name")} />
),
},
// 创建时间列
@@ -72,6 +65,7 @@ export const createSubdomainColumns = ({
size: 150,
minSize: 120,
maxSize: 200,
enableResizing: false,
meta: { title: "Created At" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Created At" />

View File

@@ -2,8 +2,6 @@
import * as React from "react"
import type { ColumnDef } from "@tanstack/react-table"
import { IconPlus } from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import { UnifiedDataTable } from "@/components/ui/data-table"
import type { FilterField } from "@/components/common/smart-filter-input"
import type { Subdomain } from "@/types/subdomain.types"
@@ -101,18 +99,6 @@ export function SubdomainsDataTable({
})
}
// 自定义工具栏右侧按钮
const toolbarRightContent = (
<>
{onBulkAdd && (
<Button onClick={onBulkAdd} size="sm" variant="outline">
<IconPlus className="h-4 w-4" />
</Button>
)}
</>
)
return (
<UnifiedDataTable
data={data}
@@ -135,14 +121,16 @@ export function SubdomainsDataTable({
// 批量操作
onBulkDelete={onBulkDelete}
bulkDeleteLabel="Delete"
// 添加按钮
onAddNew={onAddNew}
addButtonLabel={addButtonText}
// 批量添加按钮
onBulkAdd={onBulkAdd}
bulkAddLabel="批量添加"
// 下载
downloadOptions={downloadOptions.length > 0 ? downloadOptions : undefined}
// 空状态
emptyMessage="No results"
// 自定义工具栏按钮
toolbarRight={onBulkAdd ? toolbarRightContent : undefined}
/>
)
}

View File

@@ -18,9 +18,9 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip"
import { MoreHorizontal, Eye, Trash2, Play, Calendar, Copy, Check } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { toast } from "sonner"
import { DataTableColumnHeader } from "@/components/ui/data-table/column-header"
import { ExpandableBadgeList } from "@/components/ui/data-table/expandable-cell"
import type { Target } from "@/types/target.types"
/**
@@ -278,54 +278,12 @@ export const createAllTargetsColumns = ({
),
cell: ({ row }) => {
const organizations = row.getValue("organizations") as Array<{ id: number; name: string }> | undefined
if (!organizations || organizations.length === 0) {
return <span className="text-sm text-muted-foreground">-</span>
}
const displayOrgs = organizations.slice(0, 2)
const remainingCount = organizations.length - 2
return (
<div className="flex flex-wrap gap-1">
{displayOrgs.map((org) => (
<Badge
key={org.id}
variant="secondary"
className="text-xs"
title={org.name}
>
{org.name}
</Badge>
))}
{remainingCount > 0 && (
<TooltipProvider delayDuration={500} skipDelayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="text-xs cursor-default"
>
+{remainingCount}
</Badge>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
sideOffset={5}
className="max-w-sm"
>
<div className="flex flex-col gap-1">
{organizations.slice(2).map((org) => (
<div key={org.id} className="text-xs">
{org.name}
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<ExpandableBadgeList
items={organizations}
maxVisible={2}
variant="secondary"
/>
)
},
enableSorting: false,

View File

@@ -93,13 +93,6 @@ export function TargetsDataTable({
pageSize: pagination.pageSize,
} : undefined
// 自定义添加按钮(支持 onAddHover
const addButton = onAddNew ? (
<Button onClick={onAddNew} onMouseEnter={onAddHover} size="sm">
{addButtonText}
</Button>
) : undefined
return (
<UnifiedDataTable
data={data}
@@ -115,7 +108,11 @@ export function TargetsDataTable({
// 批量操作
onBulkDelete={onBulkDelete}
bulkDeleteLabel="删除"
showAddButton={false}
// 添加按钮
onAddNew={onAddNew}
onAddHover={onAddHover}
addButtonLabel={addButtonText}
showAddButton={!!onAddNew}
// 空状态
emptyMessage="暂无数据"
// 自定义工具栏
@@ -137,7 +134,6 @@ export function TargetsDataTable({
</Button>
</div>
}
toolbarRight={addButton}
/>
)
}

View File

@@ -14,6 +14,7 @@ import {
} from "@/components/ui/dropdown-menu"
import { MoreHorizontal, Eye, Trash2, Copy } from "lucide-react"
import { DataTableColumnHeader } from "@/components/ui/data-table/column-header"
import { ExpandableCell } from "@/components/ui/data-table/expandable-cell"
import { Badge } from "@/components/ui/badge"
import { formatDate } from "@/lib/utils"
import { toast } from "sonner"
@@ -110,18 +111,9 @@ export const commandColumns: ColumnDef<Command>[] = [
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Command Template" />
),
cell: ({ row }) => {
const template = row.getValue("commandTemplate") as string
if (!template) return <span className="text-muted-foreground text-sm">-</span>
return (
<div className="flex-1 min-w-0">
<span className="text-sm font-mono break-all leading-relaxed whitespace-normal">
{template}
</span>
</div>
)
},
cell: ({ row }) => (
<ExpandableCell value={row.getValue("commandTemplate")} variant="mono" />
),
},
// 描述列
@@ -133,18 +125,9 @@ export const commandColumns: ColumnDef<Command>[] = [
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Description" />
),
cell: ({ row }) => {
const description = row.getValue("description") as string
if (!description) return <span className="text-muted-foreground text-sm">-</span>
return (
<div className="flex-1 min-w-0">
<span className="text-sm text-muted-foreground break-all leading-relaxed whitespace-normal">
{description}
</span>
</div>
)
},
cell: ({ row }) => (
<ExpandableCell value={row.getValue("description")} variant="muted" />
),
},
// 更新时间列

View File

@@ -0,0 +1,280 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { ChevronDown, ChevronUp } from "lucide-react"
export interface ExpandableCellProps {
/** 要显示的值 */
value: string | null | undefined
/** 显示变体 */
variant?: "text" | "url" | "mono" | "muted"
/** 最大显示行数,默认 3 */
maxLines?: number
/** 额外的 CSS 类名 */
className?: string
/** 空值时显示的占位符 */
placeholder?: string
/** 展开按钮文本 */
expandLabel?: string
/** 收起按钮文本 */
collapseLabel?: string
}
/**
* 统一的可展开单元格组件
*
* 特性:
* - 默认显示最多 3 行(可配置)
* - 自动检测内容是否溢出
* - 只在内容超出时显示展开/收起按钮
* - 支持 text、url、mono、muted 四种变体
*/
export function ExpandableCell({
value,
variant = "text",
maxLines = 3,
className,
placeholder = "-",
expandLabel = "展开",
collapseLabel = "收起",
}: ExpandableCellProps) {
const [expanded, setExpanded] = React.useState(false)
const [isOverflowing, setIsOverflowing] = React.useState(false)
const contentRef = React.useRef<HTMLDivElement>(null)
// 检测内容是否溢出
React.useEffect(() => {
const el = contentRef.current
if (!el) return
const checkOverflow = () => {
// 比较 scrollHeight 和 clientHeight 来判断是否溢出
setIsOverflowing(el.scrollHeight > el.clientHeight + 1)
}
checkOverflow()
// 监听窗口大小变化
const resizeObserver = new ResizeObserver(checkOverflow)
resizeObserver.observe(el)
return () => resizeObserver.disconnect()
}, [value, expanded])
if (!value) {
return <span className="text-muted-foreground text-sm">{placeholder}</span>
}
const lineClampClass = {
1: "line-clamp-1",
2: "line-clamp-2",
3: "line-clamp-3",
4: "line-clamp-4",
5: "line-clamp-5",
6: "line-clamp-6",
}[maxLines] || "line-clamp-3"
return (
<div className="flex flex-col gap-1">
<div
ref={contentRef}
className={cn(
"text-sm break-all leading-relaxed whitespace-normal",
variant === "mono" && "font-mono text-xs text-muted-foreground",
variant === "url" && "text-muted-foreground",
variant === "muted" && "text-muted-foreground",
!expanded && lineClampClass,
className
)}
>
{value}
</div>
{(isOverflowing || expanded) && (
<button
onClick={() => setExpanded(!expanded)}
className="text-xs text-primary hover:underline self-start"
>
{expanded ? collapseLabel : expandLabel}
</button>
)}
</div>
)
}
/**
* URL 专用的可展开单元格
*/
export function ExpandableUrlCell(props: Omit<ExpandableCellProps, "variant">) {
return <ExpandableCell {...props} variant="url" />
}
/**
* 代码/等宽字体的可展开单元格
*/
export function ExpandableMonoCell(props: Omit<ExpandableCellProps, "variant">) {
return <ExpandableCell {...props} variant="mono" />
}
// ============================================================================
// Badge 列表相关组件
// ============================================================================
export interface BadgeItem {
id: number | string
name: string
}
export interface ExpandableBadgeListProps {
/** Badge 项目列表 */
items: BadgeItem[] | null | undefined
/** 默认显示的数量,默认 2 */
maxVisible?: number
/** Badge 变体 */
variant?: "default" | "secondary" | "outline" | "destructive"
/** 空值时显示的占位符 */
placeholder?: string
/** 额外的 CSS 类名 */
className?: string
/** 点击 Badge 时的回调 */
onItemClick?: (item: BadgeItem) => void
}
/**
* 可展开的 Badge 列表组件
*
* 特性:
* - 默认显示前 N 个 Badge可配置
* - 超过数量时显示展开按钮
* - 点击展开按钮显示所有 Badge
* - 展开后显示收起按钮
*/
export function ExpandableBadgeList({
items,
maxVisible = 2,
variant = "secondary",
placeholder = "-",
className,
onItemClick,
}: ExpandableBadgeListProps) {
const [expanded, setExpanded] = React.useState(false)
if (!items || items.length === 0) {
return <span className="text-sm text-muted-foreground">{placeholder}</span>
}
const hasMore = items.length > maxVisible
const displayItems = expanded ? items : items.slice(0, maxVisible)
const remainingCount = items.length - maxVisible
return (
<div className={cn("flex flex-wrap items-center gap-1", className)}>
{displayItems.map((item) => (
<Badge
key={item.id}
variant={variant}
className={cn(
"text-xs",
onItemClick && "cursor-pointer hover:bg-accent"
)}
title={item.name}
onClick={onItemClick ? () => onItemClick(item) : undefined}
>
{item.name}
</Badge>
))}
{hasMore && (
<button
onClick={() => setExpanded(!expanded)}
className="inline-flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{expanded ? (
<>
<ChevronUp className="h-3 w-3" />
<span></span>
</>
) : (
<>
<span></span>
<ChevronDown className="h-3 w-3" />
</>
)}
</button>
)}
</div>
)
}
// ============================================================================
// 字符串列表相关组件
// ============================================================================
export interface ExpandableTagListProps {
/** 标签列表 */
items: string[] | null | undefined
/** 默认显示的数量,默认 3 */
maxVisible?: number
/** Badge 变体 */
variant?: "default" | "secondary" | "outline" | "destructive"
/** 空值时显示的占位符 */
placeholder?: string
/** 额外的 CSS 类名 */
className?: string
}
/**
* 可展开的标签列表组件(用于字符串数组)
*
* 适用于 tech 列表、tags 列表等场景
*/
export function ExpandableTagList({
items,
maxVisible = 3,
variant = "outline",
placeholder = "-",
className,
}: ExpandableTagListProps) {
const [expanded, setExpanded] = React.useState(false)
if (!items || items.length === 0) {
return <span className="text-sm text-muted-foreground">{placeholder}</span>
}
const hasMore = items.length > maxVisible
const displayItems = expanded ? items : items.slice(0, maxVisible)
const remainingCount = items.length - maxVisible
return (
<div className={cn("flex flex-wrap items-center gap-1", className)}>
{displayItems.map((item, index) => (
<Badge
key={`${item}-${index}`}
variant={variant}
className="text-xs"
title={item}
>
{item}
</Badge>
))}
{hasMore && (
<button
onClick={() => setExpanded(!expanded)}
className="inline-flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{expanded ? (
<>
<ChevronUp className="h-3 w-3" />
<span></span>
</>
) : (
<>
<span></span>
<ChevronDown className="h-3 w-3" />
</>
)}
</button>
)}
</div>
)
}

View File

@@ -113,9 +113,15 @@ export function UnifiedDataTable<TData>({
// 添加操作
onAddNew,
onAddHover,
addButtonLabel = "Add",
showAddButton = true,
// 批量添加操作
onBulkAdd,
bulkAddLabel = "批量添加",
showBulkAdd = true,
// 下载操作
downloadOptions,
@@ -436,11 +442,19 @@ export function UnifiedDataTable<TData>({
{/* 添加按钮 */}
{showAddButton && onAddNew && (
<Button onClick={onAddNew} size="sm">
<Button onClick={onAddNew} onMouseEnter={onAddHover} size="sm">
<IconPlus className="h-4 w-4" />
{addButtonLabel}
</Button>
)}
{/* 批量添加按钮 */}
{showBulkAdd && onBulkAdd && (
<Button onClick={onBulkAdd} size="sm" variant="outline">
<IconPlus className="h-4 w-4" />
{bulkAddLabel}
</Button>
)}
</DataTableToolbar>
)}

View File

@@ -1,83 +0,0 @@
"use client"
import { cn } from "@/lib/utils"
/**
* 预设的截断长度配置(保留用于兼容)
*/
export const TRUNCATE_LENGTHS = {
url: 50,
title: 25,
location: 20,
webServer: 20,
contentType: 20,
bodyPreview: 25,
subdomain: 35,
ip: 35,
host: 30,
default: 30,
} as const
export type TruncateLengthKey = keyof typeof TRUNCATE_LENGTHS
interface TruncatedCellProps {
/** 要显示的值 */
value: string | null | undefined
/** 最大显示长度(已废弃,不再使用) */
maxLength?: number | TruncateLengthKey
/** 额外的 CSS 类名 */
className?: string
/** 是否使用等宽字体 */
mono?: boolean
/** 空值时显示的占位符 */
placeholder?: string
}
/**
* 单元格组件 - 多行显示
*/
export function TruncatedCell({
value,
className,
mono = false,
placeholder = "-",
}: TruncatedCellProps) {
if (!value) {
return <span className="text-muted-foreground text-sm">{placeholder}</span>
}
return (
<div
className={cn(
"text-sm break-all leading-relaxed whitespace-normal",
mono && "font-mono",
className
)}
>
{value}
</div>
)
}
/**
* URL 专用的单元格 - 多行显示
*/
export function TruncatedUrlCell({
value,
className,
}: Omit<TruncatedCellProps, "mono">) {
if (!value) {
return <span className="text-muted-foreground text-sm">-</span>
}
return (
<div
className={cn(
"text-sm break-all leading-relaxed whitespace-normal",
className
)}
>
{value}
</div>
)
}

View File

@@ -6,7 +6,7 @@ import { Checkbox } from "@/components/ui/checkbox"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { TruncatedUrlCell, TruncatedCell } from "@/components/ui/truncated-cell"
import { ExpandableUrlCell } from "@/components/ui/data-table/expandable-cell"
import { DataTableColumnHeader } from "@/components/ui/data-table/column-header"
import type { Vulnerability, VulnerabilitySeverity } from "@/types/vulnerability.types"
@@ -62,9 +62,10 @@ export function createVulnerabilityColumns({
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
size: 80,
minSize: 60,
size: 100,
minSize: 80,
maxSize: 120,
enableResizing: false,
cell: ({ row }) => {
const severity = row.getValue("severity") as VulnerabilitySeverity
const config = severityConfig[severity]
@@ -84,6 +85,7 @@ export function createVulnerabilityColumns({
size: 100,
minSize: 80,
maxSize: 150,
enableResizing: false,
cell: ({ row }) => {
const source = row.getValue("source") as string
return (
@@ -129,10 +131,9 @@ export function createVulnerabilityColumns({
size: 500,
minSize: 300,
maxSize: 700,
cell: ({ row }) => {
const url = row.original.url
return <TruncatedUrlCell value={url} />
},
cell: ({ row }) => (
<ExpandableUrlCell value={row.original.url} />
),
},
{
accessorKey: "createdAt",
@@ -143,6 +144,7 @@ export function createVulnerabilityColumns({
size: 150,
minSize: 120,
maxSize: 200,
enableResizing: false,
cell: ({ row }) => {
const createdAt = row.getValue("createdAt") as string
return (

View File

@@ -6,38 +6,7 @@ import { Checkbox } from "@/components/ui/checkbox"
import { Badge } from "@/components/ui/badge"
import { DataTableColumnHeader } from "@/components/ui/data-table/column-header"
import type { WebSite } from "@/types/website.types"
import { TruncatedCell, TruncatedUrlCell } from "@/components/ui/truncated-cell"
/**
* Body Preview 单元格组件 - 最多显示3行超出折叠点击展开查看完整内容
*/
function BodyPreviewCell({ value }: { value: string | null | undefined }) {
const [expanded, setExpanded] = React.useState(false)
if (!value) {
return <span className="text-muted-foreground text-sm">-</span>
}
return (
<div className="flex flex-col gap-1">
<div
className={`text-sm text-muted-foreground break-all leading-relaxed whitespace-normal cursor-pointer hover:text-foreground transition-colors ${!expanded ? 'line-clamp-3' : ''}`}
onClick={() => setExpanded(!expanded)}
title={expanded ? "点击收起" : "点击展开"}
>
{value}
</div>
{value.length > 100 && (
<button
onClick={() => setExpanded(!expanded)}
className="text-xs text-primary hover:underline self-start"
>
{expanded ? "收起" : "展开"}
</button>
)}
</div>
)
}
import { ExpandableCell, ExpandableTagList } from "@/components/ui/data-table/expandable-cell"
interface CreateWebSiteColumnsProps {
formatDate: (dateString: string) => string
@@ -82,10 +51,9 @@ export function createWebSiteColumns({
size: 400,
minSize: 200,
maxSize: 700,
cell: ({ row }) => {
const url = row.getValue("url") as string
return <TruncatedUrlCell value={url} />
},
cell: ({ row }) => (
<ExpandableCell value={row.getValue("url")} />
),
},
{
accessorKey: "host",
@@ -97,7 +65,7 @@ export function createWebSiteColumns({
minSize: 100,
maxSize: 250,
cell: ({ row }) => (
<TruncatedCell value={row.getValue("host")} maxLength="host" mono />
<ExpandableCell value={row.getValue("host")} />
),
},
{
@@ -110,7 +78,7 @@ export function createWebSiteColumns({
minSize: 100,
maxSize: 300,
cell: ({ row }) => (
<TruncatedCell value={row.getValue("title")} maxLength="title" />
<ExpandableCell value={row.getValue("title")} />
),
},
{
@@ -122,6 +90,7 @@ export function createWebSiteColumns({
size: 80,
minSize: 60,
maxSize: 100,
enableResizing: false,
cell: ({ row }) => {
const statusCode = row.getValue("statusCode") as number
if (!statusCode) return "-"
@@ -148,17 +117,7 @@ export function createWebSiteColumns({
minSize: 150,
cell: ({ row }) => {
const tech = row.getValue("tech") as string[]
if (!tech || tech.length === 0) return <span className="text-sm text-muted-foreground">-</span>
return (
<div className="flex flex-wrap items-center gap-1.5">
{tech.map((technology, index) => (
<Badge key={index} variant="outline" className="text-xs">
{technology}
</Badge>
))}
</div>
)
return <ExpandableTagList items={tech} maxVisible={3} variant="outline" />
},
},
{
@@ -186,7 +145,7 @@ export function createWebSiteColumns({
minSize: 100,
maxSize: 300,
cell: ({ row }) => (
<TruncatedCell value={row.getValue("location")} maxLength="location" />
<ExpandableCell value={row.getValue("location")} />
),
},
{
@@ -199,7 +158,7 @@ export function createWebSiteColumns({
minSize: 80,
maxSize: 200,
cell: ({ row }) => (
<TruncatedCell value={row.getValue("webserver")} maxLength="webServer" />
<ExpandableCell value={row.getValue("webserver")} />
),
},
{
@@ -212,7 +171,7 @@ export function createWebSiteColumns({
minSize: 80,
maxSize: 200,
cell: ({ row }) => (
<TruncatedCell value={row.getValue("contentType")} maxLength="contentType" />
<ExpandableCell value={row.getValue("contentType")} />
),
},
{
@@ -224,7 +183,7 @@ export function createWebSiteColumns({
size: 350,
minSize: 250,
cell: ({ row }) => (
<BodyPreviewCell value={row.getValue("bodyPreview")} />
<ExpandableCell value={row.getValue("bodyPreview")} />
),
},
{
@@ -236,6 +195,7 @@ export function createWebSiteColumns({
size: 80,
minSize: 60,
maxSize: 100,
enableResizing: false,
cell: ({ row }) => {
const vhost = row.getValue("vhost") as boolean | null
if (vhost === null) return "-"
@@ -255,6 +215,7 @@ export function createWebSiteColumns({
size: 150,
minSize: 120,
maxSize: 200,
enableResizing: false,
cell: ({ row }) => {
const createdAt = row.getValue("createdAt") as string
return <div className="text-sm">{createdAt ? formatDate(createdAt) : "-"}</div>

View File

@@ -2,8 +2,6 @@
import * as React from "react"
import type { ColumnDef } from "@tanstack/react-table"
import { IconPlus } from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import { UnifiedDataTable } from "@/components/ui/data-table"
import type { FilterField } from "@/components/common/smart-filter-input"
import type { WebSite } from "@/types/website.types"
@@ -84,14 +82,6 @@ export function WebSitesDataTable({
})
}
// 自定义工具栏右侧按钮
const toolbarRightContent = onBulkAdd ? (
<Button onClick={onBulkAdd} size="sm" variant="outline">
<IconPlus className="h-4 w-4" />
</Button>
) : undefined
return (
<UnifiedDataTable
data={data}
@@ -115,12 +105,13 @@ export function WebSitesDataTable({
onBulkDelete={onBulkDelete}
bulkDeleteLabel="Delete"
showAddButton={false}
// 批量添加按钮
onBulkAdd={onBulkAdd}
bulkAddLabel="批量添加"
// 下载
downloadOptions={downloadOptions.length > 0 ? downloadOptions : undefined}
// 空状态
emptyMessage="暂无数据"
// 自定义工具栏按钮
toolbarRight={toolbarRightContent}
/>
)
}

View File

@@ -105,9 +105,15 @@ export interface UnifiedDataTableProps<TData> {
// 添加操作
onAddNew?: () => void
onAddHover?: () => void
addButtonLabel?: string
showAddButton?: boolean
// 批量添加操作
onBulkAdd?: () => void
bulkAddLabel?: string
showBulkAdd?: boolean
// 下载操作
downloadOptions?: DownloadOption[]