feat(search): add result export functionality and pagination limit support

- Add optional limit parameter to AssetSearchService.search() method for controlling result set size
- Implement AssetSearchExportView for exporting search results as CSV files with UTF-8 BOM encoding
- Add CSV export endpoint at GET /api/assets/search/export/ with configurable MAX_EXPORT_ROWS limit (10000)
- Support both website and endpoint asset types with type-specific column mappings in CSV export
- Format array fields (tech, matched_gf_patterns) and dates appropriately in exported CSV
- Update URL routing to include new search export endpoint
- Update views __init__.py to export AssetSearchExportView
- Add CSV generation with streaming response for efficient memory usage on large exports
- Update frontend search service to support export functionality
- Add internationalization strings for export feature in en.json and zh.json
- Update smart-filter-input and search-results-table components to support export UI
- Update installation and Docker startup scripts for deployment compatibility
This commit is contained in:
yyhuni
2026-01-03 13:22:21 +08:00
parent 4bd0f9e8c1
commit 7f2af7f7e2
12 changed files with 304 additions and 90 deletions

View File

@@ -323,7 +323,8 @@ class AssetSearchService:
def search(
self,
query: str,
asset_type: AssetType = 'website'
asset_type: AssetType = 'website',
limit: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
搜索资产
@@ -331,6 +332,7 @@ class AssetSearchService:
Args:
query: 搜索查询字符串
asset_type: 资产类型 ('website''endpoint')
limit: 最大返回数量(可选)
Returns:
List[Dict]: 搜索结果列表
@@ -348,6 +350,10 @@ class AssetSearchService:
ORDER BY created_at DESC
"""
# 添加 LIMIT
if limit is not None and limit > 0:
sql += f" LIMIT {int(limit)}"
try:
with connection.cursor() as cursor:
cursor.execute(sql, params)

View File

@@ -11,6 +11,7 @@ from .views import (
VulnerabilityViewSet,
AssetStatisticsViewSet,
AssetSearchView,
AssetSearchExportView,
)
# 创建 DRF 路由器
@@ -27,4 +28,5 @@ router.register(r'statistics', AssetStatisticsViewSet, basename='asset-statistic
urlpatterns = [
path('assets/', include(router.urls)),
path('assets/search/', AssetSearchView.as_view(), name='asset-search'),
path('assets/search/export/', AssetSearchExportView.as_view(), name='asset-search-export'),
]

View File

@@ -19,7 +19,7 @@ from .asset_views import (
HostPortMappingSnapshotViewSet,
VulnerabilitySnapshotViewSet,
)
from .search_views import AssetSearchView
from .search_views import AssetSearchView, AssetSearchExportView
__all__ = [
'AssetStatisticsViewSet',
@@ -36,4 +36,5 @@ __all__ = [
'HostPortMappingSnapshotViewSet',
'VulnerabilitySnapshotViewSet',
'AssetSearchView',
'AssetSearchExportView',
]

View File

@@ -3,6 +3,7 @@
提供资产搜索的 REST API 接口:
- GET /api/assets/search/ - 搜索资产
- GET /api/assets/search/export/ - 导出搜索结果为 CSV
搜索语法:
- field="value" 模糊匹配ILIKE %value%
@@ -27,10 +28,14 @@
import logging
import json
import csv
from io import StringIO
from datetime import datetime
from urllib.parse import urlparse, urlunparse
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.request import Request
from django.http import StreamingHttpResponse
from django.db import connection
from apps.common.response_helpers import success_response, error_response
@@ -269,3 +274,127 @@ class AssetSearchView(APIView):
'totalPages': total_pages,
'assetType': asset_type,
})
class AssetSearchExportView(APIView):
"""
资产搜索导出 API
GET /api/assets/search/export/
Query Parameters:
q: 搜索查询表达式
asset_type: 资产类型 ('website''endpoint',默认 'website')
Response:
CSV 文件流
"""
# 导出数量限制
MAX_EXPORT_ROWS = 10000
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.service = AssetSearchService()
def _parse_headers(self, headers_data) -> str:
"""解析响应头为字符串"""
if not headers_data:
return ''
try:
headers = json.loads(headers_data)
return '; '.join(f'{k}: {v}' for k, v in headers.items())
except (json.JSONDecodeError, TypeError):
return str(headers_data)
def _generate_csv(self, results: list, asset_type: str):
"""生成 CSV 内容的生成器"""
# 定义列
if asset_type == 'website':
columns = ['url', 'host', 'title', 'status_code', 'content_type', 'content_length',
'webserver', 'location', 'tech', 'vhost', 'created_at']
headers = ['URL', 'Host', 'Title', 'Status', 'Content-Type', 'Content-Length',
'Webserver', 'Location', 'Technologies', 'VHost', 'Created At']
else:
columns = ['url', 'host', 'title', 'status_code', 'content_type', 'content_length',
'webserver', 'location', 'tech', 'matched_gf_patterns', 'vhost', 'created_at']
headers = ['URL', 'Host', 'Title', 'Status', 'Content-Type', 'Content-Length',
'Webserver', 'Location', 'Technologies', 'GF Patterns', 'VHost', 'Created At']
# 写入 BOM 和表头
output = StringIO()
writer = csv.writer(output)
# UTF-8 BOM
yield '\ufeff'
# 表头
writer.writerow(headers)
yield output.getvalue()
output.seek(0)
output.truncate(0)
# 数据行
for result in results:
row = []
for col in columns:
value = result.get(col)
if col == 'tech' or col == 'matched_gf_patterns':
# 数组转字符串
row.append('; '.join(value) if value else '')
elif col == 'created_at':
# 日期格式化
row.append(value.strftime('%Y-%m-%d %H:%M:%S') if value else '')
elif col == 'vhost':
row.append('true' if value else 'false' if value is False else '')
else:
row.append(str(value) if value is not None else '')
writer.writerow(row)
yield output.getvalue()
output.seek(0)
output.truncate(0)
def get(self, request: Request):
"""导出搜索结果为 CSV"""
# 获取搜索查询
query = request.query_params.get('q', '').strip()
if not query:
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message='Search query (q) is required',
status_code=status.HTTP_400_BAD_REQUEST
)
# 获取并验证资产类型
asset_type = request.query_params.get('asset_type', 'website').strip().lower()
if asset_type not in VALID_ASSET_TYPES:
return error_response(
code=ErrorCodes.VALIDATION_ERROR,
message=f'Invalid asset_type. Must be one of: {", ".join(VALID_ASSET_TYPES)}',
status_code=status.HTTP_400_BAD_REQUEST
)
# 获取搜索结果(限制数量)
results = self.service.search(query, asset_type, limit=self.MAX_EXPORT_ROWS)
if not results:
return error_response(
code=ErrorCodes.NOT_FOUND,
message='No results to export',
status_code=status.HTTP_404_NOT_FOUND
)
# 生成文件名
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'search_{asset_type}_{timestamp}.csv'
# 返回流式响应
response = StreamingHttpResponse(
self._generate_csv(results, asset_type),
content_type='text/csv; charset=utf-8'
)
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response

View File

@@ -15,10 +15,12 @@ NC='\033[0m'
# 解析参数
WITH_FRONTEND=true
DEV_MODE=false
QUIET_MODE=false
for arg in "$@"; do
case $arg in
--no-frontend) WITH_FRONTEND=false ;;
--dev) DEV_MODE=true ;;
--quiet) QUIET_MODE=true ;;
esac
done
@@ -155,6 +157,11 @@ echo -e "${GREEN}[OK]${NC} 服务已启动"
# 数据初始化
./scripts/init-data.sh
# 静默模式下不显示结果(由调用方显示)
if [ "$QUIET_MODE" = true ]; then
exit 0
fi
# 获取访问地址
PUBLIC_HOST=$(grep "^PUBLIC_HOST=" .env 2>/dev/null | cut -d= -f2)
if [ -n "$PUBLIC_HOST" ] && [ "$PUBLIC_HOST" != "server" ]; then

View File

@@ -381,9 +381,9 @@ export function SmartFilterInput({
return (
<div className={className}>
<Popover open={open} onOpenChange={setOpen} modal={false}>
<PopoverAnchor asChild>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<Popover open={open} onOpenChange={setOpen} modal={false}>
<PopoverAnchor asChild>
<div className="relative flex-1">
<Input
ref={inputRef}
@@ -420,11 +420,7 @@ export function SmartFilterInput({
</div>
)}
</div>
<Button variant="outline" size="sm" onClick={handleSearch}>
<IconSearch className="h-4 w-4" />
</Button>
</div>
</PopoverAnchor>
</PopoverAnchor>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
@@ -523,6 +519,10 @@ export function SmartFilterInput({
</Command>
</PopoverContent>
</Popover>
<Button variant="outline" size="sm" onClick={handleSearch}>
<IconSearch className="h-4 w-4" />
</Button>
</div>
</div>
)
}

View File

@@ -1,24 +1,25 @@
"use client"
import { useState, useCallback, useMemo, useEffect } from "react"
import { useSearchParams } from "next/navigation"
import { motion, AnimatePresence } from "framer-motion"
import { Search, AlertCircle, Globe, Link2, ShieldAlert, History, X } from "lucide-react"
import { Search, AlertCircle, History, X, Download } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { useQuery } from "@tanstack/react-query"
import { SmartFilterInput, type FilterField } from "@/components/common/smart-filter-input"
import { SearchPagination } from "./search-pagination"
import { useAssetSearch } from "@/hooks/use-search"
import { VulnerabilityDetailDialog } from "@/components/vulnerabilities/vulnerability-detail-dialog"
import { VulnerabilityService } from "@/services/vulnerability.service"
import { SearchService } from "@/services/search.service"
import type { SearchParams, SearchState, Vulnerability as SearchVuln, AssetType } from "@/types/search.types"
import type { Vulnerability } from "@/types/vulnerability.types"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { SearchResultsTable } from "./search-results-table"
import { SearchResultCard } from "./search-result-card"
import { Badge } from "@/components/ui/badge"
import { getAssetStatistics } from "@/services/dashboard.service"
import { cn } from "@/lib/utils"
// Website 搜索示例
@@ -98,6 +99,7 @@ function removeRecentSearch(query: string) {
export function SearchPage() {
const t = useTranslations('search')
const urlSearchParams = useSearchParams()
const [searchState, setSearchState] = useState<SearchState>("initial")
const [query, setQuery] = useState("")
const [assetType, setAssetType] = useState<AssetType>("website")
@@ -108,19 +110,28 @@ export function SearchPage() {
const [vulnDialogOpen, setVulnDialogOpen] = useState(false)
const [, setLoadingVuln] = useState(false)
const [recentSearches, setRecentSearches] = useState<string[]>([])
// 获取资产统计数据
const { data: stats } = useQuery({
queryKey: ['assetStatistics'],
queryFn: getAssetStatistics,
staleTime: 5 * 60 * 1000, // 5 minutes
})
const [initialQueryProcessed, setInitialQueryProcessed] = useState(false)
// 加载最近搜索记录
useEffect(() => {
setRecentSearches(getRecentSearches())
}, [])
// 处理 URL 参数中的搜索查询
useEffect(() => {
if (initialQueryProcessed) return
const q = urlSearchParams.get('q')
if (q) {
setQuery(q)
setSearchParams({ q, asset_type: assetType })
setSearchState("searching")
saveRecentSearch(q)
setRecentSearches(getRecentSearches())
}
setInitialQueryProcessed(true)
}, [urlSearchParams, assetType, initialQueryProcessed])
// 根据资产类型选择搜索示例
const searchExamples = useMemo(() => {
return assetType === 'endpoint' ? ENDPOINT_SEARCH_EXAMPLES : WEBSITE_SEARCH_EXAMPLES
@@ -178,6 +189,25 @@ export function SearchPage() {
setRecentSearches(getRecentSearches())
}, [])
// 导出状态
const [isExporting, setIsExporting] = useState(false)
// 导出 CSV调用后端 API 导出全部结果)
const handleExportCSV = useCallback(async () => {
if (!searchParams.q) return
setIsExporting(true)
try {
await SearchService.exportCSV(searchParams.q, assetType)
toast.success(t('exportSuccess'))
} catch (error) {
console.error('Export failed:', error)
toast.error(t('exportFailed'))
} finally {
setIsExporting(false)
}
}, [searchParams.q, assetType, t])
// 当数据加载完成时更新状态
if (searchState === "searching" && data && !isLoading) {
setSearchState("results")
@@ -289,38 +319,6 @@ export function SearchPage() {
))}
</div>
{/* 资产统计 */}
{stats && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="grid grid-cols-3 gap-6 mt-4"
>
<div className="flex flex-col items-center gap-1 p-4 rounded-xl bg-muted/50">
<Globe className="h-5 w-5 text-muted-foreground mb-1" />
<span className="text-2xl font-bold text-primary">
{stats.totalWebsites.toLocaleString()}
</span>
<span className="text-xs text-muted-foreground">{t('assetTypes.website')}</span>
</div>
<div className="flex flex-col items-center gap-1 p-4 rounded-xl bg-muted/50">
<Link2 className="h-5 w-5 text-muted-foreground mb-1" />
<span className="text-2xl font-bold text-primary">
{stats.totalEndpoints.toLocaleString()}
</span>
<span className="text-xs text-muted-foreground">{t('assetTypes.endpoint')}</span>
</div>
<div className="flex flex-col items-center gap-1 p-4 rounded-xl bg-muted/50">
<ShieldAlert className="h-5 w-5 text-muted-foreground mb-1" />
<span className="text-2xl font-bold text-primary">
{stats.totalVulns.toLocaleString()}
</span>
<span className="text-xs text-muted-foreground">{t('stats.vulnerabilities')}</span>
</div>
</motion.div>
)}
{/* 最近搜索 */}
{recentSearches.length > 0 && (
<motion.div
@@ -404,6 +402,15 @@ export function SearchPage() {
<span className="text-sm text-muted-foreground whitespace-nowrap">
{isFetching ? t('loading') : t('resultsCount', { count: data?.total ?? 0 })}
</span>
<Button
variant="outline"
size="sm"
onClick={handleExportCSV}
disabled={!data?.results || data.results.length === 0 || isExporting}
>
<Download className="h-4 w-4 mr-1.5" />
{isExporting ? t('exporting') : t('export')}
</Button>
</div>
</motion.div>

View File

@@ -1,7 +1,7 @@
"use client"
import { useMemo } from "react"
import { useTranslations, useFormatter } from "next-intl"
import { useFormatter } from "next-intl"
import type { ColumnDef } from "@tanstack/react-table"
import { Badge } from "@/components/ui/badge"
import { DataTableColumnHeader, UnifiedDataTable } from "@/components/ui/data-table"
@@ -15,7 +15,6 @@ interface SearchResultsTableProps {
}
export function SearchResultsTable({ results, assetType }: SearchResultsTableProps) {
const t = useTranslations('search.table')
const format = useFormatter()
const formatDate = (dateString: string) => {
@@ -33,9 +32,9 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
{
id: "url",
accessorKey: "url",
meta: { title: t('url') },
meta: { title: "URL" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('url')} />
<DataTableColumnHeader column={column} title="URL" />
),
size: 350,
minSize: 200,
@@ -47,9 +46,9 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
{
id: "host",
accessorKey: "host",
meta: { title: t('host') },
meta: { title: "Host" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('host')} />
<DataTableColumnHeader column={column} title="Host" />
),
size: 180,
minSize: 100,
@@ -61,9 +60,9 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
{
id: "title",
accessorKey: "title",
meta: { title: t('title') },
meta: { title: "Title" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('title')} />
<DataTableColumnHeader column={column} title="Title" />
),
size: 150,
minSize: 100,
@@ -75,9 +74,9 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
{
id: "statusCode",
accessorKey: "statusCode",
meta: { title: t('status') },
meta: { title: "Status" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('status')} />
<DataTableColumnHeader column={column} title="Status" />
),
size: 80,
minSize: 60,
@@ -103,9 +102,9 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
{
id: "technologies",
accessorKey: "technologies",
meta: { title: t('technologies') },
meta: { title: "Tech" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('technologies')} />
<DataTableColumnHeader column={column} title="Tech" />
),
size: 180,
minSize: 120,
@@ -118,9 +117,9 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
{
id: "contentLength",
accessorKey: "contentLength",
meta: { title: t('contentLength') },
meta: { title: "Length" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('contentLength')} />
<DataTableColumnHeader column={column} title="Length" />
),
size: 100,
minSize: 80,
@@ -134,9 +133,9 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
{
id: "location",
accessorKey: "location",
meta: { title: t('location') },
meta: { title: "Location" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('location')} />
<DataTableColumnHeader column={column} title="Location" />
),
size: 150,
minSize: 100,
@@ -148,9 +147,9 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
{
id: "webserver",
accessorKey: "webserver",
meta: { title: t('webserver') },
meta: { title: "Server" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('webserver')} />
<DataTableColumnHeader column={column} title="Server" />
),
size: 120,
minSize: 80,
@@ -162,9 +161,9 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
{
id: "contentType",
accessorKey: "contentType",
meta: { title: t('contentType') },
meta: { title: "Type" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('contentType')} />
<DataTableColumnHeader column={column} title="Type" />
),
size: 120,
minSize: 80,
@@ -176,9 +175,9 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
{
id: "responseBody",
accessorKey: "responseBody",
meta: { title: t('responseBody') },
meta: { title: "Body" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('responseBody')} />
<DataTableColumnHeader column={column} title="Body" />
),
size: 300,
minSize: 200,
@@ -189,9 +188,9 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
{
id: "responseHeaders",
accessorKey: "responseHeaders",
meta: { title: t('responseHeaders') },
meta: { title: "Headers" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('responseHeaders')} />
<DataTableColumnHeader column={column} title="Headers" />
),
size: 250,
minSize: 150,
@@ -210,9 +209,9 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
{
id: "vhost",
accessorKey: "vhost",
meta: { title: t('vhost') },
meta: { title: "VHost" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('vhost')} />
<DataTableColumnHeader column={column} title="VHost" />
),
size: 80,
minSize: 60,
@@ -226,9 +225,9 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
{
id: "createdAt",
accessorKey: "createdAt",
meta: { title: t('createdAt') },
meta: { title: "Created" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('createdAt')} />
<DataTableColumnHeader column={column} title="Created" />
),
size: 150,
minSize: 120,
@@ -239,16 +238,16 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
return <span className="text-sm">{formatDate(createdAt)}</span>
},
},
], [t, formatDate])
], [formatDate])
// Endpoint 特有列
const endpointColumns: ColumnDef<SearchResult, unknown>[] = useMemo(() => [
{
id: "matchedGfPatterns",
accessorKey: "matchedGfPatterns",
meta: { title: t('gfPatterns') },
meta: { title: "GF Patterns" },
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('gfPatterns')} />
<DataTableColumnHeader column={column} title="GF Patterns" />
),
size: 150,
minSize: 100,
@@ -259,7 +258,7 @@ export function SearchResultsTable({ results, assetType }: SearchResultsTablePro
return <ExpandableTagList items={patterns} maxLines={2} variant="secondary" />
},
},
], [t])
], [])
// 根据资产类型组合列
const columns = useMemo(() => {

View File

@@ -326,6 +326,10 @@
"noResultsHint": "Try adjusting your search criteria",
"vulnLoadError": "Failed to load vulnerability details",
"recentSearches": "Recent Searches",
"export": "Export",
"exporting": "Exporting...",
"exportSuccess": "Export successful",
"exportFailed": "Export failed",
"stats": {
"vulnerabilities": "Vulnerabilities"
},
@@ -1971,6 +1975,16 @@
"formatInvalid": "Invalid format"
}
},
"globalSearch": {
"search": "Search",
"placeholder": "Search assets... (host=\"api\" && tech=\"nginx\")",
"noResults": "No results found",
"searchFor": "Search for",
"recent": "Recent Searches",
"quickSearch": "Quick Search",
"hint": "Supports FOFA-style syntax",
"toSearch": "to search"
},
"errors": {
"unknown": "Operation failed, please try again later",
"validation": "Invalid input data",

View File

@@ -326,6 +326,10 @@
"noResultsHint": "请尝试调整搜索条件",
"vulnLoadError": "加载漏洞详情失败",
"recentSearches": "最近搜索",
"export": "导出",
"exporting": "导出中...",
"exportSuccess": "导出成功",
"exportFailed": "导出失败",
"stats": {
"vulnerabilities": "漏洞"
},
@@ -1971,6 +1975,16 @@
"formatInvalid": "格式无效"
}
},
"globalSearch": {
"search": "搜索",
"placeholder": "搜索资产... (host=\"api\" && tech=\"nginx\")",
"noResults": "未找到结果",
"searchFor": "搜索",
"recent": "最近搜索",
"quickSearch": "快捷搜索",
"hint": "支持 FOFA 风格语法",
"toSearch": "搜索"
},
"errors": {
"unknown": "操作失败,请稍后重试",
"validation": "输入数据无效",

View File

@@ -1,5 +1,5 @@
import { api } from "@/lib/api-client"
import type { SearchParams, SearchResponse } from "@/types/search.types"
import type { SearchParams, SearchResponse, AssetType } from "@/types/search.types"
/**
* 资产搜索 API 服务
@@ -38,4 +38,38 @@ export class SearchService {
)
return response.data
}
/**
* 导出搜索结果为 CSV
* GET /api/assets/search/export/
*/
static async exportCSV(query: string, assetType: AssetType): Promise<void> {
const queryParams = new URLSearchParams()
queryParams.append('q', query)
queryParams.append('asset_type', assetType)
const response = await api.get(
`/assets/search/export/?${queryParams.toString()}`,
{ responseType: 'blob' }
)
// 从响应头获取文件名
const contentDisposition = response.headers?.['content-disposition']
let filename = `search_${assetType}_${new Date().toISOString().slice(0, 10)}.csv`
if (contentDisposition) {
const match = contentDisposition.match(/filename="?([^"]+)"?/)
if (match) filename = match[1]
}
// 创建下载链接
const blob = new Blob([response.data as BlobPart], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
}

View File

@@ -94,7 +94,7 @@ show_banner() {
echo -e "${MAGENTA} ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝${RESET}"
echo -e ""
echo -e "${DIM} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
echo -e "${BOLD} 🔒 分布式安全扫描平台 │ 一键部署安装程序${RESET}"
echo -e "${BOLD} 🔒 分布式安全扫描平台 │ 一键部署 (Ubuntu)${RESET}"
echo -e "${DIM} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
echo -e ""
}
@@ -132,7 +132,6 @@ fi
# 显示标题
show_banner
header "XingRin 一键安装脚本 (Ubuntu)"
info "当前用户: ${BOLD}$REAL_USER${RESET}"
info "项目路径: ${BOLD}$ROOT_DIR${RESET}"
info "安装版本: ${BOLD}$APP_VERSION${RESET}"
@@ -324,7 +323,7 @@ check_pg_ivm() {
# 显示安装总结信息
show_summary() {
echo
if [ "$1" == "success" ]; then
if [ "$1" = "success" ]; then
# 成功 Banner
echo -e ""
echo -e "${GREEN}${BOLD} ╔═══════════════════════════════════════════════════╗${RESET}"
@@ -378,7 +377,9 @@ show_summary() {
echo -e " ${YELLOW} ⚠ 请首次登录后修改密码!${RESET}"
echo
if [ "$1" != "success" ]; then
if [ "$1" = "success" ]; then
: # 成功模式,不显示后续命令
else
echo -e "${DIM} ──────────────────────────────────────────────────────${RESET}"
echo -e " ${BLUE}🚀 后续命令${RESET}"
echo -e " ${DIM}├─${RESET} ./start.sh ${DIM}# 启动所有服务${RESET}"
@@ -743,7 +744,7 @@ fi
# 启动服务
# ==============================================================================
step "正在启动服务..."
"$ROOT_DIR/start.sh" $START_ARGS
"$ROOT_DIR/start.sh" ${START_ARGS} --quiet
# ==============================================================================
# 完成总结